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""" + + {html.escape(c.schema_name)} + {c.row_count:,} + {html.escape(c.distribution)} + {html.escape(c.query_pattern)} + {c.heap_timing.median * 1000:.2f} + {c.noxu_timing.median * 1000:.2f} + {c.speedup:.2f}x + """ + + # Build storage table + storage_rows = "" + for sc in report.storage_comparisons: + storage_rows += f""" + + {html.escape(sc.schema_name)} + {sc.row_count:,} + {html.escape(sc.distribution)} + {_human_bytes(sc.heap_total_bytes)} + {_human_bytes(sc.noxu_total_bytes)} + {sc.compression_ratio:.2f}x + {sc.space_savings_pct:.1f}% + """ + + # Chart image tags + def img_tag(name: Optional[str]) -> str: + if name: + return f'' + return '

Chart not available (matplotlib not installed)

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

Noxu Benchmark Dashboard

+ +
+

Summary

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

Charts

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

Query Timing Comparison

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

Storage Comparison

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

Optimization Recommendations

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

Raw Summary Data

+
{summary_json}
+
+ +
+ Generated by Noxu Benchmark Suite +
+ +""" 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; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|1 +(1 row) + +id| k|v +--+---+- + 3|100|1 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- +(0 rows) + +id|k|v +--+-+- +(0 rows) + diff --git a/src/test/isolation/expected/flux_update_serialize.out b/src/test/isolation/expected/flux_update_serialize.out new file mode 100644 index 0000000000000..f448ed79acc5b --- /dev/null +++ b/src/test/isolation/expected/flux_update_serialize.out @@ -0,0 +1,169 @@ +Parsed test spec with 3 sessions + +starting permutation: s1rc s2rc s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s1c s2u s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rc s1ks s2u s1u s2c s1c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1ks: SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2c: COMMIT; +step s1u: <... completed> +step s1c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1fu s2u s1u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1fu: SELECT id FROM fus WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1oc s2oc s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s2oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s1c: COMMIT; +step s2oc: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s1big s1locks s1c s1bigsum +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1big: UPDATE fus_big SET v = v + 1; +step s1locks: SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); +tuple_locks +----------- + 0 +(1 row) + +step s1c: COMMIT; +step s1bigsum: SELECT sum(v) FROM fus_big; + sum +----- +20000 +(1 row) + diff --git a/src/test/isolation/expected/flux_update_victim.out b/src/test/isolation/expected/flux_update_victim.out new file mode 100644 index 0000000000000..7932687a2cc9c --- /dev/null +++ b/src/test/isolation/expected/flux_update_victim.out @@ -0,0 +1,63 @@ +Parsed test spec with 4 sessions + +starting permutation: s2u2 s1u1 s2u1 s1u2 s1c s2c chk +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +s3: WARNING: there is already a transaction in progress +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s3u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1c: COMMIT; +step s2u1: <... completed> +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s3u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u2: <... completed> +step s3u1: <... completed> +ERROR: deadlock detected +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + diff --git a/src/test/isolation/expected/flux_update_waiter_abort.out b/src/test/isolation/expected/flux_update_waiter_abort.out new file mode 100644 index 0000000000000..5662a22502426 --- /dev/null +++ b/src/test/isolation/expected/flux_update_waiter_abort.out @@ -0,0 +1,338 @@ +Parsed test spec with 4 sessions + +starting permutation: s1b s1w s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|0|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1sp s1w s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1w2: UPDATE vh SET w = 20 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2d s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1c: COMMIT; +step s2d: <... completed> +id|v| w +--+-+-- + 1|0|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2d s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2l s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2l: SELECT * FROM vh WHERE id = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|0|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v| w| v| w +-+--+--+-- +1|10|11|10 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v| w +--+--+-- + 1|11|10 + 2| 0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v|w| v|w +-+-+--+- +1|0|11|0 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v|w +--+--+- + 1|11|0 + 2| 0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v|w|v|w +-+-+-+- +(0 rows) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +10|0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s2b s2w5 s2u s2c chk +step s2b: BEGIN; +step s2w5: UPDATE vh SET w = 5 WHERE id = 1; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +v|w|v|w +-+-+-+- +0|5|1|5 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|5 + 2|0|0 +(2 rows) + + +starting permutation: s1b s2b s3b s1u1 s3u2 s2u s3u1 s1u2 s1c s2h s2c s3c chkh +step s1b: BEGIN; +step s2b: BEGIN; +step s3b: BEGIN; +step s1u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s3u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s1u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2h: INSERT INTO vh_hist VALUES (1); +step s2c: COMMIT; +step s3u1: <... completed> +step s3c: COMMIT; +step chkh: SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; +id|v|w|committed +--+-+-+--------- + 1|2|0| 2 + 2|1|0| 1 +(2 rows) + diff --git a/src/test/isolation/expected/flux_vacuum_move.out b/src/test/isolation/expected/flux_vacuum_move.out new file mode 100644 index 0000000000000..1c7b9f343bfaf --- /dev/null +++ b/src/test/isolation/expected/flux_vacuum_move.out @@ -0,0 +1,55 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +step s1_upd: BEGIN; UPDATE fvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM fvmove; +step s2_ins: INSERT INTO fvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM fvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_bmp: SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM fvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_rollback: ROLLBACK; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM fvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80100| 1| 0 +(1 row) + + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_commit s2_check +step s1_upd: BEGIN; UPDATE fvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM fvmove; +step s2_ins: INSERT INTO fvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM fvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_commit: COMMIT; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM fvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80107| 1| 7 +(1 row) + diff --git a/src/test/isolation/expected/heap_cluster_sort_snapshot.out b/src/test/isolation/expected/heap_cluster_sort_snapshot.out new file mode 100644 index 0000000000000..42ab04ae1cafc --- /dev/null +++ b/src/test/isolation/expected/heap_cluster_sort_snapshot.out @@ -0,0 +1,311 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2d s2cle s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2cle: CLUSTER cs USING cs_e; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2clb s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2clb: CLUSTER cs USING cs_b; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2clg s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2clg: CLUSTER cs USING cs_g; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2vf s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2vf: VACUUM FULL cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2u s2cle s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2cle: CLUSTER cs USING cs_e; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 20| 1| 20|2210 +(1 row) + + +starting permutation: s1b s2u s2u s2clg s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2u: UPDATE cs SET v = v + 100; +step s2clg: CLUSTER cs USING cs_g; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 20| 1| 20|4210 +(1 row) + + +starting permutation: s1b s2u s2d s2vf s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2d: DELETE FROM cs WHERE k > 10; +step s2vf: VACUUM FULL cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 10| 1| 10|1055 +(1 row) + + +starting permutation: s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2i: INSERT INTO cs VALUES (21, 21, point(21, 21)); +step s2u: UPDATE cs SET v = v + 100; +step s2vff: VACUUM (FULL, FREEZE) cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s2flush: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2v: VACUUM cs; +step s2dead: SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; +n_dead_tup +---------- + 21 +(1 row) + +step s1c: COMMIT; +step s2v: VACUUM cs; +step s2dead: SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; +n_dead_tup +---------- + 0 +(1 row) + +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 21| 1| 21|2331 +(1 row) + + +starting permutation: s1b s2d s2u s1r s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2u: UPDATE cs SET v = v + 100; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 10| 1| 10|1055 +(1 row) + diff --git a/src/test/isolation/expected/heap_fk_crosscheck.out b/src/test/isolation/expected/heap_fk_crosscheck.out new file mode 100644 index 0000000000000..ee365c657fcb9 --- /dev/null +++ b/src/test/isolation/expected/heap_fk_crosscheck.out @@ -0,0 +1,81 @@ +Parsed test spec with 2 sessions + +starting permutation: s1brr s1ic s2brr s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkc|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1in s2brr s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1in: INSERT INTO fkn VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkn|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1ic s2brr s2u s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE pk SET a = 3 WHERE a = 2; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkc|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1ic s2brc s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t|a +-+- +(0 rows) + + +starting permutation: s1brr s1in s2brc s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1in: INSERT INTO fkn VALUES (2); +step s2brc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkn| +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_delete_lockers.out b/src/test/isolation/expected/heap_lock_delete_lockers.out new file mode 100644 index 0000000000000..19ca57d28421b --- /dev/null +++ b/src/test/isolation/expected/heap_lock_delete_lockers.out @@ -0,0 +1,51 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1l s2b s2l s3d s1r s2c s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s1r: ROLLBACK; +step s2c: COMMIT; +step s3d: <... completed> +ERROR: update or delete on table "parent" violates foreign key constraint "child_i_fkey" on table "child" +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 1| 1 +(1 row) + + +starting permutation: s1b s1l s2b s2l s3d s2r s1c s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s2r: ROLLBACK; +step s1c: COMMIT; +step s3d: <... completed> +ERROR: update or delete on table "parent" violates foreign key constraint "child_i_fkey" on table "child" +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 1| 1 +(1 row) + + +starting permutation: s1b s1l s2b s2l s3d s1r s2r s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s1r: ROLLBACK; +step s2r: ROLLBACK; +step s3d: <... completed> +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 0| 0 +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_recheck.out b/src/test/isolation/expected/heap_lock_recheck.out new file mode 100644 index 0000000000000..75ea7061b4b0c --- /dev/null +++ b/src/test/isolation/expected/heap_lock_recheck.out @@ -0,0 +1,2626 @@ +Parsed test spec with 5 sessions + +starting permutation: s1b s1u s2rc s2fu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fnk s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +step s1c: COMMIT; +step s2fnk: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fs s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +step s1c: COMMIT; +step s2fs: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fks s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1k s2rc s2fks s1c s2c s3r +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +step s1c: COMMIT; +step s2fks: <... completed> +id|v|w +--+-+- +10|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2sl s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2sl: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2nw s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2nw: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; +ERROR: could not obtain lock on row in relation "lr" +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2upd s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2upd: <... completed> +id|v|w +--+-+- + 2|1|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|1 +(2 rows) + + +starting permutation: s1b s1d s2rc s2fu s1c s2c s3r +step s1b: BEGIN; +step s1d: DELETE FROM lr WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2fu s1a s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1a: ROLLBACK; +step s2fu: <... completed> +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2upd s1a s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +step s1a: ROLLBACK; +step s2upd: <... completed> +id|v|w +--+-+- + 1|1|1 + 2|1|1 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|1 + 2|1|1 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2fu s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fu s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fnk s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fs s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2sl s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2sl: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2nw s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2nw: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2upd s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fks s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1k s1c s2fks s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2fu s1a s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1a: ROLLBACK; +step s2fu: <... completed> +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1d s2fu s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1d: DELETE FROM lr WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +ERROR: could not serialize access due to concurrent delete +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1pk s2rc s2ci s1c s2c s3c +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1c: COMMIT; +step s2ci: <... completed> +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +step s1b: BEGIN; +step s1pfu: SELECT id FROM lrp WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1pn: UPDATE lrp SET note = 1 WHERE id = 1; +step s1c: COMMIT; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s1b s1pk s2rc s2ci s1a s2c s3c +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1a: ROLLBACK; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s1b s1tu s2rc s2tu s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2tu: <... completed> +id|v|w +--+-+- +(0 rows) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1tu s2rc s2tu s1a s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s2tu: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2tu s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2tu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2jfu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2jfu: SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; +step s1c: COMMIT; +step s2jfu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1tu s2rc s2mq s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mq: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +step s2mq: <... completed> +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1tu s2rc s2mu s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mu: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2mu: <... completed> +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2mu s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2mu: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +step s2mu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2oc s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2oc s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2oc s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dks: DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +id|note +--+---- +(0 rows) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s4pkk s4pn101 s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 12 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dks: DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 12 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 21 +(1 row) + + +starting permutation: s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 21 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 21 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 101 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 101 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5c: COMMIT; +step s2pks: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5a: ROLLBACK; +step s2pks: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +step s5a: ROLLBACK; +step s2fc: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5c: COMMIT; +step s2ci: <... completed> +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5a: ROLLBACK; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5c: COMMIT; +step s2pks: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5a: ROLLBACK; +step s2pks: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pn1: UPDATE lrp SET note = note + 100 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pn1: UPDATE lrp SET note = note + 100 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 110 +(1 row) + + +starting permutation: s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks101: SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; + id|note +---+---- +101| 1 +(1 row) + +step s2ci101: INSERT INTO lrc VALUES (2, 101); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 2|101 +(1 row) + + +starting permutation: s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pks101: SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; + id|note +---+---- +101| 1 +(1 row) + +step s2ci101: INSERT INTO lrc VALUES (2, 101); +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 2|101 +(1 row) + + +starting permutation: s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 20 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5a: ROLLBACK; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s1b s1tu s2rc s2toc s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2toc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +s1: NOTICE: lrt_bu: OLD.v=2 NEW.v=3 +step s1tu2: UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=3 NEW.v=3 +step s2toc: <... completed> +id|v| w +--+-+-- + 1|3|11 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v| w +--+-+-- + 1|3|11 +(1 row) + + +starting permutation: s1b s1tu s2rc s2toc s1a s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s2toc: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2toc s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +step s2toc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1nu s2rc s2noc s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2noc: INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,2,100) NEW=(1,2,101) +step s2noc: <... completed> +id|v| w +--+-+--- + 1|2|201 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|201 +(1 row) + + +starting permutation: s1b s1nu s2rc s2noc s1a s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2noc: INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,1,1) +step s2noc: <... completed> +id|v| w +--+-+--- + 1|1|101 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|1|101 +(1 row) + + +starting permutation: s1b s1lfu s2rc s2oc s1k s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +10|1|0 +(3 rows) + + +starting permutation: s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +step s1b: BEGIN; +step s1tfu: SELECT * FROM lrt WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s1tk: UPDATE lrt SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2toc: <... completed> +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|0 +10|1|0 +(2 rows) + + +starting permutation: s1b s1lfu s2rc s2oc s1u s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +step s1b: BEGIN; +step s1tfu: SELECT * FROM lrt WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2toc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s1b s1lfu s2rc s2oc s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|1 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mlu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mlu: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mlu: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mlq s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mlq: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mlq: <... completed> +merge_action|id|v|w +------------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mld s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mld: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mld: <... completed> +merge_action|id|v|w +------------+--+-+- +DELETE | 1|2|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnu s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnu: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,2,100) NEW=(1,2,101) +step s2mnu: <... completed> +merge_action|id|v| w +------------+--+-+--- +UPDATE | 1|2|201 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|201 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnq s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnq: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +step s2mnq: <... completed> +merge_action|id|v|w +------------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|100 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnd s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnd: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +s2: NOTICE: lrn_bu DELETE: OLD=(1,2,100) NEW= +step s2mnd: <... completed> +merge_action|id|v| w +------------+--+-+--- +DELETE | 1|2|100 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v|w +--+-+- +(0 rows) + + +starting permutation: s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2mnu: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +step s2mnu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|100 +(1 row) + + +starting permutation: s1b s1pu s2rc s2pm s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2pm: UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2pm: <... completed> +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1pu s2rc s2pmq s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2pmq: UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2pmq: <... completed> +tableoid|id|v|w +--------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2ptm s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ptm: UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +s2: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,5) NEW=(1,2,5) +s2: NOTICE: lptt1 BEFORE DELETE: OLD=(1,1,5) NEW= +step s2ptm: <... completed> +tableoid|id|v|w +--------+--+-+- +lptt2 | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt2 | 1|2|5 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2ptmq s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ptmq: UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2ptmq: <... completed> +tableoid|id|v|w +--------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt1 | 1|1|5 +(2 rows) + + +starting permutation: s1b s1pu s2rc s2mpm s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mpm: MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; +step s1c: COMMIT; +step s2mpm: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2mptm s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mptm: MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; +step s1c: COMMIT; +s2: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,5) NEW=(1,2,5) +s2: NOTICE: lptt1 BEFORE DELETE: OLD=(1,1,5) NEW= +step s2mptm: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt2 | 1|2|5 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2ptm: UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2ptm: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt1 | 1|1|5 +(2 rows) + + +starting permutation: s1b s1qcu s2rc s2qd s1c s2c s3q +step s1b: BEGIN; +step s1qcu: SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2qd: DELETE FROM lrq WHERE id = 1; +step s1c: COMMIT; +step s2qd: <... completed> +step s2c: COMMIT; +step s3q: SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; +id|note +--+---- +(0 rows) + +id|pid|w +--+---+- +(0 rows) + + +starting permutation: s2rc s2fks s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fks s1b s1u s1c s2c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rc s2fs s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fnk s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fu s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1pk: <... completed> +ERROR: update or delete on table "lrp" violates foreign key constraint "lrc_pid_fkey" on table "lrc" +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2a: ROLLBACK; +step s1pk: <... completed> +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 2| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2uci s1b s1uk s2c s1c s3u +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2uci: INSERT INTO lruc VALUES (1); +step s1b: BEGIN; +step s1uk: UPDATE lrup SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1uk: <... completed> +step s1c: COMMIT; +step s3u: SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); +id +-- + 2 +(1 row) + +pid +--- + 2 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2rci s1b s1rk s2c s1c s3restrict +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rci: INSERT INTO lrrc VALUES (1); +step s1b: BEGIN; +step s1rk: UPDATE lrrp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1rk: <... completed> +ERROR: update or delete on table "lrrp" violates RESTRICT setting of foreign key constraint "lrrc_pid_fkey" on table "lrrc" +step s1c: COMMIT; +step s3restrict: SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); +id +-- + 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1sp: SAVEPOINT key_update; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1rb: ROLLBACK TO key_update; +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +step s1b: BEGIN; +step s1sp: SAVEPOINT key_update; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1rb: ROLLBACK TO key_update; +step s2ci: <... completed> +step s2c: COMMIT; +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2mci s1b s1mk s2c s1c s3multi +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mci: INSERT INTO lrmc VALUES (1, 1); +step s1b: BEGIN; +step s1mk: UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; +step s2c: COMMIT; +step s1mk: <... completed> +ERROR: update or delete on table "lrmp" violates foreign key constraint "lrmc_a_b_fkey" on table "lrmc" +step s1c: COMMIT; +step s3multi: SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); +a|b +-+- +1|1 +(1 row) + +a|b +-+- +1|1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s1b s1vk s2c s1c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s1b: BEGIN; +step s1vk: UPDATE lrvp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1vk: <... completed> +ERROR: update or delete on table "lrvp" violates foreign key constraint "lrvc_pid_fkey" on table "lrvc" +step s1c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 0 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s1b s1vn s1c s2c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s1b: BEGIN; +step s1vn: UPDATE lrvp SET note = note + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s4vn s1b s1vk s2c s1c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s4vn: UPDATE lrvp SET note = note + 1 WHERE id = 1; +step s1b: BEGIN; +step s1vk: UPDATE lrvp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1vk: <... completed> +ERROR: update or delete on table "lrvp" violates foreign key constraint "lrvc_pid_fkey" on table "lrvc" +step s1c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_update_lockers.out b/src/test/isolation/expected/heap_lock_update_lockers.out new file mode 100644 index 0000000000000..222e432460395 --- /dev/null +++ b/src/test/isolation/expected/heap_lock_update_lockers.out @@ -0,0 +1,33 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1l s2b s2l s3u s1r s2chk s2c s3chk +step s1b: BEGIN; +step s1l: SELECT v FROM lu WHERE id = 1 FOR SHARE; +v +- +0 +(1 row) + +step s2b: BEGIN; +step s2l: SELECT v FROM lu WHERE id = 1 FOR SHARE; +v +- +0 +(1 row) + +step s3u: UPDATE lu SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2chk: SELECT v FROM lu WHERE id = 1; +v +- +0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +step s3chk: SELECT v FROM lu WHERE id = 1; +v +- +1 +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_upgrade_queue.out b/src/test/isolation/expected/heap_lock_upgrade_queue.out new file mode 100644 index 0000000000000..e8d227c700561 --- /dev/null +++ b/src/test/isolation/expected/heap_lock_upgrade_queue.out @@ -0,0 +1,101 @@ +Parsed test spec with 3 sessions + +starting permutation: s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +step s1ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s3fu: SELECT id FROM luq FOR UPDATE; +step s2ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s2nk: SELECT id FROM luq FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s2nk: <... completed> +id +-- + 1 +(1 row) + +step s1r: ROLLBACK; +step s2c: COMMIT; +step s3fu: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; + +starting permutation: s1sh s2fu s3sh s3fu s1r s3c s2c +step s1sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s2fu: SELECT id FROM luq FOR UPDATE; +step s3sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s3fu: SELECT id FROM luq FOR UPDATE; +step s1r: ROLLBACK; +step s3fu: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; +step s2fu: <... completed> +id +-- + 1 +(1 row) + +step s2c: COMMIT; + +starting permutation: s1ks s2fu s3ks s3d s1r s3c s2c +step s1ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2fu: SELECT id FROM luq FOR UPDATE; +step s3ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s3d: DELETE FROM luq RETURNING id; +step s1r: ROLLBACK; +step s3d: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; +step s2fu: <... completed> +id +-- +(0 rows) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_nonkey_move.out b/src/test/isolation/expected/heap_nonkey_move.out new file mode 100644 index 0000000000000..cd91a3ac9bacd --- /dev/null +++ b/src/test/isolation/expected/heap_nonkey_move.out @@ -0,0 +1,830 @@ +Parsed test spec with 3 sessions + +starting permutation: rr snap b2 ui ki c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui ii c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ii: INSERT INTO ci VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui si c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step si: SELECT id, v FROM fi WHERE id=1 FOR SHARE; +step c2: COMMIT; +step si: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui di c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step di: DELETE FROM fi WHERE id=1; +step c2: COMMIT; +step di: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui wi c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step wi: UPDATE fi SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wi: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ug ig c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step ig: INSERT INTO cg VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ug sg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step sg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; +step c2: COMMIT; +step sg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug dg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step dg: DELETE FROM fg WHERE id=1; +step c2: COMMIT; +step dg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug wg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step wg: UPDATE fg SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rc snap b2 ui ki c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ui ii c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ii: INSERT INTO ci VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ui si c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step si: SELECT id, v FROM fi WHERE id=1 FOR SHARE; +step c2: COMMIT; +step si: <... completed> +id|v +--+- + 1|2 +(1 row) + +step c1: COMMIT; + +starting permutation: rc snap b2 ui di c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step di: DELETE FROM fi WHERE id=1; +step c2: COMMIT; +step di: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ui wi c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step wi: UPDATE fi SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wi: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ug kg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ug ig c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step ig: INSERT INTO cg VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ug sg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step sg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; +step c2: COMMIT; +step sg: <... completed> +id|v|length +--+-+------ + 1|1| 1920 +(1 row) + +step c1: COMMIT; + +starting permutation: rc snap b2 ug dg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step dg: DELETE FROM fg WHERE id=1; +step c2: COMMIT; +step dg: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ug wg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step wg: UPDATE fg SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wg: <... completed> +step c1: COMMIT; + +starting permutation: rr snap b2 ui ki c2 d2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step d2i: DELETE FROM fi WHERE id=1; +step c1: COMMIT; +step d2i: <... completed> + +starting permutation: rr snap b2 ui ki c2 k2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step k2i: UPDATE fi SET id=2 WHERE id=1; +step c1: COMMIT; +step k2i: <... completed> + +starting permutation: rr snap b2 ug kg c2 d2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step d2g: DELETE FROM fg WHERE id=1; +step c1: COMMIT; +step d2g: <... completed> + +starting permutation: rr snap b2 ug kg c2 k2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step k2g: UPDATE fg SET id=100 WHERE id=1; +step c1: COMMIT; +step k2g: <... completed> + +starting permutation: rr snap b2 ui ki r2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step r2: ROLLBACK; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg r2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step r2: ROLLBACK; +step c1: COMMIT; + +starting permutation: rr snap b2 ug c2 kg c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step c2: COMMIT; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c1: COMMIT; + +starting permutation: rr snap b2 ui ki c2 n2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step n2i: UPDATE fi SET v=3 WHERE id=1; +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg c2 n2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step n2g: UPDATE fg SET pad=repeat('z',1940) WHERE id=1; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 c3 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rc snap b2 ui c2 b3 k3 c3 ki c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- +(0 rows) + +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +step c3: COMMIT; +step ki: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 u3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step u3: UPDATE fi SET v=3 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c3: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step z3: UPDATE fi SET id=1 WHERE id=2; +step u3: UPDATE fi SET v=3 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step z3: UPDATE fi SET id=1 WHERE id=2; +step u3: UPDATE fi SET v=3 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +step c3: COMMIT; +step ki: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; diff --git a/src/test/isolation/expected/heap_outofplace_grow.out b/src/test/isolation/expected/heap_outofplace_grow.out new file mode 100644 index 0000000000000..9f7745dea9bf6 --- /dev/null +++ b/src/test/isolation/expected/heap_outofplace_grow.out @@ -0,0 +1,35 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2b s1g s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1g: UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; +step s2u: UPDATE xg SET n = n + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; +id|n|p8|nrows +--+-+--+----- + 1|1| 8| 400 +(1 row) + + +starting permutation: s1b s2b s1g s2u s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1g: UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; +step s2u: UPDATE xg SET n = n + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; +id|n|p8|nrows +--+-+--+----- + 1|1| | 400 +(1 row) + diff --git a/src/test/isolation/expected/heap_outofplace_update.out b/src/test/isolation/expected/heap_outofplace_update.out new file mode 100644 index 0000000000000..95e470df0dc37 --- /dev/null +++ b/src/test/isolation/expected/heap_outofplace_update.out @@ -0,0 +1,196 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2b s1u s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,10)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2uk s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,12)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1u s2uk s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2d s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2d: DELETE FROM xt WHERE id = 1; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r|nrows +--+-+-+----- +(0 rows) + + +starting permutation: s1b s2b s1uk s2u s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,10)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2uk s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1r: ROLLBACK; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s1uk s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s2sel s1uk s1c s2d s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT id, v, r FROM xt; +id|v|r +--+-+------ + 1|0|[1,10) +(1 row) + +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2d: DELETE FROM xt WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1up s2ins s1c s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1up: UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; +step s2ins: INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); +step s1c: COMMIT; +step s2ins: <... completed> +step s2c: COMMIT; + +starting permutation: s1b s2brr s1up s2ins s1c s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1up: UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; +step s2ins: INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); +step s1c: COMMIT; +step s2ins: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s1b s2b s1uk s2fu s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2fu: SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|r +--+-+------ + 1|1|[1,11) +(1 row) + +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s2sel s1uk s1c s2fu s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT id, v, r FROM xt; +id|v|r +--+-+------ + 1|0|[1,10) +(1 row) + +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2fu: SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_partition_move.out b/src/test/isolation/expected/heap_partition_move.out new file mode 100644 index 0000000000000..fed6d1384b56b --- /dev/null +++ b/src/test/isolation/expected/heap_partition_move.out @@ -0,0 +1,172 @@ +Parsed test spec with 2 sessions + +starting permutation: s2b s1u s2d s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2d: DELETE FROM pm WHERE a = 1; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1u s2u s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1u s2l s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +step s1c: COMMIT; +step s2l: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2ks s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2ks: SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; +step s1c: COMMIT; +step s2ks: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2m s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2m: MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; +step s1c: COMMIT; +step s2m: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1upr s2i s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1upr: UPDATE pr SET a = 11 WHERE a = 7; +step s2i: INSERT INTO prc VALUES (7); +step s1c: COMMIT; +step s2i: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2brr s2sel s1u s1c s2d s2c +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|ABC +(1 row) + +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1c: COMMIT; +step s2d: DELETE FROM pm WHERE a = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s2brr s2sel s1u s1c s2l s2c +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|ABC +(1 row) + +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1c: COMMIT; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2u s1r s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; + +starting permutation: s2b s1u s2d s1r s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2d: DELETE FROM pm WHERE a = 1; +step s1r: ROLLBACK; +step s2d: <... completed> +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+- +(0 rows) + +step s2c: COMMIT; + +starting permutation: s2b s1u s2l s1r s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +a|b +-+--- +1|ABC +(1 row) + +step s2c: COMMIT; + +starting permutation: s2b s1upr s2i s1r s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1upr: UPDATE pr SET a = 11 WHERE a = 7; +step s2i: INSERT INTO prc VALUES (7); +step s1r: ROLLBACK; +step s2i: <... completed> +step s2c: COMMIT; + +starting permutation: s1sp s1u s1rs s2b s2u s2sel s2c s1c +step s1sp: SAVEPOINT sp; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1rs: ROLLBACK TO sp; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; +step s1c: COMMIT; + +starting permutation: s2b s1u s1r s2l s2u s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1r: ROLLBACK; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +a|b +-+--- +1|ABC +(1 row) + +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_recheck_after_wait.out b/src/test/isolation/expected/heap_recheck_after_wait.out new file mode 100644 index 0000000000000..60bf09b298fff --- /dev/null +++ b/src/test/isolation/expected/heap_recheck_after_wait.out @@ -0,0 +1,165 @@ +Parsed test spec with 4 sessions + +starting permutation: s1sh s2d s3d s1r s2r s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1sh s2d s3u s1r s2r s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3u: UPDATE rw SET v = v + 10 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3u: <... completed> +id| v +--+-- + 1|10 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id| v +--+-- + 1|10 +(1 row) + + +starting permutation: s1u s2d s3d s1r s2r s3c s4sel +step s1u: UPDATE rw SET v = 5; +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1u s2d s3u s1r s2r s3c s4sel +step s1u: UPDATE rw SET v = 5; +step s2d: DELETE FROM rw RETURNING id, v; +step s3u: UPDATE rw SET v = v + 10 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3u: <... completed> +id| v +--+-- + 1|10 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id| v +--+-- + 1|10 +(1 row) + + +starting permutation: s1sh s2d s3d s1r s2c s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; +step s3d: <... completed> +id|v +--+- +(0 rows) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1sh s3d s1u s1c s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1u: UPDATE rw SET v = 5; +step s1c: COMMIT; +step s3d: <... completed> +id|v +--+- +(0 rows) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- + 1|5 +(1 row) + diff --git a/src/test/isolation/expected/heap_savepoint_lock_rollback.out b/src/test/isolation/expected/heap_savepoint_lock_rollback.out new file mode 100644 index 0000000000000..9c17a2099289c --- /dev/null +++ b/src/test/isolation/expected/heap_savepoint_lock_rollback.out @@ -0,0 +1,1945 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fnk s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fnk: SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fs s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fs: SELECT * FROM lr WHERE id = 1 FOR SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fks s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2d s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2d: DELETE FROM lr WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 2|0 +(1 row) + +via |id|v +---------+--+- +key share| 2|0 +(1 row) + +via |id|v +----------+--+- +for update| 2|0 +(1 row) + +via |id| v +------+--+--- +update| 2|100 +(1 row) + +via|id|v +---+--+- +(0 rows) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s2r: ROLLBACK; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2u s1r s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1r s2u s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1r: ROLLBACK; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1u s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1d s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1d: DELETE FROM lr WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1d: DELETE FROM lr WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fnk: SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1fks s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1u s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1u s1rs s2d s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2d: DELETE FROM lr WHERE id = 1; +step s1c: COMMIT; +step s2d: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 2|0 +(1 row) + +via |id|v +---------+--+- +key share| 2|0 +(1 row) + +via |id|v +----------+--+- +for update| 2|0 +(1 row) + +via |id| v +------+--+--- +update| 2|100 +(1 row) + +via|id|v +---+--+- +(0 rows) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fu s1rs s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|10 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|10 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|10 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|110 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|110 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1u s1rs s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|10 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|10 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|10 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|110 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|110 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|11 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|11 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|11 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|111 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|111 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fks s1rs s2u s1r s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1i s1sp s1fu3 s1rs s1c s3chk +step s1b: BEGIN; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1sp: SAVEPOINT a; +step s1fu3: SELECT * FROM lr WHERE id = 3 FOR UPDATE; +id|v +--+- + 3|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1sp s1i s1rs s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1i s1sp s1u3 s1rs s1c s3chk +step s1b: BEGIN; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1sp: SAVEPOINT a; +step s1u3: UPDATE lr SET v = v + 10 WHERE id = 3; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1rel: RELEASE a; +step s1sp2: SAVEPOINT b; +step s1u3: UPDATE lr SET v = v + 10 WHERE id = 3; +step s1rs2: ROLLBACK TO b; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + diff --git a/src/test/isolation/expected/heap_savepoint_lock_wake.out b/src/test/isolation/expected/heap_savepoint_lock_wake.out new file mode 100644 index 0000000000000..7629daf724334 --- /dev/null +++ b/src/test/isolation/expected/heap_savepoint_lock_wake.out @@ -0,0 +1,173 @@ +Parsed test spec with 2 sessions + +starting permutation: s1ks s1sp s1nk s2nk s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1nk s2u s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lw SET v = v + 1 RETURNING *; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +id|v +--+- + 1|1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1fu s2fs s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lw FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2fs: SELECT * FROM lw FOR SHARE; +step s1rs: ROLLBACK TO a; +step s2fs: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1sp s1fu s2u s1rs s1c s2c +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lw FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lw SET v = v + 1 RETURNING *; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +id|v +--+- + 1|1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel2: RELEASE b; +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs2: ROLLBACK TO b; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1nk s2d s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2d: DELETE FROM lw RETURNING *; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_seqscan_stats.out b/src/test/isolation/expected/heap_seqscan_stats.out new file mode 100644 index 0000000000000..cc6194dfaec38 --- /dev/null +++ b/src/test/isolation/expected/heap_seqscan_stats.out @@ -0,0 +1,55 @@ +Parsed test spec with 1 sessions + +starting permutation: s1stats s1scan s1ff s1stats s1where s1ff s1stats +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 0| 0 +(1 row) + +step s1scan: SELECT count(*) FROM ss; +count +----- + 5 +(1 row) + +step s1ff: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 1| 5 +(1 row) + +step s1where: SELECT k FROM ss WHERE v > 3 ORDER BY k; +k +- +4 +5 +(2 rows) + +step s1ff: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 2| 10 +(1 row) + diff --git a/src/test/isolation/expected/heap_ssi_conflict_out.out b/src/test/isolation/expected/heap_ssi_conflict_out.out new file mode 100644 index 0000000000000..928e9f515b7e3 --- /dev/null +++ b/src/test/isolation/expected/heap_ssi_conflict_out.out @@ -0,0 +1,130 @@ +Parsed test spec with 9 sessions + +starting permutation: foo_begin bar_begin trouble_begin foo_select bar_insert foo_insert foo_commit trouble_update bar_select bar_commit trouble_abort +step foo_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step bar_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step trouble_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step foo_select: SELECT * FROM txn0 WHERE id = 42; +id|val +--+--- +(0 rows) + +step bar_insert: INSERT INTO txn0 SELECT 42, 'bar_insert'; +step foo_insert: INSERT INTO txn1 SELECT 7, 'foo_insert'; +step foo_commit: COMMIT; +step trouble_update: UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; +step bar_select: SELECT * FROM txn1 WHERE id = 7; +ERROR: could not serialize access due to read/write dependencies among transactions +step bar_commit: COMMIT; +step trouble_abort: ABORT; + +starting permutation: foo_begin bar_begin trouble_begin foo_select bar_insert foo_insert foo_commit trouble_delete bar_select bar_commit trouble_abort +step foo_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step bar_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step trouble_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step foo_select: SELECT * FROM txn0 WHERE id = 42; +id|val +--+--- +(0 rows) + +step bar_insert: INSERT INTO txn0 SELECT 42, 'bar_insert'; +step foo_insert: INSERT INTO txn1 SELECT 7, 'foo_insert'; +step foo_commit: COMMIT; +step trouble_delete: DELETE FROM txn1 WHERE id = 7; +step bar_select: SELECT * FROM txn1 WHERE id = 7; +ERROR: could not serialize access due to read/write dependencies among transactions +step bar_commit: COMMIT; +step trouble_abort: ABORT; + +starting permutation: w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c +step w1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w1snap: SELECT v FROM ws WHERE id = 3; +v +- +0 +(1 row) + +step w2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w2r2: SELECT v FROM ws WHERE id = 2; +v +- +0 +(1 row) + +step w2w1: UPDATE ws SET v = v + 10 WHERE id = 1; +step w2c: COMMIT; +step w1r1: SELECT v FROM ws WHERE id = 1; +v +- +0 +(1 row) + +step w1w2: UPDATE ws SET v = v + 1 WHERE id = 2; +ERROR: could not serialize access due to read/write dependencies among transactions +step w1c: COMMIT; + +starting permutation: w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a +step w1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w1snap: SELECT v FROM ws WHERE id = 3; +v +- +0 +(1 row) + +step w2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w2r2: SELECT v FROM ws WHERE id = 2; +v +- +0 +(1 row) + +step w2w1: UPDATE ws SET v = v + 10 WHERE id = 1; +step w2c: COMMIT; +step w3b: BEGIN; +step w3w1: UPDATE ws SET v = v + 100 WHERE id = 1; +step w1r1: SELECT v FROM ws WHERE id = 1; +v +- +0 +(1 row) + +step w1w2: UPDATE ws SET v = v + 1 WHERE id = 2; +ERROR: could not serialize access due to read/write dependencies among transactions +step w1c: COMMIT; +step w3a: ABORT; + +starting permutation: s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s3b: BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; +step rxwy1: INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); +step wx2: UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; +step c2: COMMIT; +step rx3: SELECT * FROM ctl WHERE k = 'receipt'; +k |deposit_date +-------+------------ +receipt| 12-23-2008 +(1 row) + +step c1: COMMIT; +step ry3: SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c3: COMMIT; + +starting permutation: s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s3b: BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; +step wx2: UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; +step rxwy1: INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); +step c2: COMMIT; +step rx3: SELECT * FROM ctl WHERE k = 'receipt'; +k |deposit_date +-------+------------ +receipt| 12-23-2008 +(1 row) + +step c1: COMMIT; +step ry3: SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c3: COMMIT; diff --git a/src/test/isolation/expected/heap_ssi_copy.out b/src/test/isolation/expected/heap_ssi_copy.out new file mode 100644 index 0000000000000..36ee6d84dfce9 --- /dev/null +++ b/src/test/isolation/expected/heap_ssi_copy.out @@ -0,0 +1,22 @@ +Parsed test spec with 2 sessions + +starting permutation: b1 b2 r1 r2 w1 c1 w2 c2 +step b1: BEGIN ISOLATION LEVEL SERIALIZABLE; +step b2: BEGIN ISOLATION LEVEL SERIALIZABLE; +step r1: SELECT count(*) FROM a; +count +----- + 0 +(1 row) + +step r2: SELECT count(*) FROM b; +count +----- + 0 +(1 row) + +step w1: COPY b FROM PROGRAM 'printf "1\n"'; +step c1: COMMIT; +step w2: COPY a FROM PROGRAM 'printf "2\n"'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c2: COMMIT; diff --git a/src/test/isolation/expected/heap_ssi_revive.out b/src/test/isolation/expected/heap_ssi_revive.out new file mode 100644 index 0000000000000..7301e2dcb6f04 --- /dev/null +++ b/src/test/isolation/expected/heap_ssi_revive.out @@ -0,0 +1,145 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + + +starting permutation: s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s2c: COMMIT; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to read/write dependencies among transactions +step s1c: COMMIT; +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|1 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|0 +(1 row) + + +starting permutation: s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + + +starting permutation: s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r5: SELECT count(*) FROM t WHERE k = 5; +count +----- + 1 +(1 row) + +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + diff --git a/src/test/isolation/expected/heap_tid_fetch_snapshot.out b/src/test/isolation/expected/heap_tid_fetch_snapshot.out new file mode 100644 index 0000000000000..e2c2d4a67f13b --- /dev/null +++ b/src/test/isolation/expected/heap_tid_fetch_snapshot.out @@ -0,0 +1,103 @@ +Parsed test spec with 2 sessions + +starting permutation: s2b s2u s1b s1lock s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1lock: SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; +step s2c: COMMIT; +step s1lock: <... completed> +id | bal|id |bal +--------+----+--------+--- +checking|1050|checking|600 +savings | 600|savings |600 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s2b s2u s2uo s1b s1join s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2uo: UPDATE oth SET v = v + 10 WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1join: SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; +step s2c: COMMIT; +step s1join: <... completed> +id | bal|v +--------+----+- +checking|1050|1 +savings | 600|1 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s2b s2u s2do s1b s1join s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2do: DELETE FROM oth WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1join: SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; +step s2c: COMMIT; +step s1join: <... completed> +id | bal|v +--------+----+- +checking|1050|1 +savings | 600|1 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2u s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2d s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2d: DELETE FROM acc WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2u s2u s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; diff --git a/src/test/isolation/expected/heap_update_deadlock.out b/src/test/isolation/expected/heap_update_deadlock.out new file mode 100644 index 0000000000000..be458774fea7b --- /dev/null +++ b/src/test/isolation/expected/heap_update_deadlock.out @@ -0,0 +1,83 @@ +Parsed test spec with 4 sessions + +starting permutation: s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s3u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s1c: COMMIT; +step s3u1: <... completed> +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + + +starting permutation: s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s3u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s3u1: <... completed> +step s2u2: <... completed> +ERROR: deadlock detected +step s3c: COMMIT; +step s1u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s3u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s3u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s3c: COMMIT; +step s1u2: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|2| 2 +(2 rows) + diff --git a/src/test/isolation/expected/heap_update_key_waiter.out b/src/test/isolation/expected/heap_update_key_waiter.out new file mode 100644 index 0000000000000..364ab4a152367 --- /dev/null +++ b/src/test/isolation/expected/heap_update_key_waiter.out @@ -0,0 +1,259 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1k s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k2 s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k2: UPDATE kw SET k = 200 WHERE id = 3; +step s1r: ROLLBACK; +step s2k2: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|200|0 +(1 row) + +id| k|v +--+---+- + 3|200|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k2 s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k2: UPDATE kw SET k = 200 WHERE id = 3; +step s1c: COMMIT; +step s2k2: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|200|0 +(1 row) + +id| k|v +--+---+- + 3|200|0 +(1 row) + + +starting permutation: s1b s1k s2b s2v s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2v: UPDATE kw SET v = 2 WHERE id = 3; +step s1r: ROLLBACK; +step s2v: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- + 3|3|2 +(1 row) + +id|k|v +--+-+- + 3|3|2 +(1 row) + + +starting permutation: s1b s1k s2b s2v s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2v: UPDATE kw SET v = 2 WHERE id = 3; +step s1c: COMMIT; +step s2v: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|2 +(1 row) + +id| k|v +--+---+- + 3|100|2 +(1 row) + + +starting permutation: s1b s1v s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1v: UPDATE kw SET v = 1 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1v s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1v: UPDATE kw SET v = 1 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|1 +(1 row) + +id| k|v +--+---+- + 3|100|1 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- +(0 rows) + +id|k|v +--+-+- +(0 rows) + diff --git a/src/test/isolation/expected/heap_update_serialize.out b/src/test/isolation/expected/heap_update_serialize.out new file mode 100644 index 0000000000000..f448ed79acc5b --- /dev/null +++ b/src/test/isolation/expected/heap_update_serialize.out @@ -0,0 +1,169 @@ +Parsed test spec with 3 sessions + +starting permutation: s1rc s2rc s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s1c s2u s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rc s1ks s2u s1u s2c s1c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1ks: SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2c: COMMIT; +step s1u: <... completed> +step s1c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1fu s2u s1u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1fu: SELECT id FROM fus WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1oc s2oc s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s2oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s1c: COMMIT; +step s2oc: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s1big s1locks s1c s1bigsum +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1big: UPDATE fus_big SET v = v + 1; +step s1locks: SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); +tuple_locks +----------- + 0 +(1 row) + +step s1c: COMMIT; +step s1bigsum: SELECT sum(v) FROM fus_big; + sum +----- +20000 +(1 row) + diff --git a/src/test/isolation/expected/heap_update_victim.out b/src/test/isolation/expected/heap_update_victim.out new file mode 100644 index 0000000000000..7932687a2cc9c --- /dev/null +++ b/src/test/isolation/expected/heap_update_victim.out @@ -0,0 +1,63 @@ +Parsed test spec with 4 sessions + +starting permutation: s2u2 s1u1 s2u1 s1u2 s1c s2c chk +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +s3: WARNING: there is already a transaction in progress +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s3u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1c: COMMIT; +step s2u1: <... completed> +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s3u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u2: <... completed> +step s3u1: <... completed> +ERROR: deadlock detected +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + diff --git a/src/test/isolation/expected/heap_update_waiter_abort.out b/src/test/isolation/expected/heap_update_waiter_abort.out new file mode 100644 index 0000000000000..5662a22502426 --- /dev/null +++ b/src/test/isolation/expected/heap_update_waiter_abort.out @@ -0,0 +1,338 @@ +Parsed test spec with 4 sessions + +starting permutation: s1b s1w s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|0|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1sp s1w s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1w2: UPDATE vh SET w = 20 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2d s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1c: COMMIT; +step s2d: <... completed> +id|v| w +--+-+-- + 1|0|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2d s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2l s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2l: SELECT * FROM vh WHERE id = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|0|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v| w| v| w +-+--+--+-- +1|10|11|10 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v| w +--+--+-- + 1|11|10 + 2| 0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v|w| v|w +-+-+--+- +1|0|11|0 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v|w +--+--+- + 1|11|0 + 2| 0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v|w|v|w +-+-+-+- +(0 rows) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +10|0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s2b s2w5 s2u s2c chk +step s2b: BEGIN; +step s2w5: UPDATE vh SET w = 5 WHERE id = 1; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +v|w|v|w +-+-+-+- +0|5|1|5 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|5 + 2|0|0 +(2 rows) + + +starting permutation: s1b s2b s3b s1u1 s3u2 s2u s3u1 s1u2 s1c s2h s2c s3c chkh +step s1b: BEGIN; +step s2b: BEGIN; +step s3b: BEGIN; +step s1u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s3u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s1u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2h: INSERT INTO vh_hist VALUES (1); +step s2c: COMMIT; +step s3u1: <... completed> +step s3c: COMMIT; +step chkh: SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; +id|v|w|committed +--+-+-+--------- + 1|2|0| 2 + 2|1|0| 1 +(2 rows) + diff --git a/src/test/isolation/expected/heap_vacuum_move.out b/src/test/isolation/expected/heap_vacuum_move.out new file mode 100644 index 0000000000000..c814ea0f403c2 --- /dev/null +++ b/src/test/isolation/expected/heap_vacuum_move.out @@ -0,0 +1,55 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +step s1_upd: BEGIN; UPDATE hvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM hvmove; +step s2_ins: INSERT INTO hvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM hvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_bmp: SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM hvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_rollback: ROLLBACK; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM hvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80100| 1| 0 +(1 row) + + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_commit s2_check +step s1_upd: BEGIN; UPDATE hvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM hvmove; +step s2_ins: INSERT INTO hvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM hvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_commit: COMMIT; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM hvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80107| 1| 7 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index bf9a2037c705a..3b806b9fbcb6a 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -132,3 +132,64 @@ test: ddl-dependency-locking test: tablespace-dependency-locking test: pub-concurrent-drop test: drop-owned-grant +test: flux_inplace +test: flux_conflict +test: flux_specconflict_toast +test: flux_lostupdate +test: flux_scan_stable +test: flux_delete_conflict +test: flux_scan_history +test: flux_slot_tuple_context +test: flux_update_waiter_abort +test: heap_update_waiter_abort +test: heap_update_key_waiter +test: flux_update_key_waiter +test: flux_update_serialize +test: heap_update_serialize +test: flux_lock_recheck +test: heap_lock_recheck +test: flux_update_victim +test: heap_update_victim +test: flux_update_deadlock +test: heap_update_deadlock +test: flux_vacuum_move +test: flux_pvs_deep +test: heap_vacuum_move +test: flux_savepoint_lock_rollback +test: heap_savepoint_lock_rollback +test: flux_ssi_conflict_out +test: heap_ssi_conflict_out +test: flux_savepoint_lock_wake +test: heap_savepoint_lock_wake +test: flux_lock_delete_lockers +test: heap_lock_delete_lockers +test: flux_lock_update_lockers +test: heap_lock_update_lockers +test: flux_cluster_sort_snapshot +test: heap_cluster_sort_snapshot +test: flux_partition_move +test: heap_partition_move +test: flux_tid_fetch_snapshot +test: heap_tid_fetch_snapshot +test: flux_fk_crosscheck +test: heap_fk_crosscheck +test: flux_seqscan_stats +test: heap_seqscan_stats +test: flux_index_aba +test: flux_outofplace_update +test: heap_outofplace_update +test: flux_outofplace_grow +test: heap_outofplace_grow +test: flux_ssi_revive +test: flux_relstats +test: heap_ssi_revive +test: flux_lock_upgrade_queue +test: heap_lock_upgrade_queue +test: flux_recheck_after_wait +test: heap_recheck_after_wait + +test: heap_nonkey_move +test: flux_nonkey_move + +test: heap_ssi_copy +test: flux_ssi_copy diff --git a/src/test/isolation/specs/flux_cluster_sort_snapshot.spec b/src/test/isolation/specs/flux_cluster_sort_snapshot.spec new file mode 100644 index 0000000000000..963718e84a550 --- /dev/null +++ b/src/test/isolation/specs/flux_cluster_sort_snapshot.spec @@ -0,0 +1,62 @@ +# CLUSTER and VACUUM FULL are MVCC-safe (see "Caveats" in mvcc.sgml): a +# snapshot taken before the rewrite reads exactly the rows it read before, +# whichever way the rewrite reads the table: a sort (cs_e, an expression +# index), an index scan (cs_g: GiST never sorts), the planner's choice (cs_b) +# or a sequential scan (VACUUM FULL). s1 takes its snapshot on another +# table, so it holds no lock on cs and the rewrite runs at once. Before the +# rewrite s2 deletes half the rows, updates every row once or twice, or +# inserts one; s1 must then still read the 20 rows it saw, with their old +# values, by a sequential scan and through the rebuilt index. +# FLUX twin; heap_/flux_cluster_sort_snapshot have identical expected output. +setup +{ + CREATE TABLE cs (k int, v int, p point) USING flux; + INSERT INTO cs SELECT g, g, point(g, g) FROM generate_series(1, 20) g; + CREATE INDEX cs_e ON cs ((-k)); + CREATE INDEX cs_b ON cs (k); + CREATE INDEX cs_g ON cs USING gist (p); + CREATE TABLE cs_other (x int); +} + +teardown +{ + DROP TABLE cs, cs_other; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; } +step s1r { SELECT count(*), min(k), max(k), sum(v) FROM cs; } +step s1ri { + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; +} +step s1c { COMMIT; } + +session s2 +step s2d { DELETE FROM cs WHERE k > 10; } +step s2u { UPDATE cs SET v = v + 100; } +step s2i { INSERT INTO cs VALUES (21, 21, point(21, 21)); } +step s2cle { CLUSTER cs USING cs_e; } +step s2clb { CLUSTER cs USING cs_b; } +step s2clg { CLUSTER cs USING cs_g; } +step s2vf { VACUUM FULL cs; } +step s2vff { VACUUM (FULL, FREEZE) cs; } +step s2v { VACUUM cs; } +step s2flush { SELECT pg_stat_force_next_flush(); } +step s2dead { SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; } +step s2chk { SELECT count(*), min(k), max(k), sum(v) FROM cs; } + +# Deleted rows, each way of reading the table. +permutation s1b s2d s2cle s1r s1ri s1c s2chk +permutation s1b s2d s2clb s1r s1ri s1c s2chk +permutation s1b s2d s2clg s1r s1ri s1c s2chk +permutation s1b s2d s2vf s1r s1ri s1c s2chk +# Rows updated (in place, for FLUX) once or twice, then some deleted. +permutation s1b s2u s2cle s1r s1ri s1c s2chk +permutation s1b s2u s2u s2clg s1r s1ri s1c s2chk +permutation s1b s2u s2d s2vf s1r s1ri s1c s2chk +# A row s1 must not see, and freezing; VACUUM afterwards. +permutation s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +# No rewrite: the control. +permutation s1b s2d s2u s1r s1c s2chk diff --git a/src/test/isolation/specs/flux_conflict.spec b/src/test/isolation/specs/flux_conflict.spec new file mode 100644 index 0000000000000..29b4edb0e9e1a --- /dev/null +++ b/src/test/isolation/specs/flux_conflict.spec @@ -0,0 +1,54 @@ +# FLUX concurrent write-conflict isolation tests. +# +# Validates that write-conflict detection (which transaction is concurrently +# modifying a tuple) is correct when derived from the on-page header: +# - two writers on the SAME row serialize (blocker waits for committer), +# - two writers on DIFFERENT rows of the SAME page do NOT falsely conflict, +# - concurrent delete vs update interact like heap. +# +# All rows are packed onto one page (fillfactor 100, tiny rows) so the +# "different rows, same page" case genuinely shares a page. + +setup +{ + CREATE TABLE flux_cc (id int PRIMARY KEY, v int) USING flux WITH (fillfactor = 100); + INSERT INTO flux_cc SELECT g, 0 FROM generate_series(1, 20) g; +} + +teardown +{ + DROP TABLE flux_cc; +} + +session s1 +setup { BEGIN; } +step s1_upd1 { UPDATE flux_cc SET v = 1 WHERE id = 1; } +step s1_del1 { DELETE FROM flux_cc WHERE id = 1; } +step s1_commit { COMMIT; } +step s1_abort { ROLLBACK; } + +session s2 +setup { BEGIN; } +step s2_upd1 { UPDATE flux_cc SET v = 2 WHERE id = 1; } +step s2_upd2 { UPDATE flux_cc SET v = 2 WHERE id = 2; } +step s2_del1 { DELETE FROM flux_cc WHERE id = 1; } +step s2_commit { COMMIT; } + +# Same row: s2's update must block until s1 commits, then apply on the new row. +permutation s1_upd1 s2_upd1 s1_commit s2_commit + +# Different rows, SAME page: s2 updating id=2 must NOT block on s1's id=1 update. +permutation s1_upd1 s2_upd2 s1_commit s2_commit + +# Concurrent delete (s1) vs update (s2) of the same row: s2 blocks, then finds +# the row deleted after s1 commits. +permutation s1_del1 s2_upd1 s1_commit s2_commit + +# Delete then abort: s2's update blocks, then proceeds after s1 rolls back. +permutation s1_del1 s2_upd1 s1_abort s2_commit + +# Update vs update on the SAME row: s2 blocks behind s1's in-progress update. +permutation s1_upd1 s2_upd1 s1_abort s2_commit + +# Update (s1) vs delete (s2) of the same row: s2's delete blocks behind s1's update. +permutation s1_upd1 s2_del1 s1_commit s2_commit diff --git a/src/test/isolation/specs/flux_delete_conflict.spec b/src/test/isolation/specs/flux_delete_conflict.spec new file mode 100644 index 0000000000000..3459bc2321f23 --- /dev/null +++ b/src/test/isolation/specs/flux_delete_conflict.spec @@ -0,0 +1,47 @@ +# FLUX concurrent UPDATE/DELETE and DELETE/DELETE behave like heap. +# +# Every permutation runs against a FLUX table; the expected output is heap's +# for the same spec with USING heap (only the table AM differs). Under READ +# COMMITTED the second writer waits, then re-checks the row (EvalPlanQual); +# under REPEATABLE READ it fails with a serialization error. + +setup +{ + CREATE TABLE d (id int PRIMARY KEY, v int) USING flux; + INSERT INTO d VALUES (1, 10), (2, 20); +} + +teardown +{ + DROP TABLE d; +} + +session s1 +step b1rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step b1rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step u1 { UPDATE d SET v = v + 1 WHERE id = 1; } +step d1 { DELETE FROM d WHERE id = 1; } +step c1 { COMMIT; } +step a1 { ROLLBACK; } + +session s2 +step b2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step b2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step snap2 { SELECT count(*) FROM d; } +step u2 { UPDATE d SET v = v + 100 WHERE id = 1 RETURNING v; } +step d2 { DELETE FROM d WHERE id = 1 RETURNING v; } +step c2 { COMMIT; } +step sel { SELECT id, v FROM d ORDER BY id; } + +# READ COMMITTED +permutation b1rc b2rc d1 d2 c1 c2 sel +permutation b1rc b2rc d1 d2 a1 c2 sel +permutation b1rc b2rc u1 d2 c1 c2 sel +permutation b1rc b2rc d1 u2 c1 c2 sel +permutation b1rc b2rc d1 u2 a1 c2 sel +# REPEATABLE READ (s2's snapshot taken before s1 writes) +permutation b1rr b2rr snap2 d1 d2 c1 c2 sel +permutation b1rr b2rr snap2 d1 d2 a1 c2 sel +permutation b1rr b2rr snap2 u1 d2 c1 c2 sel +permutation b1rr b2rr snap2 d1 u2 c1 c2 sel +permutation b1rr b2rr snap2 d1 c1 d2 c2 sel diff --git a/src/test/isolation/specs/flux_fk_crosscheck.spec b/src/test/isolation/specs/flux_fk_crosscheck.spec new file mode 100644 index 0000000000000..b7cdb3c14e7ae --- /dev/null +++ b/src/test/isolation/specs/flux_fk_crosscheck.spec @@ -0,0 +1,41 @@ +# A REPEATABLE READ RI action must fail with a serialization failure when it +# finds a referencing row that its transaction snapshot cannot see, as with +# heap, whose delete and update test the row against the RI crosscheck +# snapshot. heap_fk_crosscheck.spec runs the same steps on heap; the two +# expected outputs are identical. +# +# FLUX used to ignore the crosscheck snapshot, so ON DELETE CASCADE deleted, +# and ON DELETE SET NULL and ON UPDATE CASCADE updated, a child row inserted +# by a transaction that committed after the snapshot. +setup +{ + CREATE TABLE pk (a int PRIMARY KEY) USING flux; + CREATE TABLE fkc (a int REFERENCES pk ON DELETE CASCADE ON UPDATE CASCADE) USING flux; + CREATE TABLE fkn (a int REFERENCES pk ON DELETE SET NULL) USING flux; + INSERT INTO pk VALUES (2); +} +teardown +{ + DROP TABLE fkc, fkn, pk; +} + +session s1 +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1ic { INSERT INTO fkc VALUES (2); } +step s1in { INSERT INTO fkn VALUES (2); } +step s1c { COMMIT; } +step s1sel { SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; } + +session s2 +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2brc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2d { DELETE FROM pk WHERE a = 2; } +step s2u { UPDATE pk SET a = 3 WHERE a = 2; } +step s2c { COMMIT; } + +permutation s1brr s1ic s2brr s2d s1c s2c s1sel +permutation s1brr s1in s2brr s2d s1c s2c s1sel +permutation s1brr s1ic s2brr s2u s1c s2c s1sel +# READ COMMITTED has no crosscheck: the action applies to the new child. +permutation s1brr s1ic s2brc s2d s1c s2c s1sel +permutation s1brr s1in s2brc s2d s1c s2c s1sel diff --git a/src/test/isolation/specs/flux_index_aba.spec b/src/test/isolation/specs/flux_index_aba.spec new file mode 100644 index 0000000000000..57b34070292f5 --- /dev/null +++ b/src/test/isolation/specs/flux_index_aba.spec @@ -0,0 +1,77 @@ +# FLUX: an old snapshot still reaches a row by its old key after a later +# key UPDATE of the row is rolled back. +# +# A FLUX UPDATE of an indexed column keeps the row at its TID: it inserts or +# revives (k_new, TID) and delete-marks (k_old, TID), which an older snapshot +# needs to find the row by k_old. s1's REPEATABLE READ snapshot predates +# s2's committed A -> C, so it reads A through the delete-marked (A, TID). +# s2 then moves the row back to A, reviving (A, TID), and rolls back. The +# rollback must leave (A, TID) delete-marked, not remove it: s1's index scan +# for A must still return the row, as its seqscan does. +# +# fx is FLUX, hp its heap twin, which gets the same statements; heap never +# reuses a TID, so it is the control. Every query reads both tables, by +# index scan and by seqscan, and must return the same rows from all four. + +setup +{ + CREATE TABLE fx (id int PRIMARY KEY, k int) USING flux; + CREATE TABLE hp (id int PRIMARY KEY, k int) USING heap; + INSERT INTO fx SELECT g, g FROM generate_series(1, 20) g; + INSERT INTO hp SELECT g, g FROM generate_series(1, 20) g; + CREATE INDEX fx_k ON fx (k); + CREATE INDEX hp_k ON hp (k); + + -- the rows each table returns for k = key, by index scan and by seqscan + CREATE FUNCTION aba_rows(key int) RETURNS TABLE (via text, id int, k int) + LANGUAGE plpgsql AS $$ + BEGIN + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + RETURN QUERY SELECT 'fx-idx', fx.id, fx.k FROM fx WHERE fx.k = key; + RETURN QUERY SELECT 'hp-idx', hp.id, hp.k FROM hp WHERE hp.k = key; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'off', true); + RETURN QUERY SELECT 'fx-seq', fx.id, fx.k FROM fx WHERE fx.k = key; + RETURN QUERY SELECT 'hp-seq', hp.id, hp.k FROM hp WHERE hp.k = key; + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); + END $$; +} + +teardown +{ + DROP TABLE fx, hp; + DROP FUNCTION aba_rows; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT k FROM fx WHERE id = 12; } +step s1a { SELECT * FROM aba_rows(12); } +step s1c { SELECT * FROM aba_rows(3000012); } +step s1e { COMMIT; } + +session s2 +# committed A -> C +step s2c { UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; } +# C -> A, rolled back +step s2a { BEGIN; UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; ROLLBACK; } +# C -> A -> C -> A, rolled back +step s2x { BEGIN; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + ROLLBACK; } +# A -> B -> A, rolled back (no committed change: s1 and new snapshots agree) +step s2b { BEGIN; + UPDATE fx SET k = 4000012 WHERE id = 12; UPDATE hp SET k = 4000012 WHERE id = 12; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + ROLLBACK; } +# what a new snapshot sees +step s2r { SELECT * FROM aba_rows(12) UNION ALL SELECT * FROM aba_rows(3000012); } + +permutation s1b s2c s2a s1a s1c s1e s2r +permutation s1b s2c s2x s1a s1c s1e s2r +permutation s1b s2b s1a s1e s2r diff --git a/src/test/isolation/specs/flux_inplace.spec b/src/test/isolation/specs/flux_inplace.spec new file mode 100644 index 0000000000000..81b283325f9c8 --- /dev/null +++ b/src/test/isolation/specs/flux_inplace.spec @@ -0,0 +1,45 @@ +# FLUX: always-in-place indexed-column UPDATE via delete-marking. +# +# Validates the MVCC visibility of an in-place key UPDATE across snapshots: +# - a REPEATABLE READ reader whose snapshot predates the key UPDATE keeps +# seeing the OLD key + value (reachable through the delete-marked old +# index entry), and does NOT see the row under the NEW key; +# - a reader whose snapshot follows the UPDATE sees only the NEW key; +# - the TID is stable, so no row moves. +# Both the old and new versions are reached via the SAME index; the delete- +# marking key recheck ensures neither snapshot sees a duplicate +# or a phantom. + +setup +{ + CREATE TABLE flux_ip (id int PRIMARY KEY, k int, v text) USING flux; + INSERT INTO flux_ip VALUES (1, 100, 'a'); + CREATE INDEX flux_ip_k ON flux_ip (k); + SET enable_seqscan = off; +} + +teardown +{ + DROP TABLE flux_ip; +} + +session s_old +setup { BEGIN ISOLATION LEVEL REPEATABLE READ; + SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; + SELECT k, v FROM flux_ip WHERE id = 1; } +step o_old_key { SELECT k, v FROM flux_ip WHERE k = 100; } # old snapshot: sees 100,a +step o_new_key { SELECT count(*) AS n FROM flux_ip WHERE k = 200; } # old snapshot: 0 +step o_commit { COMMIT; } + +session s_writer +step w_update { UPDATE flux_ip SET k = 200, v = 'b' WHERE id = 1; } + +session s_new +setup { SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; } +step n_new_key { SELECT k, v FROM flux_ip WHERE k = 200; } # new snapshot: sees 200,b +step n_old_key { SELECT count(*) AS n FROM flux_ip WHERE k = 100; } # new snapshot: 0 + +# The old-snapshot reader established its snapshot in setup (before the +# UPDATE), so after the committed in-place key UPDATE it still sees the old +# key/value, while a fresh reader sees only the new key. +permutation w_update o_old_key o_new_key n_new_key n_old_key o_commit diff --git a/src/test/isolation/specs/flux_lock_delete_lockers.spec b/src/test/isolation/specs/flux_lock_delete_lockers.spec new file mode 100644 index 0000000000000..7c98ce4d1d420 --- /dev/null +++ b/src/test/isolation/specs/flux_lock_delete_lockers.spec @@ -0,0 +1,39 @@ +# A DELETE that conflicts with several row lockers must wait for all of them, +# not only the first: here two foreign-key checks hold FOR KEY SHARE on the +# parent row, and one of them rolls back. The DELETE must still wait for the +# other, so its foreign-key check then finds the committed child row. +# FLUX twin of heap_lock_delete_lockers; the expected output is identical. +setup +{ + CREATE TABLE parent (i int PRIMARY KEY) USING flux; + INSERT INTO parent VALUES (1); + CREATE TABLE child (i int REFERENCES parent (i)) USING flux; +} + +teardown +{ + DROP TABLE child, parent; +} + +session s1 +step s1b { BEGIN; } +step s1l { INSERT INTO child VALUES (1); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN; } +step s2l { INSERT INTO child VALUES (1); } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +session s3 +step s3d { DELETE FROM parent; } +step s3chk { SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; } + +# The first locker rolls back; the DELETE must keep waiting for the second. +permutation s1b s1l s2b s2l s3d s1r s2c s3chk +# The second locker rolls back first. +permutation s1b s1l s2b s2l s3d s2r s1c s3chk +# Both roll back: the DELETE goes ahead. +permutation s1b s1l s2b s2l s3d s1r s2r s3chk diff --git a/src/test/isolation/specs/flux_lock_recheck.spec b/src/test/isolation/specs/flux_lock_recheck.spec new file mode 100644 index 0000000000000..3b62531cb6689 --- /dev/null +++ b/src/test/isolation/specs/flux_lock_recheck.spec @@ -0,0 +1,406 @@ +# FLUX: a row lock that waits on, or meets, a committed concurrent update must +# report it exactly as heap does. +# +# FLUX updates rows in place at a stable TID, so the latest version of a row +# is always at the TID the caller holds. table_tuple_lock() must still tell +# its callers that the version their snapshot saw was superseded: +# - READ COMMITTED, TUPLE_LOCK_FLAG_FIND_LAST_VERSION: TM_Ok with +# tmfd->traversed = true, so nodeLockRows, GetTupleForTrigger and +# ri_LockPKTuple re-check the latest version (EvalPlanQual, FK key recheck); +# - REPEATABLE READ: TM_Updated, so the caller raises "could not serialize +# access due to concurrent update"; +# - ON CONFLICT (flags 0) under READ COMMITTED: TM_Ok on the latest version, +# because heap's retry would find and lock that same row. +# A FOR KEY SHARE lock is compatible with a non-key UPDATE and, as in heap, +# neither waits nor reports it. +# +# The expected output is heap's: heap_lock_recheck.spec is this spec with +# USING heap, and both share the same expected .out. + +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO lr VALUES (1, 1, 0), (2, 1, 0); + CREATE TABLE lrp (id int PRIMARY KEY, note int) USING flux; + INSERT INTO lrp VALUES (1, 0); + CREATE TABLE lrc (id int, pid int REFERENCES lrp) USING flux; + CREATE TABLE lrt (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO lrt VALUES (1, 1, 0); + CREATE FUNCTION lrt_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'lrt_bu: OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NEW; END $$; + CREATE TRIGGER lrt_bu BEFORE UPDATE ON lrt FOR EACH ROW EXECUTE FUNCTION lrt_bu(); + -- A BEFORE UPDATE OR DELETE trigger that modifies NEW. + CREATE TABLE lrn (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO lrn VALUES (1, 1, 0); + CREATE FUNCTION lrn_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE 'lrn_bu %: OLD=% NEW=%', TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER lrn_bu BEFORE UPDATE OR DELETE ON lrn FOR EACH ROW EXECUTE FUNCTION lrn_bu(); + -- Partitioned by v, for cross-partition UPDATE; lptt has a trigger. + CREATE TABLE lpt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lpt1 PARTITION OF lpt FOR VALUES IN (1) USING flux; + CREATE TABLE lpt2 PARTITION OF lpt FOR VALUES IN (2) USING flux; + INSERT INTO lpt VALUES (1, 1, 0); + CREATE TABLE lptt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lptt1 PARTITION OF lptt FOR VALUES IN (1) USING flux; + CREATE TABLE lptt2 PARTITION OF lptt FOR VALUES IN (2) USING flux; + INSERT INTO lptt VALUES (1, 1, 0); + CREATE FUNCTION lr_note() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE '% % %: OLD=% NEW=%', TG_TABLE_NAME, TG_WHEN, TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END $$; + CREATE TRIGGER lr_note BEFORE UPDATE OR DELETE ON lptt FOR EACH ROW EXECUTE FUNCTION lr_note(); + -- FK with ON DELETE CASCADE: the RI action deletes a child row that a + -- concurrent transaction has updated. + CREATE TABLE lrq (id int PRIMARY KEY, note int) USING flux; + INSERT INTO lrq VALUES (1, 0); + CREATE TABLE lrqc (id int PRIMARY KEY, pid int REFERENCES lrq ON DELETE CASCADE, w int) USING flux; + INSERT INTO lrqc VALUES (1, 1, 0); + CREATE TABLE lrup (id int PRIMARY KEY) USING flux; + INSERT INTO lrup VALUES (1); + CREATE TABLE lruc (pid int REFERENCES lrup ON UPDATE CASCADE) USING flux; + CREATE TABLE lrrp (id int PRIMARY KEY) USING flux; + INSERT INTO lrrp VALUES (1); + CREATE TABLE lrrc (pid int REFERENCES lrrp ON UPDATE RESTRICT) USING flux; + CREATE TABLE lrmp (a int, b int, PRIMARY KEY (a, b)) USING flux; + INSERT INTO lrmp VALUES (1, 1); + CREATE TABLE lrmc (a int, b int, FOREIGN KEY (a, b) REFERENCES lrmp) USING flux; + CREATE TABLE lrvp (id int, note int) USING flux; + CREATE UNIQUE INDEX lrvp_unique ON lrvp (id); + INSERT INTO lrvp VALUES (1, 0); + CREATE TABLE lrvc (pid int REFERENCES lrvp (id)) USING flux; +} + +teardown +{ + DROP TABLE lruc, lrup, lrrc, lrrp, lrmc, lrmp, lrvc, lrvp; + DROP TABLE lr, lrc, lrp, lrt, lrn, lpt, lptt, lrqc, lrq; + DROP FUNCTION lrt_bu(), lrn_bu(), lr_note(); +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT key_update; } +step s1rb { ROLLBACK TO key_update; } +step s1uk { UPDATE lrup SET id = 2 WHERE id = 1; } +step s1rk { UPDATE lrrp SET id = 2 WHERE id = 1; } +step s1mk { UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; } +step s1vk { UPDATE lrvp SET id = 2 WHERE id = 1; } +step s1vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s1u { UPDATE lr SET v = 2 WHERE id = 1; } +step s1k { UPDATE lr SET id = 10 WHERE id = 1; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1pk { UPDATE lrp SET id = 2 WHERE id = 1; } +step s1pfu { SELECT id FROM lrp WHERE id = 1 FOR UPDATE; } +step s1pn { UPDATE lrp SET note = 1 WHERE id = 1; } +step s1tu { UPDATE lrt SET v = 2 WHERE id = 1; } +step s1lfu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1tfu { SELECT * FROM lrt WHERE id = 1 FOR UPDATE; } +step s1tk { UPDATE lrt SET id = 10 WHERE id = 1; } +step s1tu2 { UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; } +step s1nu { UPDATE lrn SET v = 2 WHERE id = 1; } +step s1pu { UPDATE lpt SET w = 5 WHERE id = 1; } +step s1ptu { UPDATE lptt SET w = 5 WHERE id = 1; } +# session_replication_role = replica skips lrqc's own FK check trigger, which +# FLUX cannot run yet on UPDATE (it reads xmin from the old row's slot). +step s1qcu { SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2a { ROLLBACK; } +step s2uci { INSERT INTO lruc VALUES (1); } +step s2rci { INSERT INTO lrrc VALUES (1); } +step s2mci { INSERT INTO lrmc VALUES (1, 1); } +step s2vci { INSERT INTO lrvc VALUES (1); } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT count(*) FROM lr; } +step s2fu { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; } +step s2fnk { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; } +step s2fks { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; } +step s2sl { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; } +step s2nw { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; } +step s2upd { UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2ci { INSERT INTO lrc VALUES (1, 1); } +step s2tu { UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2oc { INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; } +# The same locked row in several output rows: each is re-checked. +step s2jfu { SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; } +# MERGE with a BEFORE UPDATE trigger: GetTupleForTrigger reports the +# concurrent update, MERGE re-checks the join and either drops the row or +# updates the latest version, and must not loop. +step s2mq { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +step s2mu { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +# Parent-row locks after several updates (FOR KEY SHARE must see a key change +# by any of them, not only the latest). A cursor fixes the READ COMMITTED +# snapshot before the updates. +step s2pks { SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2pnk { SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; } +step s2dks { DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2dka { DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; } +step s2dnk { DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; } +step s2fc { FETCH ALL FROM c; } +step s2pks101 { SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; } +step s2ci101 { INSERT INTO lrc VALUES (2, 101); } +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger: the trigger locks the +# row ON CONFLICT already locked. +step s2toc { INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; } +step s2noc { INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; } +# MERGE without a trigger (lr) and with one that modifies NEW (lrn). +step s2mlu { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mlq { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mld { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; } +step s2mnu { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnq { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnd { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; } +# Cross-partition UPDATE (ExecCrossPartitionUpdate), and MERGE moving the row. +step s2pm { UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2pmq { UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2ptm { UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2ptmq { UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2mpm { MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; } +step s2mptm { MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; } +# RI cascade action on a child row updated concurrently. +step s2qd { DELETE FROM lrq WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3orphans { SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); } +step s3u { SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); } +step s3restrict { SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); } +step s3multi { SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); } +step s3unique { SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); } +step s3r { SELECT * FROM lr ORDER BY id; } +step s3c { SELECT * FROM lrc ORDER BY id; } +step s3t { SELECT * FROM lrt ORDER BY id; } +step s3p { SELECT * FROM lrp ORDER BY id; } +step s3n { SELECT * FROM lrn ORDER BY id; } +step s3pt { SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; } +step s3q { SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; } + +# Further parent updates, each committed on its own (autocommit) or held open. +# lrp starts as (1, 0). +session s4 +step s4vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s4pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s4pkb { UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; } +step s4pn101 { UPDATE lrp SET note = note + 10 WHERE id = 101; } +step s4pn1 { UPDATE lrp SET note = note + 10 WHERE id = 1; } + +session s5 +step s5b { BEGIN; } +step s5pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s5pn101 { UPDATE lrp SET note = note + 100 WHERE id = 101; } +step s5pn1 { UPDATE lrp SET note = note + 100 WHERE id = 1; } +step s5c { COMMIT; } +step s5a { ROLLBACK; } + +# READ COMMITTED, each lock strength that conflicts with the UPDATE waits for +# it, then re-checks v = 1 against the committed version: row 1 drops out. +permutation s1b s1u s2rc s2fu s1c s2c s3r +permutation s1b s1u s2rc s2fnk s1c s2c s3r +permutation s1b s1u s2rc s2fs s1c s2c s3r +# FOR KEY SHARE does not conflict with a non-key UPDATE: no wait, no recheck. +permutation s1b s1u s2rc s2fks s1c s2c s3r +# FOR KEY SHARE waits for a key UPDATE. +permutation s1b s1k s2rc s2fks s1c s2c s3r +# SKIP LOCKED skips the row being updated; NOWAIT fails at once. +permutation s1b s1u s2rc s2sl s1c s2c s3r +permutation s1b s1u s2rc s2nw s1c s2c s3r +# UPDATE ... WHERE v = 1 re-checks through EvalPlanQual: only row 2 updated. +permutation s1b s1u s2rc s2upd s1c s2c s3r +# DELETE racing FOR UPDATE: the deleted row is not returned. +permutation s1b s1d s2rc s2fu s1c s2c s3r +# The updater rolls back: the original row is locked and returned. +permutation s1b s1u s2rc s2fu s1a s2c s3r +permutation s1b s1u s2rc s2upd s1a s2c s3r +# REPEATABLE READ: an update committed after the snapshot is a serialization +# failure, whether the locker waited for it or met it already committed. +permutation s2rr s2snap s1b s1u s2fu s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fu s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fnk s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fs s2c s3r +permutation s2rr s2snap s1b s1u s1c s2sl s2c s3r +permutation s2rr s2snap s1b s1u s1c s2nw s2c s3r +permutation s2rr s2snap s1b s1u s1c s2upd s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed non-key UPDATE: no failure. +permutation s2rr s2snap s1b s1u s1c s2fks s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed key UPDATE: failure. +permutation s2rr s2snap s1b s1k s1c s2fks s2c s3r +# REPEATABLE READ, the updater rolls back while the locker waits: no failure. +permutation s2rr s2snap s1b s1u s2fu s1a s2c s3r +# REPEATABLE READ, a committed DELETE: "concurrent delete". +permutation s2rr s2snap s1b s1d s2fu s1c s2c s3r +# FK check (ri_LockPKTuple): the parent key changes while the child insert +# waits, so the insert fails. +permutation s1b s1pk s2rc s2ci s1c s2c s3c +# FK check: a non-key parent UPDATE commits while the child insert waits on +# FOR UPDATE; the key still matches, so the insert succeeds. +permutation s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +# FK check: the parent key update rolls back; the insert succeeds. +permutation s1b s1pk s2rc s2ci s1a s2c s3c +# GetTupleForTrigger: the BEFORE UPDATE trigger row is locked after a +# concurrent update; EvalPlanQual drops it, so neither trigger nor UPDATE runs. +permutation s1b s1tu s2rc s2tu s1c s2c s3t +# ... and after a rollback the original row is updated. +permutation s1b s1tu s2rc s2tu s1a s2c s3t +# GetTupleForTrigger under REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1tu s2tu s1c s2c s3t +# A join repeats row 1: every repetition is re-checked and dropped. +permutation s1b s1u s2rc s2jfu s1c s2c s3r +# MERGE + BEFORE UPDATE trigger after a concurrent committed update. +permutation s1b s1tu s2rc s2mq s1c s2c s3t +permutation s1b s1tu s2rc s2mu s1c s2c s3t +permutation s2rr s2snap s1b s1tu s2mu s1c s2c s3t +# ON CONFLICT DO UPDATE (flags 0), READ COMMITTED: waits, then updates the +# committed version, without looping. +permutation s1b s1u s2rc s2oc s1c s2c s3r +# ON CONFLICT DO UPDATE, REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1u s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2oc s2c s3r + +# Two updates committed after the locker's snapshot: a key change, then a +# non-key change. The key change conflicts with FOR KEY SHARE although the +# latest update left the key alone. REPEATABLE READ: serialization failure, +# for the lock and for the FK check. +permutation s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +permutation s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +# READ COMMITTED: the lock moves to the latest version and re-checks it: the +# row no longer has id = 1 and drops out, a query without that qual returns +# the latest version, and the FK insert fails because the key has gone. +permutation s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s4pkk s4pn101 s2ci s2c s3c +# The other order: non-key change, then key change (the latest). +permutation s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +permutation s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +# Two non-key changes: FOR KEY SHARE conflicts with neither. +permutation s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +# Three updates: key change, key changed back, non-key change. The key +# changes conflict although the key ends where it started. +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +permutation s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +# Three updates: key change, then two non-key changes. +permutation s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +permutation s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +# One committed, one in progress: committed key change, in-progress non-key +# change. REPEATABLE READ fails at once, without waiting for the in-progress +# update, for FOR KEY SHARE and FOR NO KEY UPDATE alike; READ COMMITTED waits +# for it, then locks the latest version. +permutation s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +permutation s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +permutation s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +permutation s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +permutation s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +# ... committed non-key change, in-progress key change: FOR KEY SHARE waits, +# then fails (commit) or locks the snapshot's version (rollback). +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +# Both in progress in one transaction: key change, then non-key change. The +# FK check must wait for the key change, then fail. +permutation s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +permutation s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +# Committed non-key change, in-progress non-key change: FOR KEY SHARE +# conflicts with neither and does not wait. +permutation s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +permutation s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +# A key change the snapshot sees, then a non-key change it does not: the +# second update did not change the key, whatever the first one did. +permutation s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +permutation s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +# A key change that rolled back, then a committed non-key change: no conflict. +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +# FOR NO KEY UPDATE against the same histories: it conflicts with every +# update, key or not, committed or in progress. +permutation s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +permutation s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p + +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger, READ COMMITTED, against +# a row updated and committed after the snapshot: ON CONFLICT locks the latest +# version and the trigger's lock of the same row reports nothing, as in heap +# (no EvalPlanQual: ON CONFLICT has none set up). Also with the row updated +# twice, with a trigger that modifies NEW, after a rollback, and under +# REPEATABLE READ. +permutation s1b s1tu s2rc s2toc s1c s2c s3t +permutation s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +permutation s1b s1tu s2rc s2toc s1a s2c s3t +permutation s2rr s2snap s1b s1tu s2toc s1c s2c s3t +permutation s1b s1nu s2rc s2noc s1c s2c s3n +permutation s1b s1nu s2rc s2noc s1a s2c s3n +# ON CONFLICT against a key update by a FOR UPDATE locker ON CONFLICT was +# waiting for: the row no longer conflicts, so the INSERT goes ahead. +permutation s1b s1lfu s2rc s2oc s1k s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +# ... and against a non-key update from that locker: the new version is +# updated (and its trigger fired once); a lock only: the row is updated. +permutation s1b s1lfu s2rc s2oc s1u s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +permutation s1b s1lfu s2rc s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +# MERGE WHEN MATCHED UPDATE/DELETE after a concurrent committed update, +# without a trigger and with a trigger that modifies NEW. +permutation s1b s1u s2rc s2mlu s1c s2c s3r +permutation s1b s1u s2rc s2mlq s1c s2c s3r +permutation s1b s1u s2rc s2mld s1c s2c s3r +permutation s1b s1nu s2rc s2mnu s1c s2c s3n +permutation s1b s1nu s2rc s2mnq s1c s2c s3n +permutation s1b s1nu s2rc s2mnd s1c s2c s3n +permutation s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +# Cross-partition UPDATE after a concurrent committed update, without and +# with BEFORE UPDATE/DELETE triggers, by UPDATE and by MERGE. +permutation s1b s1pu s2rc s2pm s1c s2c s3pt +permutation s1b s1pu s2rc s2pmq s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptm s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptmq s1c s2c s3pt +permutation s1b s1pu s2rc s2mpm s1c s2c s3pt +permutation s1b s1ptu s2rc s2mptm s1c s2c s3pt +permutation s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +# RI ON DELETE CASCADE against a child row updated concurrently. +permutation s1b s1qcu s2rc s2qd s1c s2c s3q + +# Lock first, UPDATE second: key changes conflict with all four strengths, +# but a non-key change remains compatible with KEY SHARE (including CAS). +permutation s2rc s2fks s1b s1k s2c s1c s3r +permutation s2rc s2fks s1b s1u s1c s2c s3r +permutation s2rc s2fs s1b s1k s2c s1c s3r +permutation s2rc s2fnk s1b s1k s2c s1c s3r +permutation s2rc s2fu s1b s1k s2c s1c s3r +# FK insertion holds KEY SHARE until commit. The parent UPDATE must wait +# and then check the newly committed child; an aborted child permits it. +permutation s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +permutation s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +permutation s2rc s2uci s1b s1uk s2c s1c s3u +permutation s2rc s2rci s1b s1rk s2c s1c s3restrict +# Subtransaction rollback restores the parent and releases its key lock. +permutation s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +permutation s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +# A single component change, and a standalone unique index used by an FK. +permutation s2rc s2mci s1b s1mk s2c s1c s3multi +permutation s2rc s2vci s1b s1vk s2c s1c s3unique +permutation s2rc s2vci s1b s1vn s1c s2c s3unique + +# A compatible non-key update clears the on-page lock flag, not the FK +# lock in sLog. The same-size unique-key update can now reach CAS. +permutation s2rc s2vci s4vn s1b s1vk s2c s1c s3unique diff --git a/src/test/isolation/specs/flux_lock_update_lockers.spec b/src/test/isolation/specs/flux_lock_update_lockers.spec new file mode 100644 index 0000000000000..ed15ea91019ca --- /dev/null +++ b/src/test/isolation/specs/flux_lock_update_lockers.spec @@ -0,0 +1,32 @@ +# An UPDATE that conflicts with several row lockers must wait for all of +# them: two sessions hold FOR SHARE on the row, the first rolls back, and the +# UPDATE must keep waiting for the second instead of overwriting the row it +# still has locked. FLUX twin; the expected output is identical. +setup +{ + CREATE TABLE lu (id int PRIMARY KEY, v int) USING flux; + INSERT INTO lu VALUES (1, 0); +} + +teardown +{ + DROP TABLE lu; +} + +session s1 +step s1b { BEGIN; } +step s1l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s2chk { SELECT v FROM lu WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3u { UPDATE lu SET v = v + 1 WHERE id = 1; } +step s3chk { SELECT v FROM lu WHERE id = 1; } + +# s3 must still be waiting after s1 rolls back; s2 still sees v = 0. +permutation s1b s1l s2b s2l s3u s1r s2chk s2c s3chk diff --git a/src/test/isolation/specs/flux_lock_upgrade_queue.spec b/src/test/isolation/specs/flux_lock_upgrade_queue.spec new file mode 100644 index 0000000000000..9a3407b191f31 --- /dev/null +++ b/src/test/isolation/specs/flux_lock_upgrade_queue.spec @@ -0,0 +1,51 @@ +# A transaction that already holds a row lock and waits to strengthen it +# must not queue on the heavyweight tuple lock, as with heap: a waiter +# ahead of it in that queue may be waiting for the lock it already holds +# (heap_lock_tuple's skip_tuple_lock, heap_delete's current_is_member). +# heap_lock_upgrade_queue.spec runs the same steps on heap; the two +# expected outputs are identical. +# +# FLUX used to queue the upgrader behind the waiter, which then waited for +# the upgrader's own lock: "deadlock detected" here, and in +# tuplelock-upgrade-no-deadlock, where the cycle closes through an idle +# session, a wait that never ends. +setup +{ + CREATE TABLE luq (id int PRIMARY KEY, v int) USING flux; + INSERT INTO luq VALUES (1, 0); +} +teardown +{ + DROP TABLE luq; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT id FROM luq FOR KEY SHARE; } +step s1sh { SELECT id FROM luq FOR SHARE; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1r { ROLLBACK; } + +session s2 +setup { BEGIN; } +step s2ks { SELECT id FROM luq FOR KEY SHARE; } +step s2nk { SELECT id FROM luq FOR NO KEY UPDATE; } +step s2fu { SELECT id FROM luq FOR UPDATE; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3ks { SELECT id FROM luq FOR KEY SHARE; } +step s3sh { SELECT id FROM luq FOR SHARE; } +step s3fu { SELECT id FROM luq FOR UPDATE; } +step s3d { DELETE FROM luq RETURNING id; } +step s3c { COMMIT; } + +# s2 upgrades past a lock s1 took in a savepoint while s3 waits for s1: s2 +# wakes at ROLLBACK TO, then s3 waits for s2. +permutation s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +# s3 upgrades its SHARE lock while s2 waits for s1. +permutation s1sh s2fu s3sh s3fu s1r s3c s2c +# s3 deletes the row it holds a KEY SHARE lock on while s2 waits for s1. +permutation s1ks s2fu s3ks s3d s1r s3c s2c diff --git a/src/test/isolation/specs/flux_lostupdate.spec b/src/test/isolation/specs/flux_lostupdate.spec new file mode 100644 index 0000000000000..d5c0e93c671f4 --- /dev/null +++ b/src/test/isolation/specs/flux_lostupdate.spec @@ -0,0 +1,40 @@ +# FLUX lost-update prevention under READ COMMITTED (EvalPlanQual). +# +# Two concurrent transactions each do UPDATE ... SET v = v + 1 on the SAME +# row. Under READ COMMITTED, PostgreSQL semantics require BOTH increments to +# land: the second updater must block on the first, and after the first commits +# it re-reads the latest committed version (EvalPlanQual) and re-applies its SET +# expression on top -- so v goes 0 -> 1 -> 2, never 0 -> 1 (a lost update). +# +# FLUX is a stable-TID, in-place AM with no persistent per-tuple xmax; before +# the serialization fix it returned TM_Ok against the stale snapshot value and +# silently lost the second increment. This spec pins the correct behaviour: +# s2's UPDATE waits for s1, and the final value is 2. + +setup +{ + CREATE TABLE flux_lu (id int PRIMARY KEY, v bigint) USING flux; + INSERT INTO flux_lu VALUES (1, 0); +} + +teardown +{ + DROP TABLE flux_lu; +} + +session s1 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1_upd { UPDATE flux_lu SET v = v + 1 WHERE id = 1; } +step s1_commit { COMMIT; } + +session s2 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2_upd { UPDATE flux_lu SET v = v + 1 WHERE id = 1; } +step s2_commit { COMMIT; } + +session s3 +step s3_read { SELECT v FROM flux_lu WHERE id = 1; } # must be 2, not 1 + +# s1 updates (0->1), s2's identical UPDATE blocks on s1's row lock, s1 commits, +# s2 wakes -> EvalPlanQual re-reads v=1 -> re-applies +1 -> v=2, then commits. +permutation s1_upd s2_upd s1_commit s2_commit s3_read diff --git a/src/test/isolation/specs/flux_nonkey_move.spec b/src/test/isolation/specs/flux_nonkey_move.spec new file mode 100644 index 0000000000000..76cdc424bca54 --- /dev/null +++ b/src/test/isolation/specs/flux_nonkey_move.spec @@ -0,0 +1,88 @@ +# Non-key updates that move a row must remain compatible with KEY SHARE. +# Heap is the control. A subsequent move still waits for existing lockers +# because FLUX does not transfer their sLog locks to a newly inserted TID. +setup +{ + CREATE TABLE fi (id int, v int, PRIMARY KEY (id) INCLUDE (v)) USING flux; + CREATE TABLE fg (id int PRIMARY KEY, v int, pad text) USING flux WITH (fillfactor=100); + ALTER TABLE fg ALTER COLUMN pad SET STORAGE PLAIN; + INSERT INTO fi VALUES (1, 1); + INSERT INTO fg SELECT g, 1, repeat('x', 640) FROM generate_series(1, 40) g; + CREATE TABLE ci (id int REFERENCES fi) USING flux; + CREATE TABLE cg (id int REFERENCES fg) USING flux; +} +teardown { DROP TABLE ci, cg, fi, fg; } + +session s1 +step rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step snap { SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; } +step ki { SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; } +step kg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; } +step ii { INSERT INTO ci VALUES (1); } +step ig { INSERT INTO cg VALUES (1); } +step si { SELECT id, v FROM fi WHERE id=1 FOR SHARE; } +step sg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; } +step di { DELETE FROM fi WHERE id=1; } +step dg { DELETE FROM fg WHERE id=1; } +step wi { UPDATE fi SET v=v+1 WHERE id=1; } +step wg { UPDATE fg SET v=v+1 WHERE id=1; } +step c1 { COMMIT; } + +session s2 +step b2 { BEGIN; } +step ui { UPDATE fi SET v=2 WHERE id=1; } +step ug { UPDATE fg SET pad=repeat('y',1920) WHERE id=1; } +step c2 { COMMIT; } +step r2 { ROLLBACK; } +step d2i { DELETE FROM fi WHERE id=1; } +step n2i { UPDATE fi SET v=3 WHERE id=1; } +step n2g { UPDATE fg SET pad=repeat('z',1940) WHERE id=1; } +step k2i { UPDATE fi SET id=2 WHERE id=1; } +step d2g { DELETE FROM fg WHERE id=1; } +step k2g { UPDATE fg SET id=100 WHERE id=1; } + +session s3 +step b3 { BEGIN; } +step u3 { UPDATE fi SET v=3 WHERE id=1; } +step k3 { UPDATE fi SET id=2 WHERE id=1; } +step z3 { UPDATE fi SET id=1 WHERE id=2; } +step c3 { COMMIT; } +permutation rr snap b2 ui ki c2 c1 +permutation rr snap b2 ui ii c2 c1 +permutation rr snap b2 ui si c2 c1 +permutation rr snap b2 ui di c2 c1 +permutation rr snap b2 ui wi c2 c1 +permutation rr snap b2 ug kg c2 c1 +permutation rr snap b2 ug ig c2 c1 +permutation rr snap b2 ug sg c2 c1 +permutation rr snap b2 ug dg c2 c1 +permutation rr snap b2 ug wg c2 c1 +permutation rc snap b2 ui ki c2 c1 +permutation rc snap b2 ui ii c2 c1 +permutation rc snap b2 ui si c2 c1 +permutation rc snap b2 ui di c2 c1 +permutation rc snap b2 ui wi c2 c1 +permutation rc snap b2 ug kg c2 c1 +permutation rc snap b2 ug ig c2 c1 +permutation rc snap b2 ug sg c2 c1 +permutation rc snap b2 ug dg c2 c1 +permutation rc snap b2 ug wg c2 c1 +permutation rr snap b2 ui ki c2 d2i c1 +permutation rr snap b2 ui ki c2 k2i c1 +permutation rr snap b2 ug kg c2 d2g c1 +permutation rr snap b2 ug kg c2 k2g c1 +permutation rr snap b2 ui ki r2 c1 +permutation rr snap b2 ui c2 ki c1 +permutation rr snap b2 ug kg r2 c1 +permutation rr snap b2 ug c2 kg c1 + +permutation rr snap b2 ui ki c2 n2i c1 +permutation rr snap b2 ug kg c2 n2g c1 + +permutation rr snap b2 ui c2 b3 k3 c3 ki c1 +permutation rc snap b2 ui c2 b3 k3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 ki c3 c1 +permutation rr snap b2 ui c2 b3 u3 ki c3 c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 diff --git a/src/test/isolation/specs/flux_outofplace_grow.spec b/src/test/isolation/specs/flux_outofplace_grow.spec new file mode 100644 index 0000000000000..f3d8a1945a5d2 --- /dev/null +++ b/src/test/isolation/specs/flux_outofplace_grow.spec @@ -0,0 +1,37 @@ +# A row that grows on a full page cannot stay at its TID in FLUX, so the +# UPDATE moves it to a new TID even though no indexed column changed. A +# READ COMMITTED writer that waited for it must follow the row to its new +# version, as with heap: no update is lost. +# heap_outofplace_grow.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to report the moved row as deleted, so the waiting UPDATE +# silently did nothing. +setup +{ + CREATE TABLE xg (id int, n int, p1 bigint, p2 bigint, p3 bigint, p4 bigint, + p5 bigint, p6 bigint, p7 bigint, p8 bigint) + USING flux WITH (fillfactor = 100); + INSERT INTO xg (id, n) SELECT g, 0 FROM generate_series(1, 400) g; +} +teardown +{ + DROP TABLE xg; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1g { UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2u { UPDATE xg SET n = n + 1 WHERE id = 1; } +step s2c { COMMIT; } + +permutation s1b s2b s1g s2u s1c s2c s1sel +permutation s1b s2b s1g s2u s1r s2c s1sel diff --git a/src/test/isolation/specs/flux_outofplace_update.spec b/src/test/isolation/specs/flux_outofplace_update.spec new file mode 100644 index 0000000000000..859bde9a4e2e1 --- /dev/null +++ b/src/test/isolation/specs/flux_outofplace_update.spec @@ -0,0 +1,71 @@ +# An UPDATE that FLUX cannot do in place moves the row to a new TID: it +# deletes the old version and inserts the new one. A key change on a table +# with an exclusion constraint takes that path. A writer or locker that +# waited for such an update must follow the row to its new version, as with +# heap, whose old version's t_ctid links to the new one: under READ COMMITTED +# it rechecks and acts on the new version, under REPEATABLE READ it fails with +# a serialization failure for a concurrent update. A foreign-key check +# follows the parent row the same way. +# heap_outofplace_update.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to report the moved row as deleted: a waiting UPDATE silently did +# nothing (a lost update), a DELETE left the row behind, FOR UPDATE returned +# no row, an FK insert failed with a foreign key violation, and REPEATABLE +# READ reported a concurrent delete. +setup +{ + CREATE TABLE xt (id int, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING flux; + INSERT INTO xt VALUES (1, '[1,10)', 0); + CREATE TABLE xp (id int4range NOT NULL, valid_at daterange NOT NULL, + PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)) USING flux; + CREATE TABLE xc (id int4range NOT NULL, valid_at daterange NOT NULL, + parent_id int4range, + FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES xp) + USING flux; + INSERT INTO xp VALUES ('[1,2)', '[2020-01-01,2030-01-01)'); +} +teardown +{ + DROP TABLE xc, xp, xt; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s1uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s1up { UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, v, r, count(*) OVER () AS nrows FROM xt; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT id, v, r FROM xt; } +step s2u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s2uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s2d { DELETE FROM xt WHERE id = 1; } +step s2fu { SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; } +step s2ins { INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); } +step s2c { COMMIT; } + +# (a) READ COMMITTED: the waiter acts on the new version; no update is lost. +permutation s1b s2b s1u s2u s1c s2c s1sel +permutation s1b s2b s1uk s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2u s1c s2c s1sel +permutation s1b s2b s1u s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2d s1c s2c s1sel +# The move rolls back: the waiter updates the old version. +permutation s1b s2b s1uk s2u s1r s2c s1sel +permutation s1b s2b s1uk s2uk s1r s2c s1sel +# (b) REPEATABLE READ: a serialization failure for a concurrent update. +permutation s1b s2brr s1uk s2u s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2d s2c s1sel +# (c) The FK check follows the parent row to its new version. +permutation s1b s2b s1up s2ins s1c s2c +permutation s1b s2brr s1up s2ins s1c s2c +# (d) FOR UPDATE locks the new version, or fails under REPEATABLE READ. +permutation s1b s2b s1uk s2fu s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2fu s2c diff --git a/src/test/isolation/specs/flux_partition_move.spec b/src/test/isolation/specs/flux_partition_move.spec new file mode 100644 index 0000000000000..a6b23c2ced39f --- /dev/null +++ b/src/test/isolation/specs/flux_partition_move.spec @@ -0,0 +1,69 @@ +# A row that a concurrent UPDATE moved to another partition must be reported +# as moved, as with heap, whose deleter stamps the moved-partitions marker in +# t_ctid: under READ COMMITTED every write or lock that follows the row fails +# with "tuple to be locked was already moved to another partition", and under +# REPEATABLE READ with a serialization failure for a concurrent update. A +# rolled-back move leaves the row in place, lockable and updatable. +# heap_partition_move.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to report the moved row as deleted: the write silently did +# nothing, MERGE returned no rows, and an FK insert failed with a foreign +# key violation. +setup +{ + CREATE TABLE pm (a int, b text) PARTITION BY LIST (a); + CREATE TABLE pm1 PARTITION OF pm FOR VALUES IN (1) USING flux; + CREATE TABLE pm2 PARTITION OF pm FOR VALUES IN (2) USING flux; + INSERT INTO pm VALUES (1, 'ABC'); + CREATE TABLE pr (a int, b text) PARTITION BY RANGE (a); + CREATE TABLE pr1 PARTITION OF pr FOR VALUES FROM (1) TO (10) USING flux; + CREATE TABLE pr2 PARTITION OF pr FOR VALUES FROM (10) TO (20) USING flux; + INSERT INTO pr VALUES (7, 'ABC'); + CREATE UNIQUE INDEX pr1_a ON pr1 (a); + CREATE TABLE prc (a int REFERENCES pr1 (a)) USING flux; +} +teardown +{ + DROP TABLE prc, pr, pm; +} + +session s1 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE pm SET a = 2 WHERE a = 1; } +step s1sp { SAVEPOINT sp; } +step s1rs { ROLLBACK TO sp; } +step s1upr { UPDATE pr SET a = 11 WHERE a = 7; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT * FROM pm ORDER BY a; } +step s2u { UPDATE pm SET b = 'EFG' WHERE a = 1; } +step s2d { DELETE FROM pm WHERE a = 1; } +step s2l { SELECT * FROM pm WHERE a = 1 FOR UPDATE; } +step s2ks { SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; } +step s2m { MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; } +step s2i { INSERT INTO prc VALUES (7); } +step s2c { COMMIT; } + +# The move commits: each write or lock of the old row fails. +permutation s2b s1u s2d s1c s2sel s2c +permutation s2b s1u s2u s1c s2sel s2c +permutation s2b s1u s2l s1c s2c +permutation s2b s1u s2ks s1c s2c +permutation s2b s1u s2m s1c s2sel s2c +permutation s2b s1upr s2i s1c s2c +# Under REPEATABLE READ the row was concurrently updated. +permutation s2brr s2sel s1u s1c s2d s2c +permutation s2brr s2sel s1u s1c s2l s2c +# The move rolls back: the old row is still there, lockable and updatable. +permutation s2b s1u s2u s1r s2sel s2c +permutation s2b s1u s2d s1r s2sel s2c +permutation s2b s1u s2l s1r s2c +permutation s2b s1upr s2i s1r s2c +permutation s1sp s1u s1rs s2b s2u s2sel s2c s1c +permutation s2b s1u s1r s2l s2u s2sel s2c diff --git a/src/test/isolation/specs/flux_pvs_deep.spec b/src/test/isolation/specs/flux_pvs_deep.spec new file mode 100644 index 0000000000000..16fccef606d01 --- /dev/null +++ b/src/test/isolation/specs/flux_pvs_deep.spec @@ -0,0 +1,45 @@ +# FLUX: an old snapshot reads its version of a row through a version chain +# longer than any fixed bound. +# +# A REPEATABLE READ snapshot is taken, then another session commits 10010 +# in-place UPDATEs of one row, one transaction each. The snapshot must still +# see the row as it was, by walking the whole UNDO version chain back, as +# heap returns its old tuple version. + +setup +{ + CREATE TABLE pvs_h (id int, v int) USING heap; + CREATE TABLE pvs_f (id int, v int) USING flux; + INSERT INTO pvs_h VALUES (1, 0), (2, 0); + INSERT INTO pvs_f VALUES (1, 0), (2, 0); + CREATE PROCEDURE pvs_churn(n int) LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1..n LOOP + UPDATE pvs_h SET v = i WHERE id = 1; + UPDATE pvs_f SET v = i WHERE id = 1; + COMMIT; + END LOOP; + END $$; +} + +teardown +{ + DROP TABLE pvs_h, pvs_f; + DROP PROCEDURE pvs_churn(int); +} + +session old +setup { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step snap { SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; } +step read { SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; } +step done { COMMIT; } + +session churn +setup { SET synchronous_commit = off; } +step upd { CALL pvs_churn(10010); } +step now { SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; } + +permutation snap upd read done now diff --git a/src/test/isolation/specs/flux_recheck_after_wait.spec b/src/test/isolation/specs/flux_recheck_after_wait.spec new file mode 100644 index 0000000000000..71ce6b0ee8c8c --- /dev/null +++ b/src/test/isolation/specs/flux_recheck_after_wait.spec @@ -0,0 +1,57 @@ +# A DELETE or UPDATE that waited for another transaction must classify the +# row again when it wakes, as heap does (heap_delete and heap_update start +# over): a row another writer has delete-marked in the meantime is deleted +# only once that writer commits, so it waits for it, and a row the waited-for +# transaction updated and committed goes to EvalPlanQual. +# heap_recheck_after_wait.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX reported a delete-marked row as deleted at once, though its deleter +# was still in progress: the waiter returned no row, and when the deleter +# rolled back the row survived a DELETE, or lost an UPDATE. And a DELETE +# that waited for a row lock deleted the row without rechecking it, after +# the locker had changed it so that it no longer matched. +setup +{ + CREATE TABLE rw (id int PRIMARY KEY, v int) USING flux; + INSERT INTO rw VALUES (1, 0); +} +teardown +{ + DROP TABLE rw; +} + +session s1 +setup { BEGIN; } +step s1sh { SELECT id FROM rw FOR SHARE; } +step s1u { UPDATE rw SET v = 5; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2d { DELETE FROM rw RETURNING id, v; } +step s2r { ROLLBACK; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3d { DELETE FROM rw WHERE v = 0 RETURNING id, v; } +step s3u { UPDATE rw SET v = v + 10 RETURNING id, v; } +step s3c { COMMIT; } + +session s4 +step s4sel { SELECT id, v FROM rw; } + +# s2 waits for s1's row lock or update, s3 waits behind s2; s1 rolls back +# and s2 deletes. s3 must wait for s2, which rolls back: s3 deletes or +# updates the row. +permutation s1sh s2d s3d s1r s2r s3c s4sel +permutation s1sh s2d s3u s1r s2r s3c s4sel +permutation s1u s2d s3d s1r s2r s3c s4sel +permutation s1u s2d s3u s1r s2r s3c s4sel +# s2 commits its delete: s3 finds no row. +permutation s1sh s2d s3d s1r s2c s3c s4sel +# s3 waits for s1's row lock; s1 updates the row so that it no longer +# matches s3's WHERE, and commits: s3 deletes nothing. +permutation s1sh s3d s1u s1c s3c s4sel diff --git a/src/test/isolation/specs/flux_relstats.spec b/src/test/isolation/specs/flux_relstats.spec new file mode 100644 index 0000000000000..f5d1d635a6a60 --- /dev/null +++ b/src/test/isolation/specs/flux_relstats.spec @@ -0,0 +1,123 @@ +# The relation statistics VACUUM, ANALYZE and the UPDATE counters give the +# planner and autovacuum, for heap and FLUX side by side. +# +# - VACUUM or ANALYZE while another transaction deletes and inserts rows: as +# for heap, rows being inserted are not counted yet and rows being deleted +# are still live, because the writer's commit reports both to pgstat. So +# after the commit n_live_tup and n_dead_tup are the real counts. +# - UPDATE counters: n_tup_upd and n_mod_since_analyze count an UPDATE once, +# whether FLUX does it in place or at a new TID, and it is not also counted +# as an INSERT and a DELETE. n_dead_tup counts the versions VACUUM will +# have to remove: heap leaves the old version of each row; a FLUX in-place +# UPDATE of a non-indexed column leaves none (its before-image is in UNDO, +# which VACUUM does not reclaim); a FLUX in-place key UPDATE leaves a +# delete-marked index entry and an out-of-place one a deleted version, +# both for VACUUM. A FLUX non-key UPDATE is counted as HOT: it touches no +# index. +# - ANALYZE counts no dead row for a FLUX in-place UPDATE either (permutation +# 1 and 2 cover what it counts for in-progress DML). + +setup +{ + CREATE FUNCTION st_count(tbl regclass) RETURNS bigint LANGUAGE plpgsql AS $$ + DECLARE n bigint; + BEGIN EXECUTE format('SELECT count(*) FROM %s', tbl) INTO n; RETURN n; END $$; + CREATE TABLE st_heap (a int PRIMARY KEY, b int, c bigint) USING heap WITH (autovacuum_enabled = off); + CREATE TABLE st_flux (a int PRIMARY KEY, b int, c bigint) USING flux WITH (autovacuum_enabled = off); + CREATE TABLE sh_heap (a int, c bigint) USING heap WITH (autovacuum_enabled = off); + CREATE TABLE sh_flux (a int, c bigint) USING flux WITH (autovacuum_enabled = off); + CREATE INDEX ON sh_heap USING hash (a); + CREATE INDEX ON sh_flux USING hash (a); + INSERT INTO st_heap SELECT g, g, g FROM generate_series(1, 10000) g; + INSERT INTO st_flux SELECT g, g, g FROM generate_series(1, 10000) g; + INSERT INTO sh_heap SELECT g, g FROM generate_series(1, 10000) g; + INSERT INTO sh_flux SELECT g, g FROM generate_series(1, 10000) g; + SELECT pg_stat_force_next_flush(); +} +setup { VACUUM ANALYZE st_heap; } +setup { VACUUM ANALYZE st_flux; } +setup { VACUUM ANALYZE sh_heap; } +setup { VACUUM ANALYZE sh_flux; } + +teardown +{ + DROP TABLE st_heap, st_flux, sh_heap, sh_flux; + DROP FUNCTION st_count; +} + +session s1 +step s1_dml +{ + BEGIN; + DELETE FROM st_heap WHERE a <= 1000; + DELETE FROM st_flux WHERE a <= 1000; + INSERT INTO st_heap SELECT g, g, g FROM generate_series(20001, 22000) g; + INSERT INTO st_flux SELECT g, g, g FROM generate_series(20001, 22000) g; +} +step s1_commit { COMMIT; SELECT pg_stat_force_next_flush(); } + +session s2 +step s2_vacuum_heap { VACUUM st_heap; } +step s2_vacuum_flux { VACUUM st_flux; } +step s2_analyze { ANALYZE st_heap, st_flux; } +# 10000 committed rows were live when VACUUM or ANALYZE ran (heap's VACUUM +# may extrapolate from the pages it scanned, hence the tolerance). +step s2_reltuples +{ + SELECT relname, abs(reltuples - 10000) <= 500 AS committed_rows_only + FROM pg_class WHERE relname IN ('st_heap', 'st_flux') ORDER BY relname; +} +step s2_pgstat +{ + SELECT pg_stat_clear_snapshot(); + SELECT relname, abs(n_live_tup - st_count(relid)) <= 500 AS live_ok, + n_dead_tup = 1000 AS dead_ok + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; +} +step s2_update_nonkey +{ + UPDATE st_heap SET c = c + 1; + UPDATE st_flux SET c = c + 1; + SELECT pg_stat_force_next_flush(); +} +step s2_update_key +{ + UPDATE st_heap SET a = a + 100000; + UPDATE st_flux SET a = a + 100000; + SELECT pg_stat_force_next_flush(); +} +step s2_update_hashkey +{ + UPDATE sh_heap SET a = a + 100000; + UPDATE sh_flux SET a = a + 100000; + SELECT pg_stat_force_next_flush(); +} +step s2_update_counts +{ + SELECT pg_stat_clear_snapshot(); + SELECT relname, n_tup_upd = 10000 AS upd_ok, + n_tup_ins = 10000 AND n_tup_del = 0 AS not_ins_del, + n_mod_since_analyze = 10000 AS mod_ok, + n_tup_hot_upd = n_tup_upd AS all_hot, + CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables + WHERE relname IN ('st_heap', 'st_flux', 'sh_heap', 'sh_flux') + AND n_tup_upd + n_tup_del > 0 + ORDER BY relname; +} +step s2_dead +{ + SELECT pg_stat_clear_snapshot(); + SELECT relname, CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; +} + +permutation s1_dml s2_vacuum_heap s2_vacuum_flux s2_reltuples s1_commit s2_pgstat +permutation s1_dml s2_analyze s2_reltuples s1_commit s2_pgstat +permutation s2_update_nonkey s2_update_counts s2_analyze s2_dead +permutation s2_update_key s2_update_counts +permutation s2_update_hashkey s2_update_counts diff --git a/src/test/isolation/specs/flux_savepoint_lock_rollback.spec b/src/test/isolation/specs/flux_savepoint_lock_rollback.spec new file mode 100644 index 0000000000000..f5aeb79978ec6 --- /dev/null +++ b/src/test/isolation/specs/flux_savepoint_lock_rollback.spec @@ -0,0 +1,121 @@ +# A row lock or a row change made in a savepoint that is rolled back must +# leave nothing behind once the transaction commits. heap_savepoint_lock_ +# rollback.spec runs the same steps on heap; the two expected outputs are +# identical. +# +# FLUX used to mark the row DELETED when s1 committed, because s1 still had +# an ABORTED sLog op for it (the rolled-back lock), and the image on the page +# carried an UNCOMMITTED hint -- s2's, committed or still running. The row +# stayed visible to a plain SELECT, but UPDATE, DELETE and row locks no +# longer found it. +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int) USING flux; + INSERT INTO lr VALUES (1, 0), (2, 0); +} +setup +{ + VACUUM lr; +} +teardown +{ + DROP TABLE lr; +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1fu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1fnk { SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; } +step s1fs { SELECT * FROM lr WHERE id = 1 FOR SHARE; } +step s1fks { SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; } +step s1u { UPDATE lr SET v = v + 10 WHERE id = 1; } +step s1i { INSERT INTO lr VALUES (3, 0); } +step s1fu3 { SELECT * FROM lr WHERE id = 3 FOR UPDATE; } +step s1u3 { UPDATE lr SET v = v + 10 WHERE id = 3; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel { RELEASE a; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2u { UPDATE lr SET v = v + 1 WHERE id = 1; } +step s2d { DELETE FROM lr WHERE id = 1; } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +# Every way of reaching the rows must find the same rows. +session s3 +step s3chk +{ + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; +} + +# Each lock strength, in a savepoint that is rolled back, then a committed +# UPDATE of the row by s2. +permutation s1b s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1sp s1fnk s1rs s2u s1c s3chk +permutation s1b s1sp s1fs s1rs s2u s1c s3chk +permutation s1b s1sp s1fks s1rs s2u s1c s3chk +# Nested savepoints: both rolled back; only the inner one, the outer +# released. +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +# RELEASE instead of ROLLBACK TO: the lock is s1's until it commits. +permutation s1b s1sp s1fu s1rel s2u s1c s3chk +# s2 deletes the row instead. +permutation s1b s1sp s1fu s1rs s2d s1c s3chk +# s2 aborts its UPDATE before s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +# s2's UPDATE is still running when s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +# s1 rolls back instead, with the lock in a savepoint or at the top level. +permutation s1b s1sp s1fu s1rs s2u s1r s3chk +permutation s1b s1fu s1r s2u s3chk +# A row change, not a lock, in the rolled-back savepoint, then s2's +# committed UPDATE. +permutation s1b s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1sp s1d s1rs s2u s1c s3chk +# ... and s2's UPDATE still running when s1 commits. +permutation s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +permutation s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +# A top-level lock survives the same or a weaker lock in a rolled-back +# savepoint: s2 waits for s1. +permutation s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +permutation s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +# ... and a weaker lock at the same level does not give the stronger one up. +permutation s1b s1fu s1fks s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +# ... nor does a row change in a rolled-back savepoint. +permutation s1b s1fu s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1u s1rs s2d s1c s3chk +# s1's own top-level UPDATE of the row, then the rolled-back lock or +# UPDATE. s1 still holds its UPDATE's row lock, so s2 waits for s1. +permutation s1b s1u s1sp s1fu s1rs s1c s3chk +permutation s1b s1u s1sp s1u s1rs s1c s3chk +permutation s1b s1u s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1u s1sp s1fks s1rs s2u s1r s3chk +# s1's own top-level INSERT, locked in a rolled-back savepoint: kept. An +# INSERT in a rolled-back savepoint: discarded. +permutation s1b s1i s1sp s1fu3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rs s1c s3chk +# An INSERT kept (top level, or in a released savepoint), then updated in a +# rolled-back savepoint: kept, with its first value. +permutation s1b s1i s1sp s1u3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk + +# A rolled-back upgrade releases only the savepoint's stronger lock. +permutation s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk diff --git a/src/test/isolation/specs/flux_savepoint_lock_wake.spec b/src/test/isolation/specs/flux_savepoint_lock_wake.spec new file mode 100644 index 0000000000000..cc376bcd28c4a --- /dev/null +++ b/src/test/isolation/specs/flux_savepoint_lock_wake.spec @@ -0,0 +1,50 @@ +# A waiter blocked by a row lock taken in a savepoint must wake when the +# savepoint is rolled back, as with heap, whose locker is the +# subtransaction's xid. heap_savepoint_lock_wake.spec runs the same steps +# on heap; the two expected outputs are identical. +# +# FLUX used to wait on the locker's top-level xid, so the waiter stayed +# blocked after ROLLBACK TO had released the stronger lock, until the +# locker's transaction ended. +setup +{ + CREATE TABLE lw (id int PRIMARY KEY, v int) USING flux; + INSERT INTO lw VALUES (1, 0); +} +teardown +{ + DROP TABLE lw; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT * FROM lw FOR KEY SHARE; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s1fu { SELECT * FROM lw FOR UPDATE; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel2 { RELEASE b; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lw FOR SHARE; } +step s2u { UPDATE lw SET v = v + 1 RETURNING *; } +step s2d { DELETE FROM lw RETURNING *; } +step s2c { COMMIT; } + +# The waiter wakes at ROLLBACK TO: a lock upgrade, and a lock with nothing +# at the top level. +permutation s1ks s1sp s1nk s2nk s1rs s1c s2c +permutation s1ks s1sp s1nk s2u s1rs s1c s2c +permutation s1ks s1sp s1fu s2fs s1rs s1c s2c +permutation s1sp s1fu s2u s1rs s1c s2c +# Nested savepoints: the inner lock released into the outer savepoint, +# which is then rolled back; and only the inner savepoint rolled back. +permutation s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +permutation s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +# The top-level KEY SHARE lock still blocks a DELETE until commit. +permutation s1ks s1sp s1nk s2d s1rs s1c s2c diff --git a/src/test/isolation/specs/flux_scan_history.spec b/src/test/isolation/specs/flux_scan_history.spec new file mode 100644 index 0000000000000..5055f5ae9416a --- /dev/null +++ b/src/test/isolation/specs/flux_scan_history.spec @@ -0,0 +1,45 @@ +# FLUX: TID range and TABLESAMPLE scans serve the snapshot's version of a row +# updated in place after the scan began. +# +# The scan returns row 1, then blocks on an advisory lock while another +# session UPDATEs (or DELETEs) both rows and commits. Row 2, read after +# that, must still be the version the scan's snapshot sees, reconstructed +# from UNDO, as heap returns it. (Based on the review harness +# collection_{flux,heap}_{tid,sample}.) + +setup +{ + CREATE TABLE t (id int, v text) USING flux; + CREATE INDEX ti ON t (id); + INSERT INTO t VALUES (1, 'aaaa'), (2, 'cccc'); +} + +teardown +{ + DROP TABLE t; +} + +session w +step lock { SELECT pg_advisory_lock(881); } +step upd { UPDATE t SET v = CASE id WHEN 1 THEN 'bbbb' ELSE 'dddd' END; } +step del { DELETE FROM t WHERE id = 2; } +step unlock { SELECT pg_advisory_unlock(881); } + +session tid +setup { SET enable_seqscan = off; SET enable_indexscan = off; + SET enable_bitmapscan = off; } +step tplan { EXPLAIN (COSTS OFF) SELECT v FROM t WHERE ctid >= '(0,1)'; } +step tread { SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t WHERE ctid >= '(0,1)'; } +step tdone { SELECT pg_advisory_unlock_all(); } + +session smp +step splan { EXPLAIN (COSTS OFF) SELECT v FROM t TABLESAMPLE SYSTEM (100); } +step sread { SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t TABLESAMPLE SYSTEM (100); } +step sdone { SELECT pg_advisory_unlock_all(); } + +permutation tplan lock tread upd unlock tdone +permutation tplan lock tread del unlock tdone +permutation splan lock sread upd unlock sdone +permutation splan lock sread del unlock sdone diff --git a/src/test/isolation/specs/flux_scan_stable.spec b/src/test/isolation/specs/flux_scan_stable.spec new file mode 100644 index 0000000000000..4c29388ac5298 --- /dev/null +++ b/src/test/isolation/specs/flux_scan_stable.spec @@ -0,0 +1,76 @@ +# FLUX: a scan's row must not change under its reader. +# +# FLUX updates rows in place. A scan slot that kept pointing into the shared +# page saw whatever was written there later, so a row a query had already +# fetched could turn into a newer version its snapshot must not see. The +# reader fetches the row, then blocks on an advisory lock while a same-size +# UPDATE rewrites the row and commits; it must still return the value its +# snapshot saw, as heap does. Whether v is deformed before or after the +# lock wait does not matter: the row was fetched before the UPDATE, and +# either way the answer is the snapshot's 'aaaa'. One session per scan path +# (plans shown), each against FLUX (ft) and heap (ht). + +setup +{ + CREATE TABLE ft (id int, v text) USING flux; + CREATE INDEX ft_id ON ft (id); + INSERT INTO ft VALUES (1, 'aaaa'); + CREATE TABLE ht (id int, v text) USING heap; + CREATE INDEX ht_id ON ht (id); + INSERT INTO ht VALUES (1, 'aaaa'); +} + +teardown +{ + DROP TABLE ft; + DROP TABLE ht; +} + +session w +step w_lock { SELECT pg_advisory_lock(1); } +step w_upd { UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; } +step w_unlock { SELECT pg_advisory_unlock(1); } + +session seq +setup { SET enable_indexscan = off; SET enable_bitmapscan = off; + SET enable_tidscan = off; } +step seq_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft; } +step seq_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft; } +step seq_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht; } +step seq_unlock { SELECT pg_advisory_unlock(1); } + +session bmp +setup { SET enable_seqscan = off; SET enable_indexscan = off; + SET enable_tidscan = off; } +step bmp_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE id = 1; } +step bmp_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft WHERE id = 1; } +step bmp_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht WHERE id = 1; } +step bmp_unlock { SELECT pg_advisory_unlock(1); } + +session tid +setup { SET enable_seqscan = off; SET enable_indexscan = off; + SET enable_bitmapscan = off; } +step tid_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE ctid >= '(0,1)'; } +step tid_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft + WHERE ctid >= '(0,1)'; } +step tid_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht + WHERE ctid >= '(0,1)'; } +step tid_unlock { SELECT pg_advisory_unlock(1); } + +session idx +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_tidscan = off; } +step idx_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE id = 1; } +step idx_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft WHERE id = 1; } +step idx_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht WHERE id = 1; } +step idx_unlock { SELECT pg_advisory_unlock(1); } + +permutation seq_plan w_lock seq_f w_upd w_unlock seq_unlock +permutation w_lock seq_h w_upd w_unlock seq_unlock +permutation bmp_plan w_lock bmp_f w_upd w_unlock bmp_unlock +permutation w_lock bmp_h w_upd w_unlock bmp_unlock +permutation tid_plan w_lock tid_f w_upd w_unlock tid_unlock +permutation w_lock tid_h w_upd w_unlock tid_unlock +permutation idx_plan w_lock idx_f w_upd w_unlock idx_unlock +permutation w_lock idx_h w_upd w_unlock idx_unlock diff --git a/src/test/isolation/specs/flux_seqscan_stats.spec b/src/test/isolation/specs/flux_seqscan_stats.spec new file mode 100644 index 0000000000000..dc3d3107c1ee1 --- /dev/null +++ b/src/test/isolation/specs/flux_seqscan_stats.spec @@ -0,0 +1,27 @@ +# A sequential scan counts itself and the rows it returns in the table's +# statistics, as with heap: seq_scan and seq_tup_read. +# heap_seqscan_stats.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to leave both counters at zero. +setup +{ + CREATE TABLE ss (k int, v int) USING flux; + INSERT INTO ss SELECT g, g FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE ss; +} + +session s1 +setup { SET enable_indexscan = off; SET enable_bitmapscan = off; } +step s1scan { SELECT count(*) FROM ss; } +step s1where { SELECT k FROM ss WHERE v > 3 ORDER BY k; } +step s1ff { SELECT pg_stat_force_next_flush(); } +step s1stats { + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; +} + +permutation s1stats s1scan s1ff s1stats s1where s1ff s1stats diff --git a/src/test/isolation/specs/flux_slot_tuple_context.spec b/src/test/isolation/specs/flux_slot_tuple_context.spec new file mode 100644 index 0000000000000..a8a3f616cb2f7 --- /dev/null +++ b/src/test/isolation/specs/flux_slot_tuple_context.spec @@ -0,0 +1,34 @@ +# FLUX: a scan that reconstructs an older version of a row from UNDO, run +# in a per-tuple memory context that is reset after every row. +# +# ALTER TABLE ... VALIDATE CONSTRAINT (validateCheckConstraint), like the +# table rewrite of ATRewriteTable, scans in the executor's per-tuple context +# and resets it for every row. A row that another session updated in place +# is served as its committed version, rebuilt from UNDO. The slot must own +# that image in its own context: it used to adopt the reconstruction's +# allocation, made in the per-tuple context, so the context reset freed it +# under the slot and the next clear of the slot freed it again ("pfree +# called with invalid pointer"). + +setup +{ + CREATE TABLE fv (id int PRIMARY KEY, v int, pad text) USING flux; + INSERT INTO fv SELECT g, g, 'p' || g FROM generate_series(1, 30) g; + ALTER TABLE fv ADD CONSTRAINT fv_v CHECK (v > 0) NOT VALID; +} + +teardown +{ + DROP TABLE fv; +} + +session u +step upd { BEGIN; UPDATE fv SET v = v + 100, pad = pad || 'x' WHERE id IN (3, 17); } +step abort { ROLLBACK; } + +session a +step validate { ALTER TABLE fv VALIDATE CONSTRAINT fv_v; } +step check { SELECT convalidated FROM pg_constraint WHERE conname = 'fv_v'; + SELECT count(*), sum(v) FROM fv; } + +permutation upd validate check abort diff --git a/src/test/isolation/specs/flux_specconflict_toast.spec b/src/test/isolation/specs/flux_specconflict_toast.spec new file mode 100644 index 0000000000000..adbe828ee3e0f --- /dev/null +++ b/src/test/isolation/specs/flux_specconflict_toast.spec @@ -0,0 +1,75 @@ +# FLUX: a speculative insertion that is killed (INSERT ... ON CONFLICT losing +# the race to a concurrent inserter) must delete the TOAST values it stored, +# as heap_abort_speculative does. Otherwise the loser's values stay in the +# TOAST relation, referenced by nothing, once its transaction commits. +# +# Same control flow as insert-conflict-specconflict's TOAST permutation, on a +# FLUX table whose data column is always stored out of line. + +setup +{ + CREATE FUNCTION flux_sct_lock(text) RETURNS text IMMUTABLE LANGUAGE plpgsql AS $$ + BEGIN + IF pg_try_advisory_xact_lock(current_setting('spec.session')::int, 1) THEN + PERFORM pg_advisory_xact_lock(current_setting('spec.session')::int, 2); + ELSE + PERFORM pg_advisory_xact_lock(current_setting('spec.session')::int, 3); + END IF; + RETURN $1; + END;$$; + CREATE FUNCTION flux_sct_val() RETURNS text LANGUAGE sql AS $$ + SELECT string_agg(encode(sha256(g::text::bytea), 'hex'), '') + FROM generate_series(1, 133) g $$; + CREATE TABLE flux_sct (key text, data text) USING flux; + ALTER TABLE flux_sct ALTER COLUMN data SET STORAGE EXTERNAL; + CREATE UNIQUE INDEX flux_sct_key ON flux_sct ((flux_sct_lock(key))); + CREATE FUNCTION flux_sct_chunks() RETURNS bigint LANGUAGE plpgsql AS $$ + DECLARE + n bigint; + BEGIN + EXECUTE format('SELECT count(DISTINCT chunk_id) FROM %s', + (SELECT reltoastrelid::regclass FROM pg_class + WHERE oid = 'flux_sct'::regclass)) + INTO n; + RETURN n; + END;$$; +} + +teardown +{ + DROP TABLE flux_sct; + DROP FUNCTION flux_sct_lock(text), flux_sct_val(), flux_sct_chunks(); +} + +session controller +step c_locks { SELECT pg_advisory_lock(sess, lock), sess, lock FROM generate_series(1, 2) a(sess), generate_series(1, 3) b(lock); } +step c_unlock_1_1 { SELECT pg_advisory_unlock(1, 1); } +step c_unlock_2_1 { SELECT pg_advisory_unlock(2, 1); } +step c_unlock_1_2 { SELECT pg_advisory_unlock(1, 2); } +step c_unlock_2_2 { SELECT pg_advisory_unlock(2, 2); } +step c_unlock_1_3 { SELECT pg_advisory_unlock(1, 3); } +step c_unlock_2_3 { SELECT pg_advisory_unlock(2, 3); } +# One row; one TOAST value, the winner's. +step c_show { SELECT count(*) AS nrows, flux_sct_chunks() AS nvalues, + bool_and(md5(data) = md5(flux_sct_val())) AS ok + FROM flux_sct; } + +session s1 +setup { SET spec.session = 1; } +step s1_insert { INSERT INTO flux_sct VALUES ('k', flux_sct_val()) ON CONFLICT DO NOTHING; } + +session s2 +setup { SET spec.session = 2; } +step s2_insert { INSERT INTO flux_sct VALUES ('k', flux_sct_val()) ON CONFLICT DO NOTHING; } + +permutation + c_locks + s1_insert s2_insert + # both pass the conflict probe and insert speculatively + c_unlock_1_1 c_unlock_2_1 + c_unlock_1_3 c_unlock_2_3 + # s1 finishes first and commits + c_unlock_1_2 + # s2 finds the conflict, kills its tuple, does nothing, and commits + c_unlock_2_2 + c_show diff --git a/src/test/isolation/specs/flux_ssi_conflict_out.spec b/src/test/isolation/specs/flux_ssi_conflict_out.spec new file mode 100644 index 0000000000000..d6a1d8ba49a2c --- /dev/null +++ b/src/test/isolation/specs/flux_ssi_conflict_out.spec @@ -0,0 +1,99 @@ +# SSI rw-conflict-out detection for FLUX. heap_ssi_conflict_out.spec runs +# the same steps on heap; the two expected outputs are identical. +# +# A serializable reader must report a conflict out to every writer of a +# version it examines that its snapshot cannot see, whether that writer is +# still running or committed after the snapshot was taken. FLUX keeps only +# the newest version on the page and reaches older ones through the UNDO +# version chain, so the writers the walk steps over must be reported too. +# +# Permutations 1-2 are update-conflict-out.spec. Permutations 3-4 are a write +# skew whose read goes through an index and is served a version rebuilt from +# UNDO, past one committed in-place UPDATE (3) and past a committed and a +# running one (4). Permutations 5-6 are receipt-report.spec's schedules +# where a read-only transaction sees a state no serial order produces. + +setup +{ + CREATE TABLE txn0 (id int4 PRIMARY KEY, val text) USING flux; + CREATE TABLE txn1 (id int4 PRIMARY KEY, val text) USING flux; + CREATE TABLE ws (id int PRIMARY KEY, v int) USING flux; + INSERT INTO ws VALUES (1, 0), (2, 0), (3, 0); + CREATE TABLE ctl (k text NOT NULL PRIMARY KEY, deposit_date date NOT NULL) USING flux; + INSERT INTO ctl VALUES ('receipt', DATE '2008-12-22'); + CREATE TABLE receipt (receipt_no int NOT NULL PRIMARY KEY, deposit_date date NOT NULL, amount numeric(13,2)) USING flux; + INSERT INTO receipt VALUES (1, DATE '2008-12-22', 1.00), (2, DATE '2008-12-22', 2.00); +} + +teardown +{ + DROP TABLE txn0, txn1, ws, ctl, receipt; +} + +session foo +step foo_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step foo_select { SELECT * FROM txn0 WHERE id = 42; } +step foo_insert { INSERT INTO txn1 SELECT 7, 'foo_insert'; } +step foo_commit { COMMIT; } + +session bar +step bar_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step bar_select { SELECT * FROM txn1 WHERE id = 7; } +step bar_insert { INSERT INTO txn0 SELECT 42, 'bar_insert'; } +step bar_commit { COMMIT; } + +session trouble +step trouble_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step trouble_update { UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; } +step trouble_delete { DELETE FROM txn1 WHERE id = 7; } +step trouble_abort { ABORT; } + +session w1 +step w1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w1snap { SELECT v FROM ws WHERE id = 3; } +step w1r1 { SELECT v FROM ws WHERE id = 1; } +step w1w2 { UPDATE ws SET v = v + 1 WHERE id = 2; } +step w1c { COMMIT; } + +session w2 +step w2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w2r2 { SELECT v FROM ws WHERE id = 2; } +step w2w1 { UPDATE ws SET v = v + 10 WHERE id = 1; } +step w2c { COMMIT; } + +session w3 +step w3b { BEGIN; } +step w3w1 { UPDATE ws SET v = v + 100 WHERE id = 1; } +step w3a { ABORT; } + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step rxwy1 { INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); } +step c1 { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step wx2 { UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; } +step c2 { COMMIT; } + +session s3 +step s3b { BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; } +step rx3 { SELECT * FROM ctl WHERE k = 'receipt'; } +step ry3 { SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; } +step c3 { COMMIT; } + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_update bar_select bar_commit trouble_abort + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_delete bar_select bar_commit trouble_abort + +permutation w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c + +permutation w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a + +permutation s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 + +permutation s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 diff --git a/src/test/isolation/specs/flux_ssi_copy.spec b/src/test/isolation/specs/flux_ssi_copy.spec new file mode 100644 index 0000000000000..211cd4d5e8781 --- /dev/null +++ b/src/test/isolation/specs/flux_ssi_copy.spec @@ -0,0 +1,18 @@ +# Bulk INSERT must check relation predicate locks, not promoted page locks. +setup +{ + CREATE TABLE a (id int) USING flux; + CREATE TABLE b (id int) USING flux; +} +teardown { DROP TABLE a, b; } +session s1 +step b1 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r1 { SELECT count(*) FROM a; } +step w1 { COPY b FROM PROGRAM 'printf "1\n"'; } +step c1 { COMMIT; } +session s2 +step b2 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r2 { SELECT count(*) FROM b; } +step w2 { COPY a FROM PROGRAM 'printf "2\n"'; } +step c2 { COMMIT; } +permutation b1 b2 r1 r2 w1 c1 w2 c2 diff --git a/src/test/isolation/specs/flux_ssi_revive.spec b/src/test/isolation/specs/flux_ssi_revive.spec new file mode 100644 index 0000000000000..cccfe0583797c --- /dev/null +++ b/src/test/isolation/specs/flux_ssi_revive.spec @@ -0,0 +1,54 @@ +# SSI rw-conflict-in on an index entry revived by an in-place key UPDATE. +# heap_ssi_revive.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# s3 (read committed) moves row 1 to k = 1 and away again, so FLUX keeps a +# delete-marked (1, TID) entry in t_k. When s2 moves the row back to k = 1, +# FLUX revives that entry instead of inserting a new one. An insert reports +# a rw-conflict to every serializable reader holding a predicate lock on the +# leaf page it lands on, which is what protects a key-range read from a +# phantom; the revive must report it the same way. +# +# Permutations 1-2: s1 reads k = 1 before the tombstone exists, so it holds +# only the leaf page lock; s1 and s2 then form a write skew through u, and +# one of them must fail. Permutation 3: the tombstone exists when s1 reads +# k = 1, so s1 also locks row 1 through it. Permutation 4: s1 reads another +# key on the same leaf; heap reports the (false positive) conflict through +# the page lock, and FLUX does the same. + +setup +{ + CREATE TABLE t (id int PRIMARY KEY, k int, v int) USING flux; + CREATE INDEX t_k ON t (k); + INSERT INTO t VALUES (1, 3, 0), (2, 5, 0), (3, 7, 0); + CREATE TABLE u (id int PRIMARY KEY, v int) USING flux; + INSERT INTO u VALUES (1, 0); +} + +teardown +{ + DROP TABLE t, u; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s1r { SELECT count(*) FROM t WHERE k = 1; } +step s1r5 { SELECT count(*) FROM t WHERE k = 5; } +step s1w { UPDATE u SET v = v + 1 WHERE id = 1; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s2r { SELECT v FROM u WHERE id = 1; } +step s2w { UPDATE t SET k = 1 WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3a { UPDATE t SET k = 1 WHERE id = 1; } +step s3b { UPDATE t SET k = 2 WHERE id = 1; } +step s3chk { SELECT id, k FROM t ORDER BY id; SELECT * FROM u; } + +permutation s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +permutation s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +permutation s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +permutation s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk diff --git a/src/test/isolation/specs/flux_tid_fetch_snapshot.spec b/src/test/isolation/specs/flux_tid_fetch_snapshot.spec new file mode 100644 index 0000000000000..21ebae1c97056 --- /dev/null +++ b/src/test/isolation/specs/flux_tid_fetch_snapshot.spec @@ -0,0 +1,48 @@ +# Fetching a row by TID must return the version the snapshot sees, as heap +# does, whose TID still names the version the scan found. An EvalPlanQual +# recheck of a join refetches the unlocked side's row by TID: it must see +# that row as the query saw it, not as a concurrent update left it. A TID +# scan under REPEATABLE READ must find a row updated since its snapshot, +# with its old values. heap_tid_fetch_snapshot.spec runs the same steps on +# heap; the two expected outputs are identical. +# +# FLUX used to return the row's newest version to the recheck, and nothing +# to the TID scan. +setup +{ + CREATE TABLE acc (id text PRIMARY KEY, bal int) USING flux; + INSERT INTO acc VALUES ('checking', 600), ('savings', 600); + CREATE TABLE oth (id text, v int) USING flux; + INSERT INTO oth VALUES ('checking', 1), ('savings', 1); +} +teardown +{ + DROP TABLE acc, oth; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1sel { SELECT * FROM acc ORDER BY id; } +step s1lock { SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; } +step s1tid { SELECT * FROM acc WHERE ctid = '(0,1)'; } +step s1join { SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; } +step s1c { COMMIT; } + +session s2 +step s2u { UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; } +step s2d { DELETE FROM acc WHERE id = 'checking'; } +step s2uo { UPDATE oth SET v = v + 10 WHERE id = 'checking'; } +step s2do { DELETE FROM oth WHERE id = 'checking'; } +step s2b { BEGIN; } +step s2c { COMMIT; } + +# The recheck sees a2 as the query did: 600, beside a1's new 1050. +permutation s2b s2u s1b s1lock s2c s1c +# The unlocked side is another table, updated or deleted concurrently. +permutation s2b s2u s2uo s1b s1join s2c s1c +permutation s2b s2u s2do s1b s1join s2c s1c +# A TID scan sees the version its snapshot sees, updated or deleted since. +permutation s1brr s1sel s2u s1tid s1c +permutation s1brr s1sel s2d s1tid s1c +permutation s1brr s1sel s2u s2u s1tid s1c diff --git a/src/test/isolation/specs/flux_update_deadlock.spec b/src/test/isolation/specs/flux_update_deadlock.spec new file mode 100644 index 0000000000000..7b57080f1d819 --- /dev/null +++ b/src/test/isolation/specs/flux_update_deadlock.spec @@ -0,0 +1,51 @@ +# FLUX: concurrent two-row UPDATE transactions that deadlock. The deadlock +# victim's increments must be rolled back and every survivor's must land +# exactly once, as in heap. hist records the increments of the transactions +# that commit, in the same transaction. + +setup +{ + CREATE TABLE fdl (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fdl VALUES (1, 0), (2, 0); + CREATE TABLE fdl_hist (id int, who text); +} + +teardown +{ + DROP TABLE fdl, fdl_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); } +step s1u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '500ms'; BEGIN; } +step s2u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); } +step s2u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); } +step s3u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; } + +# s2 runs with a deadlock_timeout far below the others' but far above the +# few milliseconds the tester needs to issue the step that closes the cycle, +# so s2 is always the one that detects it and is the victim. Commits are +# ordered so that no session is asked to commit while it still waits. +# +# Two-way deadlock, s2 is the victim; s1 survives. +permutation s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +# A third transaction waits on row 1 behind s1 before the cycle forms. +permutation s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +# The victim holds the row a waiter is queued on. +permutation s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +# Victim's row 2 has a queued waiter too. +permutation s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk diff --git a/src/test/isolation/specs/flux_update_key_waiter.spec b/src/test/isolation/specs/flux_update_key_waiter.spec new file mode 100644 index 0000000000000..6b9ad8eab44a6 --- /dev/null +++ b/src/test/isolation/specs/flux_update_key_waiter.spec @@ -0,0 +1,60 @@ +# An UPDATE that waits for a concurrent writer of the same row must decide +# whether it changes an indexed column, and which index entries to change, +# from the row the writer leaves behind, not from the writer's uncommitted +# image it found before the wait. heap_update_key_waiter.spec runs the same +# steps on heap; the two expected outputs are identical. +# +# FLUX updates in place, so while the writer runs the page holds its +# uncommitted image. An UPDATE that set k to the value the writer had set +# compared its new row with that image, found no indexed column changed, and +# after the writer aborted overwrote the restored row in place without index +# maintenance: the row then had the new k but the index had only its old one. +# Each permutation reads the row through the index under every key it may +# have, and once by a sequential scan; the two must agree. +setup +{ + CREATE TABLE kw (id int, k int, v int) USING flux; + CREATE INDEX kw_k ON kw (k); + INSERT INTO kw SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE kw; +} +session s1 +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1v { UPDATE kw SET v = 1 WHERE id = 3; } +step s1d { DELETE FROM kw WHERE id = 3; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +step s2b { BEGIN; } +step s2k { UPDATE kw SET k = 100 WHERE id = 3; } +step s2k2 { UPDATE kw SET k = 200 WHERE id = 3; } +step s2v { UPDATE kw SET v = 2 WHERE id = 3; } +step s2c { COMMIT; } +session s3 +step chk { + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; +} + +# The waiter's new key equals the aborted writer's key. +permutation s1b s1k s2b s2k s1r s2c chk +permutation s1b s1k s2b s2k s1c s2c chk +# The waiter changes the key to another value. +permutation s1b s1k s2b s2k2 s1r s2c chk +permutation s1b s1k s2b s2k2 s1c s2c chk +# The writer changed the key, the waiter changes only a non-key column. +permutation s1b s1k s2b s2v s1r s2c chk +permutation s1b s1k s2b s2v s1c s2c chk +# The writer changed a non-key column, the waiter changes the key. +permutation s1b s1v s2b s2k s1r s2c chk +permutation s1b s1v s2b s2k s1c s2c chk +# The writer deleted the row and rolls back. +permutation s1b s1d s2b s2k s1r s2c chk +permutation s1b s1d s2b s2k s1c s2c chk diff --git a/src/test/isolation/specs/flux_update_serialize.spec b/src/test/isolation/specs/flux_update_serialize.spec new file mode 100644 index 0000000000000..9d62110afde9e --- /dev/null +++ b/src/test/isolation/specs/flux_update_serialize.spec @@ -0,0 +1,66 @@ +# FLUX: concurrent UPDATE serialization must match heap, and a plain UPDATE +# must not hold a heavyweight tuple lock until commit. +# +# FLUX updates rows in place at a stable TID. The in-progress updater is the +# on-page t_xmin of the new image (FLUX_TUPLE_UPDATED | FLUX_TUPLE_UNCOMMITTED), +# which is what a concurrent updater must wait on, exactly as heap waits on +# xmax. The expected output is heap's (the same spec with USING heap). + +setup +{ + CREATE TABLE fus (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fus VALUES (1, 0); + CREATE TABLE fus_big (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fus_big SELECT g, 0 FROM generate_series(1, 20000) g; +} + +teardown +{ + DROP TABLE fus, fus_big; +} + +session s1 +step s1rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s1ks { SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; } +step s1fu { SELECT id FROM fus WHERE id = 1 FOR UPDATE; } +step s1oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } +# A whole-table UPDATE must not take one transaction-duration lock per row: +# 20000 rows exhaust the default lock table ("out of shared memory"). +step s1big { UPDATE fus_big SET v = v + 1; } +step s1bigsum { SELECT sum(v) FROM fus_big; } +# No tuple lock may survive the UPDATE statement (heap holds none). +step s1locks { SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT v FROM fus WHERE id = 1; } +step s2u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s2oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s2c { COMMIT; } + +session s3 +step s3r { SELECT v FROM fus WHERE id = 1; } + +# READ COMMITTED: s2 waits for s1, then re-evaluates on s1's value -> 2. +permutation s1rc s2rc s1u s2u s1c s2c s3r +# READ COMMITTED, s1 rolls back: s2 proceeds on the original value -> 1. +permutation s1rc s2rc s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 commits while s2 waits: serialization failure -> 1. +permutation s1rc s2rr s2snap s1u s2u s1c s2c s3r +# REPEATABLE READ, s1 rolls back while s2 waits: s2 succeeds -> 1. +permutation s1rc s2rr s2snap s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 committed after s2's snapshot: immediate failure -> 1. +permutation s1rc s2rr s2snap s1u s1c s2u s2c s3r +# s1 holds only a KEY SHARE lock, so s2's no-key UPDATE proceeds; s1's own +# later UPDATE must then wait for s2 and re-evaluate (no lost update) -> 2. +permutation s1rc s2rc s1ks s2u s1u s2c s1c s3r +# FOR UPDATE blocks a concurrent UPDATE until commit -> 2. +permutation s1rc s2rc s1fu s2u s1u s1c s2c s3r +# Concurrent ON CONFLICT DO UPDATE on the same key -> 2. +permutation s1rc s2rc s1oc s2oc s1c s2c s3r +# Bulk UPDATE at default settings, then no tuple locks left in the xact. +permutation s1rc s1big s1locks s1c s1bigsum diff --git a/src/test/isolation/specs/flux_update_victim.spec b/src/test/isolation/specs/flux_update_victim.spec new file mode 100644 index 0000000000000..9d1422df2e2be --- /dev/null +++ b/src/test/isolation/specs/flux_update_victim.spec @@ -0,0 +1,47 @@ +# FLUX: an UPDATE that waited for a row and then became a deadlock victim +# must not leave its increments behind. Every value must equal the number +# of committed increments recorded in fdv_hist (same transaction), as in +# heap. + +setup +{ + CREATE TABLE fdv (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fdv VALUES (1, 0), (2, 0); + CREATE TABLE fdv_hist (id int); +} + +teardown +{ + DROP TABLE fdv, fdv_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s1u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '10ms'; BEGIN; } +step s2u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s2u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s3u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; } + +# s2 waits for s1 on row 1, after updating row 2; then s1 waits on row 2 +# -> s2 is the victim (short deadlock_timeout) while its row-1 wait is +# granted the row only after s1's commit. +permutation s2u2 s1u1 s2u1 s1u2 s1c s2c chk +# Same, with s3 queued on row 2 behind s2. +permutation s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +# s2's first update waited on s1 and succeeded after s1 committed; its +# second update then deadlocks with s3. +permutation s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk diff --git a/src/test/isolation/specs/flux_update_waiter_abort.spec b/src/test/isolation/specs/flux_update_waiter_abort.spec new file mode 100644 index 0000000000000..0d793fcad0787 --- /dev/null +++ b/src/test/isolation/specs/flux_update_waiter_abort.spec @@ -0,0 +1,81 @@ +# A row change that waits for a concurrent writer of the same row must work +# on the row the writer leaves behind: its committed new version if it +# commits, the version it replaced if it aborts. heap_update_waiter_abort.spec +# runs the same steps on heap; the two expected outputs are identical. +# +# FLUX updates in place, so while the writer runs the page holds its +# uncommitted image. The executor fetches the row it is about to update +# (SnapshotAny) before the update waits, and builds the new row from what it +# got. That fetch returned the writer's image, so after the writer aborted +# the update applied the aborted values, and RETURNING OLD showed them. +setup +{ + CREATE TABLE vh (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO vh VALUES (1, 0, 0), (2, 0, 0); + CREATE TABLE vh_hist (id int); +} +teardown +{ + DROP TABLE vh, vh_hist; +} +session s1 +setup { SET deadlock_timeout = '10ms'; } +step s1b { BEGIN; } +step s1w { UPDATE vh SET w = 10 WHERE id = 1; } +step s1w2 { UPDATE vh SET w = 20 WHERE id = 1; } +step s1k { UPDATE vh SET id = 10 WHERE id = 1; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s1u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +setup { SET deadlock_timeout = '10s'; } +step s2b { BEGIN; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2u { UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s2w5 { UPDATE vh SET w = 5 WHERE id = 1; } +step s2d { DELETE FROM vh WHERE id = 1 RETURNING *; } +step s2l { SELECT * FROM vh WHERE id = 1 FOR UPDATE; } +step s2h { INSERT INTO vh_hist VALUES (1); } +step s2c { COMMIT; } +session s3 +setup { SET deadlock_timeout = '10s'; } +step s3b { BEGIN; } +step s3u { UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s3u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s3u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s3c { COMMIT; } +session s4 +step chk { SELECT * FROM vh ORDER BY id; } +step chkh { SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; } + +# READ COMMITTED UPDATE waiter: the writer commits (re-fetch: OLD w = 10), +# or aborts (OLD w = 0). +permutation s1b s1w s2b s2u s1c s2c chk +permutation s1b s1w s2b s2u s1r s2c chk +# REPEATABLE READ: commit is a serialization failure, abort succeeds. +permutation s1b s1w s2rr s2u s1c s2c chk +permutation s1b s1w s2rr s2u s1r s2c chk +# The writer's change is in a savepoint rolled back while the update waits; +# then with an earlier change outside the savepoint, which commits. +permutation s1b s1sp s1w s2b s2u s1rs s1c s2c chk +permutation s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +# DELETE ... RETURNING waiters, and a SELECT ... FOR UPDATE waiter whose +# writer aborts. (FOR UPDATE behind a writer that commits is +# flux_lock_waiter_commit.) +permutation s1b s1w s2b s2d s1c s2c chk +permutation s1b s1w s2b s2d s1r s2c chk +permutation s1b s1w s2b s2l s1r s2c chk +# Two waiters queued behind the writer. +permutation s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +permutation s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +# The writer changes the column the waiter's WHERE clause tests. +permutation s1b s1k s2b s2u s1c s2c chk +permutation s1b s1k s2b s2u s1r s2c chk +# The on-page version is our own earlier update. +permutation s2b s2w5 s2u s2c chk +# s1->s3 (row 2), s3->s2 (row 1, queued), s2->s1 (row 1): s1 is the deadlock +# victim; s2 must see OLD v = 0, not the victim's 1. +permutation s1b s2b s3b s1u1 s3u2 s2u(s1u2) s3u1 s1u2(*) s1c s2h s2c s3c chkh diff --git a/src/test/isolation/specs/flux_vacuum_move.spec b/src/test/isolation/specs/flux_vacuum_move.spec new file mode 100644 index 0000000000000..29b5488b4b98e --- /dev/null +++ b/src/test/isolation/specs/flux_vacuum_move.spec @@ -0,0 +1,45 @@ +# Ordinary VACUUM must not move a row out from under its writer. +# +# s1 updates the last row in place and keeps the transaction open. s2 +# VACUUMs the table (lower pages are empty after setup's DELETE) and inserts +# enough rows to reuse any slot VACUUM might have freed. s1 must still see +# exactly its own row, and after it ends every row must be intact. Heap gives +# the same output (heap_vacuum_move). + +setup +{ + CREATE TABLE fvmove (id int, v int, pad text) USING flux WITH (autovacuum_enabled = off); + CREATE INDEX fvmove_id ON fvmove (id); + INSERT INTO fvmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; + DELETE FROM fvmove WHERE id <= 300; +} +setup { VACUUM fvmove; } +setup { VACUUM fvmove; } + +teardown +{ + DROP TABLE fvmove; +} + +session s1 +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; } +step s1_upd { BEGIN; UPDATE fvmove SET v = 7 WHERE id = 400; } +step s1_idx { SELECT id, v FROM fvmove WHERE id = 400; } +step s1_bmp { SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM fvmove WHERE id = 400; } +step s1_commit { COMMIT; } +step s1_rollback { ROLLBACK; } + +session s2 +step s2_vac { VACUUM fvmove; } +step s2_ins { INSERT INTO fvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; } +step s2_check +{ + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM fvmove; +} + +permutation s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +permutation s1_upd s2_vac s2_ins s1_idx s1_commit s2_check diff --git a/src/test/isolation/specs/heap_cluster_sort_snapshot.spec b/src/test/isolation/specs/heap_cluster_sort_snapshot.spec new file mode 100644 index 0000000000000..825feb25936d0 --- /dev/null +++ b/src/test/isolation/specs/heap_cluster_sort_snapshot.spec @@ -0,0 +1,62 @@ +# CLUSTER and VACUUM FULL are MVCC-safe (see "Caveats" in mvcc.sgml): a +# snapshot taken before the rewrite reads exactly the rows it read before, +# whichever way the rewrite reads the table: a sort (cs_e, an expression +# index), an index scan (cs_g: GiST never sorts), the planner's choice (cs_b) +# or a sequential scan (VACUUM FULL). s1 takes its snapshot on another +# table, so it holds no lock on cs and the rewrite runs at once. Before the +# rewrite s2 deletes half the rows, updates every row once or twice, or +# inserts one; s1 must then still read the 20 rows it saw, with their old +# values, by a sequential scan and through the rebuilt index. +# HEAP twin; heap_/flux_cluster_sort_snapshot have identical expected output. +setup +{ + CREATE TABLE cs (k int, v int, p point) USING heap; + INSERT INTO cs SELECT g, g, point(g, g) FROM generate_series(1, 20) g; + CREATE INDEX cs_e ON cs ((-k)); + CREATE INDEX cs_b ON cs (k); + CREATE INDEX cs_g ON cs USING gist (p); + CREATE TABLE cs_other (x int); +} + +teardown +{ + DROP TABLE cs, cs_other; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; } +step s1r { SELECT count(*), min(k), max(k), sum(v) FROM cs; } +step s1ri { + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; +} +step s1c { COMMIT; } + +session s2 +step s2d { DELETE FROM cs WHERE k > 10; } +step s2u { UPDATE cs SET v = v + 100; } +step s2i { INSERT INTO cs VALUES (21, 21, point(21, 21)); } +step s2cle { CLUSTER cs USING cs_e; } +step s2clb { CLUSTER cs USING cs_b; } +step s2clg { CLUSTER cs USING cs_g; } +step s2vf { VACUUM FULL cs; } +step s2vff { VACUUM (FULL, FREEZE) cs; } +step s2v { VACUUM cs; } +step s2flush { SELECT pg_stat_force_next_flush(); } +step s2dead { SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; } +step s2chk { SELECT count(*), min(k), max(k), sum(v) FROM cs; } + +# Deleted rows, each way of reading the table. +permutation s1b s2d s2cle s1r s1ri s1c s2chk +permutation s1b s2d s2clb s1r s1ri s1c s2chk +permutation s1b s2d s2clg s1r s1ri s1c s2chk +permutation s1b s2d s2vf s1r s1ri s1c s2chk +# Rows updated (in place, for FLUX) once or twice, then some deleted. +permutation s1b s2u s2cle s1r s1ri s1c s2chk +permutation s1b s2u s2u s2clg s1r s1ri s1c s2chk +permutation s1b s2u s2d s2vf s1r s1ri s1c s2chk +# A row s1 must not see, and freezing; VACUUM afterwards. +permutation s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +# No rewrite: the control. +permutation s1b s2d s2u s1r s1c s2chk diff --git a/src/test/isolation/specs/heap_fk_crosscheck.spec b/src/test/isolation/specs/heap_fk_crosscheck.spec new file mode 100644 index 0000000000000..663828f5bd624 --- /dev/null +++ b/src/test/isolation/specs/heap_fk_crosscheck.spec @@ -0,0 +1,34 @@ +# heap control for flux_fk_crosscheck.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE pk (a int PRIMARY KEY) USING heap; + CREATE TABLE fkc (a int REFERENCES pk ON DELETE CASCADE ON UPDATE CASCADE) USING heap; + CREATE TABLE fkn (a int REFERENCES pk ON DELETE SET NULL) USING heap; + INSERT INTO pk VALUES (2); +} +teardown +{ + DROP TABLE fkc, fkn, pk; +} + +session s1 +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1ic { INSERT INTO fkc VALUES (2); } +step s1in { INSERT INTO fkn VALUES (2); } +step s1c { COMMIT; } +step s1sel { SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; } + +session s2 +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2brc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2d { DELETE FROM pk WHERE a = 2; } +step s2u { UPDATE pk SET a = 3 WHERE a = 2; } +step s2c { COMMIT; } + +permutation s1brr s1ic s2brr s2d s1c s2c s1sel +permutation s1brr s1in s2brr s2d s1c s2c s1sel +permutation s1brr s1ic s2brr s2u s1c s2c s1sel +# READ COMMITTED has no crosscheck: the action applies to the new child. +permutation s1brr s1ic s2brc s2d s1c s2c s1sel +permutation s1brr s1in s2brc s2d s1c s2c s1sel diff --git a/src/test/isolation/specs/heap_lock_delete_lockers.spec b/src/test/isolation/specs/heap_lock_delete_lockers.spec new file mode 100644 index 0000000000000..823b302fd4a09 --- /dev/null +++ b/src/test/isolation/specs/heap_lock_delete_lockers.spec @@ -0,0 +1,39 @@ +# A DELETE that conflicts with several row lockers must wait for all of them, +# not only the first: here two foreign-key checks hold FOR KEY SHARE on the +# parent row, and one of them rolls back. The DELETE must still wait for the +# other, so its foreign-key check then finds the committed child row. +# HEAP twin of flux_lock_delete_lockers; the expected output is identical. +setup +{ + CREATE TABLE parent (i int PRIMARY KEY) USING heap; + INSERT INTO parent VALUES (1); + CREATE TABLE child (i int REFERENCES parent (i)) USING heap; +} + +teardown +{ + DROP TABLE child, parent; +} + +session s1 +step s1b { BEGIN; } +step s1l { INSERT INTO child VALUES (1); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN; } +step s2l { INSERT INTO child VALUES (1); } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +session s3 +step s3d { DELETE FROM parent; } +step s3chk { SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; } + +# The first locker rolls back; the DELETE must keep waiting for the second. +permutation s1b s1l s2b s2l s3d s1r s2c s3chk +# The second locker rolls back first. +permutation s1b s1l s2b s2l s3d s2r s1c s3chk +# Both roll back: the DELETE goes ahead. +permutation s1b s1l s2b s2l s3d s1r s2r s3chk diff --git a/src/test/isolation/specs/heap_lock_recheck.spec b/src/test/isolation/specs/heap_lock_recheck.spec new file mode 100644 index 0000000000000..7a03302158925 --- /dev/null +++ b/src/test/isolation/specs/heap_lock_recheck.spec @@ -0,0 +1,407 @@ +# heap control for flux_lock_recheck.spec: same steps, same expected output. +# FLUX: a row lock that waits on, or meets, a committed concurrent update must +# report it exactly as heap does. +# +# FLUX updates rows in place at a stable TID, so the latest version of a row +# is always at the TID the caller holds. table_tuple_lock() must still tell +# its callers that the version their snapshot saw was superseded: +# - READ COMMITTED, TUPLE_LOCK_FLAG_FIND_LAST_VERSION: TM_Ok with +# tmfd->traversed = true, so nodeLockRows, GetTupleForTrigger and +# ri_LockPKTuple re-check the latest version (EvalPlanQual, FK key recheck); +# - REPEATABLE READ: TM_Updated, so the caller raises "could not serialize +# access due to concurrent update"; +# - ON CONFLICT (flags 0) under READ COMMITTED: TM_Ok on the latest version, +# because heap's retry would find and lock that same row. +# A FOR KEY SHARE lock is compatible with a non-key UPDATE and, as in heap, +# neither waits nor reports it. +# +# The expected output is heap's: heap_lock_recheck.spec is this spec with +# USING heap, and both share the same expected .out. + +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO lr VALUES (1, 1, 0), (2, 1, 0); + CREATE TABLE lrp (id int PRIMARY KEY, note int) USING heap; + INSERT INTO lrp VALUES (1, 0); + CREATE TABLE lrc (id int, pid int REFERENCES lrp) USING heap; + CREATE TABLE lrt (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO lrt VALUES (1, 1, 0); + CREATE FUNCTION lrt_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'lrt_bu: OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NEW; END $$; + CREATE TRIGGER lrt_bu BEFORE UPDATE ON lrt FOR EACH ROW EXECUTE FUNCTION lrt_bu(); + -- A BEFORE UPDATE OR DELETE trigger that modifies NEW. + CREATE TABLE lrn (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO lrn VALUES (1, 1, 0); + CREATE FUNCTION lrn_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE 'lrn_bu %: OLD=% NEW=%', TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER lrn_bu BEFORE UPDATE OR DELETE ON lrn FOR EACH ROW EXECUTE FUNCTION lrn_bu(); + -- Partitioned by v, for cross-partition UPDATE; lptt has a trigger. + CREATE TABLE lpt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lpt1 PARTITION OF lpt FOR VALUES IN (1) USING heap; + CREATE TABLE lpt2 PARTITION OF lpt FOR VALUES IN (2) USING heap; + INSERT INTO lpt VALUES (1, 1, 0); + CREATE TABLE lptt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lptt1 PARTITION OF lptt FOR VALUES IN (1) USING heap; + CREATE TABLE lptt2 PARTITION OF lptt FOR VALUES IN (2) USING heap; + INSERT INTO lptt VALUES (1, 1, 0); + CREATE FUNCTION lr_note() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE '% % %: OLD=% NEW=%', TG_TABLE_NAME, TG_WHEN, TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END $$; + CREATE TRIGGER lr_note BEFORE UPDATE OR DELETE ON lptt FOR EACH ROW EXECUTE FUNCTION lr_note(); + -- FK with ON DELETE CASCADE: the RI action deletes a child row that a + -- concurrent transaction has updated. + CREATE TABLE lrq (id int PRIMARY KEY, note int) USING heap; + INSERT INTO lrq VALUES (1, 0); + CREATE TABLE lrqc (id int PRIMARY KEY, pid int REFERENCES lrq ON DELETE CASCADE, w int) USING heap; + INSERT INTO lrqc VALUES (1, 1, 0); + CREATE TABLE lrup (id int PRIMARY KEY) USING heap; + INSERT INTO lrup VALUES (1); + CREATE TABLE lruc (pid int REFERENCES lrup ON UPDATE CASCADE) USING heap; + CREATE TABLE lrrp (id int PRIMARY KEY) USING heap; + INSERT INTO lrrp VALUES (1); + CREATE TABLE lrrc (pid int REFERENCES lrrp ON UPDATE RESTRICT) USING heap; + CREATE TABLE lrmp (a int, b int, PRIMARY KEY (a, b)) USING heap; + INSERT INTO lrmp VALUES (1, 1); + CREATE TABLE lrmc (a int, b int, FOREIGN KEY (a, b) REFERENCES lrmp) USING heap; + CREATE TABLE lrvp (id int, note int) USING heap; + CREATE UNIQUE INDEX lrvp_unique ON lrvp (id); + INSERT INTO lrvp VALUES (1, 0); + CREATE TABLE lrvc (pid int REFERENCES lrvp (id)) USING heap; +} + +teardown +{ + DROP TABLE lruc, lrup, lrrc, lrrp, lrmc, lrmp, lrvc, lrvp; + DROP TABLE lr, lrc, lrp, lrt, lrn, lpt, lptt, lrqc, lrq; + DROP FUNCTION lrt_bu(), lrn_bu(), lr_note(); +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT key_update; } +step s1rb { ROLLBACK TO key_update; } +step s1uk { UPDATE lrup SET id = 2 WHERE id = 1; } +step s1rk { UPDATE lrrp SET id = 2 WHERE id = 1; } +step s1mk { UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; } +step s1vk { UPDATE lrvp SET id = 2 WHERE id = 1; } +step s1vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s1u { UPDATE lr SET v = 2 WHERE id = 1; } +step s1k { UPDATE lr SET id = 10 WHERE id = 1; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1pk { UPDATE lrp SET id = 2 WHERE id = 1; } +step s1pfu { SELECT id FROM lrp WHERE id = 1 FOR UPDATE; } +step s1pn { UPDATE lrp SET note = 1 WHERE id = 1; } +step s1tu { UPDATE lrt SET v = 2 WHERE id = 1; } +step s1lfu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1tfu { SELECT * FROM lrt WHERE id = 1 FOR UPDATE; } +step s1tk { UPDATE lrt SET id = 10 WHERE id = 1; } +step s1tu2 { UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; } +step s1nu { UPDATE lrn SET v = 2 WHERE id = 1; } +step s1pu { UPDATE lpt SET w = 5 WHERE id = 1; } +step s1ptu { UPDATE lptt SET w = 5 WHERE id = 1; } +# session_replication_role = replica skips lrqc's own FK check trigger, which +# FLUX cannot run yet on UPDATE (it reads xmin from the old row's slot). +step s1qcu { SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2a { ROLLBACK; } +step s2uci { INSERT INTO lruc VALUES (1); } +step s2rci { INSERT INTO lrrc VALUES (1); } +step s2mci { INSERT INTO lrmc VALUES (1, 1); } +step s2vci { INSERT INTO lrvc VALUES (1); } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT count(*) FROM lr; } +step s2fu { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; } +step s2fnk { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; } +step s2fks { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; } +step s2sl { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; } +step s2nw { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; } +step s2upd { UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2ci { INSERT INTO lrc VALUES (1, 1); } +step s2tu { UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2oc { INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; } +# The same locked row in several output rows: each is re-checked. +step s2jfu { SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; } +# MERGE with a BEFORE UPDATE trigger: GetTupleForTrigger reports the +# concurrent update, MERGE re-checks the join and either drops the row or +# updates the latest version, and must not loop. +step s2mq { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +step s2mu { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +# Parent-row locks after several updates (FOR KEY SHARE must see a key change +# by any of them, not only the latest). A cursor fixes the READ COMMITTED +# snapshot before the updates. +step s2pks { SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2pnk { SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; } +step s2dks { DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2dka { DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; } +step s2dnk { DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; } +step s2fc { FETCH ALL FROM c; } +step s2pks101 { SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; } +step s2ci101 { INSERT INTO lrc VALUES (2, 101); } +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger: the trigger locks the +# row ON CONFLICT already locked. +step s2toc { INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; } +step s2noc { INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; } +# MERGE without a trigger (lr) and with one that modifies NEW (lrn). +step s2mlu { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mlq { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mld { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; } +step s2mnu { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnq { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnd { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; } +# Cross-partition UPDATE (ExecCrossPartitionUpdate), and MERGE moving the row. +step s2pm { UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2pmq { UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2ptm { UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2ptmq { UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2mpm { MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; } +step s2mptm { MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; } +# RI cascade action on a child row updated concurrently. +step s2qd { DELETE FROM lrq WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3orphans { SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); } +step s3u { SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); } +step s3restrict { SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); } +step s3multi { SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); } +step s3unique { SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); } +step s3r { SELECT * FROM lr ORDER BY id; } +step s3c { SELECT * FROM lrc ORDER BY id; } +step s3t { SELECT * FROM lrt ORDER BY id; } +step s3p { SELECT * FROM lrp ORDER BY id; } +step s3n { SELECT * FROM lrn ORDER BY id; } +step s3pt { SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; } +step s3q { SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; } + +# Further parent updates, each committed on its own (autocommit) or held open. +# lrp starts as (1, 0). +session s4 +step s4vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s4pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s4pkb { UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; } +step s4pn101 { UPDATE lrp SET note = note + 10 WHERE id = 101; } +step s4pn1 { UPDATE lrp SET note = note + 10 WHERE id = 1; } + +session s5 +step s5b { BEGIN; } +step s5pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s5pn101 { UPDATE lrp SET note = note + 100 WHERE id = 101; } +step s5pn1 { UPDATE lrp SET note = note + 100 WHERE id = 1; } +step s5c { COMMIT; } +step s5a { ROLLBACK; } + +# READ COMMITTED, each lock strength that conflicts with the UPDATE waits for +# it, then re-checks v = 1 against the committed version: row 1 drops out. +permutation s1b s1u s2rc s2fu s1c s2c s3r +permutation s1b s1u s2rc s2fnk s1c s2c s3r +permutation s1b s1u s2rc s2fs s1c s2c s3r +# FOR KEY SHARE does not conflict with a non-key UPDATE: no wait, no recheck. +permutation s1b s1u s2rc s2fks s1c s2c s3r +# FOR KEY SHARE waits for a key UPDATE. +permutation s1b s1k s2rc s2fks s1c s2c s3r +# SKIP LOCKED skips the row being updated; NOWAIT fails at once. +permutation s1b s1u s2rc s2sl s1c s2c s3r +permutation s1b s1u s2rc s2nw s1c s2c s3r +# UPDATE ... WHERE v = 1 re-checks through EvalPlanQual: only row 2 updated. +permutation s1b s1u s2rc s2upd s1c s2c s3r +# DELETE racing FOR UPDATE: the deleted row is not returned. +permutation s1b s1d s2rc s2fu s1c s2c s3r +# The updater rolls back: the original row is locked and returned. +permutation s1b s1u s2rc s2fu s1a s2c s3r +permutation s1b s1u s2rc s2upd s1a s2c s3r +# REPEATABLE READ: an update committed after the snapshot is a serialization +# failure, whether the locker waited for it or met it already committed. +permutation s2rr s2snap s1b s1u s2fu s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fu s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fnk s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fs s2c s3r +permutation s2rr s2snap s1b s1u s1c s2sl s2c s3r +permutation s2rr s2snap s1b s1u s1c s2nw s2c s3r +permutation s2rr s2snap s1b s1u s1c s2upd s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed non-key UPDATE: no failure. +permutation s2rr s2snap s1b s1u s1c s2fks s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed key UPDATE: failure. +permutation s2rr s2snap s1b s1k s1c s2fks s2c s3r +# REPEATABLE READ, the updater rolls back while the locker waits: no failure. +permutation s2rr s2snap s1b s1u s2fu s1a s2c s3r +# REPEATABLE READ, a committed DELETE: "concurrent delete". +permutation s2rr s2snap s1b s1d s2fu s1c s2c s3r +# FK check (ri_LockPKTuple): the parent key changes while the child insert +# waits, so the insert fails. +permutation s1b s1pk s2rc s2ci s1c s2c s3c +# FK check: a non-key parent UPDATE commits while the child insert waits on +# FOR UPDATE; the key still matches, so the insert succeeds. +permutation s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +# FK check: the parent key update rolls back; the insert succeeds. +permutation s1b s1pk s2rc s2ci s1a s2c s3c +# GetTupleForTrigger: the BEFORE UPDATE trigger row is locked after a +# concurrent update; EvalPlanQual drops it, so neither trigger nor UPDATE runs. +permutation s1b s1tu s2rc s2tu s1c s2c s3t +# ... and after a rollback the original row is updated. +permutation s1b s1tu s2rc s2tu s1a s2c s3t +# GetTupleForTrigger under REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1tu s2tu s1c s2c s3t +# A join repeats row 1: every repetition is re-checked and dropped. +permutation s1b s1u s2rc s2jfu s1c s2c s3r +# MERGE + BEFORE UPDATE trigger after a concurrent committed update. +permutation s1b s1tu s2rc s2mq s1c s2c s3t +permutation s1b s1tu s2rc s2mu s1c s2c s3t +permutation s2rr s2snap s1b s1tu s2mu s1c s2c s3t +# ON CONFLICT DO UPDATE (flags 0), READ COMMITTED: waits, then updates the +# committed version, without looping. +permutation s1b s1u s2rc s2oc s1c s2c s3r +# ON CONFLICT DO UPDATE, REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1u s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2oc s2c s3r + +# Two updates committed after the locker's snapshot: a key change, then a +# non-key change. The key change conflicts with FOR KEY SHARE although the +# latest update left the key alone. REPEATABLE READ: serialization failure, +# for the lock and for the FK check. +permutation s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +permutation s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +# READ COMMITTED: the lock moves to the latest version and re-checks it: the +# row no longer has id = 1 and drops out, a query without that qual returns +# the latest version, and the FK insert fails because the key has gone. +permutation s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s4pkk s4pn101 s2ci s2c s3c +# The other order: non-key change, then key change (the latest). +permutation s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +permutation s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +# Two non-key changes: FOR KEY SHARE conflicts with neither. +permutation s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +# Three updates: key change, key changed back, non-key change. The key +# changes conflict although the key ends where it started. +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +permutation s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +# Three updates: key change, then two non-key changes. +permutation s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +permutation s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +# One committed, one in progress: committed key change, in-progress non-key +# change. REPEATABLE READ fails at once, without waiting for the in-progress +# update, for FOR KEY SHARE and FOR NO KEY UPDATE alike; READ COMMITTED waits +# for it, then locks the latest version. +permutation s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +permutation s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +permutation s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +permutation s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +permutation s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +# ... committed non-key change, in-progress key change: FOR KEY SHARE waits, +# then fails (commit) or locks the snapshot's version (rollback). +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +# Both in progress in one transaction: key change, then non-key change. The +# FK check must wait for the key change, then fail. +permutation s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +permutation s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +# Committed non-key change, in-progress non-key change: FOR KEY SHARE +# conflicts with neither and does not wait. +permutation s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +permutation s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +# A key change the snapshot sees, then a non-key change it does not: the +# second update did not change the key, whatever the first one did. +permutation s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +permutation s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +# A key change that rolled back, then a committed non-key change: no conflict. +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +# FOR NO KEY UPDATE against the same histories: it conflicts with every +# update, key or not, committed or in progress. +permutation s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +permutation s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p + +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger, READ COMMITTED, against +# a row updated and committed after the snapshot: ON CONFLICT locks the latest +# version and the trigger's lock of the same row reports nothing, as in heap +# (no EvalPlanQual: ON CONFLICT has none set up). Also with the row updated +# twice, with a trigger that modifies NEW, after a rollback, and under +# REPEATABLE READ. +permutation s1b s1tu s2rc s2toc s1c s2c s3t +permutation s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +permutation s1b s1tu s2rc s2toc s1a s2c s3t +permutation s2rr s2snap s1b s1tu s2toc s1c s2c s3t +permutation s1b s1nu s2rc s2noc s1c s2c s3n +permutation s1b s1nu s2rc s2noc s1a s2c s3n +# ON CONFLICT against a key update by a FOR UPDATE locker ON CONFLICT was +# waiting for: the row no longer conflicts, so the INSERT goes ahead. +permutation s1b s1lfu s2rc s2oc s1k s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +# ... and against a non-key update from that locker: the new version is +# updated (and its trigger fired once); a lock only: the row is updated. +permutation s1b s1lfu s2rc s2oc s1u s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +permutation s1b s1lfu s2rc s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +# MERGE WHEN MATCHED UPDATE/DELETE after a concurrent committed update, +# without a trigger and with a trigger that modifies NEW. +permutation s1b s1u s2rc s2mlu s1c s2c s3r +permutation s1b s1u s2rc s2mlq s1c s2c s3r +permutation s1b s1u s2rc s2mld s1c s2c s3r +permutation s1b s1nu s2rc s2mnu s1c s2c s3n +permutation s1b s1nu s2rc s2mnq s1c s2c s3n +permutation s1b s1nu s2rc s2mnd s1c s2c s3n +permutation s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +# Cross-partition UPDATE after a concurrent committed update, without and +# with BEFORE UPDATE/DELETE triggers, by UPDATE and by MERGE. +permutation s1b s1pu s2rc s2pm s1c s2c s3pt +permutation s1b s1pu s2rc s2pmq s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptm s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptmq s1c s2c s3pt +permutation s1b s1pu s2rc s2mpm s1c s2c s3pt +permutation s1b s1ptu s2rc s2mptm s1c s2c s3pt +permutation s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +# RI ON DELETE CASCADE against a child row updated concurrently. +permutation s1b s1qcu s2rc s2qd s1c s2c s3q + +# Lock first, UPDATE second: key changes conflict with all four strengths, +# but a non-key change remains compatible with KEY SHARE (including CAS). +permutation s2rc s2fks s1b s1k s2c s1c s3r +permutation s2rc s2fks s1b s1u s1c s2c s3r +permutation s2rc s2fs s1b s1k s2c s1c s3r +permutation s2rc s2fnk s1b s1k s2c s1c s3r +permutation s2rc s2fu s1b s1k s2c s1c s3r +# FK insertion holds KEY SHARE until commit. The parent UPDATE must wait +# and then check the newly committed child; an aborted child permits it. +permutation s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +permutation s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +permutation s2rc s2uci s1b s1uk s2c s1c s3u +permutation s2rc s2rci s1b s1rk s2c s1c s3restrict +# Subtransaction rollback restores the parent and releases its key lock. +permutation s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +permutation s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +# A single component change, and a standalone unique index used by an FK. +permutation s2rc s2mci s1b s1mk s2c s1c s3multi +permutation s2rc s2vci s1b s1vk s2c s1c s3unique +permutation s2rc s2vci s1b s1vn s1c s2c s3unique + +# A compatible non-key update clears the on-page lock flag, not the FK +# lock in sLog. The same-size unique-key update can now reach CAS. +permutation s2rc s2vci s4vn s1b s1vk s2c s1c s3unique diff --git a/src/test/isolation/specs/heap_lock_update_lockers.spec b/src/test/isolation/specs/heap_lock_update_lockers.spec new file mode 100644 index 0000000000000..ab6a47d80a6da --- /dev/null +++ b/src/test/isolation/specs/heap_lock_update_lockers.spec @@ -0,0 +1,32 @@ +# An UPDATE that conflicts with several row lockers must wait for all of +# them: two sessions hold FOR SHARE on the row, the first rolls back, and the +# UPDATE must keep waiting for the second instead of overwriting the row it +# still has locked. HEAP twin; the expected output is identical. +setup +{ + CREATE TABLE lu (id int PRIMARY KEY, v int) USING heap; + INSERT INTO lu VALUES (1, 0); +} + +teardown +{ + DROP TABLE lu; +} + +session s1 +step s1b { BEGIN; } +step s1l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s2chk { SELECT v FROM lu WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3u { UPDATE lu SET v = v + 1 WHERE id = 1; } +step s3chk { SELECT v FROM lu WHERE id = 1; } + +# s3 must still be waiting after s1 rolls back; s2 still sees v = 0. +permutation s1b s1l s2b s2l s3u s1r s2chk s2c s3chk diff --git a/src/test/isolation/specs/heap_lock_upgrade_queue.spec b/src/test/isolation/specs/heap_lock_upgrade_queue.spec new file mode 100644 index 0000000000000..7ec4de6119a40 --- /dev/null +++ b/src/test/isolation/specs/heap_lock_upgrade_queue.spec @@ -0,0 +1,42 @@ +# heap control for flux_lock_upgrade_queue.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE luq (id int PRIMARY KEY, v int) USING heap; + INSERT INTO luq VALUES (1, 0); +} +teardown +{ + DROP TABLE luq; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT id FROM luq FOR KEY SHARE; } +step s1sh { SELECT id FROM luq FOR SHARE; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1r { ROLLBACK; } + +session s2 +setup { BEGIN; } +step s2ks { SELECT id FROM luq FOR KEY SHARE; } +step s2nk { SELECT id FROM luq FOR NO KEY UPDATE; } +step s2fu { SELECT id FROM luq FOR UPDATE; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3ks { SELECT id FROM luq FOR KEY SHARE; } +step s3sh { SELECT id FROM luq FOR SHARE; } +step s3fu { SELECT id FROM luq FOR UPDATE; } +step s3d { DELETE FROM luq RETURNING id; } +step s3c { COMMIT; } + +# s2 upgrades past a lock s1 took in a savepoint while s3 waits for s1: s2 +# wakes at ROLLBACK TO, then s3 waits for s2. +permutation s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +# s3 upgrades its SHARE lock while s2 waits for s1. +permutation s1sh s2fu s3sh s3fu s1r s3c s2c +# s3 deletes the row it holds a KEY SHARE lock on while s2 waits for s1. +permutation s1ks s2fu s3ks s3d s1r s3c s2c diff --git a/src/test/isolation/specs/heap_nonkey_move.spec b/src/test/isolation/specs/heap_nonkey_move.spec new file mode 100644 index 0000000000000..5fa04ffa53558 --- /dev/null +++ b/src/test/isolation/specs/heap_nonkey_move.spec @@ -0,0 +1,87 @@ +# Non-key updates that move a row must remain compatible with KEY SHARE. +# Heap and FLUX twins have identical expected output. +setup +{ + CREATE TABLE fi (id int, v int, PRIMARY KEY (id) INCLUDE (v)) USING heap; + CREATE TABLE fg (id int PRIMARY KEY, v int, pad text) USING heap WITH (fillfactor=100); + ALTER TABLE fg ALTER COLUMN pad SET STORAGE PLAIN; + INSERT INTO fi VALUES (1, 1); + INSERT INTO fg SELECT g, 1, repeat('x', 640) FROM generate_series(1, 40) g; + CREATE TABLE ci (id int REFERENCES fi) USING heap; + CREATE TABLE cg (id int REFERENCES fg) USING heap; +} +teardown { DROP TABLE ci, cg, fi, fg; } + +session s1 +step rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step snap { SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; } +step ki { SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; } +step kg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; } +step ii { INSERT INTO ci VALUES (1); } +step ig { INSERT INTO cg VALUES (1); } +step si { SELECT id, v FROM fi WHERE id=1 FOR SHARE; } +step sg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; } +step di { DELETE FROM fi WHERE id=1; } +step dg { DELETE FROM fg WHERE id=1; } +step wi { UPDATE fi SET v=v+1 WHERE id=1; } +step wg { UPDATE fg SET v=v+1 WHERE id=1; } +step c1 { COMMIT; } + +session s2 +step b2 { BEGIN; } +step ui { UPDATE fi SET v=2 WHERE id=1; } +step ug { UPDATE fg SET pad=repeat('y',1920) WHERE id=1; } +step c2 { COMMIT; } +step r2 { ROLLBACK; } +step d2i { DELETE FROM fi WHERE id=1; } +step n2i { UPDATE fi SET v=3 WHERE id=1; } +step n2g { UPDATE fg SET pad=repeat('z',1940) WHERE id=1; } +step k2i { UPDATE fi SET id=2 WHERE id=1; } +step d2g { DELETE FROM fg WHERE id=1; } +step k2g { UPDATE fg SET id=100 WHERE id=1; } + +session s3 +step b3 { BEGIN; } +step u3 { UPDATE fi SET v=3 WHERE id=1; } +step k3 { UPDATE fi SET id=2 WHERE id=1; } +step z3 { UPDATE fi SET id=1 WHERE id=2; } +step c3 { COMMIT; } +permutation rr snap b2 ui ki c2 c1 +permutation rr snap b2 ui ii c2 c1 +permutation rr snap b2 ui si c2 c1 +permutation rr snap b2 ui di c2 c1 +permutation rr snap b2 ui wi c2 c1 +permutation rr snap b2 ug kg c2 c1 +permutation rr snap b2 ug ig c2 c1 +permutation rr snap b2 ug sg c2 c1 +permutation rr snap b2 ug dg c2 c1 +permutation rr snap b2 ug wg c2 c1 +permutation rc snap b2 ui ki c2 c1 +permutation rc snap b2 ui ii c2 c1 +permutation rc snap b2 ui si c2 c1 +permutation rc snap b2 ui di c2 c1 +permutation rc snap b2 ui wi c2 c1 +permutation rc snap b2 ug kg c2 c1 +permutation rc snap b2 ug ig c2 c1 +permutation rc snap b2 ug sg c2 c1 +permutation rc snap b2 ug dg c2 c1 +permutation rc snap b2 ug wg c2 c1 +permutation rr snap b2 ui ki c2 d2i c1 +permutation rr snap b2 ui ki c2 k2i c1 +permutation rr snap b2 ug kg c2 d2g c1 +permutation rr snap b2 ug kg c2 k2g c1 +permutation rr snap b2 ui ki r2 c1 +permutation rr snap b2 ui c2 ki c1 +permutation rr snap b2 ug kg r2 c1 +permutation rr snap b2 ug c2 kg c1 + +permutation rr snap b2 ui ki c2 n2i c1 +permutation rr snap b2 ug kg c2 n2g c1 + +permutation rr snap b2 ui c2 b3 k3 c3 ki c1 +permutation rc snap b2 ui c2 b3 k3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 ki c3 c1 +permutation rr snap b2 ui c2 b3 u3 ki c3 c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 diff --git a/src/test/isolation/specs/heap_outofplace_grow.spec b/src/test/isolation/specs/heap_outofplace_grow.spec new file mode 100644 index 0000000000000..ae627d687fc70 --- /dev/null +++ b/src/test/isolation/specs/heap_outofplace_grow.spec @@ -0,0 +1,30 @@ +# heap control for flux_outofplace_grow.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE xg (id int, n int, p1 bigint, p2 bigint, p3 bigint, p4 bigint, + p5 bigint, p6 bigint, p7 bigint, p8 bigint) + USING heap WITH (fillfactor = 100); + INSERT INTO xg (id, n) SELECT g, 0 FROM generate_series(1, 400) g; +} +teardown +{ + DROP TABLE xg; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1g { UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2u { UPDATE xg SET n = n + 1 WHERE id = 1; } +step s2c { COMMIT; } + +permutation s1b s2b s1g s2u s1c s2c s1sel +permutation s1b s2b s1g s2u s1r s2c s1sel diff --git a/src/test/isolation/specs/heap_outofplace_update.spec b/src/test/isolation/specs/heap_outofplace_update.spec new file mode 100644 index 0000000000000..95b169c6e0736 --- /dev/null +++ b/src/test/isolation/specs/heap_outofplace_update.spec @@ -0,0 +1,58 @@ +# heap control for flux_outofplace_update.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE xt (id int, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING heap; + INSERT INTO xt VALUES (1, '[1,10)', 0); + CREATE TABLE xp (id int4range NOT NULL, valid_at daterange NOT NULL, + PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)) USING heap; + CREATE TABLE xc (id int4range NOT NULL, valid_at daterange NOT NULL, + parent_id int4range, + FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES xp) + USING heap; + INSERT INTO xp VALUES ('[1,2)', '[2020-01-01,2030-01-01)'); +} +teardown +{ + DROP TABLE xc, xp, xt; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s1uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s1up { UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, v, r, count(*) OVER () AS nrows FROM xt; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT id, v, r FROM xt; } +step s2u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s2uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s2d { DELETE FROM xt WHERE id = 1; } +step s2fu { SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; } +step s2ins { INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); } +step s2c { COMMIT; } + +# (a) READ COMMITTED: the waiter acts on the new version; no update is lost. +permutation s1b s2b s1u s2u s1c s2c s1sel +permutation s1b s2b s1uk s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2u s1c s2c s1sel +permutation s1b s2b s1u s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2d s1c s2c s1sel +# The move rolls back: the waiter updates the old version. +permutation s1b s2b s1uk s2u s1r s2c s1sel +permutation s1b s2b s1uk s2uk s1r s2c s1sel +# (b) REPEATABLE READ: a serialization failure for a concurrent update. +permutation s1b s2brr s1uk s2u s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2d s2c s1sel +# (c) The FK check follows the parent row to its new version. +permutation s1b s2b s1up s2ins s1c s2c +permutation s1b s2brr s1up s2ins s1c s2c +# (d) FOR UPDATE locks the new version, or fails under REPEATABLE READ. +permutation s1b s2b s1uk s2fu s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2fu s2c diff --git a/src/test/isolation/specs/heap_partition_move.spec b/src/test/isolation/specs/heap_partition_move.spec new file mode 100644 index 0000000000000..15a1a0539be45 --- /dev/null +++ b/src/test/isolation/specs/heap_partition_move.spec @@ -0,0 +1,59 @@ +# heap control for flux_partition_move.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE pm (a int, b text) PARTITION BY LIST (a); + CREATE TABLE pm1 PARTITION OF pm FOR VALUES IN (1) USING heap; + CREATE TABLE pm2 PARTITION OF pm FOR VALUES IN (2) USING heap; + INSERT INTO pm VALUES (1, 'ABC'); + CREATE TABLE pr (a int, b text) PARTITION BY RANGE (a); + CREATE TABLE pr1 PARTITION OF pr FOR VALUES FROM (1) TO (10) USING heap; + CREATE TABLE pr2 PARTITION OF pr FOR VALUES FROM (10) TO (20) USING heap; + INSERT INTO pr VALUES (7, 'ABC'); + CREATE UNIQUE INDEX pr1_a ON pr1 (a); + CREATE TABLE prc (a int REFERENCES pr1 (a)) USING heap; +} +teardown +{ + DROP TABLE prc, pr, pm; +} + +session s1 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE pm SET a = 2 WHERE a = 1; } +step s1sp { SAVEPOINT sp; } +step s1rs { ROLLBACK TO sp; } +step s1upr { UPDATE pr SET a = 11 WHERE a = 7; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT * FROM pm ORDER BY a; } +step s2u { UPDATE pm SET b = 'EFG' WHERE a = 1; } +step s2d { DELETE FROM pm WHERE a = 1; } +step s2l { SELECT * FROM pm WHERE a = 1 FOR UPDATE; } +step s2ks { SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; } +step s2m { MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; } +step s2i { INSERT INTO prc VALUES (7); } +step s2c { COMMIT; } + +# The move commits: each write or lock of the old row fails. +permutation s2b s1u s2d s1c s2sel s2c +permutation s2b s1u s2u s1c s2sel s2c +permutation s2b s1u s2l s1c s2c +permutation s2b s1u s2ks s1c s2c +permutation s2b s1u s2m s1c s2sel s2c +permutation s2b s1upr s2i s1c s2c +# Under REPEATABLE READ the row was concurrently updated. +permutation s2brr s2sel s1u s1c s2d s2c +permutation s2brr s2sel s1u s1c s2l s2c +# The move rolls back: the old row is still there, lockable and updatable. +permutation s2b s1u s2u s1r s2sel s2c +permutation s2b s1u s2d s1r s2sel s2c +permutation s2b s1u s2l s1r s2c +permutation s2b s1upr s2i s1r s2c +permutation s1sp s1u s1rs s2b s2u s2sel s2c s1c +permutation s2b s1u s1r s2l s2u s2sel s2c diff --git a/src/test/isolation/specs/heap_recheck_after_wait.spec b/src/test/isolation/specs/heap_recheck_after_wait.spec new file mode 100644 index 0000000000000..392b03a1f0b75 --- /dev/null +++ b/src/test/isolation/specs/heap_recheck_after_wait.spec @@ -0,0 +1,46 @@ +# heap control for flux_recheck_after_wait.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE rw (id int PRIMARY KEY, v int) USING heap; + INSERT INTO rw VALUES (1, 0); +} +teardown +{ + DROP TABLE rw; +} + +session s1 +setup { BEGIN; } +step s1sh { SELECT id FROM rw FOR SHARE; } +step s1u { UPDATE rw SET v = 5; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2d { DELETE FROM rw RETURNING id, v; } +step s2r { ROLLBACK; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3d { DELETE FROM rw WHERE v = 0 RETURNING id, v; } +step s3u { UPDATE rw SET v = v + 10 RETURNING id, v; } +step s3c { COMMIT; } + +session s4 +step s4sel { SELECT id, v FROM rw; } + +# s2 waits for s1's row lock or update, s3 waits behind s2; s1 rolls back +# and s2 deletes. s3 must wait for s2, which rolls back: s3 deletes or +# updates the row. +permutation s1sh s2d s3d s1r s2r s3c s4sel +permutation s1sh s2d s3u s1r s2r s3c s4sel +permutation s1u s2d s3d s1r s2r s3c s4sel +permutation s1u s2d s3u s1r s2r s3c s4sel +# s2 commits its delete: s3 finds no row. +permutation s1sh s2d s3d s1r s2c s3c s4sel +# s3 waits for s1's row lock; s1 updates the row so that it no longer +# matches s3's WHERE, and commits: s3 deletes nothing. +permutation s1sh s3d s1u s1c s3c s4sel diff --git a/src/test/isolation/specs/heap_savepoint_lock_rollback.spec b/src/test/isolation/specs/heap_savepoint_lock_rollback.spec new file mode 100644 index 0000000000000..235b577ba6a05 --- /dev/null +++ b/src/test/isolation/specs/heap_savepoint_lock_rollback.spec @@ -0,0 +1,113 @@ +# heap control for flux_savepoint_lock_rollback.spec, which runs the same +# steps on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int) USING heap; + INSERT INTO lr VALUES (1, 0), (2, 0); +} +setup +{ + VACUUM lr; +} +teardown +{ + DROP TABLE lr; +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1fu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1fnk { SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; } +step s1fs { SELECT * FROM lr WHERE id = 1 FOR SHARE; } +step s1fks { SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; } +step s1u { UPDATE lr SET v = v + 10 WHERE id = 1; } +step s1i { INSERT INTO lr VALUES (3, 0); } +step s1fu3 { SELECT * FROM lr WHERE id = 3 FOR UPDATE; } +step s1u3 { UPDATE lr SET v = v + 10 WHERE id = 3; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel { RELEASE a; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2u { UPDATE lr SET v = v + 1 WHERE id = 1; } +step s2d { DELETE FROM lr WHERE id = 1; } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +# Every way of reaching the rows must find the same rows. +session s3 +step s3chk +{ + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; +} + +# Each lock strength, in a savepoint that is rolled back, then a committed +# UPDATE of the row by s2. +permutation s1b s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1sp s1fnk s1rs s2u s1c s3chk +permutation s1b s1sp s1fs s1rs s2u s1c s3chk +permutation s1b s1sp s1fks s1rs s2u s1c s3chk +# Nested savepoints: both rolled back; only the inner one, the outer +# released. +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +# RELEASE instead of ROLLBACK TO: the lock is s1's until it commits. +permutation s1b s1sp s1fu s1rel s2u s1c s3chk +# s2 deletes the row instead. +permutation s1b s1sp s1fu s1rs s2d s1c s3chk +# s2 aborts its UPDATE before s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +# s2's UPDATE is still running when s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +# s1 rolls back instead, with the lock in a savepoint or at the top level. +permutation s1b s1sp s1fu s1rs s2u s1r s3chk +permutation s1b s1fu s1r s2u s3chk +# A row change, not a lock, in the rolled-back savepoint, then s2's +# committed UPDATE. +permutation s1b s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1sp s1d s1rs s2u s1c s3chk +# ... and s2's UPDATE still running when s1 commits. +permutation s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +permutation s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +# A top-level lock survives the same or a weaker lock in a rolled-back +# savepoint: s2 waits for s1. +permutation s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +permutation s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +# ... and a weaker lock at the same level does not give the stronger one up. +permutation s1b s1fu s1fks s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +# ... nor does a row change in a rolled-back savepoint. +permutation s1b s1fu s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1u s1rs s2d s1c s3chk +# s1's own top-level UPDATE of the row, then the rolled-back lock or +# UPDATE. s1 still holds its UPDATE's row lock, so s2 waits for s1. +permutation s1b s1u s1sp s1fu s1rs s1c s3chk +permutation s1b s1u s1sp s1u s1rs s1c s3chk +permutation s1b s1u s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1u s1sp s1fks s1rs s2u s1r s3chk +# s1's own top-level INSERT, locked in a rolled-back savepoint: kept. An +# INSERT in a rolled-back savepoint: discarded. +permutation s1b s1i s1sp s1fu3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rs s1c s3chk +# An INSERT kept (top level, or in a released savepoint), then updated in a +# rolled-back savepoint: kept, with its first value. +permutation s1b s1i s1sp s1u3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk + +# A rolled-back upgrade releases only the savepoint's stronger lock. +permutation s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk diff --git a/src/test/isolation/specs/heap_savepoint_lock_wake.spec b/src/test/isolation/specs/heap_savepoint_lock_wake.spec new file mode 100644 index 0000000000000..f1a756f8fdcfe --- /dev/null +++ b/src/test/isolation/specs/heap_savepoint_lock_wake.spec @@ -0,0 +1,44 @@ +# heap control for flux_savepoint_lock_wake.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE lw (id int PRIMARY KEY, v int) USING heap; + INSERT INTO lw VALUES (1, 0); +} +teardown +{ + DROP TABLE lw; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT * FROM lw FOR KEY SHARE; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s1fu { SELECT * FROM lw FOR UPDATE; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel2 { RELEASE b; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lw FOR SHARE; } +step s2u { UPDATE lw SET v = v + 1 RETURNING *; } +step s2d { DELETE FROM lw RETURNING *; } +step s2c { COMMIT; } + +# The waiter wakes at ROLLBACK TO: a lock upgrade, and a lock with nothing +# at the top level. +permutation s1ks s1sp s1nk s2nk s1rs s1c s2c +permutation s1ks s1sp s1nk s2u s1rs s1c s2c +permutation s1ks s1sp s1fu s2fs s1rs s1c s2c +permutation s1sp s1fu s2u s1rs s1c s2c +# Nested savepoints: the inner lock released into the outer savepoint, +# which is then rolled back; and only the inner savepoint rolled back. +permutation s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +permutation s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +# The top-level KEY SHARE lock still blocks a DELETE until commit. +permutation s1ks s1sp s1nk s2d s1rs s1c s2c diff --git a/src/test/isolation/specs/heap_seqscan_stats.spec b/src/test/isolation/specs/heap_seqscan_stats.spec new file mode 100644 index 0000000000000..6d5389f09c3a5 --- /dev/null +++ b/src/test/isolation/specs/heap_seqscan_stats.spec @@ -0,0 +1,23 @@ +# heap control for flux_seqscan_stats.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE ss (k int, v int) USING heap; + INSERT INTO ss SELECT g, g FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE ss; +} + +session s1 +setup { SET enable_indexscan = off; SET enable_bitmapscan = off; } +step s1scan { SELECT count(*) FROM ss; } +step s1where { SELECT k FROM ss WHERE v > 3 ORDER BY k; } +step s1ff { SELECT pg_stat_force_next_flush(); } +step s1stats { + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; +} + +permutation s1stats s1scan s1ff s1stats s1where s1ff s1stats diff --git a/src/test/isolation/specs/heap_ssi_conflict_out.spec b/src/test/isolation/specs/heap_ssi_conflict_out.spec new file mode 100644 index 0000000000000..d0e8e9f12d8b9 --- /dev/null +++ b/src/test/isolation/specs/heap_ssi_conflict_out.spec @@ -0,0 +1,99 @@ +# heap control for flux_ssi_conflict_out.spec, which runs the same steps on +# FLUX; the two expected outputs are identical. +# +# A serializable reader must report a conflict out to every writer of a +# version it examines that its snapshot cannot see, whether that writer is +# still running or committed after the snapshot was taken. FLUX keeps only +# the newest version on the page and reaches older ones through the UNDO +# version chain, so the writers the walk steps over must be reported too. +# +# Permutations 1-2 are update-conflict-out.spec. Permutations 3-4 are a write +# skew whose read goes through an index and is served a version rebuilt from +# UNDO, past one committed in-place UPDATE (3) and past a committed and a +# running one (4). Permutations 5-6 are receipt-report.spec's schedules +# where a read-only transaction sees a state no serial order produces. + +setup +{ + CREATE TABLE txn0 (id int4 PRIMARY KEY, val text) USING heap; + CREATE TABLE txn1 (id int4 PRIMARY KEY, val text) USING heap; + CREATE TABLE ws (id int PRIMARY KEY, v int) USING heap; + INSERT INTO ws VALUES (1, 0), (2, 0), (3, 0); + CREATE TABLE ctl (k text NOT NULL PRIMARY KEY, deposit_date date NOT NULL) USING heap; + INSERT INTO ctl VALUES ('receipt', DATE '2008-12-22'); + CREATE TABLE receipt (receipt_no int NOT NULL PRIMARY KEY, deposit_date date NOT NULL, amount numeric(13,2)) USING heap; + INSERT INTO receipt VALUES (1, DATE '2008-12-22', 1.00), (2, DATE '2008-12-22', 2.00); +} + +teardown +{ + DROP TABLE txn0, txn1, ws, ctl, receipt; +} + +session foo +step foo_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step foo_select { SELECT * FROM txn0 WHERE id = 42; } +step foo_insert { INSERT INTO txn1 SELECT 7, 'foo_insert'; } +step foo_commit { COMMIT; } + +session bar +step bar_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step bar_select { SELECT * FROM txn1 WHERE id = 7; } +step bar_insert { INSERT INTO txn0 SELECT 42, 'bar_insert'; } +step bar_commit { COMMIT; } + +session trouble +step trouble_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step trouble_update { UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; } +step trouble_delete { DELETE FROM txn1 WHERE id = 7; } +step trouble_abort { ABORT; } + +session w1 +step w1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w1snap { SELECT v FROM ws WHERE id = 3; } +step w1r1 { SELECT v FROM ws WHERE id = 1; } +step w1w2 { UPDATE ws SET v = v + 1 WHERE id = 2; } +step w1c { COMMIT; } + +session w2 +step w2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w2r2 { SELECT v FROM ws WHERE id = 2; } +step w2w1 { UPDATE ws SET v = v + 10 WHERE id = 1; } +step w2c { COMMIT; } + +session w3 +step w3b { BEGIN; } +step w3w1 { UPDATE ws SET v = v + 100 WHERE id = 1; } +step w3a { ABORT; } + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step rxwy1 { INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); } +step c1 { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step wx2 { UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; } +step c2 { COMMIT; } + +session s3 +step s3b { BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; } +step rx3 { SELECT * FROM ctl WHERE k = 'receipt'; } +step ry3 { SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; } +step c3 { COMMIT; } + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_update bar_select bar_commit trouble_abort + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_delete bar_select bar_commit trouble_abort + +permutation w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c + +permutation w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a + +permutation s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 + +permutation s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 diff --git a/src/test/isolation/specs/heap_ssi_copy.spec b/src/test/isolation/specs/heap_ssi_copy.spec new file mode 100644 index 0000000000000..f7ae127740805 --- /dev/null +++ b/src/test/isolation/specs/heap_ssi_copy.spec @@ -0,0 +1,18 @@ +# Bulk INSERT must check relation predicate locks, not promoted page locks. +setup +{ + CREATE TABLE a (id int) USING heap; + CREATE TABLE b (id int) USING heap; +} +teardown { DROP TABLE a, b; } +session s1 +step b1 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r1 { SELECT count(*) FROM a; } +step w1 { COPY b FROM PROGRAM 'printf "1\n"'; } +step c1 { COMMIT; } +session s2 +step b2 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r2 { SELECT count(*) FROM b; } +step w2 { COPY a FROM PROGRAM 'printf "2\n"'; } +step c2 { COMMIT; } +permutation b1 b2 r1 r2 w1 c1 w2 c2 diff --git a/src/test/isolation/specs/heap_ssi_revive.spec b/src/test/isolation/specs/heap_ssi_revive.spec new file mode 100644 index 0000000000000..68a7b9240b4fd --- /dev/null +++ b/src/test/isolation/specs/heap_ssi_revive.spec @@ -0,0 +1,54 @@ +# Heap control for flux_ssi_revive.spec. +# flux_ssi_revive.spec runs the same steps on FLUX; the two expected +# outputs are identical. +# +# s3 (read committed) moves row 1 to k = 1 and away again, so FLUX keeps a +# delete-marked (1, TID) entry in t_k. When s2 moves the row back to k = 1, +# FLUX revives that entry instead of inserting a new one. An insert reports +# a rw-conflict to every serializable reader holding a predicate lock on the +# leaf page it lands on, which is what protects a key-range read from a +# phantom; the revive must report it the same way. +# +# Permutations 1-2: s1 reads k = 1 before the tombstone exists, so it holds +# only the leaf page lock; s1 and s2 then form a write skew through u, and +# one of them must fail. Permutation 3: the tombstone exists when s1 reads +# k = 1, so s1 also locks row 1 through it. Permutation 4: s1 reads another +# key on the same leaf; heap reports the (false positive) conflict through +# the page lock, and FLUX does the same. + +setup +{ + CREATE TABLE t (id int PRIMARY KEY, k int, v int) USING heap; + CREATE INDEX t_k ON t (k); + INSERT INTO t VALUES (1, 3, 0), (2, 5, 0), (3, 7, 0); + CREATE TABLE u (id int PRIMARY KEY, v int) USING heap; + INSERT INTO u VALUES (1, 0); +} + +teardown +{ + DROP TABLE t, u; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s1r { SELECT count(*) FROM t WHERE k = 1; } +step s1r5 { SELECT count(*) FROM t WHERE k = 5; } +step s1w { UPDATE u SET v = v + 1 WHERE id = 1; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s2r { SELECT v FROM u WHERE id = 1; } +step s2w { UPDATE t SET k = 1 WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3a { UPDATE t SET k = 1 WHERE id = 1; } +step s3b { UPDATE t SET k = 2 WHERE id = 1; } +step s3chk { SELECT id, k FROM t ORDER BY id; SELECT * FROM u; } + +permutation s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +permutation s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +permutation s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +permutation s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk diff --git a/src/test/isolation/specs/heap_tid_fetch_snapshot.spec b/src/test/isolation/specs/heap_tid_fetch_snapshot.spec new file mode 100644 index 0000000000000..54b9c0917f055 --- /dev/null +++ b/src/test/isolation/specs/heap_tid_fetch_snapshot.spec @@ -0,0 +1,40 @@ +# heap control for flux_tid_fetch_snapshot.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE acc (id text PRIMARY KEY, bal int) USING heap; + INSERT INTO acc VALUES ('checking', 600), ('savings', 600); + CREATE TABLE oth (id text, v int) USING heap; + INSERT INTO oth VALUES ('checking', 1), ('savings', 1); +} +teardown +{ + DROP TABLE acc, oth; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1sel { SELECT * FROM acc ORDER BY id; } +step s1lock { SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; } +step s1tid { SELECT * FROM acc WHERE ctid = '(0,1)'; } +step s1join { SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; } +step s1c { COMMIT; } + +session s2 +step s2u { UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; } +step s2d { DELETE FROM acc WHERE id = 'checking'; } +step s2uo { UPDATE oth SET v = v + 10 WHERE id = 'checking'; } +step s2do { DELETE FROM oth WHERE id = 'checking'; } +step s2b { BEGIN; } +step s2c { COMMIT; } + +# The recheck sees a2 as the query did: 600, beside a1's new 1050. +permutation s2b s2u s1b s1lock s2c s1c +# The unlocked side is another table, updated or deleted concurrently. +permutation s2b s2u s2uo s1b s1join s2c s1c +permutation s2b s2u s2do s1b s1join s2c s1c +# A TID scan sees the version its snapshot sees, updated or deleted since. +permutation s1brr s1sel s2u s1tid s1c +permutation s1brr s1sel s2d s1tid s1c +permutation s1brr s1sel s2u s2u s1tid s1c diff --git a/src/test/isolation/specs/heap_update_deadlock.spec b/src/test/isolation/specs/heap_update_deadlock.spec new file mode 100644 index 0000000000000..daa77589c510e --- /dev/null +++ b/src/test/isolation/specs/heap_update_deadlock.spec @@ -0,0 +1,52 @@ +# heap control for flux_update_deadlock.spec: same steps, same expected output. +# FLUX: concurrent two-row UPDATE transactions that deadlock. The deadlock +# victim's increments must be rolled back and every survivor's must land +# exactly once, as in heap. hist records the increments of the transactions +# that commit, in the same transaction. + +setup +{ + CREATE TABLE fdl (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fdl VALUES (1, 0), (2, 0); + CREATE TABLE fdl_hist (id int, who text); +} + +teardown +{ + DROP TABLE fdl, fdl_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); } +step s1u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '500ms'; BEGIN; } +step s2u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); } +step s2u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); } +step s3u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; } + +# s2 runs with a deadlock_timeout far below the others' but far above the +# few milliseconds the tester needs to issue the step that closes the cycle, +# so s2 is always the one that detects it and is the victim. Commits are +# ordered so that no session is asked to commit while it still waits. +# +# Two-way deadlock, s2 is the victim; s1 survives. +permutation s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +# A third transaction waits on row 1 behind s1 before the cycle forms. +permutation s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +# The victim holds the row a waiter is queued on. +permutation s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +# Victim's row 2 has a queued waiter too. +permutation s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk diff --git a/src/test/isolation/specs/heap_update_key_waiter.spec b/src/test/isolation/specs/heap_update_key_waiter.spec new file mode 100644 index 0000000000000..adaca147f0e0e --- /dev/null +++ b/src/test/isolation/specs/heap_update_key_waiter.spec @@ -0,0 +1,50 @@ +# heap control for flux_update_key_waiter.spec: same steps, same expected +# output. An UPDATE that waits for a concurrent writer of the same row +# decides its index changes from the row the writer leaves behind. +setup +{ + CREATE TABLE kw (id int, k int, v int) USING heap; + CREATE INDEX kw_k ON kw (k); + INSERT INTO kw SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE kw; +} +session s1 +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1v { UPDATE kw SET v = 1 WHERE id = 3; } +step s1d { DELETE FROM kw WHERE id = 3; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +step s2b { BEGIN; } +step s2k { UPDATE kw SET k = 100 WHERE id = 3; } +step s2k2 { UPDATE kw SET k = 200 WHERE id = 3; } +step s2v { UPDATE kw SET v = 2 WHERE id = 3; } +step s2c { COMMIT; } +session s3 +step chk { + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; +} + +# The waiter's new key equals the aborted writer's key. +permutation s1b s1k s2b s2k s1r s2c chk +permutation s1b s1k s2b s2k s1c s2c chk +# The waiter changes the key to another value. +permutation s1b s1k s2b s2k2 s1r s2c chk +permutation s1b s1k s2b s2k2 s1c s2c chk +# The writer changed the key, the waiter changes only a non-key column. +permutation s1b s1k s2b s2v s1r s2c chk +permutation s1b s1k s2b s2v s1c s2c chk +# The writer changed a non-key column, the waiter changes the key. +permutation s1b s1v s2b s2k s1r s2c chk +permutation s1b s1v s2b s2k s1c s2c chk +# The writer deleted the row and rolls back. +permutation s1b s1d s2b s2k s1r s2c chk +permutation s1b s1d s2b s2k s1c s2c chk diff --git a/src/test/isolation/specs/heap_update_serialize.spec b/src/test/isolation/specs/heap_update_serialize.spec new file mode 100644 index 0000000000000..355ab725edf5e --- /dev/null +++ b/src/test/isolation/specs/heap_update_serialize.spec @@ -0,0 +1,67 @@ +# heap control for flux_update_serialize.spec: same steps, same expected output. +# FLUX: concurrent UPDATE serialization must match heap, and a plain UPDATE +# must not hold a heavyweight tuple lock until commit. +# +# FLUX updates rows in place at a stable TID. The in-progress updater is the +# on-page t_xmin of the new image (FLUX_TUPLE_UPDATED | FLUX_TUPLE_UNCOMMITTED), +# which is what a concurrent updater must wait on, exactly as heap waits on +# xmax. The expected output is heap's (the same spec with USING heap). + +setup +{ + CREATE TABLE fus (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fus VALUES (1, 0); + CREATE TABLE fus_big (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fus_big SELECT g, 0 FROM generate_series(1, 20000) g; +} + +teardown +{ + DROP TABLE fus, fus_big; +} + +session s1 +step s1rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s1ks { SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; } +step s1fu { SELECT id FROM fus WHERE id = 1 FOR UPDATE; } +step s1oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } +# A whole-table UPDATE must not take one transaction-duration lock per row: +# 20000 rows exhaust the default lock table ("out of shared memory"). +step s1big { UPDATE fus_big SET v = v + 1; } +step s1bigsum { SELECT sum(v) FROM fus_big; } +# No tuple lock may survive the UPDATE statement (heap holds none). +step s1locks { SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT v FROM fus WHERE id = 1; } +step s2u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s2oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s2c { COMMIT; } + +session s3 +step s3r { SELECT v FROM fus WHERE id = 1; } + +# READ COMMITTED: s2 waits for s1, then re-evaluates on s1's value -> 2. +permutation s1rc s2rc s1u s2u s1c s2c s3r +# READ COMMITTED, s1 rolls back: s2 proceeds on the original value -> 1. +permutation s1rc s2rc s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 commits while s2 waits: serialization failure -> 1. +permutation s1rc s2rr s2snap s1u s2u s1c s2c s3r +# REPEATABLE READ, s1 rolls back while s2 waits: s2 succeeds -> 1. +permutation s1rc s2rr s2snap s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 committed after s2's snapshot: immediate failure -> 1. +permutation s1rc s2rr s2snap s1u s1c s2u s2c s3r +# s1 holds only a KEY SHARE lock, so s2's no-key UPDATE proceeds; s1's own +# later UPDATE must then wait for s2 and re-evaluate (no lost update) -> 2. +permutation s1rc s2rc s1ks s2u s1u s2c s1c s3r +# FOR UPDATE blocks a concurrent UPDATE until commit -> 2. +permutation s1rc s2rc s1fu s2u s1u s1c s2c s3r +# Concurrent ON CONFLICT DO UPDATE on the same key -> 2. +permutation s1rc s2rc s1oc s2oc s1c s2c s3r +# Bulk UPDATE at default settings, then no tuple locks left in the xact. +permutation s1rc s1big s1locks s1c s1bigsum diff --git a/src/test/isolation/specs/heap_update_victim.spec b/src/test/isolation/specs/heap_update_victim.spec new file mode 100644 index 0000000000000..92ce8259d21d2 --- /dev/null +++ b/src/test/isolation/specs/heap_update_victim.spec @@ -0,0 +1,48 @@ +# heap control for flux_update_victim.spec: same steps, same expected output. +# FLUX: an UPDATE that waited for a row and then became a deadlock victim +# must not leave its increments behind. Every value must equal the number +# of committed increments recorded in fdv_hist (same transaction), as in +# heap. + +setup +{ + CREATE TABLE fdv (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fdv VALUES (1, 0), (2, 0); + CREATE TABLE fdv_hist (id int); +} + +teardown +{ + DROP TABLE fdv, fdv_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s1u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '10ms'; BEGIN; } +step s2u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s2u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s3u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; } + +# s2 waits for s1 on row 1, after updating row 2; then s1 waits on row 2 +# -> s2 is the victim (short deadlock_timeout) while its row-1 wait is +# granted the row only after s1's commit. +permutation s2u2 s1u1 s2u1 s1u2 s1c s2c chk +# Same, with s3 queued on row 2 behind s2. +permutation s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +# s2's first update waited on s1 and succeeded after s1 committed; its +# second update then deadlocks with s3. +permutation s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk diff --git a/src/test/isolation/specs/heap_update_waiter_abort.spec b/src/test/isolation/specs/heap_update_waiter_abort.spec new file mode 100644 index 0000000000000..e6851473d56c1 --- /dev/null +++ b/src/test/isolation/specs/heap_update_waiter_abort.spec @@ -0,0 +1,75 @@ +# heap control for flux_update_waiter_abort.spec: same steps, same expected +# output. A row change that waits for a concurrent writer of the same row +# works on the committed new version if the writer commits, and on the version +# the writer replaced if it aborts. +setup +{ + CREATE TABLE vh (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO vh VALUES (1, 0, 0), (2, 0, 0); + CREATE TABLE vh_hist (id int); +} +teardown +{ + DROP TABLE vh, vh_hist; +} +session s1 +setup { SET deadlock_timeout = '10ms'; } +step s1b { BEGIN; } +step s1w { UPDATE vh SET w = 10 WHERE id = 1; } +step s1w2 { UPDATE vh SET w = 20 WHERE id = 1; } +step s1k { UPDATE vh SET id = 10 WHERE id = 1; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s1u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +setup { SET deadlock_timeout = '10s'; } +step s2b { BEGIN; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2u { UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s2w5 { UPDATE vh SET w = 5 WHERE id = 1; } +step s2d { DELETE FROM vh WHERE id = 1 RETURNING *; } +step s2l { SELECT * FROM vh WHERE id = 1 FOR UPDATE; } +step s2h { INSERT INTO vh_hist VALUES (1); } +step s2c { COMMIT; } +session s3 +setup { SET deadlock_timeout = '10s'; } +step s3b { BEGIN; } +step s3u { UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s3u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s3u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s3c { COMMIT; } +session s4 +step chk { SELECT * FROM vh ORDER BY id; } +step chkh { SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; } + +# READ COMMITTED UPDATE waiter: the writer commits (re-fetch: OLD w = 10), +# or aborts (OLD w = 0). +permutation s1b s1w s2b s2u s1c s2c chk +permutation s1b s1w s2b s2u s1r s2c chk +# REPEATABLE READ: commit is a serialization failure, abort succeeds. +permutation s1b s1w s2rr s2u s1c s2c chk +permutation s1b s1w s2rr s2u s1r s2c chk +# The writer's change is in a savepoint rolled back while the update waits; +# then with an earlier change outside the savepoint, which commits. +permutation s1b s1sp s1w s2b s2u s1rs s1c s2c chk +permutation s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +# DELETE ... RETURNING waiters, and a SELECT ... FOR UPDATE waiter whose +# writer aborts. (FOR UPDATE behind a writer that commits is +# flux_lock_waiter_commit.) +permutation s1b s1w s2b s2d s1c s2c chk +permutation s1b s1w s2b s2d s1r s2c chk +permutation s1b s1w s2b s2l s1r s2c chk +# Two waiters queued behind the writer. +permutation s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +permutation s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +# The writer changes the column the waiter's WHERE clause tests. +permutation s1b s1k s2b s2u s1c s2c chk +permutation s1b s1k s2b s2u s1r s2c chk +# The on-page version is our own earlier update. +permutation s2b s2w5 s2u s2c chk +# s1->s3 (row 2), s3->s2 (row 1, queued), s2->s1 (row 1): s1 is the deadlock +# victim; s2 must see OLD v = 0, not the victim's 1. +permutation s1b s2b s3b s1u1 s3u2 s2u(s1u2) s3u1 s1u2(*) s1c s2h s2c s3c chkh diff --git a/src/test/isolation/specs/heap_vacuum_move.spec b/src/test/isolation/specs/heap_vacuum_move.spec new file mode 100644 index 0000000000000..33cc6f3ab4bfb --- /dev/null +++ b/src/test/isolation/specs/heap_vacuum_move.spec @@ -0,0 +1,45 @@ +# heap control for flux_vacuum_move: same steps, same +# output. Ordinary VACUUM must not move a row out from under its writer. +# +# s1 updates the last row in place and keeps the transaction open. s2 +# VACUUMs the table (lower pages are empty after setup's DELETE) and inserts +# enough rows to reuse any slot VACUUM might have freed. s1 must still see +# exactly its own row, and after it ends every row must be intact. + +setup +{ + CREATE TABLE hvmove (id int, v int, pad text) USING heap WITH (autovacuum_enabled = off); + CREATE INDEX hvmove_id ON hvmove (id); + INSERT INTO hvmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; + DELETE FROM hvmove WHERE id <= 300; +} +setup { VACUUM hvmove; } +setup { VACUUM hvmove; } + +teardown +{ + DROP TABLE hvmove; +} + +session s1 +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; } +step s1_upd { BEGIN; UPDATE hvmove SET v = 7 WHERE id = 400; } +step s1_idx { SELECT id, v FROM hvmove WHERE id = 400; } +step s1_bmp { SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM hvmove WHERE id = 400; } +step s1_commit { COMMIT; } +step s1_rollback { ROLLBACK; } + +session s2 +step s2_vac { VACUUM hvmove; } +step s2_ins { INSERT INTO hvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; } +step s2_check +{ + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM hvmove; +} + +permutation s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +permutation s1_upd s2_vac s2_ins s1_idx s1_commit s2_check diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 6c1504ac9323a..2d1425722fc7b 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -4,7 +4,9 @@ subdir = src/test/modules top_builddir = ../../.. include $(top_builddir)/src/Makefile.global -SUBDIRS = \ +SUBDIRS = test_deletemark \ + test_idxundo \ + \ brin \ commit_ts \ delay_execution \ @@ -32,6 +34,7 @@ SUBDIRS = \ test_escape \ test_extensible \ test_extensions \ + test_fileops \ test_ginpostinglist \ test_int128 \ test_integerset \ diff --git a/src/test/modules/brin/Makefile b/src/test/modules/brin/Makefile index e74af891ce7f6..4f81a53b40936 100644 --- a/src/test/modules/brin/Makefile +++ b/src/test/modules/brin/Makefile @@ -2,7 +2,9 @@ EXTRA_INSTALL = contrib/pageinspect contrib/pg_walinspect -ISOLATION = summarization-and-inprogress-insertion +REGRESS = flux_summarize +ISOLATION = summarization-and-inprogress-insertion \ + flux-summarization-and-inprogress-insertion TAP_TESTS = 1 ifdef USE_PGXS diff --git a/src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out b/src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out new file mode 100644 index 0000000000000..201786c82c001 --- /dev/null +++ b/src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out @@ -0,0 +1,51 @@ +Parsed test spec with 2 sessions + +starting permutation: s2check s1b s2b s1i s2summ s1c s2c s2check +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+-------- + 1| 0| 1|f |t |f |f |{1 .. 1} +(1 row) + +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; +?column? +-------- + 1 +(1 row) + +step s1i: INSERT INTO brin_iso VALUES (1000); +step s2summ: SELECT brin_summarize_new_values('brinidx'::regclass); +brin_summarize_new_values +------------------------- + 1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+----------- + 1| 0| 1|f |t |f |f |{1 .. 1} + 2| 1| 1|f |f |f |f |{1 .. 1000} +(2 rows) + + +starting permutation: s2check s1b s1i s2vacuum s1c s2check +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+-------- + 1| 0| 1|f |t |f |f |{1 .. 1} +(1 row) + +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1i: INSERT INTO brin_iso VALUES (1000); +step s2vacuum: VACUUM brin_iso; +step s1c: COMMIT; +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+----------- + 1| 0| 1|f |t |f |f |{1 .. 1} + 2| 1| 1|f |f |f |f |{1 .. 1000} +(2 rows) + diff --git a/src/test/modules/brin/expected/flux_summarize.out b/src/test/modules/brin/expected/flux_summarize.out new file mode 100644 index 0000000000000..acb074bcec6d3 --- /dev/null +++ b/src/test/modules/brin/expected/flux_summarize.out @@ -0,0 +1,177 @@ +-- +-- BRIN summarization of one block range on a FLUX table, with a heap twin. +-- +-- VACUUM, brin_summarize_new_values and brin_summarize_range summarize a +-- range through table_index_build_range_scan with that range's blocks; the +-- table AM must scan only those. Each summary item must then equal the +-- minimum and maximum of the rows in its range, there must be one item per +-- range, and a BRIN bitmap scan must find every row. +-- +CREATE EXTENSION pageinspect; +-- Items of a BRIN index on column v that are not the min/max of the rows in +-- their range ("wrong"), and items beyond one per range ("dup"). +CREATE FUNCTION brin_check(tbl regclass, idx regclass, ppr int, + OUT wrong bigint, OUT dup bigint) +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format($q$ + WITH items AS ( + SELECT i.blknum, i.value + FROM generate_series(1, (pg_relation_size(%2$L::regclass) / + current_setting('block_size')::int)::int - 1) p, + LATERAL get_raw_page(%2$L, p) g(pg), + LATERAL brin_page_items(g.pg, %2$L::regclass) i + WHERE brin_page_type(g.pg) = 'regular' AND NOT i.placeholder), + ranges AS ( + SELECT (ctid::text::point)[0]::bigint / %3$s * %3$s AS blknum, + '{' || min(v) || ' .. ' || max(v) || '}' AS value + FROM %1$s GROUP BY 1) + SELECT (SELECT count(*) FROM (SELECT * FROM items + EXCEPT ALL SELECT * FROM ranges) x), + (SELECT count(*) - count(DISTINCT blknum) FROM items)$q$, + tbl, idx, ppr) + INTO wrong, dup; +END $$; +-- The text key of row g. +CREATE FUNCTION brin_key(g int) RETURNS text IMMUTABLE LANGUAGE sql + RETURN lpad(g::text, 8, '0') || md5(g::text) || md5(g::text || 'a'); +-- Text keys, ranges summarized by VACUUM: a varlena summary does not fit in +-- place, so a summary of the wrong blocks lands elsewhere and the revmap +-- points at it. +CREATE TABLE brin_t_heap (k int, v text) USING heap; +CREATE TABLE brin_t_flux (k int, v text) USING flux; +CREATE INDEX brin_t_heap_idx ON brin_t_heap USING brin (v) WITH (pages_per_range = 4); +CREATE INDEX brin_t_flux_idx ON brin_t_flux USING brin (v) WITH (pages_per_range = 4); +INSERT INTO brin_t_heap SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +INSERT INTO brin_t_flux SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +VACUUM brin_t_heap, brin_t_flux; +SELECT * FROM brin_check('brin_t_heap', 'brin_t_heap_idx', 4); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SELECT * FROM brin_check('brin_t_flux', 'brin_t_flux_idx', 4); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SET enable_seqscan = off; +SET enable_indexscan = off; +EXPLAIN (COSTS OFF) SELECT k FROM brin_t_flux WHERE v = brin_key(300); + QUERY PLAN +------------------------------------------------------------------------------------------------------------ + Bitmap Heap Scan on brin_t_flux + Recheck Cond: (v = '0000030094f6d7e04a4d452035300f18b984988cff0b2007a9ecc1bba0a8ef8528e80653'::text) + -> Bitmap Index Scan on brin_t_flux_idx + Index Cond: (v = '0000030094f6d7e04a4d452035300f18b984988cff0b2007a9ecc1bba0a8ef8528e80653'::text) +(4 rows) + +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_heap t WHERE t.v = brin_key(x)); + missed +-------- + 0 +(1 row) + +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_flux t WHERE t.v = brin_key(x)); + missed +-------- + 0 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +-- Integer keys, one range desummarized and summarized again. +CREATE TABLE brin_i_heap (v int) USING heap; +CREATE TABLE brin_i_flux (v int) USING flux; +INSERT INTO brin_i_heap SELECT generate_series(1, 20000); +INSERT INTO brin_i_flux SELECT generate_series(1, 20000); +CREATE INDEX brin_i_heap_idx ON brin_i_heap USING brin (v) WITH (pages_per_range = 1); +CREATE INDEX brin_i_flux_idx ON brin_i_flux USING brin (v) WITH (pages_per_range = 1); +SELECT brin_desummarize_range('brin_i_heap_idx', 10); + brin_desummarize_range +------------------------ + +(1 row) + +SELECT brin_desummarize_range('brin_i_flux_idx', 10); + brin_desummarize_range +------------------------ + +(1 row) + +SELECT brin_summarize_range('brin_i_heap_idx', 10); + brin_summarize_range +---------------------- + 1 +(1 row) + +SELECT brin_summarize_range('brin_i_flux_idx', 10); + brin_summarize_range +---------------------- + 1 +(1 row) + +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +-- New ranges, summarized by brin_summarize_new_values. +INSERT INTO brin_i_heap SELECT generate_series(20001, 30000); +INSERT INTO brin_i_flux SELECT generate_series(20001, 30000); +SELECT brin_summarize_new_values('brin_i_heap_idx') > 0 AS summarized; + summarized +------------ + t +(1 row) + +SELECT brin_summarize_new_values('brin_i_flux_idx') > 0 AS summarized; + summarized +------------ + t +(1 row) + +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SET enable_seqscan = off; +SET enable_indexscan = off; +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_heap t WHERE t.v = x); + missed +-------- + 0 +(1 row) + +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_flux t WHERE t.v = x); + missed +-------- + 0 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +DROP TABLE brin_t_heap, brin_t_flux, brin_i_heap, brin_i_flux; +DROP FUNCTION brin_check, brin_key; +DROP EXTENSION pageinspect; diff --git a/src/test/modules/brin/meson.build b/src/test/modules/brin/meson.build index 39a8b2fc925c0..f923d302c468a 100644 --- a/src/test/modules/brin/meson.build +++ b/src/test/modules/brin/meson.build @@ -4,9 +4,15 @@ tests += { 'name': 'brin', 'sd': meson.current_source_dir(), 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'flux_summarize', + ], + }, 'isolation': { 'specs': [ 'summarization-and-inprogress-insertion', + 'flux-summarization-and-inprogress-insertion', ], }, 'tap': { diff --git a/src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec b/src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec new file mode 100644 index 0000000000000..136c97803a012 --- /dev/null +++ b/src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec @@ -0,0 +1,46 @@ +# summarization-and-inprogress-insertion on a FLUX table: the same output as +# heap's. Summarizing range 1 must scan only block 1 (the NULL of block 0 +# stays out of its summary), and finding an in-progress insert there is not +# worth a warning in the "any visible" scan that summarization uses. + +setup +{ + CREATE TABLE brin_iso ( + value int + ) USING flux WITH (fillfactor=10); + CREATE INDEX brinidx ON brin_iso USING brin (value) WITH (pages_per_range=1); + -- this fills the first page + INSERT INTO brin_iso VALUES (NULL); + DO $$ + DECLARE curtid tid; + BEGIN + LOOP + INSERT INTO brin_iso VALUES (1) RETURNING ctid INTO curtid; + EXIT WHEN curtid > tid '(1, 0)'; + END LOOP; + END; + $$; + CREATE EXTENSION IF NOT EXISTS pageinspect; +} + +teardown +{ + DROP TABLE brin_iso; +} + +session "s1" +step "s1b" { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step "s1i" { INSERT INTO brin_iso VALUES (1000); } +step "s1c" { COMMIT; } + +session "s2" +step "s2b" { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; } +step "s2summ" { SELECT brin_summarize_new_values('brinidx'::regclass); } +step "s2c" { COMMIT; } + +step "s2vacuum" { VACUUM brin_iso; } + +step "s2check" { SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); } + +permutation "s2check" "s1b" "s2b" "s1i" "s2summ" "s1c" "s2c" "s2check" +permutation "s2check" "s1b" "s1i" "s2vacuum" "s1c" "s2check" diff --git a/src/test/modules/brin/sql/flux_summarize.sql b/src/test/modules/brin/sql/flux_summarize.sql new file mode 100644 index 0000000000000..f6dd110a26fe9 --- /dev/null +++ b/src/test/modules/brin/sql/flux_summarize.sql @@ -0,0 +1,97 @@ +-- +-- BRIN summarization of one block range on a FLUX table, with a heap twin. +-- +-- VACUUM, brin_summarize_new_values and brin_summarize_range summarize a +-- range through table_index_build_range_scan with that range's blocks; the +-- table AM must scan only those. Each summary item must then equal the +-- minimum and maximum of the rows in its range, there must be one item per +-- range, and a BRIN bitmap scan must find every row. +-- +CREATE EXTENSION pageinspect; + +-- Items of a BRIN index on column v that are not the min/max of the rows in +-- their range ("wrong"), and items beyond one per range ("dup"). +CREATE FUNCTION brin_check(tbl regclass, idx regclass, ppr int, + OUT wrong bigint, OUT dup bigint) +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format($q$ + WITH items AS ( + SELECT i.blknum, i.value + FROM generate_series(1, (pg_relation_size(%2$L::regclass) / + current_setting('block_size')::int)::int - 1) p, + LATERAL get_raw_page(%2$L, p) g(pg), + LATERAL brin_page_items(g.pg, %2$L::regclass) i + WHERE brin_page_type(g.pg) = 'regular' AND NOT i.placeholder), + ranges AS ( + SELECT (ctid::text::point)[0]::bigint / %3$s * %3$s AS blknum, + '{' || min(v) || ' .. ' || max(v) || '}' AS value + FROM %1$s GROUP BY 1) + SELECT (SELECT count(*) FROM (SELECT * FROM items + EXCEPT ALL SELECT * FROM ranges) x), + (SELECT count(*) - count(DISTINCT blknum) FROM items)$q$, + tbl, idx, ppr) + INTO wrong, dup; +END $$; + +-- The text key of row g. +CREATE FUNCTION brin_key(g int) RETURNS text IMMUTABLE LANGUAGE sql + RETURN lpad(g::text, 8, '0') || md5(g::text) || md5(g::text || 'a'); + +-- Text keys, ranges summarized by VACUUM: a varlena summary does not fit in +-- place, so a summary of the wrong blocks lands elsewhere and the revmap +-- points at it. +CREATE TABLE brin_t_heap (k int, v text) USING heap; +CREATE TABLE brin_t_flux (k int, v text) USING flux; +CREATE INDEX brin_t_heap_idx ON brin_t_heap USING brin (v) WITH (pages_per_range = 4); +CREATE INDEX brin_t_flux_idx ON brin_t_flux USING brin (v) WITH (pages_per_range = 4); +INSERT INTO brin_t_heap SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +INSERT INTO brin_t_flux SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +VACUUM brin_t_heap, brin_t_flux; +SELECT * FROM brin_check('brin_t_heap', 'brin_t_heap_idx', 4); +SELECT * FROM brin_check('brin_t_flux', 'brin_t_flux_idx', 4); + +SET enable_seqscan = off; +SET enable_indexscan = off; +EXPLAIN (COSTS OFF) SELECT k FROM brin_t_flux WHERE v = brin_key(300); +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_heap t WHERE t.v = brin_key(x)); +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_flux t WHERE t.v = brin_key(x)); +RESET enable_seqscan; +RESET enable_indexscan; + +-- Integer keys, one range desummarized and summarized again. +CREATE TABLE brin_i_heap (v int) USING heap; +CREATE TABLE brin_i_flux (v int) USING flux; +INSERT INTO brin_i_heap SELECT generate_series(1, 20000); +INSERT INTO brin_i_flux SELECT generate_series(1, 20000); +CREATE INDEX brin_i_heap_idx ON brin_i_heap USING brin (v) WITH (pages_per_range = 1); +CREATE INDEX brin_i_flux_idx ON brin_i_flux USING brin (v) WITH (pages_per_range = 1); +SELECT brin_desummarize_range('brin_i_heap_idx', 10); +SELECT brin_desummarize_range('brin_i_flux_idx', 10); +SELECT brin_summarize_range('brin_i_heap_idx', 10); +SELECT brin_summarize_range('brin_i_flux_idx', 10); +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + +-- New ranges, summarized by brin_summarize_new_values. +INSERT INTO brin_i_heap SELECT generate_series(20001, 30000); +INSERT INTO brin_i_flux SELECT generate_series(20001, 30000); +SELECT brin_summarize_new_values('brin_i_heap_idx') > 0 AS summarized; +SELECT brin_summarize_new_values('brin_i_flux_idx') > 0 AS summarized; +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + +SET enable_seqscan = off; +SET enable_indexscan = off; +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_heap t WHERE t.v = x); +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_flux t WHERE t.v = x); +RESET enable_seqscan; +RESET enable_indexscan; + +DROP TABLE brin_t_heap, brin_t_flux, brin_i_heap, brin_i_flux; +DROP FUNCTION brin_check, brin_key; +DROP EXTENSION pageinspect; diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile index 2463d6e0fb9a5..92db2e585d3ff 100644 --- a/src/test/modules/injection_points/Makefile +++ b/src/test/modules/injection_points/Makefile @@ -9,7 +9,7 @@ EXTENSION = injection_points DATA = injection_points--1.0.sql PGFILEDESC = "injection_points - facility for injection points" -EXTRA_INSTALL = contrib/test_decoding +EXTRA_INSTALL = contrib/amcheck contrib/test_decoding REGRESS = injection_points hashagg reindex_conc vacuum REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress @@ -30,12 +30,19 @@ ISOLATION = basic \ repack_temporal \ repack_temporal_multirange \ repack_toast \ + repack_other_table \ ri_fastpath_reindex \ ri_fastpath_snapshot \ syscache-update-pruned \ $(WAIT_CLEANUP_TST) \ heap_lock_update \ - on_conflict_probe_window + on_conflict_probe_window \ + flux_cic \ + flux_update_cas_race \ + flux_update_redecide_queue \ + flux_repack \ + flux_repack_decode \ + vacuum_progress # some isolation tests require wal_level=replica ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/injection_points/extra.conf diff --git a/src/test/modules/injection_points/expected/flux_cic.out b/src/test/modules/injection_points/expected/flux_cic.out new file mode 100644 index 0000000000000..e7b2628ab7f77 --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_cic.out @@ -0,0 +1,1017 @@ +Parsed test spec with 6 sessions + +starting permutation: pick_fx b1l c_cic w1b w1u b1c w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1u b1c w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2a: UPDATE t SET k = 1 WHERE id = 1; +step b2c: COMMIT; +step c_cic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2a: UPDATE t SET k = 1 WHERE id = 1; +step b2c: COMMIT; +step c_cic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1x: DELETE FROM t WHERE id = 1; +step w1i: INSERT INTO t VALUES (23, 100); +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1x: DELETE FROM t WHERE id = 1; +step w1i: INSERT INTO t VALUES (23, 100); +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1u b1c w1r chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1r: ROLLBACK; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1u b1c w1r chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1r: ROLLBACK; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step w2c: COMMIT; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step rrc: COMMIT; +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step w2c: COMMIT; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step rrc: COMMIT; +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_ucic w1b w1d b1c w1c c_state chk2 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1d: UPDATE t SET k = 2 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ucic: <... completed> +ERROR: duplicate key value violates unique constraint "t_k" +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |f +(1 row) + +step chk2: SELECT * FROM chk('k = 2'); +via |ids +-------+--- +idx |1,2 +seq |1,2 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_ucic w1b w1d b1c w1c c_state chk2 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1d: UPDATE t SET k = 2 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ucic: <... completed> +ERROR: duplicate key value violates unique constraint "t_k" +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |f +(1 row) + +step chk2: SELECT * FROM chk('k = 2'); +via |ids +-------+--- +idx |1,2 +seq |1,2 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_ecic w1b w1u b1c w1c chke +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ecic: CREATE INDEX CONCURRENTLY t_k ON t ((k * 2)); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ecic: <... completed> +step chke: SELECT * FROM chk('k * 2 = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_ecic w1b w1u b1c w1c chke +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ecic: CREATE INDEX CONCURRENTLY t_k ON t ((k * 2)); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ecic: <... completed> +step chke: SELECT * FROM chk('k * 2 = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_pcic w1b w1u b1c w1c chkp +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_pcic: CREATE INDEX CONCURRENTLY t_k ON t (k) WHERE k < 1000; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_pcic: <... completed> +step chkp: SELECT * FROM chk('k = 100 AND k < 1000'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_pcic w1b w1u b1c w1c chkp +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_pcic: CREATE INDEX CONCURRENTLY t_k ON t (k) WHERE k < 1000; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_pcic: <... completed> +step chkp: SELECT * FROM chk('k = 100 AND k < 1000'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_icic w1b w1v b1c w1c chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_icic: CREATE INDEX CONCURRENTLY t_k ON t (k) INCLUDE (v); +step w1b: BEGIN; +step w1v: UPDATE t SET v = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_icic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_icic w1b w1v b1c w1c chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_icic: CREATE INDEX CONCURRENTLY t_k ON t (k) INCLUDE (v); +step w1b: BEGIN; +step w1v: UPDATE t SET v = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_icic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_uidx: CREATE UNIQUE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step w2i100: INSERT INTO t VALUES (22, 100); +ERROR: duplicate key value violates unique constraint "t_k" +step w2i1: INSERT INTO t VALUES (21, 1); +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |21 +seq |21 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_uidx: CREATE UNIQUE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step w2i100: INSERT INTO t VALUES (22, 100); +ERROR: duplicate key value violates unique constraint "t_k" +step w2i1: INSERT INTO t VALUES (21, 1); +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |21 +seq |21 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_idx w1b w1u c_dic w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_idx w1b w1u c_dic w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_dup: UPDATE t SET k = 2 WHERE id = 1; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +ERROR: could not create unique index "t_k" +step c_undup: UPDATE t SET k = 1 WHERE id = 1; +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |f |f +(1 row) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step w1b: BEGIN; +step w1n: UPDATE t SET v = 1000 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +t +(1 row) + +step w1u2: UPDATE t SET k = 200 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +f +(1 row) + +step w1c: COMMIT; +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |t +(1 row) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_dup: UPDATE t SET k = 2 WHERE id = 1; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +ERROR: could not create unique index "t_k" +step c_undup: UPDATE t SET k = 1 WHERE id = 1; +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |f |f +(1 row) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step w1b: BEGIN; +step w1n: UPDATE t SET v = 1000 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +f +(1 row) + +step w1u2: UPDATE t SET k = 200 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +f +(1 row) + +step w1c: COMMIT; +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |t +(1 row) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step c_attach: + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); + +injection_points_set_local +-------------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step r_wake: + SELECT injection_points_detach('reindex-relation-concurrently-before-swap'); + SELECT injection_points_wakeup('reindex-relation-concurrently-before-swap'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +step w2r: ROLLBACK; +step c_ric: <... completed> +step c_done: +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step c_attach: + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); + +injection_points_set_local +-------------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step r_wake: + SELECT injection_points_detach('reindex-relation-concurrently-before-swap'); + SELECT injection_points_wakeup('reindex-relation-concurrently-before-swap'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +step w2r: ROLLBACK; +step c_ric: <... completed> +step c_done: +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + diff --git a/src/test/modules/injection_points/expected/flux_commit_window.out b/src/test/modules/injection_points/expected/flux_commit_window.out new file mode 100644 index 0000000000000..ed775c6eb779e --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_commit_window.out @@ -0,0 +1,29 @@ +Parsed test spec with 3 sessions + +starting permutation: s1u s2u s3w s2r +injection_points_attach +----------------------- + +(1 row) + +step s1u: UPDATE fcw SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fcw SET v = v + 1 WHERE id = 1; +step s3w: SELECT injection_points_wakeup('commit-before-clog-update'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1u: <... completed> +step s2u: <... completed> +step s2r: SELECT v FROM fcw WHERE id = 1; +v +- +2 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_repack.out b/src/test/modules/injection_points/expected/flux_repack.out new file mode 100644 index 0000000000000..622b6874105dc --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_repack.out @@ -0,0 +1,3103 @@ +Parsed test spec with 8 sessions + +starting permutation: pick_fx rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_other w_k truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_other w_k truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_other w_ins wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261| 8261|12003|16000|067aa91d50e89e3915ee51dc80f7f74e| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_other w_ins wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261| 8261|12003|16000|067aa91d50e89e3915ee51dc80f7f74e| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_sp wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_sp: + UPDATE t SET k = 4000 WHERE id = 40; + SAVEPOINT a; + UPDATE t SET k = 4001, id = 1040 WHERE id = 40; + DELETE FROM t WHERE id = 41; + INSERT INTO t VALUES (1043, 1043, 'sp', NULL); + UPDATE t SET v = repeat('s', 2000) WHERE id = 11; + ROLLBACK TO SAVEPOINT a; + UPDATE t SET v = 'after' WHERE id = 41; + SAVEPOINT b; + INSERT INTO t VALUES (1044, 1044, 'kept', NULL); + RELEASE SAVEPOINT b; + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8304|12264|11909|16000|907eafa90f7ce59a7242e06214603fa5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_sp wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_sp: + UPDATE t SET k = 4000 WHERE id = 40; + SAVEPOINT a; + UPDATE t SET k = 4001, id = 1040 WHERE id = 40; + DELETE FROM t WHERE id = 41; + INSERT INTO t VALUES (1043, 1043, 'sp', NULL); + UPDATE t SET v = repeat('s', 2000) WHERE id = 11; + ROLLBACK TO SAVEPOINT a; + UPDATE t SET v = 'after' WHERE id = 41; + SAVEPOINT b; + INSERT INTO t VALUES (1044, 1044, 'kept', NULL); + RELEASE SAVEPOINT b; + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8304|12264|11909|16000|907eafa90f7ce59a7242e06214603fa5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_upsert truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 122| 10391|14331|11905|16000|50f34a2add247dd21bf86459d1dda042| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_upsert truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 122| 10391|14331|11905|16000|50f34a2add247dd21bf86459d1dda042| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_toast truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9326|10326|11905|16000|bb5b2535bbf84bdb49b76d69d72da293| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_toast truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9326|10326|11905|16000|bb5b2535bbf84bdb49b76d69d72da293| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_prep: PREPARE TRANSACTION 'rp1'; +step pc1: COMMIT PREPARED 'rp1'; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 8260| 9339|13900|16000|40a27fe85c8cc1ad079bb79ed9f1cafa| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_prep: PREPARE TRANSACTION 'rp1'; +step pc1: COMMIT PREPARED 'rp1'; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 8260| 9339|13900|16000|40a27fe85c8cc1ad079bb79ed9f1cafa| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx pp rp pc0 hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step pp: + BEGIN; + UPDATE t SET k = 8000, v = repeat('p', 100) WHERE id = 80; + UPDATE t SET id = 1081 WHERE id = 81; + PREPARE TRANSACTION 'rp0'; + +step rp: REPACK (CONCURRENTLY) t; +step pc0: COMMIT PREPARED 'rp0'; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 9261|16181|12003|16000|6c96eae3861ba706cfa636f24f6b62f5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp pp rp pc0 hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step pp: + BEGIN; + UPDATE t SET k = 8000, v = repeat('p', 100) WHERE id = 80; + UPDATE t SET id = 1081 WHERE id = 81; + PREPARE TRANSACTION 'rp0'; + +step rp: REPACK (CONCURRENTLY) t; +step pc0: COMMIT PREPARED 'rp0'; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 9261|16181|12003|16000|6c96eae3861ba706cfa636f24f6b62f5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rrb rp hold w_k truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rrb rp hold w_k truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_k rrb truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_k rrb truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|13287|13803|16000|2f6cc286819633d865aa7a52d6509356| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|13287|13803|16000|2f6cc286819633d865aa7a52d6509356| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wc: COMMIT; +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 123| 14458|20477|13713|16000|43655c13bb425e94e41df2864e20f237| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wc: COMMIT; +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 123| 14458|20477|13713|16000|43655c13bb425e94e41df2864e20f237| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx wxid rp w_same w_k wc hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step wxid: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step w_same: UPDATE t SET v = repeat('c', 100) WHERE id = 70; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step wc: COMMIT; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261|10340|12003|16000|a0ab81a914c0f48975de77d063e1a0d1| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp wxid rp w_same w_k wc hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step wxid: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step w_same: UPDATE t SET v = repeat('c', 100) WHERE id = 70; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step wc: COMMIT; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261|10340|12003|16000|a0ab81a914c0f48975de77d063e1a0d1| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_repack_decode.out b/src/test/modules/injection_points/expected/flux_repack_decode.out new file mode 100644 index 0000000000000..8c3f69692939a --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_repack_decode.out @@ -0,0 +1,129 @@ +Parsed test spec with 2 sessions + +starting permutation: pick_fx skip rp s2_changes s2_wake decode +?column? +-------- +init +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE fx RENAME TO t; +step skip: + SELECT count(*) > 0 AS skipped FROM pg_logical_slot_get_changes('s', NULL, NULL); + +skipped +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step s2_changes: + UPDATE t SET t = 'short' WHERE i = 1; + UPDATE t SET k = 20 WHERE i = 2; + UPDATE t SET t = repeat(md5('new'), 100) WHERE i = 3; + INSERT INTO t VALUES (4, 4, 'four'); + DELETE FROM t WHERE i = 4; + +step s2_wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step decode: + SELECT regexp_replace(data, '''[0-9a-f]+''', '''''', 'g') AS data + FROM pg_logical_slot_get_changes('s', NULL, NULL, 'include-rewrites', '1', + 'skip-empty-xacts', '1', 'include-xids', '0'); + +data +-------------------------------------------------------------------------------- +BEGIN +table public.t: UPDATE: i[integer]:1 k[integer]:1 t[text]:'short' +table public.t: UPDATE: i[integer]:2 k[integer]:20 t[text]:unchanged-toast-datum +table public.t: UPDATE: i[integer]:3 k[integer]:3 t[text]:'' +table public.t: INSERT: i[integer]:4 k[integer]:4 t[text]:'four' +table public.t: DELETE: i[integer]:4 +COMMIT +(7 rows) + +injection_points_detach +----------------------- + +(1 row) + +pg_drop_replication_slot +------------------------ + +(1 row) + + +starting permutation: pick_hp skip rp s2_changes s2_wake decode +?column? +-------- +init +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE hp RENAME TO t; +step skip: + SELECT count(*) > 0 AS skipped FROM pg_logical_slot_get_changes('s', NULL, NULL); + +skipped +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step s2_changes: + UPDATE t SET t = 'short' WHERE i = 1; + UPDATE t SET k = 20 WHERE i = 2; + UPDATE t SET t = repeat(md5('new'), 100) WHERE i = 3; + INSERT INTO t VALUES (4, 4, 'four'); + DELETE FROM t WHERE i = 4; + +step s2_wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step decode: + SELECT regexp_replace(data, '''[0-9a-f]+''', '''''', 'g') AS data + FROM pg_logical_slot_get_changes('s', NULL, NULL, 'include-rewrites', '1', + 'skip-empty-xacts', '1', 'include-xids', '0'); + +data +-------------------------------------------------------------------------------- +BEGIN +table public.t: UPDATE: i[integer]:1 k[integer]:1 t[text]:'short' +table public.t: UPDATE: i[integer]:2 k[integer]:20 t[text]:unchanged-toast-datum +table public.t: UPDATE: i[integer]:3 k[integer]:3 t[text]:'' +table public.t: INSERT: i[integer]:4 k[integer]:4 t[text]:'four' +table public.t: DELETE: i[integer]:4 +COMMIT +(7 rows) + +injection_points_detach +----------------------- + +(1 row) + +pg_drop_replication_slot +------------------------ + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_update_cas_race.out b/src/test/modules/injection_points/expected/flux_update_cas_race.out new file mode 100644 index 0000000000000..2f6f185ce42eb --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_update_cas_race.out @@ -0,0 +1,314 @@ +Parsed test spec with 3 sessions + +starting permutation: pick_fx s1b s1k s2k s1r wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_hp s1b s1k s2k s1r wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_fx s1b s1k s2k s1c wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1c: COMMIT; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_hp s1b s1k s2k s1c wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1c: COMMIT; +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_fx s2k2 s1b s1k wake s1r s2done back chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; +step s2k2: UPDATE kw SET k = 200, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s1r: ROLLBACK; +step s2k2: <... completed> +id| k|v +--+---+- + 3|200|2 +(1 row) + +step s2done: +step back: UPDATE kw SET k = 3 WHERE id = 3; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id|k|v +---+--+-+- +idx| 3|3|2 +(1 row) + +via|id|k|v +---+--+-+- +seq| 3|3|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_hp s2k2 s1b s1k wake s1r s2done back chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; +step s2k2: UPDATE kw SET k = 200, v = 2 WHERE id = 3 RETURNING id, k, v; +id| k|v +--+---+- + 3|200|2 +(1 row) + +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s1r: ROLLBACK; +step s2done: +step back: UPDATE kw SET k = 3 WHERE id = 3; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id|k|v +---+--+-+- +idx| 3|3|2 +(1 row) + +via|id|k|v +---+--+-+- +seq| 3|3|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_update_redecide_queue.out b/src/test/modules/injection_points/expected/flux_update_redecide_queue.out new file mode 100644 index 0000000000000..13305311c20d8 --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_update_redecide_queue.out @@ -0,0 +1,141 @@ +Parsed test spec with 4 sessions + +starting permutation: pick_fx s1b s1k s2k s3d s1r sync wake s3sel s2det +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE ur_fx RENAME TO kw; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s3d: DELETE FROM kw WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step sync: + DO $$ + DECLARE + n int := 0; + BEGIN + LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_activity a2, pg_stat_activity a3 + WHERE a2.application_name LIKE '%/s2' + AND a3.application_name LIKE '%/s3' + AND (a2.wait_event = 'flux-update-redecide' OR a2.state = 'idle') + AND (a3.state = 'idle' OR a2.pid = ANY (pg_blocking_pids(a3.pid)))); + n := n + 1; + IF n > 30000 THEN + RAISE EXCEPTION 's2 and s3 did not settle'; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + END $$; + +step wake: + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-redecide') THEN + PERFORM injection_points_wakeup('flux-update-redecide'); + END IF; + END $$; + +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s3d: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s3sel: SELECT id, k, v FROM kw ORDER BY id; +id|k|v +--+-+- + 1|1|0 + 2|2|0 + 4|4|0 + 5|5|0 +(4 rows) + +step s2det: SELECT injection_points_detach('flux-update-redecide'); +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp s1b s1k s2k s3d s1r sync wake s3sel s2det +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE ur_hp RENAME TO kw; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s3d: DELETE FROM kw WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s3d: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step sync: + DO $$ + DECLARE + n int := 0; + BEGIN + LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_activity a2, pg_stat_activity a3 + WHERE a2.application_name LIKE '%/s2' + AND a3.application_name LIKE '%/s3' + AND (a2.wait_event = 'flux-update-redecide' OR a2.state = 'idle') + AND (a3.state = 'idle' OR a2.pid = ANY (pg_blocking_pids(a3.pid)))); + n := n + 1; + IF n > 30000 THEN + RAISE EXCEPTION 's2 and s3 did not settle'; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + END $$; + +step wake: + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-redecide') THEN + PERFORM injection_points_wakeup('flux-update-redecide'); + END IF; + END $$; + +step s3sel: SELECT id, k, v FROM kw ORDER BY id; +id|k|v +--+-+- + 1|1|0 + 2|2|0 + 4|4|0 + 5|5|0 +(4 rows) + +step s2det: SELECT injection_points_detach('flux-update-redecide'); +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/repack_other_table.out b/src/test/modules/injection_points/expected/repack_other_table.out new file mode 100644 index 0000000000000..b0897fdc6fdfb --- /dev/null +++ b/src/test/modules/injection_points/expected/repack_other_table.out @@ -0,0 +1,131 @@ +Parsed test spec with 2 sessions + +starting permutation: pick_fx rp w_dml wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE ofx RENAME TO o; +step rp: REPACK (CONCURRENTLY) r; +step w_dml: + INSERT INTO o VALUES (1001, 1001, 'other'); + UPDATE o SET k = 2222, v = 'other' WHERE id = 2; + UPDATE o SET id = 1003 WHERE id = 3; + DELETE FROM o WHERE id = 4; + INSERT INTO o SELECT g, g, 'other' FROM generate_series(1010, 1019) g; + INSERT INTO o VALUES (5, 0, 'other') ON CONFLICT (id) DO UPDATE SET k = 5555; + INSERT INTO o VALUES (1020, 0, 'other') ON CONFLICT (id) DO NOTHING; + UPDATE o SET v = repeat('x', 2000) WHERE id = 6; + SELECT (ctid::text::point)[0] > 0 AS moved FROM o WHERE id = 6; + DO $$ BEGIN + EXECUTE format('COPY o FROM %L', + current_setting('data_directory') || '/repack_other_table.dat'); + END $$; + +moved +----- +t +(1 row) + +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: + SELECT count(*), sum(id), sum(k), string_agg(DISTINCT v, ',') AS v FROM r; + SELECT bt_index_check(indexrelid, true) FROM pg_index + WHERE indrelid = 'r'::regclass; + SELECT count(*), sum(id), sum(k) FROM o; + +count|sum|sum|v +-----+---+---+- + 20|210|210|r +(1 row) + +bt_index_check +-------------- + + +(2 rows) + +count| sum| sum +-----+-----+------ + 141|30767|754168 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp w_dml wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE ohp RENAME TO o; +step rp: REPACK (CONCURRENTLY) r; +step w_dml: + INSERT INTO o VALUES (1001, 1001, 'other'); + UPDATE o SET k = 2222, v = 'other' WHERE id = 2; + UPDATE o SET id = 1003 WHERE id = 3; + DELETE FROM o WHERE id = 4; + INSERT INTO o SELECT g, g, 'other' FROM generate_series(1010, 1019) g; + INSERT INTO o VALUES (5, 0, 'other') ON CONFLICT (id) DO UPDATE SET k = 5555; + INSERT INTO o VALUES (1020, 0, 'other') ON CONFLICT (id) DO NOTHING; + UPDATE o SET v = repeat('x', 2000) WHERE id = 6; + SELECT (ctid::text::point)[0] > 0 AS moved FROM o WHERE id = 6; + DO $$ BEGIN + EXECUTE format('COPY o FROM %L', + current_setting('data_directory') || '/repack_other_table.dat'); + END $$; + +moved +----- +t +(1 row) + +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: + SELECT count(*), sum(id), sum(k), string_agg(DISTINCT v, ',') AS v FROM r; + SELECT bt_index_check(indexrelid, true) FROM pg_index + WHERE indrelid = 'r'::regclass; + SELECT count(*), sum(id), sum(k) FROM o; + +count|sum|sum|v +-----+---+---+- + 20|210|210|r +(1 row) + +bt_index_check +-------------- + + +(2 rows) + +count| sum| sum +-----+-----+------ + 141|30767|754168 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/vacuum_progress.out b/src/test/modules/injection_points/expected/vacuum_progress.out new file mode 100644 index 0000000000000..b4fe626d0501a --- /dev/null +++ b/src/test/modules/injection_points/expected/vacuum_progress.out @@ -0,0 +1,73 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_vac_heap s2_round0 s2_wake s1_done s1_rows_heap +step s1_vac_heap: VACUUM (PARALLEL 0) vp_heap; +step s2_round0: SELECT * FROM vp_progress(0); +rel |phase |total_is_size|scanned|vacuumed|index_vacuum_count|max_dead_tuple_bytes|have_dead_bytes|num_dead_item_ids|indexes_total|indexes_processed|at_index|delayed|mode |started_by +-------+-----------------+-------------+-------+--------+------------------+--------------------+---------------+-----------------+-------------+-----------------+--------+-------+------+---------- +vp_heap|vacuuming indexes|t |all |none | 0| 1048576|t | 1000| 1| 0|t |t |normal|manual +(1 row) + +step s2_wake: SELECT injection_points_wakeup('vacuum-bulkdel-one-index'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1_vac_heap: <... completed> +step s1_done: + SELECT count(*) AS progress_rows FROM pg_stat_progress_vacuum + WHERE pid = pg_backend_pid(); + +progress_rows +------------- + 0 +(1 row) + +step s1_rows_heap: SELECT count(*), sum(id) FROM vp_heap; +count| sum +-----+------- + 2000|3000000 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: s1_vac_flux s2_round0 s2_wake s1_done s1_rows_flux +step s1_vac_flux: VACUUM (PARALLEL 0) vp_flux; +step s2_round0: SELECT * FROM vp_progress(0); +rel |phase |total_is_size|scanned|vacuumed|index_vacuum_count|max_dead_tuple_bytes|have_dead_bytes|num_dead_item_ids|indexes_total|indexes_processed|at_index|delayed|mode |started_by +-------+-----------------+-------------+-------+--------+------------------+--------------------+---------------+-----------------+-------------+-----------------+--------+-------+------+---------- +vp_flux|vacuuming indexes|t |all |none | 0| 1048576|t | 1000| 1| 0|t |t |normal|manual +(1 row) + +step s2_wake: SELECT injection_points_wakeup('vacuum-bulkdel-one-index'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1_vac_flux: <... completed> +step s1_done: + SELECT count(*) AS progress_rows FROM pg_stat_progress_vacuum + WHERE pid = pg_backend_pid(); + +progress_rows +------------- + 0 +(1 row) + +step s1_rows_flux: SELECT count(*), sum(id) FROM vp_flux; +count| sum +-----+------- + 2000|3000000 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build index b4a0079484b75..568514d619405 100644 --- a/src/test/modules/injection_points/meson.build +++ b/src/test/modules/injection_points/meson.build @@ -43,6 +43,14 @@ injection_points_isolation = [ 'syscache-update-pruned', 'heap_lock_update', 'on_conflict_probe_window', + 'repack_other_table', + 'vacuum_progress', + 'flux_commit_window', + 'flux_cic', + 'flux_update_cas_race', + 'flux_update_redecide_queue', + 'flux_repack', + 'flux_repack_decode', ] # wait_cleanup terminates another backend, whose FATAL message can be lost diff --git a/src/test/modules/injection_points/specs/flux_cic.spec b/src/test/modules/injection_points/specs/flux_cic.spec new file mode 100644 index 0000000000000..4c604c269350a --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_cic.spec @@ -0,0 +1,239 @@ +# FLUX: CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY under concurrent +# key UPDATEs. +# +# FLUX updates a row in place, at the same TID, even when an indexed column +# changes: it inserts the new (key, TID) entry and delete-marks the old one +# in every ready index. A concurrent build cannot rely on that for the index +# it is building. Its phase 2 indexes the rows a snapshot sees, and its +# validation (phase 3) adds only the TIDs the index does not have yet. A key +# UPDATE that sees the new index not yet ready skips it, so if the build's +# snapshot missed that UPDATE, the index keeps the old key for a TID the +# validation then skips. Heap avoids this because such an UPDATE is never HOT: +# the new version gets a new TID, which the validation finds missing. +# +# Each scenario runs twice, on a FLUX table and on its heap twin (pick_fx or +# pick_hp renames one of them to t); heap is the control and both must give +# the same answers. chk() compares an index scan with a seqscan for a qual +# and runs amcheck (heapallindexed, checkunique) on every valid index of t, +# reporting its error if it finds one. +# +# The windows between the phases are held open by lock holders: blocker b1 +# holds RowExclusiveLock before the build starts, so the build waits for it +# (phase 2 wait) after its catalog entry is visible but before it takes its +# snapshot; b2 takes the lock while the build waits, so the build later waits +# for it with the index ready (phase 3 wait) before validating. For +# REINDEX CONCURRENTLY the swap is held at an injection point. + +setup +{ + CREATE EXTENSION amcheck; + CREATE EXTENSION injection_points; + CREATE TABLE fx (id int PRIMARY KEY, k int, v int) USING flux + WITH (autovacuum_enabled = off); + CREATE TABLE hp (id int PRIMARY KEY, k int, v int) USING heap + WITH (autovacuum_enabled = off); + INSERT INTO fx SELECT g, g, g FROM generate_series(1, 20) g; + INSERT INTO hp SELECT g, g, g FROM generate_series(1, 20) g; + + -- whether row id is still where tid_at() last found it: an in-place + -- UPDATE keeps the TID, a moved row has a new one + CREATE TABLE tid_seen (id int PRIMARY KEY, tid tid); + CREATE FUNCTION tid_at(rid int) RETURNS void LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO tid_seen SELECT id, ctid FROM t WHERE id = rid + ON CONFLICT (id) DO UPDATE SET tid = excluded.tid; + END $$; + CREATE FUNCTION tid_kept(rid int) RETURNS bool LANGUAGE plpgsql AS $$ + BEGIN + RETURN (SELECT s.tid = t.ctid FROM tid_seen s JOIN t USING (id) + WHERE id = rid); + END $$; + + -- ids that match qual through an index scan and through a seqscan, then + -- amcheck every valid index of t + CREATE FUNCTION chk(qual text) RETURNS TABLE (via text, ids text) + LANGUAGE plpgsql AS $$ + DECLARE + q text := format('SELECT string_agg(id::text, '','' ORDER BY id) FROM t WHERE %s', qual); + r text; + BEGIN + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + EXECUTE q INTO r; + via := 'idx'; ids := r; RETURN NEXT; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'off', true); + EXECUTE q INTO r; + via := 'seq'; ids := r; RETURN NEXT; + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + via := 'amcheck'; ids := 'ok'; + BEGIN + PERFORM bt_index_check(i.indexrelid, true, true) + FROM pg_index i WHERE i.indrelid = 't'::regclass AND i.indisvalid; + EXCEPTION WHEN OTHERS THEN + ids := SQLERRM; + END; + RETURN NEXT; + END $$; +} + +teardown +{ + DROP TABLE IF EXISTS fx, hp, t, tid_seen; + DROP FUNCTION chk, tid_at, tid_kept; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +session b1 +step b1l { BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; } +step b1c { COMMIT; } + +session b2 +step b2l { BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; } +step b2c { COMMIT; } + +session c +step c_attach { + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); +} +step pick_fx { ALTER TABLE fx RENAME TO t; } +step pick_hp { ALTER TABLE hp RENAME TO t; } +step c_idx { CREATE INDEX t_k ON t (k); } +step c_uidx { CREATE UNIQUE INDEX t_k ON t (k); } +step c_cic { CREATE INDEX CONCURRENTLY t_k ON t (k); } +step c_ucic { CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); } +step c_ecic { CREATE INDEX CONCURRENTLY t_k ON t ((k * 2)); } +step c_icic { CREATE INDEX CONCURRENTLY t_k ON t (k) INCLUDE (v); } +step c_pcic { CREATE INDEX CONCURRENTLY t_k ON t (k) WHERE k < 1000; } +step c_ric { REINDEX INDEX CONCURRENTLY t_k; } +step c_dic { DROP INDEX CONCURRENTLY t_k; } +step c_dup { UPDATE t SET k = 2 WHERE id = 1; } +step c_undup { UPDATE t SET k = 1 WHERE id = 1; } +step c_tid { SELECT tid_at(1); } +# Cannot start before the session's previous step (c_ric) has finished. +step c_done { } +step c_state { + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; +} + +session w1 +step w1b { BEGIN; } +step w1u { UPDATE t SET k = 100 WHERE id = 1; } +step w1d { UPDATE t SET k = 2 WHERE id = 1; } +step w1v { UPDATE t SET v = 100 WHERE id = 1; } +step w1x { DELETE FROM t WHERE id = 1; } +step w1i { INSERT INTO t VALUES (23, 100); } +step w1n { UPDATE t SET v = 1000 WHERE id = 1; SELECT tid_kept(1) AS in_place; } +step w1u2 { UPDATE t SET k = 200 WHERE id = 1; SELECT tid_kept(1) AS in_place; } +step w1c { COMMIT; } +step w1r { ROLLBACK; } + +session w2 +step w2b { BEGIN; } +step w2u { UPDATE t SET k = 300 WHERE id = 3; } +step w2a { UPDATE t SET k = 1 WHERE id = 1; } +step w2v { UPDATE t SET k = 500 WHERE id = 23; } +step w2r { ROLLBACK; } +step w2c { COMMIT; } +step w2i1 { INSERT INTO t VALUES (21, 1); } +step w2i100 { INSERT INTO t VALUES (22, 100); } + +session r +step r_wake { + SELECT injection_points_detach('reindex-relation-concurrently-before-swap'); + SELECT injection_points_wakeup('reindex-relation-concurrently-before-swap'); +} +step rrb { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; } +step rrc { COMMIT; } +step chk200 { SELECT * FROM chk('k = 200'); } +step chk1 { SELECT * FROM chk('k = 1'); } +step chk2 { SELECT * FROM chk('k = 2'); } +step chk100 { SELECT * FROM chk('k = 100'); } +step chk300 { SELECT * FROM chk('k = 300'); } +step chk3 { SELECT * FROM chk('k = 3'); } +step chke { SELECT * FROM chk('k * 2 = 200'); } +step chk500 { SELECT * FROM chk('k = 500'); } +step chkp { SELECT * FROM chk('k = 100 AND k < 1000'); } + +# Key UPDATE in phase 2, after the index is visible, invisible to the build +# snapshot, committed before validation. +permutation pick_fx b1l c_cic w1b w1u b1c w1c chk100 chk1 +permutation pick_hp b1l c_cic w1b w1u b1c w1c chk100 chk1 + +# A -> B in phase 2, B -> A in phase 3 (index ready, not yet validated). +permutation pick_fx b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 +permutation pick_hp b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 + +# DELETE and INSERT in phase 2. +permutation pick_fx b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 +permutation pick_hp b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 + +# Key UPDATE in phase 2 that rolls back after the build. +permutation pick_fx b1l c_cic w1b w1u b1c w1r chk100 chk1 +permutation pick_hp b1l c_cic w1b w1u b1c w1r chk100 chk1 + +# Key UPDATE in phase 3, still running through validation, rolled back +# after the index is valid: the rollback must reverse it in the new index. +permutation pick_fx b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 +permutation pick_hp b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 + +# INSERT in phase 2, then in phase 3 (index ready) a key UPDATE of that row +# that runs through validation and rolls back after it. The validation must +# index the version its snapshot sees, and the rollback must leave it there. +permutation pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 +permutation pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 + +# The same UPDATE committed after a REPEATABLE READ snapshot that predates it +# was taken: the snapshot still needs the row under its old key. +permutation pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 +permutation pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 + +# UNIQUE: an UPDATE in phase 2 makes a duplicate; the build must fail. +permutation pick_fx b1l c_ucic w1b w1d b1c w1c c_state chk2 +permutation pick_hp b1l c_ucic w1b w1d b1c w1c c_state chk2 + +# Expression and partial indexes. +permutation pick_fx b1l c_ecic w1b w1u b1c w1c chke +permutation pick_hp b1l c_ecic w1b w1u b1c w1c chke +permutation pick_fx b1l c_pcic w1b w1u b1c w1c chkp +permutation pick_hp b1l c_pcic w1b w1u b1c w1c chkp + +# An UPDATE of an INCLUDE column only. +permutation pick_fx b1l c_icic w1b w1v b1c w1c chk1 +permutation pick_hp b1l c_icic w1b w1v b1c w1c chk1 + +# REINDEX CONCURRENTLY of a unique index with a key UPDATE in phase 2: the +# rebuilt index must still reject a duplicate of the new key and accept the +# old one. +permutation pick_fx c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 +permutation pick_hp c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 + +# DROP INDEX CONCURRENTLY: a key UPDATE made while the index is still +# valid commits after the drop has cleared indisvalid; another one is made +# with the index invalid, before it is dead. +permutation pick_fx c_idx w1b w1u c_dic w1c chk100 chk1 +permutation pick_hp c_idx w1b w1u c_dic w1c chk100 chk1 +permutation pick_fx c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 +permutation pick_hp c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 + +# A UNIQUE build that fails leaves an invalid index. On FLUX a key UPDATE +# then moves the row and an UPDATE of a column no index uses stays in place +# (in_place; heap gives every updated row a new TID, so its twin says f +# both times). REINDEX INDEX CONCURRENTLY of the invalid index, with a key +# UPDATE in its phase 2, makes it valid and complete. +permutation pick_fx c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 +permutation pick_hp c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 + +# REINDEX CONCURRENTLY held before the swap; a key UPDATE made with both the +# old and the new index ready rolls back after the swap wait is released. +# This does not force the rollback to occur after the swap itself. +# After r_wake, c_ric may still be in the injection point's wait loop and +# so look blocked when w2r has finished; c_done keeps chk300 from starting +# before c_ric is done. +permutation pick_fx c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 +permutation pick_hp c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 diff --git a/src/test/modules/injection_points/specs/flux_commit_window.spec b/src/test/modules/injection_points/specs/flux_commit_window.spec new file mode 100644 index 0000000000000..11fd92864a0ab --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_commit_window.spec @@ -0,0 +1,49 @@ +# FLUX: an UPDATE that meets a row whose last writer is between PRE_COMMIT +# and its CLOG update must wait for it and then re-evaluate on its value. +# +# At PRE_COMMIT FLUX finalizes the writer's sLog ops and clears the on-page +# UNCOMMITTED hint, but the transaction is not committed in CLOG until +# RecordTransactionCommit. The commit-before-clog-update injection point +# holds s1 exactly in that window. The row's writer is still on the page +# (t_xmin of the in-place image), so s2 must wait on it rather than overwrite +# the value it cannot see yet; after s1 completes, s2 re-evaluates via +# EvalPlanQual and the row ends at 2 -- heap's result. Before the writer was +# read from the header, nothing on the page or in the sLog named s1 in this +# window except the transaction-duration tuple lock FLUX used to hold. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE fcw (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fcw VALUES (1, 0); +} + +teardown +{ + DROP TABLE fcw; + DROP EXTENSION injection_points; +} + +session s1 +setup +{ + SET synchronous_commit = on; + SELECT injection_points_set_local(); + SELECT injection_points_attach('commit-before-clog-update', 'wait'); +} +step s1u { UPDATE fcw SET v = v + 1 WHERE id = 1; } +teardown +{ + SELECT injection_points_detach('commit-before-clog-update'); +} + +session s2 +step s2u { UPDATE fcw SET v = v + 1 WHERE id = 1; } +step s2r { SELECT v FROM fcw WHERE id = 1; } + +session s3 +step s3w { SELECT injection_points_wakeup('commit-before-clog-update'); } + +# s1 (autocommit) stops after writing its commit record, before CLOG. s2's +# UPDATE must block until s3 lets s1 finish, then apply on top of s1's value. +permutation s1u s2u(s1u) s3w s2r diff --git a/src/test/modules/injection_points/specs/flux_repack.spec b/src/test/modules/injection_points/specs/flux_repack.spec new file mode 100644 index 0000000000000..ccc74e6bbdbe6 --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_repack.spec @@ -0,0 +1,397 @@ +# FLUX: REPACK (CONCURRENTLY) with concurrent data changes. +# +# REPACK (CONCURRENTLY) copies the rows its initial snapshot sees into a new +# table, then replays onto it, by replica identity key, the changes logical +# decoding finds committed after that snapshot. FLUX updates rows in place +# (also when a key changes), writes the old version to UNDO, and reverts an +# aborted transaction in place from UNDO; the copy must still read exactly +# what the snapshot sees, and the replay must neither miss a committed change +# nor apply an aborted one. +# +# Each scenario runs twice, on a FLUX table and on its heap twin (pick_fx or +# pick_hp renames one of them, and its indexes, to t); heap is the control +# and both must give the same answers. The changes are made while REPACK +# waits at repack-concurrently-before-lock (after the copy and the index +# builds, before the catch-up), unless a scenario says otherwise. "truth" +# saves what a reader sees just before REPACK resumes; chk() then reports +# whether the table was rewritten, a digest of its contents, how many rows +# differ from truth, whether an index scan of each index returns what a +# seqscan does, and amcheck (heapallindexed, checkunique) of every index. +# +# Row 10 sits on a full first page and v is stored PLAIN, so growing v moves +# the row (FLUX: out-of-place UPDATE, logged as XLOG_FLUX_SUPERSEDE); w is +# stored EXTERNAL, so rows 1-5 have their w in the TOAST table. + +setup +{ + CREATE EXTENSION amcheck; + CREATE EXTENSION injection_points; + CREATE TABLE fx (id int PRIMARY KEY, k int, v text, w text) USING flux + WITH (fillfactor = 100, autovacuum_enabled = off); + CREATE TABLE hp (id int PRIMARY KEY, k int, v text, w text) USING heap + WITH (fillfactor = 100, autovacuum_enabled = off); + ALTER TABLE fx ALTER COLUMN v SET STORAGE PLAIN, + ALTER COLUMN w SET STORAGE EXTERNAL; + ALTER TABLE hp ALTER COLUMN v SET STORAGE PLAIN, + ALTER COLUMN w SET STORAGE EXTERNAL; + CREATE INDEX fx_k ON fx (k); + CREATE INDEX hp_k ON hp (k); + INSERT INTO fx SELECT g, g, repeat('a', 100), + CASE WHEN g <= 5 THEN repeat(md5(g::text), 100) END + FROM generate_series(1, 120) g; + INSERT INTO hp SELECT * FROM fx; + + CREATE TABLE truth (id int, k int, v text, w text) USING heap; + -- other tables, changed during REPACK: their changes are not t's + CREATE TABLE ofx (id int PRIMARY KEY, k int, v text, w text) USING flux; + CREATE TABLE ohp (id int PRIMARY KEY, k int, v text, w text) USING heap; + INSERT INTO ofx SELECT * FROM fx WHERE id <= 30; + INSERT INTO ohp SELECT * FROM fx WHERE id <= 30; + CREATE TABLE rf (node oid) USING heap; + + -- wait until REPACK holds at the injection point (copy and index builds + -- done, catch-up not started) + CREATE FUNCTION wait_hold() RETURNS void LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1 .. 30000 LOOP + PERFORM pg_stat_clear_snapshot(); + IF EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event_type = 'InjectionPoint' + AND wait_event = 'repack-concurrently-before-lock') THEN + RETURN; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + RAISE EXCEPTION 'REPACK did not reach the injection point'; + END $$; + + -- wait until some process waits for the xid of isolation session sess + CREATE FUNCTION wait_for(sess text) RETURNS void LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1 .. 30000 LOOP + IF EXISTS (SELECT 1 FROM pg_locks w JOIN pg_locks h + ON h.locktype = 'transactionid' + AND h.transactionid = w.transactionid AND h.granted + JOIN pg_stat_activity a ON a.pid = h.pid + WHERE w.locktype = 'transactionid' AND NOT w.granted + AND a.application_name LIKE '%/' || sess) THEN + RETURN; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + RAISE EXCEPTION 'nobody waited for session %', sess; + END $$; + + CREATE FUNCTION chk() RETURNS TABLE (rewritten bool, nrows bigint, + sum_id bigint, sum_k bigint, len_v bigint, len_w bigint, + digest text, vs_truth bigint, pk_scan text, k_scan text, amcheck text) + LANGUAGE plpgsql AS $$ + DECLARE + seq_pk text; + seq_k text; + idx_pk text; + idx_k text; + BEGIN + rewritten := (SELECT c.relfilenode <> (SELECT node FROM rf) + FROM pg_class c WHERE c.relname = 't'); + SELECT count(*), sum(id), sum(k), sum(length(v)), sum(length(w)), + md5(string_agg(format('%s/%s/%s/%s', id, k, md5(v), md5(w)), + ',' ORDER BY id)) + INTO nrows, sum_id, sum_k, len_v, len_w, digest FROM t; + vs_truth := (SELECT count(*) FROM + ((SELECT * FROM t EXCEPT ALL SELECT * FROM truth) + UNION ALL + (SELECT * FROM truth EXCEPT ALL SELECT * FROM t)) d); + PERFORM set_config('enable_indexscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO seq_pk + FROM t WHERE id > -1000000; + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO seq_k + FROM t WHERE k > -1000000; + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO idx_pk + FROM t WHERE id > -1000000; + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO idx_k + FROM t WHERE k > -1000000; + pk_scan := CASE WHEN idx_pk IS NOT DISTINCT FROM seq_pk THEN 'same' + ELSE 'differs' END; + k_scan := CASE WHEN idx_k IS NOT DISTINCT FROM seq_k THEN 'same' + ELSE 'differs' END; + amcheck := 'ok'; + BEGIN + PERFORM bt_index_check(i.indexrelid, true, true) + FROM pg_index i WHERE i.indrelid = 't'::regclass; + EXCEPTION WHEN OTHERS THEN + amcheck := SQLERRM; + END; + RETURN NEXT; + END $$; +} + +teardown +{ + DROP TABLE IF EXISTS fx, hp, t, truth, rf, ofx, ohp; + DROP FUNCTION wait_hold, wait_for, chk; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +# The REPACK session. The injection point is attached for every process +# (injection_points_set_local() registers an exit callback each time a +# permutation starts, and this spec has more permutations than there are +# slots); only REPACK reaches it. +session s +setup +{ + RESET enable_sort; + RESET enable_indexscan; + SELECT injection_points_attach('repack-concurrently-before-lock', 'wait'); +} +step pick_fx { + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; +} +step pick_hp { + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; +} +step by_idx { SET enable_sort = off; } +step by_sort { SET enable_indexscan = off; } +step rp { REPACK (CONCURRENTLY) t; } +step rpi { REPACK (CONCURRENTLY) t USING INDEX t_pkey; } +step chk { SELECT * FROM chk(); } +teardown +{ + SELECT injection_points_detach('repack-concurrently-before-lock'); +} + +# Writers. +session w +step wb { BEGIN; } +step wc { COMMIT; } +step wr { ROLLBACK; } +step wxid { BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; } +step w_ins { INSERT INTO t VALUES (1001, 1001, 'new', NULL); } +step w_nonkey { UPDATE t SET v = repeat('n', 100) WHERE id = 20; } +step w_k { UPDATE t SET k = 2100 WHERE id = 21; } +step w_id { UPDATE t SET id = 1022 WHERE id = 22; } +step w_grow { + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; +} +step w_del { DELETE FROM t WHERE id = 23; } +step w_aba { + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; +} +step w_life { + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; +} +step w_a2b { UPDATE t SET id = 1031 WHERE id = 31; } +step w_b2a { UPDATE t SET id = 31, k = 3100 WHERE id = 1031; } +step w_ins2 { INSERT INTO t VALUES (1050, 1050, 'x', NULL); } +step w_upd2 { UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; } +step w_del2 { DELETE FROM t WHERE id = 1052; } +step w_sp { + UPDATE t SET k = 4000 WHERE id = 40; + SAVEPOINT a; + UPDATE t SET k = 4001, id = 1040 WHERE id = 40; + DELETE FROM t WHERE id = 41; + INSERT INTO t VALUES (1043, 1043, 'sp', NULL); + UPDATE t SET v = repeat('s', 2000) WHERE id = 11; + ROLLBACK TO SAVEPOINT a; + UPDATE t SET v = 'after' WHERE id = 41; + SAVEPOINT b; + INSERT INTO t VALUES (1044, 1044, 'kept', NULL); + RELEASE SAVEPOINT b; +} +step w_upsert { + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; +} +step w_toast { + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; +} +step w_prep { PREPARE TRANSACTION 'rp1'; } +step w_same { UPDATE t SET v = repeat('c', 100) WHERE id = 70; } +step w_after { UPDATE t SET v = 'later', k = 2200 WHERE id = 22; } +step w_other { + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; +} + +# Transactions that keep an xid open, so that the decoding's snapshot builder +# has to wait for them (see repack_commit_race.spec). +session x2 +step x2b { BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; } +step x2r { ROLLBACK; } +session x3 +step x3b { BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; } +step x3r { ROLLBACK; } + +# Holds REPACK between its snapshot and its copy: REPACK names its new table +# pg_temp_, so while g has created a table of that name and not +# committed, REPACK waits for g in make_new_heap. g gets its xid only after +# the snapshot builder stopped waiting for new transactions (x3), so REPACK's +# snapshot does not wait for g. +session g +step gb { + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; +} +step gr { ROLLBACK; } + +# A transaction prepared before REPACK starts. +session p +step pp { + BEGIN; + UPDATE t SET k = 8000, v = repeat('p', 100) WHERE id = 80; + UPDATE t SET id = 1081 WHERE id = 81; + PREPARE TRANSACTION 'rp0'; +} +step pc0 { COMMIT PREPARED 'rp0'; } +step pc1 { COMMIT PREPARED 'rp1'; } + +# An old snapshot. +session r +step rrb { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; } +step rrs { + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; +} +step rrc { COMMIT; } + +# Observer: waits for the hold, saves the expected contents, resumes REPACK. +session o +step hold { SELECT wait_hold(); } +step wx3 { SELECT wait_for('x3'); } +step wg { SELECT wait_for('g'); } +step truth { INSERT INTO truth SELECT * FROM t; } +step wake { + SELECT injection_points_wakeup('repack-concurrently-before-lock'); +} + +# F1: INSERT, non-key UPDATE, secondary-key UPDATE, identity-key UPDATE, an +# UPDATE that moves the row, DELETE; each its own transaction. +permutation pick_fx rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +permutation pick_hp rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +# Other tables, FLUX and heap, changed by the same keys during REPACK: none +# of their changes may reach t. +permutation pick_fx rp hold w_other w_k truth wake chk +permutation pick_hp rp hold w_other w_k truth wake chk +permutation pick_fx rp hold wb w_other w_ins wc truth wake chk +permutation pick_hp rp hold wb w_other w_ins wc truth wake chk +# The same, copying in index order and through a sort. +permutation pick_fx by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +permutation pick_hp by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +permutation pick_fx by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +permutation pick_hp by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +# The same changes in one transaction. +permutation pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk +permutation pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk + +# F2: one row changed many times: A->B->A of the identity key in one +# transaction and across transactions; INSERT, UPDATEs and DELETE of a new +# row in one transaction and across transactions. +permutation pick_fx rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +permutation pick_hp rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +permutation pick_fx rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk +permutation pick_hp rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk + +# F3: a transaction that changes everything and rolls back during REPACK +# (its revert runs at once), then committed changes of the same rows. +permutation pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk +permutation pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk + +# F4: ROLLBACK TO SAVEPOINT inside a committed transaction. +permutation pick_fx rp hold wb w_sp wc truth wake chk +permutation pick_hp rp hold wb w_sp wc truth wake chk + +# F5: INSERT ... ON CONFLICT DO UPDATE / DO NOTHING. +permutation pick_fx rp hold w_upsert truth wake chk +permutation pick_hp rp hold w_upsert truth wake chk + +# F6: TOAST: wide values inserted, replaced, kept across an UPDATE, deleted. +permutation pick_fx rp hold w_toast truth wake chk +permutation pick_hp rp hold w_toast truth wake chk + +# F7: a transaction prepared and committed during REPACK, and one prepared +# before REPACK starts, whose COMMIT PREPARED REPACK's snapshot waits for. +permutation pick_fx rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +permutation pick_hp rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +permutation pick_fx pp rp(*) pc0 hold w_ins truth wake chk +permutation pick_hp pp rp(*) pc0 hold w_ins truth wake chk + +# F8: snapshots taken before REPACK starts, and during it, used after it +# committed. REPACK (CONCURRENTLY) is not MVCC-safe: like heap, FLUX must +# show such a snapshot an empty table. +permutation pick_fx rrb rp hold w_k truth wake chk rrs rrc +permutation pick_hp rrb rp hold w_k truth wake chk rrs rrc +permutation pick_fx rp hold w_k rrb truth wake chk rrs rrc +permutation pick_hp rp hold w_k rrb truth wake chk rrs rrc + +# Changes in progress while REPACK copies: the snapshot builder waits for x2, +# then x3; w gets its xid once the builder waits for x3, so w is still running +# when the snapshot is taken, and its changes are decoded. The copy must read the +# versions before them (FLUX: from UNDO), and commit or rollback during the +# hold decides whether they are replayed. +permutation pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +permutation pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk + +# Changes committed after REPACK's snapshot, before its copy: the copy must +# read the versions the snapshot sees (FLUX: from UNDO) and the replay apply +# the changes once. Also a transaction that rolls back in that window, and +# one running at the snapshot that commits in it. +permutation pick_fx x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +permutation pick_fx x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +permutation pick_fx x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk + +# The same window, copying through the identity index and through a sort: +# key UPDATEs after the snapshot leave delete-marked entries under the old +# keys (FLUX), and each row must still be copied once. +permutation pick_fx by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +permutation pick_hp by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +permutation pick_fx by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +permutation pick_hp by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk + +# F11 (wal_level = replica, so REPACK raises the effective level): w has an +# xid before REPACK enables logical decoding, so w keeps logging without +# logical images (FLUX may take its same-size fast path) until it ends. +# REPACK's snapshot waits for w, so w's changes must be in the copy. +permutation pick_fx wxid rp w_same w_k wc hold w_ins truth wake chk +permutation pick_hp wxid rp w_same w_k wc hold w_ins truth wake chk diff --git a/src/test/modules/injection_points/specs/flux_repack_decode.spec b/src/test/modules/injection_points/specs/flux_repack_decode.spec new file mode 100644 index 0000000000000..2d2b4dbe8bbf4 --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_repack_decode.spec @@ -0,0 +1,67 @@ +# REPACK (CONCURRENTLY) of a FLUX table: the changes REPACK replays onto its +# transient table must not reach an output plugin, not even one that asks for +# the changes of table rewrites (see repack_decode.spec, for heap). Each +# permutation runs on a FLUX table and on its heap twin; heap is the control. + +setup +{ + SELECT 'init' FROM pg_create_logical_replication_slot('s', 'test_decoding'); +} +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE fx (i int PRIMARY KEY, k int, t text) USING flux; + CREATE TABLE hp (i int PRIMARY KEY, k int, t text) USING heap; + ALTER TABLE fx ALTER COLUMN t SET STORAGE EXTERNAL; + ALTER TABLE hp ALTER COLUMN t SET STORAGE EXTERNAL; + INSERT INTO fx SELECT g, g, repeat(md5(g::text), 100) + FROM generate_series(1, 3) g; + INSERT INTO hp SELECT * FROM fx; +} + +teardown +{ + DROP TABLE IF EXISTS fx, hp, t; + DROP EXTENSION injection_points; + SELECT pg_drop_replication_slot('s'); +} + +session s1 +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('repack-concurrently-before-lock', 'wait'); +} +step pick_fx { ALTER TABLE fx RENAME TO t; } +step pick_hp { ALTER TABLE hp RENAME TO t; } +step rp { REPACK (CONCURRENTLY) t; } +# Skip what the setup and the rename did. +step skip { + SELECT count(*) > 0 AS skipped FROM pg_logical_slot_get_changes('s', NULL, NULL); +} +step decode { + SELECT regexp_replace(data, '''[0-9a-f]+''', '''''', 'g') AS data + FROM pg_logical_slot_get_changes('s', NULL, NULL, 'include-rewrites', '1', + 'skip-empty-xacts', '1', 'include-xids', '0'); +} +teardown +{ + SELECT injection_points_detach('repack-concurrently-before-lock'); +} + +session s2 +# Replayed by REPACK: an in-place UPDATE of a non-key column to a short +# value, of a key, to a new TOASTed value, an INSERT and a DELETE. +step s2_changes { + UPDATE t SET t = 'short' WHERE i = 1; + UPDATE t SET k = 20 WHERE i = 2; + UPDATE t SET t = repeat(md5('new'), 100) WHERE i = 3; + INSERT INTO t VALUES (4, 4, 'four'); + DELETE FROM t WHERE i = 4; +} +step s2_wake { + SELECT injection_points_wakeup('repack-concurrently-before-lock'); +} + +permutation pick_fx skip rp s2_changes s2_wake decode +permutation pick_hp skip rp s2_changes s2_wake decode diff --git a/src/test/modules/injection_points/specs/flux_update_cas_race.spec b/src/test/modules/injection_points/specs/flux_update_cas_race.spec new file mode 100644 index 0000000000000..72d717cdbd074 --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_update_cas_race.spec @@ -0,0 +1,91 @@ +# An UPDATE decides whether it changes an indexed column, and which index +# entries change, from the image on the page, read without waiting. The row +# can change between that decision and the moment the UPDATE has it locked: +# a running writer can roll back, or a new one can write. Two checks keep +# the decision tied to the version it was made from (flux_same_version): +# +# - the same-size in-place path (CAS), which overwrites the row without +# index work when no indexed column changes, must find that version on the +# page. s1 changes k from 3 to 100; s2 sets k to 100 too, so it finds no +# key change; s1 rolls back before s2 locks the page. Overwriting the +# restored row (k = 3) without index work would leave it with k = 100 and +# no entry for it. +# +# - the copy of the old row that a key change forms its old index entries +# from must be that version too. s2 sets k to 200 and decides from the +# committed row (k = 3); s1 then changes k to 100, so s2's copy is s1's +# image; s1 rolls back. Delete-marking the entry of k = 100 instead of +# k = 3 would leave (3, TID) live, and changing k back to 3 would add a +# second (3, TID) entry. +# +# s2 stops at flux-update-after-decision; wake detaches the point (a second +# decision does not stop) and lets s2 go on; s2done waits for s2. The row +# is then read through the index under every key it may have and by a +# seqscan, and amcheck checks the index. Each permutation runs on FLUX +# (pick_fx) and heap (pick_hp), which must return the same rows; heap's s2 +# never stops at the injection point. + +setup +{ + CREATE EXTENSION injection_points; + CREATE EXTENSION amcheck; + CREATE TABLE cr_fx (id int, k int, v int) USING flux; + CREATE TABLE cr_hp (id int, k int, v int) USING heap; + CREATE INDEX cr_fx_k ON cr_fx (k); + CREATE INDEX cr_hp_k ON cr_hp (k); + INSERT INTO cr_fx SELECT g, g, 0 FROM generate_series(1, 5) g; + INSERT INTO cr_hp SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE IF EXISTS kw, cr_fx, cr_hp; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +session s1 +step pick_fx { ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; } +step pick_hp { ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; } +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +step back { UPDATE kw SET k = 3 WHERE id = 3; } + +session s2 +setup { + SELECT injection_points_set_local(); + SELECT injection_points_attach('flux-update-after-decision', 'wait'); +} +step s2k { UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; } +step s2k2 { UPDATE kw SET k = 200, v = 2 WHERE id = 3 RETURNING id, k, v; } +step s2done { } + +session s3 +step wake { + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; +} +step chk { + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); +} + +# CAS: s2 decides from s1's image. +permutation pick_fx s1b s1k s2k s1r wake s2done chk +permutation pick_hp s1b s1k s2k s1r wake s2done chk +permutation pick_fx s1b s1k s2k s1c wake s2done chk +permutation pick_hp s1b s1k s2k s1c wake s2done chk +# Copy of the old row: s1 writes after s2's decision. +permutation pick_fx s2k2 s1b s1k wake s1r s2done back chk +permutation pick_hp s2k2 s1b s1k wake s1r s2done back chk diff --git a/src/test/modules/injection_points/specs/flux_update_redecide_queue.spec b/src/test/modules/injection_points/specs/flux_update_redecide_queue.spec new file mode 100644 index 0000000000000..26274996299bc --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_update_redecide_queue.spec @@ -0,0 +1,87 @@ +# An UPDATE that waits for a writer of its row keeps its place in the queue +# of transactions waiting for that row, as heap_update does by holding the +# tuple lock until it is done. FLUX decides an UPDATE's index work from the +# image it reads before waiting; when the writer it waited for rolls back, +# the row is another version and the UPDATE decides again +# (flux-update-redecide). It must still hold the tuple lock then, or a +# DELETE queued behind it deletes the row first. +# +# s1 changes k, s2 sets k to the same value and waits for s1, s3 deletes the +# row and queues behind s2; s1 rolls back. On heap s2 updates the row and +# s3 then deletes s2's version. s2 and s3 run in autocommit, so neither +# needs a commit step that could wait behind the other. The step sync waits +# until s2 is at the injection point (or done) and s3 is either done or +# blocked by s2, whichever happens; wake then lets s2 go on, and s3sel +# reads the table once s3 is done. Each permutation runs on FLUX (pick_fx) +# and heap (pick_hp), which must return the same rows; heap's s2 does not +# stop at the injection point, so its steps complete right after s1r. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE ur_fx (id int, k int, v int) USING flux; + CREATE TABLE ur_hp (id int, k int, v int) USING heap; + CREATE INDEX ON ur_fx (k); + CREATE INDEX ON ur_hp (k); + INSERT INTO ur_fx SELECT g, g, 0 FROM generate_series(1, 5) g; + INSERT INTO ur_hp SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE IF EXISTS kw, ur_fx, ur_hp; + DROP EXTENSION injection_points; +} + +session s1 +step pick_fx { ALTER TABLE ur_fx RENAME TO kw; } +step pick_hp { ALTER TABLE ur_hp RENAME TO kw; } +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1r { ROLLBACK; } + +session s2 +setup { + SELECT injection_points_set_local(); + SELECT injection_points_attach('flux-update-redecide', 'wait'); +} +step s2k { UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; } +step s2det { SELECT injection_points_detach('flux-update-redecide'); } + +session s3 +step s3d { DELETE FROM kw WHERE id = 3 RETURNING id, k, v; } +step s3sel { SELECT id, k, v FROM kw ORDER BY id; } + +session s4 +step sync { + DO $$ + DECLARE + n int := 0; + BEGIN + LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_activity a2, pg_stat_activity a3 + WHERE a2.application_name LIKE '%/s2' + AND a3.application_name LIKE '%/s3' + AND (a2.wait_event = 'flux-update-redecide' OR a2.state = 'idle') + AND (a3.state = 'idle' OR a2.pid = ANY (pg_blocking_pids(a3.pid)))); + n := n + 1; + IF n > 30000 THEN + RAISE EXCEPTION 's2 and s3 did not settle'; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + END $$; +} +step wake { + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-redecide') THEN + PERFORM injection_points_wakeup('flux-update-redecide'); + END IF; + END $$; +} + +permutation pick_fx s1b s1k s2k(wake) s3d(s2k) s1r sync wake s3sel s2det +permutation pick_hp s1b s1k s2k(wake) s3d(s2k) s1r sync wake s3sel s2det diff --git a/src/test/modules/injection_points/specs/repack_other_table.spec b/src/test/modules/injection_points/specs/repack_other_table.spec new file mode 100644 index 0000000000000..6c97c497715d3 --- /dev/null +++ b/src/test/modules/injection_points/specs/repack_other_table.spec @@ -0,0 +1,83 @@ +# REPACK (CONCURRENTLY) of one table while another table changes. +# +# REPACK's decoding worker decodes the WAL of the whole database but must +# keep only the changes of the table it repacks (and its TOAST table); each +# rmgr's decode routine asks change_useless_for_repack() to drop the rest. +# The repacked table here is heap; the other table is FLUX in one run and +# heap (the control) in the other. Both must leave the repacked table as it +# was, and the other table's changes in the other table only. + +setup +{ + CREATE EXTENSION amcheck; + CREATE EXTENSION injection_points; + CREATE TABLE r (id int PRIMARY KEY, k int, v text) USING heap; + CREATE INDEX r_k ON r (k); + INSERT INTO r SELECT g, g, 'r' FROM generate_series(1, 20) g; + -- v is PLAIN and the first page full, so growing v moves the row + CREATE TABLE ofx (id int PRIMARY KEY, k int, v text) USING flux + WITH (fillfactor = 100); + CREATE TABLE ohp (id int PRIMARY KEY, k int, v text) USING heap + WITH (fillfactor = 100); + ALTER TABLE ofx ALTER COLUMN v SET STORAGE PLAIN; + ALTER TABLE ohp ALTER COLUMN v SET STORAGE PLAIN; + INSERT INTO ofx SELECT g, g * 100, repeat('o', 100) + FROM generate_series(1, 120) g; + INSERT INTO ohp SELECT * FROM ofx; + -- COPY FROM inserts in batches (FLUX: XLOG_FLUX_MULTI_INSERT) + DO $$ BEGIN + EXECUTE format('COPY (SELECT g, g, %L FROM generate_series(1030, 1039) g) TO %L', + 'copied', current_setting('data_directory') || '/repack_other_table.dat'); + END $$; +} + +teardown +{ + DROP TABLE IF EXISTS r, ofx, ohp, o; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +session s +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('repack-concurrently-before-lock', 'wait'); +} +step rp { REPACK (CONCURRENTLY) r; } +step chk { + SELECT count(*), sum(id), sum(k), string_agg(DISTINCT v, ',') AS v FROM r; + SELECT bt_index_check(indexrelid, true) FROM pg_index + WHERE indrelid = 'r'::regclass; + SELECT count(*), sum(id), sum(k) FROM o; +} +teardown +{ + SELECT injection_points_detach('repack-concurrently-before-lock'); +} + +session w +step pick_fx { ALTER TABLE ofx RENAME TO o; } +step pick_hp { ALTER TABLE ohp RENAME TO o; } +# Keys that also exist in r, so that a change replayed onto r would find a row. +step w_dml { + INSERT INTO o VALUES (1001, 1001, 'other'); + UPDATE o SET k = 2222, v = 'other' WHERE id = 2; + UPDATE o SET id = 1003 WHERE id = 3; + DELETE FROM o WHERE id = 4; + INSERT INTO o SELECT g, g, 'other' FROM generate_series(1010, 1019) g; + INSERT INTO o VALUES (5, 0, 'other') ON CONFLICT (id) DO UPDATE SET k = 5555; + INSERT INTO o VALUES (1020, 0, 'other') ON CONFLICT (id) DO NOTHING; + UPDATE o SET v = repeat('x', 2000) WHERE id = 6; + SELECT (ctid::text::point)[0] > 0 AS moved FROM o WHERE id = 6; + DO $$ BEGIN + EXECUTE format('COPY o FROM %L', + current_setting('data_directory') || '/repack_other_table.dat'); + END $$; +} +step wake { + SELECT injection_points_wakeup('repack-concurrently-before-lock'); +} + +permutation pick_fx rp w_dml wake chk +permutation pick_hp rp w_dml wake chk diff --git a/src/test/modules/injection_points/specs/vacuum_progress.spec b/src/test/modules/injection_points/specs/vacuum_progress.spec new file mode 100644 index 0000000000000..234399f8148fa --- /dev/null +++ b/src/test/modules/injection_points/specs/vacuum_progress.spec @@ -0,0 +1,102 @@ +# pg_stat_progress_vacuum as another session sees it while VACUUM is in its +# index-vacuum phase. The same steps run on a heap and a FLUX table, and +# the expected values are the same for both. +# +# VACUUM waits in vac_bulkdel_one_index: its table scan has collected the +# dead TIDs and the first index is about to be vacuumed. A cost limit of 1 +# makes the scan sleep at every page, so delay_time must be positive. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE vp_heap (id int, v int) USING heap + WITH (autovacuum_enabled = off, fillfactor = 10); + CREATE TABLE vp_flux (id int, v int) USING flux + WITH (autovacuum_enabled = off, fillfactor = 10); + INSERT INTO vp_heap SELECT g, g FROM generate_series(1, 3000) g; + INSERT INTO vp_flux SELECT g, g FROM generate_series(1, 3000) g; + CREATE INDEX vp_heap_id ON vp_heap (id); + CREATE INDEX vp_flux_id ON vp_flux (id); + DELETE FROM vp_heap WHERE id % 3 = 0; + DELETE FROM vp_flux WHERE id % 3 = 0; + + -- Wait until the VACUUM is at the injection point in index-vacuum + -- round "round" (counting from 0), then show its progress. + CREATE FUNCTION vp_progress(round int) + RETURNS TABLE (rel regclass, phase text, total_is_size bool, + scanned text, vacuumed text, index_vacuum_count bigint, + max_dead_tuple_bytes bigint, have_dead_bytes bool, + num_dead_item_ids bigint, indexes_total bigint, + indexes_processed bigint, at_index bool, delayed bool, + mode text, started_by text) + LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1 .. 18000 LOOP + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_progress_vacuum p + JOIN pg_stat_activity a USING (pid) + WHERE p.datname = current_database() + AND p.index_vacuum_count = round + AND a.wait_event = 'vacuum-bulkdel-one-index'); + PERFORM pg_sleep(0.01); + END LOOP; + RETURN QUERY + SELECT p.relid::regclass, p.phase, + p.heap_blks_total = pg_relation_size(p.relid) / + current_setting('block_size')::int, + CASE WHEN p.heap_blks_scanned = 0 THEN 'none' + WHEN p.heap_blks_scanned < p.heap_blks_total THEN 'some' + ELSE 'all' END, + CASE WHEN p.heap_blks_vacuumed = 0 THEN 'none' + WHEN p.heap_blks_vacuumed < p.heap_blks_total THEN 'some' + ELSE 'all' END, + p.index_vacuum_count, p.max_dead_tuple_bytes, + p.dead_tuple_bytes > 0, p.num_dead_item_ids, + p.indexes_total, p.indexes_processed, + p.current_index_relid = (SELECT indexrelid FROM pg_index + WHERE indrelid = p.relid), + p.delay_time > 0, p.mode, p.started_by + FROM pg_stat_progress_vacuum p + WHERE p.datname = current_database(); + END $$; +} + +teardown +{ + DROP TABLE vp_heap; + DROP TABLE vp_flux; + DROP FUNCTION vp_progress(int); + DROP EXTENSION injection_points; +} + +session s1 +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('vacuum-bulkdel-one-index', 'wait'); + SET maintenance_work_mem = '1MB'; + -- Cost-based delay must throttle the table scan. + SET track_cost_delay_timing = on; + SET vacuum_cost_delay = '1ms'; + SET vacuum_cost_limit = 1; +} +step s1_vac_heap { VACUUM (PARALLEL 0) vp_heap; } +step s1_vac_flux { VACUUM (PARALLEL 0) vp_flux; } +step s1_done +{ + SELECT count(*) AS progress_rows FROM pg_stat_progress_vacuum + WHERE pid = pg_backend_pid(); +} +step s1_rows_heap { SELECT count(*), sum(id) FROM vp_heap; } +step s1_rows_flux { SELECT count(*), sum(id) FROM vp_flux; } +teardown +{ + SELECT injection_points_detach('vacuum-bulkdel-one-index'); +} + +session s2 +step s2_round0 { SELECT * FROM vp_progress(0); } +step s2_wake { SELECT injection_points_wakeup('vacuum-bulkdel-one-index'); } + +permutation s1_vac_heap s2_round0 s2_wake s1_done s1_rows_heap +permutation s1_vac_flux s2_round0 s2_wake s1_done s1_rows_flux diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index ef3cdb8faf9c9..a73f3cbf67e04 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -28,12 +28,15 @@ subdir('test_cplusplusext') subdir('test_custom_rmgrs') subdir('test_custom_stats') subdir('test_custom_types') +subdir('test_deletemark') +subdir('test_idxundo') subdir('test_ddl_deparse') subdir('test_dsa') subdir('test_dsm_registry') subdir('test_escape') subdir('test_extensible') subdir('test_extensions') +subdir('test_fileops') subdir('test_ginpostinglist') subdir('test_int128') subdir('test_integerset') diff --git a/src/test/modules/test_checksums/meson.build b/src/test/modules/test_checksums/meson.build index 7eccd5156b9f6..50862dc7b669a 100644 --- a/src/test/modules/test_checksums/meson.build +++ b/src/test/modules/test_checksums/meson.build @@ -48,6 +48,7 @@ tests += { 't/022_rewind_state.pl', 't/023_rewind_standby_target.pl', 't/024_combinebackup_mixed.pl', + 't/025_undo.pl', ], }, } diff --git a/src/test/modules/test_checksums/t/025_undo.pl b/src/test/modules/test_checksums/t/025_undo.pl new file mode 100644 index 0000000000000..8de6ffc5868fb --- /dev/null +++ b/src/test/modules/test_checksums/t/025_undo.pl @@ -0,0 +1,42 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Undo pages must participate in both initial and online checksums. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use FindBin; +use lib $FindBin::RealBin; +use DataChecksums::Utils; + +for my $am (qw(heap flux)) +{ + for my $online (0, 1) + { + my $node = PostgreSQL::Test::Cluster->new("${am}_${online}"); + $node->init( + allows_streaming => 1, + extra => [ $online ? '--no-data-checksums' : '--data-checksums' ] + ); + $node->start; + $node->safe_psql( + 'postgres', qq[ + CREATE TABLE t (a int, b text) USING $am; + INSERT INTO t SELECT g, repeat('x', 200) FROM generate_series(1,10000) g; + ]); + my $writer = $node->background_psql('postgres'); + $writer->query_safe('BEGIN; UPDATE t SET a = a + 1;'); + # Force pre-enable undo pages to disk while the transaction is open. + $node->safe_psql('postgres', 'CHECKPOINT'); + $writer->query_safe('COMMIT'); + $writer->quit; + enable_data_checksums($node, wait => 'on') if $online; + $node->backup('checksummed'); + $node->stop; + command_ok( + [ 'pg_checksums', '--check', '-D', $node->data_dir ], + "$am offline verification (online=$online)"); + } +} +done_testing(); diff --git a/src/test/modules/test_deletemark/.gitignore b/src/test/modules/test_deletemark/.gitignore new file mode 100644 index 0000000000000..536e465d407f7 --- /dev/null +++ b/src/test/modules/test_deletemark/.gitignore @@ -0,0 +1,4 @@ +/log/ +/results/ +/tmp_check/ +/output_iso/ diff --git a/src/test/modules/test_deletemark/Makefile b/src/test/modules/test_deletemark/Makefile new file mode 100644 index 0000000000000..53248dee6e087 --- /dev/null +++ b/src/test/modules/test_deletemark/Makefile @@ -0,0 +1,29 @@ +# src/test/modules/test_deletemark/Makefile + +MODULE_big = test_deletemark +OBJS = \ + $(WIN32RES) \ + test_deletemark.o +PGFILEDESC = "test_deletemark - synthetic exerciser for nbtree delete-marking" + +EXTENSION = test_deletemark +DATA = test_deletemark--1.0.sql + +REGRESS = test_deletemark +TAP_TESTS = 1 + +# The SQL test uses amcheck to verify structural soundness of an index that +# contains a delete-marked entry, so amcheck must be present in the temporary +# install used by "make check". +EXTRA_INSTALL = contrib/amcheck + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_deletemark +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_deletemark/expected/test_deletemark.out b/src/test/modules/test_deletemark/expected/test_deletemark.out new file mode 100644 index 0000000000000..a25060960dc1d --- /dev/null +++ b/src/test/modules/test_deletemark/expected/test_deletemark.out @@ -0,0 +1,101 @@ +-- Synthetic exerciser for nbtree delete-marking. +CREATE EXTENSION test_deletemark; +-- Basic table + non-unique btree index over an ordinary heap. +-- (No table AM enables delete-marking yet; we drive btdeletemark directly.) +CREATE TABLE dm_t (k int, v int); +INSERT INTO dm_t SELECT g, g FROM generate_series(1, 20) g; +CREATE INDEX dm_idx ON dm_t (k); +-- Pick a concrete heap TID to operate on (k = 10). +SELECT ctid AS tid10 FROM dm_t WHERE k = 10 \gset +-- (a) Before marking: entry classifies as a plain leaf tuple, heap TID matches. +SELECT dm_classify('dm_idx', :'tid10') AS before_class; + before_class +-------------- + plain +(1 row) + +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS before_heaptid_ok; + before_heaptid_ok +------------------- + t +(1 row) + +-- Mark it delete-marked in place (re-descend by key+TID, WAL-logged). +SELECT dm_mark('dm_idx', :'tid10') AS marked; + marked +-------- + t +(1 row) + +-- (a) After marking: classifies as delete-marked (NOT pivot, NOT posting), +-- and the heap TID is still retrievable from the trailer == original TID. +SELECT dm_classify('dm_idx', :'tid10') AS after_class; + after_class +--------------- + delete-marked +(1 row) + +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS after_heaptid_ok; + after_heaptid_ok +------------------ + t +(1 row) + +-- Marking an already-marked entry is idempotent (returns false: nothing to do). +SELECT dm_mark('dm_idx', :'tid10') AS remark; + remark +-------- + f +(1 row) + +-- (b) A scan returns the delete-marked entry WITH recheck set. +SELECT dm_scan_recheck('dm_idx') AS scan_sets_recheck; + scan_sets_recheck +------------------- + t +(1 row) + +-- (d) amcheck: structural checks must still pass on an index with a +-- delete-marked entry (TID validity, key ordering, alt-TID subtype). +CREATE EXTENSION amcheck; +SELECT bt_index_check('dm_idx'); + bt_index_check +---------------- + +(1 row) + +SELECT bt_index_parent_check('dm_idx'); + bt_index_parent_check +----------------------- + +(1 row) + +-- (c) Uniqueness: delete-marked entry must be skipped as a duplicate. +CREATE TABLE dm_u (k int); +INSERT INTO dm_u SELECT g FROM generate_series(1, 20) g; +CREATE UNIQUE INDEX dm_uidx ON dm_u (k); +SELECT ctid AS utid7 FROM dm_u WHERE k = 7 \gset +-- Delete-mark the (k=7) entry, then a NEW insert of k=7 must SUCCEED +-- (the tombstone is not a live duplicate). +SELECT dm_mark('dm_uidx', :'utid7') AS u_marked; + u_marked +---------- + t +(1 row) + +INSERT INTO dm_u VALUES (7); -- accepted: delete-marked dup skipped +-- A true duplicate of a still-live key must still be REJECTED. +INSERT INTO dm_u VALUES (8); -- should fail: k=8 is live +ERROR: duplicate key value violates unique constraint "dm_uidx" +DETAIL: Key (k)=(8) already exists. +-- amcheck the unique index too (structural). +SELECT bt_index_check('dm_uidx'); + bt_index_check +---------------- + +(1 row) + +DROP EXTENSION test_deletemark CASCADE; +DROP EXTENSION amcheck CASCADE; +DROP TABLE dm_t; +DROP TABLE dm_u; diff --git a/src/test/modules/test_deletemark/meson.build b/src/test/modules/test_deletemark/meson.build new file mode 100644 index 0000000000000..95b2767c2ecf9 --- /dev/null +++ b/src/test/modules/test_deletemark/meson.build @@ -0,0 +1,39 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +test_deletemark_sources = files( + 'test_deletemark.c', +) + +if host_system == 'windows' + test_deletemark_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_deletemark', + '--FILEDESC', 'test_deletemark - synthetic exerciser for nbtree delete-marking',]) +endif + +test_deletemark = shared_module('test_deletemark', + test_deletemark_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_deletemark + +test_install_data += files( + 'test_deletemark.control', + 'test_deletemark--1.0.sql', +) + +tests += { + 'name': 'test_deletemark', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_deletemark', + ], + }, + 'tap': { + 'tests': [ + 't/001_deletemark_recovery.pl', + 't/002_deletemark_lp_dead.pl', + ], + }, +} diff --git a/src/test/modules/test_deletemark/sql/test_deletemark.sql b/src/test/modules/test_deletemark/sql/test_deletemark.sql new file mode 100644 index 0000000000000..de581bec3c405 --- /dev/null +++ b/src/test/modules/test_deletemark/sql/test_deletemark.sql @@ -0,0 +1,58 @@ +-- Synthetic exerciser for nbtree delete-marking. +CREATE EXTENSION test_deletemark; + +-- Basic table + non-unique btree index over an ordinary heap. +-- (No table AM enables delete-marking yet; we drive btdeletemark directly.) +CREATE TABLE dm_t (k int, v int); +INSERT INTO dm_t SELECT g, g FROM generate_series(1, 20) g; +CREATE INDEX dm_idx ON dm_t (k); + +-- Pick a concrete heap TID to operate on (k = 10). +SELECT ctid AS tid10 FROM dm_t WHERE k = 10 \gset + +-- (a) Before marking: entry classifies as a plain leaf tuple, heap TID matches. +SELECT dm_classify('dm_idx', :'tid10') AS before_class; +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS before_heaptid_ok; + +-- Mark it delete-marked in place (re-descend by key+TID, WAL-logged). +SELECT dm_mark('dm_idx', :'tid10') AS marked; + +-- (a) After marking: classifies as delete-marked (NOT pivot, NOT posting), +-- and the heap TID is still retrievable from the trailer == original TID. +SELECT dm_classify('dm_idx', :'tid10') AS after_class; +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS after_heaptid_ok; + +-- Marking an already-marked entry is idempotent (returns false: nothing to do). +SELECT dm_mark('dm_idx', :'tid10') AS remark; + +-- (b) A scan returns the delete-marked entry WITH recheck set. +SELECT dm_scan_recheck('dm_idx') AS scan_sets_recheck; + +-- (d) amcheck: structural checks must still pass on an index with a +-- delete-marked entry (TID validity, key ordering, alt-TID subtype). +CREATE EXTENSION amcheck; +SELECT bt_index_check('dm_idx'); +SELECT bt_index_parent_check('dm_idx'); + +-- (c) Uniqueness: delete-marked entry must be skipped as a duplicate. +CREATE TABLE dm_u (k int); +INSERT INTO dm_u SELECT g FROM generate_series(1, 20) g; +CREATE UNIQUE INDEX dm_uidx ON dm_u (k); + +SELECT ctid AS utid7 FROM dm_u WHERE k = 7 \gset + +-- Delete-mark the (k=7) entry, then a NEW insert of k=7 must SUCCEED +-- (the tombstone is not a live duplicate). +SELECT dm_mark('dm_uidx', :'utid7') AS u_marked; +INSERT INTO dm_u VALUES (7); -- accepted: delete-marked dup skipped + +-- A true duplicate of a still-live key must still be REJECTED. +INSERT INTO dm_u VALUES (8); -- should fail: k=8 is live + +-- amcheck the unique index too (structural). +SELECT bt_index_check('dm_uidx'); + +DROP EXTENSION test_deletemark CASCADE; +DROP EXTENSION amcheck CASCADE; +DROP TABLE dm_t; +DROP TABLE dm_u; diff --git a/src/test/modules/test_deletemark/t/001_deletemark_recovery.pl b/src/test/modules/test_deletemark/t/001_deletemark_recovery.pl new file mode 100644 index 0000000000000..4aa2fcd94c9ab --- /dev/null +++ b/src/test/modules/test_deletemark/t/001_deletemark_recovery.pl @@ -0,0 +1,63 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +# Crash-recovery test for nbtree delete-marking: +# delete-mark a leaf entry, crash (immediate stop, no clean shutdown), +# restart so WAL redo replays XLOG_BTREE_DELETE_MARK, and verify the entry +# is still delete-marked with an intact heap TID and passes amcheck. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('dm_recovery'); +$node->init; +# Deterministic redo verification for the new WAL record type. +$node->append_conf('postgresql.conf', 'wal_consistency_checking = btree'); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION test_deletemark'); +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->safe_psql('postgres', <<'SQL'); +CREATE TABLE dm_r (k int, v int); +INSERT INTO dm_r SELECT g, g FROM generate_series(1, 30) g; +CREATE INDEX dm_ridx ON dm_r (k); +SQL + +# Locate a concrete heap TID and delete-mark its index entry. +my $tid = $node->safe_psql('postgres', "SELECT ctid FROM dm_r WHERE k = 15"); +is( $node->safe_psql( + 'postgres', "SELECT dm_mark('dm_ridx'::regclass, '$tid'::tid)"), + 't', + 'entry delete-marked before crash'); +is( $node->safe_psql( + 'postgres', "SELECT dm_classify('dm_ridx'::regclass, '$tid'::tid)"), + 'delete-marked', + 'classified delete-marked before crash'); + +# Make sure the mark's WAL is flushed, then crash without a clean shutdown so +# recovery must replay it. +$node->safe_psql('postgres', "SELECT pg_switch_wal()"); +$node->stop('immediate'); +$node->start; + +# After crash recovery the bit must be re-set (WAL redo), TID intact. +is( $node->safe_psql( + 'postgres', "SELECT dm_classify('dm_ridx'::regclass, '$tid'::tid)"), + 'delete-marked', + 'entry still delete-marked after crash recovery'); +is( $node->safe_psql( + 'postgres', + "SELECT dm_heaptid('dm_ridx'::regclass, '$tid'::tid) = '$tid'::tid"), + 't', + 'heap TID intact after crash recovery'); + +# amcheck must be clean on the recovered index. +is($node->safe_psql('postgres', "SELECT bt_index_check('dm_ridx')"), + '', 'bt_index_check clean after crash recovery'); +is($node->safe_psql('postgres', "SELECT bt_index_parent_check('dm_ridx')"), + '', 'bt_index_parent_check clean after crash recovery'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl b/src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl new file mode 100644 index 0000000000000..94cbd544bbd8d --- /dev/null +++ b/src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl @@ -0,0 +1,90 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +# nbtree delete-marking and the LP_DEAD hint. +# +# (1) Delete-marking an entry that already carries LP_DEAD must not produce an +# LP_DEAD tombstone: _bt_simpledel_pass asserts that a tombstone is never +# LP_DEAD, and an LP_DEAD tombstone would also be skipped by the very +# old-snapshot scans it exists for. +# (2) Demoting a tombstone back to a plain entry (rollback) must leave a live +# entry on the primary and on a streaming standby alike: the primary must +# clear LP_DEAD just as redo does, or the restored entry stays hidden from +# index scans on the primary only. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', 'wal_consistency_checking = btree'); +$primary->start; +$primary->safe_psql('postgres', 'CREATE EXTENSION test_deletemark'); +$primary->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$primary->safe_psql( + 'postgres', q{ +CREATE TABLE dm_d (k int, v int) WITH (autovacuum_enabled = off); +INSERT INTO dm_d SELECT g, g FROM generate_series(1, 20) g; +CREATE INDEX dm_didx ON dm_d (k); +}); + +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +# (2) An LP_DEAD tombstone demoted by a rollback. +my $tid15 = + $primary->safe_psql('postgres', "SELECT ctid FROM dm_d WHERE k = 15"); +is($primary->safe_psql('postgres', "SELECT dm_mark('dm_didx', '$tid15')"), + 't', 'entry for k=15 delete-marked'); +is( $primary->safe_psql( + 'postgres', "SELECT dm_set_dead('dm_didx', '$tid15')"), + 't', + 'LP_DEAD set on tombstone'); +is( $primary->safe_psql('postgres', "SELECT dm_unmark('dm_didx', '$tid15')"), + 't', + 'tombstone demoted'); +is( $primary->safe_psql('postgres', "SELECT dm_is_dead('dm_didx', '$tid15')"), + 'f', + 'demoted entry is not LP_DEAD on the primary'); + +$primary->wait_for_catchup($standby, 'replay'); +is( $standby->safe_psql('postgres', "SELECT dm_is_dead('dm_didx', '$tid15')"), + 'f', + 'demoted entry is not LP_DEAD on the standby'); + +my $q = "SET enable_seqscan = off; SET enable_bitmapscan = off; " + . "SELECT count(*) FROM dm_d WHERE k = 15"; +my $p = $primary->safe_psql('postgres', $q); +my $s = $standby->safe_psql('postgres', $q); +is($p, '1', 'primary index scan finds the restored entry'); +is($s, $p, 'standby index scan agrees with the primary'); + + +# (1) LP_DEAD plain entry, then delete-mark it. +my $tid10 = + $primary->safe_psql('postgres', "SELECT ctid FROM dm_d WHERE k = 10"); +is( $primary->safe_psql( + 'postgres', "SELECT dm_set_dead('dm_didx', '$tid10')"), + 't', + 'LP_DEAD set on plain entry'); +is($primary->safe_psql('postgres', "SELECT dm_mark('dm_didx', '$tid10')"), + 'f', 'delete-marking an LP_DEAD entry reports "not present"'); +is( $primary->safe_psql( + 'postgres', "SELECT dm_classify('dm_didx', '$tid10')"), + 'plain', + 'LP_DEAD entry was not turned into a tombstone'); + +# Fill the leaf so an insert runs the LP_DEAD simple-deletion pass over it. +my ($ret, $out, $err) = $primary->psql('postgres', + "INSERT INTO dm_d SELECT g, g FROM generate_series(21, 1500) g"); +is($ret, 0, 'insert that runs _bt_simpledel_pass succeeds'); +is($primary->safe_psql('postgres', "SELECT bt_index_check('dm_didx')"), + '', 'bt_index_check clean after simple deletion'); + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/modules/test_deletemark/test_deletemark--1.0.sql b/src/test/modules/test_deletemark/test_deletemark--1.0.sql new file mode 100644 index 0000000000000..c201d57d414b0 --- /dev/null +++ b/src/test/modules/test_deletemark/test_deletemark--1.0.sql @@ -0,0 +1,32 @@ +/* src/test/modules/test_deletemark/test_deletemark--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_deletemark" to load this file. \quit + +CREATE FUNCTION dm_classify(idx regclass, t tid) +RETURNS text STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_heaptid(idx regclass, t tid) +RETURNS tid STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_mark(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_scan_recheck(idx regclass) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_set_dead(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_is_dead(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_unmark(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; diff --git a/src/test/modules/test_deletemark/test_deletemark.c b/src/test/modules/test_deletemark/test_deletemark.c new file mode 100644 index 0000000000000..c7532a26d3f44 --- /dev/null +++ b/src/test/modules/test_deletemark/test_deletemark.c @@ -0,0 +1,347 @@ +/*------------------------------------------------------------------------- + * + * test_deletemark.c + * Synthetic exerciser for nbtree delete-marking. + * + * No in-tree table AM enables delete-marking yet (it is dormant until a + * later phase flips it on for FLUX/ZHEAP). This module drives the + * nbtree delete-marking mechanism directly against an ordinary btree index + * so the mechanism itself (classification, heap-TID retrieval, scan recheck, + * uniqueness handling, WAL/redo) can be tested end to end. + * + * SQL-callable primitives: + * dm_mark(regclass idx, tid t) -> mark the (single) entry with TID t + * dm_classify(regclass idx, tid t) -> text: classification of entry at t + * dm_heaptid(regclass idx, tid t) -> tid: heap TID recovered from entry + * dm_scan_recheck(regclass idx) -> bool: any returned tuple set recheck + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/modules/test_deletemark/test_deletemark.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/genam.h" +#include "access/nbtree.h" +#include "access/relation.h" +#include "access/relscan.h" +#include "access/tableam.h" +#include "catalog/index.h" +#include "storage/bufmgr.h" +#include "storage/itemptr.h" +#include "utils/rel.h" +#include "utils/snapmgr.h" +#include "utils/builtins.h" +#include "fmgr.h" + +PG_MODULE_MAGIC; + +/* + * Locate the single leaf entry whose heap TID equals 'target' by a linear + * scan over all leaf pages. Returns the buffer (pinned+share-locked) and the + * offset, or InvalidBuffer if not found. Intended for tiny test indexes. + */ +static Buffer +find_leaf_entry(Relation irel, ItemPointer target, OffsetNumber *offout) +{ + BlockNumber nblocks = RelationGetNumberOfBlocks(irel); + BlockNumber blk; + + for (blk = 0; blk < nblocks; blk++) + { + Buffer buf = ReadBuffer(irel, blk); + Page page; + BTPageOpaque opaque; + OffsetNumber off, + minoff, + maxoff; + + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + opaque = BTPageGetOpaque(page); + + if (!P_ISLEAF(opaque) || P_IGNORE(opaque)) + { + UnlockReleaseBuffer(buf); + continue; + } + + minoff = P_FIRSTDATAKEY(opaque); + maxoff = PageGetMaxOffsetNumber(page); + for (off = minoff; off <= maxoff; off = OffsetNumberNext(off)) + { + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + ItemPointer htid = BTreeTupleGetHeapTID(itup); + + if (htid != NULL && ItemPointerIsValid(htid) && + ItemPointerEquals(htid, target)) + { + *offout = off; + return buf; /* caller unlocks/releases */ + } + } + UnlockReleaseBuffer(buf); + } + return InvalidBuffer; +} + +PG_FUNCTION_INFO_V1(dm_classify); +Datum +dm_classify(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, AccessShareLock); + OffsetNumber off; + Buffer buf; + const char *res; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, AccessShareLock); + PG_RETURN_TEXT_P(cstring_to_text("not found")); + } + { + Page page = BufferGetPage(buf); + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + + if (BTreeTupleIsDeleteMarked(itup)) + { + /* Must NOT also classify as pivot or posting */ + if (BTreeTupleIsPivot(itup) || BTreeTupleIsPosting(itup)) + res = "BROKEN: delete-marked also pivot/posting"; + else + res = "delete-marked"; + } + else if (BTreeTupleIsPivot(itup)) + res = "pivot"; + else if (BTreeTupleIsPosting(itup)) + res = "posting"; + else + res = "plain"; + + UnlockReleaseBuffer(buf); + } + index_close(irel, AccessShareLock); + PG_RETURN_TEXT_P(cstring_to_text(res)); +} + +PG_FUNCTION_INFO_V1(dm_heaptid); +Datum +dm_heaptid(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, AccessShareLock); + OffsetNumber off; + Buffer buf; + ItemPointer result; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, AccessShareLock); + PG_RETURN_NULL(); + } + result = palloc(sizeof(ItemPointerData)); + { + Page page = BufferGetPage(buf); + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + + ItemPointerCopy(BTreeTupleGetHeapTID(itup), result); + UnlockReleaseBuffer(buf); + } + index_close(irel, AccessShareLock); + PG_RETURN_POINTER(result); +} + +/* + * dm_mark(idx, heaptid, values...) is awkward to pass key datums through SQL, + * so instead we mark the entry located purely by heap TID: read the existing + * plain entry's key from the leaf, then call btdeletemark with those datums. + * This exercises the full re-descend-by-(key,TID) path in btdeletemark. + */ +PG_FUNCTION_INFO_V1(dm_mark); +Datum +dm_mark(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, RowExclusiveLock); + Relation heapRel; + Oid heapoid; + OffsetNumber off; + Buffer buf; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + bool ok; + + heapoid = irel->rd_index->indrelid; + heapRel = relation_open(heapoid, RowExclusiveLock); + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(false); + } + { + Page page = BufferGetPage(buf); + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + IndexTuple copy; + + /* copy the tuple out before unlocking to deform its key */ + copy = CopyIndexTuple(itup); + UnlockReleaseBuffer(buf); + + index_deform_tuple(copy, RelationGetDescr(irel), values, isnull); + pfree(copy); + } + + /* Direct call to the AM method (bypasses the dormant capability gate) */ + ok = irel->rd_indam->amdeletemark(irel, heapRel, values, isnull, target); + + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(ok); +} + +/* + * dm_scan_recheck(idx) -- run a full index scan and report whether ANY + * returned tuple carried scan->xs_recheck (which delete-marked entries set). + */ +PG_FUNCTION_INFO_V1(dm_scan_recheck); +Datum +dm_scan_recheck(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + Relation irel = index_open(idxoid, AccessShareLock); + Relation heapRel = relation_open(irel->rd_index->indrelid, AccessShareLock); + IndexScanDesc scan; + bool any_recheck = false; + + /* + * Post-ddce1da5b1b (slot-based table-AM index scan): index_getnext_tid is + * gone and index_beginscan takes an index_only_scan flag. We only want + * to observe scan->xs_recheck (set by delete-marked entries in + * nbtsearch.c), not fetch heap tuples, so drive the index AM's amgettuple + * directly -- exactly what tableam_index_getnext_tid does internally. + */ + scan = index_beginscan(heapRel, irel, false, GetActiveSnapshot(), + NULL, 0, 0, SO_NONE); + index_rescan(scan, NULL, 0, NULL, 0); + while (scan->indexRelation->rd_indam->amgettuple(scan, ForwardScanDirection)) + { + scan->kill_prior_tuple = false; + if (scan->xs_recheck) + any_recheck = true; + } + index_endscan(scan); + + relation_close(heapRel, AccessShareLock); + index_close(irel, AccessShareLock); + PG_RETURN_BOOL(any_recheck); +} + +/* + * dm_set_dead(idx, heaptid) -- set the LP_DEAD hint on the leaf entry for + * heaptid, the way _bt_killitems does. Lets a test build the state where a + * plain entry or a tombstone carries LP_DEAD. + */ +PG_FUNCTION_INFO_V1(dm_set_dead); +Datum +dm_set_dead(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, RowExclusiveLock); + OffsetNumber off; + Buffer buf; + Page page; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(false); + } + /* Tiny single-session test indexes: the entry cannot move meanwhile. */ + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + ItemIdMarkDead(PageGetItemId(page, off)); + BTPageGetOpaque(page)->btpo_flags |= BTP_HAS_GARBAGE; + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(true); +} + +/* dm_is_dead(idx, heaptid) -- does the leaf entry for heaptid carry LP_DEAD? */ +PG_FUNCTION_INFO_V1(dm_is_dead); +Datum +dm_is_dead(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, AccessShareLock); + OffsetNumber off; + Buffer buf; + bool dead; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, AccessShareLock); + PG_RETURN_NULL(); + } + dead = ItemIdIsDead(PageGetItemId(BufferGetPage(buf), off)); + UnlockReleaseBuffer(buf); + index_close(irel, AccessShareLock); + PG_RETURN_BOOL(dead); +} + +/* + * dm_unmark(idx, heaptid) -- demote the tombstone for heaptid back to a plain + * entry through amundomark(clear_mark = true), as a rollback does. + */ +PG_FUNCTION_INFO_V1(dm_unmark); +Datum +dm_unmark(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, RowExclusiveLock); + Relation heapRel; + OffsetNumber off; + Buffer buf; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + IndexTuple copy; + bool ok; + + heapRel = relation_open(irel->rd_index->indrelid, RowExclusiveLock); + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(false); + } + copy = CopyIndexTuple((IndexTuple) PageGetItem(BufferGetPage(buf), + PageGetItemId(BufferGetPage(buf), off))); + UnlockReleaseBuffer(buf); + index_deform_tuple(copy, RelationGetDescr(irel), values, isnull); + + ok = irel->rd_indam->amundomark(irel, heapRel, values, isnull, target, + true, false); + + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(ok); +} diff --git a/src/test/modules/test_deletemark/test_deletemark.control b/src/test/modules/test_deletemark/test_deletemark.control new file mode 100644 index 0000000000000..febd27144e11d --- /dev/null +++ b/src/test/modules/test_deletemark/test_deletemark.control @@ -0,0 +1,4 @@ +comment = 'Synthetic exerciser for nbtree delete-marking' +default_version = '1.0' +module_pathname = '$libdir/test_deletemark' +relocatable = true diff --git a/src/test/modules/test_fileops/Makefile b/src/test/modules/test_fileops/Makefile new file mode 100644 index 0000000000000..69100c7527288 --- /dev/null +++ b/src/test/modules/test_fileops/Makefile @@ -0,0 +1,19 @@ +MODULE_big = test_fileops +OBJS = test_fileops.o +EXTENSION = test_fileops +DATA = test_fileops--1.0.sql +PGFILEDESC = "test_fileops - test transactional file operations" +REGRESS = test_fileops +TAP_TESTS = 1 +EXTRA_INSTALL = src/test/modules/injection_points +export enable_injection_points +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_fileops +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_fileops/expected/test_fileops.out b/src/test/modules/test_fileops/expected/test_fileops.out new file mode 100644 index 0000000000000..c3d4dc531fd87 --- /dev/null +++ b/src/test/modules/test_fileops/expected/test_fileops.out @@ -0,0 +1,404 @@ +-- +-- Tests for FILEOPS UNDO rollback of direct file operations. +-- +-- This test module provides SQL-callable wrappers around FileOps C functions +-- that have no DDL-level callers, enabling direct testing of UNDO rollback +-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr, +-- FileOpsRemoveXattr. +-- +-- REQUIRES: UNDO subsystem (always active) +-- +CREATE EXTENSION test_fileops; +-- ================================================================ +-- Setup: create test files in the data directory +-- ================================================================ +SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset +SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset +-- Record initial state (umask may affect mode) +SELECT test_fileops_file_size(:'filepath_a') AS initial_size; + initial_size +-------------- + 1024 +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode; + initial_mode +-------------- + 384 +(1 row) + +-- ================================================================ +-- Test 1: FileOpsTruncate UNDO - rollback restores original size +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_a', 256); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn; + size_during_txn +----------------- + 256 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore original size (1024) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback; + size_after_rollback +--------------------- + 1024 +(1 row) + +-- ================================================================ +-- Test 2: FileOpsTruncate commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_b', 512); + test_fileops_truncate +----------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit; + size_after_commit +------------------- + 512 +(1 row) + +-- ================================================================ +-- Test 3: FileOpsChmod UNDO - rollback restores original permissions +-- ================================================================ +-- Change to a known starting mode first (committed) +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline; + mode_baseline +--------------- + 420 +(1 row) + +BEGIN; +-- Change to 0600 = 384 decimal +SELECT test_fileops_chmod(:'filepath_a', 384); + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn; + mode_during_txn +----------------- + 384 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore to 0644 = 420 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback; + mode_after_rollback +--------------------- + 420 +(1 row) + +-- ================================================================ +-- Test 4: FileOpsChmod commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal + test_fileops_chmod +-------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- Restore for subsequent tests +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +-- ================================================================ +-- Test 5: FileOpsLink UNDO - rollback removes the hard link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn; + link_during_txn +----------------- + t +(1 row) + +ROLLBACK; +-- After rollback, UNDO should remove the hard link +SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback; + link_after_rollback +--------------------- + f +(1 row) + +-- ================================================================ +-- Test 6: FileOpsLink commit - link persists +-- ================================================================ +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit; + link_after_commit +------------------- + t +(1 row) + +-- ================================================================ +-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it +-- ================================================================ +-- Ensure file is 1024 bytes +SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7; + size_before_test7 +------------------- + 1024 +(1 row) + +BEGIN; +-- Outer truncate (should persist after commit) +SELECT test_fileops_truncate(:'filepath_a', 800); + test_fileops_truncate +----------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner truncate (should be rolled back) +SELECT test_fileops_truncate(:'filepath_a', 100); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint; + size_in_savepoint +------------------- + 100 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 800 (the outer truncate value) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to; + size_after_rollback_to +------------------------ + 800 +(1 row) + +COMMIT; +-- After commit, the outer truncate to 800 should persist +SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit; + size_after_commit +------------------- + 800 +(1 row) + +-- ================================================================ +-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode +-- ================================================================ +-- Set a known mode baseline +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +BEGIN; +-- Outer chmod (persists) +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 + test_fileops_chmod +-------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner chmod (rolled back) +SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400 + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint; + mode_in_savepoint +------------------- + 256 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 0700 = 448 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to; + mode_after_rollback_to +------------------------ + 448 +(1 row) + +COMMIT; +-- After commit, outer chmod (0700 = 448) persists +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- ================================================================ +-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_link(:'filepath_a', :'linkpath2'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint; + link2_in_savepoint +-------------------- + t +(1 row) + +ROLLBACK TO sp1; +-- Link should be removed by UNDO +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to; + link2_after_rollback_to +------------------------- + f +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit; + link2_after_commit +-------------------- + f +(1 row) + +-- ================================================================ +-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO +-- (skipped on platforms without xattr support) +-- ================================================================ +-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES) +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value') + AS xattr_supported \gset +\if :xattr_supported +-- Test 10a: SetXattr UNDO - rollback removes newly-set xattr +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.rollback_test', 'will_vanish'); + test_fileops_setxattr +----------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_during_txn; + xattr_during_txn +------------------ + will_vanish +(1 row) + +ROLLBACK; +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_after_rollback; + xattr_after_rollback +---------------------- + +(1 row) + +-- Test 10b: SetXattr overwrite UNDO - rollback restores old value +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'overwritten'); + test_fileops_setxattr +----------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_overwritten; + xattr_overwritten +------------------- + overwritten +(1 row) + +ROLLBACK; +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_restored; + xattr_restored +---------------- + initial_value +(1 row) + +-- Test 10c: RemoveXattr UNDO - rollback restores removed xattr +BEGIN; +SELECT test_fileops_removexattr(:'filepath_a', 'user.test_key'); + test_fileops_removexattr +-------------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove; + xattr_after_remove +-------------------- + +(1 row) + +ROLLBACK; +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove_rollback; + xattr_after_remove_rollback +----------------------------- + initial_value +(1 row) + +-- Test 10d: Xattr in subtransaction +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_setxattr(:'filepath_a', 'user.sub_key', 'sub_value'); + test_fileops_setxattr +----------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_in_savepoint; + xattr_in_savepoint +-------------------- + sub_value +(1 row) + +ROLLBACK TO sp1; +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_after_sp_rollback; + xattr_after_sp_rollback +------------------------- + +(1 row) + +COMMIT; +\else +SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice; +\endif +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP EXTENSION test_fileops; diff --git a/src/test/modules/test_fileops/expected/test_fileops_1.out b/src/test/modules/test_fileops/expected/test_fileops_1.out new file mode 100644 index 0000000000000..c0a1721cc1f80 --- /dev/null +++ b/src/test/modules/test_fileops/expected/test_fileops_1.out @@ -0,0 +1,323 @@ +-- +-- Tests for FILEOPS UNDO rollback of direct file operations. +-- +-- This test module provides SQL-callable wrappers around FileOps C functions +-- that have no DDL-level callers, enabling direct testing of UNDO rollback +-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr, +-- FileOpsRemoveXattr. +-- +-- REQUIRES: UNDO subsystem (always active) +-- +CREATE EXTENSION test_fileops; +-- ================================================================ +-- Setup: create test files in the data directory +-- ================================================================ +SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset +SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset +-- Record initial state (umask may affect mode) +SELECT test_fileops_file_size(:'filepath_a') AS initial_size; + initial_size +-------------- + 1024 +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode; + initial_mode +-------------- + 384 +(1 row) + +-- ================================================================ +-- Test 1: FileOpsTruncate UNDO - rollback restores original size +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_a', 256); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn; + size_during_txn +----------------- + 256 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore original size (1024) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback; + size_after_rollback +--------------------- + 1024 +(1 row) + +-- ================================================================ +-- Test 2: FileOpsTruncate commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_b', 512); + test_fileops_truncate +----------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit; + size_after_commit +------------------- + 512 +(1 row) + +-- ================================================================ +-- Test 3: FileOpsChmod UNDO - rollback restores original permissions +-- ================================================================ +-- Change to a known starting mode first (committed) +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline; + mode_baseline +--------------- + 420 +(1 row) + +BEGIN; +-- Change to 0600 = 384 decimal +SELECT test_fileops_chmod(:'filepath_a', 384); + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn; + mode_during_txn +----------------- + 384 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore to 0644 = 420 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback; + mode_after_rollback +--------------------- + 420 +(1 row) + +-- ================================================================ +-- Test 4: FileOpsChmod commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal + test_fileops_chmod +-------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- Restore for subsequent tests +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +-- ================================================================ +-- Test 5: FileOpsLink UNDO - rollback removes the hard link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn; + link_during_txn +----------------- + t +(1 row) + +ROLLBACK; +-- After rollback, UNDO should remove the hard link +SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback; + link_after_rollback +--------------------- + f +(1 row) + +-- ================================================================ +-- Test 6: FileOpsLink commit - link persists +-- ================================================================ +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit; + link_after_commit +------------------- + t +(1 row) + +-- ================================================================ +-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it +-- ================================================================ +-- Ensure file is 1024 bytes +SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7; + size_before_test7 +------------------- + 1024 +(1 row) + +BEGIN; +-- Outer truncate (should persist after commit) +SELECT test_fileops_truncate(:'filepath_a', 800); + test_fileops_truncate +----------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner truncate (should be rolled back) +SELECT test_fileops_truncate(:'filepath_a', 100); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint; + size_in_savepoint +------------------- + 100 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 800 (the outer truncate value) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to; + size_after_rollback_to +------------------------ + 800 +(1 row) + +COMMIT; +-- After commit, the outer truncate to 800 should persist +SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit; + size_after_commit +------------------- + 800 +(1 row) + +-- ================================================================ +-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode +-- ================================================================ +-- Set a known mode baseline +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +BEGIN; +-- Outer chmod (persists) +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 + test_fileops_chmod +-------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner chmod (rolled back) +SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400 + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint; + mode_in_savepoint +------------------- + 256 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 0700 = 448 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to; + mode_after_rollback_to +------------------------ + 448 +(1 row) + +COMMIT; +-- After commit, outer chmod (0700 = 448) persists +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- ================================================================ +-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_link(:'filepath_a', :'linkpath2'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint; + link2_in_savepoint +-------------------- + t +(1 row) + +ROLLBACK TO sp1; +-- Link should be removed by UNDO +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to; + link2_after_rollback_to +------------------------- + f +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit; + link2_after_commit +-------------------- + f +(1 row) + +-- ================================================================ +-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO +-- (skipped on platforms without xattr support) +-- ================================================================ +-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES) +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value') + AS xattr_supported \gset +\if :xattr_supported +\else +SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice; + notice +----------------------------------------------------------------- + xattr tests skipped (not supported on this platform/filesystem) +(1 row) + +\endif +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP EXTENSION test_fileops; diff --git a/src/test/modules/test_fileops/meson.build b/src/test/modules/test_fileops/meson.build new file mode 100644 index 0000000000000..bdfa55a3afb0c --- /dev/null +++ b/src/test/modules/test_fileops/meson.build @@ -0,0 +1,56 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +test_fileops_sources = files( + 'test_fileops.c', +) + +if host_system == 'windows' + test_fileops_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_fileops', + '--FILEDESC', 'test_fileops - regression testing of FILEOPS subsystem',]) +endif + +test_fileops = shared_module('test_fileops', + test_fileops_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_fileops + +test_install_data += files( + 'test_fileops.control', + 'test_fileops--1.0.sql', +) + +tests += { + 'name': 'test_fileops', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_fileops', + ], + }, + 'tap': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 't/001_fileops_apply_failure.pl', + 't/002_fileops_large_rollback.pl', + 't/003_fileops_atm_reapply.pl', + 't/004_fileops_fs_errors.pl', + 't/005_fileops_checkpoint_loser.pl', + 't/006_fileops_rollback_prepared_crash.pl', + 't/007_fileops_commit_crash.pl', + 't/008_fileops_standby_abort.pl', + 't/009_fileops_reuse.pl', + 't/010_fileops_abort_redo.pl', + 't/011_fileops_undo_ownership.pl', + 't/012_fileops_contracts.pl', + 't/013_fileops_loser_reuse.pl', + 't/014_fileops_loser_ownership.pl', + 't/015_fileops_promote_loser.pl', + 't/016_fileops_inverse_fsync.pl', + ], + }, +} diff --git a/src/test/modules/test_fileops/sql/test_fileops.sql b/src/test/modules/test_fileops/sql/test_fileops.sql new file mode 100644 index 0000000000000..085b90257f610 --- /dev/null +++ b/src/test/modules/test_fileops/sql/test_fileops.sql @@ -0,0 +1,220 @@ +-- +-- Tests for FILEOPS UNDO rollback of direct file operations. +-- +-- This test module provides SQL-callable wrappers around FileOps C functions +-- that have no DDL-level callers, enabling direct testing of UNDO rollback +-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr, +-- FileOpsRemoveXattr. +-- +-- REQUIRES: UNDO subsystem (always active) +-- + +CREATE EXTENSION test_fileops; + +-- ================================================================ +-- Setup: create test files in the data directory +-- ================================================================ + +SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset +SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset + +-- Record initial state (umask may affect mode) +SELECT test_fileops_file_size(:'filepath_a') AS initial_size; +SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode; + +-- ================================================================ +-- Test 1: FileOpsTruncate UNDO - rollback restores original size +-- ================================================================ + +BEGIN; +SELECT test_fileops_truncate(:'filepath_a', 256); +SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn; +ROLLBACK; + +-- After rollback, UNDO should restore original size (1024) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback; + +-- ================================================================ +-- Test 2: FileOpsTruncate commit - change persists +-- ================================================================ + +BEGIN; +SELECT test_fileops_truncate(:'filepath_b', 512); +COMMIT; + +SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit; + +-- ================================================================ +-- Test 3: FileOpsChmod UNDO - rollback restores original permissions +-- ================================================================ + +-- Change to a known starting mode first (committed) +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal +SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline; + +BEGIN; +-- Change to 0600 = 384 decimal +SELECT test_fileops_chmod(:'filepath_a', 384); +SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn; +ROLLBACK; + +-- After rollback, UNDO should restore to 0644 = 420 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback; + +-- ================================================================ +-- Test 4: FileOpsChmod commit - change persists +-- ================================================================ + +BEGIN; +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal +COMMIT; + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + +-- Restore for subsequent tests +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + +-- ================================================================ +-- Test 5: FileOpsLink UNDO - rollback removes the hard link +-- ================================================================ + +SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset + +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); +SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn; +ROLLBACK; + +-- After rollback, UNDO should remove the hard link +SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback; + +-- ================================================================ +-- Test 6: FileOpsLink commit - link persists +-- ================================================================ + +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); +COMMIT; + +SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit; + +-- ================================================================ +-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it +-- ================================================================ + +-- Ensure file is 1024 bytes +SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7; + +BEGIN; +-- Outer truncate (should persist after commit) +SELECT test_fileops_truncate(:'filepath_a', 800); + +SAVEPOINT sp1; +-- Inner truncate (should be rolled back) +SELECT test_fileops_truncate(:'filepath_a', 100); +SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint; +ROLLBACK TO sp1; + +-- After ROLLBACK TO, should be back to 800 (the outer truncate value) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to; +COMMIT; + +-- After commit, the outer truncate to 800 should persist +SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit; + +-- ================================================================ +-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode +-- ================================================================ + +-- Set a known mode baseline +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + +BEGIN; +-- Outer chmod (persists) +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 + +SAVEPOINT sp1; +-- Inner chmod (rolled back) +SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint; +ROLLBACK TO sp1; + +-- After ROLLBACK TO, should be back to 0700 = 448 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to; +COMMIT; + +-- After commit, outer chmod (0700 = 448) persists +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + +-- ================================================================ +-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link +-- ================================================================ + +SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset + +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_link(:'filepath_a', :'linkpath2'); +SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint; +ROLLBACK TO sp1; + +-- Link should be removed by UNDO +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to; +COMMIT; + +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit; + +-- ================================================================ +-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO +-- (skipped on platforms without xattr support) +-- ================================================================ + +-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES) +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value') + AS xattr_supported \gset + +\if :xattr_supported + +-- Test 10a: SetXattr UNDO - rollback removes newly-set xattr +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.rollback_test', 'will_vanish'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_during_txn; +ROLLBACK; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_after_rollback; + +-- Test 10b: SetXattr overwrite UNDO - rollback restores old value +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'overwritten'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_overwritten; +ROLLBACK; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_restored; + +-- Test 10c: RemoveXattr UNDO - rollback restores removed xattr +BEGIN; +SELECT test_fileops_removexattr(:'filepath_a', 'user.test_key'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove; +ROLLBACK; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove_rollback; + +-- Test 10d: Xattr in subtransaction +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_setxattr(:'filepath_a', 'user.sub_key', 'sub_value'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_in_savepoint; +ROLLBACK TO sp1; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_after_sp_rollback; +COMMIT; + +\else +SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice; +\endif + +-- ================================================================ +-- Cleanup +-- ================================================================ + +DROP EXTENSION test_fileops; diff --git a/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl new file mode 100644 index 0000000000000..ff3b04d87eb99 --- /dev/null +++ b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl @@ -0,0 +1,44 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# FILEOPS inverses belong to the abort record, not the generic ATM. A +# syscall failure is reported; crash replay retries the embedded inverse. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_apply_failure'); +$node->init; +# FILEOPS writes UNDO only when WAL is needed. No revert worker, so the +# hand-off stays observable in the ATM. +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $path = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('fileops_fail.dat')"); + +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe("SELECT test_fileops_truncate('$path', 10)"); +chmod(0444, $path) or die "chmod $path: $!"; +$bg->query('ROLLBACK'); +$bg->quit; + +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 't', + 'FILEOPS abort inverses are not handed to the generic ATM'); +like( + slurp_file($node->logfile), + qr/FILEOPS UNDO TRUNCATE: could not open/, + 'the failure was reported'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/002_fileops_large_rollback.pl b/src/test/modules/test_fileops/t/002_fileops_large_rollback.pl new file mode 100644 index 0000000000000..3feaa4bce91e8 --- /dev/null +++ b/src/test/modules/test_fileops/t/002_fileops_large_rollback.pl @@ -0,0 +1,47 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A transaction whose FILEOPS UNDO is larger than undo_instant_abort_threshold +# must still be rolled back by its own ROLLBACK, not handed to the logical +# revert worker: filesystem effects have to be gone when ROLLBACK returns (and +# with no revert worker they would otherwise never go away). + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_large_rollback'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +undo_instant_abort_threshold = 1024 +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +my $dir = $node->data_dir; + +# 50 creates are several kB of FILEOPS UNDO, well over the threshold. +$node->safe_psql( + 'postgres', qq{ +BEGIN; +SELECT test_fileops_create('$dir/large_rb_' || g, 384) + FROM generate_series(1, 50) g; +ROLLBACK; +}); + +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM generate_series(1, 50) g WHERE test_fileops_file_exists('$dir/large_rb_' || g)" + ), + '0', + 'ROLLBACK removed every file the transaction created'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 't', + 'nothing was left for the logical revert worker'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl b/src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl new file mode 100644 index 0000000000000..3bcbf4e15ee92 --- /dev/null +++ b/src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl @@ -0,0 +1,82 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A FILEOPS UNDO chain can be walked more than once: the inline rollback +# reverts part of it, fails, and hands the whole chain to the logical revert +# worker, which walks it again later. By then the reverted files may have +# been changed again. The second walk must leave them alone. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('fileops_atm_reapply'); +$node->init; +# FILEOPS writes UNDO only when WAL is needed. No revert worker until the +# later changes are in place. +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); +$node->start; +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION test_fileops'); + +my %path; +for my $f (qw(m t l)) +{ + $path{$f} = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('reapply_$f.dat')"); + chmod(0644, $path{$f}) or die "chmod $path{$f}: $!"; +} +my $link = "$path{l}.lnk"; + +# One batch per operation. The rollback applies the newest batch (the +# truncate), then the injection point fails it, and the chain goes to the ATM. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('SELECT injection_points_set_local()'); +$bg->query_safe( + "SELECT injection_points_attach('undo-apply-after-batch', 'error')"); +$bg->query_safe('BEGIN'); +$bg->query_safe("SELECT test_fileops_link('$path{l}', '$link')"); +$bg->query_safe("SELECT test_fileops_chmod('$path{m}', 384)"); # 0600 +$bg->query_safe("SELECT test_fileops_truncate('$path{t}', 10)"); +$bg->query('ROLLBACK'); +$bg->quit; + +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'the failed inline rollback was handed to the ATM'); +is(-s $path{t}, 1024, 'truncate was reverted'); +is((stat($path{m}))[2] & 07777, 0644, 'chmod was reverted'); +ok(!-e $link, 'link was reverted'); + +# Later changes to the reverted files, which a second walk must not undo. +chmod(0640, $path{m}) or die "chmod $path{m}: $!"; +open(my $fh, '>>', $path{t}) or die "open $path{t}: $!"; +print $fh 'Y' x 1024; +close($fh); +# a different inode: a new link to the same one looks like ours +link($path{m}, $link) or die "link $link: $!"; + +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'revert worker drained the ATM'); + +is(-s $path{t}, 2048, 'later write to the truncated file survives'); +is((stat($path{m}))[2] & 07777, 0640, 'later chmod survives'); +ok(-e $link, 'later link survives'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/004_fileops_fs_errors.pl b/src/test/modules/test_fileops/t/004_fileops_fs_errors.pl new file mode 100644 index 0000000000000..8b111037b4d28 --- /dev/null +++ b/src/test/modules/test_fileops/t/004_fileops_fs_errors.pl @@ -0,0 +1,107 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# An ordinary filesystem error in an immediate FileOps operation (permission +# denied, no space, ...) must be a plain ERROR, exactly as without FILEOPS: +# the server must not PANIC, and the failed operation must leave nothing in +# the WAL for a standby to replay. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($windows_os) +{ + plan skip_all => 'POSIX permission semantics required'; +} + +my $node = PostgreSQL::Test::Cluster->new('fileops_fs_errors'); +$node->init; +$node->append_conf('postgresql.conf', "wal_level = replica\n"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $datadir = $node->data_dir; +my $ro = "$datadir/ro_dir"; +mkdir($ro) or die "mkdir $ro: $!"; +my $file = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('fserr.dat')"); +my $rofile = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('fserr_ro.dat')"); +my $has_xattr = $node->safe_psql('postgres', + "SELECT test_fileops_setxattr('$rofile', 'user.pgtest', 'v')"); +chmod(0500, $ro) or die "chmod $ro: $!"; +chmod(0444, $rofile) or die "chmod $rofile: $!"; + +my $start_time = + $node->safe_psql('postgres', 'SELECT pg_postmaster_start_time()'); +my $start_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + +my %cases = ( + create => "SELECT test_fileops_create('$ro/new.dat', 384)", + write => "SELECT test_fileops_write('/dev/full', 0, 'x')", + truncate => "SELECT test_fileops_truncate('$rofile', 10)", + chmod => "SELECT test_fileops_chmod('/', 448)", + chown => "SELECT test_fileops_chown('$file', 0, 0)", + mkdir => "SELECT test_fileops_mkdir('$ro/sub', 448)", + symlink => "SELECT test_fileops_symlink('$file', '$ro/lnk')", + link => "SELECT test_fileops_link('$file', '$ro/hard')",); + +if ($has_xattr eq 't') +{ + $cases{setxattr} = + "SELECT test_fileops_setxattr('$rofile', 'user.pgtest2', 'v')"; + $cases{removexattr} = + "SELECT test_fileops_removexattr('$rofile', 'user.pgtest')"; +} + +foreach my $op (sort keys %cases) +{ + my ($ret, $stdout, $stderr) = $node->psql('postgres', $cases{$op}); + + # The xattr wrappers report a refused operation as false. + ok( ($stderr =~ /ERROR:/ && $stderr !~ /PANIC|FATAL/) + || ($stdout eq 'f' && $stderr eq ''), + "$op: a filesystem error is an ERROR, not a PANIC" + ) or diag("stdout: $stdout\nstderr: $stderr"); + is($node->safe_psql('postgres', 'SELECT pg_postmaster_start_time()'), + $start_time, "$op: the server did not restart"); +} + +unlike( + slurp_file($node->logfile), + qr/PANIC|terminated by signal/, + 'no PANIC or backend crash in the server log'); + +# CREATE TABLESPACE on a directory we do not own fails as it does without +# FILEOPS. "/" is owned by root, and the server never runs as root. +my ($ret, $stdout, $stderr) = + $node->psql('postgres', "CREATE TABLESPACE t LOCATION '/'"); +like( + $stderr, + qr/ERROR: could not set permissions on directory "\/": Operation not permitted/, + 'CREATE TABLESPACE on a root-owned directory gives the usual error'); +is($node->safe_psql('postgres', 'SELECT pg_postmaster_start_time()'), + $start_time, 'the server survived CREATE TABLESPACE'); + +# Nothing the failed operations attempted reached the WAL. +my $end_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +my ($walout, $walerr); +IPC::Run::run [ + 'pg_waldump', + '--path' => "$datadir/pg_wal", + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'FileOps' + ], + '>' => \$walout, + '2>' => \$walerr; +is($walout, '', 'no FILEOPS WAL record was written for a failed operation'); + +chmod(0700, $ro); +chmod(0644, $rofile); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl b/src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl new file mode 100644 index 0000000000000..4b782975835ee --- /dev/null +++ b/src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl @@ -0,0 +1,103 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# Crash recovery must roll back a transaction whose UNDO was all written +# before the last checkpoint's redo pointer. Redo starts there and so +# replays none of its UNDO batches; the checkpoint itself has to say that +# the transaction was in flight. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_ckpt_loser'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +autovacuum = off +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$node->safe_psql('postgres', + 'CREATE TABLE ct (id int primary key) WITH (index_undo = on)'); +$node->safe_psql('postgres', 'INSERT INTO ct SELECT generate_series(1, 10)'); + +my $datadir = $node->data_dir; + +# Leave transactions in flight, all of their UNDO written, and checkpoint. +sub start_losers +{ + my ($tag) = @_; + my $bg = $node->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe("SELECT test_fileops_create('$tag.dat', 384)"); + my $xid1 = $bg->query_safe('SELECT pg_current_xact_id()'); + + my $bg2 = $node->background_psql('postgres'); + $bg2->query_safe('BEGIN'); + $bg2->query_safe('INSERT INTO ct SELECT generate_series(1001, 1300)'); + # Writes the pending index UNDO batch. + $bg2->query_safe('SAVEPOINT s'); + my $xid2 = $bg2->query_safe('SELECT pg_current_xact_id()'); + + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', 'SELECT pg_switch_wal()'); + return ($bg, $bg2, $xid1, $xid2); +} + +my ($bg, $bg2, $xid1, $xid2) = start_losers('ckpt'); +ok(-e "$datadir/ckpt.dat", 'the in-flight FILEOPS create is on disk'); + +my $offset = -s $node->logfile; +$node->stop('immediate'); +$bg->{run}->finish; +$bg2->{run}->finish; +$node->start; + +ok(!-e "$datadir/ckpt.dat", + 'crash recovery reverted a FILEOPS create made before the checkpoint'); +my $log = slurp_file($node->logfile, $offset); +like( + $log, + qr/UNDO recovery: rolling back transaction $xid1\b/, + 'the FILEOPS transaction was found as a loser'); +like( + $log, + qr/UNDO recovery: rolling back transaction $xid2\b/, + 'the index UNDO transaction was found as a loser'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM ct'), + '10', 'the uncommitted rows are not visible'); + +# The same on a standby that is promoted: the standby's last restartpoint +# can likewise follow the loser's batches. +$node->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('fileops_ckpt_standby'); +$standby->init_from_backup($node, 'bk', has_streaming => 1); +$standby->start; + +($bg, $bg2, $xid1, $xid2) = start_losers('sb'); +$node->wait_for_catchup($standby); +$standby->safe_psql('postgres', 'CHECKPOINT'); +my $sdatadir = $standby->data_dir; +ok(-e "$sdatadir/sb.dat", 'the standby replayed the in-flight create'); + +$offset = -s $standby->logfile; +$node->stop('immediate'); +$bg->{run}->finish; +$bg2->{run}->finish; +$standby->stop('immediate'); +$standby->start; +$standby->promote; + +ok(!-e "$sdatadir/sb.dat", + 'promotion reverted a FILEOPS create made before the restartpoint'); +$log = slurp_file($standby->logfile, $offset); +like( + $log, + qr/UNDO recovery: rolling back transaction $xid1\b/, + 'the standby found the FILEOPS transaction as a loser'); + +$standby->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl b/src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl new file mode 100644 index 0000000000000..7e26b8701582f --- /dev/null +++ b/src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl @@ -0,0 +1,95 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A crash during ROLLBACK PREPARED must not lose the rollback. After the +# abort record nothing is left prepared, so the chain must already be in the +# ATM by then. Before the abort record the transaction stays prepared, and +# nothing may be reverted until it is rolled back again; it may also be +# committed instead. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('fileops_rp_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +shared_preload_libraries = 'injection_points' +max_prepared_transactions = 5 +wal_level = replica +}); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $file = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('rp.dat')"); + +sub crash_in_rollback_prepared +{ + my ($ip) = @_; + $node->safe_psql('postgres', "SELECT test_fileops_chmod('$file', 420)"); + $node->safe_psql( + 'postgres', qq{ +BEGIN; +SELECT test_fileops_chmod('$file', 448); +PREPARE TRANSACTION 'rp'; +}); + is( $node->safe_psql('postgres', "SELECT test_fileops_get_mode('$file')"), + '448', + "$ip: the prepared chmod is on disk"); + $node->safe_psql('postgres', + "SELECT injection_points_attach('$ip', 'wait')"); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until( + qr/start/, qq(\\echo start +ROLLBACK PREPARED 'rp'; +)); + $node->wait_for_event('client backend', $ip); + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; +} + +crash_in_rollback_prepared('rollback-prepared-after-abort-record'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '0', 'after the abort record: no longer prepared'); +ok( $node->poll_query_until( + 'postgres', "SELECT test_fileops_get_mode('$file') = 420"), + 'after the abort record: the chmod is reverted'); + +crash_in_rollback_prepared('rollback-prepared-before-abort-record'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '1', 'before the abort record: still prepared'); +sleep(2); +is($node->safe_psql('postgres', "SELECT test_fileops_get_mode('$file')"), + '448', 'before the abort record: nothing reverted while still prepared'); +$node->safe_psql('postgres', "ROLLBACK PREPARED 'rp'"); +ok( $node->poll_query_until( + 'postgres', "SELECT test_fileops_get_mode('$file') = 420"), + 'before the abort record: ROLLBACK PREPARED again reverts the chmod'); + +# Or the transaction is committed after all, and keeps its change. +crash_in_rollback_prepared('rollback-prepared-before-abort-record'); +$node->safe_psql('postgres', "COMMIT PREPARED 'rp'"); +sleep(2); +is($node->safe_psql('postgres', "SELECT test_fileops_get_mode('$file')"), + '448', 'before the abort record: COMMIT PREPARED keeps the chmod'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 't', + 'and leaves no ATM entry behind'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl new file mode 100644 index 0000000000000..f40dcd271b05b --- /dev/null +++ b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl @@ -0,0 +1,95 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A deferred commit-time file operation must survive a crash right after +# its transaction's commit record: the commit is durable, so the deletion, +# rename or rmdir it implies must happen too, on restart if not before. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('fileops_commit_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +shared_preload_libraries = 'injection_points' +wal_level = replica +}); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +my $datadir = $node->data_dir; + +# Commit $sql, crash right after the commit record, restart. +sub commit_and_crash +{ + my ($sql) = @_; + $node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-commit-before-pending-ops', 'wait')" + ); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until(qr/start/, "\\echo start\n$sql\n"); + $node->wait_for_event('client backend', + 'fileops-commit-before-pending-ops'); + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; +} + +$node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('del.dat')"); +$node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('ren.dat')"); +$node->safe_psql('postgres', "SELECT test_fileops_mkdir('rmd', 448)"); +commit_and_crash( + q{BEGIN; +SELECT test_fileops_delete('del.dat'); +SELECT test_fileops_rename('ren.dat', 'ren2.dat'); +SELECT test_fileops_rmdir('rmd'); +COMMIT;}); +TODO: +{ + # The deferred ops are WAL-logged only when executed, after the commit + # record, so this crash loses them. Their intent must be durable before + # the commit record and executed by commit redo. + local $TODO = + 'commit-time FILEOPS ops are logged after the commit record'; + ok(!-e "$datadir/del.dat", + 'committed FileOpsDelete happened despite the crash'); + ok( !-e "$datadir/ren.dat" && -e "$datadir/ren2.dat", + 'committed FileOpsRename happened despite the crash'); + ok(!-e "$datadir/rmd", + 'committed FileOpsRmdir happened despite the crash'); +} + +# DROP TABLESPACE is safe: its XLOG_TBLSPC_DROP precedes the commit record +# and its redo removes the directories. (In-place, so they are under +# pg_tblspc.) +$node->safe_psql('postgres', + 'SET allow_in_place_tablespaces = on; CREATE TABLESPACE ts LOCATION \'\'' +); +my $tsoid = $node->safe_psql('postgres', + "SELECT oid FROM pg_tablespace WHERE spcname = 'ts'"); +ok(-d "$datadir/pg_tblspc/$tsoid", 'tablespace directory exists'); +commit_and_crash('DROP TABLESPACE ts;'); +is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM pg_tablespace WHERE spcname = 'ts'" + ), + '0', + 'DROP TABLESPACE committed'); +ok(!-e "$datadir/pg_tblspc/$tsoid", + 'committed DROP TABLESPACE removed its directory despite the crash'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl new file mode 100644 index 0000000000000..75ae70f28c3d6 --- /dev/null +++ b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl @@ -0,0 +1,85 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', "max_prepared_transactions = 5\n"); +$primary->start; +$primary->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$primary->safe_psql('postgres', 'CREATE EXTENSION injection_points') + if $ENV{enable_injection_points} eq 'yes'; +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; +$primary->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('base.dat', 384); +SELECT test_fileops_write('base.dat', 0, 'committed-base'); +COMMIT; +BEGIN; +SELECT test_fileops_create('abort.dat', 384); +SELECT test_fileops_write('abort.dat', 0, 'aborted'); +ROLLBACK; +BEGIN; SELECT test_fileops_mkdir('abortdir', 448); ROLLBACK; +BEGIN; SELECT test_fileops_chmod('base.dat', 448); ROLLBACK; +BEGIN; SELECT test_fileops_write('base.dat', 0, 'OVERWRITTEN!!!'); ROLLBACK; +BEGIN; SELECT test_fileops_truncate('base.dat', 3); ROLLBACK; +}); +$primary->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_write('base.dat', 0, 'prepared'); +SELECT test_fileops_truncate('base.dat', 2); +PREPARE TRANSACTION 'p'; +ROLLBACK PREPARED 'p'; +BEGIN; +SAVEPOINT a; +SELECT test_fileops_chmod('base.dat', 448); +ROLLBACK TO a; +COMMIT; +}); +$primary->wait_for_catchup($standby); +if ($ENV{enable_injection_points} eq 'yes') +{ + $standby->safe_psql('postgres', + "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')" + ); + $primary->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('standby-window.dat', 384); +ROLLBACK; +}); + $standby->wait_for_event('startup', 'fileops-abort-after-flush'); + ok(-e $standby->data_dir . '/standby-window.dat', + 'standby paused before inverse'); + $standby->stop('immediate'); + $standby->start; + $primary->wait_for_catchup($standby); + ok(!-e $standby->data_dir . '/standby-window.dat', + 'standby crash replays inverse'); +} +for my $stage ('streaming', 'promoted') +{ + $standby->promote if $stage eq 'promoted'; + for my $node ($primary, $standby) + { + my $dir = $node->data_dir; + my $name = $node->name; + ok(!-e "$dir/abort.dat", "$stage $name: aborted create absent"); + ok(!-e "$dir/abortdir", "$stage $name: aborted mkdir absent"); + is((stat("$dir/base.dat"))[2] & 07777, + 0600, "$stage $name: original mode"); + is(slurp_file("$dir/base.dat"), + 'committed-base', "$stage $name: original bytes"); + } +} +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/009_fileops_reuse.pl b/src/test/modules/test_fileops/t/009_fileops_reuse.pl new file mode 100644 index 0000000000000..8c9861497efb1 --- /dev/null +++ b/src/test/modules/test_fileops/t/009_fileops_reuse.pl @@ -0,0 +1,70 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('reuse'); +$node->init; +$node->append_conf('postgresql.conf', + "wal_level = replica\nmax_prepared_transactions = 5\ncheckpoint_timeout = '1h'\n" +); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops; CHECKPOINT'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('base.dat', 384); +SELECT test_fileops_write('base.dat', 0, 'original'); +COMMIT; +BEGIN; +SAVEPOINT a; +SELECT test_fileops_chmod('base.dat', 448); +SAVEPOINT b; +SELECT test_fileops_write('base.dat', 0, 'changed!'); +RELEASE b; +ROLLBACK TO a; +COMMIT; +BEGIN; +SELECT test_fileops_write('base.dat', 0, 'prepared'); +SELECT test_fileops_truncate('base.dat', 2); +PREPARE TRANSACTION 'bytes'; +}); +$node->stop('immediate'); +$node->start; +$node->safe_psql('postgres', "ROLLBACK PREPARED 'bytes'"); +my $dir = $node->data_dir; +for my $kind ('plain', 'sub', 'prepared') +{ + my $end = + $kind eq 'prepared' ? "PREPARE TRANSACTION 'p'; ROLLBACK PREPARED 'p';" + : $kind eq 'sub' ? 'ROLLBACK TO s; COMMIT;' + : 'ROLLBACK;'; + my $savepoint = $kind eq 'sub' ? 'SAVEPOINT s;' : ''; + $node->safe_psql('postgres', + "BEGIN; $savepoint SELECT test_fileops_create('$kind.dat', 384); SELECT test_fileops_write('$kind.dat', 0, 'aborted'); $end" + ); + ok( $node->poll_query_until( + 'postgres', "SELECT NOT test_fileops_file_exists('$kind.dat')"), + "$kind: abort removed file"); + $node->safe_psql('postgres', + "BEGIN; SELECT test_fileops_create('$kind.dat', 384); SELECT test_fileops_write('$kind.dat', 0, 'T2-committed'); COMMIT;" + ); +} +for my $restart (1 .. 2) +{ + $node->stop('immediate'); + $node->start; + is(slurp_file("$dir/base.dat"), + 'original', "$restart: nested and prepared bytes restored"); + is((stat("$dir/base.dat"))[2] & 07777, + 0600, "$restart: nested mode restored"); + for my $kind ('plain', 'sub', 'prepared') + { + is(slurp_file("$dir/$kind.dat"), + 'T2-committed', "$restart $kind: committed reuse survives"); + } +} +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl b/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl new file mode 100644 index 0000000000000..756e8ae0bd02e --- /dev/null +++ b/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl @@ -0,0 +1,197 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('abort_redo'); +$node->init; +$node->append_conf('postgresql.conf', + "wal_level = replica\nautovacuum = off\ncheckpoint_timeout = '1h'\nmax_logical_revert_workers = 0\nmax_prepared_transactions = 5\n" +); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +my $dir = $node->data_dir; +$node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('base.dat', 384); +SELECT test_fileops_write('base.dat', 0, 'committed-base'); +COMMIT; +CHECKPOINT; +BEGIN; +SELECT test_fileops_create('abort.dat', 384); +SELECT test_fileops_write('abort.dat', 0, 'aborted'); +ROLLBACK; +BEGIN; SELECT test_fileops_mkdir('abortdir', 448); ROLLBACK; +BEGIN; SELECT test_fileops_chmod('base.dat', 448); ROLLBACK; +BEGIN; SELECT test_fileops_write('base.dat', 0, 'OVERWRITTEN!!!'); ROLLBACK; +BEGIN; SELECT test_fileops_truncate('base.dat', 3); ROLLBACK; +BEGIN; +SAVEPOINT s; +SELECT test_fileops_create('sub.dat', 384); +ROLLBACK TO s; +SELECT test_fileops_write('base.dat', 0, 'parent'); +ROLLBACK; +BEGIN; SELECT test_fileops_symlink('base.dat', 'abort.sym'); ROLLBACK; +BEGIN; SELECT test_fileops_link('base.dat', 'abort.link'); ROLLBACK; + +}); +my $xattrs = $node->safe_psql('postgres', + "SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'original')") + eq 't'; +if ($xattrs) +{ + $node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'changed'); +ROLLBACK; +BEGIN; +SELECT test_fileops_removexattr('base.dat', 'user.abort_test'); +ROLLBACK; +}); +} +my ($uid, $gid) = (stat("$dir/base.dat"))[ 4, 5 ]; +# An unprivileged test can chown to its own identity; a different uid needs +# a privileged harness. This still exercises CHOWN inverse serialization. +$node->safe_psql('postgres', + "BEGIN; SELECT test_fileops_chown('base.dat', $uid, $gid); ROLLBACK;"); +for my $restart (0 .. 2) +{ + if ($restart) + { + $node->stop('immediate'); + $node->start; + } + ok(!-e "$dir/sub.dat", "$restart: subabort then parent abort"); + ok(!-l "$dir/abort.sym", "$restart: symlink inverse"); + ok(!-e "$dir/abort.link", "$restart: link inverse"); + ok(!-e "$dir/abort.dat", "$restart: aborted create absent"); + ok(!-e "$dir/abortdir", "$restart: aborted mkdir absent"); + is((stat("$dir/base.dat"))[2] & 07777, 0600, "$restart: original mode"); + is((stat("$dir/base.dat"))[4], + $uid, "$restart: owner after chown inverse"); + is((stat("$dir/base.dat"))[5], + $gid, "$restart: group after chown inverse"); + SKIP: + { + skip 'extended attributes not supported', 1 unless $xattrs; + is( $node->safe_psql( + 'postgres', + "SELECT test_fileops_getxattr('base.dat', 'user.abort_test')" + ), + 'original', + "$restart: xattr inverses restore value"); + } + is(-s "$dir/base.dat", 14, "$restart: original size"); + is(slurp_file("$dir/base.dat"), + 'committed-base', "$restart: original bytes"); +} + +# Stop with a durable abort decision but no inverses applied yet. A +# concurrently requested checkpoint must wait, not omit the abort from redo. +if ($ENV{enable_injection_points} eq 'yes') +{ + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + for my $checkpoint (0, 1, 2) + { + my $finish = + $checkpoint == 2 + ? "PREPARE TRANSACTION 'window'; ROLLBACK PREPARED 'window';" + : 'ROLLBACK;'; + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')" + ); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until( + qr/start/, qq(\\echo start +BEGIN; +SELECT test_fileops_create('mid.dat', 384); +SELECT test_fileops_write('mid.dat', 0, 'uncommitted'); +SELECT test_fileops_truncate('base.dat', 3); +$finish +)); + $node->wait_for_event('client backend', 'fileops-abort-after-flush'); + my $ckpt; + if ($checkpoint == 1) + { + $ckpt = $node->background_psql('postgres', on_error_stop => 0); + $ckpt->query_until(qr/checkpoint/, + "\\echo checkpoint\nCHECKPOINT;\n"); + $node->wait_for_event('checkpointer', 'CheckpointDelayStart'); + } + $node->stop('immediate'); + $bg->{run}->finish; + $ckpt->{run}->finish if $ckpt; + $node->start; + ok(!-e "$dir/mid.dat", + "$checkpoint: interrupted abort removed create"); + is(slurp_file("$dir/base.dat"), + 'committed-base', + "$checkpoint: interrupted abort restored shrink bytes"); + $node->stop('immediate'); + $node->start; + ok(!-e "$dir/mid.dat", + "$checkpoint: repeated recovery keeps create absent"); + } +} + +if ($ENV{enable_injection_points} eq 'yes') +{ + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql( + 'postgres', q{ +SELECT injection_points_attach('createdb-wal-log-after-copy-relation', 'error'); +SELECT injection_points_attach('fileops-abort-after-flush', 'wait'); +}); + my $db = $node->background_psql('postgres', on_error_stop => 0); + $db->query_until(qr/start/, + "\\echo start\nCREATE DATABASE aborted_tree STRATEGY WAL_LOG OID 90000;\n" + ); + $node->wait_for_event('client backend', 'fileops-abort-after-flush'); + # createdb's error cleanup removes the online directory already. Redo + # recreates it from forward WAL, so the embedded tree inverse must remove + # it again even though the original inode no longer exists. + ok(!-e "$dir/base/90000", 'createdb error cleanup removed online tree'); + $node->stop('immediate'); + $db->{run}->finish; + $node->start; + ok(!-e "$dir/base/90000", 'abort redo removes database tree'); +} + +# A WARNING is not a durable retry obligation: fixing permissions does not +# retry this inverse online. A crash before the next checkpoint replays it. +$node->safe_psql('postgres', 'CHECKPOINT'); +my $failed = $node->background_psql('postgres'); +$failed->query_safe('BEGIN'); +$failed->query_safe("SELECT test_fileops_truncate('base.dat', 3)"); +chmod(0400, "$dir/base.dat") or die "chmod: $!"; +my $log_offset = -s $node->logfile; +$failed->query('ROLLBACK'); +$failed->quit; +like( + slurp_file($node->logfile, $log_offset), + qr/WARNING:.*FILEOPS UNDO TRUNCATE: could not open/, + 'failed inverse reports a warning'); +chmod(0600, "$dir/base.dat") or die "chmod: $!"; +is(slurp_file("$dir/base.dat"), + 'com', 'failed inverse remains unapplied online'); +$node->stop('immediate'); +$node->start; +is(slurp_file("$dir/base.dat"), + 'committed-base', 'abort redo retries failed inverse after crash'); + +# Refuse excessive before-images before touching the file. +my ($ret, $out, $err) = $node->psql('postgres', + "SELECT test_fileops_write('base.dat', 0, repeat('x', 1048577))"); +like( + $err, + qr/ERROR:.*excessive transactional file write range/, + 'oversized write is an ordinary error'); +is(slurp_file("$dir/base.dat"), + 'committed-base', 'refused write changes nothing'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl b/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl new file mode 100644 index 0000000000000..707ffbe9df861 --- /dev/null +++ b/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl @@ -0,0 +1,120 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; + +my $node = PostgreSQL::Test::Cluster->new('ownership'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +autovacuum = off +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION test_fileops; +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CHECKPOINT; +}); + +# This point must precede the abort decision. Otherwise recovery forgets +# the non-FILEOPS chain before it was applied or registered in the ATM. +$node->safe_psql('postgres', + "SELECT injection_points_attach('undo-xact-abort-before-apply', 'wait')"); +my $bg = $node->background_psql('postgres', on_error_stop => 0); +$bg->query_until( + qr/start/, qq(\\echo start +BEGIN; +INSERT INTO t SELECT generate_series(1, 10); +SELECT test_fileops_create('first.dat', 384); +ROLLBACK; +)); +$node->wait_for_event('client backend', 'undo-xact-abort-before-apply'); +$node->stop('immediate'); +$bg->{run}->finish; +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->start; +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'crash rollback drained'); +# The worker walks the index chain but leaves its entries for VACUUM (see +# nbtree_undo_apply()). +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'crash before generic apply leaves the index entries for VACUUM'); +ok(!-e $node->data_dir . '/first.dat', 'crash rollback removes file'); + +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); +$node->restart; +$bg = $node->background_psql('postgres'); +$bg->query_safe('SELECT injection_points_set_local()'); +$bg->query_safe( + "SELECT injection_points_attach('undo-apply-after-batch', 'error')"); +$bg->query_safe('BEGIN; SAVEPOINT s'); +$bg->query_safe("SELECT test_fileops_create('reuse.dat', 384)"); +$bg->query_safe('INSERT INTO t VALUES (11)'); +$bg->query('ROLLBACK TO s'); +$bg->query_safe('COMMIT'); +$bg->quit; +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'failed bounded walk is queued after parent commits'); +ok(!-e $node->data_dir . '/reuse.dat', 'subabort removed file'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('reuse.dat', 384); +SELECT test_fileops_write('reuse.dat', 0, 'T2-committed'); +COMMIT; +CHECKPOINT; +}); +# The checkpoint/restart also checks that ownership survives ATM persistence. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'bounded ATM entry drained'); +my $path = $node->data_dir . '/reuse.dat'; +is(-e $path ? slurp_file($path) : '', + 'T2-committed', 'bounded worker leaves committed same-path reuse intact'); +# The inline walk killed entry 11 before the injected error; the worker +# leaves index entries alone. +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '1', 'only the inline subtransaction walk marked an index entry dead'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-write-short', 'error')"); +my ($ret, $out, $err) = $node->psql('postgres', + "SELECT test_fileops_write('reuse.dat', 0, 'partialwrite')"); +like($err, qr/could not write/, 'short write reports error'); +$node->safe_psql('postgres', + "SELECT injection_points_detach('fileops-write-short')"); +is(slurp_file($path), 'T2-committed', 'short write restores before-image'); +# A first write inside a savepoint must assign the subxid before failing. +$bg = $node->background_psql('postgres', on_error_stop => 0); +$bg->query_safe('BEGIN; SAVEPOINT short_write'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-write-short', 'error')"); +$bg->query("SELECT test_fileops_write('reuse.dat', 20, 'extension')"); +like( + $bg->{stderr}, + qr/could not write/, + 'short extending write reports error'); +$bg->{stderr} = ''; +$bg->query_safe('ROLLBACK TO short_write; COMMIT'); +$bg->quit; +$node->safe_psql('postgres', + "SELECT injection_points_detach('fileops-write-short')"); +is(slurp_file($path), 'T2-committed', + 'short extending subxact write restores length'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/012_fileops_contracts.pl b/src/test/modules/test_fileops/t/012_fileops_contracts.pl new file mode 100644 index 0000000000000..a566b9d242053 --- /dev/null +++ b/src/test/modules/test_fileops/t/012_fileops_contracts.pl @@ -0,0 +1,22 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# These are source-contract checks, not an fsync fault-injection test. +use strict; +use warnings FATAL => 'all'; +use FindBin; +use PostgreSQL::Test::Utils; +use Test::More; + +my $src = "$FindBin::RealBin/../../../.."; +my $undo = slurp_file("$src/backend/storage/file/fileops_undo.c"); +$undo =~ /void\s+FileOpsApplyInverse\(.*?(?=fileops_undo_desc -)/s + or die 'cannot find inverse applier'; +my $apply = $&; +my @syncs = $apply =~ /(fsync_fname_ext\([^;]+;)/g; +is(scalar @syncs, 2, 'inverse syncs file and parent'); +# 016_fileops_inverse_fsync.pl tests what a failed inverse sync does. +my $header = slurp_file("$src/include/access/xlog_internal.h"); +unlike( + $header, + qr/#define XLOG_PAGE_MAGIC 0xD125\b/, + 'incompatible FILEOPS and ATM records change WAL magic'); +done_testing(); diff --git a/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl b/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl new file mode 100644 index 0000000000000..932c913f1267e --- /dev/null +++ b/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl @@ -0,0 +1,83 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Startup can revert FILEOPS without the syscache needed for index UNDO. +# A deferred worker must not repeat those inverses after same-path reuse. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $crash_again (0, 1) +{ + my $node = PostgreSQL::Test::Cluster->new("loser_$crash_again"); + $node->init; + $node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +autovacuum = off +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); + $node->start; + $node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_fileops; +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CHECKPOINT; +}); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_safe('BEGIN'); + $bg->query_safe('INSERT INTO t SELECT generate_series(1, 10)'); + $bg->query_safe("SELECT test_fileops_create('loser.dat', 384)"); + $node->safe_psql('postgres', 'SELECT pg_switch_wal()'); + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; + my $path = $node->data_dir . '/loser.dat'; + ok(!-e $path, "$crash_again: startup removed loser file"); + is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + "$crash_again: index chain deferred"); + $node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('loser.dat', 384); +SELECT test_fileops_write('loser.dat', 0, 'T2-committed'); +COMMIT; +}); + if ($crash_again) + { + # No user checkpoint: recover the end-of-recovery ATM snapshot and + # its subsequent WAL while the worker is still disabled. + $node->stop('immediate'); + $node->start; + is(-e $path ? slurp_file($path) : '', + 'T2-committed', + 'second crash preserves committed reuse before worker runs'); + is( $node->safe_psql( + 'postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'second crash retains deferred index chain'); + } + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); + $node->restart; + ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + "$crash_again: worker drained ATM"); + # The worker leaves index entries for VACUUM (see nbtree_undo_apply()). + is( $node->safe_psql( + 'postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', + "$crash_again: worker left index entries for VACUUM"); + is(-e $path ? slurp_file($path) : '', + 'T2-committed', + "$crash_again: committed reuse survives deferred loser revert"); + $node->stop; +} + +done_testing(); diff --git a/src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl b/src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl new file mode 100644 index 0000000000000..2dd82ca8a6e36 --- /dev/null +++ b/src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl @@ -0,0 +1,68 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# An online ATM entry does not own FILEOPS if the server crashes before +# writing the abort record. A failed startup inverse must remain retryable. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; +plan skip_all => 'POSIX permission semantics required' if $windows_os; + +my $node = PostgreSQL::Test::Cluster->new('loser_ownership'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +undo_instant_abort_threshold = 0 +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION test_fileops; +CHECKPOINT; +SELECT injection_points_attach('undo-apply-before-batch', 'error'); +SELECT injection_points_attach('undo-xact-abort-after-atm', 'wait'); +}); +my $dir = $node->data_dir . '/blocked'; +mkdir($dir) or die "mkdir $dir: $!"; +my $path = "$dir/loser.dat"; +my $bg = $node->background_psql('postgres', on_error_stop => 0); +$bg->query_until( + qr/start/, qq(\\echo start +BEGIN; +SELECT test_fileops_create('blocked/loser.dat', 384); +ROLLBACK; +)); +$node->wait_for_event('client backend', 'undo-xact-abort-after-atm'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'online abort registered in ATM before abort WAL'); +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); +$bg->{run}->finish; +chmod(0500, $dir) or die "chmod $dir: $!"; +$node->start; +ok(-e $path, 'failed startup inverse leaves loser file'); +like( + slurp_file($node->logfile), + qr/FILEOPS UNDO CREATE: could not unlink/, + 'startup attempted the inverse'); +chmod(0700, $dir) or die "chmod $dir: $!"; +# Persist the replacement marker before allowing the worker to run. +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'worker drained ATM'); +ok(!-e $path, 'worker retries FILEOPS without a durable abort record'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/015_fileops_promote_loser.pl b/src/test/modules/test_fileops/t/015_fileops_promote_loser.pl new file mode 100644 index 0000000000000..3ef248c0ed873 --- /dev/null +++ b/src/test/modules/test_fileops/t/015_fileops_promote_loser.pl @@ -0,0 +1,78 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Promotion must not leave a FILEOPS loser eligible for another startup walk +# after a committed transaction has reused its path. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(time); +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', q{ +checkpoint_timeout = '1h' +checkpoint_completion_target = 0.9 +max_wal_size = '2GB' +autovacuum = off +max_logical_revert_workers = 0 +log_checkpoints = on +}); +$primary->start; +$primary->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_fileops; +CREATE TABLE dirty (data text); +}); +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +# Dirty enough buffers that the asynchronous, time-smoothed checkpoint +# requested after fast promotion cannot finish before the immediate stop. +$primary->safe_psql('postgres', + "INSERT INTO dirty SELECT repeat('x', 1000) FROM generate_series(1, 10000)" +); +my $bg = $primary->background_psql('postgres', on_error_stop => 0); +$bg->query_safe('BEGIN'); +$bg->query_safe("SELECT test_fileops_create('loser.dat', 384)"); +$primary->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$primary->wait_for_catchup($standby); +my $path = $standby->data_dir . '/loser.dat'; +ok(-e $path, 'standby replayed the loser create'); +$primary->stop('immediate'); +$bg->{run}->finish; + +my $started = time(); +$standby->promote; +diag( + sprintf( + 'promotion with 10MB dirty data: %.3f seconds', time() - $started)); +ok(!-e $path, 'promotion removed loser file'); +my $checkpoint = $standby->safe_psql('postgres', + 'SELECT checkpoint_lsn FROM pg_control_checkpoint()'); +$standby->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('loser.dat', 384); +SELECT test_fileops_write('loser.dat', 0, 'committed-after-promotion'); +COMMIT; +}); +is( $standby->safe_psql( + 'postgres', 'SELECT checkpoint_lsn FROM pg_control_checkpoint()'), + $checkpoint, + 'no checkpoint completed after promotion'); + +for my $restart (1, 2) +{ + $standby->stop('immediate'); + $standby->start; + is( -e $path ? slurp_file($path) : '', + 'committed-after-promotion', + "restart $restart preserves committed reuse"); +} +$standby->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl b/src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl new file mode 100644 index 0000000000000..c16af9334747a --- /dev/null +++ b/src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl @@ -0,0 +1,79 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Abort applies FILEOPS inverses after the abort record is flushed and clog +# says aborted. A failed fsync of an inverse must PANIC there even with +# data_sync_retry = on: an ERROR would re-enter AbortTransaction and log a +# second abort record. Crash recovery replays the abort record instead. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('inverse_fsync'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +autovacuum = off +fsync = on +data_sync_retry = on +restart_after_crash = on +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); +my $dir = $node->data_dir; +my $start_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-inverse-fsync-fail', 'notice')"); +my ($ret, $stdout, $stderr) = $node->psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('inverse.dat', 384); +ROLLBACK; +}); + +# The server restarts after the PANIC, which also drops the attachment. +$node->poll_query_until('postgres', 'SELECT 1', '1'); +my $log = slurp_file($node->logfile); +like( + $log, + qr/PANIC: could not fsync file ".*inverse\.dat"/, + 'failed inverse fsync during abort is a PANIC'); +unlike( + $log, + qr/AbortTransaction while in ABORT state/, + 'abort is not re-entered'); + +my $end_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +my ($walout, $walerr); +IPC::Run::run [ + 'pg_waldump', + '--path' => "$dir/pg_wal", + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'Transaction' + ], + '>' => \$walout, + '2>' => \$walerr; +my $aborts = () = $walout =~ /desc: ABORT/g; +is($aborts, 1, 'exactly one abort record'); +ok(!-e "$dir/inverse.dat", 'crash recovery removed the aborted create'); + +$node->safe_psql('postgres', + "BEGIN; SELECT test_fileops_create('after.dat', 384); ROLLBACK;"); +ok(!-e "$dir/after.dat", 'later aborts still apply their inverses'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/test_fileops--1.0.sql b/src/test/modules/test_fileops/test_fileops--1.0.sql new file mode 100644 index 0000000000000..09544f76f864b --- /dev/null +++ b/src/test/modules/test_fileops/test_fileops--1.0.sql @@ -0,0 +1,105 @@ +/* src/test/modules/test_fileops/test_fileops--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_fileops" to load this file. \quit + +-- Create a temporary file and return its path +CREATE FUNCTION test_fileops_create_tempfile(filename text) + RETURNS text + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a file via FileOpsCreate (exercises the WAL-before-syscall path) +CREATE FUNCTION test_fileops_create(filepath text, mode int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Truncate a file to the given length (uses FileOpsTruncate) +CREATE FUNCTION test_fileops_truncate(filepath text, length bigint) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get file size +CREATE FUNCTION test_fileops_file_size(filepath text) + RETURNS bigint + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Chmod a file (uses FileOpsChmod) +CREATE FUNCTION test_fileops_chmod(filepath text, mode int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get file permissions (mode) +CREATE FUNCTION test_fileops_get_mode(filepath text) + RETURNS int + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a hard link (uses FileOpsLink) +CREATE FUNCTION test_fileops_link(oldpath text, newpath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Check if a file exists +CREATE FUNCTION test_fileops_file_exists(filepath text) + RETURNS boolean + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Set extended attribute (uses FileOpsSetXattr) +CREATE FUNCTION test_fileops_setxattr(filepath text, attrname text, attrvalue text) + RETURNS boolean + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get extended attribute value +CREATE FUNCTION test_fileops_getxattr(filepath text, attrname text) + RETURNS text + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Remove extended attribute (uses FileOpsRemoveXattr) +CREATE FUNCTION test_fileops_removexattr(filepath text, attrname text) + RETURNS boolean + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get the data directory path (for constructing absolute paths) +CREATE FUNCTION test_fileops_data_dir() + RETURNS text + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Read the oldest un-reverted ATM last_batch_lsn (the LSN that pins UNDO WAL +-- against recycling via ATMGetOldestUnrevertedLSN -> KeepLogSeg). NULL when +-- no un-reverted entry exists. Test-only, for the retention-invariant test. +CREATE FUNCTION test_fileops_atm_oldest_lsn() + RETURNS pg_lsn + AS 'MODULE_PATHNAME' LANGUAGE C; +-- Deferred delete at commit (uses FileOpsDelete, at_commit=true) +CREATE FUNCTION test_fileops_delete(filepath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Deferred rename at commit (uses FileOpsRename) +CREATE FUNCTION test_fileops_rename(oldpath text, newpath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a directory (uses FileOpsMkdir) +CREATE FUNCTION test_fileops_mkdir(path text, mode int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Deferred rmdir at commit (uses FileOpsRmdir, at_commit=true) +CREATE FUNCTION test_fileops_rmdir(path text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Write a string at an offset (uses FileOpsWrite) +CREATE FUNCTION test_fileops_write(filepath text, "offset" bigint, data text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Change ownership (uses FileOpsChown) +CREATE FUNCTION test_fileops_chown(filepath text, uid int, gid int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a symbolic link (uses FileOpsSymlink) +CREATE FUNCTION test_fileops_symlink(target text, linkpath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; diff --git a/src/test/modules/test_fileops/test_fileops.c b/src/test/modules/test_fileops/test_fileops.c new file mode 100644 index 0000000000000..ae3de459f9561 --- /dev/null +++ b/src/test/modules/test_fileops/test_fileops.c @@ -0,0 +1,494 @@ +/*------------------------------------------------------------------------- + * + * test_fileops.c + * Test module exposing FileOps C API to SQL for regression testing. + * + * This module provides SQL-callable wrappers around the FileOps functions + * that have no DDL-level callers, enabling direct testing of: + * - FileOpsTruncate (with UNDO rollback of file size) + * - FileOpsChmod (with UNDO rollback of permissions) + * - FileOpsLink (with UNDO rollback of hard links) + * - FileOpsSetXattr / FileOpsRemoveXattr (with UNDO rollback) + * + * Copyright (c) 2024-2026, PostgreSQL Global Development Group + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include +#include + +#include "fmgr.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "port/pg_xattr.h" +#include "access/atm.h" +#include "access/xlogdefs.h" +#include "storage/fd.h" +#include "storage/fileops.h" +#include "utils/builtins.h" +#include "utils/pg_lsn.h" + +PG_MODULE_MAGIC; + +PG_FUNCTION_INFO_V1(test_fileops_create_tempfile); +PG_FUNCTION_INFO_V1(test_fileops_create); +PG_FUNCTION_INFO_V1(test_fileops_truncate); +PG_FUNCTION_INFO_V1(test_fileops_file_size); +PG_FUNCTION_INFO_V1(test_fileops_chmod); +PG_FUNCTION_INFO_V1(test_fileops_get_mode); +PG_FUNCTION_INFO_V1(test_fileops_link); +PG_FUNCTION_INFO_V1(test_fileops_file_exists); +PG_FUNCTION_INFO_V1(test_fileops_setxattr); +PG_FUNCTION_INFO_V1(test_fileops_getxattr); +PG_FUNCTION_INFO_V1(test_fileops_removexattr); +PG_FUNCTION_INFO_V1(test_fileops_data_dir); +PG_FUNCTION_INFO_V1(test_fileops_atm_oldest_lsn); +PG_FUNCTION_INFO_V1(test_fileops_write); +PG_FUNCTION_INFO_V1(test_fileops_chown); +PG_FUNCTION_INFO_V1(test_fileops_symlink); + +/* + * test_fileops_atm_oldest_lsn - Read the oldest un-reverted ATM last_batch_lsn. + * + * This is the exact value ATMGetOldestUnrevertedLSN() feeds into + * UndoGetOldestBatchLSN() -> KeepLogSeg(), i.e. the LSN that pins UNDO WAL + * against recycling. Returning it lets a recovery test assert, after crash + + * restart, that the ATM was faithfully reconstructed and still pins the WAL -- + * NOT merely that the WAL file happens to exist on disk. Returns NULL when no + * un-reverted entry exists. + */ +Datum +test_fileops_atm_oldest_lsn(PG_FUNCTION_ARGS) +{ + XLogRecPtr lsn = ATMGetOldestUnrevertedLSN(); + + if (XLogRecPtrIsInvalid(lsn)) + PG_RETURN_NULL(); + + PG_RETURN_LSN(lsn); +} + +PG_FUNCTION_INFO_V1(test_fileops_delete); +PG_FUNCTION_INFO_V1(test_fileops_rename); +PG_FUNCTION_INFO_V1(test_fileops_mkdir); +PG_FUNCTION_INFO_V1(test_fileops_rmdir); + +/* + * test_fileops_create_tempfile - Create a test file in data directory. + * + * Creates a file with some content using FileOpsCreate + write, returning + * the absolute path. The file is created with 1024 bytes of content. + */ +Datum +test_fileops_create_tempfile(PG_FUNCTION_ARGS) +{ + text *filename = PG_GETARG_TEXT_PP(0); + char *fname = text_to_cstring(filename); + char filepath[MAXPGPATH]; + int fd; + char buf[1024]; + + snprintf(filepath, MAXPGPATH, "%s/%s", DataDir, fname); + + /* Use raw create + write so we have a file to manipulate */ + fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC | PG_BINARY, 0644); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", filepath))); + + /* Fill with 1024 bytes */ + memset(buf, 'X', sizeof(buf)); + if (write(fd, buf, sizeof(buf)) != sizeof(buf)) + { + int save_errno = errno; + + close(fd); + errno = save_errno; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", filepath))); + } + close(fd); + + PG_RETURN_TEXT_P(cstring_to_text(filepath)); +} + +/* + * test_fileops_create - Create a file using FileOpsCreate. + * + * Unlike test_fileops_create_tempfile (which uses raw open()/write() to set + * up fixture files outside any transactional path), this calls FileOpsCreate + * directly so tests can exercise its WAL-before-syscall crash-recovery + * behavior. + */ +Datum +test_fileops_create(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + int mode = PG_GETARG_INT32(1); + char *filepath = text_to_cstring(filepath_text); + int fd; + + fd = FileOpsCreate(filepath, O_WRONLY | PG_BINARY, (mode_t) mode, true); + if (fd >= 0) + CloseTransientFile(fd); + + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_truncate - Truncate a file using FileOpsTruncate. + * + * This exercises the UNDO path: on rollback, the original file size + * should be restored. + */ +Datum +test_fileops_truncate(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + int64 length = PG_GETARG_INT64(1); + char *filepath = text_to_cstring(filepath_text); + + FileOpsTruncate(filepath, (off_t) length); + + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_file_size - Get the current size of a file. + */ +Datum +test_fileops_file_size(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + char *filepath = text_to_cstring(filepath_text); + struct stat st; + + if (stat(filepath, &st) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", filepath))); + + pfree(filepath); + PG_RETURN_INT64((int64) st.st_size); +} + +/* + * test_fileops_chmod - Change file permissions using FileOpsChmod. + * + * This exercises the UNDO path: on rollback, the original permissions + * should be restored. + */ +Datum +test_fileops_chmod(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + int mode = PG_GETARG_INT32(1); + char *filepath = text_to_cstring(filepath_text); + + if (FileOpsChmod(filepath, (mode_t) mode) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not chmod file \"%s\": %m", filepath))); + + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_get_mode - Get file permission bits. + */ +Datum +test_fileops_get_mode(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + char *filepath = text_to_cstring(filepath_text); + struct stat st; + + if (stat(filepath, &st) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", filepath))); + + pfree(filepath); + PG_RETURN_INT32((int32) (st.st_mode & 0777)); +} + +/* + * test_fileops_link - Create a hard link using FileOpsLink. + * + * This exercises the UNDO path: on rollback, the link should be removed. + */ +Datum +test_fileops_link(PG_FUNCTION_ARGS) +{ + text *oldpath_text = PG_GETARG_TEXT_PP(0); + text *newpath_text = PG_GETARG_TEXT_PP(1); + char *oldpath = text_to_cstring(oldpath_text); + char *newpath = text_to_cstring(newpath_text); + + if (FileOpsLink(oldpath, newpath) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not link \"%s\" to \"%s\": %m", + oldpath, newpath))); + + pfree(oldpath); + pfree(newpath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_file_exists - Check whether a file exists. + */ +Datum +test_fileops_file_exists(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + char *filepath = text_to_cstring(filepath_text); + struct stat st; + bool exists; + + exists = (stat(filepath, &st) == 0); + + pfree(filepath); + PG_RETURN_BOOL(exists); +} + +/* + * test_fileops_setxattr - Set an extended attribute using FileOpsSetXattr. + * + * Returns true if the platform supports xattrs, false if ENOTSUP. + * This exercises the UNDO path: on rollback, the original xattr value + * (or absence) should be restored. + */ +Datum +test_fileops_setxattr(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + text *name_text = PG_GETARG_TEXT_PP(1); + text *value_text = PG_GETARG_TEXT_PP(2); + char *filepath = text_to_cstring(filepath_text); + char *name = text_to_cstring(name_text); + char *value = text_to_cstring(value_text); + int ret; + + ret = FileOpsSetXattr(filepath, name, value, strlen(value)); + if (ret != 0) + { + if (errno == ENOTSUP || errno == EOPNOTSUPP || + errno == EPERM || errno == EACCES) + { + pfree(filepath); + pfree(name); + pfree(value); + PG_RETURN_BOOL(false); + } + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not set xattr \"%s\" on \"%s\": %m", + name, filepath))); + } + + pfree(filepath); + pfree(name); + pfree(value); + PG_RETURN_BOOL(true); +} + +/* + * test_fileops_getxattr - Get an extended attribute value. + * + * Returns NULL if the attribute doesn't exist or xattrs are unsupported. + */ +Datum +test_fileops_getxattr(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + text *name_text = PG_GETARG_TEXT_PP(1); + char *filepath = text_to_cstring(filepath_text); + char *name = text_to_cstring(name_text); + char buf[1024]; + ssize_t len; + + len = pg_getxattr(filepath, name, buf, sizeof(buf) - 1); + if (len < 0) + { + pfree(filepath); + pfree(name); + PG_RETURN_NULL(); + } + + buf[len] = '\0'; + + pfree(filepath); + pfree(name); + PG_RETURN_TEXT_P(cstring_to_text(buf)); +} + +/* + * test_fileops_removexattr - Remove an extended attribute using + * FileOpsRemoveXattr. + * + * Returns true if successful, false if xattrs are unsupported. + * This exercises the UNDO path: on rollback, the removed xattr should + * be restored. + */ +Datum +test_fileops_removexattr(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + text *name_text = PG_GETARG_TEXT_PP(1); + char *filepath = text_to_cstring(filepath_text); + char *name = text_to_cstring(name_text); + int ret; + + ret = FileOpsRemoveXattr(filepath, name); + if (ret != 0) + { + if (errno == ENOTSUP || errno == EOPNOTSUPP) + { + pfree(filepath); + pfree(name); + PG_RETURN_BOOL(false); + } + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not remove xattr \"%s\" from \"%s\": %m", + name, filepath))); + } + + pfree(filepath); + pfree(name); + PG_RETURN_BOOL(true); +} + +/* + * test_fileops_data_dir - Return the data directory path. + */ +Datum +test_fileops_data_dir(PG_FUNCTION_ARGS) +{ + PG_RETURN_TEXT_P(cstring_to_text(DataDir)); +} + +/* + * test_fileops_delete - Deferred delete via FileOpsDelete(at_commit=true). + * + * Exercises the log-at-commit-execution path: the delete happens (and is + * WAL-logged) in FileOpsDoPendingOps() at commit, so a physical standby + * reproduces it by replay. + */ +Datum +test_fileops_delete(PG_FUNCTION_ARGS) +{ + char *filepath = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + FileOpsDelete(filepath, true); + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_rename - Deferred rename via FileOpsRename (always at_commit). + */ +Datum +test_fileops_rename(PG_FUNCTION_ARGS) +{ + char *oldpath = text_to_cstring(PG_GETARG_TEXT_PP(0)); + char *newpath = text_to_cstring(PG_GETARG_TEXT_PP(1)); + + if (FileOpsRename(oldpath, newpath) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not rename \"%s\" to \"%s\": %m", + oldpath, newpath))); + pfree(oldpath); + pfree(newpath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_mkdir - Create a directory via FileOpsMkdir. + * + * FileOpsMkdir creates the directory immediately and registers an + * rmdir-on-abort; on commit the directory persists. Used to set up a + * directory the test can then remove with test_fileops_rmdir. + */ +Datum +test_fileops_mkdir(PG_FUNCTION_ARGS) +{ + char *path = text_to_cstring(PG_GETARG_TEXT_PP(0)); + int mode = PG_GETARG_INT32(1); + + if (FileOpsMkdir(path, (mode_t) mode) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", path))); + pfree(path); + PG_RETURN_VOID(); +} + +/* + * test_fileops_rmdir - Deferred rmdir via FileOpsRmdir(at_commit=true). + */ +Datum +test_fileops_rmdir(PG_FUNCTION_ARGS) +{ + char *path = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + FileOpsRmdir(path, true); + pfree(path); + PG_RETURN_VOID(); +} + +/* + * test_fileops_write - Write a string at an offset via FileOpsWrite. + */ +Datum +test_fileops_write(PG_FUNCTION_ARGS) +{ + char *path = text_to_cstring(PG_GETARG_TEXT_PP(0)); + int64 offset = PG_GETARG_INT64(1); + char *data = text_to_cstring(PG_GETARG_TEXT_PP(2)); + + (void) FileOpsWrite(path, (off_t) offset, data, (uint32) strlen(data)); + pfree(path); + pfree(data); + PG_RETURN_VOID(); +} + +/* + * test_fileops_chown - Change ownership via FileOpsChown. + */ +Datum +test_fileops_chown(PG_FUNCTION_ARGS) +{ + char *path = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + (void) FileOpsChown(path, (uid_t) PG_GETARG_INT32(1), + (gid_t) PG_GETARG_INT32(2)); + pfree(path); + PG_RETURN_VOID(); +} + +/* + * test_fileops_symlink - Create a symbolic link via FileOpsSymlink. + */ +Datum +test_fileops_symlink(PG_FUNCTION_ARGS) +{ + char *target = text_to_cstring(PG_GETARG_TEXT_PP(0)); + char *linkpath = text_to_cstring(PG_GETARG_TEXT_PP(1)); + + (void) FileOpsSymlink(target, linkpath); + pfree(target); + pfree(linkpath); + PG_RETURN_VOID(); +} diff --git a/src/test/modules/test_fileops/test_fileops.control b/src/test/modules/test_fileops/test_fileops.control new file mode 100644 index 0000000000000..1d39ab2a9166b --- /dev/null +++ b/src/test/modules/test_fileops/test_fileops.control @@ -0,0 +1,4 @@ +comment = 'Test module for transactional file operations (FILEOPS)' +default_version = '1.0' +module_pathname = '$libdir/test_fileops' +relocatable = true diff --git a/src/test/modules/test_idxundo/.gitignore b/src/test/modules/test_idxundo/.gitignore new file mode 100644 index 0000000000000..536e465d407f7 --- /dev/null +++ b/src/test/modules/test_idxundo/.gitignore @@ -0,0 +1,4 @@ +/log/ +/results/ +/tmp_check/ +/output_iso/ diff --git a/src/test/modules/test_idxundo/Makefile b/src/test/modules/test_idxundo/Makefile new file mode 100644 index 0000000000000..6141a25f15475 --- /dev/null +++ b/src/test/modules/test_idxundo/Makefile @@ -0,0 +1,33 @@ +# src/test/modules/test_idxundo/Makefile + +MODULE_big = test_idxundo +OBJS = \ + $(WIN32RES) \ + test_idxundo.o +PGFILEDESC = "test_idxundo - exercise index UNDO" + +EXTENSION = test_idxundo +DATA = test_idxundo--1.0.sql + +REGRESS = test_idxundo +# The end-to-end tests verify that ROLLBACK reversed the index entries by +# reading them back through the index, and check the result with amcheck. +EXTRA_INSTALL = contrib/amcheck contrib/pageinspect src/test/modules/injection_points +export enable_injection_points +REGRESS_OPTS = --load-extension=amcheck --load-extension=pageinspect +REGRESS_OPTS += --temp-config $(top_srcdir)/src/test/modules/test_idxundo/test_idxundo.conf +# Disables the logical revert worker, which needs a server of its own. +NO_INSTALLCHECK = 1 + +TAP_TESTS = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_idxundo +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_idxundo/expected/test_idxundo.out b/src/test/modules/test_idxundo/expected/test_idxundo.out new file mode 100644 index 0000000000000..88e682cc6e953 --- /dev/null +++ b/src/test/modules/test_idxundo/expected/test_idxundo.out @@ -0,0 +1,663 @@ +-- --------------------------------------------------------------------------- +-- Index UNDO, end to end through the real abort path. +-- +-- A table created WITH (index_undo = on) makes its btree and hash indexes +-- write UNDO into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, +-- AtAbort_XactUndo() walks the chain and the apply callbacks mark the +-- provisionally-inserted entries LP_DEAD -- no VACUUM required. +-- --------------------------------------------------------------------------- +CREATE EXTENSION test_idxundo; +CREATE EXTENSION amcheck; +ERROR: extension "amcheck" already exists +-- =========================================================================== +-- Reloption plumbing +-- =========================================================================== +CREATE TABLE iu_opt (id int, val text) WITH (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +----------------- + {index_undo=on} +(1 row) + +-- round-trips through ALTER ... SET / RESET +ALTER TABLE iu_opt SET (index_undo = off); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +------------------ + {index_undo=off} +(1 row) + +ALTER TABLE iu_opt SET (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +----------------- + {index_undo=on} +(1 row) + +ALTER TABLE iu_opt RESET (index_undo); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +------------ + +(1 row) + +-- rejected where it has no meaning +CREATE INDEX iu_opt_idx ON iu_opt (id); +ALTER INDEX iu_opt_idx SET (index_undo = on); +ERROR: unrecognized parameter "index_undo" +DROP TABLE iu_opt; +-- =========================================================================== +-- The core promise: ROLLBACK reverses provisional entries, COMMIT keeps them +-- =========================================================================== +CREATE TABLE iu_btree (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_btree_idx ON iu_btree (id); +-- committed baseline +INSERT INTO iu_btree SELECT g, 'committed' FROM generate_series(1, 200) g; +-- Nothing is dead yet. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_before; + dead_before +------------- + 0 +(1 row) + +-- An aborted insert. Inside the transaction the chain head must be published, +-- or AtAbort_XactUndo() would have nothing to walk (the defect that made this +-- feature a silent no-op). +BEGIN; +INSERT INTO iu_btree SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; + chain_published +----------------- + t +(1 row) + +ROLLBACK; +-- The 100 aborted entries are now LP_DEAD, without any VACUUM. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 100 +(1 row) + +-- Every committed row is still reachable THROUGH THE INDEX. This is the +-- assertion that fails if apply killed the wrong slot. +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_btree WHERE id BETWEEN 1 AND 200; + committed_via_index +--------------------- + 200 +(1 row) + +SELECT count(*) AS aborted_via_index FROM iu_btree WHERE id BETWEEN 1001 AND 1100; + aborted_via_index +------------------- + 0 +(1 row) + +RESET enable_seqscan; +-- Index and heap agree, and the index is structurally sound. +SELECT count(*) AS heap_rows FROM iu_btree; + heap_rows +----------- + 200 +(1 row) + +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- A committed insert is untouched by the same machinery. +BEGIN; +INSERT INTO iu_btree SELECT g, 'committed2' FROM generate_series(2001, 2050) g; +COMMIT; +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_commit; + dead_after_commit +------------------- + 100 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed2_via_index FROM iu_btree WHERE id BETWEEN 2001 AND 2050; + committed2_via_index +---------------------- + 50 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Opting out must leave the old behaviour exactly as it was +-- +-- index_undo defaults to ON, so opting out takes an EXPLICIT off. (A table with +-- no reloptions at all is covered by the default-ON cases below; that is the +-- common configuration, not this one.) +-- =========================================================================== +CREATE TABLE iu_off (id int, val text) WITH (index_undo = off); +CREATE INDEX iu_off_idx ON iu_off (id); +BEGIN; +INSERT INTO iu_off SELECT g, 'aborted' FROM generate_series(1, 100) g; +SELECT idxundo_undo_chain_published() AS chain_published_when_off; + chain_published_when_off +-------------------------- + f +(1 row) + +ROLLBACK; +-- No index UNDO was written, so the entries are left for VACUUM: not dead. +SELECT idxundo_count_dead_all('iu_off_idx') AS dead_when_off; + dead_when_off +--------------- + 0 +(1 row) + +SELECT bt_index_check('iu_off_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Subtransactions: only the aborted subxact's entries are reversed +-- =========================================================================== +CREATE TABLE iu_sub (id int) WITH (index_undo = on); +CREATE INDEX iu_sub_idx ON iu_sub (id); +BEGIN; + INSERT INTO iu_sub SELECT g FROM generate_series(1, 50) g; -- keep + SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(501, 550) g; -- discard + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(101, 150) g; -- keep +COMMIT; +SET enable_seqscan = off; +SELECT count(*) AS kept_via_index FROM iu_sub WHERE id BETWEEN 1 AND 150; + kept_via_index +---------------- + 100 +(1 row) + +SELECT count(*) AS discarded_via_index FROM iu_sub WHERE id BETWEEN 501 AND 550; + discarded_via_index +--------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_sub; + heap_rows +----------- + 100 +(1 row) + +SELECT bt_index_check('iu_sub_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Page splits during an aborted transaction: splits are not reversed, the +-- aborted leaf entries are killed wherever the split moved them, and the tree +-- must stay consistent. +-- =========================================================================== +CREATE TABLE iu_split (id int, pad text) WITH (index_undo = on); +CREATE INDEX iu_split_idx ON iu_split (id); +INSERT INTO iu_split SELECT g, repeat('c', 50) FROM generate_series(1, 500) g; +BEGIN; +-- Roll back inline rather than handing the batch to the revert worker, so the +-- dead count below is deterministic. +SET LOCAL undo_instant_abort_threshold = 2147483647; +-- Enough rows, interleaved with the committed keys, to force many leaf splits. +INSERT INTO iu_split SELECT g, repeat('a', 50) FROM generate_series(501, 5000) g; +-- Keep any rollback diagnostics, whose detail depends on page geometry, out of +-- the expected output. +SET client_min_messages = error; +ROLLBACK; +RESET client_min_messages; +-- Every aborted entry is dead, including those whose insert split its leaf. +SELECT idxundo_count_dead_all('iu_split_idx') AS dead_after_split_rollback; + dead_after_split_rollback +--------------------------- + 4500 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_split WHERE id BETWEEN 1 AND 500; + committed_via_index +--------------------- + 500 +(1 row) + +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_split; + heap_rows +----------- + 500 +(1 row) + +SELECT bt_index_check('iu_split_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- hash indexes +-- =========================================================================== +CREATE TABLE iu_hash (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash_idx ON iu_hash USING hash (id); +INSERT INTO iu_hash SELECT g, 'committed' FROM generate_series(1, 200) g; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_before; + dead_before +------------- + 0 +(1 row) + +BEGIN; +INSERT INTO iu_hash SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; + chain_published +----------------- + t +(1 row) + +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 100 +(1 row) + +-- The pages holding them carry the dead-tuples hint, so an insert that finds +-- the page full reclaims them (_hash_vacuum_one_page) instead of waiting for +-- VACUUM. +SELECT bool_or((s.hasho_flag & 128) <> 0) AS has_dead_hint + FROM generate_series(1, pg_relation_size('iu_hash_idx') / + current_setting('block_size')::int - 1) b, + LATERAL hash_page_stats(get_raw_page('iu_hash_idx', b::int)) s + WHERE hash_page_type(get_raw_page('iu_hash_idx', b::int)) IN ('bucket', 'overflow'); + has_dead_hint +--------------- + t +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash WHERE id BETWEEN 1 AND 200; + committed_via_index +--------------------- + 200 +(1 row) + +SELECT count(*) AS aborted_via_index FROM iu_hash WHERE id BETWEEN 1001 AND 1100; + aborted_via_index +------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_hash; + heap_rows +----------- + 200 +(1 row) + +-- =========================================================================== +-- Turning the option on AFTER rows already exist. +-- +-- The option only governs whether NEW inserts write UNDO; pre-existing entries +-- have no UNDO record and are therefore unaffected by a later rollback. That +-- is the documented behaviour, and it is safe: an entry with no UNDO record is +-- simply left to VACUUM, exactly as today. +-- =========================================================================== +CREATE TABLE iu_late (id int); +CREATE INDEX iu_late_idx ON iu_late (id); +INSERT INTO iu_late SELECT g FROM generate_series(1, 100) g; -- no UNDO written +ALTER TABLE iu_late SET (index_undo = on); +BEGIN; +INSERT INTO iu_late SELECT g FROM generate_series(201, 250) g; -- UNDO written +ROLLBACK; +-- Only the 50 post-ALTER entries are reversed; the 100 earlier ones are intact. +SELECT idxundo_count_dead_all('iu_late_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 50 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS preexisting_via_index FROM iu_late WHERE id BETWEEN 1 AND 100; + preexisting_via_index +----------------------- + 100 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_late_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Mutual exclusion with delete-marking. +-- +-- A delete-marking table AM drives index cleanup from its own table UNDO, so +-- RelationUsesIndexUndo() must refuse index UNDO for it no matter what the +-- reloption says -- otherwise the same entry would be reverted twice. +-- =========================================================================== +CREATE TABLE iu_flux (id int, val text) USING flux; +CREATE INDEX iu_flux_idx ON iu_flux (id); +BEGIN; +INSERT INTO iu_flux SELECT g, 'x' FROM generate_series(1, 50) g; +-- FLUX writes its own table UNDO, so a chain exists -- but no INDEX undo is in +-- it. The check that matters is that rollback leaves a consistent index. +ROLLBACK; +SELECT bt_index_check('iu_flux_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +SELECT count(*) AS heap_rows FROM iu_flux; + heap_rows +----------- + 0 +(1 row) + +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late, iu_flux; +-- =========================================================================== +-- DEFERRED BATCHING SAFETY +-- +-- Index UNDO records are DEFERRED, not written per record: they accumulate in +-- one UndoRecordSet and a single XLOG_UNDO_BATCH carries a whole statement's +-- entries. That is the whole performance win, and it is only sound because +-- every path that can consume the chain flushes the pending batch first. +-- +-- A missed flush does not corrupt anything -- it makes ROLLBACK a SILENT NO-OP, +-- which is far worse, because it looks like success. So each case below aborts +-- work and asserts the entries were actually reversed, i.e. that the flush +-- happened at that choke point. A regression here shows up as +-- dead_after_rollback = 0. +-- =========================================================================== +CREATE TABLE iu_def (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_def_idx ON iu_def (id); +INSERT INTO iu_def SELECT g, 'committed' FROM generate_series(1, 200) g; +-- --- 1. error mid-statement ------------------------------------------------- +-- The error unwinds into AbortTransaction() -> AtAbort_XactUndo(). A partial +-- statement's deferred records must be flushed and applied exactly like a +-- completed statement's. +BEGIN; +INSERT INTO iu_def SELECT g, 'x' FROM generate_series(1001, 1100) g; +-- now fail, mid-transaction, with records pending +SELECT 1 / 0; +ERROR: division by zero +ROLLBACK; +SELECT idxundo_count_dead_all('iu_def_idx') AS dead_after_error; + dead_after_error +------------------ + 100 +(1 row) + +-- --- 2. subxact abort whose parent COMMITS ---------------------------------- +-- The one case that loses data permanently rather than deferring it: an aborted +-- subtransaction under a committing parent produces no top-level ATM entry, so +-- if its records never reached WAL nothing would ever revert them and the +-- entries would persist as though committed. AtSubAbort_XactUndo() must flush. +CREATE TABLE iu_sub2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub2_idx ON iu_sub2 (id); +INSERT INTO iu_sub2 SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; + SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'rolled back' FROM generate_series(501, 560) g; + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'kept' FROM generate_series(601, 620) g; +COMMIT; +-- The 60 subxact entries are reversed even though the top-level txn committed. +SELECT idxundo_count_dead_all('iu_sub2_idx') AS dead_after_subabort; + dead_after_subabort +--------------------- + 60 +(1 row) + +SET enable_seqscan = off; +-- Committed rows, both pre-savepoint and post-rollback-to-savepoint, survive. +SELECT count(*) AS committed_via_index FROM iu_sub2 WHERE id BETWEEN 1 AND 100; + committed_via_index +--------------------- + 100 +(1 row) + +SELECT count(*) AS kept_via_index FROM iu_sub2 WHERE id BETWEEN 601 AND 620; + kept_via_index +---------------- + 20 +(1 row) + +SELECT count(*) AS rolledback_via_index FROM iu_sub2 WHERE id BETWEEN 501 AND 560; + rolledback_via_index +---------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_sub2_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 3. ROLLBACK TO SAVEPOINT must not over-revert --------------------------- +-- A pending batch must never span a subtransaction boundary. If the flush +-- happened after the boundary was saved, the OUTER level's records would land +-- above it and be reverted along with the inner subtransaction's -- discarding +-- work that was never rolled back. Hence the flush at SUBXACT_EVENT_START_SUB. +CREATE TABLE iu_sub3 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub3_idx ON iu_sub3 (id); +BEGIN; + -- outer-level work, deferred and still pending when the savepoint opens + INSERT INTO iu_sub3 SELECT g, 'outer' FROM generate_series(1, 40) g; + SAVEPOINT s2; + INSERT INTO iu_sub3 SELECT g, 'inner' FROM generate_series(101, 140) g; + ROLLBACK TO SAVEPOINT s2; +COMMIT; +-- Exactly the 40 inner entries died; the 40 outer ones committed. +SELECT idxundo_count_dead_all('iu_sub3_idx') AS dead_after_rollback_to_sp; + dead_after_rollback_to_sp +--------------------------- + 40 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS outer_via_index FROM iu_sub3 WHERE id BETWEEN 1 AND 40; + outer_via_index +----------------- + 40 +(1 row) + +SELECT count(*) AS inner_via_index FROM iu_sub3 WHERE id BETWEEN 101 AND 140; + inner_via_index +----------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_sub3_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 4. nested subxacts ----------------------------------------------------- +CREATE TABLE iu_nest (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_nest_idx ON iu_nest (id); +BEGIN; + INSERT INTO iu_nest SELECT g, 'L0' FROM generate_series(1, 20) g; + SAVEPOINT a; + INSERT INTO iu_nest SELECT g, 'L1' FROM generate_series(101, 120) g; + SAVEPOINT b; + INSERT INTO iu_nest SELECT g, 'L2' FROM generate_series(201, 220) g; + RELEASE SAVEPOINT b; -- L2 merges into L1, still live + SAVEPOINT c; + INSERT INTO iu_nest SELECT g, 'L2c' FROM generate_series(301, 320) g; + ROLLBACK TO SAVEPOINT c; -- only L2c dies + RELEASE SAVEPOINT a; +COMMIT; +-- 20 dead (L2c only); L0, L1 and the released L2 all committed. +SELECT idxundo_count_dead_all('iu_nest_idx') AS dead_after_nested; + dead_after_nested +------------------- + 20 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS live_via_index FROM iu_nest WHERE id < 300; + live_via_index +---------------- + 60 +(1 row) + +SELECT count(*) AS dead_via_index FROM iu_nest WHERE id BETWEEN 301 AND 320; + dead_via_index +---------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_nest_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 5. the memory bound: an intermediate flush must still publish the chain - +-- A statement bigger than undo_batch_record_limit (1000) / undo_batch_size_kb +-- flushes mid-statement. Each intermediate flush is a complete batch chained +-- onto the previous one, so rollback must walk EVERY chained batch, not just the +-- final partial one. If chaining were broken the count would collapse to +-- roughly one batch worth (<=1000), which is what this guards. +CREATE TABLE iu_big (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_big_idx ON iu_big (id); +BEGIN; +-- This much UNDO is over undo_instant_abort_threshold, which would hand the +-- abort to the logical revert worker; keep it inline so the walk is observable. +SET LOCAL undo_instant_abort_threshold = 2147483647; +INSERT INTO iu_big SELECT g, 'big' FROM generate_series(1, 5000) g; +SELECT idxundo_undo_chain_published() AS chain_published; + chain_published +----------------- + t +(1 row) + +ROLLBACK; +-- All 5000 reversed, i.e. every chained batch was walked. +SELECT idxundo_count_dead_all('iu_big_idx') AS dead_after_big_rollback; + dead_after_big_rollback +------------------------- + 5000 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS survivors_via_index FROM iu_big; + survivors_via_index +--------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_big_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 6. 2PC: PREPARE must not lose the deferred batch ------------------------ +-- PrepareTransaction() captures GetCurrentXactLastBatchLSN() into the 2PC state +-- before AtPrepare_XactUndo() runs, and that snapshot is the only chain head +-- ROLLBACK PREPARED will ever get. A record still pending at that moment would +-- be dropped from the prepared transaction's chain with no later chance to +-- notice -- so XactUndoFlushPending() is called before the capture, and +-- AtPrepare_XactUndo() asserts nothing is left pending. +-- +-- ROLLBACK PREPARED hands the chain to the logical revert worker through the +-- ATM rather than applying it inline. The worker is off for this test (see +-- test_idxundo.conf), so the entries are still live afterwards and the ATM +-- holds the transaction; t/007 shows the worker reverting it. This case also +-- proves PREPARE itself is safe: it does not error, lose the chain, or trip +-- the AtPrepare_XactUndo() assertion, and the committed rows stay +-- index-reachable. +CREATE TABLE iu_2pc (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_2pc_idx ON iu_2pc (id); +INSERT INTO iu_2pc SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; +INSERT INTO iu_2pc SELECT g, 'prepared' FROM generate_series(501, 560) g; +SELECT pg_current_xact_id()::xid AS iu_p1_xid \gset +PREPARE TRANSACTION 'iu_p1'; +ROLLBACK PREPARED 'iu_p1'; +SELECT count(*) AS queued_for_revert FROM idxundo_atm_entries() + WHERE xid = :'iu_p1_xid' AND NOT revert_complete; + queued_for_revert +------------------- + 1 +(1 row) + +SELECT idxundo_count_dead_all('iu_2pc_idx') AS dead_after_rollback_prepared; + dead_after_rollback_prepared +------------------------------ + 0 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_2pc WHERE id BETWEEN 1 AND 100; + committed_via_index +--------------------- + 100 +(1 row) + +SELECT count(*) AS prepared_via_index FROM iu_2pc WHERE id BETWEEN 501 AND 560; + prepared_via_index +-------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_2pc_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 7. hash AM, same deferral path ----------------------------------------- +CREATE TABLE iu_hash2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash2_idx ON iu_hash2 USING hash (id); +INSERT INTO iu_hash2 SELECT g, 'committed' FROM generate_series(1, 200) g; +BEGIN; +INSERT INTO iu_hash2 SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash2_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 100 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash2 WHERE id BETWEEN 1 AND 200; + committed_via_index +--------------------- + 200 +(1 row) + +SELECT count(*) AS aborted_via_index FROM iu_hash2 WHERE id BETWEEN 1001 AND 1100; + aborted_via_index +------------------- + 0 +(1 row) + +RESET enable_seqscan; +DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc, iu_hash2; +DROP EXTENSION amcheck; +DROP EXTENSION test_idxundo; diff --git a/src/test/modules/test_idxundo/meson.build b/src/test/modules/test_idxundo/meson.build new file mode 100644 index 0000000000000..2302b1aec7588 --- /dev/null +++ b/src/test/modules/test_idxundo/meson.build @@ -0,0 +1,56 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +test_idxundo_sources = files( + 'test_idxundo.c', +) + +if host_system == 'windows' + test_idxundo_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_idxundo', + '--FILEDESC', 'test_idxundo - exercise index UNDO',]) +endif + +test_idxundo = shared_module('test_idxundo', + test_idxundo_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_idxundo + +test_install_data += files( + 'test_idxundo.control', + 'test_idxundo--1.0.sql', +) + +tests += { + 'name': 'test_idxundo', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_idxundo', + ], + # The end-to-end tests read the index back and verify it with amcheck. + 'regress_args': ['--load-extension=amcheck', '--load-extension=pageinspect', + '--temp-config', files('test_idxundo.conf')], + 'runningcheck': false, + }, + 'tap': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 't/001_idxundo_crash.pl', + 't/002_idxundo_abort_threshold.pl', + 't/003_idxundo_apply_failure.pl', + 't/004_idxundo_apply_order.pl', + 't/005_idxundo_atm_retention.pl', + 't/006_idxundo_revert_retry.pl', + 't/007_idxundo_2pc_database.pl', + 't/008_idxundo_revert_liveness.pl', + 't/009_idxundo_recovery_first_batch.pl', + 't/010_slog_abort.pl', + 't/011_idxundo_wal_consistency.pl', + 't/012_flux_delete_nowait.pl', + ], + }, +} diff --git a/src/test/modules/test_idxundo/sql/test_idxundo.sql b/src/test/modules/test_idxundo/sql/test_idxundo.sql new file mode 100644 index 0000000000000..1093aaa26d8e8 --- /dev/null +++ b/src/test/modules/test_idxundo/sql/test_idxundo.sql @@ -0,0 +1,382 @@ +-- --------------------------------------------------------------------------- +-- Index UNDO, end to end through the real abort path. +-- +-- A table created WITH (index_undo = on) makes its btree and hash indexes +-- write UNDO into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, +-- AtAbort_XactUndo() walks the chain and the apply callbacks mark the +-- provisionally-inserted entries LP_DEAD -- no VACUUM required. +-- --------------------------------------------------------------------------- +CREATE EXTENSION test_idxundo; +CREATE EXTENSION amcheck; + +-- =========================================================================== +-- Reloption plumbing +-- =========================================================================== + +CREATE TABLE iu_opt (id int, val text) WITH (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + +-- round-trips through ALTER ... SET / RESET +ALTER TABLE iu_opt SET (index_undo = off); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; +ALTER TABLE iu_opt SET (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; +ALTER TABLE iu_opt RESET (index_undo); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + +-- rejected where it has no meaning +CREATE INDEX iu_opt_idx ON iu_opt (id); +ALTER INDEX iu_opt_idx SET (index_undo = on); +DROP TABLE iu_opt; + +-- =========================================================================== +-- The core promise: ROLLBACK reverses provisional entries, COMMIT keeps them +-- =========================================================================== + +CREATE TABLE iu_btree (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_btree_idx ON iu_btree (id); + +-- committed baseline +INSERT INTO iu_btree SELECT g, 'committed' FROM generate_series(1, 200) g; + +-- Nothing is dead yet. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_before; + +-- An aborted insert. Inside the transaction the chain head must be published, +-- or AtAbort_XactUndo() would have nothing to walk (the defect that made this +-- feature a silent no-op). +BEGIN; +INSERT INTO iu_btree SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; +ROLLBACK; + +-- The 100 aborted entries are now LP_DEAD, without any VACUUM. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_rollback; + +-- Every committed row is still reachable THROUGH THE INDEX. This is the +-- assertion that fails if apply killed the wrong slot. +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_btree WHERE id BETWEEN 1 AND 200; +SELECT count(*) AS aborted_via_index FROM iu_btree WHERE id BETWEEN 1001 AND 1100; +RESET enable_seqscan; + +-- Index and heap agree, and the index is structurally sound. +SELECT count(*) AS heap_rows FROM iu_btree; +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + +-- A committed insert is untouched by the same machinery. +BEGIN; +INSERT INTO iu_btree SELECT g, 'committed2' FROM generate_series(2001, 2050) g; +COMMIT; +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_commit; +SET enable_seqscan = off; +SELECT count(*) AS committed2_via_index FROM iu_btree WHERE id BETWEEN 2001 AND 2050; +RESET enable_seqscan; +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + +-- =========================================================================== +-- Opting out must leave the old behaviour exactly as it was +-- +-- index_undo defaults to ON, so opting out takes an EXPLICIT off. (A table with +-- no reloptions at all is covered by the default-ON cases below; that is the +-- common configuration, not this one.) +-- =========================================================================== + +CREATE TABLE iu_off (id int, val text) WITH (index_undo = off); +CREATE INDEX iu_off_idx ON iu_off (id); +BEGIN; +INSERT INTO iu_off SELECT g, 'aborted' FROM generate_series(1, 100) g; +SELECT idxundo_undo_chain_published() AS chain_published_when_off; +ROLLBACK; +-- No index UNDO was written, so the entries are left for VACUUM: not dead. +SELECT idxundo_count_dead_all('iu_off_idx') AS dead_when_off; +SELECT bt_index_check('iu_off_idx', heapallindexed => true); + +-- =========================================================================== +-- Subtransactions: only the aborted subxact's entries are reversed +-- =========================================================================== + +CREATE TABLE iu_sub (id int) WITH (index_undo = on); +CREATE INDEX iu_sub_idx ON iu_sub (id); +BEGIN; + INSERT INTO iu_sub SELECT g FROM generate_series(1, 50) g; -- keep + SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(501, 550) g; -- discard + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(101, 150) g; -- keep +COMMIT; +SET enable_seqscan = off; +SELECT count(*) AS kept_via_index FROM iu_sub WHERE id BETWEEN 1 AND 150; +SELECT count(*) AS discarded_via_index FROM iu_sub WHERE id BETWEEN 501 AND 550; +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_sub; +SELECT bt_index_check('iu_sub_idx', heapallindexed => true); + +-- =========================================================================== +-- Page splits during an aborted transaction: splits are not reversed, the +-- aborted leaf entries are killed wherever the split moved them, and the tree +-- must stay consistent. +-- =========================================================================== + +CREATE TABLE iu_split (id int, pad text) WITH (index_undo = on); +CREATE INDEX iu_split_idx ON iu_split (id); +INSERT INTO iu_split SELECT g, repeat('c', 50) FROM generate_series(1, 500) g; +BEGIN; +-- Roll back inline rather than handing the batch to the revert worker, so the +-- dead count below is deterministic. +SET LOCAL undo_instant_abort_threshold = 2147483647; +-- Enough rows, interleaved with the committed keys, to force many leaf splits. +INSERT INTO iu_split SELECT g, repeat('a', 50) FROM generate_series(501, 5000) g; +-- Keep any rollback diagnostics, whose detail depends on page geometry, out of +-- the expected output. +SET client_min_messages = error; +ROLLBACK; +RESET client_min_messages; +-- Every aborted entry is dead, including those whose insert split its leaf. +SELECT idxundo_count_dead_all('iu_split_idx') AS dead_after_split_rollback; +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_split WHERE id BETWEEN 1 AND 500; +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_split; +SELECT bt_index_check('iu_split_idx', heapallindexed => true); + +-- =========================================================================== +-- hash indexes +-- =========================================================================== + +CREATE TABLE iu_hash (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash_idx ON iu_hash USING hash (id); +INSERT INTO iu_hash SELECT g, 'committed' FROM generate_series(1, 200) g; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_before; +BEGIN; +INSERT INTO iu_hash SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_after_rollback; +-- The pages holding them carry the dead-tuples hint, so an insert that finds +-- the page full reclaims them (_hash_vacuum_one_page) instead of waiting for +-- VACUUM. +SELECT bool_or((s.hasho_flag & 128) <> 0) AS has_dead_hint + FROM generate_series(1, pg_relation_size('iu_hash_idx') / + current_setting('block_size')::int - 1) b, + LATERAL hash_page_stats(get_raw_page('iu_hash_idx', b::int)) s + WHERE hash_page_type(get_raw_page('iu_hash_idx', b::int)) IN ('bucket', 'overflow'); +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash WHERE id BETWEEN 1 AND 200; +SELECT count(*) AS aborted_via_index FROM iu_hash WHERE id BETWEEN 1001 AND 1100; +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_hash; + +-- =========================================================================== +-- Turning the option on AFTER rows already exist. +-- +-- The option only governs whether NEW inserts write UNDO; pre-existing entries +-- have no UNDO record and are therefore unaffected by a later rollback. That +-- is the documented behaviour, and it is safe: an entry with no UNDO record is +-- simply left to VACUUM, exactly as today. +-- =========================================================================== + +CREATE TABLE iu_late (id int); +CREATE INDEX iu_late_idx ON iu_late (id); +INSERT INTO iu_late SELECT g FROM generate_series(1, 100) g; -- no UNDO written +ALTER TABLE iu_late SET (index_undo = on); +BEGIN; +INSERT INTO iu_late SELECT g FROM generate_series(201, 250) g; -- UNDO written +ROLLBACK; +-- Only the 50 post-ALTER entries are reversed; the 100 earlier ones are intact. +SELECT idxundo_count_dead_all('iu_late_idx') AS dead_after_rollback; +SET enable_seqscan = off; +SELECT count(*) AS preexisting_via_index FROM iu_late WHERE id BETWEEN 1 AND 100; +RESET enable_seqscan; +SELECT bt_index_check('iu_late_idx', heapallindexed => true); + +-- =========================================================================== +-- Mutual exclusion with delete-marking. +-- +-- A delete-marking table AM drives index cleanup from its own table UNDO, so +-- RelationUsesIndexUndo() must refuse index UNDO for it no matter what the +-- reloption says -- otherwise the same entry would be reverted twice. +-- =========================================================================== + +CREATE TABLE iu_flux (id int, val text) USING flux; +CREATE INDEX iu_flux_idx ON iu_flux (id); +BEGIN; +INSERT INTO iu_flux SELECT g, 'x' FROM generate_series(1, 50) g; +-- FLUX writes its own table UNDO, so a chain exists -- but no INDEX undo is in +-- it. The check that matters is that rollback leaves a consistent index. +ROLLBACK; +SELECT bt_index_check('iu_flux_idx', heapallindexed => true); +SELECT count(*) AS heap_rows FROM iu_flux; + +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late, iu_flux; + +-- =========================================================================== +-- DEFERRED BATCHING SAFETY +-- +-- Index UNDO records are DEFERRED, not written per record: they accumulate in +-- one UndoRecordSet and a single XLOG_UNDO_BATCH carries a whole statement's +-- entries. That is the whole performance win, and it is only sound because +-- every path that can consume the chain flushes the pending batch first. +-- +-- A missed flush does not corrupt anything -- it makes ROLLBACK a SILENT NO-OP, +-- which is far worse, because it looks like success. So each case below aborts +-- work and asserts the entries were actually reversed, i.e. that the flush +-- happened at that choke point. A regression here shows up as +-- dead_after_rollback = 0. +-- =========================================================================== + +CREATE TABLE iu_def (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_def_idx ON iu_def (id); +INSERT INTO iu_def SELECT g, 'committed' FROM generate_series(1, 200) g; + +-- --- 1. error mid-statement ------------------------------------------------- +-- The error unwinds into AbortTransaction() -> AtAbort_XactUndo(). A partial +-- statement's deferred records must be flushed and applied exactly like a +-- completed statement's. +BEGIN; +INSERT INTO iu_def SELECT g, 'x' FROM generate_series(1001, 1100) g; +-- now fail, mid-transaction, with records pending +SELECT 1 / 0; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_def_idx') AS dead_after_error; + +-- --- 2. subxact abort whose parent COMMITS ---------------------------------- +-- The one case that loses data permanently rather than deferring it: an aborted +-- subtransaction under a committing parent produces no top-level ATM entry, so +-- if its records never reached WAL nothing would ever revert them and the +-- entries would persist as though committed. AtSubAbort_XactUndo() must flush. +CREATE TABLE iu_sub2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub2_idx ON iu_sub2 (id); +INSERT INTO iu_sub2 SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; + SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'rolled back' FROM generate_series(501, 560) g; + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'kept' FROM generate_series(601, 620) g; +COMMIT; +-- The 60 subxact entries are reversed even though the top-level txn committed. +SELECT idxundo_count_dead_all('iu_sub2_idx') AS dead_after_subabort; +SET enable_seqscan = off; +-- Committed rows, both pre-savepoint and post-rollback-to-savepoint, survive. +SELECT count(*) AS committed_via_index FROM iu_sub2 WHERE id BETWEEN 1 AND 100; +SELECT count(*) AS kept_via_index FROM iu_sub2 WHERE id BETWEEN 601 AND 620; +SELECT count(*) AS rolledback_via_index FROM iu_sub2 WHERE id BETWEEN 501 AND 560; +RESET enable_seqscan; +SELECT bt_index_check('iu_sub2_idx', heapallindexed => true); + +-- --- 3. ROLLBACK TO SAVEPOINT must not over-revert --------------------------- +-- A pending batch must never span a subtransaction boundary. If the flush +-- happened after the boundary was saved, the OUTER level's records would land +-- above it and be reverted along with the inner subtransaction's -- discarding +-- work that was never rolled back. Hence the flush at SUBXACT_EVENT_START_SUB. +CREATE TABLE iu_sub3 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub3_idx ON iu_sub3 (id); +BEGIN; + -- outer-level work, deferred and still pending when the savepoint opens + INSERT INTO iu_sub3 SELECT g, 'outer' FROM generate_series(1, 40) g; + SAVEPOINT s2; + INSERT INTO iu_sub3 SELECT g, 'inner' FROM generate_series(101, 140) g; + ROLLBACK TO SAVEPOINT s2; +COMMIT; +-- Exactly the 40 inner entries died; the 40 outer ones committed. +SELECT idxundo_count_dead_all('iu_sub3_idx') AS dead_after_rollback_to_sp; +SET enable_seqscan = off; +SELECT count(*) AS outer_via_index FROM iu_sub3 WHERE id BETWEEN 1 AND 40; +SELECT count(*) AS inner_via_index FROM iu_sub3 WHERE id BETWEEN 101 AND 140; +RESET enable_seqscan; +SELECT bt_index_check('iu_sub3_idx', heapallindexed => true); + +-- --- 4. nested subxacts ----------------------------------------------------- +CREATE TABLE iu_nest (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_nest_idx ON iu_nest (id); +BEGIN; + INSERT INTO iu_nest SELECT g, 'L0' FROM generate_series(1, 20) g; + SAVEPOINT a; + INSERT INTO iu_nest SELECT g, 'L1' FROM generate_series(101, 120) g; + SAVEPOINT b; + INSERT INTO iu_nest SELECT g, 'L2' FROM generate_series(201, 220) g; + RELEASE SAVEPOINT b; -- L2 merges into L1, still live + SAVEPOINT c; + INSERT INTO iu_nest SELECT g, 'L2c' FROM generate_series(301, 320) g; + ROLLBACK TO SAVEPOINT c; -- only L2c dies + RELEASE SAVEPOINT a; +COMMIT; +-- 20 dead (L2c only); L0, L1 and the released L2 all committed. +SELECT idxundo_count_dead_all('iu_nest_idx') AS dead_after_nested; +SET enable_seqscan = off; +SELECT count(*) AS live_via_index FROM iu_nest WHERE id < 300; +SELECT count(*) AS dead_via_index FROM iu_nest WHERE id BETWEEN 301 AND 320; +RESET enable_seqscan; +SELECT bt_index_check('iu_nest_idx', heapallindexed => true); + +-- --- 5. the memory bound: an intermediate flush must still publish the chain - +-- A statement bigger than undo_batch_record_limit (1000) / undo_batch_size_kb +-- flushes mid-statement. Each intermediate flush is a complete batch chained +-- onto the previous one, so rollback must walk EVERY chained batch, not just the +-- final partial one. If chaining were broken the count would collapse to +-- roughly one batch worth (<=1000), which is what this guards. +CREATE TABLE iu_big (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_big_idx ON iu_big (id); +BEGIN; +-- This much UNDO is over undo_instant_abort_threshold, which would hand the +-- abort to the logical revert worker; keep it inline so the walk is observable. +SET LOCAL undo_instant_abort_threshold = 2147483647; +INSERT INTO iu_big SELECT g, 'big' FROM generate_series(1, 5000) g; +SELECT idxundo_undo_chain_published() AS chain_published; +ROLLBACK; +-- All 5000 reversed, i.e. every chained batch was walked. +SELECT idxundo_count_dead_all('iu_big_idx') AS dead_after_big_rollback; +SET enable_seqscan = off; +SELECT count(*) AS survivors_via_index FROM iu_big; +RESET enable_seqscan; +SELECT bt_index_check('iu_big_idx', heapallindexed => true); + +-- --- 6. 2PC: PREPARE must not lose the deferred batch ------------------------ +-- PrepareTransaction() captures GetCurrentXactLastBatchLSN() into the 2PC state +-- before AtPrepare_XactUndo() runs, and that snapshot is the only chain head +-- ROLLBACK PREPARED will ever get. A record still pending at that moment would +-- be dropped from the prepared transaction's chain with no later chance to +-- notice -- so XactUndoFlushPending() is called before the capture, and +-- AtPrepare_XactUndo() asserts nothing is left pending. +-- +-- ROLLBACK PREPARED hands the chain to the logical revert worker through the +-- ATM rather than applying it inline. The worker is off for this test (see +-- test_idxundo.conf), so the entries are still live afterwards and the ATM +-- holds the transaction; t/007 shows the worker reverting it. This case also +-- proves PREPARE itself is safe: it does not error, lose the chain, or trip +-- the AtPrepare_XactUndo() assertion, and the committed rows stay +-- index-reachable. +CREATE TABLE iu_2pc (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_2pc_idx ON iu_2pc (id); +INSERT INTO iu_2pc SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; +INSERT INTO iu_2pc SELECT g, 'prepared' FROM generate_series(501, 560) g; +SELECT pg_current_xact_id()::xid AS iu_p1_xid \gset +PREPARE TRANSACTION 'iu_p1'; +ROLLBACK PREPARED 'iu_p1'; +SELECT count(*) AS queued_for_revert FROM idxundo_atm_entries() + WHERE xid = :'iu_p1_xid' AND NOT revert_complete; +SELECT idxundo_count_dead_all('iu_2pc_idx') AS dead_after_rollback_prepared; +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_2pc WHERE id BETWEEN 1 AND 100; +SELECT count(*) AS prepared_via_index FROM iu_2pc WHERE id BETWEEN 501 AND 560; +RESET enable_seqscan; +SELECT bt_index_check('iu_2pc_idx', heapallindexed => true); + +-- --- 7. hash AM, same deferral path ----------------------------------------- +CREATE TABLE iu_hash2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash2_idx ON iu_hash2 USING hash (id); +INSERT INTO iu_hash2 SELECT g, 'committed' FROM generate_series(1, 200) g; +BEGIN; +INSERT INTO iu_hash2 SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash2_idx') AS dead_after_rollback; +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash2 WHERE id BETWEEN 1 AND 200; +SELECT count(*) AS aborted_via_index FROM iu_hash2 WHERE id BETWEEN 1001 AND 1100; +RESET enable_seqscan; + +DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc, iu_hash2; + +DROP EXTENSION amcheck; +DROP EXTENSION test_idxundo; diff --git a/src/test/modules/test_idxundo/t/001_idxundo_crash.pl b/src/test/modules/test_idxundo/t/001_idxundo_crash.pl new file mode 100644 index 0000000000000..1830b69e9904b --- /dev/null +++ b/src/test/modules/test_idxundo/t/001_idxundo_crash.pl @@ -0,0 +1,224 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# Crash recovery for index UNDO. +# +# A transaction that inserts into a table WITH (index_undo = on) and is killed +# before it commits must, after recovery, leave no index entry pointing at a row +# that is not in the heap. The index UNDO records reach the recovery path the +# same way FILEOPS' do: the XLOG_UNDO_BATCH records are replayed, the +# transaction is found to have no commit record, and the chain is applied -- +# either during the recovery UNDO phase or, when the syscache is not yet +# available there (always, for index UNDO), deferred to the ATM and drained by +# the logical revert worker for the transaction's database. +# +# A transaction that committed while its index UNDO was still deferred must not +# be mistaken for a loser. +# +# The assertion is deliberately about index/heap AGREEMENT rather than about a +# dead-entry count: whether a given entry ends up LP_DEAD immediately or is left +# for VACUUM is an efficiency question, but an index scan returning a row the +# heap does not have would be corruption. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 2 +}); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); +my $dboid = $node->safe_psql('postgres', + "SELECT oid FROM pg_database WHERE datname = 'postgres'"); + +# A table whose indexes write UNDO, plus a committed baseline to protect. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE ct (id int primary key, val text) WITH (index_undo = on); +CREATE INDEX ct_val_idx ON ct (val); +INSERT INTO ct SELECT g, 'committed-' || g FROM generate_series(1, 500) g; +}); + +my $committed = $node->safe_psql('postgres', 'SELECT count(*) FROM ct'); +is($committed, '500', 'committed baseline present before crash'); + +# A committed transaction whose index UNDO is still sitting in the deferred +# batch when it commits: 300 rows x 1 index stays under undo_batch_record_limit, +# so nothing is written until the commit path flushes it. That batch must reach +# WAL before the commit record; if it follows the commit record, redo removes +# the xid at COMMIT and re-adds it at the batch, and recovery then treats this +# committed transaction as one to roll back. +$node->safe_psql('postgres', + 'CREATE TABLE cc (id int primary key) WITH (index_undo = on)'); +my $committed_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO cc SELECT g FROM generate_series(1, 300) g; +SELECT pg_current_xact_id(); +COMMIT; +}); + +# The same, but the transaction's first index UNDO is written inside a +# subtransaction. Its batches must still be attributed to the top-level xid: +# the commit record clears only that xid from recovery tracking. +$node->safe_psql('postgres', + 'CREATE TABLE cs (id int primary key) WITH (index_undo = on)'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +SAVEPOINT s; +INSERT INTO cs SELECT g FROM generate_series(1, 300) g; +RELEASE SAVEPOINT s; +INSERT INTO cs SELECT g FROM generate_series(301, 310) g; +COMMIT; +}); + +# Start a transaction, insert, and leave it uncommitted. A background psql +# keeps the session (and so the transaction) open while we kill the server. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe( + q{INSERT INTO ct SELECT g, 'aborted-' || g FROM generate_series(10001, 10300) g} +); + +# The rows are visible to their own transaction, so the insert really happened +# and its UNDO is in WAL. +my $in_txn = $bg->query_safe('SELECT count(*) FROM ct WHERE id >= 10001'); +is($in_txn, '300', 'uncommitted rows visible inside their own transaction'); +my $killed_xid = $bg->query_safe('SELECT pg_current_xact_id()'); + +# Index UNDO is deferred and batched; 600 records is below the batch limits, so +# they would still be in backend memory at the crash and never reach WAL (such +# entries are left for VACUUM by design). Starting a subtransaction is one of +# the points that writes the pending batch, so recovery sees a real loser chain. +$bg->query_safe('SAVEPOINT s'); + +# Force the WAL out, then crash before the transaction can commit. +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); + +$node->start; + +# The killed transaction never committed, so none of its rows may be visible. +my $after = + $node->safe_psql('postgres', 'SELECT count(*) FROM ct WHERE id >= 10001'); +is($after, '0', + 'rows from the killed transaction are not visible after recovery'); + +# The committed rows survived. +is($node->safe_psql('postgres', 'SELECT count(*) FROM ct'), + '500', 'committed rows survived the crash'); + +# Recovery cannot apply index UNDO itself (no syscache in the startup process), +# so it defers the killed transaction to the ATM. The entry must carry the +# database the transaction ran in: no revert worker serves any other database, +# so an entry with the wrong one would never drain and would pin WAL forever. +my $log = slurp_file($node->logfile); +like( + $log, + qr/UNDO recovery: rolling back transaction $killed_xid\b/, + 'recovery found the killed transaction as a loser'); +like( + $log, + qr/UNDO recovery: transaction $killed_xid deferred to logical revert worker/, + 'recovery deferred its index UNDO to the logical revert worker'); + +like( + $log, + qr/UNDO recovery: 1 incomplete transaction\(s\) to roll back/, + 'the killed transaction is the only recovery loser'); + +# The logical revert worker for that database must revert and forget it. +ok( $node->poll_query_until( + 'postgres', "SELECT count(*) = 0 FROM idxundo_atm_entries()"), + 'logical revert worker drained the deferred ATM entry'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE dboid <> $dboid"), + '0', + 'no ATM entry is left for a database without a revert worker'); + +# The worker leaves the killed transaction's index entries for VACUUM: after +# the abort is visible their heap TIDs could hold committed rows, so index UNDO +# is applied only by the aborting backend itself. +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('ct_pkey')"), + '0', + 'killed transaction\'s primary key entries are left for VACUUM'); +is( $node->safe_psql( + 'postgres', "SELECT idxundo_count_dead_all('ct_val_idx')"), + '0', + 'killed transaction\'s secondary index entries are left for VACUUM'); + +# The property that matters: every index agrees with the heap. A forced +# index-only path must not produce a row the heap scan does not, and amcheck +# must find no entry without a matching heap tuple. +for my $idx ('ct_pkey', 'ct_val_idx') +{ + my ($rc, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_check('$idx', heapallindexed => true)"); + is($rc, 0, "amcheck clean on $idx after crash recovery"); + is($stderr, '', "amcheck reported nothing on $idx"); +} + +my $viaidx = $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM ct WHERE id BETWEEN 1 AND 500; +}); +is($viaidx, '500', 'all committed rows still reachable through the index'); + +my $ghosts = $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM ct WHERE id BETWEEN 10001 AND 10300; +}); +is($ghosts, '0', 'no index entry resolves to a killed transaction row'); + +# The same check for the secondary index, which is scanned by value. +my $ghosts_val = $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM ct WHERE val LIKE 'aborted-%'; +}); +is($ghosts_val, '0', 'no secondary-index entry resolves to a killed row'); + +# The committed transaction is not a recovery loser, and its index entries are +# all still live. +unlike( + $log, + qr/UNDO recovery: rolling back transaction $committed_xid\b/, + 'committed transaction with deferred index UNDO is not rolled back'); +is( $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM cc WHERE id BETWEEN 1 AND 300; +}), + '300', + 'committed rows with deferred index UNDO still reachable through the index' +); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cc_pkey')"), + '0', + 'no index entry of the committed transaction was marked dead'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cs_pkey')"), + '0', + 'no index entry of the committed subtransaction-first writer was marked dead' +); +is( $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM cs WHERE id BETWEEN 1 AND 310; +}), + '310', + 'committed rows first written in a subtransaction reachable through the index' +); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl b/src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl new file mode 100644 index 0000000000000..a443c7a39c699 --- /dev/null +++ b/src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl @@ -0,0 +1,86 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# undo_instant_abort_threshold: an abort whose UNDO is at least this many bytes +# is handed to the logical revert worker through the ATM; a smaller one is +# applied inline by the aborting backend. The revert launcher is disabled so a +# deferred abort stays observable in the ATM. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_threshold'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 0 +undo_instant_abort_threshold = 65536 +}); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CREATE TABLE big (id int primary key) WITH (index_undo = on); +}); + +# Small abort: a handful of index entries, far below the threshold. +my $small_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 10) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM idxundo_atm_entries()'), + '0', + 'small abort is applied inline, not queued in the ATM'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '10', 'small abort marked its index entries dead inline'); + +# Large abort: 5000 index UNDO records, several flushed batches and well over +# 64kB of UNDO in total. +my $big_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO big SELECT g FROM generate_series(1, 5000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$big_xid'"), + '1', + 'abort above undo_instant_abort_threshold is queued in the ATM'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('big_pkey')"), + '0', + 'large abort was not applied inline'); + +# The retention view reports what holds UNDO WAL back: the queued abort, and an +# in-flight transaction that has written UNDO. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_stat_get_undo_retention() WHERE holder = 'aborted' AND xid = '$big_xid' AND oldest_lsn IS NOT NULL AND retained_bytes > 0" + ), + '1', + 'pg_stat_get_undo_retention reports the queued abort'); +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('INSERT INTO t VALUES (100001)'); +$bg->query_safe('SAVEPOINT s'); # writes the deferred index UNDO batch +my $pid = $bg->query_safe('SELECT pg_backend_pid()'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_stat_get_undo_retention() WHERE holder = 'backend' AND pid = $pid" + ), + '1', + 'pg_stat_get_undo_retention reports an in-flight transaction'); +$bg->query_safe('ROLLBACK'); +$bg->quit; + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl b/src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl new file mode 100644 index 0000000000000..973b3ad17e43f --- /dev/null +++ b/src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl @@ -0,0 +1,144 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# An UNDO record that fails to apply must not be counted as applied. +# +# Inline rollback (top-level abort and ROLLBACK TO SAVEPOINT) that cannot apply +# its whole chain hands it to the logical revert worker through the ATM. For a +# subtransaction the hand-off covers only the subtransaction's part of the +# chain: the parent goes on to commit, and its work must not be reverted. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_apply_failure'); +$node->init; +# No revert worker at first, so the hand-offs stay observable in the ATM. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CREATE TABLE p (id int primary key) WITH (index_undo = on); +CREATE TABLE c (id int primary key) WITH (index_undo = on); +}); + +# Top-level abort whose chain holds one record that cannot be applied. +my $xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 10) g; +SELECT idxundo_write_failing_undo(); +SELECT pg_current_xact_id(); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$xid' AND stop_lsn IS NULL" + ), + '1', + 'abort whose inline apply failed is handed to the ATM'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '10', 'the records that could be applied were applied inline'); + +# ROLLBACK TO SAVEPOINT whose chain holds such a record; the parent commits. +$node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO p SELECT g FROM generate_series(1, 10) g; +SAVEPOINT s; +INSERT INTO c SELECT g FROM generate_series(1, 10) g; +SELECT idxundo_write_failing_undo(); +ROLLBACK TO SAVEPOINT s; +COMMIT; +}); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE stop_lsn IS NOT NULL" + ), + '1', + 'subtransaction whose inline apply failed hands its bounded chain to the ATM' +); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('c_pkey')"), + '10', 'the subtransaction records that could be applied were applied'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('p_pkey')"), + '0', 'the parent\'s index entries are untouched'); + +# A savepoint whose failing chain is a temporary one. It cannot be handed +# off: a revert worker cannot reach this backend's temporary relations. (Nor +# can recovery, which never applies TEMP or UNLOGGED UNDO.) A failed chain at +# two levels would also have collided on one ATM key. +$node->safe_psql( + 'postgres', q{ +BEGIN; +SAVEPOINT s; +SELECT idxundo_write_failing_undo('t'); +ROLLBACK TO SAVEPOINT s; +COMMIT; +}); +is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM idxundo_atm_entries()"), + '2', + 'a failed temporary chain is not handed to the ATM'); +like( + slurp_file($node->logfile), + qr/could not apply the temporary UNDO of rolled-back subtransaction/, + 'the failed temporary chain is reported'); + +# An inline apply that throws (rather than reporting a failed record) falls +# back to the ATM too, without leaving the abort's interrupt holdoff unbalanced. +SKIP: +{ + skip 'Injection points not supported by this build', 2 + unless $ENV{enable_injection_points} eq 'yes'; + + $node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +SELECT injection_points_set_local(); +SELECT injection_points_attach('undo-apply-after-batch', + 'test_idxundo', 'idxundo_error_with_resources', ''::bytea); +BEGIN; +INSERT INTO t SELECT g FROM generate_series(11, 20) g; +ROLLBACK; +BEGIN; +INSERT INTO p SELECT g FROM generate_series(11, 20) g; +SAVEPOINT s; +INSERT INTO c SELECT g FROM generate_series(11, 20) g; +ROLLBACK TO SAVEPOINT s; +COMMIT; +}); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM idxundo_atm_entries()"), + '4', + 'aborts whose inline apply threw are handed to the ATM'); + is($node->safe_psql('postgres', 'SELECT count(*) FROM p'), + '20', 'the parent of the subtransaction committed'); +} + +# Now let the revert worker run. The ATM survives the restart; the worker +# retries both chains (the failing record fails again, in a process that +# does not know its resource manager) and forgets them. Walking the +# subtransaction's entry must stop at the parent's part of the chain. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'logical revert worker processed both hand-offs'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('p_pkey')"), + '0', + 'reverting the subtransaction did not revert its committed parent'); +is( $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM p WHERE id BETWEEN 1 AND 20; +}), + $node->safe_psql('postgres', 'SELECT count(*) FROM p'), + 'the committed parent\'s rows are reachable through the index'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl b/src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl new file mode 100644 index 0000000000000..2422f6f96efdc --- /dev/null +++ b/src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl @@ -0,0 +1,86 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# UNDO records are applied newest first -- within a batch as well as across +# batches -- both by an aborting backend and by crash recovery. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_order'); +$node->init; +$node->append_conf('postgresql.conf', + "shared_preload_libraries = 'test_idxundo'"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); + +# Returns the record numbers applied for transaction $xid, in log order. +sub applied_order +{ + my ($xid) = @_; + my @seq = slurp_file($node->logfile) =~ + /test_idxundo: applying UNDO record (\d+) of transaction $xid\b/g; + return join(',', @seq); +} + +# Online rollback. +my $xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT idxundo_write_ordered_undo(3); +SELECT idxundo_write_ordered_undo(2); +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($xid) = $xid =~ /(\d+)\s*$/; +# The log is written asynchronously; wait until the last (oldest) of the five +# records shows up. Records are numbered per call, so record 1 appears twice; +# wait for the second occurrence via a pattern that spans all five. +$node->wait_for_log( + qr/(?s:test_idxundo: applying UNDO record \d+ of transaction $xid\b.*){5}/ +); +is(applied_order($xid), '2,1,3,2,1', 'abort applies UNDO newest first'); + +# Crash recovery. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('SELECT idxundo_write_ordered_undo(3)'); +$bg->query_safe('SELECT idxundo_write_ordered_undo(2)'); +$xid = $bg->query_safe('SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); +$node->start; +is(applied_order($xid), '2,1,3,2,1', + 'crash recovery applies UNDO newest first'); + +# ROLLBACK TO applies the subtransaction's batch; a later abort of the +# transaction must apply each remaining record exactly once and not the +# subtransaction's again. The values tell the batches apart. +$node->restart; +my $before = -s $node->logfile; +$xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT idxundo_write_ordered_undo(1); +SAVEPOINT s; +SELECT idxundo_write_ordered_undo(3); +ROLLBACK TO s; +SELECT idxundo_write_ordered_undo(2); +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($xid) = $xid =~ /(\d+)\s*$/; +$node->wait_for_log( + qr/(?s:test_idxundo: applying UNDO record \d+ of transaction $xid\b.*){6}/, + $before); +sleep(1); +my @seq = slurp_file($node->logfile, $before) =~ + /test_idxundo: applying UNDO record (\d+) of transaction $xid\b/g; +is(join(',', @seq), + '3,2,1,2,1,1', + 'ROLLBACK TO, then ROLLBACK: every record applied once, newest first'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl new file mode 100644 index 0000000000000..cf908f168109a --- /dev/null +++ b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl @@ -0,0 +1,105 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# The WAL holding a deferred rollback's UNDO must survive until the revert +# worker has applied it -- all of it, not only the newest batch. +# +# An abort handed to the ATM, and a prepared transaction, must pin WAL from the +# OLDEST batch of their chain. With 1MB WAL segments a large transaction's +# chain spans many segments; checkpoints taken before the revert worker runs +# must not recycle any of them, or the worker reverts only part of the chain. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_retention'); +$node->init(extra => ['--wal-segsize=1']); +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 0 +max_prepared_transactions = 2 +min_wal_size = 2MB +max_wal_size = 4MB +checkpoint_timeout = 1h +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE a (id int primary key) WITH (index_undo = on); +CREATE TABLE p (id int primary key) WITH (index_undo = on); +}); + +my $nrows = 40000; + +# Recycle every WAL segment nothing pins. +sub recycle_wal +{ + for (1 .. 3) + { + $node->safe_psql('postgres', + 'SELECT pg_switch_wal(); CREATE TABLE junk AS SELECT g FROM generate_series(1, 20000) g; DROP TABLE junk; CHECKPOINT;' + ); + } +} + +# Run the revert worker (it is off until now) and check that it walked the +# whole chain of $table's index: it forgets an entry only after reading every +# batch, and a removed batch is a PANIC. Each phase has a single deferred +# chain, so no other entry's pin can cover for a missing one. +sub revert_and_check +{ + my ($table, $what) = @_; + + $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); + $node->restart; + ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + "revert worker drained the ATM ($what)"); + + # The worker leaves index entries for VACUUM (see nbtree_undo_apply()). + is( $node->safe_psql( + 'postgres', "SELECT idxundo_count_dead_all('${table}_pkey')"), + '0', + "the $what chain's index entries are left for VACUUM"); + + $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); + $node->restart; +} + +# A large abort goes to the ATM, and only the ATM entry pins its WAL. +$node->safe_psql( + 'postgres', qq{ +BEGIN; +INSERT INTO a SELECT g FROM generate_series(1, $nrows) g; +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM idxundo_atm_entries()'), + '1', + 'large abort is queued in the ATM'); +recycle_wal(); +revert_and_check('a', 'aborted'); + +# A large prepared transaction: only the 2PC state pins its WAL, until ROLLBACK +# PREPARED hands it to the ATM. +$node->safe_psql( + 'postgres', qq{ +BEGIN; +INSERT INTO p SELECT g FROM generate_series(1, $nrows) g; +PREPARE TRANSACTION 'big'; +}); +recycle_wal(); +$node->safe_psql('postgres', "ROLLBACK PREPARED 'big'"); +recycle_wal(); +revert_and_check('p', 'rolled-back prepared'); + +unlike( + slurp_file($node->logfile), + qr/could not read batch|no longer exists \(recycled/, + 'no UNDO batch was recycled before it was applied'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl b/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl new file mode 100644 index 0000000000000..917c640bcd1da --- /dev/null +++ b/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl @@ -0,0 +1,129 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# The logical revert worker must not forget an ATM entry whose UNDO chain it +# could not read in full. A read failure that goes away (simulated with an +# injection point) leaves the entry queued, and the next pass reverts it. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('idxundo_revert_retry'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 2 +logical_revert_naptime = 100ms +}); +$node->start; +# No autovacuum: it would remove the aborted entries before the worker does. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION test_idxundo; +CREATE TABLE warm (id int primary key) WITH (index_undo = on); +CREATE TABLE t (id int primary key) + WITH (index_undo = on, autovacuum_enabled = off); +}); + +# Get a revert worker running for this database: the launcher starts one at +# startup for a database with ATM entries (otherwise only once a minute), and +# the worker then stays. +$node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO warm SELECT g FROM generate_series(1, 5000) g; +ROLLBACK; +}); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'revert worker is running'); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('undo-read-batch-fail', 'notice')"); + +# A large abort goes to the ATM; the injection point makes every read of its chain fail. +my $xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 5000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($xid) = $xid =~ /(\d+)\s*$/; + +$node->wait_for_log( + qr/could not read the whole UNDO chain of transaction $xid, will retry/); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$xid'"), + '1', + 'entry whose chain could not be read stays in the ATM'); + +# The failure clears; the worker retries and reverts the whole chain. +$node->safe_psql('postgres', + "SELECT injection_points_detach('undo-read-batch-fail')"); +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$xid'"), + 'entry is reverted and forgotten on retry'); +# The worker leaves index entries for VACUUM (see nbtree_undo_apply()). +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'its index entries are left for VACUUM'); + +# ERROR must abort, not commit a transaction with outstanding resources. +$node->safe_psql( + 'postgres', q{ +SELECT injection_points_attach('logical-revert-process-error', + 'test_idxundo', 'idxundo_error_with_resources', ''::bytea); +}); +my $worker_pid = $node->safe_psql( + 'postgres', q{ +SELECT pid FROM pg_stat_activity WHERE backend_type = 'logical revert worker'; +}); +my $error_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(10001, 15000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +$node->wait_for_log( + qr/test_idxundo: error with resources at logical-revert-process-error/); +$node->safe_psql('postgres', + "SELECT injection_points_detach('logical-revert-process-error')"); +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$error_xid'" + ), + 'worker retries entry after ERROR'); +is( $node->safe_psql( + 'postgres', q{ +SELECT pid FROM pg_stat_activity WHERE backend_type = 'logical revert worker'; +}), + $worker_pid, + 'same worker survives ERROR'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(20001, 25000) g; +ROLLBACK; +}); +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'next entry is also processed'); +unlike( + slurp_file($node->logfile), + qr/Assertion|WARNING:.*(?:buffer.*(?:pin|leak)|relcache reference leak)|TRAP:/, + 'no assertion or resource leak'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl b/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl new file mode 100644 index 0000000000000..a6200c283187d --- /dev/null +++ b/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl @@ -0,0 +1,67 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# ROLLBACK PREPARED hands a large prepared transaction's index UNDO to the +# logical revert worker of the database the transaction was PREPARED in. +# +# Finishing a prepared transaction from another database is refused, so the +# database must come from the 2PC state, not the finishing session -- which +# also covers a transaction recovered from the 2PC state after a crash. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_2pc_db'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_prepared_transactions = 2 +max_logical_revert_workers = 0 +}); +$node->start; +$node->safe_psql('postgres', 'CREATE DATABASE db1'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); +$node->safe_psql( + 'db1', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +}); +my $db1 = $node->safe_psql('postgres', + "SELECT oid FROM pg_database WHERE datname = 'db1'"); + +$node->safe_psql( + 'db1', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 5000) g; +PREPARE TRANSACTION 'p1'; +}); + +my ($rc, $out, $err) = $node->psql('postgres', "ROLLBACK PREPARED 'p1'"); +like( + $err, + qr/prepared transaction belongs to another database/, + 'ROLLBACK PREPARED from another database is refused'); + +# Crash, so the transaction is recovered from its 2PC state, then roll it +# back. The ATM entry must name db1. +$node->stop('immediate'); +$node->start; +$node->safe_psql('db1', "ROLLBACK PREPARED 'p1'"); +is( $node->safe_psql('postgres', 'SELECT dboid FROM idxundo_atm_entries()'), + $db1, + 'the rolled-back prepared transaction is queued for its own database'); + +# The revert worker for db1 reverts it. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'the revert worker for that database drained it'); +# The worker leaves index entries for VACUUM (see nbtree_undo_apply()). +is($node->safe_psql('db1', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'the prepared transaction\'s index entries are left for VACUUM'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl new file mode 100644 index 0000000000000..453866200bebe --- /dev/null +++ b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl @@ -0,0 +1,78 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# An ATM entry the logical revert worker cannot finish must not hold up the +# other entries of its database, and a malformed UNDO chain, which can never +# be walked, must be given up on rather than retried (and its WAL pinned) +# forever. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_revert_liveness'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); +$node->start; +# No autovacuum: it would remove the aborted entries before the worker does. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) + WITH (index_undo = on, autovacuum_enabled = off); +}); + +# The oldest entry has a malformed chain: it points at a checkpoint record. +# Its xid wrote nothing, so treating it as aborted changes no data. +my $bad = + $node->safe_psql('postgres', 'BEGIN; SELECT pg_current_xact_id(); COMMIT;'); +($bad) = $bad =~ /(\d+)\s*$/; +$node->safe_psql('postgres', 'CHECKPOINT'); +is( $node->safe_psql( + 'postgres', + "SELECT idxundo_atm_add('$bad', checkpoint_lsn) FROM pg_control_checkpoint()" + ), + 't', + 'malformed entry queued'); + +# A newer, good entry: a large abort the worker has to revert. +my $good = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 5000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($good) = $good =~ /(\d+)\s*$/; +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$good'"), + '1', + 'large abort queued'); + +my $log_offset = -s $node->logfile; +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; + +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$good'"), + 'the entry behind the malformed one is reverted'); +# The worker leaves index entries for VACUUM (see nbtree_undo_apply()). +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'its index entries are left for VACUUM'); + +$node->wait_for_log( + qr/giving up on transaction $bad, whose UNDO chain is malformed/, + $log_offset); +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'the malformed entry is forgotten'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl b/src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl new file mode 100644 index 0000000000000..38cda45e38667 --- /dev/null +++ b/src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl @@ -0,0 +1,57 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A loser transaction that crash recovery defers to the logical revert worker +# must keep the WAL of its whole UNDO chain, from the chain's first batch, +# even when the recovery walk stopped before reaching that batch. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_recovery_first_batch'); +$node->init; +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); + +# Two batches, in different WAL segments, with a checkpoint between them so +# that redo starts after the first. The startup process does not know their +# resource manager, so recovery fails to apply them and defers the chain. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +my $before = $bg->query_safe('SELECT pg_current_wal_insert_lsn()'); +$bg->query_safe('SELECT idxundo_write_failing_undo()'); +my $after = $bg->query_safe('SELECT pg_current_wal_insert_lsn()'); +my $first_seg = $bg->query_safe("SELECT pg_walfile_name('$before')"); +$bg->query_safe('SELECT pg_switch_wal()'); +$bg->query_safe('CHECKPOINT'); +$bg->query_safe('SELECT idxundo_write_failing_undo()'); +$bg->query_safe('SELECT pg_switch_wal()'); +$node->stop('immediate'); +$bg->quit; + +# Recovery does not need the first segment, but walking the chain does: take +# it away, so the walk stops at the first batch without reading it. +my $seg = $node->data_dir . "/pg_wal/$first_seg"; +rename($seg, "$seg.away") or die "rename $seg: $!"; +$node->start; +rename("$seg.away", $seg) or die "rename $seg back: $!"; + +ok( $node->log_contains('stopping chain walk'), + 'the recovery walk stopped before the first batch'); +is( $node->safe_psql( + 'postgres', q{SELECT count(*) FROM pg_stat_get_undo_retention() +WHERE holder = 'aborted'}), + '1', + 'the chain was deferred to the ATM'); +is( $node->safe_psql( + 'postgres', + qq{SELECT oldest_lsn >= '$before' AND oldest_lsn < '$after' +FROM pg_stat_get_undo_retention() WHERE holder = 'aborted'}), + 't', + 'the ATM entry retains WAL from the first batch of the chain'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/010_slog_abort.pl b/src/test/modules/test_idxundo/t/010_slog_abort.pl new file mode 100644 index 0000000000000..448e7f9e9e99d --- /dev/null +++ b/src/test/modules/test_idxundo/t/010_slog_abort.pl @@ -0,0 +1,45 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +my $node = PostgreSQL::Test::Cluster->new('slog_abort'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +for my $am ('heap', 'flux') +{ + $node->safe_psql('postgres', + "CREATE TABLE t(id int PRIMARY KEY) USING $am"); + for my $body ( + 'INSERT INTO t SELECT g FROM generate_series(1,20) g', + 'SAVEPOINT a; INSERT INTO t SELECT g FROM generate_series(1,20) g; ROLLBACK TO a', + 'INSERT INTO t VALUES (1) ON CONFLICT DO NOTHING', + 'SAVEPOINT a; INSERT INTO t VALUES (1) ON CONFLICT DO NOTHING; ROLLBACK TO a' + ) + { + $node->safe_psql('postgres', "BEGIN; $body; ROLLBACK;" x 30); + is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '0', "$am aborted rows invisible: $body"); + } + $node->safe_psql('postgres', 'DROP TABLE t'); +} +# Force the relation OID to differ from its physical file number. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE replay(id int) USING flux; +TRUNCATE replay; +CHECKPOINT; +INSERT INTO replay VALUES (1); +COPY replay FROM STDIN; +2 +\. +}); +$node->stop('immediate'); +$node->start; +is($node->safe_psql('postgres', q{SELECT count(*) FROM replay}), + '2', 'crash replay restored INSERT and COPY rows'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl b/src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl new file mode 100644 index 0000000000000..b2aceae92379f --- /dev/null +++ b/src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl @@ -0,0 +1,58 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Rolling back index inserts writes compensation records (CLRs) through the +# Undo resource manager that change btree and hash pages. wal_consistency_ +# checking must be able to verify the redo of those page changes, so it has +# to accept "undo", and a standby replaying the CLRs must find every page +# consistent with the primary's image. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', + "wal_consistency_checking = 'undo,btree,hash'"); +$primary->start; + +$primary->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE w (id int, val text) WITH (index_undo = on); +CREATE INDEX w_b ON w (id); +CREATE INDEX w_h ON w USING hash (id); +INSERT INTO w SELECT g, 'committed' FROM generate_series(1, 200) g; +}); + +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +$primary->safe_psql( + 'postgres', q{ +BEGIN; +SET LOCAL undo_instant_abort_threshold = 2147483647; +INSERT INTO w SELECT g, 'aborted' FROM generate_series(1001, 1300) g; +ROLLBACK; +}); +is($primary->safe_psql('postgres', "SELECT idxundo_count_dead_all('w_b')"), + '300', 'rollback killed the btree entries'); +cmp_ok( + $primary->safe_psql('postgres', "SELECT idxundo_count_dead_all('w_h')"), + '>', 0, 'rollback killed hash entries'); + +$primary->wait_for_catchup($standby, 'replay'); +my $q = "SET enable_seqscan = off; SET enable_bitmapscan = off; " + . "SELECT count(*) FROM w WHERE id BETWEEN 1 AND 200"; +is($standby->safe_psql('postgres', $q), + '200', 'standby replayed the CLRs and still finds the committed rows'); +ok( !$standby->log_contains('inconsistent page found'), + 'no page inconsistency in the standby log'); + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl b/src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl new file mode 100644 index 0000000000000..1ae7cccdb4d67 --- /dev/null +++ b/src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl @@ -0,0 +1,65 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A table AM's DELETE called with wait = false (REPACK (CONCURRENTLY) +# applies its deletes that way) must neither wait for nor misreport a +# writer or locker still in progress: heap_delete returns TM_BeingModified +# with that transaction in tmfd->xmax, as tableam.h documents. heap is the +# control for every case. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_delete_nowait'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); + +foreach my $am ('heap', 'flux') +{ + foreach my $case ( + [ 'DELETE', 'DELETE FROM t_AM WHERE id = 1' ], + [ 'UPDATE', 'UPDATE t_AM SET v = v + 1 WHERE id = 1' ], + [ 'FOR UPDATE', 'SELECT * FROM t_AM WHERE id = 1 FOR UPDATE' ], + [ 'FOR SHARE', 'SELECT * FROM t_AM WHERE id = 1 FOR SHARE' ]) + { + my ($what, $sql) = @$case; + $sql =~ s/_AM/_$am/g; + $node->safe_psql( + 'postgres', qq{ +DROP TABLE IF EXISTS t_$am; +CREATE TABLE t_$am (id int, v int) USING $am; +INSERT INTO t_$am VALUES (1, 10), (2, 20); +}); + my $tid = + $node->safe_psql('postgres', "SELECT ctid FROM t_$am WHERE id = 1"); + my $s1 = $node->background_psql('postgres'); + $s1->query_safe('BEGIN'); + my $xid = $s1->query_safe('SELECT txid_current()'); + $s1->query_safe($sql); + is( $node->safe_psql( + 'postgres', "SELECT idxundo_delete_nowait('t_$am', '$tid')"), + "BeingModified $xid", + "$am: delete without waiting, $what in progress"); + $s1->query_safe('ROLLBACK'); + $s1->quit; + is( $node->safe_psql('postgres', "SELECT v FROM t_$am WHERE id = 1"), + '10', + "$am: delete without waiting deleted nothing ($what)"); + } + + # Nothing in progress: the delete is done. + my $tid = + $node->safe_psql('postgres', "SELECT ctid FROM t_$am WHERE id = 1"); + is( $node->safe_psql( + 'postgres', "SELECT idxundo_delete_nowait('t_$am', '$tid')"), + 'Ok', + "$am: delete without waiting, no conflict"); + is($node->safe_psql('postgres', "SELECT count(*) FROM t_$am"), + '1', "$am: deleted"); +} + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/test_idxundo--1.0.sql b/src/test/modules/test_idxundo/test_idxundo--1.0.sql new file mode 100644 index 0000000000000..9feab42b05f9b --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo--1.0.sql @@ -0,0 +1,46 @@ +/* src/test/modules/test_idxundo/test_idxundo--1.0.sql */ +\echo Use "CREATE EXTENSION test_idxundo" to load this file. \quit + +-- Total LP_DEAD line pointers across every block of an index. Used by the +-- end-to-end tests, where the aborted entries' block is not known in advance. +CREATE FUNCTION idxundo_count_dead_all(index regclass) +RETURNS int AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Does the current transaction hold published index UNDO? Reports +-- XactUndo.has_undo together with whether a permanent chain-head batch LSN was +-- recorded -- the two pieces AtAbort_XactUndo() requires before it will walk a +-- chain. An index insert that published neither would make ROLLBACK a +-- silent no-op; this guards against that. +CREATE FUNCTION idxundo_undo_chain_published() +RETURNS bool AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Every Aborted Transaction Map entry: which aborts were handed to the logical +-- revert worker, for which database, whether the chain is bounded (a +-- subtransaction's; stop_lsn is NULL for a whole chain), and whether they were +-- reverted. +CREATE FUNCTION idxundo_atm_entries(OUT xid xid, OUT dboid oid, + OUT last_batch_lsn pg_lsn, OUT stop_lsn pg_lsn, OUT revert_complete bool) +RETURNS SETOF record AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Queue xid in the ATM with an UNDO chain at lsn (which need not be one). +CREATE FUNCTION idxundo_atm_add(xid xid, lsn pg_lsn) +RETURNS bool AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Write an UNDO record that fails to apply, in the current (sub)transaction, +-- into the chain of the given relpersistence ('p', 'u' or 't'). +CREATE FUNCTION idxundo_write_failing_undo(persistence "char" DEFAULT 'p') +RETURNS void AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Write UNDO records 1..n of an order-logging resource manager in one batch +-- (needs shared_preload_libraries = 'test_idxundo'). +CREATE FUNCTION idxundo_write_ordered_undo(n int) +RETURNS void AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- FLUX's count of active transactions (oldest-active timestamp slots). +CREATE FUNCTION idxundo_flux_active_xacts() +RETURNS int AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- table_tuple_delete of t with wait = false: the result and the xid it +-- reports. +CREATE FUNCTION idxundo_delete_nowait(rel regclass, t tid) +RETURNS text AS 'MODULE_PATHNAME' LANGUAGE C STRICT; diff --git a/src/test/modules/test_idxundo/test_idxundo.c b/src/test/modules/test_idxundo/test_idxundo.c new file mode 100644 index 0000000000000..59f8bda93e2bd --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo.c @@ -0,0 +1,405 @@ +/*------------------------------------------------------------------------- + * + * test_idxundo.c + * SQL-callable probes for the index UNDO tests. + * + * The tests drive index UNDO end to end through the real abort path: a table + * created WITH (index_undo = on) makes its indexes write UNDO, and ROLLBACK + * reverses the provisional entries. These functions only observe the result: + * how many index entries are LP_DEAD, and whether the current transaction has + * published an UNDO chain for AtAbort_XactUndo() to walk. + * + * This is a test-only module. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/flux.h" +#include "access/hash.h" +#include "access/nbtree.h" +#include "access/relation.h" +#include "access/table.h" +#include "access/tableam.h" +#include "access/slog.h" +#include "access/slog_internal.h" +#include "access/xact.h" +#include "access/undormgr.h" +#include "access/xactundo.h" +#include "catalog/pg_am_d.h" +#include "catalog/pg_class.h" +#include "fmgr.h" +#include "miscadmin.h" +#include "storage/ipc.h" +#include "storage/lwlock.h" +#include "utils/injection_point.h" +#include "utils/guc.h" +#include "funcapi.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "utils/pg_lsn.h" +#include "utils/rel.h" +#include "utils/tuplestore.h" +#include "utils/snapmgr.h" +#include "utils/builtins.h" + +PG_MODULE_MAGIC; + +void _PG_init(void); + +/* + * idxundo_count_dead_all(index regclass) -> total LP_DEAD items in the index + * + * The end-to-end tests do not know which block the aborted entries landed on, + * so they count over every entry-bearing page of the index: btree leaves, hash + * bucket and overflow pages. + */ +PG_FUNCTION_INFO_V1(idxundo_count_dead_all); +Datum +idxundo_count_dead_all(PG_FUNCTION_ARGS) +{ + Oid indexoid = PG_GETARG_OID(0); + Relation indexrel; + BlockNumber blk, + nblocks; + Oid relam; + int ndead = 0; + + indexrel = relation_open(indexoid, AccessShareLock); + nblocks = RelationGetNumberOfBlocks(indexrel); + relam = indexrel->rd_rel->relam; + + if (relam != BTREE_AM_OID && relam != HASH_AM_OID) + ereport(ERROR, + (errmsg("index \"%s\" is neither a btree nor a hash index", + RelationGetRelationName(indexrel)))); + + for (blk = 0; blk < nblocks; blk++) + { + Buffer buffer = ReadBuffer(indexrel, blk); + Page page; + OffsetNumber off, + maxoff; + + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + /* + * Only count line pointers on pages that actually hold index entries. + * Every other page kind must be skipped, not merely tested for + * emptiness: a hash bitmap page is a dense run of set bits, which + * read as line pointers whose lp_flags happen to be LP_DEAD. Counting + * those reports over a thousand phantom dead entries in a freshly + * built hash index and makes the before/after comparison meaningless. + */ + if (!PageIsNew(page) && !PageIsEmpty(page) && + PageGetSpecialSize(page) > 0 && + (relam == BTREE_AM_OID + ? P_ISLEAF(BTPageGetOpaque(page)) + : ((HashPageGetOpaque(page)->hasho_flag & LH_PAGE_TYPE) == LH_BUCKET_PAGE || + (HashPageGetOpaque(page)->hasho_flag & LH_PAGE_TYPE) == LH_OVERFLOW_PAGE))) + { + maxoff = PageGetMaxOffsetNumber(page); + for (off = FirstOffsetNumber; off <= maxoff; off++) + { + if (ItemIdIsDead(PageGetItemId(page, off))) + ndead++; + } + } + UnlockReleaseBuffer(buffer); + } + relation_close(indexrel, AccessShareLock); + + PG_RETURN_INT32(ndead); +} + +/* + * idxundo_undo_chain_published() -> bool + * + * True when the current transaction has UNDO that AtAbort_XactUndo() will act + * on: either a permanent-persistence batch LSN already recorded in + * XactUndo.last_batch_lsn[], or records still sitting in the deferred batch that + * the abort path flushes before it reads that LSN. + * + * An insert that produced a valid XLOG_UNDO_BATCH but published no chain head + * would make ROLLBACK a silent no-op; this returns false in that case. + * + * Both states count as published, because index UNDO DEFERS its records so a + * statement's entries share one batch (DeferXactUndoData). Mid-transaction + * the records are normally still pending, and that is correct -- what would be a + * bug is neither: no pending records AND no chain head means the insert produced + * no UNDO at all. + */ +PG_FUNCTION_INFO_V1(idxundo_undo_chain_published); +Datum +idxundo_undo_chain_published(PG_FUNCTION_ARGS) +{ + XLogRecPtr lsn = GetCurrentXactLastBatchLSN(UNDOPERSISTENCE_PERMANENT); + + PG_RETURN_BOOL(!XLogRecPtrIsInvalid(lsn) || XactUndoHasPendingData()); +} + +/* + * idxundo_atm_entries() -> setof (xid, dboid, last_batch_lsn, stop_lsn, + * revert_complete) + * + * Every entry in the Aborted Transaction Map. Lets a test see whether an abort + * was handed to the logical revert worker (and for which database) or applied + * inline, whether a crash-recovery deferral was ever drained, and whether an + * entry covers a bounded (subtransaction) chain. + */ +PG_FUNCTION_INFO_V1(idxundo_atm_entries); +Datum +idxundo_atm_entries(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + SLogTxnEntry *entries; + int n; + + InitMaterializedSRF(fcinfo, 0); + + n = SLogTxnSnapshotForCheckpoint(&entries); + for (int i = 0; i < n; i++) + { + Datum values[5]; + bool nulls[5] = {0}; + + values[0] = TransactionIdGetDatum(entries[i].xid); + values[1] = ObjectIdGetDatum(entries[i].dboid); + values[2] = LSNGetDatum(entries[i].last_batch_lsn); + values[3] = LSNGetDatum(entries[i].stop_lsn); + nulls[3] = XLogRecPtrIsInvalid(entries[i].stop_lsn); + values[4] = BoolGetDatum(entries[i].revert_complete); + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, + values, nulls); + } + + return (Datum) 0; +} + +/* + * idxundo_atm_add(xid xid, lsn pg_lsn) -> bool + * + * Queue xid in the ATM for this database with an UNDO chain at lsn, which + * need not be an UNDO batch: pointing it at some other WAL record makes a + * malformed chain. + */ +PG_FUNCTION_INFO_V1(idxundo_atm_add); +Datum +idxundo_atm_add(PG_FUNCTION_ARGS) +{ + TransactionId xid = DatumGetTransactionId(PG_GETARG_DATUM(0)); + XLogRecPtr lsn = PG_GETARG_LSN(1); + + ATMEnsureCreated(); + PG_RETURN_BOOL(ATMAddAborted(xid, MyDatabaseId, lsn, lsn, false)); +} + +/* + * A test UNDO resource manager whose records always fail to apply. + * + * It is registered in the calling backend only, on first use, so only that + * backend's own (inline) rollback can dispatch to it; anywhere else -- the + * logical revert worker -- its records are of an unknown resource manager, + * which fails just the same. + */ +#define TEST_IDXUNDO_RMID 200 + +static UndoApplyResult +test_idxundo_failing_apply(uint8 rmid, uint16 info, TransactionId xid, + Oid reloid, const char *payload, Size payload_len, + UndoRecPtr urec_ptr) +{ + return UNDO_APPLY_ERROR; +} + +static const UndoRmgrData test_idxundo_failing_rmgr = { + .rm_name = "test_idxundo_failing", + .rm_undo = test_idxundo_failing_apply, +}; + +/* + * idxundo_write_failing_undo(persistence "char" DEFAULT 'p') -> void + * + * Write one UNDO record, in the current (sub)transaction, that cannot be + * applied, into the chain of the given relpersistence ('p', 'u' or 't'). + * Any index UNDO still deferred is written in the same batch. + */ +PG_FUNCTION_INFO_V1(idxundo_write_failing_undo); +Datum +idxundo_write_failing_undo(PG_FUNCTION_ARGS) +{ + XactUndoContext ctx; + + if (GetUndoRmgr(TEST_IDXUNDO_RMID) == NULL) + RegisterUndoRmgr(TEST_IDXUNDO_RMID, &test_idxundo_failing_rmgr); + + PrepareXactUndoData(&ctx, PG_GETARG_CHAR(0), TEST_IDXUNDO_RMID, 0, + InvalidOid, NULL, 0); + START_CRIT_SECTION(); + InsertXactUndoData(&ctx); + CleanupXactUndoInsertion(&ctx); + END_CRIT_SECTION(); + + PG_RETURN_VOID(); +} + +/* + * A test UNDO resource manager that logs the order its records are applied in. + * + * Registered only when the module is in shared_preload_libraries, from + * shmem_startup_hook: the core zeroes the dispatch table while it sets up + * shared memory, after _PG_init has run. Every process forked by the + * postmaster -- the startup process included -- inherits the registration. + */ +#define TEST_IDXUNDO_ORDER_RMID 201 + +static shmem_startup_hook_type prev_shmem_startup_hook = NULL; +static bool recovery_error = false; + +static UndoApplyResult +test_idxundo_order_apply(uint8 rmid, uint16 info, TransactionId xid, + Oid reloid, const char *payload, Size payload_len, + UndoRecPtr urec_ptr) +{ + int32 seq; + + if (payload_len != sizeof(int32)) + return UNDO_APPLY_ERROR; + memcpy(&seq, payload, sizeof(int32)); + elog(LOG, "test_idxundo: applying UNDO record %d of transaction %u", + seq, xid); + return UNDO_APPLY_SUCCESS; +} + +static const UndoRmgrData test_idxundo_order_rmgr = { + .rm_name = "test_idxundo_order", + .rm_undo = test_idxundo_order_apply, +}; + +static void +test_idxundo_shmem_startup(void) +{ + if (prev_shmem_startup_hook) + prev_shmem_startup_hook(); + RegisterUndoRmgr(TEST_IDXUNDO_ORDER_RMID, &test_idxundo_order_rmgr); +#ifdef USE_INJECTION_POINTS + if (recovery_error) + InjectionPointAttach("pbu-recovery-error", "test_idxundo", + "idxundo_recovery_error", NULL, 0); +#endif +} + +void +_PG_init(void) +{ + if (!process_shared_preload_libraries_in_progress) + return; + DefineCustomBoolVariable("test_idxundo.recovery_error", + "Inject an error during per-backend undo recovery.", + NULL, &recovery_error, false, PGC_POSTMASTER, 0, + NULL, NULL, NULL); + prev_shmem_startup_hook = shmem_startup_hook; + shmem_startup_hook = test_idxundo_shmem_startup; +} + +/* + * idxundo_write_ordered_undo(n int) -> void + * + * Write records 1..n of the order-logging resource manager, all in ONE batch. + * Requires the module in shared_preload_libraries. + */ +PG_FUNCTION_INFO_V1(idxundo_write_ordered_undo); +Datum +idxundo_write_ordered_undo(PG_FUNCTION_ARGS) +{ + int32 n = PG_GETARG_INT32(0); + XactUndoContext ctx; + + if (GetUndoRmgr(TEST_IDXUNDO_ORDER_RMID) == NULL) + elog(ERROR, "test_idxundo must be in shared_preload_libraries"); + + for (int32 seq = 1; seq <= n; seq++) + PrepareXactUndoData(&ctx, RELPERSISTENCE_PERMANENT, + TEST_IDXUNDO_ORDER_RMID, 0, InvalidOid, + (const char *) &seq, sizeof(int32)); + START_CRIT_SECTION(); + InsertXactUndoData(&ctx); + CleanupXactUndoInsertion(&ctx); + END_CRIT_SECTION(); + + PG_RETURN_VOID(); +} + +/* + * idxundo_flux_active_xacts() -> FLUX's count of active transactions + * + * The count FLUX keeps of transactions that registered an oldest-active + * timestamp slot. A PREPARE that forgets to release its slot shows up as a + * count that grows with every prepared transaction. + */ +PG_FUNCTION_INFO_V1(idxundo_flux_active_xacts); +Datum +idxundo_flux_active_xacts(PG_FUNCTION_ARGS) +{ + uint64 current_ts; + uint64 oldest_ts; + int active; + + FluxGetMvccStats(¤t_ts, &oldest_ts, &active); + PG_RETURN_INT32(active); +} + +/* + * idxundo_delete_nowait(rel regclass, t tid) -> text + * + * table_tuple_delete of t with wait = false, as REPACK (CONCURRENTLY) applies + * a delete; no SQL statement deletes without waiting. Returns the result's + * name and, when it is not TM_Ok, the xid reported in tmfd->xmax. + */ +PG_FUNCTION_INFO_V1(idxundo_delete_nowait); +Datum +idxundo_delete_nowait(PG_FUNCTION_ARGS) +{ + static const char *const names[] = { + "Ok", "Invisible", "SelfModified", "Updated", "Deleted", + "BeingModified", "WouldBlock" + }; + Relation rel = table_open(PG_GETARG_OID(0), RowExclusiveLock); + TM_FailureData tmfd; + TM_Result res; + + res = table_tuple_delete(rel, PG_GETARG_ITEMPOINTER(1), + GetCurrentCommandId(true), 0, GetActiveSnapshot(), + InvalidSnapshot, false, &tmfd); + table_close(rel, NoLock); + + Assert(res >= 0 && res < lengthof(names)); + if (res == TM_Ok) + PG_RETURN_TEXT_P(cstring_to_text(names[res])); + PG_RETURN_TEXT_P(cstring_to_text(psprintf("%s %u", names[res], + tmfd.xmax))); +} + +/* Throw with both a buffer pin and an LWLock outstanding. */ +extern PGDLLEXPORT void idxundo_error_with_resources(const char *name, + const void *private_data, + void *arg); +void +idxundo_error_with_resources(const char *name, const void *private_data, void *arg) +{ + Relation rel = table_open(RelationRelationId, AccessShareLock); + + (void) ReadBuffer(rel, 0); + LWLockAcquire(ProcArrayLock, LW_SHARED); + elog(ERROR, "test_idxundo: error with resources at %s", name); +} + +extern PGDLLEXPORT void idxundo_recovery_error(const char *name, + const void *private_data, void *arg); +void +idxundo_recovery_error(const char *name, const void *private_data, void *arg) +{ + elog(ERROR, "test_idxundo: recovery error"); +} diff --git a/src/test/modules/test_idxundo/test_idxundo.conf b/src/test/modules/test_idxundo/test_idxundo.conf new file mode 100644 index 0000000000000..98ccc52b5db93 --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo.conf @@ -0,0 +1,5 @@ +# ROLLBACK PREPARED and large rollbacks hand their UNDO to the logical revert +# worker, which applies it asynchronously. Keep the worker off so the dead +# counts in the regression test do not race it; t/007 covers the worker. +max_prepared_transactions = 2 +max_logical_revert_workers = 0 diff --git a/src/test/modules/test_idxundo/test_idxundo.control b/src/test/modules/test_idxundo/test_idxundo.control new file mode 100644 index 0000000000000..f42cd8b163a8f --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo.control @@ -0,0 +1,4 @@ +comment = 'test harness for nbtree/hash index UNDO apply callbacks' +default_version = '1.0' +module_pathname = '$libdir/test_idxundo' +relocatable = true diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile index 9c4102b6b2c34..b3a145191375b 100644 --- a/src/test/recovery/Makefile +++ b/src/test/recovery/Makefile @@ -9,10 +9,17 @@ # #------------------------------------------------------------------------- -EXTRA_INSTALL=contrib/pg_prewarm \ +EXTRA_INSTALL=contrib/amcheck \ + contrib/pg_buffercache \ + contrib/pg_walinspect \ + contrib/pageinspect \ + contrib/pg_visibility \ + contrib/pg_prewarm \ contrib/pg_stat_statements \ contrib/test_decoding \ src/test/modules/injection_points \ + src/test/modules/test_fileops \ + src/test/modules/test_idxundo \ src/test/modules/test_wait_lsn subdir = src/test/recovery diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index e1cf57ff611a2..3388e364101c2 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -68,6 +68,82 @@ tests += { 't/057_snapshot_commit_race.pl', 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', + 't/060_fileops_standby_divergence.pl', + 't/061_fileops_2pc_deferred.pl', + 't/062_fileops_recovery.pl', + 't/063_fileops_2pc.pl', + 't/071_perbackend_undo.pl', + 't/072_perbackend_crash_undo.pl', + 't/073_perbackend_2pc_rollback.pl', + 't/074_perbackend_subxact.pl', + 't/075_perbackend_nonpermanent.pl', + 't/076_flux_deferred_rollback.pl', + 't/077_flux_undo_idempotency.pl', + 't/078_flux_undo_restore_fail.pl', + 't/079_flux_vacuum_aborted_insert.pl', + 't/083_perbackend_multi_update.pl', + 't/084_perbackend_helping_once.pl', + 't/085_perbackend_worker_done.pl', + 't/086_flux_standby_helping.pl', + 't/087_flux_index_undo_fail.pl', + 't/089_perbackend_rewrite_rollback.pl', + 't/090_fileops_createdb_crash.pl', + 't/093_flux_toast_supersede.pl', + 't/094_flux_tombstone_prune.pl', + 't/095_flux_rewrite_cutoffs.pl', + 't/096_perbackend_redo_header_image.pl', + 't/100_flux_vacuum_recorded_tids.pl', + 't/102_flux_insert_redo_consistency.pl', + 't/097_flux_vacuum_truncate.pl', + 't/101_flux_external_wal.pl', + 't/103_flux_nonpermanent_wal.pl', + 't/108_flux_tuple_layout.pl', + 't/109_flux_savepoint_lock_2pc.pl', + 't/106_flux_rewrite_pending_revert.pl', + 't/107_flux_rewrite_2pc.pl', + 't/110_flux_unique_pending_revert.pl', + 't/111_flux_index_build_pending_revert.pl', + 't/104_flux_index_aba_revert.pl', + 't/112_flux_index_no_orphan.pl', + 't/113_flux_prepared_row_locks.pl', + 't/114_undo_recovery_error.pl', + 't/145_flux_slog_storage.pl', + 't/115_flux_freeze.pl', + 't/116_flux_minimal.pl', + 't/117_undo_rewind_snapshot.pl', + 't/118_flux_minimal_undo_log.pl', + 't/119_flux_standby_conflict.pl', + 't/140_flux_spec_insert_vm.pl', + 't/141_flux_index_exact_keys.pl', + 't/142_flux_vm_clear_redo.pl', + 't/143_flux_update_page_lsn.pl', + 't/149_flux_partial_index_member.pl', + 't/150_flux_vm_stale_size.pl', + 't/151_flux_vm_set_fpi.pl', + 't/121_flux_deletemark_race.pl', + 't/122_flux_amcheck_deletemark.pl', + 't/123_flux_tombstone_size.pl', + 't/146_flux_subxact_abort_dropped_storage.pl', + 't/120_flux_undo_remark_split.pl', + 't/125_flux_undo_meta_nonpermanent.pl', + 't/126_atm_full_abort.pl', + 't/127_flux_rollback_prepared_crash.pl', + 't/124_index_undo_tid_reuse.pl', + 't/128_flux_delete_superseded_redo.pl', + 't/129_undo_redo_no_fpw.pl', + 't/130_flux_single_user_abort.pl', + 't/132_flux_rowlock_store.pl', + 't/133_flux_insert_markers.pl', + 't/134_undo_shmem_on_demand.pl', + 't/135_flux_rowlock_filter.pl', + 't/136_flux_rowlock_oom.pl', + 't/131_flux_vacuum_rounds.pl', + 't/137_flux_vacuum_parallel.pl', + 't/138_flux_vacuum_toast.pl', + 't/139_flux_toast_delete.pl', + 't/147_flux_repack_concurrently.pl', + 't/148_flux_pg_visibility.pl', + 't/152_undo_attach_order_after_checkpoint.pl', ], }, } diff --git a/src/test/recovery/t/035_standby_logical_decoding.pl b/src/test/recovery/t/035_standby_logical_decoding.pl index 88893f7135082..4aa48b3a07bb1 100644 --- a/src/test/recovery/t/035_standby_logical_decoding.pl +++ b/src/test/recovery/t/035_standby_logical_decoding.pl @@ -814,7 +814,7 @@ sub wait_until_vacuum_can_remove # This should trigger the conflict $node_primary->safe_psql('testdb', - qq[CREATE TABLE prun(id integer, s char(2000)) WITH (fillfactor = 75, user_catalog_table = true);] + qq[CREATE TABLE prun(id integer, s char(2000)) USING heap WITH (fillfactor = 75, user_catalog_table = true);] ); $node_primary->safe_psql('testdb', qq[INSERT INTO prun VALUES (1, 'A');]); $node_primary->safe_psql('testdb', qq[UPDATE prun SET s = 'B';]); diff --git a/src/test/recovery/t/060_fileops_standby_divergence.pl b/src/test/recovery/t/060_fileops_standby_divergence.pl new file mode 100644 index 0000000000000..65a07fb873e04 --- /dev/null +++ b/src/test/recovery/t/060_fileops_standby_divergence.pl @@ -0,0 +1,124 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Regression test: physical-standby reproduction of FILEOPS deferred +# DESTRUCTIVE operations (DELETE, RENAME, RMDIR). +# +# These three ops execute on the primary at commit inside +# FileOpsDoPendingOps(). Each is logged at commit-EXECUTION time with a +# real, idempotent redo handler, so a physical standby (which only replays +# WAL, never running FileOpsDoPendingOps) reproduces them and its filesystem +# converges with the primary's. +# +# This test sets up a streaming physical standby, performs a committed +# deferred DELETE, RENAME, and RMDIR on the primary, waits for catchup, and +# asserts the standby's filesystem MATCHES the primary's. It MUST FAIL on the +# pre-fix (no-op redo) code -- see the header note in fileops.c and the +# "prove non-vacuity" comment below. +# +# All FILEOPS paths are RELATIVE to the data directory, matching every core +# FILEOPS caller (dbcommands.c, tablespace.c): the backend's cwd is PGDATA, so +# a relative path resolves to the right file on both the primary and a standby +# whose absolute data_dir differs. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('fileops_div_primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + "postgresql.conf", qq( +autovacuum = off +wal_level = replica +)); +$primary->start; + +if (!$primary->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$primary->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $primary_dir = $primary->data_dir; + +# Fixture, all committed and WAL-logged so the standby has them before we +# start mutating. Use FileOps CREATE (WAL-logged, standby-reproduced) and +# FileOps MKDIR (WAL-logged) rather than raw open(), so the fixture itself +# replicates. Paths are relative to PGDATA. +$primary->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_create('div_delete_me.dat', 384); -- 0600 +SELECT test_fileops_create('div_rename_src.dat', 384); +SELECT test_fileops_mkdir('div_rmdir_me', 448); -- 0700 +COMMIT; +)); + +# Take the base backup AFTER the fixture exists so init_from_backup gives the +# standby the fixture files, then stream the mutations. +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('fileops_div_standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +my $standby_dir = $standby->data_dir; + +$primary->wait_for_catchup($standby); + +# Sanity: the fixture replicated to the standby. +ok(-e "$standby_dir/div_delete_me.dat", 'fixture: delete target on standby'); +ok(-e "$standby_dir/div_rename_src.dat", 'fixture: rename source on standby'); +ok(-d "$standby_dir/div_rmdir_me", 'fixture: rmdir target on standby'); + +# Perform the three deferred destructive ops in a committed transaction on the +# primary. Each goes through the FileOps deferred path (at_commit=true) and, +# with the fix, is WAL-logged at commit-execution. +$primary->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_delete('div_delete_me.dat'); +SELECT test_fileops_rename('div_rename_src.dat', 'div_rename_dst.dat'); +SELECT test_fileops_rmdir('div_rmdir_me'); +COMMIT; +)); + +# Confirm the primary applied them (the op ran in FileOpsDoPendingOps). +ok(!-e "$primary_dir/div_delete_me.dat", 'primary: delete applied'); +ok(!-e "$primary_dir/div_rename_src.dat", 'primary: rename source gone'); +ok(-e "$primary_dir/div_rename_dst.dat", 'primary: rename dest present'); +ok(!-d "$primary_dir/div_rmdir_me", 'primary: rmdir applied'); + +$primary->wait_for_catchup($standby); + +# The diff the reviewer asked for: the standby's filesystem must MATCH the +# primary's. Pre-fix (no-op redo) the standby still has the original file / +# dir and lacks the renamed file -- these four assertions all fail, which is +# the non-vacuity proof. Post-fix they pass. +ok(!-e "$standby_dir/div_delete_me.dat", + 'deferred DELETE reproduced on standby (file gone)'); +ok(!-e "$standby_dir/div_rename_src.dat", + 'deferred RENAME reproduced on standby (old name gone)'); +ok(-e "$standby_dir/div_rename_dst.dat", + 'deferred RENAME reproduced on standby (new name present)'); +ok(!-d "$standby_dir/div_rmdir_me", + 'deferred RMDIR reproduced on standby (dir gone)'); + +# Idempotent redo across a standby restart: replaying the same records again +# after a crash must not error or diverge. +$standby->stop('immediate'); +$standby->start; +$primary->safe_psql('postgres', 'SELECT txid_current()'); +$primary->wait_for_catchup($standby); +ok(!-e "$standby_dir/div_delete_me.dat", + 'standby end state stable after restart (delete)'); +ok(-e "$standby_dir/div_rename_dst.dat", + 'standby end state stable after restart (rename)'); +ok(!-d "$standby_dir/div_rmdir_me", + 'standby end state stable after restart (rmdir)'); + +$standby->stop; +$primary->stop; + +done_testing(); diff --git a/src/test/recovery/t/061_fileops_2pc_deferred.pl b/src/test/recovery/t/061_fileops_2pc_deferred.pl new file mode 100644 index 0000000000000..7d2abcf3b294e --- /dev/null +++ b/src/test/recovery/t/061_fileops_2pc_deferred.pl @@ -0,0 +1,168 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Test that DEFERRED commit-time FILEOPS (delete/rename) and abort-time +# filesystem undo survive PREPARE TRANSACTION across a crash. +# +# The bug: PostPrepare_FileOps() used to free the pending-op list without +# recording it anywhere, so a transaction PREPARE'd with a deferred delete or +# rename (at_commit=true) lost that op entirely -- COMMIT PREPARED performed +# nothing -- and an immediate create's abort-time undo was lost for ROLLBACK +# PREPARED. The fix serializes the pending ops into the 2PC state file at +# PREPARE (TWOPHASE_RM_FILEOPS_ID records) and performs them in the FILEOPS +# post-commit / post-abort callbacks, so they survive a crash between PREPARE +# and the final decision, driven from the durable 2PC state. +# +# Unlike 063_fileops_2pc.pl (which only exercises chmod, a constructive op +# reversed via the cluster-wide UNDO chain), this test drives the deferred +# commit-time delete/rename path -- the one that was silently dropped. +# +# NB: FILEOPS writes WAL/UNDO only when XLogIsNeeded() (wal_level >= replica), +# so 2PC durability requires wal_level = replica; this test runs there. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_2pc_deferred'); +$node->init; +$node->append_conf( + "postgresql.conf", qq( +autovacuum = off +max_prepared_transactions = 10 +wal_level = replica +)); +$node->start; + +if (!$node->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $datadir = $node->data_dir; + +sub file_exists +{ + my ($path) = @_; + return $node->safe_psql('postgres', + qq{SELECT test_fileops_file_exists('$path')}); +} + +# Poll until the file's existence matches $want ('t'/'f'); some reversals run +# through the async cluster-wide UNDO revert worker, not synchronously. +sub wait_for_exists +{ + my ($path, $want, $desc) = @_; + my $ok = $node->poll_query_until('postgres', + qq{SELECT test_fileops_file_exists('$path') = '$want'}); + is($ok, '1', $desc); +} + +# ================================================================ +# Scenario A: COMMIT PREPARED performs the deferred commit-time ops +# - deferred delete of an existing file +# - deferred rename of an existing file +# - immediate create of a new file (register_delete) +# PREPARE, crash, restart, then COMMIT PREPARED. All three must take effect. +# ================================================================ +my $del_a = "$datadir/fo2pc_del_a.dat"; +my $ren_a = "$datadir/fo2pc_ren_a.dat"; +my $ren_a2 = "$datadir/fo2pc_ren_a.renamed"; +my $new_a = "$datadir/fo2pc_new_a.dat"; + +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('fo2pc_del_a.dat')}); +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('fo2pc_ren_a.dat')}); + +is(file_exists($del_a), 't', 'A: delete target exists before txn'); +is(file_exists($ren_a), 't', 'A: rename source exists before txn'); + +$node->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_delete('$del_a'); +SELECT test_fileops_rename('$ren_a', '$ren_a2'); +SELECT test_fileops_create('$new_a', 384); +PREPARE TRANSACTION 'fo2pc_commit'; +)); + +# While prepared, deferred ops have NOT run yet; the immediate create has. +is(file_exists($del_a), 't', + 'A: delete deferred -- file still present while prepared'); +is(file_exists($ren_a), 't', + 'A: rename deferred -- source still present while prepared'); +is(file_exists($new_a), 't', 'A: immediate create present while prepared'); + +# Crash between PREPARE and COMMIT PREPARED. +$node->stop('immediate'); +$node->start; + +is($node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts}), + 'fo2pc_commit', 'A: prepared xact survives crash'); + +$node->safe_psql('postgres', q{COMMIT PREPARED 'fo2pc_commit'}); + +is(file_exists($del_a), 'f', + 'A: COMMIT PREPARED performed the deferred delete'); +is(file_exists($ren_a), 'f', + 'A: COMMIT PREPARED performed the rename (source gone)'); +is(file_exists($ren_a2), 't', + 'A: COMMIT PREPARED performed the rename (dest present)'); +is(file_exists($new_a), 't', + 'A: created file persists after COMMIT PREPARED'); + +# ================================================================ +# Scenario B: ROLLBACK PREPARED does NOT perform the deferred commit-time +# ops, and DOES undo the immediate create. +# PREPARE, crash, restart, then ROLLBACK PREPARED. +# ================================================================ +my $del_b = "$datadir/fo2pc_del_b.dat"; +my $ren_b = "$datadir/fo2pc_ren_b.dat"; +my $ren_b2 = "$datadir/fo2pc_ren_b.renamed"; +my $new_b = "$datadir/fo2pc_new_b.dat"; + +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('fo2pc_del_b.dat')}); +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('fo2pc_ren_b.dat')}); + +$node->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_delete('$del_b'); +SELECT test_fileops_rename('$ren_b', '$ren_b2'); +SELECT test_fileops_create('$new_b', 384); +PREPARE TRANSACTION 'fo2pc_rollback'; +)); + +is(file_exists($new_b), 't', 'B: immediate create present while prepared'); + +# Crash between PREPARE and ROLLBACK PREPARED. +$node->stop('immediate'); +$node->start; + +is($node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts}), + 'fo2pc_rollback', 'B: prepared xact survives crash'); + +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo2pc_rollback'}); + +is(file_exists($del_b), 't', + 'B: ROLLBACK PREPARED did NOT delete (file still present)'); +is(file_exists($ren_b), 't', + 'B: ROLLBACK PREPARED did NOT rename (source still present)'); +is(file_exists($ren_b2), 'f', + 'B: ROLLBACK PREPARED did NOT rename (dest absent)'); +# The immediate create is undone by the constructive op's cluster-wide UNDO +# record via the async revert worker on ROLLBACK PREPARED, so poll. +wait_for_exists($new_b, 'f', + 'B: ROLLBACK PREPARED undid the immediate create'); + +# Server healthy at the end. +is($node->safe_psql('postgres', 'SELECT 1'), + '1', 'server operational after all deferred-2PC scenarios'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/062_fileops_recovery.pl b/src/test/recovery/t/062_fileops_recovery.pl new file mode 100644 index 0000000000000..de57f699ef7ba --- /dev/null +++ b/src/test/recovery/t/062_fileops_recovery.pl @@ -0,0 +1,419 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Test crash recovery for transactional file operations (FILEOPS). +# +# These tests verify that FILEOPS WAL replay correctly handles: +# - Crash during file creation (with delete-on-abort) +# - Crash during deferred file deletion +# - Crash during file operations on standby +# - Multiple sequential crashes + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_recovery'); +$node->init; +$node->append_conf( + "postgresql.conf", qq( +autovacuum = off +log_min_messages = debug2 +)); +$node->start; + +# ================================================================ +# Test 1: CREATE TABLE survives crash +# ================================================================ + +$node->safe_psql( + "postgres", qq( +CREATE TABLE fileops_test (id int, data text); +INSERT INTO fileops_test VALUES (1, 'created_table'); +)); + +$node->stop('immediate'); +$node->start; + +my $result = + $node->safe_psql("postgres", "SELECT data FROM fileops_test WHERE id = 1"); +is($result, 'created_table', 'CREATE TABLE survives crash'); + +# ================================================================ +# Test 2: DROP TABLE is properly handled after crash +# ================================================================ + +$node->safe_psql( + "postgres", qq( +CREATE TABLE drop_me (id int); +INSERT INTO drop_me VALUES (1); +)); + +# Get the relfilenode before dropping +my $relpath = + $node->safe_psql("postgres", "SELECT pg_relation_filepath('drop_me')"); + +$node->safe_psql("postgres", "DROP TABLE drop_me"); + +$node->stop('immediate'); +$node->start; + +# Table should be gone +my ($ret, $stdout, $stderr) = + $node->psql("postgres", "SELECT * FROM drop_me"); +isnt($ret, 0, 'dropped table is gone after crash recovery'); + +# ================================================================ +# Test 3: Crash during transaction with CREATE TABLE (uncommitted) +# ================================================================ + +# This table is committed +$node->safe_psql( + "postgres", qq( +CREATE TABLE committed_table (id int); +INSERT INTO committed_table VALUES (42); +)); + +# Crash the server +$node->stop('immediate'); +$node->start; + +# Committed table should exist +$result = $node->safe_psql("postgres", "SELECT id FROM committed_table"); +is($result, '42', 'committed CREATE TABLE survives crash'); + +# ================================================================ +# Test 4: Multiple CREATE and DROP operations then crash +# ================================================================ + +$node->safe_psql( + "postgres", qq( +CREATE TABLE t1 (id int); +CREATE TABLE t2 (id int); +CREATE TABLE t3 (id int); +INSERT INTO t1 VALUES (1); +INSERT INTO t2 VALUES (2); +INSERT INTO t3 VALUES (3); +DROP TABLE t2; +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", "SELECT id FROM t1"); +is($result, '1', 't1 survives crash'); + +($ret, $stdout, $stderr) = $node->psql("postgres", "SELECT * FROM t2"); +isnt($ret, 0, 't2 (dropped) is gone after crash'); + +$result = $node->safe_psql("postgres", "SELECT id FROM t3"); +is($result, '3', 't3 survives crash'); + +# ================================================================ +# Test 5: Crash after checkpoint with file operations +# ================================================================ + +$node->safe_psql( + "postgres", qq( +DROP TABLE IF EXISTS t1; +DROP TABLE IF EXISTS t3; +CREATE TABLE checkpoint_test (id int); +INSERT INTO checkpoint_test VALUES (1); +CHECKPOINT; +INSERT INTO checkpoint_test VALUES (2); +)); + +$node->stop('immediate'); +$node->start; + +$result = + $node->safe_psql("postgres", "SELECT count(*) FROM checkpoint_test"); +is($result, '2', 'data after checkpoint survives crash'); + +# ================================================================ +# Test 6: Multiple crashes in sequence with file operations +# ================================================================ + +$node->safe_psql( + "postgres", qq( +DROP TABLE IF EXISTS checkpoint_test; +CREATE TABLE multi_crash (id int); +INSERT INTO multi_crash VALUES (1); +)); + +$node->stop('immediate'); +$node->start; + +$node->safe_psql( + "postgres", qq( +INSERT INTO multi_crash VALUES (2); +CREATE TABLE multi_crash_2 (id int); +INSERT INTO multi_crash_2 VALUES (10); +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", "SELECT count(*) FROM multi_crash"); +is($result, '2', 'multi_crash table correct after double crash'); + +$result = $node->safe_psql("postgres", "SELECT id FROM multi_crash_2"); +is($result, '10', 'multi_crash_2 table correct after double crash'); + +# ================================================================ +# Test 7: CREATE TABLESPACE survives crash +# ================================================================ + +$node->safe_psql( + "postgres", qq( +DROP TABLE IF EXISTS multi_crash; +DROP TABLE IF EXISTS multi_crash_2; +SET allow_in_place_tablespaces = on; +CREATE TABLESPACE fileops_crash_ts LOCATION ''; +CREATE TABLE ts_crash_table (id int) TABLESPACE fileops_crash_ts; +INSERT INTO ts_crash_table VALUES (99); +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", "SELECT id FROM ts_crash_table"); +is($result, '99', 'table in tablespace survives crash'); + +$result = $node->safe_psql("postgres", + "SELECT spcname FROM pg_tablespace WHERE spcname = 'fileops_crash_ts'"); +is($result, 'fileops_crash_ts', 'tablespace survives crash'); + +# ================================================================ +# Test 8: DROP TABLESPACE completes after crash +# ================================================================ + +$node->safe_psql( + "postgres", qq( +DROP TABLE ts_crash_table; +DROP TABLESPACE fileops_crash_ts; +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT count(*) FROM pg_tablespace WHERE spcname = 'fileops_crash_ts'"); +is($result, '0', 'dropped tablespace is gone after crash'); + +# ================================================================ +# Test 9: CREATE DATABASE survives crash +# ================================================================ + +$node->safe_psql( + "postgres", qq( +CREATE DATABASE fileops_crash_db; +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT datname FROM pg_database WHERE datname = 'fileops_crash_db'"); +is($result, 'fileops_crash_db', 'CREATE DATABASE survives crash'); + +$node->safe_psql("postgres", "DROP DATABASE fileops_crash_db"); + +# ================================================================ +# Test 10: Standby crash during FILEOPS replay +# ================================================================ + +# Set up primary + standby +my $primary = PostgreSQL::Test::Cluster->new('fileops_primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + "postgresql.conf", qq( +autovacuum = off +)); +$primary->start; +$primary->backup('backup'); + +my $standby = PostgreSQL::Test::Cluster->new('fileops_standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +# Create table on primary and wait for standby to catch up +$primary->safe_psql( + "postgres", qq( +CREATE TABLE standby_test (id int); +INSERT INTO standby_test VALUES (1); +)); + +$primary->wait_for_catchup($standby); + +# Verify on standby +$result = $standby->safe_psql("postgres", "SELECT id FROM standby_test"); +is($result, '1', 'CREATE TABLE replicated to standby'); + +# Crash the standby +$standby->stop('immediate'); +$standby->start; + +# Add more data on primary +$primary->safe_psql( + "postgres", qq( +INSERT INTO standby_test VALUES (2); +)); + +$primary->wait_for_catchup($standby); + +$result = + $standby->safe_psql("postgres", "SELECT count(*) FROM standby_test"); +is($result, '2', 'standby recovers and catches up after crash'); + +# Clean up primary/standby +$standby->stop; +$primary->stop; + +# Clean up original node +$node->stop; + +# ================================================================ +# Test 11/12: Crash right after an immediate op's WAL is flushed, before +# its transaction commits (FileOpsCreate, FileOpsChmod). +# +# An immediate op makes its filesystem change first and then flushes its +# WAL and UNDO records. The injection points pause the backend just after +# that flush, inside the still-open implicit transaction, and the postmaster +# is then hard-killed. Redo replays the change, and crash recovery applies +# the UNDO of the never-committed transaction, so the end state must be as +# if the transaction never ran: no orphaned file, no leftover mode change. +# ================================================================ + +if ($ENV{enable_injection_points} eq 'yes') +{ + my $inode = PostgreSQL::Test::Cluster->new('fileops_wal_ordering'); + $inode->init; + $inode->append_conf( + 'postgresql.conf', qq( +shared_preload_libraries = 'injection_points' +autovacuum = off +log_min_messages = debug2 +wal_level = replica +)); + $inode->start; + + if ( $inode->check_extension('injection_points') + && $inode->check_extension('test_fileops')) + { + $inode->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + $inode->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + + my $idatadir = $inode->data_dir; + + # --- Test 11: FileOpsCreate ----------------------------------- + $inode->safe_psql('postgres', + q{SELECT injection_points_attach('fileops-create-after-wal-flush', 'wait')} + ); + + my $create_path = "$idatadir/wal_ordering_create.dat"; + my $bgpsql = $inode->background_psql('postgres'); + $bgpsql->query_until( + qr/starting_create/, + qq(\\echo starting_create +SELECT test_fileops_create('$create_path', 384); +\\q +)); + + $inode->wait_for_event('client backend', + 'fileops-create-after-wal-flush'); + + # The file exists and its WAL is durable; the transaction is open. + ok(-e $create_path, + 'Test 11: file exists at the injection point (WAL durable)'); + + # Hard-kill the postmaster from exactly this window. The wrapping + # implicit transaction is still in progress (blocked on the + # injection point), so it never committed. + $inode->stop('immediate'); + $inode->start; + + # Recovery redoes the physical CREATE from the durable WAL record + # unconditionally, then determines the transaction never committed + # and applies its UNDO record to reverse the create. Net effect: + # the file must NOT exist -- exactly as if the transaction had + # never run, with no orphaned state left behind by the crash. + ok( !-e $create_path, + 'Test 11: FileOpsCreate of a not-committed transaction is ' + . 'reversed after a crash'); + + # Server is healthy after the reconciliation. + my $result11 = $inode->safe_psql('postgres', 'SELECT 1'); + is($result11, '1', 'Test 11: server operational after recovery'); + + $bgpsql->{run}->finish; + + # --- Test 12: FileOpsChmod ------------------------------------- + $inode->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('wal_ordering_chmod.dat')} + ); + my $chmod_path = "$idatadir/wal_ordering_chmod.dat"; + $inode->safe_psql('postgres', + qq{SELECT test_fileops_chmod('$chmod_path', 420)}); # 0644 + my $mode_before = $inode->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$chmod_path')}); + is($mode_before, '420', 'Test 12: baseline mode is 0644'); + + $inode->safe_psql('postgres', + q{SELECT injection_points_attach('fileops-chmod-after-wal-flush', 'wait')} + ); + + $bgpsql = $inode->background_psql('postgres'); + $bgpsql->query_until( + qr/starting_chmod/, + qq(\\echo starting_chmod +SELECT test_fileops_chmod('$chmod_path', 448); -- 0700 +\\q +)); + + $inode->wait_for_event('client backend', + 'fileops-chmod-after-wal-flush'); + + # chmod() has run and its WAL is durable; the transaction is open. + my $mode_at_injection = $inode->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$chmod_path')}); + is($mode_at_injection, '448', + 'Test 12: mode changed on disk at the injection point (WAL durable)' + ); + + # Hard-kill, same as Test 11. The chmod's wrapping implicit + # transaction never committed. + $inode->stop('immediate'); + $inode->start; + + # Recovery redoes the physical CHMOD unconditionally, then reverses + # it via UNDO because the transaction never committed. Net effect: + # mode must be back to the pre-transaction baseline (0644), not the + # attempted 0700, and not some undefined intermediate state. + my $mode_after = $inode->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$chmod_path')}); + is($mode_after, '420', + 'Test 12: FileOpsChmod of a not-committed transaction is ' + . 'reversed after a crash'); + + # Server is healthy after the reconciliation. + my $result12 = $inode->safe_psql('postgres', 'SELECT 1'); + is($result12, '1', 'Test 12: server operational after recovery'); + + $bgpsql->{run}->finish; + } + else + { + note + 'skipping Test 11/12 (injection_points or test_fileops extension not installed)'; + } + + $inode->stop; +} +else +{ + note 'skipping Test 11/12 (injection points not supported by this build)'; +} + +done_testing(); diff --git a/src/test/recovery/t/063_fileops_2pc.pl b/src/test/recovery/t/063_fileops_2pc.pl new file mode 100644 index 0000000000000..068415d8b2377 --- /dev/null +++ b/src/test/recovery/t/063_fileops_2pc.pl @@ -0,0 +1,146 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Test two-phase commit (PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK +# PREPARED) for transactions that generated cluster-wide FILEOPS UNDO. +# +# FILEOPS uses the undo-in-common-WAL mechanism. At PREPARE the permanent +# UNDO chain-head LSN is durably saved in the 2PC state (xl_xact_prepare / +# gxact->undo_batch_lsn), and its WAL is retained while the xact stays +# prepared. ROLLBACK PREPARED (even from a different backend, post-crash) +# hands that LSN to the async logical-revert machinery via ATMAddAborted(), +# which reverses the filesystem change. COMMIT PREPARED keeps it. +# +# Vehicle: the test_fileops extension's SQL wrappers for FileOpsChmod, which +# are legal inside a transaction block that can then be PREPARE'd. +# +# NB: FILEOPS only writes UNDO WAL when XLogIsNeeded() (wal_level >= replica). +# At wal_level = minimal it relies on the backend-private pending-op list, +# which is discarded at PREPARE and cannot survive 2PC. This test therefore +# runs at wal_level = replica, which is what any FILEOPS + 2PC deployment +# requires. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_2pc'); +$node->init; +$node->append_conf( + "postgresql.conf", qq( +autovacuum = off +max_prepared_transactions = 10 +wal_level = replica +)); +$node->start; + +if (!$node->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $datadir = $node->data_dir; +my $file = "$datadir/twopc.dat"; + +# Poll until the file mode equals $want (async revert takes a moment) or fail. +sub wait_for_mode +{ + my ($want, $desc) = @_; + my $ok = $node->poll_query_until('postgres', + qq{SELECT test_fileops_get_mode('$file') = $want}); + is($ok, '1', $desc); +} + +# 0644 = 420, 0700 = 448, 0600 = 384 (decimal) +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('twopc.dat')}); +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)}); +my $baseline = + $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')}); +is($baseline, '420', 'baseline mode is 0644'); + +# ================================================================ +# Test 1: ROLLBACK PREPARED reverses the chmod +# ================================================================ +$node->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); -- 0700 +PREPARE TRANSACTION 'fo_rb'; +)); +my $m = + $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')}); +is($m, '448', 'Test 1: chmod applied and still visible while prepared'); + +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_rb'}); +wait_for_mode(420, 'Test 1: ROLLBACK PREPARED reverses chmod (0700 -> 0644)'); + +# ================================================================ +# Test 2: COMMIT PREPARED keeps the chmod +# ================================================================ +$node->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); -- 0700 +PREPARE TRANSACTION 'fo_cp'; +)); +$node->safe_psql('postgres', q{COMMIT PREPARED 'fo_cp'}); +$m = $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')}); +is($m, '448', 'Test 2: COMMIT PREPARED keeps chmod (0700)'); + +# Reset to baseline for the crash test. +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)}); + +# ================================================================ +# Test 3: WAL retention across checkpoints while prepared +# ================================================================ +$node->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 384); -- 0600 +PREPARE TRANSACTION 'fo_ckpt'; +)); +# Churn WAL and checkpoint; the prepared xact's UNDO batch must be retained. +$node->safe_psql('postgres', 'CHECKPOINT'); +for my $i (1 .. 20) +{ + $node->safe_psql('postgres', + "CREATE TABLE junk_$i(x int); " + . "INSERT INTO junk_$i SELECT g FROM generate_series(1,5000) g; " + . "DROP TABLE junk_$i;"); +} +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_ckpt'}); +wait_for_mode(420, + 'Test 3: ROLLBACK PREPARED still reverses after WAL churn + checkpoints'); + +# ================================================================ +# Test 4: crash recovery -- prepared UNDO xact survives, then reverts +# ================================================================ +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)}); +$node->safe_psql( + 'postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); -- 0700 +PREPARE TRANSACTION 'fo_crash'; +)); +$node->stop('immediate'); +$node->start; + +my $prep = $node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts}); +is($prep, 'fo_crash', 'Test 4: prepared xact survives crash recovery'); +$m = $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')}); +is($m, '448', 'Test 4: chmod still applied (prepared, not yet rolled back)'); + +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_crash'}); +wait_for_mode(420, + 'Test 4: ROLLBACK PREPARED after crash+restart reverses chmod'); + +# Server healthy at the end. +is($node->safe_psql('postgres', 'SELECT 1'), + '1', 'server operational after all 2PC UNDO scenarios'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/071_perbackend_undo.pl b/src/test/recovery/t/071_perbackend_undo.pl new file mode 100644 index 0000000000000..21e8298bfe2ca --- /dev/null +++ b/src/test/recovery/t/071_perbackend_undo.pl @@ -0,0 +1,58 @@ +# Per-backend UNDO engine lifecycle and crash-recovery smoke test. +# The engine's background workers always run; this commit adds no table AM +# that produces per-backend undo, so here they only initialize and idle. This +# test confirms the engine initializes, both workers launch, and a +# crash/recover cycle is clean. Reclamation of undo written by a table AM is +# tested with the AMs that produce it. +use strict; +use warnings; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_primary'); +$node->init; +$node->start; + +# 1. server up +my $up = $node->safe_psql('postgres', 'SELECT 1'); +is($up, '1', 'server up'); + +# 2. txn + rollback + commit + checkpoint are correct (heap; engine dormant) +$node->safe_psql('postgres', 'CREATE TABLE t(a int)'); +$node->safe_psql('postgres', 'BEGIN; INSERT INTO t VALUES (1),(2); ROLLBACK'); +$node->safe_psql('postgres', 'INSERT INTO t VALUES (3)'); +$node->safe_psql('postgres', 'CHECKPOINT'); +my $cnt = $node->safe_psql('postgres', 'SELECT count(*) FROM t'); +is($cnt, '1', 'rollback discarded uncommitted rows, commit survived'); + +# 3. bgworkers actually present +my $log = slurp_file($node->logfile); +like($log, qr/UNDO worker started/, 'undo worker launched'); +like($log, qr/discard worker started/, 'discard worker launched'); + +# 4. crash + recover cleanly +$node->stop('immediate'); +$node->start; +my $after = $node->safe_psql('postgres', 'SELECT count(*) FROM t'); +is($after, '1', 'data intact after crash recovery'); +my $rlog = slurp_file($node->logfile); +unlike( + $rlog, + qr/PANIC|inconsistent|could not|corrupt/i, + 'clean crash recovery, no PANIC/corruption'); + +# 5. The engine's workers are always running, so each must absorb +# ProcSignalBarriers: one that never does stalls every +# WaitForProcSignalBarrier() caller. DROP DATABASE emits a barrier and waits +# for every process to absorb it, so it is a direct probe. +$node->safe_psql('postgres', 'CREATE DATABASE barrier_probe'); +my ($ret, $stdout, $stderr) = $node->psql( + 'postgres', + 'DROP DATABASE barrier_probe', + timeout => $PostgreSQL::Test::Utils::timeout_default); +is($ret, 0, + 'DROP DATABASE completes: undo workers absorb ProcSignalBarriers'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/072_perbackend_crash_undo.pl b/src/test/recovery/t/072_perbackend_crash_undo.pl new file mode 100644 index 0000000000000..b964995b38f2e --- /dev/null +++ b/src/test/recovery/t/072_perbackend_crash_undo.pl @@ -0,0 +1,130 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# FILEOPS crash-recovery revert via the cluster-wide UNDO engine (common WAL). +# +# FILEOPS is a consumer of the cluster-wide UNDO engine: its structural +# operations (file existence, permissions, directory structure) are +# WAL-durable via RM_FILEOPS_ID forward records (fileops_redo), and each +# reversible op also writes an UNDO record into the common WAL +# (PrepareXactUndoData(UNDO_RMID_FILEOPS,...) / InsertXactUndoData) in the +# same critical section as its forward record. Filesystem structural +# operations are cluster-wide changes whose durability/revert belong in the +# common WAL, replayed like heap's own records -- NOT in any table-AM +# per-backend UNDO log. This test proves that model is crash-safe. +# +# A transaction produces FILEOPS create/chmod (immediate, WAL-flushed), then the +# server is crashed (immediate stop) BEFORE the transaction commits. On +# recovery: +# - fileops_redo() REDOES the forward create/chmod unconditionally, and +# - PerformUndoRecovery() (end of redo) applies the never-committed +# transaction's FILEOPS UNDO chain via the RM_FILEOPS_ID rm_undo +# callbacks (fileops_undo.c), reversing every op: +# - the created file is unlinked (CREATE revert), and +# - the chmod'd file's mode is restored (CHMOD revert, before-state in WAL). +# No per-backend UNDO engine is involved. +# +# FILEOPS only writes WAL at wal_level >= replica (XLogIsNeeded()), so the test +# runs at wal_level = replica, matching any FILEOPS deployment. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_level = replica +)); +$node->start; + +if (!$node->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $datadir = $node->data_dir; +my $create_path = "$datadir/pbu_crash_create.dat"; +my $chmod_path = "$datadir/pbu_crash_chmod.dat"; + +# --------------------------------------------------------------------------- +# Set up a pre-existing file to chmod (this part is committed). 0644 = 420. +# --------------------------------------------------------------------------- +$node->safe_psql('postgres', + q{SELECT test_fileops_create_tempfile('pbu_crash_chmod.dat')}); +$node->safe_psql('postgres', + qq{SELECT test_fileops_chmod('$chmod_path', 420)}); +is( $node->safe_psql( + 'postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}), + '420', + 'baseline mode of pre-existing file is 0644'); + +# --------------------------------------------------------------------------- +# Open a transaction that produces per-backend UNDO (create a new file and +# chmod the existing one) and DO NOT commit it. The FILEOPS forward operations +# execute and are WAL-flushed immediately (the file exists on disk, the mode is +# changed), but the wrapping transaction stays in progress. +# --------------------------------------------------------------------------- +my $bg = $node->background_psql('postgres'); +$bg->query_until( + qr/pbu_ready/, qq( +BEGIN; +SELECT test_fileops_create('$create_path', 384); -- 0600, new file +SELECT test_fileops_chmod('$chmod_path', 448); -- 0700, existing file +\\echo pbu_ready +)); + +# The forward ops are visible on disk while the xact is in progress. +ok(-e $create_path, + 'created file exists on disk before crash (in-progress xact)'); +is( $node->safe_psql( + 'postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}), + '448', + 'chmod applied on disk before crash (in-progress xact)'); + +# --------------------------------------------------------------------------- +# Crash immediately: the transaction never commits. The background psql +# connection dies with the server; abandon it (do not try to drain/finish it, +# which would hang waiting on the dead connection). +# --------------------------------------------------------------------------- +$bg->{run}->kill_kill; +$node->stop('immediate'); +$node->start; + +# --------------------------------------------------------------------------- +# Recovery redoes the forward ops from WAL, then PerformUndoRecovery() reverses +# the never-committed transaction's ops from its common-WAL UNDO chain: +# - the created file is unlinked, and +# - the chmod is restored to the original 0644. +# --------------------------------------------------------------------------- +ok( !-e $create_path, + 'FILEOPS COMMON-WAL CRASH RECOVERY: created file was REVERTED (unlinked) after crash' +); +is( $node->safe_psql( + 'postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}), + '420', + 'FILEOPS COMMON-WAL CRASH RECOVERY: chmod was REVERTED (0700 -> 0644) after crash' +); + +# Recovery must be clean and must NOT have engaged the per-backend UNDO engine +# for FILEOPS: the revert rides the cluster-wide UNDO engine on the common WAL. +my $rlog = slurp_file($node->logfile); +unlike( + $rlog, + qr/per-backend UNDO recovery: rolling back transaction/, + 'FILEOPS revert did NOT go through the per-backend UNDO engine'); +unlike( + $rlog, + qr/PANIC|inconsistent|corrupt/i, + 'clean crash recovery, no PANIC/corruption'); + +# Server healthy afterwards. +is($node->safe_psql('postgres', 'SELECT 1'), + '1', 'server operational after per-backend crash-recovery undo'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/073_perbackend_2pc_rollback.pl b/src/test/recovery/t/073_perbackend_2pc_rollback.pl new file mode 100644 index 0000000000000..c28d64f3a6013 --- /dev/null +++ b/src/test/recovery/t/073_perbackend_2pc_rollback.pl @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# ROLLBACK PREPARED must reverse-apply the prepared transaction's whole +# per-backend UNDO chain, not just its first record, both live and after a +# crash/restart between PREPARE and ROLLBACK PREPARED. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_2pc'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 10 +)); +$node->start; + +# Each AM gets a table whose prepared transaction updates and deletes rows on +# several pages; every row must come back after ROLLBACK PREPARED. +sub setup_table +{ + my ($name, $am) = @_; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $name (id int, v text) USING $am; +INSERT INTO $name SELECT g, 'orig' || g FROM generate_series(1, 2000) g; +)); +} + +sub prepare_changes +{ + my ($name, $gid) = @_; + $node->safe_psql( + 'postgres', qq( +BEGIN; +UPDATE $name SET v = 'upd' WHERE id IN (1, 500, 1000, 1500, 2000); +DELETE FROM $name WHERE id IN (10, 1200); +PREPARE TRANSACTION '$gid'; +)); +} + +sub check_restored +{ + my ($name, $what) = @_; + is( $node->safe_psql( + 'postgres', + "SELECT count(*), count(*) FILTER (WHERE v = 'orig' || id) FROM $name" + ), + '2000|2000', + "$what: every row restored"); +} + +foreach my $am ('flux') +{ + setup_table("live_$am", $am); + prepare_changes("live_$am", "live_$am"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'live_$am'"); + check_restored("live_$am", "$am ROLLBACK PREPARED"); + + setup_table("crash_$am", $am); + prepare_changes("crash_$am", "crash_$am"); +} + +# A prepared transaction with undo in both a permanent and an unlogged undo +# log must have both chains applied by ROLLBACK PREPARED. +foreach my $am ('flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE TABLE mp_$am (id int, v text) USING $am; +CREATE UNLOGGED TABLE mu_$am (id int, v text) USING $am; +INSERT INTO mp_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mu_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +UPDATE mp_$am SET v = 'upd'; +UPDATE mu_$am SET v = 'upd'; +PREPARE TRANSACTION 'mixed_$am'; +ROLLBACK PREPARED 'mixed_$am'; +)); + is( $node->safe_psql( + 'postgres', qq( +SELECT (SELECT count(*) FROM mp_$am WHERE v = 'orig' || id) + + (SELECT count(*) FROM mu_$am WHERE v = 'orig' || id))), + '20', + "$am ROLLBACK PREPARED of permanent and unlogged undo"); +} + +# Crash with both transactions still prepared, then resolve after restart. +$node->stop('immediate'); +$node->start; + +foreach my $am ('flux') +{ + $node->safe_psql('postgres', "ROLLBACK PREPARED 'crash_$am'"); + check_restored("crash_$am", "$am ROLLBACK PREPARED after crash"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/074_perbackend_subxact.pl b/src/test/recovery/t/074_perbackend_subxact.pl new file mode 100644 index 0000000000000..a33472ed3778a --- /dev/null +++ b/src/test/recovery/t/074_perbackend_subxact.pl @@ -0,0 +1,294 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Per-backend UNDO written inside subtransactions must be rolled back by the +# enclosing (sub)transaction: undo of a RELEASEd savepoint belongs to its +# parent, both for a top-level ROLLBACK and for ROLLBACK TO an outer savepoint. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_subxact'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +foreach my $am ('flux') +{ + my $t = "sx_$am"; + + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 20) g; +)); + + my %cases = ( + 'only a released savepoint writes' => qq( +BEGIN; +SAVEPOINT s; +UPDATE $t SET v = 'x' WHERE id = 1; +RELEASE s; +ROLLBACK;), + 'top, released savepoint, top' => qq( +BEGIN; +UPDATE $t SET v = 'x' WHERE id = 2; +SAVEPOINT s; +UPDATE $t SET v = 'x' WHERE id = 3; +RELEASE s; +UPDATE $t SET v = 'x' WHERE id = 4; +ROLLBACK;), + 'released savepoint writes first, then top' => qq( +BEGIN; +SAVEPOINT s; +UPDATE $t SET v = 'x' WHERE id = 5; +RELEASE s; +UPDATE $t SET v = 'x' WHERE id = 6; +ROLLBACK;), + 'nested released savepoints' => qq( +BEGIN; +SAVEPOINT a; +SAVEPOINT b; +UPDATE $t SET v = 'x' WHERE id = 7; +RELEASE b; +UPDATE $t SET v = 'x' WHERE id = 8; +RELEASE a; +ROLLBACK;), + 'ROLLBACK TO outer after RELEASE inner' => qq( +BEGIN; +SAVEPOINT p; +SAVEPOINT c; +UPDATE $t SET v = 'x' WHERE id = 9; +RELEASE c; +ROLLBACK TO p; +COMMIT;), + 'PL/pgSQL exception block that completes' => qq( +BEGIN; +DO \$\$ BEGIN + BEGIN + UPDATE $t SET v = 'x' WHERE id = 10; + EXCEPTION WHEN others THEN NULL; + END; +END \$\$; +ROLLBACK;),); + + foreach my $name (sort keys %cases) + { + my ($ret, $out, $err) = $node->psql('postgres', $cases{$name}); + is($ret, 0, "$am: $name: no error") or diag($err); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM $t WHERE v = 'orig' || id"), + '20', + "$am: $name: every row restored"); + } +} + +# Undo that ROLLBACK TO SAVEPOINT already applied must never be applied +# again. Once the subtransaction has rolled back and released its row locks, +# another transaction may update and commit the same row; re-applying the +# subtransaction's before-image would silently overwrite that committed value. +# Three variants cover the three ways the rest of the chain gets applied: the +# aborting backend itself, an undo apply worker, and crash recovery. +sub n1_setup +{ + my ($t, $am) = @_; + $node->safe_psql( + 'postgres', qq( +DROP TABLE IF EXISTS $t; +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 3) g; +)); +} + +# T1 writes, rolls back a savepoint that wrote row 2, and keeps running while +# T2 updates row 2 and commits. Returns T1's session. +sub n1_interleave +{ + my ($t) = @_; + my $t1 = $node->background_psql('postgres'); + $t1->query_safe( + qq(BEGIN; +UPDATE $t SET v = 't1' WHERE id = 1; +SAVEPOINT s; +UPDATE $t SET v = 't1-sub' WHERE id = 2; +ROLLBACK TO s; +RELEASE s;)); + $node->safe_psql('postgres', "UPDATE $t SET v = 't2' WHERE id = 2"); + return $t1; +} + +sub n1_check +{ + my ($t, $what) = @_; + is( $node->safe_psql( + 'postgres', "SELECT string_agg(v, ',' ORDER BY id) FROM $t"), + 'orig1,t2,orig3', + $what); +} + +foreach my $am ('flux') +{ + my $t = "n1_$am"; + + # Inline: T1 writes again after ROLLBACK TO, then rolls back. + n1_setup($t, $am); + my $t1 = n1_interleave($t); + $t1->query_safe("UPDATE $t SET v = 't1' WHERE id = 3; ROLLBACK;"); + $t1->quit; + n1_check($t, + "$am: ROLLBACK keeps a committed update made after ROLLBACK TO"); + + # Worker: every rollback request goes to an undo apply worker, which + # derives the end of the chain from the undo log's insert pointer. + $node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); + $node->reload; + n1_setup($t, $am); + $t1 = n1_interleave($t); + $t1->query_safe('ROLLBACK'); + $t1->quit; + $node->poll_query_until('postgres', + "SELECT v = 'orig1' FROM $t WHERE id = 1") + or diag("$am: worker never rolled back T1"); + n1_check($t, + "$am: worker rollback keeps a committed update made after ROLLBACK TO" + ); + $node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); + $node->reload; + + # Crash recovery: T1 is still running when the server crashes. + n1_setup($t, $am); + $t1 = n1_interleave($t); + $node->stop('immediate'); + $t1->{run}->kill_kill; + $node->start; + $node->poll_query_until('postgres', + "SELECT v = 'orig1' FROM $t WHERE id = 1") + or diag("$am: crashed T1 was never rolled back"); + n1_check($t, + "$am: crash recovery keeps a committed update made after ROLLBACK TO" + ); +} + +# A subtransaction that writes tables of several persistence levels has an +# undo chain in each level's undo log. ROLLBACK TO SAVEPOINT must apply all +# of them, whichever level it wrote first. Nothing of them may be left for +# later: a top-level ROLLBACK walks only the parent's own records, and after +# a COMMIT the undo is discarded, so a change left in place survives or the +# row is lost. +my @mixed; +foreach my $am ('flux') +{ + foreach my $order ('p u t', 'u t p', 't p u') + { + foreach my $end ('COMMIT', 'ROLLBACK') + { + my $sfx = "${am}_" . join('', split(/ /, $order)) . "_$end"; + my $upd = join("\n", + map { "UPDATE m${_}_$sfx SET v = 'sub';" } + split(/ /, $order)); + my $cnt = join( + " || ',' || ", + map { + "(SELECT count(*) FROM m${_}_$sfx WHERE v = 'orig' || id)" + } ('p', 'u', 't')); + my $out = $node->safe_psql( + 'postgres', qq( +CREATE TABLE mp_$sfx (id int, v text) USING $am; +CREATE UNLOGGED TABLE mu_$sfx (id int, v text) USING $am; +CREATE TEMP TABLE mt_$sfx (id int, v text) USING $am; +INSERT INTO mp_$sfx SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mu_$sfx SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mt_$sfx SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +SAVEPOINT s; +$upd +ROLLBACK TO s; +SELECT $cnt; +$end; +SELECT $cnt; +)); + is($out, "10,10,10\n10,10,10", + "$am: ROLLBACK TO restores every level (order $order), then $end" + ); + push @mixed, $sfx; + } + } +} + +# A savepoint that updates rows and then truncates or drops the table. When +# the subtransaction's undo is first applied, the relation still looks +# dropped or empty, so the records cannot be applied then. They must be +# applied once the subtransaction's catalog changes are rolled back, before +# the transaction goes on: it commits with every committed row intact. The +# variants reach that through ROLLBACK TO, ROLLBACK TO after an error, +# ROLLBACK TO an outer savepoint, and a PL/pgSQL exception block. +my %sd = ( + rt => sub { + my ($t, $op) = @_; + "SAVEPOINT s;\nUPDATE $t SET v = 'sub';\n$op $t;\nROLLBACK TO s;"; + }, + rterr => sub { + my ($t, $op) = @_; + "SAVEPOINT s;\nUPDATE $t SET v = 'sub';\n$op $t;\nSELECT 1/0;\nROLLBACK TO s;"; + }, + outer => sub { + my ($t, $op) = @_; + "SAVEPOINT a;\nSAVEPOINT b;\nUPDATE $t SET v = 'sub';\n$op $t;\nROLLBACK TO a;"; + }, + plpgsql => sub { + my ($t, $op) = @_; + "DO \$\$ BEGIN\n BEGIN\n UPDATE $t SET v = 'sub';\n $op $t;\n" + . " RAISE EXCEPTION 'x';\n EXCEPTION WHEN raise_exception THEN NULL;\n" + . " END;\nEND \$\$;"; + },); +foreach my $am ('flux') +{ + foreach my $op ('TRUNCATE', 'DROP TABLE') + { + foreach my $how (sort keys %sd) + { + my $t = "sd_${am}_" . lc((split(/ /, $op))[0]) . "_$how"; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my ($ret, $out, $err) = $node->psql( + 'postgres', qq( +BEGIN; +@{[ $sd{$how}->($t, $op) ]} +SELECT count(*) FROM $t WHERE v = 'orig' || id; +COMMIT; +SELECT count(*) FROM $t WHERE v = 'orig' || id; +), + on_error_stop => 0); + is($out, "10\n10", + "$am: $op in a savepoint ($how): rows restored") + or diag($err); + unlike($err, qr/incomplete/, + "$am: $op in a savepoint ($how): commits"); + push @mixed, $t; + } + } +} + +# Committed or rolled back, the undo is gone after a restart; the rows must +# not depend on it. +$node->restart; +foreach my $sfx (@mixed) +{ + my $q = + $sfx =~ /^sd_/ + ? "SELECT count(*) FROM $sfx WHERE v = 'orig' || id" + : qq(SELECT (SELECT count(*) FROM mp_$sfx WHERE v = 'orig' || id) || ',' || + (SELECT count(*) FROM mu_$sfx WHERE v = 'orig' || id)); + is( $node->safe_psql('postgres', $q), + $sfx =~ /^sd_/ ? '10' : '10,10', + "$sfx: rows intact after restart"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/075_perbackend_nonpermanent.pl b/src/test/recovery/t/075_perbackend_nonpermanent.pl new file mode 100644 index 0000000000000..fcd1e843a1952 --- /dev/null +++ b/src/test/recovery/t/075_perbackend_nonpermanent.pl @@ -0,0 +1,282 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Per-backend UNDO for UNLOGGED and TEMP tables. Their undo lives in undo +# logs whose contents are never WAL-logged, so redo must not try to patch +# those logs; temp undo must be applied by its own backend and discarded once +# its transaction has ended. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_nonperm'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +# A rollback on an unlogged table marks its undo as applied. That must not +# leave anything in the WAL that redo cannot replay: the undo pages it would +# touch were never WAL-logged. +foreach my $am ('flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE UNLOGGED TABLE u_$am (id int, v text) USING $am; +INSERT INTO u_$am SELECT g, 'orig' || g FROM generate_series(1, 100) g; +BEGIN; +UPDATE u_$am SET v = 'upd' WHERE id <= 10; +ROLLBACK; +)); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM u_$am WHERE v = 'orig' || id"), + '100', + "$am unlogged: rollback restored every row"); +} + +# pg_stat_get_undo_logs() shows the per-backend undo logs: the rollbacks +# above wrote to an unlogged one. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_stat_get_undo_logs() + WHERE persistence = 'unlogged'"), + 't', + 'pg_stat_get_undo_logs shows the unlogged undo log'); + +# The numbers of the unlogged and temporary undo logs, as a SQL list. Their +# pages are the undo pseudo-relations with these relfilenumbers. +sub nonpermanent_lognos +{ + return $node->safe_psql( + 'postgres', q( +SELECT coalesce(string_agg(logno::text, ','), '0') FROM pg_stat_get_undo_logs() + WHERE persistence <> 'permanent')); +} + +# WAL records that discard or extend an unlogged or temporary undo log since +# $from. Those logs are not WAL-logged: their contents never reach the WAL, +# crash recovery empties them, and a standby replaying their discards and +# extensions would create, recycle and remove segments of logs it never has. +sub nonpermanent_undolog_wal +{ + my ($from) = @_; + my $lognos = nonpermanent_lognos(); + return $node->safe_psql( + 'postgres', qq( +SELECT count(*) FROM pg_get_wal_records_info('$from', pg_current_wal_lsn()) + WHERE resource_manager = 'UndoLog' AND record_type IN ('DISCARD', 'EXTEND') + AND split_part(description, ' ', 2)::int IN ($lognos))); +} + +# Has the discard worker discarded all of the unlogged undo? +my $unlogged_discarded = q( +SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'unlogged'); + +# Undo pages of unlogged and temp tables are not written at a checkpoint, as +# for any unlogged page; only permanent undo must be. +$node->safe_psql('postgres', 'CREATE EXTENSION pg_buffercache'); +$node->safe_psql('postgres', 'CREATE EXTENSION pg_walinspect'); +$node->safe_psql( + 'postgres', q( +CREATE TEMP TABLE bt (id int, v text) USING flux; +INSERT INTO bt SELECT g, 'orig' || g FROM generate_series(1, 100) g; +UPDATE bt SET v = 'upd'; +UPDATE u_flux SET v = 'upd2'; +UPDATE u_flux SET v = 'orig' || id; +CHECKPOINT; +)); +my $lognos = nonpermanent_lognos(); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_buffercache + WHERE reldatabase = 9 AND relfilenode IN ($lognos) AND isdirty"), + 't', + 'checkpoint leaves unlogged and temp undo pages dirty'); + +# Write committed unlogged undo after that checkpoint too, so the crash +# loses undo the discard worker already knew about, and leave an +# unlogged-table transaction in flight, so the crash finds undo for it whose +# pages never reached disk. +my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +$node->safe_psql('postgres', "UPDATE u_flux SET v = 'orig' || id"); +ok($node->poll_query_until('postgres', $unlogged_discarded), + 'discard worker discards the unlogged undo'); +is(nonpermanent_undolog_wal($lsn), '0', + 'unlogged undo discards write no WAL'); +my $inflight = $node->background_psql('postgres'); +$inflight->query_safe("BEGIN; UPDATE u_flux SET v = 'inflight';"); + +$node->stop('immediate'); +$inflight->{run}->kill_kill; +ok($node->start(fail_ok => 1), + 'server restarts after a crash following unlogged rollbacks'); + +# The unlogged undo written before the crash, including the in-flight +# transaction's, is garbage now, like the unlogged tables themselves: crash +# recovery must not walk it, and the discard worker must not trip over it. +my $rlog = slurp_file($node->logfile); +unlike( + $rlog, + qr/per-backend UNDO recovery: rolling back/, + 'crash recovery does not walk lost unlogged undo'); +foreach my $am ('flux') +{ + is($node->safe_psql('postgres', "SELECT count(*) FROM u_$am"), + '0', "$am unlogged: table reset by crash recovery"); + $node->safe_psql( + 'postgres', qq( +INSERT INTO u_$am SELECT g, 'orig' || g FROM generate_series(1, 100) g; +BEGIN; +UPDATE u_$am SET v = 'upd' WHERE id <= 10; +ROLLBACK; +)); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM u_$am WHERE v = 'orig' || id"), + '100', + "$am unlogged: rollback after crash recovery restored every row"); +} + +# The discard worker skips an undo log with nothing to discard, so it meets +# a reset unlogged log only once undo has been written to it again, as just +# now. Wait until it has discarded that. +ok( $node->poll_query_until('postgres', $unlogged_discarded), + 'discard worker discards unlogged undo written after the reset'); +unlike( + slurp_file($node->logfile), + qr/cannot move discard pointer backwards|TRAP|PANIC/, + 'discard worker copes with reset unlogged undo logs'); + +# A rollback handed to an undo apply worker that cannot apply every record +# must be retried until it is, not left marked in progress forever. +SKIP: +{ + skip 'Injection points not supported by this build', 3 + if $ENV{enable_injection_points} ne 'yes'; + + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + # The worker requeueing the request logs "Undo action pushed(error)". + $node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); + $node->safe_psql('postgres', + 'ALTER SYSTEM SET log_min_messages = debug1'); + $node->reload; + my $off = -s $node->logfile; + $node->safe_psql( + 'postgres', q( +CREATE TABLE w_flux (id int, v text) USING flux; +INSERT INTO w_flux SELECT g, 'orig' || g FROM generate_series(1, 10) g; +SELECT injection_points_attach('pbu-undo-apply-skip', 'notice'); +BEGIN; +UPDATE w_flux SET v = 'upd'; +ROLLBACK; +)); + $node->wait_for_log(qr/Undo action pushed\(error\)/, $off); + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-apply-skip')"); + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 10 FROM w_flux WHERE v = 'orig' || id"), + 'incomplete worker rollback is retried and applied'); + + # Temp undo cannot be applied by a worker, which cannot see the table. + foreach my $am ('flux') + { + is( $node->safe_psql( + 'postgres', qq( +CREATE TEMP TABLE tw_$am (id int, v text) USING $am; +INSERT INTO tw_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +UPDATE tw_$am SET v = 'upd'; +ROLLBACK; +SELECT count(*) FROM tw_$am WHERE v = 'orig' || id; +)), + '10', + "$am temp: rollback is applied by the backend, not a worker"); + } + $node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); + $node->safe_psql('postgres', 'ALTER SYSTEM RESET log_min_messages'); + $node->reload; +} + +# One transaction that writes tables of several persistence levels has an +# undo chain in each level's undo log; a rollback must apply all of them, +# whether the backend applies them itself or hands them to an undo apply +# worker (undo_rollback_overflow_size = 0; temp undo always stays in the +# backend). Two chains of one transaction are also two rollback requests with +# the same xid, which the request table must keep apart. +foreach my $overflow (16, 0) +{ + $node->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = $overflow"); + $node->reload; + foreach my $am ('flux') + { + my $out = $node->safe_psql( + 'postgres', qq( +CREATE TABLE mp_$am (id int, v text) USING $am; +CREATE UNLOGGED TABLE mu_$am (id int, v text) USING $am; +CREATE TEMP TABLE mt_$am (id int, v text) USING $am; +INSERT INTO mp_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mu_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mt_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +UPDATE mp_$am SET v = 'upd'; +UPDATE mu_$am SET v = 'upd'; +UPDATE mt_$am SET v = 'upd'; +ROLLBACK; +SELECT count(*) FROM mt_$am WHERE v = 'orig' || id; +)); + is($out, '10', "$am mixed, overflow $overflow: temp rows restored"); + ok( $node->poll_query_until( + 'postgres', qq( +SELECT (SELECT count(*) FROM mp_$am WHERE v = 'orig' || id) + + (SELECT count(*) FROM mu_$am WHERE v = 'orig' || id) = 20)), + "$am mixed, overflow $overflow: permanent and unlogged rows restored" + ); + $node->safe_psql('postgres', "DROP TABLE mp_$am, mu_$am"); + } +} +$node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); +$node->reload; + +# Temp undo is private to its backend and needed only until the transaction +# that wrote it ends, so a session churning a temp table must not accumulate +# undo segments: each transaction's undo is about 0.3 MB, 30 of them would +# need 10 one-megabyte segments if nothing were ever discarded. +sub undo_segments +{ + opendir(my $dh, $node->data_dir . '/base/undo') or return 0; + my $n = grep { !/^\./ } readdir($dh); + closedir($dh); + return $n; +} + +# Its discards, at the first temp write of every transaction, and the +# segments added and removed for it, write no WAL. +my $temp_lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +foreach my $am ('flux') +{ + my $before = undo_segments(); + my $out = $node->safe_psql( + 'postgres', qq( +CREATE TEMP TABLE t_$am (id int, v text) USING $am; +INSERT INTO t_$am SELECT g, repeat('x', 200) FROM generate_series(1, 1000) g; +SELECT format('UPDATE t_$am SET v = repeat(%L, 200)', chr(97 + i % 20)) + FROM generate_series(1, 30) i \\gexec +BEGIN; +UPDATE t_$am SET v = 'rolled back' WHERE id <= 10; +ROLLBACK; +SELECT count(*) FROM t_$am WHERE v = repeat(chr(97 + 30 % 20), 200); +)); + is($out, '1000', "$am temp: rollback after churn restored every row"); + cmp_ok(undo_segments() - $before, + '<=', 3, "$am temp: undo is discarded as transactions end"); +} +is(nonpermanent_undolog_wal($temp_lsn), + '0', 'temp undo discards and segment changes write no WAL'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/076_flux_deferred_rollback.pl b/src/test/recovery/t/076_flux_deferred_rollback.pl new file mode 100644 index 0000000000000..d410ffb6cb2db --- /dev/null +++ b/src/test/recovery/t/076_flux_deferred_rollback.pl @@ -0,0 +1,287 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX visibility while a rollback is still pending. +# +# FLUX updates in place: the page holds the newest version and the older +# ones live in the per-backend UNDO log. When a transaction aborts, its +# in-place images stay on the page until the UNDO is applied. Small live +# rollbacks apply inline, but a rollback handed to an undo apply worker +# (undo_rollback_overflow_size) and every crash loser are reverted later. +# Until then, readers must treat the aborted writer as invisible and walk +# past its versions to the last committed one. +# +# We keep the undo apply worker from ever starting: max_worker_processes is +# exactly the number of static background workers (discard worker, UNDO +# worker, undo worker launcher), so the launcher can never register one. +# That gate depends on the static worker count, so the test checks it +# directly: no "too many background workers" at startup, no undo apply worker +# in pg_stat_activity, and the aborted images still on the page. A change in +# the worker count makes the test fail instead of passing without testing +# anything. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_deferred'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +# discard worker + UNDO worker + undo worker launcher: no room for an +# undo apply worker +max_worker_processes = 3 +# every rollback request goes to the (starved) undo apply worker +undo_rollback_overflow_size = 0 +)); +$node->start; + +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +unlike( + slurp_file($node->logfile), + qr/too many background workers/, + 'every static background worker registered'); + +# Row 1's aborted value is 16908801 = 0x01020201, whose bytes read the same +# in either byte order, so the raw page can be searched for it. +# The gate holds: no undo apply worker ran, and the aborted image is still +# on the page of table $tab. +sub gate_holds +{ + my ($label, $tab) = @_; + + is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + "$label: no undo apply worker"); + is( $node->safe_psql( + 'postgres', + "SELECT position('\\x01020201'::bytea IN get_raw_page('$tab', 0)) > 0" + ), + 't', + "$label: aborted image still on the page"); +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 10) g; +UPDATE f SET v = 31 WHERE id = 3; +}); + +my $seq = 'SET enable_indexscan = off; SET enable_bitmapscan = off; '; +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; '; + +sub check_rows +{ + my ($label, %expect) = @_; + + is($node->safe_psql('postgres', $seq . 'SELECT count(*) FROM f'), + '10', "$label: seqscan count unchanged"); + foreach my $id (sort keys %expect) + { + is( $node->safe_psql( + 'postgres', $seq . "SELECT v FROM f WHERE id = $id"), + $expect{$id}, + "$label: seqscan sees committed value of row $id"); + is( $node->safe_psql( + 'postgres', $idx . "SELECT v FROM f WHERE id = $id"), + $expect{$id}, + "$label: index scan sees committed value of row $id"); + } +} + +# --------------------------------------------------------------------------- +# Live aborts whose rollback is deferred to the undo apply worker. +# --------------------------------------------------------------------------- +$node->safe_psql( + 'postgres', q{ +BEGIN; UPDATE f SET v = 16908801 WHERE id = 1; ROLLBACK; +BEGIN; DELETE FROM f WHERE id = 2; ROLLBACK; +BEGIN; UPDATE f SET v = 32 WHERE id = 3; UPDATE f SET v = 33 WHERE id = 3; ROLLBACK; +}); + +gate_holds('deferred rollback', 'f'); +check_rows('deferred rollback', 1 => '10', 2 => '20', 3 => '31'); + +# --------------------------------------------------------------------------- +# Crash with an in-place UPDATE in flight. +# --------------------------------------------------------------------------- +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('UPDATE f SET v = 4000 WHERE id = 4'); + +# Write the loser's page images out, so recovery does not have to redo the +# in-place UPDATEs (the CAS redo path is tested separately) and the pages +# hold the aborted and in-flight images at restart. +$node->safe_psql('postgres', 'CHECKPOINT'); + +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +gate_holds('crash loser', 'f'); +check_rows('crash loser', 1 => '10', 2 => '20', 3 => '31', 4 => '40'); + +# --------------------------------------------------------------------------- +# Crash with an in-place UPDATE in flight, and no checkpoint: recovery has to +# redo the UPDATE (same-size CAS fast path), including the version-chain +# pointer it stamped. Row 5 has never been updated, so its old t_verptr is +# invalid. +# --------------------------------------------------------------------------- +$node->append_conf('postgresql.conf', 'wal_consistency_checking = flux'); +$node->restart; + +# Take the page's first full-page image since the checkpoint on another +# row. Otherwise row 5's UPDATE record would carry that FPI, redo would +# restore the page from it, and the record's byte-diff (the part that was +# missing t_verptr) would never be replayed. +$node->safe_psql('postgres', 'UPDATE f SET v = 61 WHERE id = 6'); + +$bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('UPDATE f SET v = 5000 WHERE id = 5'); + +# A commit flushes the WAL written before it, including the UPDATE above. +$node->safe_psql('postgres', 'CREATE TABLE flush_marker (x int)'); + +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +check_rows('crash loser, redo', 4 => '40', 5 => '50', 6 => '61'); +unlike( + slurp_file($node->logfile), + qr/inconsistent page found/, + 'FLUX redo reproduces the primary page'); + +# --------------------------------------------------------------------------- +# Writers that meet an aborted, not-yet-reverted version must not depend on +# the undo apply worker: they revert that version themselves ("helping") and +# then proceed against the last committed version. +# --------------------------------------------------------------------------- +$node->safe_psql( + 'postgres', q{ +CREATE TABLE h (id int PRIMARY KEY, v int, k int) USING flux; +CREATE INDEX h_k ON h (k); +INSERT INTO h SELECT g, g * 10, g FROM generate_series(1, 8) g; +BEGIN; +UPDATE h SET v = 16908801 WHERE id = 1; +UPDATE h SET v = v + 1000 WHERE id IN (2, 3, 4); +UPDATE h SET v = v + 1000 WHERE id = 4; +UPDATE h SET k = k + 100 WHERE id = 5; +DELETE FROM h WHERE id IN (6, 7); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + 'helping: no undo apply worker'); +gate_holds('helping', 'h'); + +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE id = 1 RETURNING v'), + '11', + 'helping: UPDATE over an aborted in-place UPDATE'); +is($node->safe_psql('postgres', 'DELETE FROM h WHERE id = 2 RETURNING v'), + '20', 'helping: DELETE over an aborted in-place UPDATE'); +is( $node->safe_psql( + 'postgres', 'BEGIN; SELECT v FROM h WHERE id = 3 FOR UPDATE; COMMIT;' + ), + '30', + 'helping: SELECT FOR UPDATE over an aborted in-place UPDATE'); +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE id = 4 RETURNING v'), + '41', + 'helping: UPDATE over a chain of two aborted in-place UPDATEs'); +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE k = 5 RETURNING v'), + '51', + 'helping: UPDATE through the index over an aborted key UPDATE'); +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE id = 6 RETURNING v'), + '61', + 'helping: UPDATE over an aborted DELETE'); +is($node->safe_psql('postgres', 'DELETE FROM h WHERE id = 7 RETURNING v'), + '70', 'helping: DELETE over an aborted DELETE'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM h WHERE k = 105'), + '0', + 'helping: the aborted key UPDATE left no live index entry'); + +# Now let the undo apply worker run. It must not clobber what the writers +# committed on top of the versions they reverted: each record a writer +# already reverted must be skipped by the idempotency key (t_verptr no +# longer points at it). The log at DEBUG2 counts those skips. +$node->append_conf('postgresql.conf', + "max_worker_processes = 8\nlog_min_messages = debug2"); +my $log_offset = -s $node->logfile; +$node->restart; + +# The worker restores table f's deferred rollbacks: wait until row 1's +# aborted image is gone from the page. +$node->poll_query_until('postgres', + q{SELECT position('\\x01020201'::bytea IN get_raw_page('f', 0)) = 0}) + or die 'undo apply worker did not run'; +$node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'} +) or die 'undo apply worker did not finish'; + +# The worker did run (table f's images are restored, checked above), and for +# table h it found that writers had already reverted the aborted versions: +# it skipped those records by the idempotency key. +# Writers reverted 8 of h's records (rows 1-7, row 4 twice); f's records +# were all still current. So the worker skips at least those 8 (it may +# meet a record twice if a request is retried). +my $wlog = substr(slurp_file($node->logfile), $log_offset); +my $skips = () = + $wlog =~ + /undo apply worker.*FLUX UNDO: record at \d+ for .* was already undone/g; +cmp_ok($skips, '>=', 8, + 'helping: the undo apply worker skipped every record a writer reverted'); +unlike($wlog, qr/cannot restore/, 'helping: no failed restore'); +$node->append_conf('postgresql.conf', 'log_min_messages = warning'); +$node->reload; + +is( $node->safe_psql( + 'postgres', + $seq + . q{SELECT string_agg(id || ':' || v || ':' || k, ',' ORDER BY id) FROM h} + ), + '1:11:1,3:30:3,4:41:4,5:51:5,6:61:6,8:80:8', + 'helping: undo apply worker left the helped rows alone'); +is( $node->safe_psql( + 'postgres', + $idx + . q{SELECT string_agg(id::text, ',' ORDER BY id) FROM h WHERE k < 100} + ), + '1,3,4,5,6,8', + 'helping: index agrees with the table'); +check_rows( + 'after undo apply worker', + 1 => '10', + 2 => '20', + 3 => '31', + 4 => '40', + 5 => '50', + 6 => '61'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/077_flux_undo_idempotency.pl b/src/test/recovery/t/077_flux_undo_idempotency.pl new file mode 100644 index 0000000000000..83436eb8185a0 --- /dev/null +++ b/src/test/recovery/t/077_flux_undo_idempotency.pl @@ -0,0 +1,56 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX UNDO apply must be idempotent: a record whose change was already +# undone (by ROLLBACK TO SAVEPOINT, or by a writer that reverted it) must not +# be applied again on top of a version some other transaction committed +# since. flux_undo_apply checks that the tuple still carries the version the +# record produced (t_verptr == the record's pointer for UPDATE, or a DELETE +# mark by the record's writer) and otherwise leaves the tuple alone. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_idem'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 6) g; +}); + +# T1 changes r2 (UPDATE) and r5 (DELETE) in a savepoint and rolls the +# savepoint back; T2 then commits its own change to both rows; T1 writes +# again and rolls back. The top-level rollback walks every record T1 wrote, +# including the ones ROLLBACK TO SAVEPOINT already undid. +my $t1 = $node->background_psql('postgres'); +$t1->query_safe('BEGIN'); +$t1->query_safe('UPDATE f SET v = 11 WHERE id = 1'); +$t1->query_safe('SAVEPOINT s'); +$t1->query_safe('UPDATE f SET v = 22 WHERE id = 2'); +$t1->query_safe('DELETE FROM f WHERE id = 5'); +$t1->query_safe('ROLLBACK TO s'); + +$node->safe_psql('postgres', 'UPDATE f SET v = 200 WHERE id = 2'); +$node->safe_psql('postgres', 'UPDATE f SET v = 500 WHERE id = 5'); + +# Write again at the top level, so the top-level chain starts after the +# savepoint's records. +$t1->query_safe('RELEASE s'); +$t1->query_safe('UPDATE f SET v = 33 WHERE id = 3'); +$t1->query_safe('ROLLBACK'); +$t1->quit; + +is( $node->safe_psql( + 'postgres', + 'SELECT string_agg(id || \':\' || v, \',\' ORDER BY id) FROM f'), + '1:10,2:200,3:30,4:40,5:500,6:60', + 'top-level rollback did not re-apply records undone by ROLLBACK TO'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/078_flux_undo_restore_fail.pl b/src/test/recovery/t/078_flux_undo_restore_fail.pl new file mode 100644 index 0000000000000..3cc741ccc3708 --- /dev/null +++ b/src/test/recovery/t/078_flux_undo_restore_fail.pl @@ -0,0 +1,139 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX UNDO apply that cannot restore a before-image must not report +# success. +# +# An in-place UPDATE never shrinks a tuple's slot, so a current record's +# before-image always fits. If a restore cannot be done anyway, +# flux_undo_apply must return UNDO_APPLY_ERROR, not SUCCESS: the rollback +# request stays, and is retried once the cause is gone. Before this, the +# restore was skipped with a WARNING and reported as done, so the aborted +# image was left on the page for good. The injection point +# flux-undo-restore-no-room simulates the failure. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_x1'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +)); +$node->start; + +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 4) g; +SELECT injection_points_attach('flux-undo-restore-no-room', 'notice'); +}); + +# 0x01020201 reads the same in either byte order, so the raw page can be +# searched for it. +my $on_page = + q{SELECT position('\x01020201'::bytea IN get_raw_page('f', 0)) > 0}; + +# The rollback cannot restore the row. It must not be marked done: the +# aborted image is still on the page, the transaction header still says +# "not applied", and the request is still queued. +my $start_lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +$node->safe_psql('postgres', + 'BEGIN; UPDATE f SET v = 16908801 WHERE id = 1; ROLLBACK;'); +$node->safe_psql('postgres', 'CHECKPOINT'); # flush the rollback's WAL +my $end_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +is($node->safe_psql('postgres', $on_page), + 't', 'failed restore left the aborted image on the page'); + +# A small rollback is applied inline by the aborting backend; no undo apply +# worker took part in it. +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + 'rollback was applied inline, not by a worker'); + +# Marking the rollback complete writes XLOG_UNDO_APPLY_PROGRESS. It must not +# be written for a rollback that left a record unapplied. +my ($stdout, $stderr); +IPC::Run::run [ + 'pg_waldump', + '--path' => $node->data_dir . '/pg_wal', + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'UndoAction' + ], + '>' => \$stdout, + '2>' => \$stderr; +like($stdout . $stderr, qr/rmgr: UndoAction|^$/s, 'pg_waldump ran'); +unlike($stdout, qr/APPLY_PROGRESS/, + 'rollback with an unapplied record is not marked complete'); + +# Readers are not affected: the aborted writer is invisible. +is($node->safe_psql('postgres', 'SELECT v FROM f WHERE id = 1'), + '10', 'reader sees the committed value'); + +# Once the cause is gone, the outstanding rollback is completed. A writer +# running into the row reverts it itself (helping) and builds on the +# committed value; the queued request then finds nothing left to do. +$node->safe_psql('postgres', + "SELECT injection_points_detach('flux-undo-restore-no-room')"); +is( $node->safe_psql( + 'postgres', 'UPDATE f SET v = v + 1 WHERE id = 1 RETURNING v'), + '11', + 'writer builds on the committed value'); +is($node->safe_psql('postgres', $on_page), 'f', 'aborted image is gone'); + +# The same failure inside ROLLBACK TO SAVEPOINT. The record must not be +# marked applied: it stays due, so the transaction refuses to commit (which +# would keep the aborted image) and its rollback applies the record. +$node->safe_psql('postgres', + "SELECT injection_points_attach('flux-undo-restore-no-room', 'notice')"); +my ($ret, $out, $err) = $node->psql( + 'postgres', q{ +BEGIN; +SAVEPOINT s; +UPDATE f SET v = 16908801 WHERE id = 2; +ROLLBACK TO s; +SELECT injection_points_detach('flux-undo-restore-no-room'); +COMMIT; +}); +like( + $err, + qr/rollback to a savepoint was incomplete/, + 'commit after an incomplete ROLLBACK TO is refused'); +is($node->safe_psql('postgres', $on_page), + 'f', 'the rollback that followed applied the unapplied record'); +is( $node->safe_psql( + 'postgres', 'UPDATE f SET v = v + 1 WHERE id = 2 RETURNING v'), + '21', + 'writer builds on the committed value'); + +$node->restart; +is( $node->safe_psql( + 'postgres', + 'SELECT string_agg(id || \':\' || v, \',\' ORDER BY id) FROM f'), + '1:11,2:21,3:30,4:40', + 'no later apply clobbered the committed value'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/079_flux_vacuum_aborted_insert.pl b/src/test/recovery/t/079_flux_vacuum_aborted_insert.pl new file mode 100644 index 0000000000000..5278fd05f510f --- /dev/null +++ b/src/test/recovery/t/079_flux_vacuum_aborted_insert.pl @@ -0,0 +1,111 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX VACUUM reclaims rows of aborted INSERTs. +# +# INSERT writes no UNDO; an aborted inserter's rows are hidden by CLOG +# alone and nothing marks them DELETED. VACUUM used to count them as live +# and never removed them, so the space was lost for good. They must be +# reclaimed like heap reclaims them: dead once the inserter is known +# aborted. Rows of an in-place UPDATE or DELETE whose writer aborted are +# not dead (the row lives on at its before-image) and must stay. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_x2'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE a (id int PRIMARY KEY, pad text) USING flux; +INSERT INTO a SELECT g, 'live' FROM generate_series(1, 10) g; +-- aborted top-level insert +BEGIN; +INSERT INTO a SELECT g, repeat('x', 200) FROM generate_series(1001, 2000) g; +ROLLBACK; +-- savepoint-aborted insert in a committed transaction +BEGIN; +INSERT INTO a VALUES (11, 'keep'); +SAVEPOINT s; +INSERT INTO a SELECT g, repeat('y', 200) FROM generate_series(2001, 3000) g; +ROLLBACK TO s; +COMMIT; +}); + +my $normal_items = q{ +SELECT count(*) +FROM generate_series(0, pg_relation_size('a') / 8192 - 1) b, + heap_page_items(get_raw_page('a', b::int)) +WHERE lp_flags = 1}; + +is($node->safe_psql('postgres', $normal_items), + '2011', 'aborted rows are on the pages before VACUUM'); + +$node->safe_psql('postgres', 'VACUUM a'); + +is($node->safe_psql('postgres', $normal_items), + '11', 'VACUUM removed the aborted INSERTs'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM a'), + '11', 'committed rows are all still there'); +is( $node->safe_psql( + 'postgres', + 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SELECT count(*) FROM a WHERE id > 1000'), + '0', + 'no index entry leads to a removed row'); + +# The space is reused: re-inserting the same volume does not grow the table. +my $pages = $node->safe_psql('postgres', "SELECT pg_relation_size('a')"); +$node->safe_psql('postgres', + "INSERT INTO a SELECT g, repeat('z', 200) FROM generate_series(1001, 2000) g" +); +is($node->safe_psql('postgres', "SELECT pg_relation_size('a')"), + $pages, 'freed space is reused'); + +# An aborted UPDATE / DELETE of a committed row must survive VACUUM. +$node->safe_psql( + 'postgres', q{ +BEGIN; UPDATE a SET pad = 'aborted' WHERE id = 1; DELETE FROM a WHERE id = 2; ROLLBACK; +VACUUM a; +}); +is( $node->safe_psql( + 'postgres', + 'SELECT string_agg(id || pad, \',\' ORDER BY id) FROM a WHERE id <= 2' + ), + '1live,2live', + 'VACUUM kept rows whose UPDATE / DELETE aborted'); + +# VACUUM truncates the empty pages it leaves at the end of the table, as heap +# does: all of them, not just the last one. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE tr (id int, pad text) USING flux; +INSERT INTO tr VALUES (1, 'first'); +BEGIN; +INSERT INTO tr SELECT g, repeat('t', 200) FROM generate_series(2, 20000) g; +ROLLBACK; +}); +cmp_ok($node->safe_psql('postgres', "SELECT pg_relation_size('tr') / 8192"), + '>', 100, 'aborted rows fill many pages'); +$node->safe_psql('postgres', 'VACUUM tr'); +is($node->safe_psql('postgres', "SELECT pg_relation_size('tr') / 8192"), + '1', 'VACUUM truncated every trailing empty page'); +is($node->safe_psql('postgres', 'SELECT id, pad FROM tr'), + '1|first', 'the live row survives'); +$node->safe_psql('postgres', 'DELETE FROM tr; VACUUM tr'); +is($node->safe_psql('postgres', "SELECT pg_relation_size('tr')"), + '0', 'an empty table is truncated to nothing'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/083_perbackend_multi_update.pl b/src/test/recovery/t/083_perbackend_multi_update.pl new file mode 100644 index 0000000000000..e6b4ac3c7ae79 --- /dev/null +++ b/src/test/recovery/t/083_perbackend_multi_update.pl @@ -0,0 +1,134 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A row updated several times in one transaction has one undo record per +# update, each restoring the image the one before it wrote. A rollback must +# apply all of them, newest first, and end at the committed image: inline, by +# an undo apply worker, by crash recovery, and by ROLLBACK TO SAVEPOINT; for +# permanent, unlogged and temporary FLUX tables. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_multi_update'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +my %kw = (p => '', u => 'UNLOGGED', t => 'TEMP'); + +# Create the table, update every row $n times in one transaction (the +# updates grow the row, then shrink it), then run $end. Returns the number +# of rows that hold the committed value. +sub run_case +{ + my ($am, $pers, $n, $body_end) = @_; + my $t = "mu_${am}_$pers"; + my @upd = + map { "UPDATE $t SET v = repeat('u$_', " . ($_ == 2 ? 50 : $_) . ');' } + (1 .. $n); + return $node->safe_psql( + 'postgres', qq( +CREATE $kw{$pers} TABLE $t (id int PRIMARY KEY, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +@{[ $body_end->($t, join("\n", @upd)) ]} +SELECT count(*) FROM $t WHERE v = 'orig' || id; +DROP TABLE $t; +)); +} + +my %ends = ( + 'ROLLBACK' => sub { my ($t, $u) = @_; "$u\nROLLBACK;" }, + 'ROLLBACK TO + COMMIT' => + sub { my ($t, $u) = @_; "SAVEPOINT s;\n$u\nROLLBACK TO s;\nCOMMIT;" }, + 'ROLLBACK TO + ROLLBACK' => + sub { my ($t, $u) = @_; "SAVEPOINT s;\n$u\nROLLBACK TO s;\nROLLBACK;" }, + 'one update before the savepoint, the rest after; ROLLBACK TO + COMMIT' + => sub { + my ($t, $u) = @_; + my ($first, @rest) = split(/\n/, $u); + "$first\nSAVEPOINT s;\n@{[ join(qq(\n), @rest) ]}\nROLLBACK TO s;\n" + . "UPDATE $t SET v = 'orig' || id;\nCOMMIT;"; + },); + +foreach my $overflow (16, 0) +{ + $node->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = $overflow"); + $node->reload; + foreach my $am ('flux') + { + foreach my $pers ('p', 'u', 't') + { + foreach my $n (2, 3) + { + foreach my $end (sort keys %ends) + { + my $name = + "$am $pers, $n updates, $end, overflow $overflow"; + + # A worker applies the rollback asynchronously: wait for + # it rather than reading the table at once. + if ($overflow == 0 && $pers ne 't' && $end eq 'ROLLBACK') + { + my $t = "mw_${am}_$pers$n"; + my @upd = map { + "UPDATE $t SET v = repeat('u$_', " + . ($_ == 2 ? 50 : $_) . ');' + } (1 .. $n); + $node->safe_psql( + 'postgres', qq( +CREATE $kw{$pers} TABLE $t (id int PRIMARY KEY, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +@upd +ROLLBACK; +)); + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 10 FROM $t WHERE v = 'orig' || id" + ), + $name); + next; + } + is(run_case($am, $pers, $n, $ends{$end}), '10', $name); + } + } + } + } +} +$node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); +$node->reload; + +# Crash recovery: the transaction is still running when the server crashes. +# Permanent tables only; unlogged tables are reset by the crash. +foreach my $am ('flux') +{ + foreach my $n (2, 3) + { + my $t = "mc_${am}_$n"; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int PRIMARY KEY, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my $s = $node->background_psql('postgres'); + $s->query_safe('BEGIN;'); + $s->query_safe( + "UPDATE $t SET v = repeat('u$_', " . ($_ == 2 ? 50 : $_) . ')') + for (1 .. $n); + $node->stop('immediate'); + $s->{run}->kill_kill; + $node->start; + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 10 FROM $t WHERE v = 'orig' || id"), + "$am: $n updates, crash: committed rows restored"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/084_perbackend_helping_once.pl b/src/test/recovery/t/084_perbackend_helping_once.pl new file mode 100644 index 0000000000000..2cc920ece0d5d --- /dev/null +++ b/src/test/recovery/t/084_perbackend_helping_once.pl @@ -0,0 +1,227 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Every per-backend undo record is applied exactly once, whoever applies it: +# ROLLBACK TO SAVEPOINT (which marks what it applied), a writer that runs +# into an aborted version (helping), the undo apply worker, and crash +# recovery. Here all of them work on one aborted transaction, T1, whose +# rows other transactions update and commit before the worker gets to it. +# Applying any record twice would put an old image back over a committed +# value. +# +# undo_rollback_overflow_size = 0 sends T1's rollback to the undo apply +# workers, which are held at the injection point pbu-undo-worker-before-apply +# until the end. T1 has an undo chain in the permanent and in the unlogged +# undo log, each a rollback request of its own, so there can be more than one +# worker to release. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('pbu_helping'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); + +my @s = + map { $node->background_psql('postgres', on_error_stop => 0) } (1 .. 3); + +my @tabs; +foreach my $am ('flux') +{ + foreach my $pers ('', 'UNLOGGED') + { + my $t = "h_${am}_" . ($pers ? 'u' : 'p'); + push @tabs, $t; + $s[0]->query_safe( + qq( +CREATE $pers TABLE $t (id int PRIMARY KEY, v int) USING $am; +INSERT INTO $t SELECT g, g FROM generate_series(1, 6) g; +)); + } +} + +# T1: rows 1-3 at the top level; rows 2 and 4 in a savepoint that is rolled +# back at once (and released, so that what follows is top-level again); row +# 5 after it; then the whole transaction rolls back. Row +# 5's aborted value, 16908801 = 0x01020201, reads the same in either byte +# order, so the page can be searched for it. +my $each = sub { + my $sql = shift; + return join("\n", map { (my $x = $sql) =~ s/\bT\b/$_/g; $x } @tabs); +}; +$s[0]->query_safe("BEGIN;\n" + . $each->('UPDATE T SET v = v + 100 WHERE id IN (1, 2, 3);') + . "\nSAVEPOINT s;\n" + . $each->('UPDATE T SET v = v + 1000 WHERE id IN (2, 4);') + . "\nROLLBACK TO s;\nRELEASE s;\n" + . $each->('SELECT string_agg(v::text, \',\' ORDER BY id) FROM T;') + . "\n" + . $each->('UPDATE T SET v = 16908801 WHERE id = 5;') + . "\nROLLBACK;"); + +my $pending = sub { + my $t = shift; + return + "SELECT position('\\x01020201'::bytea IN get_raw_page('$t', 0)) > 0"; +}; +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +foreach my $t (@tabs) +{ + is($s[1]->query_safe($pending->($t)), + 't', "$t: T1's rollback is still pending"); +} + +# T2 updates rows 1, 2 and 4 and commits. Rows 1 and 2 carry T1's aborted +# top-level images, which T2 reverts first (helping); row 4 was already +# restored, and marked applied, by ROLLBACK TO. Row 2 has both: a record +# T1's ROLLBACK TO applied and marked, under one helping reverts. T3 helps row 3 inside a savepoint that it rolls +# back, then commits an update of row 6. +$s[1]->query_safe($each->('UPDATE T SET v = v + 20 WHERE id IN (1, 2, 4);')); +$s[2]->query_safe("BEGIN;\nSAVEPOINT a;\n" + . $each->('UPDATE T SET v = v + 7000 WHERE id = 3;') + . "\nROLLBACK TO a;\n" + . $each->('UPDATE T SET v = v + 30 WHERE id = 6;') + . "\nCOMMIT;"); + +my $want = '21,22,3,24,5,36'; +my $rows = sub { + my $t = shift; + return "SELECT string_agg(v::text, ',' ORDER BY id) FROM $t"; +}; +foreach my $t (@tabs) +{ + is($s[1]->query_safe($rows->($t)), + $want, "$t: helpers' committed values over T1's pending rollback"); +} + +# Let the held undo apply workers go. injection_points_wakeup() releases +# one waiter per call. +my $release = sub { + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); + $node->poll_query_until('postgres', 'SELECT release_one()') + or die 'held undo apply workers were not released'; +}; + +# Now the workers apply the rest of T1: row 5, and nothing else. +$release->(); +foreach my $t (@tabs) +{ + ok( $node->poll_query_until( + 'postgres', "SELECT NOT (" . $pending->($t) . ")"), + "$t: worker applied T1's remaining record"); + is($node->safe_psql('postgres', $rows->($t)), + $want, "$t: worker left the committed values alone"); +} + +# ROLLBACK TO a savepoint that wrote nothing reverts nothing: not the +# parent's records, whose rollback is left to the worker, nor, in a +# transaction that commits, the parent's changes. ROLLBACK TO restarts the +# savepoint, so what T4 writes after it is the savepoint's, and the +# top-level ROLLBACK applies that itself when it aborts the savepoint. Row +# 6's aborted value 16974593 = 0x01030301 is searchable like row 5's. +my $pending6 = sub { + my $t = shift; + return + "SELECT position('\\x01030301'::bytea IN get_raw_page('$t', 0)) > 0"; +}; +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +my $t4 = $node->safe_psql('postgres', + "BEGIN;\n" + . $each->('UPDATE T SET v = 16908801 WHERE id = 5;') + . "\nSAVEPOINT s;\nROLLBACK TO s;\n" + . $each->("SELECT 'T', v FROM T WHERE id = 5;") . "\n" + . $each->('UPDATE T SET v = 16974593 WHERE id = 6;') + . "\nROLLBACK;"); +is( $t4, + join("\n", map { "$_|16908801" } @tabs), + 'ROLLBACK TO an empty savepoint keeps the parent\'s change'); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +foreach my $t (@tabs) +{ + is($node->safe_psql('postgres', $pending->($t)), + 't', "$t: the parent's record is left to the worker"); + is($node->safe_psql('postgres', $pending6->($t)), + 'f', "$t: the savepoint's record is applied by the ROLLBACK"); +} +$release->(); +foreach my $t (@tabs) +{ + ok( $node->poll_query_until( + 'postgres', "SELECT NOT (" . $pending->($t) . ")"), + "$t: worker applied the parent's record"); + is($node->safe_psql('postgres', $rows->($t)), + $want, "$t: T4 left no trace"); +} +$node->safe_psql('postgres', + "BEGIN;\n" + . $each->('UPDATE T SET v = v + 1 WHERE id = 1;') + . "\nSAVEPOINT s;\nROLLBACK TO s;\nCOMMIT;"); +foreach my $t (@tabs) +{ + is( $node->safe_psql('postgres', $rows->($t)), + '22,22,3,24,5,36', + "$t: a commit after ROLLBACK TO an empty savepoint keeps the change"); +} +$want = '22,22,3,24,5,36'; + +$_->quit for @s; + +$node->restart; +foreach my $t (@tabs) +{ + is($node->safe_psql('postgres', $rows->($t)), $want, "$t: after restart"); +} + +# Crash recovery finds nothing left to apply either. +$node->stop('immediate'); +$node->start; +foreach my $t (grep { /_p$/ } @tabs) +{ + is($node->safe_psql('postgres', $rows->($t)), $want, "$t: after crash"); +} +unlike( + slurp_file($node->logfile), + qr/cannot restore|TRAP|PANIC/, + 'no failed restore'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/085_perbackend_worker_done.pl b/src/test/recovery/t/085_perbackend_worker_done.pl new file mode 100644 index 0000000000000..dce2d0f49d955 --- /dev/null +++ b/src/test/recovery/t/085_perbackend_worker_done.pl @@ -0,0 +1,113 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A rollback handed to an undo apply worker must finish, and its undo must +# become discardable, even when there is nothing left to undo or when some of +# it can never be applied. +# +# - The table was dropped (or truncated) before the worker got to it: the +# records have nothing left to undo, so the request completes. +# - A record keeps failing (injection point pbu-undo-apply-skip): after a +# bounded number of attempts the worker gives up with a WARNING instead of +# retrying forever and pinning the undo log. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_worker_done'); +$node->init; +$node->append_conf('postgresql.conf', + "autovacuum = off\nundo_rollback_overflow_size = 0\nlog_min_messages = debug1\n" +); +$node->start; + +# The worker logs "Undo action pushed(error)" (DEBUG1) whenever it puts a +# request back for a retry. +sub retries +{ + my ($from) = @_; + my $log = substr(slurp_file($node->logfile), $from); + return scalar(() = $log =~ /Undo action pushed\(error\)/g); +} + +# Undo of a transaction is discardable once its rollback is done: the +# discard worker then moves past it. +sub wait_discarded +{ + my ($lsn, $what) = @_; + ok( $node->poll_query_until( + 'postgres', qq( +SELECT count(*) > 0 FROM pg_get_wal_records_info('$lsn', pg_current_wal_lsn()) + WHERE resource_manager = 'UndoLog' AND record_type = 'DISCARD')), + "$what: undo discarded"); +} + +$node->safe_psql('postgres', 'CREATE EXTENSION pg_walinspect'); + +foreach my $am ('flux') +{ + foreach my $op ('DROP TABLE', 'TRUNCATE') + { + my $t = "w_${am}_" . lc((split(/ /, $op))[0]); + my $what = "$am, $op before the worker ran"; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); + my $off = -s $node->logfile; + + # The DROP / TRUNCATE takes the table's AccessExclusiveLock right after + # the rollback, before the worker can open the relation; the worker's + # open then waits and finds the relation gone or empty. + $node->safe_psql( + 'postgres', qq( +BEGIN; +UPDATE $t SET v = 'x'; +ROLLBACK; +$op $t; +)); + ok( $node->poll_query_until( + 'postgres', q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + "$what: worker finished"); + wait_discarded($lsn, $what); + + is(retries($off), 0, "$what: request was not retried"); + } +} + +SKIP: +{ + skip 'Injection points not supported by this build', 3 + if $ENV{enable_injection_points} ne 'yes'; + + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + $node->safe_psql( + 'postgres', q( +CREATE TABLE stuck (id int, v text) USING flux; +INSERT INTO stuck SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); + my $off = -s $node->logfile; + $node->safe_psql( + 'postgres', q( +SELECT injection_points_attach('pbu-undo-apply-skip', 'notice'); +BEGIN; +UPDATE stuck SET v = 'x'; +ROLLBACK; +)); + ok( $node->wait_for_log( + qr/WARNING: .*giving up on rolling back transaction/, $off), + 'worker gives up on a request that keeps failing'); + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-apply-skip')"); + my $n = retries($off); + ok($n >= 1 && $n <= 10, "bounded number of retries ($n)"); + wait_discarded($lsn, 'given-up request'); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/086_flux_standby_helping.pl b/src/test/recovery/t/086_flux_standby_helping.pl new file mode 100644 index 0000000000000..181ec9ac487b6 --- /dev/null +++ b/src/test/recovery/t/086_flux_standby_helping.pl @@ -0,0 +1,98 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX helping (a writer reverting an aborted, not-yet-reverted version) is +# for writers on the primary only. A hot standby must not change a page +# itself: every change has to arrive through the WAL, or the standby's page +# diverges from the primary's. Its readers see the right row anyway, from +# CLOG and the version chain. The SnapshotAny row fetch that helps on the +# primary (flux_tuple_fetch_row_version) is reachable on a standby through a +# TID scan's fetch; it must not help there. +# +# The undo apply worker is held at the injection point +# pbu-undo-worker-before-apply, so the rollback stays pending. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +wal_consistency_checking = all +shared_preload_libraries = 'injection_points' +)); +$primary->start; +if ( !$primary->check_extension('injection_points') + || !$primary->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} +$primary->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 4) g; +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +)); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +$primary->safe_psql('postgres', + 'BEGIN; UPDATE f SET v = 16908801 WHERE id = 1; ROLLBACK;'); +$primary->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +# An abort does not flush its WAL, so wait for the insert position: the flush +# position wait_for_replay_catchup uses can still be short of the UPDATE. +$primary->wait_for_catchup($standby, 'replay', $primary->lsn('insert')); + +my $on_page = + q{SELECT position('\x01020201'::bytea IN get_raw_page('f', 0)) > 0}; +my $page_lsn = q{SELECT lsn FROM page_header(get_raw_page('f', 0))}; +is($primary->safe_psql('postgres', $on_page), + 't', 'primary: rollback still pending'); +is($standby->safe_psql('postgres', $on_page), 't', 'standby: same page'); + +is($standby->safe_psql('postgres', 'SELECT v FROM f WHERE id = 1'), + '10', 'standby reads the committed value'); +my $lsn_before = $standby->safe_psql('postgres', $page_lsn); +my $ctid = $standby->safe_psql('postgres', 'SELECT ctid FROM f WHERE id = 1'); +is($standby->safe_psql('postgres', "SELECT v FROM f WHERE ctid = '$ctid'"), + '10', 'standby TID fetch returns the committed value'); +is($standby->safe_psql('postgres', $on_page), + 't', 'standby did not revert the aborted version'); +is($standby->safe_psql('postgres', $page_lsn), + $lsn_before, 'standby did not change the page'); + +# A writer on the primary helps; the standby replays that. +is( $primary->safe_psql( + 'postgres', 'UPDATE f SET v = v + 1 WHERE id = 1 RETURNING v'), + '11', + 'primary writer builds on the committed value'); +$primary->safe_psql( + 'postgres', + "SELECT injection_points_wakeup('pbu-undo-worker-before-apply'); + SELECT injection_points_detach('pbu-undo-worker-before-apply');"); +$primary->wait_for_catchup($standby, 'replay', $primary->lsn('insert')); +is($standby->safe_psql('postgres', 'SELECT v FROM f WHERE id = 1'), + '11', 'standby replays the helped row'); +is($standby->safe_psql('postgres', $on_page), + 'f', 'aborted image gone on the standby'); +unlike( + slurp_file($standby->logfile), + qr/inconsistent page found|TRAP|PANIC/, + 'standby pages match the primary'); + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/087_flux_index_undo_fail.pl b/src/test/recovery/t/087_flux_index_undo_fail.pl new file mode 100644 index 0000000000000..16db182de99b6 --- /dev/null +++ b/src/test/recovery/t/087_flux_index_undo_fail.pl @@ -0,0 +1,103 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Rolling back a FLUX in-place UPDATE of an indexed column also reverses the +# index: the new (k_new, TID) entry is killed and the old (k_old, TID) entry +# un-delete-marked. That reversal evaluates index expressions, which can +# fail. A failure must leave the record to be retried, and the retry must +# redo the index reversal too. If the tuple were restored first, the retry +# would find the record already undone and skip it, leaving the index broken +# for good. +# +# The index expression fails while ctl.fail is set. The rollback goes to +# the undo apply worker (undo_rollback_overflow_size = 0), held at the +# injection point pbu-undo-worker-before-apply until ctl.fail is set; it +# then fails, and succeeds on a retry once ctl.fail is cleared. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_index_undo_fail'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('amcheck') + || !$node->check_extension('injection_points')) +{ + plan skip_all => 'amcheck or injection_points not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE EXTENSION injection_points; +CREATE TABLE ctl (fail bool); +INSERT INTO ctl VALUES (false); +CREATE FUNCTION kf(int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS + $$ BEGIN + IF $1 >= 100 AND (SELECT fail FROM public.ctl) THEN + RAISE EXCEPTION 'kf fails'; + END IF; + RETURN $1; + END $$; +CREATE TABLE f (id int PRIMARY KEY, k int) USING flux; +CREATE INDEX f_kf ON f (kf(k)); +INSERT INTO f SELECT g, g FROM generate_series(1, 4) g; +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +}); + +my $off = -s $node->logfile; +$node->safe_psql('postgres', + 'BEGIN; UPDATE f SET k = 100 WHERE id = 1; ROLLBACK;'); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +$node->safe_psql( + 'postgres', q{ +UPDATE ctl SET fail = true; +SELECT injection_points_detach('pbu-undo-worker-before-apply'); +SELECT injection_points_wakeup('pbu-undo-worker-before-apply'); +}); +ok( $node->wait_for_log( + qr/undo apply worker.*ERROR: .*\n.*PL\/pgSQL function kf/, $off), + 'the index reversal fails in the undo apply worker'); + +$node->safe_psql('postgres', 'UPDATE ctl SET fail = false'); +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; '; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 4 FROM f WHERE k < 100'), + 'the retry rolled the row back'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM f WHERE kf(k) = 1'), + '1', + 'the k_old index entry is live again'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM f WHERE kf(k) = 100'), + '0', + 'no k_new index entry'); +$node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'} +) or die 'undo apply worker did not finish'; +is($node->safe_psql('postgres', q{SELECT bt_index_check('f_kf', true)}), + '', 'index matches the table'); +is( $node->safe_psql( + 'postgres', + q{SELECT string_agg(id || ':' || k, ',' ORDER BY id) FROM f}), + '1:1,2:2,3:3,4:4', + 'rows rolled back'); +unlike(slurp_file($node->logfile, $off), + qr/giving up/, 'the worker did not give up'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/089_perbackend_rewrite_rollback.pl b/src/test/recovery/t/089_perbackend_rewrite_rollback.pl new file mode 100644 index 0000000000000..18fc274170221 --- /dev/null +++ b/src/test/recovery/t/089_perbackend_rewrite_rollback.pl @@ -0,0 +1,176 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Per-backend UNDO of a table that the same transaction then rewrote into a +# new relfilenode (TRUNCATE, ALTER TABLE ... TYPE) must be applied to the +# file the undo was written against. The rollback restores the old +# relfilenode, so the records belong to it; applying them to the new file +# (or counting them as done there) leaves the aborted images in the old file +# and the committed rows are lost. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_rewrite_rollback'); +$node->init; +# Applying undo to the wrong file can crash the undo apply worker; let the +# remaining checks run. +$node->append_conf('postgresql.conf', + "autovacuum = off\nrestart_after_crash = on\n"); +$node->start; +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +# The tables live in rr, so that the undo apply worker, which connects to the +# database it reverts, can be kept out of it (ALLOW_CONNECTIONS false) while +# a rollback handed to it is checked to be still pending. +$node->safe_psql('postgres', 'CREATE DATABASE rr'); +$node->safe_psql( + 'rr', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE mk (v int) USING flux; +INSERT INTO mk VALUES (0); +VACUUM (FREEZE) mk; +}); + +# Freeze the marker before capturing its bytes: DML before-images can freeze +# globally visible xmin, so an unfrozen baseline is not byte-stable. +# +# A deferred transaction updates the marker row mk first, so that its undo +# record is the last of the request to be applied: while mk's raw bytes +# differ from the committed image the rollback is pending, and once they are +# back the worker has applied all of it. +# +# The comparison is exact except for the two CLOG hint bits in the high byte +# of t_flags (byte 33 of the FLUX tuple header): FLUX_TUPLE_XMIN_COMMITTED +# (0x02) and FLUX_TUPLE_XMAX_COMMITTED (0x04). The UPDATE's visibility +# check may set them on the page after the committed image was read here and +# before the UPDATE copies its before-image, so the image the worker puts +# back can carry a hint the committed image did not. A hint bit only caches +# what CLOG says about t_xmin or t_xmax, which both images share; it is not +# WAL-logged and wal_consistency_checking masks it. Every other byte, +# must match. +my $raw_mk = q{SELECT encode(set_byte(t, 33, get_byte(t, 33) & ~6), 'hex') + FROM (SELECT substr(get_raw_page('mk', 0), lp_off + 1, lp_len) AS t + FROM heap_page_items(get_raw_page('mk', 0)) WHERE lp = 1) s}; +my $orig_mk = $node->safe_psql('rr', $raw_mk); + +my %cases = ( + 'a_update_truncate_insert' => sub { + my ($t) = @_; + return qq( +BEGIN; +UPDATE $t SET v = 'x'; +TRUNCATE $t; +INSERT INTO $t VALUES (99, 'new'); +ROLLBACK;); + }, + 'b_update_truncate' => sub { + my ($t) = @_; + return qq( +BEGIN; +UPDATE $t SET v = 'x'; +TRUNCATE $t; +ROLLBACK;); + }, + 'c_savepoint_update_truncate_insert' => sub { + my ($t) = @_; + return qq( +BEGIN; +SAVEPOINT s; +UPDATE $t SET v = 'x'; +TRUNCATE $t; +INSERT INTO $t VALUES (99, 'new'); +ROLLBACK TO s; +COMMIT;); + }, + 'd_update_alter_type' => sub { + my ($t) = @_; + return qq( +BEGIN; +UPDATE $t SET v = 'x'; +ALTER TABLE $t ALTER COLUMN v TYPE varchar(100); +ROLLBACK;); + },); + +# The cases whose top-level ROLLBACK goes to the worker on the worker path. +my %deferred = (a => 1, b => 1, d => 1); + +# The worker path applies undo in the background; wait for it. +sub check +{ + my ($t, $what) = @_; + my $q = + "SELECT count(*) || ',' || count(*) FILTER (WHERE v = 'orig') FROM $t"; + ok($node->poll_query_until('rr', $q, '10,10'), $what) + or diag($node->safe_psql('rr', $q)); +} + +foreach my $path ('inline', 'worker') +{ + if ($path eq 'worker') + { + $node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); + $node->restart; + } + + my @tables; + my $bg = $node->background_psql('rr', on_error_stop => 0); + foreach my $am ('flux') + { + foreach my $case (sort keys %cases) + { + my $c = substr($case, 0, 1); + my $t = "${path}_${am}_$c"; + my $what = "$path, $am, $case"; + my $defer = $path eq 'worker' && $deferred{$c}; + $bg->query_safe( + qq( +CREATE TABLE $t (id int primary key, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' FROM generate_series(1, 10) g; +)); + my $sql = $cases{$case}->($t); + if ($defer) + { + $sql =~ s/BEGIN;/BEGIN;\nUPDATE mk SET v = v + 1;/; + $node->safe_psql('postgres', + 'ALTER DATABASE rr ALLOW_CONNECTIONS false'); + ok( $node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + "$what: no undo apply worker is connected"); + } + $bg->{stderr} = ''; + $bg->query($sql); + is($bg->{stderr}, '', "$what: no error"); + if ($defer) + { + isnt($bg->query_safe($raw_mk), + $orig_mk, "$what: the rollback is left to the worker"); + $node->safe_psql('postgres', + 'ALTER DATABASE rr ALLOW_CONNECTIONS true'); + ok( $node->poll_query_until( + 'rr', "SELECT ($raw_mk) = '$orig_mk'"), + "$what: the worker applied the rollback"); + } + check($t, "$what: rows restored"); + push @tables, [ $t, $what ]; + } + } + $bg->quit; + + $node->restart; + check($_->[0], "$_->[1]: rows restored after restart") foreach @tables; + + $node->stop('immediate'); + $node->start; + check($_->[0], "$_->[1]: rows restored after crash restart") + foreach @tables; +} + +done_testing(); diff --git a/src/test/recovery/t/090_fileops_createdb_crash.pl b/src/test/recovery/t/090_fileops_createdb_crash.pl new file mode 100644 index 0000000000000..8772ad34ff933 --- /dev/null +++ b/src/test/recovery/t/090_fileops_createdb_crash.pl @@ -0,0 +1,113 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A CREATE DATABASE that crashes, or fails, part way through its copy must +# leave no database directory behind, for either strategy. The directory +# is created by FILEOPS but filled by smgr (WAL_LOG) or copydir (FILE_COPY), +# so its UNDO must remove the whole tree, not just rmdir it. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('createdb_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +shared_preload_libraries = 'injection_points' +wal_level = replica +autovacuum = off +}); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); +$node->safe_psql('postgres', 'CREATE DATABASE tmpl'); +$node->safe_psql('tmpl', + "CREATE TABLE t AS SELECT g, repeat('x', 200) v FROM generate_series(1, 20000) g" +); + +my $base = $node->data_dir . '/base'; + +# Directories under base/ that belong to no database. +sub orphans +{ + my %known = map { $_ => 1 } split /\n/, + $node->safe_psql('postgres', 'SELECT oid FROM pg_database'); + opendir(my $dh, $base) or die "opendir $base: $!"; + my @o = grep { /^\d+$/ && !$known{$_} } readdir($dh); + closedir($dh); + return join(' ', sort @o); +} + +my %point = ( + wal_log => 'createdb-wal-log-after-copy-relation', + file_copy => 'createdb-file-copy-after-copydir'); + +foreach my $strategy ('wal_log', 'file_copy') +{ + my $ip = $point{$strategy}; + my %before = map { $_ => 1 } split / /, orphans(); + + # Crash while the new directory holds copied files. + $node->safe_psql('postgres', + "SELECT injection_points_attach('$ip', 'wait')"); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until( + qr/start/, qq(\\echo start +CREATE DATABASE c_$strategy TEMPLATE tmpl STRATEGY $strategy; +)); + $node->wait_for_event('client backend', $ip); + + my ($new) = grep { !$before{$_} } split / /, orphans(); + ok(defined $new, "$strategy: the new database directory exists"); + $new //= 'none'; + my @files = glob("$base/$new/*"); + cmp_ok(scalar(@files), '>', 1, + "$strategy: it holds copied files at the crash"); + + my $log_offset = -s $node->logfile; + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; + + is(orphans(), '', "$strategy: no database directory left after a crash"); + my $log = slurp_file($node->logfile, $log_offset); + unlike( + $log, + qr/WARNING: FILEOPS UNDO/, + "$strategy: crash recovery applied the UNDO cleanly"); + + # The same, with the copy failing instead. + $node->safe_psql('postgres', + "SELECT injection_points_attach('$ip', 'error')"); + my ($ret, $out, $err) = $node->psql('postgres', + "CREATE DATABASE e_$strategy TEMPLATE tmpl STRATEGY $strategy"); + like($err, qr/ERROR/, "$strategy: CREATE DATABASE failed mid-copy"); + $node->safe_psql('postgres', "SELECT injection_points_detach('$ip')"); + is(orphans(), '', "$strategy: no database directory left after an error"); + + # A later CREATE DATABASE still works. + $node->safe_psql('postgres', + "CREATE DATABASE ok_$strategy TEMPLATE tmpl STRATEGY $strategy"); + is($node->safe_psql("ok_$strategy", 'SELECT count(*) FROM t'), + '20000', "$strategy: a new CREATE DATABASE succeeds"); +} + +# No UNDO chain was left for the revert worker to retry forever. +sleep(1); +unlike( + slurp_file($node->logfile), + qr/could not rmdir .*Directory not empty/, + 'no failed rmdir of a non-empty directory'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/093_flux_toast_supersede.pl b/src/test/recovery/t/093_flux_toast_supersede.pl new file mode 100644 index 0000000000000..2d97e39e9594e --- /dev/null +++ b/src/test/recovery/t/093_flux_toast_supersede.pl @@ -0,0 +1,135 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# FLUX in-place UPDATE must not leak superseded TOAST values. +# +# An in-place UPDATE overwrites the row at its TID and keeps the old version +# only as an UNDO before-image. The old version's external TOAST values must +# then be deleted exactly like heap deletes them: by the updating transaction +# (so they survive for snapshot-toast readers of the before-image, and an +# abort keeps them), and physically reclaimed by VACUUM once no snapshot can +# reach the before-image. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_toast'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE t (id int PRIMARY KEY, n int, big text) USING flux; +ALTER TABLE t ALTER big SET STORAGE EXTERNAL; +INSERT INTO t VALUES (1, 0, repeat('a', 10000)), (2, 0, repeat('z', 10000)); +}); +my $toast = $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE relname = 't'"); + +# Values visible to an MVCC snapshot, and chunks physically on the pages. +my $values = "SELECT count(DISTINCT chunk_id) FROM $toast"; +my $physical = qq{ +SELECT (SELECT count(*) + FROM generate_series(0, pg_relation_size('$toast') / 8192 - 1) b, + heap_page_items(get_raw_page('$toast', b::int)) + WHERE lp_flags = 1) = (SELECT count(*) FROM $toast)}; + +# Five UPDATEs of the wide column, five of a narrow column. +for my $i (1 .. 5) +{ + $node->safe_psql('postgres', + "UPDATE t SET big = repeat(chr(97 + $i), 10000) WHERE id = 1"); + $node->safe_psql('postgres', "UPDATE t SET n = n + 1 WHERE id = 1"); +} +is($node->safe_psql('postgres', $values), + '2', 'superseded TOAST values are deleted by the UPDATE'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $physical), + 't', 'VACUUM reclaimed the superseded TOAST chunks'); +is( $node->safe_psql( + 'postgres', + 'SELECT n, big = repeat(\'f\', 10000) FROM t WHERE id = 1'), + '5|t', + 'current value intact'); + +# A REPEATABLE READ snapshot taken before the UPDATE still detoasts the old +# value through the before-image, even across a VACUUM. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +$bg->query_safe('SELECT count(*) FROM t'); +$node->safe_psql('postgres', + "UPDATE t SET big = repeat('g', 10000) WHERE id = 1"); +$node->safe_psql('postgres', 'VACUUM t'); +is($bg->query_safe("SELECT big = repeat('f', 10000) FROM t WHERE id = 1"), + 't', 'old snapshot detoasts the superseded value'); +$bg->query_safe('COMMIT'); +$bg->quit; +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $physical), + 't', 'superseded value reclaimed once the old snapshot ended'); +is($node->safe_psql('postgres', $values), '2', 'two live values remain'); + +# An aborted UPDATE (top level and savepoint) restores a detoastable value. +$node->safe_psql( + 'postgres', q{ +BEGIN; UPDATE t SET big = repeat('h', 10000) WHERE id = 1; ROLLBACK; +BEGIN; SAVEPOINT s; UPDATE t SET big = repeat('i', 10000) WHERE id = 1; +ROLLBACK TO s; COMMIT; +VACUUM t; +}); +is( $node->safe_psql( + 'postgres', "SELECT big = repeat('g', 10000) FROM t WHERE id = 1"), + 't', + 'aborted UPDATE keeps the restored value detoastable'); +is($node->safe_psql('postgres', $values), + '2', 'aborted UPDATEs left no values'); +is($node->safe_psql('postgres', $physical), + 't', 'aborted UPDATEs left no chunks after VACUUM'); + +# Taking another row's TOAST pointer must copy the value, not share it. +$node->safe_psql( + 'postgres', q{ +UPDATE t SET big = (SELECT big FROM t WHERE id = 2) WHERE id = 1; +DELETE FROM t WHERE id = 2; +VACUUM t; +}); +is( $node->safe_psql( + 'postgres', "SELECT big = repeat('z', 10000) FROM t WHERE id = 1"), + 't', + 'value copied from another row survives that row\'s removal'); +is($node->safe_psql('postgres', $values), '1', 'one live value remains'); + +# A row that grows past its full page after toasting falls back to an +# out-of-place UPDATE; the abandoned in-place attempt must not leave the +# value it externalized behind. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE g (id int, a text, b text) USING flux; +ALTER TABLE g ALTER a SET STORAGE EXTERNAL, ALTER b SET STORAGE EXTERNAL; +INSERT INTO g SELECT i, '', repeat('p', 150) FROM generate_series(1, 200) i; +UPDATE g SET a = repeat('q', 5000), b = repeat('r', 1500) WHERE id = 1; +}); +my $gtoast = $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE relname = 'g'"); +is( $node->safe_psql( + 'postgres', + "SELECT ctid <> '(0,1)', a = repeat('q', 5000) FROM g WHERE id = 1"), + 't|t', + 'row moved out of place'); +is( $node->safe_psql( + 'postgres', "SELECT count(DISTINCT chunk_id) FROM $gtoast"), + '1', + 'abandoned in-place attempt left no TOAST value'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/094_flux_tombstone_prune.pl b/src/test/recovery/t/094_flux_tombstone_prune.pl new file mode 100644 index 0000000000000..3229b3699b963 --- /dev/null +++ b/src/test/recovery/t/094_flux_tombstone_prune.pl @@ -0,0 +1,197 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# VACUUM prunes the delete-marked index tombstones a FLUX in-place key UPDATE +# leaves behind for a row that is still live. +# +# An UPDATE of an indexed column keeps the row at its TID, inserts the new +# (key, TID) entry and delete-marks the old one so older snapshots still reach +# the row by its old key. The TID stays live, so the ordinary dead-TID test +# never lets VACUUM remove the tombstone. It must go once no snapshot can need +# the old key, and not before. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_tomb'); +$node->init(allows_streaming => 1); +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if ( !$node->check_extension('pageinspect') + || !$node->check_extension('amcheck') + || !$node->check_extension('pg_walinspect')) +{ + plan skip_all => 'pageinspect, amcheck or pg_walinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE TABLE t (id int PRIMARY KEY, k int, v text) USING flux; +CREATE INDEX t_k ON t (k); +INSERT INTO t SELECT g, g, 'x' FROM generate_series(1, 10) g; +}); + +# The index fits on its root leaf page. +my $entries = q{SELECT live_items FROM bt_page_stats('t_k', 1)}; +my $q = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SELECT count(*) FROM t WHERE k = '; +my $by_key = sub { + my ($s, $k) = @_; + return $s->isa('PostgreSQL::Test::Cluster') + ? $s->safe_psql('postgres', $q . $k) + : $s->query_safe($q . $k); +}; + +# A snapshot taken before the key UPDATEs. +my $old = $node->background_psql('postgres'); +$old->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +$old->query_safe('SELECT count(*) FROM t'); + +$node->safe_psql('postgres', + join('', map { "UPDATE t SET k = k + 1000 WHERE id = 1;\n" } 1 .. 100)); +is($node->safe_psql('postgres', $entries), + '110', 'one tombstone per key UPDATE'); + +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '110', 'tombstones kept while an older snapshot is open'); +is($by_key->($old, 1), + '1', 'older snapshot still finds the row by its old key'); +is($by_key->($old, 100001), '0', 'older snapshot does not see the new key'); +$old->query_safe('COMMIT'); +$old->quit; + +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '10', 'VACUUM pruned the tombstones'); +is($node->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'index is consistent'); +is($by_key->($node, 100001), '1', 'row found by its current key'); +is($by_key->($node, 1001), '0', 'row not found by a superseded key'); + +# Back to a key whose tombstone was pruned: a fresh entry, ordering intact. +$node->safe_psql('postgres', 'UPDATE t SET k = 1 WHERE id = 1'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), '10', 'return to a pruned key'); +is($by_key->($node, 1), '1', 'row found by the key it returned to'); +is($node->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'index is consistent after the return'); + +# Expression and partial indexes keep their tombstones (not evaluated under +# the index page lock). +$node->safe_psql( + 'postgres', q{ +CREATE INDEX t_e ON t ((k + 0)); +CREATE INDEX t_p ON t (k) WHERE id > 0; +UPDATE t SET k = 2000 WHERE id = 2; +VACUUM t; +}); +is( $node->safe_psql( + 'postgres', q{SELECT string_agg(live_items::text, ',' ORDER BY i) + FROM (VALUES (1, 't_e'), (2, 't_p'), (3, 't_k')) v(i, n), + LATERAL bt_page_stats(n, 1)}), + '11,11,10', + 'expression and partial index tombstones kept'); + +# An in-progress key UPDATE keeps its tombstone, and its rollback still +# restores the index. +my $w = $node->background_psql('postgres'); +$w->query_safe('BEGIN'); +$w->query_safe('UPDATE t SET k = -1 WHERE id = 1'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '11', 'tombstone of an in-progress UPDATE kept'); +$w->query_safe('ROLLBACK'); +$w->quit; +is($by_key->($node, 1), '1', 'rolled-back key UPDATE: old key found'); +is($by_key->($node, -1), '0', 'rolled-back key UPDATE: new key gone'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '10', 'index back to one entry per row'); +is($node->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'index is consistent after the rollback'); + +# The newest version's writer can hold an older XID than an earlier writer. +# A snapshot taken between them reads the earlier version by its key; the +# tombstone stays while it is open, and on a standby removing it cancels it. +$node->backup('b'); +my $standby = PostgreSQL::Test::Cluster->new('flux_tomb_standby'); +$standby->init_from_backup($node, 'b', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "max_standby_streaming_delay = 0\nhot_standby_feedback = off"); +$standby->start; + +$node->safe_psql('postgres', 'CREATE TABLE other (i int)'); +my $a = $node->background_psql('postgres'); +$a->query_safe('BEGIN'); +$a->query_safe('INSERT INTO other VALUES (1)'); # A takes its XID first +my $axid = $a->query_safe('SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', 'UPDATE t SET k = 7000 WHERE id = 3'); # B +$node->wait_for_replay_catchup($standby); + +my $p = $node->background_psql('postgres'); +my $s = $standby->background_psql('postgres', on_error_stop => 0); +foreach my $c ($p, $s) +{ + $c->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + is($by_key->($c, 7000), '1', + 'snapshot between the writers sees B\'s key'); +} +$a->query_safe('UPDATE t SET k = 8000 WHERE id = 3'); +$a->query_safe('COMMIT'); +$a->quit; + +$node->safe_psql('postgres', 'VACUUM t'); +is($by_key->($p, 7000), '1', 'tombstone kept for the primary snapshot'); +$p->query_safe('COMMIT'); +$p->quit; + +my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), '10', 'tombstones pruned'); +# VACUUM does not flush its WAL; a commit does. +$node->safe_psql('postgres', 'INSERT INTO other VALUES (2)'); +is( $node->safe_psql( + 'postgres', qq{SELECT string_agg(substring(description + from 'snapshotConflictHorizon: (\\d+)'), ',') + FROM pg_get_wal_records_info('$lsn', pg_current_wal_lsn()) + WHERE resource_manager = 'Btree' AND record_type = 'DELETE'}), + $axid, + 'removal conflicts with snapshots not seeing the older XID'); +ok( $standby->wait_for_log( + qr/terminating connection due to conflict with recovery/), + 'standby snapshot needing the tombstone was cancelled'); +$s->{run}->kill_kill; +is($by_key->($standby, 8000), '1', + 'standby finds the row by its current key'); + +# A key UPDATE committed through a released savepoint, and one replayed on +# the standby, leave page images with a stale UNCOMMITTED hint (it is cleared +# by the committing backend, by top-level XID, and never by redo). CLOG, not +# that hint, decides. +$node->safe_psql( + 'postgres', q{ +BEGIN; SAVEPOINT s; UPDATE t SET k = 9000 WHERE id = 4; RELEASE s; COMMIT; +UPDATE t SET k = 9001 WHERE id = 5; +}); +$node->wait_for_replay_catchup($standby); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '10', 'tombstone of a committed subtransaction pruned'); +$standby->promote; +$standby->safe_psql('postgres', 'VACUUM t'); +is($standby->safe_psql('postgres', $entries), + '10', 'promoted standby prunes tombstones of replayed UPDATEs'); +is($standby->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'promoted standby: index is consistent'); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/095_flux_rewrite_cutoffs.pl b/src/test/recovery/t/095_flux_rewrite_cutoffs.pl new file mode 100644 index 0000000000000..59fc1ce6eae10 --- /dev/null +++ b/src/test/recovery/t/095_flux_rewrite_cutoffs.pl @@ -0,0 +1,51 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# VACUUM FULL and CLUSTER of a FLUX table must leave valid relfrozenxid / +# relminmxid behind, on the table and on its TOAST table, so that a later +# VACUUM (which asserts them normal) and anti-wraparound bookkeeping work. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_rewrite'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE TABLE vf (id int PRIMARY KEY, v text) USING flux; +INSERT INTO vf SELECT g, repeat('x', 3000 + g) FROM generate_series(1, 50) g; +DELETE FROM vf WHERE id % 2 = 0; +}); + +my $cutoffs = q{ +SELECT c.relfrozenxid::text::bigint > 2, c.relminmxid::text::bigint >= 1, + t.relfrozenxid::text::bigint > 2, t.relminmxid::text::bigint >= 1 +FROM pg_class c JOIN pg_class t ON t.oid = c.reltoastrelid +WHERE c.relname = 'vf'}; + +foreach my $cmd ('VACUUM FULL vf', 'CLUSTER vf USING vf_pkey') +{ + $node->safe_psql('postgres', $cmd); + is($node->safe_psql('postgres', $cutoffs), + 't|t|t|t', "$cmd: valid cutoffs"); + $node->safe_psql('postgres', 'VACUUM vf'); + is( $node->safe_psql( + 'postgres', q{SELECT count(*), sum(length(v)) FROM vf}), + '25|75625', + "$cmd: then VACUUM: rows intact"); +} + +# FREEZE and a database-wide VACUUM (advances datfrozenxid) work too. +$node->safe_psql('postgres', 'VACUUM (FREEZE) vf'); +$node->safe_psql('postgres', 'VACUUM'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM vf'), + '25', 'rows after FREEZE'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/096_perbackend_redo_header_image.pl b/src/test/recovery/t/096_perbackend_redo_header_image.pl new file mode 100644 index 0000000000000..cae84457860e5 --- /dev/null +++ b/src/test/recovery/t/096_perbackend_redo_header_image.pl @@ -0,0 +1,117 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A transaction's first undo record links the previous transaction's header +# to it (uur_next). When that is the first change to the header's undo page +# since the last checkpoint, the WAL record carries a full-page image of the +# page. If a later discard then removes the whole segment file holding the +# page and the server crashes, redo of the link finds the page neither in +# shared buffers nor on disk: it has to come from the image. Redo used to +# ignore undo page images, read a zeroed page, and fail an assertion. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_redo_header_image'); +$node->init; +$node->append_conf( + 'postgresql.conf', q( +autovacuum = off +checkpoint_timeout = 1h +max_wal_size = 4GB +)); +$node->start; +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION amcheck; +CREATE TABLE t (id int PRIMARY KEY, v text) USING flux; +INSERT INTO t SELECT g, repeat('x', 100) FROM generate_series(1, 20000) g; +)); + +# Hold the discard horizon back, so that the first transaction's undo is +# still there when the second one links its header. +my $hold = $node->background_psql('postgres'); +$hold->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;'); + +my $s = $node->background_psql('postgres'); +$s->query_safe("UPDATE t SET v = 'a' WHERE id = 1"); +my $logno = $s->query_safe( + "SELECT logno FROM pg_stat_get_undo_logs() + WHERE pid = pg_backend_pid() AND persistence = 'permanent'"); +$s->query_safe('CHECKPOINT'); + +# The link to the first transaction's header: the first change to its page +# since the checkpoint, hence a full-page image. Check the replay of this +# record against the primary's pages too. +$s->query_safe("SET wal_consistency_checking = 'FLUX'"); +$s->query_safe('DELETE FROM t WHERE id = 2'); +$s->query_safe('RESET wal_consistency_checking'); + +# More than a segment of undo, so that discarding it all removes the segment +# file holding the header page. +for (my $lo = 2; $lo < 20000; $lo += 1000) +{ + $s->query_safe( + "UPDATE t SET v = repeat('y', 100) + WHERE id > $lo AND id <= $lo + 1000"); +} +$hold->query_safe('COMMIT'); + +ok( $node->poll_query_until( + 'postgres', qq( +SELECT ('x' || discard)::bit(64)::bigint & ((1::bigint << 40) - 1) >= 1048576 + FROM pg_stat_get_undo_logs() WHERE logno = $logno)), + 'the discard worker discards past the first segment'); +my $seg0 = $node->data_dir . sprintf('/base/undo/%06X.0000000000', $logno); +ok(!-e $seg0, 'the segment file holding the header page is gone'); + +$node->stop('immediate'); +$s->{run}->kill_kill; +$hold->{run}->kill_kill; + +my $integrity = q( +SELECT count(*), count(*) FILTER (WHERE id = 1 AND v = 'a'), + count(*) FILTER (WHERE id = 2), + count(*) FILTER (WHERE id > 2 AND v = repeat('y', 100)) + FROM t); + +ok($node->start(fail_ok => 1), + 'crash recovery restores the header page from its image'); +my $log = slurp_file($node->logfile); +unlike($log, qr/TRAP:|PANIC:/, 'no assertion failure or PANIC in recovery'); + +foreach my $round ( + 'after crash recovery', + 'after a second crash', + 'after a clean restart') +{ + if ($round eq 'after a second crash') + { + $node->stop('immediate'); + $node->start; + } + elsif ($round eq 'after a clean restart') + { + $node->restart; + } + is($node->safe_psql('postgres', $integrity), + '19999|1|0|19998', "committed changes intact $round"); + is( $node->safe_psql('postgres', "SELECT bt_index_check('t_pkey', true)"), + '', + "index consistent with the table $round"); +} + +# The recovered undo log still takes undo and rolls it back. +$node->safe_psql( + 'postgres', q( +BEGIN; +UPDATE t SET v = 'z' WHERE id <= 100; +DELETE FROM t WHERE id BETWEEN 101 AND 200; +ROLLBACK; +)); +ok($node->poll_query_until('postgres', $integrity, '19999|1|0|19998'), + 'a rollback after recovery restores every row'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/097_flux_vacuum_truncate.pl b/src/test/recovery/t/097_flux_vacuum_truncate.pl new file mode 100644 index 0000000000000..6c636e84d7492 --- /dev/null +++ b/src/test/recovery/t/097_flux_vacuum_truncate.pl @@ -0,0 +1,107 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# FLUX: VACUUM truncates an empty tail with heap's protocol. A session +# holding ROW EXCLUSIVE makes the AccessExclusiveLock attempt give up, and +# VACUUM VERBOSE must then report no pages truncated; once the lock is free +# the whole empty tail goes and VERBOSE reports the pages it truncated. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_truncate'); +$node->init; +$node->append_conf('postgresql.conf', "autovacuum = off\n"); +$node->append_conf('postgresql.conf', + "shared_preload_libraries = 'injection_points'\n") + if ($ENV{enable_injection_points} // '') eq 'yes'; +$node->start; + +my $stderr; +$node->safe_psql( + 'postgres', q{ +CREATE TABLE ft (id int, pad text) USING flux; +INSERT INTO ft SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +DELETE FROM ft WHERE id > 1; +}); +my $fpages = + $node->safe_psql('postgres', q{SELECT pg_relation_size('ft') / 8192}); +my $holder = $node->background_psql('postgres'); +$holder->query_safe('BEGIN'); +$holder->query_safe('LOCK ft IN ROW EXCLUSIVE MODE'); +$node->psql('postgres', 'VACUUM VERBOSE ft', stderr => \$stderr); +like( + $stderr, + qr/stopping truncate due to conflicting lock request/, + 'FLUX: truncation gives up behind a conflicting lock'); +like( + $stderr, + qr/truncated 0 pages/, + 'FLUX: VACUUM VERBOSE reports no truncation when it gave up'); +is($node->safe_psql('postgres', q{SELECT pg_relation_size('ft') / 8192}), + $fpages, 'FLUX: the table kept its pages'); +$holder->query_safe('COMMIT'); +$holder->quit; +$node->psql('postgres', 'VACUUM VERBOSE ft', stderr => \$stderr); +my $want = $fpages - 1; +like( + $stderr, + qr/truncated $want pages/, + 'FLUX: VACUUM VERBOSE reports the pages it truncated'); +is($node->safe_psql('postgres', q{SELECT pg_relation_size('ft') / 8192}), + '1', 'FLUX: empty tail truncated'); + +# Rows written into the empty tail after VACUUM found it, but before it +# truncates, must survive: the truncation gives up when the relation grew and +# otherwise recounts the tail under its AccessExclusiveLock. Hold VACUUM +# there with an injection point and write as many rows as were deleted, so +# that they reach the last page whether they go to the free pages or extend +# the relation. +SKIP: +{ + skip 'injection points not supported by this build', 2 + if ($ENV{enable_injection_points} // '') ne 'yes'; + skip 'Extension injection_points not installed', 2 + if !$node->check_extension('injection_points'); + + $node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE TABLE fr (id int, pad text) USING flux WITH (autovacuum_enabled = off); +INSERT INTO fr SELECT g, repeat('r', 200) FROM generate_series(1, 20000) g; +DELETE FROM fr WHERE id > 1; +SELECT injection_points_attach('flux-vacuum-before-truncate', 'wait'); +}); + my $vacuumer = $node->background_psql('postgres'); + $vacuumer->query_until( + qr/started/, q{ +\echo started +VACUUM fr; +}); + $node->wait_for_event('client backend', 'flux-vacuum-before-truncate'); + $node->safe_psql( + 'postgres', q{ +INSERT INTO fr SELECT g, repeat('w', 200) FROM generate_series(100001, 120000) g; +}); + $node->safe_psql( + 'postgres', q{ +SELECT injection_points_wakeup('flux-vacuum-before-truncate'); +SELECT injection_points_detach('flux-vacuum-before-truncate'); +}); + $vacuumer->quit; + + is($node->safe_psql('postgres', q{SELECT count(*) FROM fr}), + '20001', 'FLUX: rows written during VACUUM survive truncation'); + is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM fr WHERE id > 100000 AND pad = repeat('w', 200)} + ), + '20000', + 'FLUX: their values are intact'); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/100_flux_vacuum_recorded_tids.pl b/src/test/recovery/t/100_flux_vacuum_recorded_tids.pl new file mode 100644 index 0000000000000..77c051740782f --- /dev/null +++ b/src/test/recovery/t/100_flux_vacuum_recorded_tids.pl @@ -0,0 +1,142 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX VACUUM Phase III frees a dead line pointer only if Phase I recorded +# it, so that Phase II removed the index entries naming it. A row that dies +# between Phase I and Phase III stays LP_DEAD for the next VACUUM; were it +# set LP_UNUSED, the next INSERT would reuse a TID an index still names. +# +# The row that dies late is a committed DELETE that an older snapshot still +# saw at Phase I (recently dead, not recorded) and that opportunistic pruning +# marks LP_DEAD while VACUUM waits between Phase II and Phase III. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if (($ENV{enable_injection_points} // '') ne 'yes') +{ + plan skip_all => 'injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_vacuum_recorded'); +$node->init; +$node->append_conf('postgresql.conf', + "autovacuum = off\n" . "shared_preload_libraries = 'injection_points'\n"); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_visibility; +CREATE EXTENSION injection_points; +CREATE TABLE ab (id int, pad text) USING flux WITH (autovacuum_enabled = off); +CREATE INDEX ab_id ON ab (id); +INSERT INTO ab VALUES (0, repeat('x', 200)); +CREATE FUNCTION release_one(p text) RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event = p) THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup(p); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + RETURN false; +END $$; +}); + +# lp_flags of line pointer $lp on block 0 of $t; 0 if absent, -1 if the +# relation has no block 0 (so truncation fails an assertion, not the test). +my $lp_flags = sub { + my ($t, $lp) = @_; + return -1 + if $node->safe_psql('postgres', "SELECT pg_relation_size('$t')") == 0; + return $node->safe_psql('postgres', + "SELECT coalesce((SELECT lp_flags FROM heap_page_items(get_raw_page('$t', 0)) WHERE lp = $lp), 0)" + ); +}; + +# Fill page 0 so that opportunistic pruning runs on it (it only prunes a +# page with under 10% free space). Row 1 is lp 2. +$node->safe_psql('postgres', + "INSERT INTO ab SELECT g, repeat('x', 200) FROM generate_series(1, 34) g" +); +cmp_ok( + $node->safe_psql( + 'postgres', + "SELECT count(*) FROM heap_page_items(get_raw_page('ab', 0))"), + '>=', 30, + 'page 0 is nearly full'); + +# An old snapshot keeps row 1 visible while it is deleted, so VACUUM's +# Phase I sees it recently dead and does not record it. +my $old = $node->background_psql('postgres'); +$old->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +$old->query_safe('SELECT count(*) FROM ab'); +$node->safe_psql('postgres', 'DELETE FROM ab WHERE id = 1'); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('flux-vacuum-before-phase3', 'wait')"); +my $vac = $node->background_psql('postgres'); +$vac->query_until(qr/start/, "\\echo start\nVACUUM ab;\n"); +$node->wait_for_event('client backend', 'flux-vacuum-before-phase3'); + +# The snapshot goes away; a seq scan prunes row 1 with a fresh horizon, +# leaving it LP_DEAD while its index entry, never recorded, survives. +$old->query_safe('COMMIT'); +$old->quit; +$node->safe_psql('postgres', 'SELECT count(*) FROM ab'); +is($lp_flags->('ab', 2), '3', + 'pruning left row 1 LP_DEAD while VACUUM waits'); + +$node->safe_psql('postgres', + "SELECT injection_points_detach('flux-vacuum-before-phase3')"); +$node->poll_query_until('postgres', + "SELECT release_one('flux-vacuum-before-phase3')") + or die 'VACUUM was not released'; +$vac->query_safe('SELECT 1'); # VACUUM has finished + +isnt($lp_flags->('ab', 2), + '0', 'VACUUM does not free a line pointer that Phase I did not record'); + +# The LP_DEAD slot left behind keeps the page out of the visibility map, so +# the next VACUUM visits the page and frees the slot, as heap's does. +is( $node->safe_psql( + 'postgres', + "SELECT all_visible OR all_frozen FROM pg_visibility_map('ab', 0)"), + 'f', + 'a page with an LP_DEAD slot is not marked all-visible'); +$node->safe_psql('postgres', 'VACUUM ab'); +is($lp_flags->('ab', 2), '0', 'the next VACUUM frees the LP_DEAD slot'); + +# Reuse: the freed TID is reused only now that no index entry names it. +$node->safe_psql('postgres', + "INSERT INTO ab SELECT g, 'y' FROM generate_series(100, 140) g"); +is( $node->safe_psql( + 'postgres', q{SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM ab WHERE id = 1}), + '0', + 'the stale index entry for id 1 leads to no row'); +is( $node->safe_psql( + 'postgres', q{SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM ab}), + $node->safe_psql( + 'postgres', q{SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM ab}), + 'index scan and seq scan agree'); +is($node->safe_psql('postgres', "SELECT bt_index_check('ab_id', true)"), + '', 'bt_index_check heapallindexed passes'); + +$node->safe_psql('postgres', 'VACUUM ab'); +is($node->safe_psql('postgres', "SELECT bt_index_check('ab_id', true)"), + '', 'bt_index_check passes after a further VACUUM'); +$vac->quit; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/101_flux_external_wal.pl b/src/test/recovery/t/101_flux_external_wal.pl new file mode 100644 index 0000000000000..6cc7d150016e6 --- /dev/null +++ b/src/test/recovery/t/101_flux_external_wal.pl @@ -0,0 +1,94 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Rows with out-of-line (EXTERNAL) columns survive WAL replay on FLUX: +# INSERT, a re-storing UPDATE, an indexed-column UPDATE, DELETE and VACUUM of +# ~12 kB EXTERNAL rows. Replay runs on a streaming standby and in crash +# recovery, both with wal_consistency_checking = all. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', q{ +wal_consistency_checking = all +autovacuum = off +}); +$primary->start; +$primary->backup('bkp'); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->start; + +my @ams = ('flux'); + +# About 12.8 kB of incompressible text, stored out of line. +sub big +{ + my ($seed) = @_; + return "(SELECT string_agg(md5($seed || x::text), '') " + . "FROM generate_series(1, 400) x)"; +} +my $ins_big = big('g::text'); +my $upd_big = big("'u' || id::text"); + +foreach my $am (@ams) +{ + my $t = "ovf_$am"; + $primary->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int PRIMARY KEY, b int, big text) USING $am; +ALTER TABLE $t ALTER COLUMN big SET STORAGE EXTERNAL; +CREATE INDEX ${t}_b ON $t (b); +INSERT INTO $t SELECT g, g * 10, $ins_big FROM generate_series(1, 6) g; +UPDATE $t SET big = $upd_big WHERE id IN (2, 4); +UPDATE $t SET b = b + 1 WHERE id IN (1, 3); +DELETE FROM $t WHERE id = 5; +VACUUM $t; +DELETE FROM $t WHERE id = 6; +VACUUM $t; +UPDATE $t SET b = b + 1 WHERE id = 3; +)); +} + +sub rowsum +{ + my ($node, $am) = @_; + return $node->safe_psql('postgres', + "SELECT count(*) || ' ' || md5(string_agg(id || ':' || b || ':' || big, ',' ORDER BY id)) " + . "FROM ovf_$am"); +} + +my %want = map { $_ => rowsum($primary, $_) } @ams; +like($want{$_}, qr/^4 /, "$_: four rows on the primary") for @ams; + +$primary->wait_for_catchup($standby); +is(rowsum($standby, $_), $want{$_}, "$_: standby matches the primary") + for @ams; + +foreach my $round (1 .. 2) +{ + $primary->stop('immediate'); + $primary->start; + is(rowsum($primary, $_), + $want{$_}, "$_: unchanged after crash restart $round") + for @ams; +} + +# The standby replays the same WAL again from its own restart point. +$standby->stop('immediate'); +$standby->start; +$primary->wait_for_catchup($standby); +is(rowsum($standby, $_), + $want{$_}, "$_: standby unchanged after crash restart") + for @ams; + +ok(!$primary->log_contains('PANIC'), 'primary replayed without PANIC'); +ok(!$standby->log_contains('PANIC'), 'standby replayed without PANIC'); + +done_testing(); diff --git a/src/test/recovery/t/102_flux_insert_redo_consistency.pl b/src/test/recovery/t/102_flux_insert_redo_consistency.pl new file mode 100644 index 0000000000000..39a549af8efa8 --- /dev/null +++ b/src/test/recovery/t/102_flux_insert_redo_consistency.pl @@ -0,0 +1,67 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# An INSERT into a page whose free space holds stale bytes must replay to a +# page that wal_consistency_checking accepts. +# +# PageAddItem copies exactly the item's length and leaves the alignment +# padding up to the next MAXALIGN boundary as it was. On the primary that +# is whatever an earlier tuple left there before VACUUM compacted the page; +# on a standby that restored the page from a full-page image, whose hole is +# zero-filled, it is zero. The padding is not part of any tuple, so the +# masking function must ignore it, as heap_mask does. Heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf('postgresql.conf', + "autovacuum = off\nwal_consistency_checking = all\n"); +$node->start; +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +foreach my $am (qw(heap flux)) +{ + my $t = "t_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, pad text) USING $am WITH (autovacuum_enabled = off); +INSERT INTO $t SELECT g, repeat('x', 200) FROM generate_series(1, 30) g; +DELETE FROM $t WHERE id % 3 <> 0; +}); + # VACUUM leaves LP_UNUSED slots and compacts the page; the stale 'x' + # bytes of the deleted rows are now in the page's free space. + $node->safe_psql('postgres', "VACUUM $t"); + # The next change to the page after a checkpoint carries a full-page + # image, which a standby restores with a zeroed hole. + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', "INSERT INTO $t VALUES (100, 'a')"); + # These land in the old free space. Lengths 1..8 make sure some items + # are not MAXALIGNed and so are followed by padding. + $node->safe_psql('postgres', + "INSERT INTO $t SELECT 100 + g, repeat('y', g) FROM generate_series(1, 8) g" + ); + + # wait_for_catchup dies if the standby's startup process has exited. + my $caught_up = eval { $node->wait_for_catchup($standby); 1 }; + ok($caught_up && $standby->is_alive, + "$am: standby replays INSERT into a compacted page"); + last unless $caught_up && $standby->is_alive; + is( $standby->safe_psql( + 'postgres', "SELECT count(*), sum(length(pad)) FROM $t"), + $node->safe_psql( + 'postgres', "SELECT count(*), sum(length(pad)) FROM $t"), + "$am: standby sees the primary's rows"); +} + +ok(!$standby->log_contains('inconsistent page found'), + 'standby found no inconsistent page'); + +$standby->stop('fast', fail_ok => 1); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/103_flux_nonpermanent_wal.pl b/src/test/recovery/t/103_flux_nonpermanent_wal.pl new file mode 100644 index 0000000000000..3f17e025f5f76 --- /dev/null +++ b/src/test/recovery/t/103_flux_nonpermanent_wal.pl @@ -0,0 +1,246 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# DML on an UNLOGGED or TEMP table writes no WAL proportional to the rows it +# touches, for FLUX as for heap: not for the table, not for its indexes or +# TOAST table, not for the per-backend undo that FLUX writes for it, and not +# for rolling that undo back, whether the backend or an undo apply worker does +# it. After a crash an unlogged FLUX table is empty and a temp one is gone, +# and recovery does not roll back the undo either left behind. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('nonperm_wal'); +$node->init(allows_streaming => 'logical'); +$node->append_conf( + 'postgresql.conf', q( +autovacuum = off +checkpoint_timeout = 1h +max_wal_size = 10GB +)); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pg_walinspect'); + +# Each session first changes a permanent FLUX table, so it holds a permanent +# undo log whose meta precedes the next checkpoint: its first undo insert after +# that checkpoint would WAL-log the meta of whatever undo log it uses. +$node->safe_psql('postgres', + 'CREATE TABLE p (v int) USING flux; INSERT INTO p VALUES (0)'); + +# Has the discard worker discarded all of the unlogged undo? It cannot get +# past a rollback that has not been applied yet. +my $unlogged_discarded = q( +SELECT coalesce(bool_and(discard = insert), true) FROM pg_stat_get_undo_logs() + WHERE persistence = 'unlogged'); + +# The WAL each statement writes is recorded in the session's temp table lsn; +# writing a temp table writes no WAL itself. +sub measured +{ + my ($op, $sql) = @_; + return qq( +INSERT INTO lsn VALUES ('$op', pg_current_wal_insert_lsn(), NULL); +$sql; +UPDATE lsn SET e = pg_current_wal_insert_lsn() WHERE op = '$op'; +); +} + +my %bytes; +foreach my $overflow (16, 0) +{ + # 0 hands every unlogged rollback to an undo apply worker. + $node->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = $overflow"); + $node->reload; + foreach my $am ('heap', 'flux') + { + foreach my $pers ('unlogged', 'temp') + { + foreach my $n (10000, 100000) + { + my $case = "$am $pers $n overflow $overflow"; + my $s = $node->background_psql('postgres'); + $s->query_safe('UPDATE p SET v = v + 1'); + $node->safe_psql('postgres', 'CHECKPOINT'); + $s->query_safe( + qq( +SET client_min_messages = warning; +CREATE $pers TABLE w (id int, v text, t text) USING $am; +CREATE INDEX w_id ON w (id); +CREATE INDEX w_v ON w USING hash (v); +CREATE TEMP TABLE lsn (op text, s pg_lsn, e pg_lsn); +) + . measured( + 'insert', + "INSERT INTO w SELECT g, 'x' || g, CASE WHEN g % 100 = 0 + THEN repeat(md5(g::text), 300) END + FROM generate_series(1, $n) g") + . measured('update', "UPDATE w SET v = v || 'u'") + . measured( + 'rollback', + "BEGIN; UPDATE w SET v = v || 'r', t = t || 'r'; ROLLBACK" + ) + . measured( + 'aborted insert', + "BEGIN; INSERT INTO w SELECT -g, 'n' FROM generate_series(1, $n) g; ROLLBACK" + ) + . measured( + 'savepoint', + "BEGIN; SAVEPOINT s; UPDATE w SET v = v || 's'; DELETE FROM w WHERE id % 2 = 0; + ROLLBACK TO s; COMMIT") + . measured('delete', 'DELETE FROM w') . q( +INSERT INTO lsn VALUES ('settle', pg_current_wal_insert_lsn(), NULL); +)); + + # Let an undo apply worker finish the rollbacks and the + # discard worker discard their undo: whatever WAL they write + # for it is counted too. + $node->poll_query_until('postgres', $unlogged_discarded) + or die "$case: unlogged undo not discarded"; + $s->query_safe( + "UPDATE lsn SET e = pg_current_wal_insert_lsn() WHERE op = 'settle'" + ); + + # Flush the WAL, for pg_walinspect to read it. Commits that + # wrote no WAL are not flushed. + $node->safe_psql('postgres', 'CHECKPOINT'); + + # Every op restored or kept what it should. + is($s->query_safe("SELECT count(*) FROM w"), + '0', "$case: rows deleted"); + + # Full-page images for hint bits are of catalog pages, never of + # unlogged ones, and come and go with checkpoints: leave them + # out. + foreach my $row ( + split /\n/, + $s->query_safe( + q( +SELECT op || '|' || ((e - s) - CASE WHEN e > s THEN + (SELECT coalesce(sum(record_length + fpi_length), 0) + FROM pg_get_wal_records_info(s, e) + WHERE record_type = 'FPI_FOR_HINT') ELSE 0 END) + FROM lsn ORDER BY s))) + { + my ($op, $b) = split /\|/, $row; + $bytes{$case}{$op} = $b; + cmp_ok($b, '<', 65536, "$case: $op WAL is small"); + } + + # No WAL record in the whole run references a block of the + # table, its indexes, its TOAST table or a non-permanent undo + # log. + is( $s->query_safe( + q( +WITH r AS (SELECT oid FROM pg_class + WHERE oid IN ('w'::regclass, (SELECT reltoastrelid FROM pg_class WHERE oid = 'w'::regclass))), + f AS (SELECT pg_relation_filenode(oid) AS fn FROM r + UNION ALL + SELECT pg_relation_filenode(indexrelid) FROM pg_index + WHERE indrelid IN (SELECT oid FROM r)) +SELECT count(*) FROM pg_get_wal_block_info( + (SELECT s FROM lsn WHERE op = 'insert'), + (SELECT e FROM lsn WHERE op = 'settle')) + WHERE relfilenode IN (SELECT fn FROM f) + OR (reldatabase = 9 AND relfilenode IN + (SELECT logno FROM pg_stat_get_undo_logs() WHERE persistence <> 'permanent')) +)), + '0', + "$case: no WAL references the relation or its undo"); + + # Nor does any undo log record name a non-permanent undo log, + # except the one that creates it: the creation of any undo log + # is WAL-logged, which keeps replay's log numbers in step. + is( $s->query_safe( + q( +SELECT string_agg(record_type || ' ' || description, ', ') + FROM pg_get_wal_records_info( + (SELECT s FROM lsn WHERE op = 'insert'), + (SELECT e FROM lsn WHERE op = 'settle')) + WHERE resource_manager = 'UndoLog' AND record_type <> 'CREATE' + AND substring(description from 'logno (\d+)')::oid IN + (SELECT logno FROM pg_stat_get_undo_logs() WHERE persistence <> 'permanent') +)), + '', + "$case: no undo log record names a non-permanent undo log" + ); + $s->query_safe('DROP TABLE w'); + $s->quit; + } + + # The WAL does not grow with the number of rows. + my $small = "$am $pers 10000 overflow $overflow"; + my $big = "$am $pers 100000 overflow $overflow"; + foreach my $op (sort keys %{ $bytes{$big} }) + { + cmp_ok($bytes{$big}{$op}, '<', $bytes{$small}{$op} + 16384, + "$am $pers overflow $overflow: $op WAL does not grow with rows" + ); + } + } + } +} +$node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); +$node->reload; + +# A crash with FLUX transactions on an unlogged and on a temp table in +# flight. Recovery resets the unlogged table to empty and removes the temp +# table's files, and must not roll back the undo either of them left behind: +# that undo was never WAL-logged and describes pages that are gone. +$node->safe_psql( + 'postgres', q( +CREATE UNLOGGED TABLE cu (id int, v text) USING flux; +CREATE INDEX ON cu (id); +INSERT INTO cu SELECT g, 'orig' || g FROM generate_series(1, 10000) g; +)); +my $ut = $node->background_psql('postgres'); +$ut->query_safe( + "BEGIN; UPDATE cu SET v = 'inflight'; DELETE FROM cu WHERE id % 3 = 0;"); +my $tt = $node->background_psql('postgres'); +$tt->query_safe( + q( +CREATE TEMP TABLE ct (id int, v text) USING flux; +CREATE INDEX ON ct (id); +INSERT INTO ct SELECT g, 'orig' || g FROM generate_series(1, 10000) g; +BEGIN; +UPDATE ct SET v = 'inflight'; +)); +my $tpath = $node->data_dir . '/' + . $tt->query_safe("SELECT pg_relation_filepath('ct')"); +ok(-f $tpath, 'temp table file exists before the crash'); + +my $logstart = -s $node->logfile; +$node->stop('immediate'); +$ut->{run}->kill_kill; +$tt->{run}->kill_kill; +$node->start; + +is($node->safe_psql('postgres', 'SELECT count(*) FROM cu'), + '0', 'unlogged FLUX table is empty after the crash'); +ok(!-e $tpath, 'temp FLUX table files are gone after the crash'); +my $rlog = slurp_file($node->logfile, $logstart); +unlike( + $rlog, + qr/UNDO recovery: rolling back/, + 'crash recovery rolls back no undo of the unlogged and temp tables'); +unlike($rlog, qr/TRAP|PANIC/, + 'no assertion failure or PANIC after the crash'); + +# The reset table works. +$node->safe_psql( + 'postgres', q( +INSERT INTO cu SELECT g, 'orig' || g FROM generate_series(1, 100) g; +BEGIN; +UPDATE cu SET v = 'upd'; +ROLLBACK; +)); +is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM cu WHERE v = 'orig' || id"), + '100', + 'unlogged FLUX table rolls back after the crash'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/104_flux_index_aba_revert.pl b/src/test/recovery/t/104_flux_index_aba_revert.pl new file mode 100644 index 0000000000000..633169370087f --- /dev/null +++ b/src/test/recovery/t/104_flux_index_aba_revert.pl @@ -0,0 +1,203 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX: an indexed key that goes A -> B -> A in a transaction that rolls +# back, when the rollback is not applied at once. +# +# A FLUX UPDATE of an indexed column keeps the row at its TID: it inserts or +# revives (k_new, TID) and delete-marks (k_old, TID). The second UPDATE +# revives the (A, TID) the first one delete-marked. Reverting the second +# UPDATE must put (A, TID) back the way the first one left it, +# delete-marked, for reverting the first one to make it live again; removing +# it instead loses the row from the index. +# +# undo_rollback_overflow_size = 0 sends the rollback to the undo apply +# workers, which are held at the injection point pbu-undo-worker-before-apply +# while the row is read, and while a writer runs into the aborted version and +# reverts it (helping). Then the workers revert the rest. Each FLUX table +# has a heap twin that gets the same statements; heap never reuses a TID, so +# it is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_index_aba'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points, pageinspect or amcheck not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +-- ids of the rows with k = key, by index scan and by seqscan +CREATE FUNCTION by_k(t regclass, key int, idx bool) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + r text; +BEGIN + PERFORM set_config('enable_seqscan', CASE WHEN idx THEN 'off' ELSE 'on' END, true); + PERFORM set_config('enable_indexscan', CASE WHEN idx THEN 'on' ELSE 'off' END, true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + EXECUTE format('SELECT coalesce(string_agg(id::text, '','' ORDER BY id), '''') FROM %s WHERE k = $1', t) + INTO r USING key; + RETURN r; +END $$; +CREATE TABLE fx (id int PRIMARY KEY, j int, k int) USING flux; +CREATE TABLE hp (id int PRIMARY KEY, j int, k int) USING heap; +INSERT INTO fx SELECT g, 0, g FROM generate_series(1, 20) g; +INSERT INTO hp SELECT g, 0, g FROM generate_series(1, 20) g; +CREATE INDEX fx_k ON fx (k); +CREATE INDEX hp_k ON hp (k); +CREATE UNIQUE INDEX fx_uk ON fx (k); +CREATE UNIQUE INDEX hp_uk ON hp (k); +)); + +# Every key a row has held, for both tables and both scans. +my $agree = sub { + my ($what, @keys) = @_; + foreach my $key (@keys) + { + my $r = $node->safe_psql('postgres', + "SELECT by_k('fx', $key, true), by_k('fx', $key, false), " + . "by_k('hp', $key, true), by_k('hp', $key, false)"); + my ($fi, $fs, $hi, $hs) = split /\|/, $r, -1; + is("$fi|$fs|$hi", "$hs|$hs|$hs", + "$what: k = $key: FLUX index scan, FLUX seqscan, heap index scan match heap seqscan" + ); + } +}; +my $amcheck = sub { + my $what = shift; + foreach my $i ('fx_k', 'fx_uk', 'fx_pkey') + { + my ($ret, $out, $err) = + $node->psql('postgres', "SELECT bt_index_check('$i', true)"); + is($ret . $err, '0', "$what: bt_index_check($i, heapallindexed)"); + } +}; +my $release = sub { + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); + $node->poll_query_until('postgres', 'SELECT release_one()') + or die 'held undo apply workers were not released'; +}; + +# T1: rows 12 and 13 go A -> B -> A; the last UPDATE also sets j to +# 16908801 = 0x01020201, which reads the same in either byte order, so the +# page can be searched for the aborted version. +my $pending = + "SELECT position('\\x01020201'::bytea IN get_raw_page('fx', 0)) > 0"; +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +foreach my $t ('fx', 'hp') +{ + $node->safe_psql( + 'postgres', qq( +BEGIN; +UPDATE $t SET k = k + 3000000 WHERE id IN (12, 13); +UPDATE $t SET k = k - 3000000, j = 16908801 WHERE id IN (12, 13); +ROLLBACK; +)); +} +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +is($node->safe_psql('postgres', $pending), 't', "T1's rollback is pending"); +$agree->('revert pending', 12, 13, 3000012, 3000013); + +# A writer runs into row 13's aborted version and reverts it first. +$node->safe_psql('postgres', 'UPDATE fx SET j = 1 WHERE id = 13'); +$node->safe_psql('postgres', 'UPDATE hp SET j = 1 WHERE id = 13'); +$agree->('row 13 reverted by a writer', 12, 13, 3000012, 3000013); + +# The worker reverts row 12. +$release->(); +ok($node->poll_query_until('postgres', "SELECT NOT ($pending)"), + "worker applied T1's rollback"); +$agree->('worker applied the rollback', 12, 13, 3000012, 3000013); +$amcheck->('worker applied the rollback'); + +# Row 12's key is taken: no other row may take it, nor row 13's. +foreach my $t ('fx', 'hp') +{ + foreach my $key (12, 13) + { + my ($ret, $out, $err) = + $node->psql('postgres', "UPDATE $t SET k = $key WHERE id = 1"); + like( + $err, + qr/duplicate key value violates unique constraint/, + "$t: k = $key is still taken"); + } +} + +# And the same for a key moved away and back by a committed transaction, +# then by an aborted one whose rollback a writer reverts: the committed +# move leaves k = 14 free. +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +foreach my $t ('fx', 'hp') +{ + $node->safe_psql( + 'postgres', qq( +UPDATE $t SET k = 4000014 WHERE id = 14; +BEGIN; +UPDATE $t SET k = 14 WHERE id = 14; +UPDATE $t SET k = 4000014, j = 16908801 WHERE id = 14; +UPDATE $t SET k = 14 WHERE id = 14; +ROLLBACK; +)); +} +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +$agree->('committed move, aborted return pending', 14, 4000014); +$release->(); +ok($node->poll_query_until('postgres', "SELECT NOT ($pending)"), + "worker applied the aborted return"); +$agree->('committed move, aborted return applied', 14, 4000014); +$amcheck->('committed move, aborted return applied'); +foreach my $t ('fx', 'hp') +{ + is( $node->safe_psql( + 'postgres', "UPDATE $t SET k = 14 WHERE id = 2 RETURNING id"), + '2', + "$t: k = 14 is free once the row moved off it"); +} +$agree->('another row took the freed key', 2, 14, 4000014); + +$node->safe_psql('postgres', 'VACUUM fx, hp'); +$agree->('after VACUUM', 2, 12, 13, 14, 3000012, 3000013, 4000014); +$amcheck->('after VACUUM'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/106_flux_rewrite_pending_revert.pl b/src/test/recovery/t/106_flux_rewrite_pending_revert.pl new file mode 100644 index 0000000000000..58a835d29fc96 --- /dev/null +++ b/src/test/recovery/t/106_flux_rewrite_pending_revert.pl @@ -0,0 +1,442 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A FLUX table rewrite while an aborted transaction's revert is still pending. +# +# FLUX updates in place, so an aborted transaction whose rollback was handed +# to an undo apply worker (undo_rollback_overflow_size = 0) or that lost a +# crash leaves its images on the page until the undo is applied. Readers +# walk past them. A rewrite must copy exactly the committed state too: the +# undo records name the old relfilenode, so once the rewrite commits nothing +# can apply them any more. An aborted DELETE's row must not be lost, an +# aborted UPDATE's value must not be copied, an aborted INSERT must not come +# back, and the rewritten table must be indistinguishable from a heap twin +# that ran the same statements, on the primary and on a standby. +# +# Rewrites: VACUUM FULL, CLUSTER (sort and index scan), REPACK, ALTER TABLE +# ... SET TABLESPACE (a file copy), ALTER TABLE ... SET ACCESS METHOD heap, +# ALTER TABLE ... ALTER COLUMN ... TYPE, and REPACK (CONCURRENTLY), whose copy +# reads through the decoding's snapshot while the revert is pending. +# +# The live variant holds the undo apply worker at the injection point +# pbu-undo-worker-before-apply (see 084_perbackend_helping_once.pl). The +# crash variant keeps the worker from starting at all, as +# 076_flux_deferred_rollback.pl does, so that the revert is still pending +# after the restart. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# name => rewrite statements, with T for the table +my @rewrites = ( + [ vacfull => 'VACUUM FULL T' ], + [ + cluster_sort => + "SET enable_indexscan = off;\nCLUSTER (VERBOSE) T USING T_pkey;\nRESET enable_indexscan" + ], + [ + cluster_index => + "SET random_page_cost = 0;\nSET seq_page_cost = 1000;\nCLUSTER (VERBOSE) T USING T_pkey;\nRESET random_page_cost;\nRESET seq_page_cost" + ], + [ repack => 'REPACK T' ], + [ repack_conc => 'REPACK (CONCURRENTLY) T' ], + # Copies the file as it is (relation_copy_data) to a new relfilenumber. + [ set_tablespace => 'ALTER TABLE T SET TABLESPACE ts' ], + [ set_am_heap => 'ALTER TABLE T SET ACCESS METHOD heap' ], + [ alter_type => 'ALTER TABLE T ALTER COLUMN v TYPE bigint' ], + # The rewriting transaction reads its own new rows before it commits. + [ + alter_type_xact => + "BEGIN;\nALTER TABLE T ALTER COLUMN v TYPE bigint;\nUPDATE T SET pad = pad || '+';\nCOMMIT" + ],); + +# Run $sql once per table, T replaced by the table name. +sub each_tab +{ + my ($sql, @tabs) = @_; + return join("\n", map { (my $x = $sql) =~ s/\bT(?=\b|_)/$_/g; $x } @tabs); +} + +# Row 3's aborted value, 16908801 = 0x01020201, reads the same in either byte +# order, so the raw page can be searched for it. +sub on_page +{ + my $t = shift; + return + "SELECT position('\\x01020201'::bytea IN get_raw_page('$t', 0)) > 0"; +} + +# Content of a table as count and md5, through a seqscan and through the +# primary key. +sub content +{ + my ($node, $t) = @_; + my $q = + "SELECT count(*) || ':' || md5(coalesce(string_agg(x::text, ',' ORDER BY id), '')) FROM $t x"; + return $node->safe_psql('postgres', + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q;\n" + . "SET enable_seqscan = off; SET enable_indexscan = on; $q;"); +} + +# bt_index_check (heapallindexed) of one relation. bt_index_check returns +# void and reports corruption with an ERROR, so it passes when psql succeeds. +# Returns psql's stderr, empty on success. +sub bt_check +{ + my ($node, $rel) = @_; + my ($out, $err); + my $rc = $node->psql( + 'postgres', + "SELECT bt_index_check('$rel'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $rc == 0 && $err eq '' ? '' : "rc=$rc: $err"; +} + +# Every row's key is taken: a unique check (dirty snapshot) must see it. +sub dup_count +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', + "WITH i AS (INSERT INTO $t SELECT * FROM $t ON CONFLICT DO NOTHING RETURNING 1) SELECT count(*) FROM i" + ); +} + +# Check the rewritten FLUX table against its heap twin. +sub check_twin +{ + my ($node, $f, $h, $what) = @_; + is(content($node, $f), content($node, $h), "$f: same rows as $h, $what"); + is(dup_count($node, $f), '0', "$f: unique checks see every row, $what"); + my @idx = split /\n/, + $node->safe_psql('postgres', + "SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = '$f'::regclass ORDER BY 1" + ); + is(join(',', @idx), "${f}_k_idx,${f}_pkey", "$f: both indexes, $what"); + is(bt_check($node, $_), '', "$_: bt_index_check, $what") foreach @idx; + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM verify_heapam('$h')"), + '0', + "$h: verify_heapam, $what"); +} + +# The statements every table pair runs. First committed history: a +# committed DELETE and UPDATE, and a committed transaction with an +# UPDATE, DELETE and INSERT in a savepoint that is rolled back. +my $setup = q( +CREATE TABLE T (id int PRIMARY KEY, v int, k int, pad text) USING AM; +CREATE INDEX T_k_idx ON T (k); +INSERT INTO T SELECT g, g, g, 'p' || g FROM generate_series(1, 30) g; +DELETE FROM T WHERE id = 13; +UPDATE T SET v = v + 1 WHERE id = 14; +); +my $committed_sp = q( +BEGIN; +UPDATE T SET v = v + 1000 WHERE id = 10; +SAVEPOINT s; +UPDATE T SET v = 16974593 WHERE id = 11; +DELETE FROM T WHERE id = 12; +INSERT INTO T VALUES (104, 104, 104, 'sp'); +ROLLBACK TO s; +COMMIT; +); + +# The aborted transaction: DELETE; in-place UPDATE (same size, larger, of an +# indexed column, twice); a primary key UPDATE (new TID); INSERT; an INSERT +# that is then updated and deleted; and a savepoint with an UPDATE, DELETE +# and INSERT, rolled back to and released. +my $aborted = q( +DELETE FROM T WHERE id IN (1, 2); +UPDATE T SET v = 16908801 WHERE id = 3; +UPDATE T SET v = v + 100, pad = repeat('w', 60) WHERE id = 4; +UPDATE T SET k = k + 1000 WHERE id = 5; +UPDATE T SET id = 1006 WHERE id = 6; +UPDATE T SET v = 71 WHERE id = 7; +UPDATE T SET v = 72 WHERE id = 7; +INSERT INTO T VALUES (101, 101, 101, 'ins'); +INSERT INTO T VALUES (102, 102, 102, 'ins'); +UPDATE T SET v = 1020 WHERE id = 102; +DELETE FROM T WHERE id = 102; +SAVEPOINT s; +UPDATE T SET v = 999 WHERE id = 8; +DELETE FROM T WHERE id = 9; +INSERT INTO T VALUES (103, 103, 103, 'sp'); +ROLLBACK TO s; +RELEASE s; +UPDATE T SET v = v + 1 WHERE id = 15; +UPDATE T SET v = v + 1 WHERE id = 19; +DELETE FROM T WHERE id = 19; +); + +# After the rewrite, more DML on the rewritten table: nothing the stale undo +# or sLog state of the old file holds may interfere with it. +my $after = q( +UPDATE T SET v = v + 1; +DELETE FROM T WHERE id % 5 = 0; +INSERT INTO T SELECT g, g, g, 'a' FROM generate_series(201, 210) g; +UPDATE T SET k = k + 1 WHERE id IN (3, 4, 201); +); + +sub tabs_of +{ + my ($prefix) = @_; + return (map { ("${prefix}f_$_->[0]", "${prefix}h_$_->[0]") } @rewrites); +} + +sub create_tables +{ + my ($node, $prefix) = @_; + foreach my $rw (@rewrites) + { + foreach my $am ('flux', 'heap') + { + my $t = $prefix . substr($am, 0, 1) . "_$rw->[0]"; + (my $s = $setup) =~ s/\bAM\b/$am/; + $node->safe_psql('postgres', each_tab($s . $committed_sp, $t)); + } + } +} + +sub run_rewrites +{ + my ($node, $prefix) = @_; + foreach my $rw (@rewrites) + { + foreach my $am ('f', 'h') + { + my $t = "$prefix${am}_$rw->[0]"; + my ($out, $err); + my $rc = $node->psql( + 'postgres', + "SET client_min_messages = info;\n" . each_tab($rw->[1], $t), + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + is($rc, 0, "$t: $rw->[0] ran") or diag($err); + like( + $err, + qr/"public\.$t" using sequential scan and sort/, + "$t: CLUSTER sorts") if $rw->[0] eq 'cluster_sort'; + like( + $err, + qr/"public\.$t" using index scan/, + "$t: CLUSTER reads through the index" + ) if $rw->[0] eq 'cluster_index'; + } + } +} + +sub check_all +{ + my ($node, $prefix, $what) = @_; + # Control: bt_check must be able to fail. + like( + bt_check($node, "${prefix}h_vacfull"), + qr/ERROR: "${prefix}h_vacfull" is not an index/, + "control: bt_index_check of a table fails, $what"); + foreach my $rw (@rewrites) + { + check_twin($node, "${prefix}f_$rw->[0]", "${prefix}h_$rw->[0]", + $what); + } +} + +# Is any undo, or any ATM entry, still holding WAL back? +my $retained = q( +SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0 + AND (SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'permanent')); + +########################################################################### +# Live: the undo apply worker is held while the rewrites run. A standby +# replays all of it. +########################################################################### +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +allow_in_place_tablespaces = on +)); +$primary->start; +if ( !$primary->check_extension('injection_points') + || !$primary->check_extension('pageinspect') + || !$primary->check_extension('amcheck')) +{ + plan skip_all => 'injection_points, pageinspect or amcheck not installed'; +} +$primary->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE TABLESPACE ts LOCATION ''; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +create_tables($primary, ''); +my @tabs = tabs_of(''); +my @ftabs = grep { /^f_/ } @tabs; + +$primary->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +$primary->safe_psql('postgres', + "BEGIN;\n" . each_tab($aborted, @tabs) . "\nROLLBACK;"); +$primary->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); + +foreach my $t (@ftabs) +{ + is($primary->safe_psql('postgres', on_page($t)), + 't', "$t: the rollback is pending"); + (my $h = $t) =~ s/^f_/h_/; + is( content($primary, $t), + content($primary, $h), + "$t: readers see the committed rows before the rewrite"); +} + +run_rewrites($primary, ''); +check_all($primary, '', 'rewritten while the revert was pending'); +foreach my $t (@ftabs) +{ + is($primary->safe_psql('postgres', on_page($t)), + 'f', "$t: the aborted value was not copied"); +} +$primary->wait_for_replay_catchup($standby); +foreach my $t (@tabs) +{ + is( content($standby, $t), + content($primary, $t), + "$t: standby agrees after the rewrite"); +} + +# Let the held workers go: they find the records' file gone. +$primary->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); +ok($primary->poll_query_until('postgres', 'SELECT release_one()'), + 'held undo apply workers released'); +ok( $primary->poll_query_until('postgres', $retained), + 'no undo and no ATM entry left holding WAL'); +check_all($primary, '', 'after the undo apply worker'); + +$primary->safe_psql('postgres', each_tab($after, @tabs)); +check_all($primary, '', 'after more DML'); + +$primary->wait_for_replay_catchup($standby); +foreach my $t (@tabs) +{ + is( content($standby, $t), + content($primary, $t), + "$t: standby agrees with the primary"); +} + +$standby->stop; +$primary->stop; + +########################################################################### +# Crash: the revert is still pending after the restart, while no undo +# apply worker can start (see 076_flux_deferred_rollback.pl). +########################################################################### +# REPACK (CONCURRENTLY) needs wal_level replica and a background worker for +# its decoding, neither of which this node has; the live variant covers it. +@rewrites = grep { $_->[0] ne 'repack_conc' } @rewrites; +my $node = PostgreSQL::Test::Cluster->new('crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +max_parallel_maintenance_workers = 0 +# discard worker + UNDO worker + undo worker launcher: no room for an +# undo apply worker +max_worker_processes = 3 +allow_in_place_tablespaces = on +)); +$node->start; +unlike( + slurp_file($node->logfile), + qr/too many background workers/, + 'every static background worker registered'); +$node->safe_psql('postgres', + "CREATE EXTENSION pageinspect; CREATE EXTENSION amcheck; CREATE TABLESPACE ts LOCATION '';" +); +create_tables($node, 'c'); +@tabs = tabs_of('c'); +@ftabs = grep { /^cf_/ } @tabs; + +# One aborted transaction and one that the crash kills. +$node->safe_psql('postgres', + "BEGIN;\n" . each_tab($aborted, @tabs) . "\nROLLBACK;"); +my $bg = $node->background_psql('postgres'); +$bg->query_safe( + "BEGIN;\n" + . each_tab( + "UPDATE T SET v = v + 500 WHERE id IN (16, 17);\nDELETE FROM T WHERE id = 18;\nINSERT INTO T VALUES (105, 105, 105, 'loser');", + @tabs)); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'undo apply worker'" + ), + '0', + 'no undo apply worker ran'); +foreach my $t (@ftabs) +{ + is($node->safe_psql('postgres', on_page($t)), + 't', "$t: the rollback is pending after the crash"); +} + +run_rewrites($node, 'c'); +check_all($node, 'c', 'rewritten after the crash, revert pending'); + +# Now let the undo apply worker run. +$node->append_conf('postgresql.conf', 'max_worker_processes = 8'); +$node->restart; +ok( $node->poll_query_until('postgres', $retained), + 'crash: no undo and no ATM entry left holding WAL'); +check_all($node, 'c', 'crash, after the undo apply worker'); +$node->safe_psql('postgres', each_tab($after, @tabs)); +check_all($node, 'c', 'crash, after more DML'); + +foreach my $n ($primary, $standby, $node) +{ + unlike( + slurp_file($n->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + $n->name . ': no failed assertion, mismatched or abandoned undo'); +} +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/107_flux_rewrite_2pc.pl b/src/test/recovery/t/107_flux_rewrite_2pc.pl new file mode 100644 index 0000000000000..8befed5bc4bf4 --- /dev/null +++ b/src/test/recovery/t/107_flux_rewrite_2pc.pl @@ -0,0 +1,176 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX: PREPARE TRANSACTION after rows were written and the relation's storage +# was then replaced in the same transaction (SET ACCESS METHOD, ALTER COLUMN +# TYPE, CLUSTER, TRUNCATE), then COMMIT PREPARED or ROLLBACK PREPARED from +# another backend, with and without a restart in between. Heap is the +# control: every FLUX table must end up as its heap twin does, on the primary +# and on a streaming standby. +# +# FLUX remembers the tuples a transaction wrote by relation OID and TID. At +# PREPARE it marks the rows a rolled-back savepoint inserted as deleted; after +# a rewrite that work landed on the new storage, where the same TIDs name +# other rows (or are past its end). src/test/regress/sql/flux_rewrite_xact.sql +# covers COMMIT and ROLLBACK. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 100 +)); +$node->start; +my $have_amcheck = $node->check_extension('amcheck'); +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck') if $have_amcheck; +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +$node->safe_psql( + 'postgres', q{ +CREATE FUNCTION rx_val(k int) RETURNS text LANGUAGE sql IMMUTABLE + RETURN repeat(md5(k::text), 150); +-- What a reader sees: rows, their ids and keys, intact values. +CREATE FUNCTION rx_seen(rel regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE n bigint; ids bigint; ks bigint; good bigint; +BEGIN + EXECUTE format('SELECT count(*), sum(id), sum(k), + count(*) FILTER (WHERE w = rx_val(k)) FROM %s', rel) + INTO n, ids, ks, good; + RETURN format('rows=%s ids=%s ks=%s good=%s', n, ids, ks, good); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || SQLERRM; +END $$; +-- What a writer finds, rolled back so the standby keeps the same rows. +CREATE FUNCTION rx_found(rel regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE lck bigint; upd bigint; del bigint; r text; +BEGIN + BEGIN + EXECUTE format('SELECT count(*) FROM (SELECT 1 FROM %s FOR UPDATE) x', rel) + INTO lck; + EXECUTE format('WITH x AS (UPDATE %s SET w = w RETURNING 1) + SELECT count(*) FROM x', rel) INTO upd; + EXECUTE format('WITH x AS (DELETE FROM %s RETURNING 1) + SELECT count(*) FROM x', rel) INTO del; + r := format('lock=%s upd=%s del=%s', lck, upd, del); + RAISE EXCEPTION USING ERRCODE = 'P0099', MESSAGE = r; + EXCEPTION WHEN SQLSTATE 'P0099' THEN + RETURN SQLERRM; + END; +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || SQLERRM; +END $$; +}); + +# name => transaction body; $T is the table, $AM its access method. 20 rows +# are committed first. The rows written in the transaction have the same +# width as those, so the TIDs of the old and the new storage collide. +my $ins21 = + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g'; +my $ins41 = + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g'; +my $refill = + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g'; +my $sp = 'SAVEPOINT a; ' . $ins21 . '; ROLLBACK TO a'; +my %cases = ( + fh => "$ins21; ALTER TABLE \$T SET ACCESS METHOD heap", + fhf => "$ins21; ALTER TABLE \$T SET ACCESS METHOD heap; " + . "ALTER TABLE \$T SET ACCESS METHOD \$AM", + sp_fh => "$sp; $ins41; ALTER TABLE \$T SET ACCESS METHOD heap", + trunc => "TRUNCATE \$T; $refill", + sp_trunc => "$sp; TRUNCATE \$T; $refill", + act => "$ins21; ALTER TABLE \$T ALTER COLUMN id TYPE bigint", + sp_act => "$sp; $ins41; ALTER TABLE \$T ALTER COLUMN id TYPE bigint", + cluster => + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0; ' + . "DELETE FROM \$T WHERE id % 5 = 0; $ins21; CLUSTER \$T USING \$T_pkey", + sp_trunc_act => 'SAVEPOINT a; ' + . 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g; ' + . 'ROLLBACK TO a; TRUNCATE $T; ' + . "ALTER TABLE \$T ALTER COLUMN id TYPE bigint; $refill",); + +# Each case on each AM, then COMMIT PREPARED or ROLLBACK PREPARED, before or +# after a restart. +my @runs; +foreach my $name (sort keys %cases) +{ + foreach my $fin ('commit', 'rollback') + { + foreach my $when ('live', 'restart') + { + foreach my $am ('heap', 'flux') + { + my $t = "rx_${name}_${fin}_${when}_$am"; + (my $body = $cases{$name}) =~ s/\$T/$t/g; + $body =~ s/\$AM/$am/g; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, k int, w text) USING $am; +INSERT INTO $t SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g; +}); + my ($ret, $out, $err) = $node->psql('postgres', + "BEGIN; $body; PREPARE TRANSACTION '$t';"); + is($ret, 0, "$t: PREPARE TRANSACTION") + or diag($err); + push @runs, [ $name, $fin, $when, $am, $t ]; + } + } + } +} + +sub resolve +{ + my ($when) = @_; + foreach my $r (grep { $_->[2] eq $when } @runs) + { + my ($name, $fin, $w, $am, $t) = @$r; + my ($ret, $out, $err) = $node->psql('postgres', + ($fin eq 'commit' ? 'COMMIT' : 'ROLLBACK') . " PREPARED '$t'"); + is($ret, 0, "$t: $fin prepared from another backend") + or diag($err); + } +} +resolve('live'); +$node->restart; +resolve('restart'); +$node->wait_for_replay_catchup($standby); + +foreach my $r (grep { $_->[3] eq 'heap' } @runs) +{ + my ($name, $fin, $when, $am, $heap) = @$r; + (my $flux = $heap) =~ s/_heap$/_flux/; + my $what = "$name, $fin prepared, $when"; + + my $want = $node->safe_psql('postgres', "SELECT rx_seen('$heap')"); + is($node->safe_psql('postgres', "SELECT rx_seen('$flux')"), + $want, "$what: rows on the primary"); + is($standby->safe_psql('postgres', "SELECT rx_seen('$flux')"), + $want, "$what: rows on the standby"); + is( $node->safe_psql('postgres', "SELECT rx_found('$flux')"), + $node->safe_psql('postgres', "SELECT rx_found('$heap')"), + "$what: rows UPDATE, DELETE and FOR UPDATE find"); + + # A rewrite to heap must leave a valid heap. + if ($have_amcheck + && $node->safe_psql('postgres', + "SELECT a.amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam " + . "WHERE c.oid = '$flux'::regclass") eq 'heap') + { + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM verify_heapam('$flux')"), + '0', + "$what: verify_heapam"); + } +} + +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '0', 'no prepared transactions left'); + +done_testing(); diff --git a/src/test/recovery/t/108_flux_tuple_layout.pl b/src/test/recovery/t/108_flux_tuple_layout.pl new file mode 100644 index 0000000000000..72739b3e58013 --- /dev/null +++ b/src/test/recovery/t/108_flux_tuple_layout.pl @@ -0,0 +1,209 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Check the compact header and tuple layout through rollback and replay. +# The next UPDATE must still use byte-range CAS WAL. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf('postgresql.conf', + "autovacuum = off\nwal_consistency_checking = all\n"); +$node->start; +if ( !$node->check_extension('pageinspect') + || !$node->check_extension('pg_walinspect')) +{ + plan skip_all => 'pageinspect or pg_walinspect not installed'; +} + +# The undo apply worker connects to the database it reverts. The worker +# case uses its own database, rr, so that the worker can be kept out of it +# (ALLOW_CONNECTIONS false) while its rollback is checked to be pending. +$node->safe_psql('postgres', 'CREATE DATABASE rr'); +foreach my $db ('postgres', 'rr') +{ + $node->safe_psql($db, + 'CREATE EXTENSION pageinspect; CREATE EXTENSION pg_walinspect;'); +} +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +# The first change to the page after VACUUM marked it all-visible clears +# the visibility map in its own record, which the CAS record cannot do, so it +# takes the exclusive path. Make it to the other row, so that the UPDATEs +# below find a page that is not all-visible. +sub make_table +{ + my ($db, $t) = @_; + $node->safe_psql( + $db, qq{ +CREATE TABLE $t (id int, v int) USING flux; +INSERT INTO $t VALUES (1, 0), (2, 0); +VACUUM $t; +UPDATE $t SET v = 0 WHERE id = 2; +}); +} + +# Two int columns: 36-byte header, bitmap and alignment to 40, then 8 bytes. +sub layout +{ + my ($n, $db, $t, $id) = @_; + return $n->safe_psql( + $db, qq{ +SELECT lp_len || ':' || get_byte(get_raw_page('$t', 0), lp_off + 36) + FROM heap_page_items(get_raw_page('$t', 0)) + WHERE lp = (SELECT (ctid::text::point)[1]::int FROM $t WHERE id = $id)}); +} + +# The FLUX WAL records that $sql emits. +sub flux_records +{ + my ($db, $sql) = @_; + my $s = $node->safe_psql($db, 'SELECT pg_current_wal_insert_lsn()'); + $node->safe_psql($db, $sql); + my $e = $node->safe_psql($db, 'SELECT pg_current_wal_insert_lsn()'); + return $node->safe_psql( + $db, qq{ +SELECT string_agg(record_type, ',') FROM pg_get_wal_records_info('$s', '$e') + WHERE resource_manager = 'FLUX'}); +} + +my $rows = sub { + my $t = shift; + return "SELECT string_agg(id || ':' || v, ',' ORDER BY id) FROM $t"; +}; + +# What is left on the page once a rollback is done. +sub check_row +{ + my ($db, $t, $id, $want, $what) = @_; + is($node->safe_psql($db, $rows->($t)), $want, "$what: rows restored"); + is(layout($node, $db, $t, $id), + '48:255', "$what: header and bitmap layout matches on the primary"); + $node->wait_for_catchup($standby); + is(layout($standby, $db, $t, $id), + '48:255', "$what: header and bitmap layout matches on the standby"); + is(flux_records($db, "UPDATE $t SET v = v + 1 WHERE id = $id"), + 'CAS_UPDATE_UNDO', "$what: the next UPDATE takes the CAS path"); + is(layout($node, $db, $t, $id), + '48:255', + "$what: header and bitmap layout matches after that UPDATE"); +} + +# Inline ROLLBACK. The control shows that an UPDATE of the row takes the +# CAS path in this setup in the first place. +make_table('postgres', 'ti'); +is(flux_records('postgres', 'UPDATE ti SET v = v + 1 WHERE id = 1'), + 'CAS_UPDATE_UNDO', + 'control: an UPDATE of a fresh row takes the CAS path'); +$node->safe_psql('postgres', + 'BEGIN; UPDATE ti SET v = v + 10 WHERE id = 1; ROLLBACK;'); +check_row('postgres', 'ti', 1, '1:1,2:0', 'inline ROLLBACK'); + +# Two in a row: on the old code the second UPDATE took the slow path over +# the stale word, and its rollback restored the stale word again. +$node->safe_psql('postgres', + 'BEGIN; UPDATE ti SET v = v + 10 WHERE id = 1; ROLLBACK;'); +$node->safe_psql('postgres', + 'BEGIN; UPDATE ti SET v = v + 10 WHERE id = 1; ROLLBACK;'); +check_row('postgres', 'ti', 1, '1:2,2:0', 'two inline ROLLBACKs'); + +# ROLLBACK TO SAVEPOINT. +make_table('postgres', 'ts'); +$node->safe_psql('postgres', + 'BEGIN; SAVEPOINT s; UPDATE ts SET v = v + 10 WHERE id = 1; ROLLBACK TO s; COMMIT;' +); +check_row('postgres', 'ts', 1, '1:0,2:0', 'ROLLBACK TO SAVEPOINT'); + +# An ERROR inside what used to be the locked window: the write-skew UPDATE +# fails in CheckForSerializableConflictIn, after the eligibility checks and +# before the page is written. +make_table('postgres', 'tx'); +my $s1 = $node->background_psql('postgres', on_error_stop => 0); +my $s2 = $node->background_psql('postgres', on_error_stop => 0); +$s1->query_safe( + 'BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT v FROM tx WHERE id = 1;'); +$s2->query_safe( + 'BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT v FROM tx WHERE id = 2;'); +$s1->query_safe('UPDATE tx SET v = 1 WHERE id = 2;'); +$s1->query_safe('COMMIT;'); +$s2->{stderr} = ''; +$s2->query('UPDATE tx SET v = 1 WHERE id = 1;'); +like( + $s2->{stderr}, + qr/could not serialize access.*during write/s, + 'SSI: the write-skew UPDATE fails while writing'); +$s2->{stderr} = ''; +$s2->query_safe('ROLLBACK;'); +$s1->quit; +$s2->quit; +check_row('postgres', 'tx', 1, '1:0,2:1', 'SSI serialization failure'); + +# Crash recovery. A committed transaction flushes the WAL, the open +# UPDATE's record with it, so the restarted server has it to undo. Its +# value, 16908801 = 0x01020201, reads the same in either byte order, so the +# page can be searched for it. +my $aborted = sub { + my $t = shift; + return + "SELECT position('\\x01020201'::bytea IN get_raw_page('$t', 0)) > 0"; +}; +make_table('postgres', 'tc'); +my $s3 = $node->background_psql('postgres', on_error_stop => 0); +$s3->query_safe('BEGIN; UPDATE tc SET v = 16908801 WHERE id = 1;'); +$node->safe_psql('postgres', 'CREATE TABLE flush_wal ()'); +is($node->safe_psql('postgres', $aborted->('tc')), + 't', 'crash: the open UPDATE is on the page'); +$node->stop('immediate'); +eval { $s3->quit; }; +$node->start; +ok($node->poll_query_until('postgres', $aborted->('tc'), 'f'), + 'crash: the UPDATE was undone'); +check_row('postgres', 'tc', 1, '1:0,2:0', 'crash recovery'); + +# The undo apply worker. +$node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); +$node->reload; +ok($node->poll_query_until('rr', 'SHOW undo_rollback_overflow_size', '0'), + 'worker: undo_rollback_overflow_size is 0'); +make_table('rr', 'tw'); +my $bg = $node->background_psql('rr', on_error_stop => 0); +$node->safe_psql('postgres', 'ALTER DATABASE rr ALLOW_CONNECTIONS false'); +ok( $node->poll_query_until( + 'postgres', q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + 'worker: no undo apply worker is connected'); +$bg->query_safe('BEGIN; UPDATE tw SET v = 16908801 WHERE id = 1; ROLLBACK;'); +is($bg->query_safe($aborted->('tw')), + 't', 'worker: the rollback is left to the worker'); +$bg->quit; +$node->safe_psql('postgres', 'ALTER DATABASE rr ALLOW_CONNECTIONS true'); +ok($node->poll_query_until('rr', $aborted->('tw'), 'f'), + 'worker: the worker applied the rollback'); +check_row('rr', 'tw', 1, '1:0,2:0', 'undo apply worker'); + +$node->wait_for_catchup($standby); +foreach my $c ( + [ 'postgres', 'ti' ], + [ 'postgres', 'ts' ], + [ 'postgres', 'tx' ], + [ 'postgres', 'tc' ], + [ 'rr', 'tw' ]) +{ + is( $standby->safe_psql($c->[0], $rows->($c->[1])), + $node->safe_psql($c->[0], $rows->($c->[1])), + "$c->[1]: the standby sees the primary's rows"); +} +ok($standby->is_alive, 'standby is alive'); +ok(!$standby->log_contains('inconsistent page found'), + 'standby found no inconsistent page'); + +$standby->stop('fast', fail_ok => 1); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/109_flux_savepoint_lock_2pc.pl b/src/test/recovery/t/109_flux_savepoint_lock_2pc.pl new file mode 100644 index 0000000000000..19c1445664302 --- /dev/null +++ b/src/test/recovery/t/109_flux_savepoint_lock_2pc.pl @@ -0,0 +1,137 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A row lock taken in a savepoint that is rolled back leaves nothing behind, +# however the transaction ends: COMMIT, PREPARE TRANSACTION and COMMIT +# PREPARED, or ROLLBACK PREPARED. Another session updates the row and +# commits after the savepoint rollback. isolation/flux_savepoint_lock_ +# rollback runs more variants without two-phase commit. Heap is the control. +# +# FLUX used to mark the other session's committed image DELETED when the +# locker committed or prepared, on the primary only and with no WAL: a plain +# SELECT still returned the row, but UPDATE, DELETE and row locks did not +# find it, and the standby had a different page. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +max_prepared_transactions = 5 +)); +$node->start; +if ( !$node->check_extension('pageinspect') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'pageinspect or amcheck not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION pageinspect; CREATE EXTENSION amcheck;'); +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +# The FLUX_TUPLE_DELETED bit (0x0004 of t_flags, at byte 32) of row 1. +sub deleted_bit +{ + my ($n, $t) = @_; + return $n->safe_psql( + 'postgres', qq{ +SELECT get_byte(get_raw_page('$t', 0), lp_off + 32) & 4 + FROM heap_page_items(get_raw_page('$t', 0)) + WHERE lp = (SELECT (ctid::text::point)[1]::int FROM $t WHERE id = 1)}); +} + +my $s1 = $node->background_psql('postgres', on_error_stop => 0); +my $n = 0; +my @tables; +foreach my $am ('heap', 'flux') +{ + foreach my $mode ('FOR UPDATE', 'FOR NO KEY UPDATE', 'FOR SHARE', + 'FOR KEY SHARE') + { + foreach my $end ('COMMIT', 'COMMIT PREPARED', 'ROLLBACK PREPARED') + { + $n++; + my $t = "t_${am}_$n"; + my $what = "$am, $mode, $end"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int) USING $am; +INSERT INTO $t VALUES (1, 0), (2, 0); +VACUUM $t; +}); + $s1->query_safe( + "BEGIN; SAVEPOINT a; SELECT v FROM $t WHERE id = 1 $mode; ROLLBACK TO a;" + ); + $node->safe_psql('postgres', + "UPDATE $t SET v = v + 1 WHERE id = 1"); + if ($end eq 'COMMIT') + { + $s1->query_safe('COMMIT'); + } + else + { + $s1->query_safe("PREPARE TRANSACTION 'p$n'"); + $node->safe_psql('postgres', "$end 'p$n'"); + } + + is( $node->safe_psql('postgres', "SELECT v FROM $t WHERE id = 1"), + '1', + "$what: SELECT finds the row"); + is( $node->safe_psql( + 'postgres', "SELECT v FROM $t WHERE id = 1 FOR KEY SHARE" + ), + '1', + "$what: FOR KEY SHARE finds the row"); + is( $node->safe_psql( + 'postgres', "SELECT v FROM $t WHERE id = 1 FOR UPDATE"), + '1', + "$what: FOR UPDATE finds the row"); + is( $node->safe_psql( + 'postgres', qq{ +BEGIN; +UPDATE $t SET v = v + 100 WHERE id = 1; +DELETE FROM $t WHERE id = 1 RETURNING v; +ROLLBACK;}), + '101', + "$what: UPDATE, then DELETE of its new version"); + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('${t}_pkey', true)"), + '', + "$what: amcheck finds every row in the index"); + is(deleted_bit($node, $t), '0', "$what: row 1 is not DELETED") + if $am eq 'flux'; + push @tables, [ $t, $what, $am ]; + } + } +} +$s1->quit; + +$node->wait_for_catchup($standby); +foreach my $c (@tables) +{ + my ($t, $what, $am) = @$c; + is( $standby->safe_psql( + 'postgres', + "SELECT string_agg(id || ':' || v, ',' ORDER BY id) FROM $t"), + '1:1,2:0', + "$what: the standby has the rows"); + is( deleted_bit($standby, $t), + deleted_bit($node, $t), + "$what: the standby's row 1 has the primary's DELETED bit" + ) if $am eq 'flux'; +} +ok($standby->is_alive, 'standby is alive'); +ok(!$standby->log_contains('inconsistent page found'), + 'standby found no inconsistent page'); + +$standby->stop('fast', fail_ok => 1); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/110_flux_unique_pending_revert.pl b/src/test/recovery/t/110_flux_unique_pending_revert.pl new file mode 100644 index 0000000000000..340f1d1c51db0 --- /dev/null +++ b/src/test/recovery/t/110_flux_unique_pending_revert.pl @@ -0,0 +1,462 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX uniqueness and exclusion checks while an aborted transaction's revert +# is still pending. +# +# FLUX updates in place, so an aborted transaction whose rollback was handed +# to an undo apply worker (undo_rollback_overflow_size = 0) leaves its images +# on the page until the undo is applied; an in-place UPDATE also re-stamps +# t_xmin to the updater. A uniqueness check (SnapshotDirty, SnapshotSelf) +# must still see the row at its committed before-image: a PRIMARY KEY, a +# UNIQUE constraint (immediate and deferred), an exclusion constraint (gist, +# hash) and ON CONFLICT must all behave as on a heap twin that ran the same +# statements. The same with an inline rollback, with the conflicting writer +# still in progress (the check must wait for it), after the undo apply +# worker has applied the rollback, and on a standby. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# The aborted value, 16908801 = 0x01020201, reads the same in either byte +# order, so the raw page can be searched for it. +my $X = 16908801; + +# name, DDL (T = the table), aborted statements, probe, expected probe result +# ("ok:" and the rows it returned, or the SQLSTATE of its error). +my @cases = ( + [ + upd_nonkey => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_key_old => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_key_new => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) RETURNING *", "ok:$X|333" + ], + [ + uq_inplace_old => 'id int, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (33, 3)', '23505' + ], + [ + uq_inplace_new => 'id int, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", + "INSERT INTO T VALUES (33, $X) RETURNING *", "ok:33|$X" + ], + [ + del_then_ins => 'id int PRIMARY KEY, v int', + "DELETE FROM T WHERE id = 3; INSERT INTO T VALUES (3, $X)", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + del => 'id int PRIMARY KEY, v int', + 'DELETE FROM T WHERE id = 3', + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + oc_nothing => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT DO NOTHING RETURNING *', + 'ok:' + ], + [ + oc_update => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000 RETURNING *', + 'ok:3|1003' + ], + [ + oc_update_key_old => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000 RETURNING *', + 'ok:3|1003' + ], + [ + oc_update_key_new => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000 RETURNING *", + "ok:$X|333" + ], + [ + oc_nothing_key_old => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT DO NOTHING RETURNING *', + 'ok:' + ], + [ + oc_nothing_key_new => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) ON CONFLICT DO NOTHING RETURNING *", + "ok:$X|333" + ], + [ + oc_update_uq_old => 'id int PRIMARY KEY, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (33, 3) ON CONFLICT (v) DO UPDATE SET id = T.id + 1000 RETURNING *', + 'ok:1003|3' + ], + # An in-place UPDATE of another indexed column: the primary key entry + # is live, the row's image says "keys updated". + [ + sec_index => 'id int PRIMARY KEY, v int, k int UNIQUE', + "UPDATE T SET k = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + # The same with the other index created in the aborted transaction. + [ + sec_index_aborted => 'id int PRIMARY KEY, v int, k int', + "CREATE INDEX ON T (k); UPDATE T SET k = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + deferrable => + 'id int, v int, UNIQUE (id) DEFERRABLE INITIALLY DEFERRED', + "UPDATE T SET v = $X WHERE id = 3", + 'BEGIN; INSERT INTO T VALUES (3, 333); COMMIT', '23505' + ], + [ + deferrable_key => + 'id int, v int, UNIQUE (id) DEFERRABLE INITIALLY DEFERRED', + "UPDATE T SET id = $X WHERE id = 3", + 'BEGIN; INSERT INTO T VALUES (3, 333); COMMIT', '23505' + ], + [ + gist_excl => 'id int, v int, EXCLUDE USING gist (id WITH =)', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + gist_excl_key_old => 'id int, v int, EXCLUDE USING gist (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + gist_excl_key_new => 'id int, v int, EXCLUDE USING gist (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) RETURNING *", "ok:$X|333" + ], + [ + btree_excl_key_old => + 'id int, v int, EXCLUDE USING btree (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + btree_excl_key_new => + 'id int, v int, EXCLUDE USING btree (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) RETURNING *", "ok:$X|333" + ], + [ + hash_excl => 'id int, v int, EXCLUDE USING hash (id WITH =)', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + upd_grow => 'id int PRIMARY KEY, v int, pad text', + "UPDATE T SET v = $X, pad = repeat('x', 3000) WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_del => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3; DELETE FROM T WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_twice => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3; UPDATE T SET id = 1003 WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ],); + +# Run $sql with T replaced by the table name. +sub tab +{ + my ($sql, $t) = @_; + $sql =~ s/\bT\b/$t/g; + return $sql; +} + +# Run a probe: "ok:" and its rows, or the SQLSTATE of its error. +sub probe +{ + my ($node, $sql) = @_; + my ($out, $err); + $node->psql( + 'postgres', "\\set VERBOSITY sqlstate\n$sql", + stdout => \$out, + stderr => \$err); + return $1 if $err =~ /ERROR:\s+([0-9A-Z]{5})/; + return "ok:" . join(',', split(/\n/, $out)); +} + +# Content of a table through a seqscan and through each index. +sub content +{ + my ($node, $t) = @_; + my $q = + "SELECT count(*) || ':' || coalesce(string_agg(id || '/' || v, ',' ORDER BY id, v), '') FROM $t"; + return $node->safe_psql('postgres', + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q;\n" + . "SET enable_seqscan = off; SET enable_indexscan = on; $q;"); +} + +sub on_page +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', + "SELECT bool_or(position('\\x01020201'::bytea IN get_raw_page('$t', b::int)) > 0) FROM generate_series(0, pg_relation_size('$t') / 8192 - 1) b" + ); +} + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$primary->start; +if ( !$primary->check_extension('injection_points') + || !$primary->check_extension('pageinspect') + || !$primary->check_extension('amcheck') + || !$primary->check_extension('btree_gist')) +{ + plan skip_all => + 'injection_points, pageinspect, amcheck or btree_gist not installed'; +} +$primary->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION btree_gist; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +my @all; # every table, for the later checks + +# One aborted transaction per case and access method, then the probe. +sub run_cases +{ + my ($mode) = @_; + foreach my $c (@cases) + { + my ($name, $cols, $aborted, $probe, $expect) = @$c; + my %res; + foreach my $am ('flux', 'heap') + { + my $t = "${mode}_${name}_" . substr($am, 0, 1); + push @all, $t; + $primary->safe_psql('postgres', + "CREATE TABLE $t ($cols) USING $am;\n" + . "INSERT INTO $t (id, v) SELECT g, g FROM generate_series(1, 5) g;\n" + . "BEGIN;\n" + . tab($aborted, $t) + . ";\nROLLBACK;"); + is(on_page($primary, $t), 't', "$t: the rollback is pending") + if $mode eq 'deferred' && $am eq 'flux' && $aborted =~ /$X/; + $res{$am} = probe($primary, tab($probe, $t)); + is($res{$am}, $expect, "$t: probe"); + } + my $f = "${mode}_${name}_f"; + (my $h = $f) =~ s/_f$/_h/; + is($res{flux}, $res{heap}, "$f: probe as heap"); + is(content($primary, $f), content($primary, $h), "$f: rows as heap"); + } +} + +# A uniqueness check against a writer that is still running must wait for +# it, and then decide by its outcome. +sub run_waits +{ + my ($mode) = @_; + my $pk = 'id int PRIMARY KEY, v int'; + my $ins = 'INSERT INTO T VALUES (3, 333)'; + my $ocn = 'INSERT INTO T VALUES (3, 333) ON CONFLICT DO NOTHING'; + my $ocu = + 'INSERT INTO T VALUES (3, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000'; + my $upd = "UPDATE T SET v = $X WHERE id = 3"; + my $updk = "UPDATE T SET id = $X WHERE id = 3"; + my $del = 'DELETE FROM T WHERE id = 3'; + my @waits = ( + [ 'upd_nonkey_abort', $pk, $upd, 'ROLLBACK', $ins, '23505' ], + [ 'upd_key_abort', $pk, $updk, 'ROLLBACK', $ins, '23505' ], + [ 'upd_nonkey_commit', $pk, $upd, 'COMMIT', $ins, '23505' ], + [ 'upd_key_commit', $pk, $updk, 'COMMIT', $ins, 'ok:' ], + [ 'del_abort', $pk, $del, 'ROLLBACK', $ins, '23505' ], + [ 'del_commit', $pk, $del, 'COMMIT', $ins, 'ok:' ], + [ 'upd_key_abort_ocn', $pk, $updk, 'ROLLBACK', $ocn, 'ok:' ], + [ 'upd_key_commit_ocn', $pk, $updk, 'COMMIT', $ocn, 'ok:' ], + [ 'upd_key_abort_ocu', $pk, $updk, 'ROLLBACK', $ocu, 'ok:' ], + [ 'upd_key_commit_ocu', $pk, $updk, 'COMMIT', $ocu, 'ok:' ], + [ + 'uq_key_abort', 'id int, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", 'ROLLBACK', + 'INSERT INTO T VALUES (33, 3)', '23505' + ], + [ + 'gist_key_abort', 'id int, v int, EXCLUDE USING gist (id WITH =)', + $updk, 'ROLLBACK', $ins, '23P01' + ], + [ + 'gist_key_commit', + 'id int, v int, EXCLUDE USING gist (id WITH =)', + $updk, 'COMMIT', $ins, 'ok:' + ],); + + foreach my $w (@waits) + { + my ($name, $cols, $writer, $end, $probe, $expect) = @$w; + my %res; + foreach my $am ('flux', 'heap') + { + my $t = "${mode}_wait_${name}_" . substr($am, 0, 1); + push @all, $t; + $primary->safe_psql('postgres', + "CREATE TABLE $t ($cols) USING $am;\n" + . "INSERT INTO $t SELECT g, g FROM generate_series(1, 5) g;" + ); + my $s1 = $primary->background_psql('postgres'); + my $s2 = + $primary->background_psql('postgres', on_error_stop => 0); + $s1->query_safe("BEGIN; " . tab($writer, $t)); + $s2->query_safe("\\set VERBOSITY sqlstate"); + my $pid = $s2->query_safe('SELECT pg_backend_pid()'); + $s2->query_until(qr/go/, "\\echo go\n" . tab($probe, $t) . ";\n"); + ok( $primary->poll_query_until( + 'postgres', + "SELECT wait_event_type = 'Lock' FROM pg_stat_activity WHERE pid = $pid" + ), + "$t: the unique check waits for the writer"); + $s1->query_safe($end); + $s2->query("SELECT 'synced'"); + $res{$am} = + $s2->{stderr} =~ /ERROR:\s+([0-9A-Z]{5})/ ? $1 : 'ok:'; + $s1->quit; + $s2->quit; + is($res{$am}, $expect, "$t: outcome after the writer ended"); + } + my $f = "${mode}_wait_${name}_f"; + (my $h = $f) =~ s/_f$/_h/; + is($res{flux}, $res{heap}, "$f: outcome as heap"); + is(content($primary, $f), content($primary, $h), "$f: rows as heap"); + } +} + +# Inline rollback first, then with the undo apply worker held. +$primary->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = '1GB'; SELECT pg_reload_conf()" +); +run_cases('inline'); +run_waits('inline'); +$primary->safe_psql('postgres', + "ALTER SYSTEM RESET undo_rollback_overflow_size; SELECT pg_reload_conf()" +); +$primary->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +run_cases('deferred'); +run_waits('deferred'); +ok( $primary->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'pbu-undo-worker-before-apply'" + ), + 'undo apply workers are held'); + +# A standby sees what the primary sees. +$primary->wait_for_replay_catchup($standby); +foreach my $t (@all) +{ + is(content($standby, $t), content($primary, $t), "$t: standby agrees"); +} + +# Let the undo apply workers go, then check again: same rows as the heap +# twin, every key still taken, every btree index intact. +$primary->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); +ok($primary->poll_query_until('postgres', 'SELECT release_one()'), + 'held undo apply workers released'); +ok( $primary->poll_query_until( + 'postgres', + 'SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0'), + 'every rollback applied'); +foreach my $t (grep { /_f$/ } @all) +{ + (my $h = $t) =~ s/_f$/_h/; + is( content($primary, $t), + content($primary, $h), + "$t: rows as heap after the undo apply worker"); + is( $primary->safe_psql( + 'postgres', + "WITH i AS (INSERT INTO $t SELECT * FROM $t ON CONFLICT DO NOTHING RETURNING 1) SELECT count(*) FROM i" + ), + '0', + "$t: unique checks see every row after the undo apply worker" + ) unless $t =~ /deferrable/; + is( probe($primary, "INSERT INTO $t VALUES (3, 3)"), + probe($primary, "INSERT INTO $h VALUES (3, 3)"), + "$t: key 3 as heap after the undo apply worker"); + foreach my $i ( + split /\n/, + $primary->safe_psql( + 'postgres', + "SELECT i.indexrelid::regclass FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am a ON a.oid = c.relam WHERE i.indrelid = '$t'::regclass AND a.amname = 'btree'" + )) + { + my ($out, $err); + my $rc = $primary->psql( + 'postgres', + "SELECT bt_index_check('$i'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err); + is("$rc:$err", '0:', "$i: bt_index_check"); + } +} +$primary->wait_for_replay_catchup($standby); +foreach my $t (@all) +{ + is( content($standby, $t), + content($primary, $t), + "$t: standby agrees after the undo apply worker"); +} + +foreach my $n ($primary, $standby) +{ + unlike( + slurp_file($n->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + $n->name . ': no failed assertion, mismatched or abandoned undo'); +} +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/111_flux_index_build_pending_revert.pl b/src/test/recovery/t/111_flux_index_build_pending_revert.pl new file mode 100644 index 0000000000000..fc91a01a749bd --- /dev/null +++ b/src/test/recovery/t/111_flux_index_build_pending_revert.pl @@ -0,0 +1,420 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Index builds, ANALYZE and TABLESAMPLE on a FLUX table while an aborted +# transaction's revert is still pending. +# +# FLUX updates in place, so a transaction whose rollback was handed to an +# undo apply worker (undo_rollback_overflow_size = 0) leaves its images on +# the page until the worker applies the undo. A reader that looks at the +# page itself instead of through an MVCC snapshot must still see the row as +# the rollback will leave it: an aborted UPDATE's row at its before-image, an +# aborted DELETE's row alive, an aborted INSERT's row gone. An index built +# from the aborted images stays wrong after the revert, because the revert +# reverses index changes the new index never had. +# +# Each FLUX table has a heap twin that runs the same statements; the heap +# twin is the control. The undo apply worker is held at the injection point +# pbu-undo-worker-before-apply (see 106_flux_rewrite_pending_revert.pl), the +# checks run while it is held, after it has applied the undo, and after more +# DML. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# Index kinds. idx creates the index T_i on table T; pred(x) is a qual the +# index answers for the key x; order, if set, is an ORDER BY the index +# answers (with where, its qual); bt says amcheck can check it. +my %kind = ( + btree => { + idx => 'CREATE INDEX T_i ON T (k)', + pred => sub { "k = $_[0]" }, + order => 'k', + bt => 1 + }, + hash => { + idx => 'CREATE INDEX T_i ON T USING hash (k)', + pred => sub { "k = $_[0]" } + }, + gist => { + idx => "CREATE INDEX T_i ON T USING gist (int4range(k, k, '[]'))", + pred => + sub { "int4range(k, k, '[]') && int4range($_[0], $_[0], '[]')" } + }, + unique => { + idx => 'CREATE UNIQUE INDEX T_i ON T (k)', + pred => sub { "k = $_[0]" }, + order => 'k', + bt => 1 + }, + expr => { + idx => 'CREATE INDEX T_i ON T ((k * 2))', + pred => sub { "k * 2 = 2 * $_[0]" }, + order => 'k * 2', + bt => 1 + }, + partial => { + idx => 'CREATE INDEX T_i ON T (k) WHERE k < 1000', + pred => sub { "k = $_[0] AND k < 1000" }, + order => 'k', + where => 'k < 1000', + bt => 1 + }, + multi => { + idx => 'CREATE INDEX T_i ON T (k, v)', + pred => sub { "k = $_[0]" }, + order => 'k, v', + bt => 1 + },); + +# name => [index kind, index exists before the abort, statement after it] +my @cases = ( + (map { [ "create_$_", $_, 0, $kind{$_}{idx} ] } sort keys %kind), + [ reindex_index => 'btree', 1, 'REINDEX INDEX T_i' ], + [ reindex_table => 'btree', 1, 'REINDEX TABLE T' ], + [ reindex_conc => 'btree', 1, 'REINDEX INDEX CONCURRENTLY T_i' ], + # Its validate scan (table_index_validate_scan) runs as well. + [ cic => 'btree', 0, 'CREATE INDEX CONCURRENTLY T_i ON T (k)' ], + [ + parallel => 'btree', + 0, + "SET client_min_messages = debug1;\nCREATE INDEX T_i ON T (k)" + ], + [ + parallel_unique => 'unique', + 0, + "SET client_min_messages = debug1;\nCREATE UNIQUE INDEX T_i ON T (k)" + ],); + +# Run $sql once per table, T replaced by the table name. +sub each_tab +{ + my ($sql, @tabs) = @_; + return join("\n", map { (my $x = $sql) =~ s/\bT(?=\b|_)/$_/g; $x } @tabs); +} + +# 2000 rows, about 60 pages: enough for a parallel build. v = id % 50, so +# every value of v is equally common. +my $setup = q( +CREATE TABLE T (id int PRIMARY KEY, k int, v int, pad text) USING AM; +INSERT INTO T SELECT g, g, g % 50, repeat('p', 200) FROM generate_series(1, 2000) g; +); + +# The aborted transaction. 424242 and 31337 are values no committed row +# has; v = 13 is a value it deletes. +my $aborted = q( +UPDATE T SET k = k + 1000000, v = 424242 WHERE id IN (3, 4, 1500); +UPDATE T SET k = 20 WHERE id = 8; +UPDATE T SET v = 424242 WHERE id BETWEEN 100 AND 160; +DELETE FROM T WHERE id IN (5, 1600); +DELETE FROM T WHERE v = 13; +INSERT INTO T SELECT g, g, 31337, 'ins' FROM generate_series(5001, 5060) g; +UPDATE T SET k = k + 1 WHERE id = 5001; +UPDATE T SET v = 71 WHERE id = 7; +UPDATE T SET v = 72 WHERE id = 7; +UPDATE T SET k = k + 2000000 WHERE id = 9; +DELETE FROM T WHERE id = 9; +); + +my $after = q( +UPDATE T SET k = k + 10000 WHERE id IN (3, 4, 8, 120, 1500); +DELETE FROM T WHERE id IN (7, 1600); +INSERT INTO T VALUES (6001, 6001, 1, 'a'); +); + +# Committed and aborted keys. +my @probes = ( + 3, 4, 5, 7, 8, 9, 13, 20, 63, 100, 113, 120, 160, 999, 1500, 1600, 2000, + 1000003, 1000004, 1001500, 2000009, 5001, 5002, 5060, 10003, 6001); + +# enable_sort = off only for ORDER BY: with it, the probes' string_agg +# (... ORDER BY) would rather read presorted rows through the primary key. +my $idx_probe = "SET enable_seqscan = off; SET enable_bitmapscan = off;\n"; +my $idx_on = $idx_probe . "SET enable_sort = off;\n"; +my $seq_on = "SET enable_indexscan = off; SET enable_bitmapscan = off;\n"; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +max_worker_processes = 16 +max_parallel_workers = 8 +max_parallel_maintenance_workers = 2 +min_parallel_table_scan_size = 0 +maintenance_work_mem = '256MB' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points or amcheck not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); + +# stdout of $sql, and stderr if there is any. Never dies. +sub run +{ + my ($sql) = @_; + my ($out, $err) = ('', ''); + my $rc = $node->psql( + 'postgres', $sql, + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $err eq '' && $rc == 0 ? $out : "$out\nrc=$rc stderr: $err"; +} + +# bt_index_check (heapallindexed) of one index: '' on success. +sub bt_check +{ + my ($rel) = @_; + my ($out, $err) = ('', ''); + my $rc = $node->psql( + 'postgres', + "SELECT bt_index_check('$rel'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $rc == 0 && $err eq '' ? '' : "rc=$rc: $err"; +} + +sub content +{ + my ($t) = @_; + return run($seq_on + . "SELECT count(*) || ':' || md5(coalesce(string_agg(id || ':' || k || ':' || v || ':' || pad, ',' ORDER BY id), '')) FROM $t" + ); +} + +# One probe: the rows the qual finds for key $x. +sub probe +{ + my ($t, $kind, $x) = @_; + return + "SELECT '$x=' || coalesce(string_agg(r, ',' ORDER BY r), '') FROM (SELECT id || ':' || k || ':' || v AS r FROM $t WHERE " + . $kind{$kind}{pred}->($x) . ') s;'; +} + +# Every probe. +sub probes +{ + my ($t, $kind) = @_; + return join("\n", map { probe($t, $kind, $_) } @probes); +} + +sub ordered +{ + my ($t, $kind) = @_; + my $k = $kind{$kind}; + my $w = $k->{where} ? "WHERE $k->{where} " : ''; + return "SELECT id, k, v FROM $t ${w}ORDER BY $k->{order}"; +} + +sub check_case +{ + my ($c, $what) = @_; + my ($name, $kind) = @$c; + my ($f, $h) = ("f_$name", "h_$name"); + + is(content($f), content($h), "$f: same rows as $h, $what"); + + foreach my $t ($f, $h) + { + like( + run($idx_probe . 'EXPLAIN (COSTS OFF) ' . probe($t, $kind, 3)), + qr/Index Scan using ${t}_i /, + "$t: the probes use the index, $what"); + } + my $fi = run($idx_probe . probes($f, $kind)); + is( $fi, + run($seq_on . probes($f, $kind)), + "$f: index scan finds what a seqscan finds, $what"); + is( $fi, + run($idx_probe . probes($h, $kind)), + "$f: index scan finds what $h finds, $what"); + + if ($kind{$kind}{order}) + { + like( + run($idx_on . 'EXPLAIN (COSTS OFF) ' . ordered($f, $kind)), + qr/^\s*Index Scan using ${f}_i /m, + "$f: ORDER BY uses the index, $what"); + my $fo = run($idx_on . ordered($f, $kind)); + is( $fo, + run($seq_on . ordered($f, $kind)), + "$f: ORDER BY through the index = sorted seqscan, $what"); + is( $fo, + run($idx_on . ordered($h, $kind)), + "$f: ORDER BY through the index = $h, $what"); + } + + if ($kind{$kind}{bt}) + { + foreach my $t ($f, $h) + { + is(bt_check($_), '', "$_: bt_index_check, $what") + foreach ("${t}_i", "${t}_pkey"); + } + } +} + +# ANALYZE must not see the aborted values, and must see the rows the +# aborted DELETEs did not delete. +my $stats_q = q( +SELECT attname, null_frac, n_distinct, most_common_vals, most_common_freqs, + histogram_bounds +FROM pg_stats WHERE schemaname = 'public' AND tablename = 'T' AND attname IN ('k', 'v') +ORDER BY attname; +SELECT reltuples FROM pg_class WHERE relname = 'T'; +); + +sub check_stats +{ + my ($what) = @_; + $node->safe_psql('postgres', 'ANALYZE f_stats; ANALYZE h_stats'); + my $fs = run(each_tab($stats_q, 'f_stats')); + is( $fs, + run(each_tab($stats_q, 'h_stats')), + "f_stats: ANALYZE statistics = h_stats, $what"); + unlike( + $fs, + qr/424242|31337|100000[34]|1001500/, + "f_stats: no aborted value in the statistics, $what"); + + foreach my $m ('BERNOULLI (100)', 'SYSTEM (100)') + { + my $q = + "SELECT count(*) || ':' || md5(string_agg(id || ':' || k || ':' || v, ',' ORDER BY id)) FROM T TABLESAMPLE $m"; + is( run(each_tab($q, 'f_stats')), + run(each_tab($q, 'h_stats')), + "f_stats: TABLESAMPLE $m = h_stats, $what"); + } +} + +my $retained = q( +SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0 + AND (SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'permanent')); + +my $held = + "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'pbu-undo-worker-before-apply'"; + +########################################################################### +# Set up, abort, build while the revert is pending. +########################################################################### +my @tabs; +foreach my $c (@cases, [ 'stats', undef, 0 ]) +{ + foreach my $am ('flux', 'heap') + { + my $t = substr($am, 0, 1) . "_$c->[0]"; + (my $s = $setup) =~ s/\bAM\b/$am/; + $s .= $kind{ $c->[1] }{idx} . ";\n" if $c->[2]; + $node->safe_psql('postgres', each_tab($s, $t)); + push @tabs, $t; + } +} + +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +$node->safe_psql('postgres', + "BEGIN;\n" . each_tab($aborted, @tabs) . "\nROLLBACK;"); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); + +foreach my $c (@cases) +{ + foreach my $t ("f_$c->[0]", "h_$c->[0]") + { + my ($out, $err) = ('', ''); + my $rc = $node->psql( + 'postgres', each_tab($c->[3], $t), + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + is($rc, 0, "$t: $c->[0] ran") or diag($err); + like( + $err, + qr/building index "${t}_i" on table "$t" with request for 2 parallel workers/, + "$t: the build is parallel") if $c->[0] =~ /^parallel/; + } +} + +is($node->safe_psql('postgres', $held), + '1', 'the undo apply worker is still held'); +check_case($_, 'built while the revert was pending') foreach @cases; +check_stats('while the revert is pending'); +is($node->safe_psql('postgres', $held), + '1', 'the undo apply worker is still held after the checks'); + +# get_actual_variable_endpoint (selfuncs.c) reads the index with +# SnapshotNonVacuumable through the index-only-scan machinery, so it would +# see whatever the table AM reports for that snapshot. It is not reachable +# for a FLUX table: get_relation_info sets canreturn = false for every +# index of a delete-marking table (a delete-marked entry can carry a stale +# key), and get_actual_variable_range skips indexes with !canreturn[0]. +# Show that: no index-only scan for FLUX, while the heap twin has one. +{ + my $q = "${idx_on}EXPLAIN (COSTS OFF) SELECT k FROM T WHERE k > 1999000"; + unlike( + run(each_tab($q, 'f_create_btree')), + qr/Index Only Scan/, + 'FLUX: no index-only scan, so no endpoint probe'); + like( + run(each_tab($q, 'h_create_btree')), + qr/Index Only Scan/, + 'heap: index-only scan (control)'); +} + +# Control: bt_check must be able to fail. +like( + bt_check('h_stats'), + qr/"h_stats" is not an index/, + 'control: bt_index_check of a table fails'); + +########################################################################### +# Let the undo apply worker go. +########################################################################### +$node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); +ok($node->poll_query_until('postgres', 'SELECT release_one()'), + 'held undo apply worker released'); +ok($node->poll_query_until('postgres', $retained), 'the undo was applied'); +check_case($_, 'after the undo apply worker') foreach @cases; +check_stats('after the undo apply worker'); + +$node->safe_psql('postgres', each_tab($after, @tabs)); +check_case($_, 'after more DML') foreach @cases; + +unlike( + slurp_file($node->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + 'no failed assertion, mismatched or abandoned undo'); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/112_flux_index_no_orphan.pl b/src/test/recovery/t/112_flux_index_no_orphan.pl new file mode 100644 index 0000000000000..d19fc699a5177 --- /dev/null +++ b/src/test/recovery/t/112_flux_index_no_orphan.pl @@ -0,0 +1,355 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# Inspect physical leaf entries, not just rows visible through an index scan. +# No VACUUM is run: undo must remove every newly inserted aborted key itself. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'injection points required' + if $ENV{enable_injection_points} ne 'yes'; +my $node = PostgreSQL::Test::Cluster->new('flux_no_orphan', port => 5890); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +wal_consistency_checking = all +)); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE EXTENSION pg_buffercache; +CREATE TABLE flush_marker(i int); +}); +$node->backup('base'); +my $standby = + PostgreSQL::Test::Cluster->new('flux_no_orphan_standby', port => 5891); +$standby->init_from_backup($node, 'base', has_streaming => 1); +$standby->start; + +# Byte-order-independent integer keys, including on big-endian machines. +my ($a, $b, $c) = (16843009, 33686018, 50529027); +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SET enable_indexonlyscan = off; '; + +sub make_table +{ + my ($t) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t(k int, j int, marker int, padding text) USING flux; +CREATE INDEX ${t}_k ON $t(k); +INSERT INTO $t VALUES ($a, $a, 0, repeat('x', 500)); +}); +} + +sub key_count +{ + my ($t, $hex) = @_; + return "SELECT count(*) FROM bt_page_items('${t}_k', 1) " + . "WHERE data LIKE '$hex $hex $hex $hex%'"; +} + +sub check_clean +{ + my ($t, $what) = @_; + $node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (0)'); + $node->wait_for_catchup($standby); + foreach my $n ($node, $standby) + { + is($n->safe_psql('postgres', key_count($t, '02')), + '0', "$what: no B entry on " . $n->name); + is($n->safe_psql('postgres', key_count($t, '03')), + '0', "$what: no C entry on " . $n->name); + is( $n->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM bt_page_items('${t}_k', 1) +WHERE data LIKE '01 01 01 01%' AND itemlen = 16 AND NOT dead +}), + '1', + "$what: exactly one live A entry on " . $n->name); + is( $n->safe_psql( + 'postgres', $idx . "SELECT count(*) FROM $t WHERE k = $a"), + '1', + "$what: A reaches row on " . $n->name); + } +} + +sub wait_reverted +{ + my ($t) = @_; + # Raw page inspection cannot help revert the row itself. + ok( $node->poll_query_until( + 'postgres', + "SELECT position('\\x04040404'::bytea IN get_raw_page('$t', 0)) = 0" + ), + "$t: worker restored the tuple"); +} +make_table('inline_one'); +$node->safe_psql('postgres', "BEGIN; UPDATE inline_one SET k = $b; ROLLBACK"); +check_clean('inline_one', 'inline single update'); + +foreach my $path ([ 'aba', [ $b, $a ] ], [ 'abca', [ $b, $c, $a ] ]) +{ + my ($t, $keys) = @$path; + make_table($t); + $node->safe_psql('postgres', + 'BEGIN; ' + . join('; ', map { "UPDATE $t SET k = $_" } @$keys) + . '; ROLLBACK'); + check_clean($t, $t); +} +make_table('savepoint_one'); +$node->safe_psql( + 'postgres', qq{ +BEGIN; SAVEPOINT s; UPDATE savepoint_one SET k = $b; +ROLLBACK TO s; COMMIT; +}); +check_clean('savepoint_one', 'savepoint'); + +$node->append_conf('postgresql.conf', 'undo_rollback_overflow_size = 0'); +$node->restart; +make_table('worker_one'); +$node->safe_psql( + 'postgres', qq{ +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +BEGIN; UPDATE worker_one SET k = $b, marker = 67372036; ROLLBACK; +}); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +is($node->safe_psql('postgres', key_count('worker_one', '02')), + '1', 'worker rollback has not run'); +$node->safe_psql( + 'postgres', q{ +SELECT injection_points_detach('pbu-undo-worker-before-apply'); +SELECT injection_points_wakeup('pbu-undo-worker-before-apply'); +}); +wait_reverted('worker_one'); +check_clean('worker_one', 'background worker'); + +make_table('crash_one'); +my $bg = $node->background_psql('postgres'); +$bg->query_safe("BEGIN; UPDATE crash_one SET k = $b, marker = 67372036"); +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (1)'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; +wait_reverted('crash_one'); +check_clean('crash_one', 'crash rollback'); + +$node->append_conf('postgresql.conf', 'undo_rollback_overflow_size = 1024'); +$node->restart; +make_table('historic'); +my $old = $node->background_psql('postgres'); +$old->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM historic'); +$node->safe_psql( + 'postgres', qq{ +UPDATE historic SET k = $b; +BEGIN; UPDATE historic SET k = $a; ROLLBACK; +}); +is($old->query_safe($idx . "SELECT count(*) FROM historic WHERE k = $a"), + '1', 'older snapshot finds A after aborted revival'); +is($node->safe_psql('postgres', key_count('historic', '01')), + '1', 'rollback preserves the historic A tombstone'); +$old->query_safe('COMMIT'); +$old->quit; + +# In the second UPDATE k revives A, but j inserts C for the first time. +make_table('mixed'); +$node->safe_psql( + 'postgres', qq{ +CREATE INDEX mixed_j ON mixed(j); +BEGIN; +UPDATE mixed SET k = $b, j = $b; +UPDATE mixed SET k = $a, j = $c; +ROLLBACK; +}); +check_clean('mixed', 'mixed revived and inserted indexes'); +is( $node->safe_psql( + 'postgres', q{SELECT count(*) FROM bt_page_items('mixed_j', 1)}), + '1', + 'second changed index has only its original entry'); + +make_table('dropped'); +$node->safe_psql( + 'postgres', qq{ +BEGIN; +SAVEPOINT before_update; +UPDATE dropped SET k = $b; +SAVEPOINT before_drop; +DROP INDEX dropped_k; +ROLLBACK TO before_update; +COMMIT; +}); +check_clean('dropped', 'index dropped inside savepoint'); + +# Crash after logging intent but before changing the index leaf. A was +# delete-marked by a committed update; the incomplete revival must retain it. +make_table('ordered'); +$node->safe_psql( + 'postgres', qq{ +UPDATE ordered SET k = $b; +SELECT injection_points_attach('flux-index-after-amend', 'wait'); +}); +$bg = $node->background_psql('postgres'); +$bg->query_until(qr/starting/, + "BEGIN; SELECT 'starting'; UPDATE ordered SET k = $a, marker = 67372036;\n" +); +$node->wait_for_event('client backend', 'flux-index-after-amend'); +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (2)'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; +wait_reverted('ordered'); +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (0)'); +$node->wait_for_catchup($standby); + +foreach my $n ($node, $standby) +{ + is($n->safe_psql('postgres', key_count('ordered', '01')), + '1', 'crash before revive retains A on ' . $n->name); + is( $n->safe_psql( + 'postgres', $idx . "SELECT count(*) FROM ordered WHERE k = $b"), + '1', + 'crash before revive restores B on ' . $n->name); +} +# A future undo page can reach disk before the crash while replay starts +# before the original insert. Replaying that insert must not zero its list +# and then skip the amendment because the page LSN is already newer. +make_table('future_page'); +$bg = $node->background_psql('postgres'); +$bg->query_safe("UPDATE future_page SET k = $b"); +$node->safe_psql('postgres', 'CHECKPOINT'); +$bg->query_safe('UPDATE future_page SET marker = 1'); +$bg->query_safe("BEGIN; UPDATE future_page SET k = $a, marker = 67372036"); +$node->safe_psql('postgres', 'SELECT pg_buffercache_evict_all()'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; +wait_reverted('future_page'); +is($node->safe_psql('postgres', key_count('future_page', '01')), + '1', 'replay preserves the amended list on an already-flushed undo page'); + +# Every accepted near-page-size inline row must also support full-image undo. +# Discover the INSERT boundary rather than baking in tuple header sizes. +foreach my $nindexes (1, 20) +{ + my $t = "wide_$nindexes"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t(k int, pad text STORAGE PLAIN) USING flux; +} . join('; ', map { "CREATE INDEX ${t}_k$_ ON $t(k)" } 1 .. $nindexes)); + my $limit; + for my $pad (8000 .. 8192) + { + my ($out, $err) = ('', ''); + my $ret = $node->psql( + 'postgres', + "INSERT INTO $t VALUES (1, repeat('x', $pad))", + stdout => \$out, + stderr => \$err); + if ($ret != 0) + { + like( + $err, + qr/row is too big|tuple is too big|row size|tuple size/, + "$nindexes indexes: INSERT reaches row size limit"); + $limit = $pad - 1; + last; + } + $ret = $node->psql( + 'postgres', qq{ +BEGIN; +UPDATE $t SET k = 2, pad = repeat('y', $pad); +ROLLBACK; +}, + stdout => \$out, + stderr => \$err); + is($ret, 0, "$nindexes indexes: pad $pad key update and rollback") + or diag $err; + is( $node->safe_psql( + 'postgres', + "SELECT k = 1 AND pad = repeat('x', $pad) FROM $t"), + 't', + "$nindexes indexes: pad $pad before-image restored"); + $node->safe_psql( + 'postgres', + join('; ', + map { "SELECT bt_index_check('${t}_k$_', true)" } + 1 .. $nindexes)); + $node->safe_psql('postgres', "TRUNCATE $t"); + } + ok(defined $limit && $limit >= 8000, + "$nindexes indexes: tested through the INSERT limit"); + $node->safe_psql('postgres', + "INSERT INTO $t VALUES (1, repeat('x', $limit))"); + my $wide = $node->background_psql('postgres'); + $wide->query_safe( + "BEGIN; UPDATE $t SET k = 2, pad = repeat('y', $limit)"); + my $wal_start = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + # Stay in one transaction so later records cannot include a previous + # transaction header page. Vary the starting byte with full before-images. + for (1 .. 128) + { + $wide->query_safe( + qq{ +UPDATE $t SET k = 1, pad = repeat('x', $limit); +UPDATE $t SET k = 2, pad = repeat('y', $limit); +}); + } + $wide->query_safe('ROLLBACK'); + $wide->quit; + ok( $node->safe_psql( + 'postgres', qq{ +SELECT EXISTS ( + SELECT start_lsn FROM pg_get_wal_block_info('$wal_start', pg_current_wal_lsn(), false) + WHERE reldatabase = 9 AND resource_manager = 'FLUX' + GROUP BY start_lsn HAVING count(*) = 3 +) +}) eq 't', + "$nindexes indexes: full before-image spans three undo pages"); + is( $node->safe_psql( + 'postgres', "SELECT k = 1 AND pad = repeat('x', $limit) FROM $t"), + 't', + "$nindexes indexes: three-page rollback restores row"); + $node->safe_psql( + 'postgres', + join('; ', + map { "SELECT bt_index_check('${t}_k$_', true)" } 1 .. $nindexes) + ); +} + +# A large reserved area and many record positions exercise page boundaries. +make_table('boundary'); +$node->safe_psql('postgres', + join('; ', map { "CREATE INDEX boundary_k$_ ON boundary(k)" } 1 .. 31)); +my $start = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +$bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +for (1 .. 256) +{ + $bg->query_safe("UPDATE boundary SET k = $b; UPDATE boundary SET k = $a"); +} +$bg->query_safe('ROLLBACK'); +$bg->quit; +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (3)'); +ok( $node->safe_psql( + 'postgres', qq{ +SELECT count(*) > 0 FROM pg_get_wal_records_info('$start', pg_current_wal_lsn()) +WHERE record_type = 'AMEND_RECORD' AND description = 'blocks 2' +}) eq 't', + 'revive list amendment crosses an undo page boundary'); +check_clean('boundary', 'cross-page amendment'); +$node->safe_psql('postgres', q{SELECT bt_index_check('boundary_k', true)}); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/113_flux_prepared_row_locks.pl b/src/test/recovery/t/113_flux_prepared_row_locks.pl new file mode 100644 index 0000000000000..d2d33bfe31bad --- /dev/null +++ b/src/test/recovery/t/113_flux_prepared_row_locks.pl @@ -0,0 +1,380 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# The row locks and row changes of a PREPARED transaction survive an +# immediate restart and a standby promotion, as heap's do. Heap is the +# control for every case. +# +# FLUX keeps a row lock's owner and strength only in the shared-memory tuple +# sLog; the page carries an ownerless FLUX_TUPLE_LOCKED bit. A restart +# empties the sLog, so a prepared transaction's FLUX row locks used to +# vanish: an UPDATE of a FLUX row the prepared transaction had locked FOR +# UPDATE succeeded after the restart, while the same UPDATE of a heap row +# still blocked. Each PREPARE also leaked one count of FLUX's active +# transactions. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 40 +)); +$node->start; +if ( !$node->check_extension('amcheck') + || !$node->check_extension('test_idxundo')) +{ + plan skip_all => 'amcheck or test_idxundo not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION amcheck; CREATE EXTENSION test_idxundo;'); + +my @ams = ('heap', 'flux'); +foreach my $am (@ams) +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE t_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO t_$am SELECT g, g FROM generate_series(1, 80) g;}); +} + +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +# Run sql in a transaction that is rolled back, giving up after a short +# lock_timeout. Reports whether it had to wait for a lock. +sub probe +{ + my ($n, $sql) = @_; + my ($ret, $stdout, $stderr) = $n->psql('postgres', + "BEGIN; SET LOCAL lock_timeout = '300ms'; $sql; ROLLBACK;"); + return 'blocks' if $stderr =~ /canceling statement due to lock timeout/; + return 'proceeds' if $stderr eq ''; + return "error: $stderr"; +} + +sub prepare +{ + my ($gid, @sql) = @_; + $node->safe_psql('postgres', + join(";\n", 'BEGIN', @sql, "PREPARE TRANSACTION '$gid'")); +} + +my @modes = ('FOR UPDATE', 'FOR NO KEY UPDATE', 'FOR SHARE', 'FOR KEY SHARE'); +my @probes = ( + [ 'non-key UPDATE', 'UPDATE %s SET v = v + 1 WHERE id = %d' ], + [ 'DELETE', 'DELETE FROM %s WHERE id = %d' ], + [ 'FOR UPDATE', 'SELECT 1 FROM %s WHERE id = %d FOR UPDATE' ], + [ 'FOR SHARE', 'SELECT 1 FROM %s WHERE id = %d FOR SHARE' ], + [ 'FOR KEY SHARE', 'SELECT 1 FROM %s WHERE id = %d FOR KEY SHARE' ],); +# A key-changing UPDATE is left out: FLUX does not make it wait for a FOR KEY +# SHARE lock even without two-phase commit, a separate bug. +# Which probes a lock in each mode blocks: heap's tuple lock conflict table. +my %blocks = ( + 'FOR UPDATE' => [ 1, 1, 1, 1, 1 ], + 'FOR NO KEY UPDATE' => [ 1, 1, 1, 1, 0 ], + 'FOR SHARE' => [ 1, 1, 1, 0, 0 ], + 'FOR KEY SHARE' => [ 0, 1, 1, 0, 0 ],); + +# Run every probe against row $id of t_$am and compare with what a lock in +# $mode should block ($mode undef: nothing is locked). +sub check_probes +{ + my ($n, $am, $id, $mode, $what) = @_; + for my $p (0 .. $#probes) + { + my ($pname, $fmt) = @{ $probes[$p] }; + my $want = + (defined $mode && $blocks{$mode}[$p]) ? 'blocks' : 'proceeds'; + is(probe($n, sprintf($fmt, "t_$am", $id)), + $want, "$what: $pname of the $am row"); + } +} + +# Visible rows of the rows the DML transactions touch, by seq scan and by +# index scan. +my $dmlrows = '41, 42, 51, 52, 90, 91'; +# $n is a node or, to read in an open transaction, a background session. +sub dml_state +{ + my ($n, $am) = @_; + my $q = + "SELECT string_agg(id || ':' || v, ',' ORDER BY id) FROM t_$am WHERE id IN ($dmlrows)"; + my @sql = ( + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q", + "SET enable_seqscan = off; SET enable_indexscan = on; $q"); + my @out = map { + $n->isa('PostgreSQL::Test::Cluster') + ? $n->safe_psql('postgres', $_) + : $n->query_safe($_) + } @sql; + $n->query_safe( + 'RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan' + ) unless $n->isa('PostgreSQL::Test::Cluster'); + return "seq $out[0] idx $out[1]"; +} + +# Row $id of t_$am is locked in mode $i by gid lock_$am_$i, resolved after +# the restart; rows 11..14 by gid early_$am_$i, resolved before it. +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + prepare("lock_${am}_$i", + "SELECT 1 FROM t_$am WHERE id = " . (1 + $i) . " $modes[$i]"); + prepare("early_${am}_$i", + "SELECT 1 FROM t_$am WHERE id = " . (11 + $i) . " $modes[$i]"); + } + + # Two prepared transactions share a FOR SHARE lock on row 20. + prepare("share_a_$am", "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"); + prepare("share_b_$am", "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"); + + # Prepared row changes: one transaction is committed after the restart, + # the other rolled back. + prepare( + "dml_c_$am", + "INSERT INTO t_$am VALUES (90, 90)", + "UPDATE t_$am SET v = v + 100 WHERE id = 41", + "DELETE FROM t_$am WHERE id = 42"); + prepare( + "dml_r_$am", + "INSERT INTO t_$am VALUES (91, 91)", + "UPDATE t_$am SET v = v + 100 WHERE id = 51", + "DELETE FROM t_$am WHERE id = 52"); +} + +foreach my $am (@ams) +{ + prepare( + "upgrade_$am", + "SELECT 1 FROM t_$am WHERE id = 75 FOR KEY SHARE", + 'SAVEPOINT a', + "SELECT 1 FROM t_$am WHERE id = 75 FOR UPDATE", + 'ROLLBACK TO a'); + check_probes( + $node, $am, 75, + 'FOR KEY SHARE', + 'rolled-back upgrade before restart'); +} + +# One transaction locks a heap row and a FLUX row. +prepare( + 'mixed', + 'SELECT 1 FROM t_heap WHERE id = 30 FOR UPDATE', + 'SELECT 1 FROM t_flux WHERE id = 30 FOR UPDATE'); + +# Resolved on the promoted standby. +prepare( + 'sb', + 'SELECT 1 FROM t_heap WHERE id = 60 FOR UPDATE', + 'SELECT 1 FROM t_flux WHERE id = 60 FOR UPDATE', + 'SELECT 1 FROM t_heap WHERE id = 61 FOR KEY SHARE', + 'SELECT 1 FROM t_flux WHERE id = 61 FOR KEY SHARE'); + +# Before the restart. +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + check_probes($node, $am, 1 + $i, $modes[$i], + "before restart, $modes[$i]"); + + my $early = 11 + $i; + is(probe($node, "DELETE FROM t_$am WHERE id = $early"), + 'blocks', "before restart, early $modes[$i]: $am row locked"); + $node->safe_psql('postgres', + ($i % 2 ? 'COMMIT' : 'ROLLBACK') . " PREPARED 'early_${am}_$i'"); + check_probes($node, $am, $early, undef, + "before restart, early $modes[$i] resolved"); + } + + is(probe($node, "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"), + 'proceeds', "before restart, shared FOR SHARE: $am FOR SHARE"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'blocks', "before restart, shared FOR SHARE: $am UPDATE"); + + is( dml_state($node, $am), + 'seq 41:41,42:42,51:51,52:52 idx 41:41,42:42,51:51,52:52', + "before restart, prepared row changes are invisible in $am"); +} + +# Immediate restart. The checkpoint leaves nothing to redo, so whatever +# FLUX needs must come from the pages, CLOG and the two-phase state files. +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$node->start; + +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + check_probes($node, $am, 1 + $i, $modes[$i], + "after restart, $modes[$i]"); + } + + # Shared FOR SHARE: still blocked after one of the two holders goes. + is(probe($node, "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"), + 'proceeds', "after restart, shared FOR SHARE: $am FOR SHARE"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'blocks', "after restart, shared FOR SHARE: $am UPDATE"); + $node->safe_psql('postgres', "COMMIT PREPARED 'share_a_$am'"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'blocks', + "after restart, shared FOR SHARE, one committed: $am UPDATE"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'share_b_$am'"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'proceeds', + "after restart, shared FOR SHARE, both resolved: $am UPDATE"); + + # Prepared row changes: invisible, and writers wait for them. + is( dml_state($node, $am), + 'seq 41:41,42:42,51:51,52:52 idx 41:41,42:42,51:51,52:52', + "after restart, prepared row changes are invisible in $am"); + foreach my $id (41, 51) + { + is(probe($node, "UPDATE t_$am SET v = v WHERE id = $id"), + 'blocks', "after restart, UPDATE of a prepared UPDATE's $am row"); + is(probe($node, "DELETE FROM t_$am WHERE id = $id"), + 'blocks', "after restart, DELETE of a prepared UPDATE's $am row"); + } + foreach my $id (42, 52) + { + is(probe($node, "UPDATE t_$am SET v = v WHERE id = $id"), + 'blocks', "after restart, UPDATE of a prepared DELETE's $am row"); + } + foreach my $id (90, 91) + { + is(probe($node, "INSERT INTO t_$am VALUES ($id, 0)"), + 'blocks', "after restart, INSERT of a prepared INSERT's $am key"); + } +} + +foreach my $am (@ams) +{ + check_probes( + $node, $am, 75, + 'FOR KEY SHARE', + 'rolled-back upgrade after restart'); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'upgrade_$am'"); + check_probes($node, $am, 75, undef, 'rolled-back upgrade resolved'); +} + +# A session waiting on the mixed transaction's locks proceeds when it +# commits. +my %waiter; +foreach my $am (@ams) +{ + $waiter{$am} = $node->background_psql('postgres'); + $waiter{$am}->query_until(qr/start/, + "\\echo start\nUPDATE t_$am SET v = v + 1000 WHERE id = 30;\n"); +} +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 2 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE t_% SET v = v + 1000%'" + ), + 'after restart, UPDATEs of the mixed rows wait'); +$node->safe_psql('postgres', "COMMIT PREPARED 'mixed'"); +foreach my $am (@ams) +{ + $waiter{$am}->query_safe('SELECT 1'); + $waiter{$am}->quit; + is( $node->safe_psql('postgres', "SELECT v FROM t_$am WHERE id = 30"), + '1030', + "the waiter's UPDATE of the $am row ran after COMMIT PREPARED"); +} + +# REPEATABLE READ snapshots taken after the restart, before COMMIT PREPARED, +# must keep seeing the prepared row changes as uncommitted. +my %rr; +foreach my $am (@ams) +{ + $rr{$am} = $node->background_psql('postgres'); + $rr{$am}->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr{$am}->query_safe("SELECT count(*) FROM t_$am"); +} + +# Resolve the rest. +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + $node->safe_psql('postgres', + ($i % 2 ? 'COMMIT' : 'ROLLBACK') . " PREPARED 'lock_${am}_$i'"); + check_probes($node, $am, 1 + $i, undef, + "after restart, $modes[$i] resolved"); + } + + $node->safe_psql('postgres', "COMMIT PREPARED 'dml_c_$am'"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'dml_r_$am'"); + is( dml_state($node, $am), + 'seq 41:141,51:51,52:52,90:90 idx 41:141,51:51,52:52,90:90', + "after restart, COMMIT and ROLLBACK PREPARED row changes in $am"); + # Read every row again, setting whatever hints the readers set. + $node->safe_psql('postgres', "SELECT * FROM t_$am"); + $node->safe_psql('postgres', + "SELECT * FROM t_$am WHERE id IN ($dmlrows)"); + is( dml_state($rr{$am}, $am), + 'seq 41:41,42:42,51:51,52:52 idx 41:41,42:42,51:51,52:52', + "after restart, an older snapshot does not see the resolved $am changes" + ); + $rr{$am}->query_safe('COMMIT'); + $rr{$am}->quit; + foreach my $id (41, 51, 52, 90) + { + is(probe($node, "UPDATE t_$am SET v = v WHERE id = $id"), + 'proceeds', + "after restart and resolution, UPDATE of $am row $id"); + } + is(probe($node, "INSERT INTO t_$am VALUES (91, 0)"), + 'proceeds', "after restart and resolution, INSERT of $am key 91"); +} +is( $node->safe_psql( + 'postgres', + 'SELECT count(*) FROM (SELECT * FROM t_heap EXCEPT SELECT * FROM t_flux) x' + ), + '0', + 'heap and FLUX tables match'); +is( $node->safe_psql('postgres', 'SELECT count(*) FROM t_heap'), + $node->safe_psql('postgres', 'SELECT count(*) FROM t_flux'), + 'heap and FLUX row counts match'); +$node->safe_psql('postgres', "SELECT bt_index_check('t_flux_pkey', true)"); + +# FLUX's count of active transactions does not grow with each PREPARE. +my $active0 = + $node->safe_psql('postgres', 'SELECT idxundo_flux_active_xacts()'); +for my $i (1 .. 3) +{ + prepare("leak_$i", 'UPDATE t_flux SET v = v WHERE id = 70'); + $node->safe_psql('postgres', "COMMIT PREPARED 'leak_$i'"); +} +is($node->safe_psql('postgres', 'SELECT idxundo_flux_active_xacts()'), + $active0, 'PREPARE does not leak a FLUX active transaction'); + +# The standby promoted with 'sb' still prepared holds its locks. +$node->wait_for_replay_catchup($standby); +$standby->promote; +foreach my $am (@ams) +{ + check_probes($standby, $am, 60, 'FOR UPDATE', + 'promoted standby, FOR UPDATE'); + check_probes( + $standby, $am, 61, + 'FOR KEY SHARE', + 'promoted standby, FOR KEY SHARE'); +} +$standby->safe_psql('postgres', "COMMIT PREPARED 'sb'"); +foreach my $am (@ams) +{ + check_probes($standby, $am, 60, undef, 'promoted standby, resolved'); + check_probes($standby, $am, 61, undef, 'promoted standby, resolved'); +} + +done_testing(); diff --git a/src/test/recovery/t/114_undo_recovery_error.pl b/src/test/recovery/t/114_undo_recovery_error.pl new file mode 100644 index 0000000000000..13a655b75648b --- /dev/null +++ b/src/test/recovery/t/114_undo_recovery_error.pl @@ -0,0 +1,48 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# An ERROR in startup undo must fail startup, leaving the chain for retry. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +plan skip_all => 'Injection points required' + unless $ENV{enable_injection_points} eq 'yes'; +my $node = PostgreSQL::Test::Cluster->new('undo_recovery_error'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +autovacuum = off +shared_preload_libraries = 'test_idxundo' +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int, v int) USING flux; +INSERT INTO f VALUES (1, 10); +CHECKPOINT; +}); +my $s = $node->background_psql('postgres'); +$s->query_safe('BEGIN; UPDATE f SET v = 16908801;'); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$s->{run}->kill_kill; +$node->append_conf('postgresql.conf', 'test_idxundo.recovery_error = on'); +ok(!$node->start(fail_ok => 1), 'undo ERROR fails startup'); +like( + slurp_file($node->logfile), + qr/FATAL:.*test_idxundo: recovery error/, + 'startup reports FATAL'); +# Stop the baseline if it incorrectly accepted connections. +$node->stop('immediate', fail_ok => 1); +$node->append_conf('postgresql.conf', 'test_idxundo.recovery_error = off'); +$node->start; +ok( $node->poll_query_until( + 'postgres', q{ +SELECT position('\x01020201'::bytea IN get_raw_page('f', 0)) = 0 +}), + 'retry physically removes the aborted image'); +is($node->safe_psql('postgres', 'SELECT v FROM f'), + '10', 'committed value survives restart'); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/115_flux_freeze.pl b/src/test/recovery/t/115_flux_freeze.pl new file mode 100644 index 0000000000000..39eeec81ff780 --- /dev/null +++ b/src/test/recovery/t/115_flux_freeze.pl @@ -0,0 +1,184 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# CLOG truncation must not invalidate frozen rows or UNDO before-images. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +vacuum_freeze_min_age = 0 +wal_consistency_checking = all +full_page_writes = off +statement_timeout = '120s' +}); +$node->start; +$node->safe_psql( + 'postgres', + 'CREATE EXTENSION amcheck; CREATE EXTENSION xid_wraparound; CREATE EXTENSION injection_points; +ALTER DATABASE template0 ALLOW_CONNECTIONS true;'); +for my $am (qw(heap flux)) +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE frozen_$am (id int PRIMARY KEY, v int) USING $am; +CREATE TABLE chain_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO frozen_$am VALUES (1,10); +INSERT INTO chain_$am VALUES (1,10); +}); +} +my $old_xid = $node->safe_psql('postgres', 'SELECT xmin FROM chain_flux'); +my $old_segment = sprintf('%04X', int($old_xid / (8192 * 4 * 32))); +ok(-f $node->data_dir . "/pg_xact/$old_segment", + 'original CLOG segment exists'); +$node->safe_psql('postgres', 'SELECT consume_xids(2200000)'); +$node->safe_psql('postgres', 'SELECT pg_current_xact_id()'); +$node->backup('base'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'base', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "wal_consistency_checking = all\nmax_standby_streaming_delay = 0\n"); +$standby->start; +my $reader = $node->background_psql('postgres'); +$reader->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT pg_current_snapshot();'); +# Do not read the old row here: doing so could set the missing commit hint. +for my $am (qw(heap flux)) +{ + $node->safe_psql('postgres', "UPDATE chain_$am SET v=20;"); +} +my $before = $node->safe_psql('postgres', + 'SELECT datfrozenxid::text::bigint FROM pg_database WHERE datname=current_database()' +); +for my $db (qw(template0 template1 postgres)) +{ + $node->safe_psql($db, 'VACUUM FREEZE'); +} +$node->safe_psql('postgres', 'CHECKPOINT'); +my $after = $node->safe_psql('postgres', + 'SELECT datfrozenxid::text::bigint FROM pg_database WHERE datname=current_database()' +); +cmp_ok($after, '>', $before, 'database freeze horizon advances'); +ok( !-f $node->data_dir . "/pg_xact/$old_segment", + 'original CLOG segment actually truncated'); +for my $am (qw(heap flux)) +{ + is($reader->query_safe("SELECT v FROM chain_$am"), + '10', "$am old snapshot reads before-image after truncation"); + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am"), + '10', "$am frozen row survives truncation"); +} +$reader->query_safe('COMMIT'); +$reader->quit; +$node->wait_for_replay_catchup($standby); +for my $am (qw(heap flux)) +{ + is($standby->safe_psql('postgres', "SELECT v FROM frozen_$am"), + '10', "$am standby frozen row"); + is($standby->safe_psql('postgres', "SELECT v FROM chain_$am"), + '20', "$am standby current version"); + for my $n ($node, $standby) + { + is( $n->safe_psql( + 'postgres', + "SELECT bt_index_check('frozen_${am}_pkey'); SELECT bt_index_check('chain_${am}_pkey');" + ), + "\n", + "$am indexes pass amcheck on " . $n->name); + } +} +# A non-freezing VACUUM setting ALL_VISIBLE must also resolve conflicts. +my $vm_reader = $standby->background_psql('postgres', on_error_stop => 0); +$vm_reader->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT pg_current_snapshot();'); +$node->safe_psql('postgres', 'SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', 'INSERT INTO frozen_flux VALUES (2,30)'); +$node->safe_psql('postgres', 'SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', + 'SET vacuum_freeze_min_age=100000000; VACUUM frozen_flux'); +is( $node->safe_psql( + 'postgres', + "SELECT relallvisible FROM pg_class WHERE oid='frozen_flux'::regclass" + ), + '1', + 'nonfreezing vacuum made the page all-visible'); +# VACUUM's last VM record need not yet be flushed. Flush before waiting. +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->wait_for_replay_catchup($standby); +eval { $vm_reader->query('SELECT v FROM frozen_flux WHERE id=2'); }; +like( + $vm_reader->{stderr}, + qr/conflict with recovery/, + 'all-visible WAL resolves older standby snapshots'); +eval { $vm_reader->quit; }; +# A DML-normalized before-image must cancel an older standby snapshot. +# Disable feedback so the primary can advance beyond that snapshot. +my $old_standby = $standby->background_psql('postgres', on_error_stop => 0); +$old_standby->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT pg_current_snapshot();'); +$node->safe_psql('postgres', 'INSERT INTO chain_flux VALUES (2,30)'); +$node->safe_psql('postgres', 'UPDATE chain_flux SET v=40 WHERE id=2'); +$node->wait_for_replay_catchup($standby); +eval { $old_standby->query('SELECT v FROM chain_flux WHERE id=2'); }; +# An idle-in-transaction conflict terminates the connection, not just a query. +like( + $old_standby->{stderr}, + qr/conflict with recovery/, + 'before-image freezing resolves older standby snapshots'); +eval { $old_standby->quit; }; +for my $am (qw(heap flux)) +{ + $node->safe_psql('postgres', + "CREATE TABLE parent_$am (id int PRIMARY KEY) USING $am; CREATE TABLE child_$am (id int PRIMARY KEY, p int REFERENCES parent_$am(id) DEFERRABLE INITIALLY DEFERRED) USING $am; INSERT INTO parent_$am VALUES (1); INSERT INTO child_$am VALUES (1,1);" + ); + is( $node->safe_psql( + 'postgres', + "BEGIN; UPDATE child_$am SET p=1; UPDATE child_$am SET p=1; SET CONSTRAINTS ALL IMMEDIATE; COMMIT; SELECT count(*) FROM child_$am" + ), + '1', + "$am deferred checks survive normalized before-images"); +} +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait');" +); +$node->safe_psql('postgres', + "BEGIN; DELETE FROM frozen_heap WHERE id=1; DELETE FROM frozen_flux WHERE id=1; ROLLBACK;" +); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +$node->safe_psql('postgres', + 'SELECT consume_xids(100); SELECT pg_current_xact_id()'); +for my $am (qw(heap flux)) +{ + $node->safe_psql('postgres', "VACUUM (FREEZE) frozen_$am"); + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am WHERE id=1"), + '10', "$am VACUUM preserves a row with an aborted delete"); + is( $node->safe_psql( + 'postgres', + "SELECT age(relfrozenxid) <= 1 FROM pg_class WHERE oid='frozen_$am'::regclass" + ), + 't', + "$am aborted delete does not pin freezing"); +} +$node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply'); SELECT injection_points_wakeup('pbu-undo-worker-before-apply');" +); +$standby->stop; +$node->stop('immediate'); +$node->start; +for my $am (qw(heap flux)) +{ + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am WHERE id=1"), + '10', "$am frozen row survives crash recovery"); + $node->safe_psql('postgres', + "BEGIN; UPDATE frozen_$am SET v=99 WHERE id=1; ROLLBACK;"); + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am WHERE id=1"), + '10', "$am rollback restores a frozen row"); +} +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/116_flux_minimal.pl b/src/test/recovery/t/116_flux_minimal.pl new file mode 100644 index 0000000000000..979ebcabb2a8a --- /dev/null +++ b/src/test/recovery/t/116_flux_minimal.pl @@ -0,0 +1,40 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# A new relation synchronized without WAL must survive loss of its undo. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am (qw(heap flux)) +{ + my $node = PostgreSQL::Test::Cluster->new($am); + $node->init; + $node->append_conf( + 'postgresql.conf', qq[ +wal_level = minimal +wal_skip_threshold = 0 +]); + $node->start; + $node->safe_psql( + 'postgres', qq[ + BEGIN; + CREATE TABLE upd (id int, id2 int) USING $am; + INSERT INTO upd SELECT g, g FROM generate_series(1,10000) g; + COPY upd FROM STDIN; +10001 10001 +\\. + UPDATE upd SET id2 = id2 + 1; + DELETE FROM upd; + COMMIT; + ]); + $node->stop('immediate'); + $node->start; + my ($ret, $stdout, $stderr) = + $node->psql('postgres', 'SELECT count(*) FROM upd'); + is($ret, 0, "$am recovered table is readable") or diag $stderr; + is($stdout, '0', "$am committed delete survives recovery"); + $node->stop; +} +done_testing(); diff --git a/src/test/recovery/t/117_undo_rewind_snapshot.pl b/src/test/recovery/t/117_undo_rewind_snapshot.pl new file mode 100644 index 0000000000000..09b50bd9c4b15 --- /dev/null +++ b/src/test/recovery/t/117_undo_rewind_snapshot.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# pg_rewind copies pg_undo/ whole from the source, whose checkpoints have +# already pruned the snapshot for the target's restart checkpoint (the last +# common one). The rewound server must still replay the per-backend undo +# log records written after that checkpoint and come up with the source's +# data. The heap twin is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am (qw(heap flux)) +{ + my $primary = PostgreSQL::Test::Cluster->new("primary_$am"); + $primary->init(allows_streaming => 1); + $primary->append_conf( + 'postgresql.conf', qq[ +wal_log_hints = on +wal_keep_size = 320MB +undo_worker_naptime = 10 +]); + $primary->start; + $primary->safe_psql( + 'postgres', qq[ + CREATE EXTENSION amcheck; + CREATE TABLE t (id int PRIMARY KEY, v int) USING $am; + INSERT INTO t SELECT g, 0 FROM generate_series(1, 2000) g; + UPDATE t SET v = v + 1; + CHECKPOINT; + ]); + $primary->backup('b'); + my $standby = PostgreSQL::Test::Cluster->new("standby_$am"); + $standby->init_from_backup($primary, 'b', has_streaming => 1); + $standby->start; + + # Undo activity and the last common checkpoint. + $primary->safe_psql('postgres', 'UPDATE t SET v = v + 1; CHECKPOINT;'); + $primary->wait_for_catchup($standby); + $standby->promote; + + # Undo writes, discards and checkpoints on the new primary only: the + # checkpoints prune the common checkpoint's pg_undo snapshot. + for my $i (1 .. 3) + { + $standby->safe_psql('postgres', 'UPDATE t SET v = v + 1'); + $standby->poll_query_until('postgres', + "SELECT coalesce(bool_and(discard = insert), true) FROM pg_stat_get_undo_logs() WHERE persistence = 'permanent'" + ) or die "timed out waiting for undo discard"; + $standby->safe_psql('postgres', 'CHECKPOINT'); + } + my $expected = + $standby->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'); + + # Diverge the old primary. + $primary->safe_psql('postgres', 'UPDATE t SET v = v + 100'); + $primary->stop; + $standby->stop; + + command_ok( + [ + 'pg_rewind', + '--target-pgdata' => $primary->data_dir, + '--source-pgdata' => $standby->data_dir, + ], + "$am: pg_rewind"); + + $primary->append_conf('postgresql.conf', + 'port = ' . $primary->port . "\n"); + $primary->enable_streaming($standby); + $standby->start; + my $logstart = -s $primary->logfile; + my $started = $primary->start(fail_ok => 1); + ok($started, "$am: rewound server starts"); + if (!$started) + { + diag(slurp_file($primary->logfile, $logstart)); + $standby->stop; + next; + } + + # More undo after the rewind must replay too. + $standby->safe_psql('postgres', 'UPDATE t SET v = v + 1'); + $standby->wait_for_catchup($primary); + $expected = + $standby->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'); + is($primary->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'), + $expected, "$am: rewound data matches the source"); + is( $primary->safe_psql( + 'postgres', "SELECT bt_index_check('t_pkey', true)"), + '', + "$am: index consistent after rewind"); + unlike( + slurp_file($primary->logfile, $logstart), + qr/unknown undo log/, + "$am: no unknown undo log during replay"); + ok( $am eq 'heap' || $primary->log_contains( + 'undo log checkpoint snapshot for redo point .* is missing, using', + $logstart), + "$am: replay started from a newer undo log snapshot"); + $primary->stop; + $standby->stop; + + # With every snapshot gone, a cluster that has undo logs must refuse to + # start instead of starting with no undo logs. + next if $am eq 'heap'; + my $dir = $primary->data_dir . '/pg_undo'; + unlink glob("$dir/*"); + command_fails_like( + [ 'postgres', '-D', $primary->data_dir ], + qr/undo log checkpoint snapshot .* is missing/, + "$am: start without any undo log snapshot fails"); +} +done_testing(); diff --git a/src/test/recovery/t/118_flux_minimal_undo_log.pl b/src/test/recovery/t/118_flux_minimal_undo_log.pl new file mode 100644 index 0000000000000..209c02705f0f7 --- /dev/null +++ b/src/test/recovery/t/118_flux_minimal_undo_log.pl @@ -0,0 +1,100 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# At wal_level = minimal a relation created in the same transaction skips +# WAL: it is synchronized at commit. FLUX still writes undo for it, and that +# undo must not end up in an undo log that crash recovery replays, because +# nothing in the WAL describes it. Two consequences are checked, with a heap +# control: +# +# - A later WAL-logged change whose undo lands in the same log must replay +# after a crash. +# - Once the discard worker has discarded that undo, the discard is +# WAL-logged; after a crash the log must still be consistent, the discard +# worker must answer a shutdown request, and new undo must be writable. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am (qw(heap flux)) +{ + my $node = PostgreSQL::Test::Cluster->new("minimal_$am"); + $node->init; + $node->append_conf( + 'postgresql.conf', qq[ +wal_level = minimal +max_wal_senders = 0 +wal_skip_threshold = 0 +autovacuum = off +]); + $node->start; + $node->safe_psql( + 'postgres', qq[ + CREATE TABLE old (id int PRIMARY KEY, v int) USING $am; + INSERT INTO old SELECT g, 0 FROM generate_series(1, 100) g; + CHECKPOINT; + ]); + + # One session: a transaction that skips WAL, then a WAL-logged one. + $node->safe_psql( + 'postgres', qq[ + BEGIN; + CREATE TABLE neu (id int, v int) USING $am; + INSERT INTO neu SELECT g, g FROM generate_series(1, 5000) g; + UPDATE neu SET v = v + 1; + COMMIT; + UPDATE old SET v = v + 1 WHERE id <= 50; + ]); + $node->stop('immediate'); + + my $ret = $node->start(fail_ok => 1); + ok($ret, + "$am: crash recovery replays a change logged after a transaction that skipped WAL" + ); + if (!$ret) + { + my ($panic) = slurp_file($node->logfile) =~ /(PANIC:.*)/; + diag($panic // 'no PANIC'); + next; + } + is( $node->safe_psql( + 'postgres', + 'SELECT sum(v) FROM old UNION ALL SELECT sum(v) FROM neu'), + "50\n12507500", + "$am: both transactions survive the crash"); + + # Again, but let the discard worker discard the undo before the crash. + $node->safe_psql( + 'postgres', qq[ + BEGIN; + CREATE TABLE neu2 (id int, v int) USING $am; + INSERT INTO neu2 SELECT g, g FROM generate_series(1, 5000) g; + UPDATE neu2 SET v = v + 1; + DELETE FROM neu2; + COMMIT; + ]); + ok( $node->poll_query_until( + 'postgres', q( + SELECT coalesce(bool_and(discard = insert), true) + FROM pg_stat_get_undo_logs())), + "$am: the discard worker discards the undo"); + $node->stop('immediate'); + $node->start; + is($node->safe_psql('postgres', 'SELECT count(*) FROM neu2'), + '0', "$am: committed delete of a table that skipped WAL survives"); + is( $node->safe_psql( + 'postgres', q( + SELECT count(*) FROM pg_stat_get_undo_logs() + WHERE discard > insert)), + '0', + "$am: no undo log is discarded past its insert point after recovery"); + my ($uret, $uout, $uerr) = + $node->psql('postgres', 'UPDATE old SET v = v + 1 WHERE id <= 10'); + is($uret, 0, "$am: undo is writable after recovery") or diag $uerr; + my $stopped = $node->stop('fast', fail_ok => 1); + ok($stopped, "$am: fast shutdown completes after recovery"); + $node->stop('immediate') if !$stopped; +} + +done_testing(); diff --git a/src/test/recovery/t/119_flux_standby_conflict.pl b/src/test/recovery/t/119_flux_standby_conflict.pl new file mode 100644 index 0000000000000..42471cffd4b61 --- /dev/null +++ b/src/test/recovery/t/119_flux_standby_conflict.pl @@ -0,0 +1,158 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Removing tuples on the primary must resolve recovery conflicts on a hot +# standby, as heap pruning does. A standby query whose snapshot can still see +# the removed rows has to be cancelled before the removal is replayed: it must +# never read the page with the rows gone. Replay that compacts a page also +# needs a cleanup lock, so a standby backend holding a pin on that page is +# cancelled with a buffer pin conflict. heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use Time::HiRes qw(usleep); + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq[ +autovacuum = off +]); +$primary->start; +$primary->backup('bk'); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->append_conf( + 'postgresql.conf', qq[ +hot_standby_feedback = off +max_standby_streaming_delay = 50ms +]); +$standby->start; + +# Poll a condition for a bounded time, so a missing conflict fails fast. +sub poll_true +{ + my ($cond) = @_; + + for (my $i = 0; $i < 300; $i++) + { + return 1 if $cond->(); + usleep(100_000); + } + return 0; +} + +sub catchup +{ + # VACUUM's last record need not be flushed yet: wait for the insert LSN. + $primary->wait_for_catchup($standby, 'replay', $primary->lsn('insert')); +} + +sub conflicts +{ + my ($db, $kind) = @_; + + return $standby->safe_psql($db, + "SELECT confl_$kind FROM pg_stat_database_conflicts WHERE datname = '$db'" + ); +} + +# Open a REPEATABLE READ transaction on the standby that reads every row of +# table $tab, run $removal on the primary, then read again in the same +# transaction. It must either have been cancelled or still see every row. +sub snapshot_case +{ + my ($am, $db, $tab, $what, $removal) = @_; + + $primary->safe_psql( + $db, qq[ +CREATE TABLE $tab (id int, v int, pad char(200)) USING $am + WITH (fillfactor = 100); +INSERT INTO $tab SELECT g, g, 'x' FROM generate_series(1, 100) g; +]); + catchup(); + + my $s = $standby->background_psql($db, on_error_stop => 0); + is( $s->query_safe( + "BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*), sum(v) FROM $tab;" + ), + '100|5050', + "$am $what: standby snapshot sees every row"); + + $primary->safe_psql($db, "DELETE FROM $tab WHERE id % 2 = 0;"); + $primary->safe_psql($db, $_) for @$removal; + catchup(); + + my $out = ''; + eval { $out = $s->query("SELECT count(*), sum(v) FROM $tab;"); }; + my $err = $s->{stderr} // ''; + ok( $err =~ /conflict with recovery/ || $out eq '100|5050', + "$am $what: standby query cancelled or still sees every row (got '$out')" + ); + eval { $s->quit; }; + return $err =~ /conflict with recovery/; +} + +for my $am (qw(heap flux)) +{ + my $db = "db_$am"; + + $primary->safe_psql('postgres', "CREATE DATABASE $db"); + catchup(); + + ## (a) VACUUM removes rows a standby snapshot still sees. + snapshot_case($am, $db, 'vac', 'VACUUM', ['VACUUM vac;']); + ok(poll_true(sub { conflicts($db, 'snapshot') >= 1 }), + "$am VACUUM: snapshot conflict counted"); + + ## (b) On-access pruning: a scan of full pages with deleted rows. + snapshot_case( + $am, $db, 'prn', + 'on-access pruning', + [ 'SELECT count(*) FROM prn;', 'SELECT count(*) FROM prn;' ]); + + ## (c) Buffer pin: replay of the removal needs a cleanup lock. + # The removed row is an aborted insert older than the standby snapshot, + # so it is not a snapshot conflict. + $primary->safe_psql( + $db, qq[ +CREATE TABLE pin (a int, b int) USING $am; +INSERT INTO pin SELECT i % 3, 0 FROM generate_series(1, 20) i; +BEGIN; +INSERT INTO pin VALUES (1, 0); +ROLLBACK; +BEGIN; LOCK pin; COMMIT; +]); + catchup(); + + my $s = $standby->background_psql($db, on_error_stop => 0); + like( + $s->query_safe( + "BEGIN; DECLARE c CURSOR FOR SELECT b FROM pin; FETCH FORWARD FROM c;" + ), + qr/^0$/m, + "$am buffer pin: cursor holds a pin on the page"); + my $log_offset = -s $standby->logfile; + $primary->safe_psql($db, 'VACUUM pin;'); + catchup(); + ok( poll_true( + sub { + $standby->log_contains( + qr/User was holding shared buffer pin for too long/, + $log_offset); + }), + "$am buffer pin: conflict logged"); + ok(poll_true(sub { conflicts($db, 'bufferpin') >= 1 }), + "$am buffer pin: conflict counted"); + eval { $s->quit; }; + + is($standby->safe_psql($db, 'SELECT count(*) FROM pin'), + '20', "$am buffer pin: standby reads the table after replay"); +} + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/120_flux_undo_remark_split.pl b/src/test/recovery/t/120_flux_undo_remark_split.pl new file mode 100644 index 0000000000000..f3ee2a58c8b52 --- /dev/null +++ b/src/test/recovery/t/120_flux_undo_remark_split.pl @@ -0,0 +1,212 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Rolling back a FLUX in-place UPDATE that revived an index tombstone, when +# the leaf has filled up in between, must delete-mark the entry again +# atomically. +# +# A row moved from key A to B and committed leaves a delete-marked (A, TID) +# entry. An UPDATE moving it back to A revives that entry: clearing the mark +# shrinks the tuple by its heap TID trailer. If the UPDATE rolls back, the +# entry is delete-marked again, which needs the trailer's room back. Here a +# concurrent transaction fills the leaf in between, so the room is gone and +# undo has to split the leaf. The marked tuple must replace the plain one in +# that split: a reader whose snapshot predates the A -> B UPDATE needs the +# tombstone, and must never find (A, TID) missing. +# +# Checked for an inline ROLLBACK, with such a reader looking while the undo +# action waits at the injection point nbtree-deletemark-no-room, and for a +# rollback after a crash, which the undo apply worker performs. Each case +# checks that the split was logged as a replace split, that the row is +# reachable under B and not A, and amcheck with heapallindexed. A final +# crash replays all of it with wal_consistency_checking. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_undo_remark_split'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +)); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +# fill_leaf() fills block 1 of an index on (k text) exactly, so that fewer +# than 8 bytes (one heap TID trailer) remain free and nothing splits. A key +# of L bytes takes MAXALIGN(9 + L) + 4 bytes including its line pointer, so +# the entry sizes are 8m + 4, and the last one is sized to leave 0 or 4 bytes. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE FUNCTION fill_leaf(t regclass, idx regclass) RETURNS int +LANGUAGE plpgsql AS $f$ +DECLARE gap int; s int; n int := 100000; +BEGIN + LOOP + SELECT upper - lower INTO gap FROM page_header(get_raw_page(idx::text, 1)); + EXIT WHEN gap < 8; + IF gap >= 40 THEN s := 20; + ELSIF gap % 8 = 4 THEN s := gap; + ELSE s := gap - 4; + END IF; + n := n + 1; + EXECUTE format('INSERT INTO %s VALUES ($1, $2)', t) + USING n, 'c' || lpad(n::text, s - 14, '0'); + END LOOP; + RETURN gap; +END $f$; +}); + +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SET enable_indexonlyscan = off; '; + +sub nblocks +{ + my ($t) = @_; + return $node->safe_psql('postgres', + "SELECT pg_relation_size('${t}_k') / 8192"); +} + +sub lsn +{ + return $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +} + +sub check_rolled_back +{ + my ($t, $what) = @_; + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = 'b'"), + '1', + "$what: row reachable under its restored key"); + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = 'a'"), + '0', + "$what: row not reachable under the rolled-back key"); + my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('${t}_k', true)"); + is($rc . $stderr, '0', "$what: amcheck heapallindexed"); +} + +# The rollback's undo split the leaf with a replace split record, and did not +# delete the plain entry first (a DELETE_MARK record with setmark set; the +# un-marking of (B, TID) is one with setmark clear). +sub check_replace_split +{ + my ($t, $start, $what) = @_; + is(nblocks($t), '4', "$what: undo split the leaf"); + # The rollback's records need not be flushed yet; a checkpoint does that. + $node->safe_psql('postgres', 'CHECKPOINT'); + my $out = $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(record_type || ' ' || description, E'\n') + FROM pg_get_wal_records_info('$start', pg_current_wal_flush_lsn()) + WHERE resource_manager = 'Btree'}); + like( + $out, + qr/SPLIT_[LR] .*replace: T/, + "$what: the entry was replaced in the split"); + like($out, qr/DELETE_MARK .*setmark: F/, "$what: (B, TID) was un-marked"); + unlike( + $out, + qr/DELETE_MARK .*setmark: T/, + "$what: the entry was not deleted first"); +} + +# Move the row from A to B and commit, revive (A, TID) in an open transaction, +# then fill the leaf from another session. Returns the open session. +sub revive_and_fill +{ + my ($t) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k text) USING flux; +CREATE INDEX ${t}_k ON $t (k) WITH (deduplicate_items = off); +INSERT INTO $t VALUES (1, 'a'); +}); + my $reader = $node->background_psql('postgres'); + $reader->query_safe($idx); + $reader->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + is($reader->query_safe("SELECT count(id) FROM $t WHERE k = 'a'"), + '1', "$t: old reader sees the row under A"); + $node->safe_psql('postgres', "UPDATE $t SET k = 'b' WHERE id = 1"); + + my $bg = $node->background_psql('postgres'); + $bg->query_safe("BEGIN; UPDATE $t SET k = 'a' WHERE id = 1"); + cmp_ok($node->safe_psql('postgres', "SELECT fill_leaf('$t', '${t}_k')"), + '<', 8, "$t: leaf is full"); + is(nblocks($t), '2', "$t: filling did not split the leaf"); + return ($bg, $reader); +} + +# Inline ROLLBACK. The rolling-back session waits right after re-marking +# (A, TID), while the old reader looks it up. +my ($bg, $reader) = revive_and_fill('inline_one'); +my $start = lsn(); +$bg->query_safe( + q{SELECT injection_points_set_local(); + SELECT injection_points_attach('nbtree-deletemark-no-room', 'wait')}); +$bg->query_until( + qr/start/, q{ +\echo start +ROLLBACK; +}); +$node->wait_for_event('client backend', 'nbtree-deletemark-no-room'); +is($reader->query_safe("SELECT count(id) FROM inline_one WHERE k = 'a'"), + '1', 'inline rollback: old reader sees the row under A during undo'); +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('nbtree-deletemark-no-room')"); +$bg->query_safe( + "SELECT injection_points_detach('nbtree-deletemark-no-room')"); +$bg->quit; +is($reader->query_safe("SELECT count(id) FROM inline_one WHERE k = 'a'"), + '1', 'inline rollback: old reader sees the row under A after undo'); +$reader->query_safe('COMMIT'); +$reader->quit; +check_replace_split('inline_one', $start, 'inline rollback'); +check_rolled_back('inline_one', 'inline rollback'); + +# Crash with the transaction open; the undo apply worker rolls it back. +($bg, $reader) = revive_and_fill('crash_one'); +$node->safe_psql('postgres', 'CHECKPOINT'); +$start = lsn(); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$reader->{run}->kill_kill; +$node->start; +ok( $node->poll_query_until( + 'postgres', "SELECT k = 'b' FROM crash_one WHERE id = 1"), + 'crash: row rolled back'); +ok( $node->poll_query_until( + 'postgres', q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + 'crash: undo apply worker finished'); +check_replace_split('crash_one', $start, 'crash rollback'); +check_rolled_back('crash_one', 'crash rollback'); + +# Replay both rollbacks' splits; wal_consistency_checking compares the +# replayed pages against the logged images. +$node->stop('immediate'); +$node->start; +check_rolled_back('inline_one', 'inline rollback after replay'); +check_rolled_back('crash_one', 'crash rollback after replay'); +ok(!$node->log_contains('inconsistent page'), 'no inconsistent page in redo'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/121_flux_deletemark_race.pl b/src/test/recovery/t/121_flux_deletemark_race.pl new file mode 100644 index 0000000000000..ed03372140113 --- /dev/null +++ b/src/test/recovery/t/121_flux_deletemark_race.pl @@ -0,0 +1,255 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Delete-marking an nbtree entry that has no room to grow on its leaf must be +# atomic. A FLUX in-place UPDATE that changes an indexed key delete-marks the +# old (k_old, TID) entry, which grows it by a heap TID trailer. When the leaf +# is full the entry cannot grow in place. A reader whose snapshot predates +# the UPDATE still has to reach the row under k_old the whole time; it must +# never find the entry missing. +# +# The injection point nbtree-deletemark-no-room fires in btdeletemark's +# no-room path once the leaf lock has been released. The UPDATE waits there +# while the old-snapshot reader looks the row up under k_old. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_deletemark_race'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +# fill_leaf() fills leaf block blk of an index on (k text) until fewer than +# 8 bytes (one heap TID trailer) remain free. A key of L bytes takes +# MAXALIGN(9 + L) + 4 bytes including its line pointer, so the entry sizes +# are 8m + 4, and the last one is sized to leave 0 or 4 bytes. The keys +# start with 'c', so they land on the leftmost leaf. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE FUNCTION fill_leaf(t regclass, idx regclass, blk int) RETURNS int +LANGUAGE plpgsql AS $f$ +DECLARE gap int; s int; n int := 100000; +BEGIN + LOOP + SELECT upper - lower INTO gap FROM page_header(get_raw_page(idx::text, blk)); + EXIT WHEN gap < 8; + IF gap >= 40 THEN s := 20; + ELSIF gap % 8 = 4 THEN s := gap; + ELSE s := gap - 4; + END IF; + n := n + 1; + EXECUTE format('INSERT INTO %s VALUES ($1, $2)', t) + USING n, 'c' || lpad(n::text, s - 14, '0'); + END LOOP; + RETURN gap; +END $f$; +CREATE FUNCTION leftmost_leaf(idx regclass) RETURNS int +LANGUAGE sql AS $f$ + SELECT s.blkno::int FROM generate_series(1, + pg_relation_size(idx) / 8192 - 1) AS g(b), + LATERAL bt_page_stats(idx::text, g.b::int) s + WHERE s.type = 'l' AND s.btpo_prev = 0 +$f$; + +}); + +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SET enable_indexonlyscan = off; '; +my @lsns; + +# Fill the leftmost leaf of $t's index and delete-mark one of its entries: +# the first one ('first'), which the split leaves on the left half, or the +# last one ('last'), which goes to the right half. +sub run_case +{ + my ($t, $which) = @_; + + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k text) USING flux; +CREATE INDEX ${t}_k ON $t (k) WITH (deduplicate_items = off); +INSERT INTO $t VALUES (1, 'a'); +INSERT INTO $t SELECT i, 'm' || lpad(i::text, 30, '0') + FROM generate_series(2, 1000) i; +}); + + my $leaf = $node->safe_psql('postgres', "SELECT leftmost_leaf('${t}_k')"); + cmp_ok( + $node->safe_psql( + 'postgres', "SELECT fill_leaf('$t', '${t}_k', $leaf)"), + '<', 8, + "$t: leftmost leaf is full"); + is($node->safe_psql('postgres', "SELECT leftmost_leaf('${t}_k')"), + $leaf, "$t: filling did not split the leaf"); + + my $id = 1; + $id = $node->safe_psql( + 'postgres', qq{ +SELECT id FROM $t WHERE ctid = (SELECT htid FROM bt_page_items('${t}_k', $leaf) + ORDER BY itemoffset DESC LIMIT 1)}) if $which eq 'last'; + my $kold = + $node->safe_psql('postgres', "SELECT k FROM $t WHERE id = $id"); + + my $q = "SELECT count(id) FROM $t WHERE k = '$kold'"; + + # The reader takes its snapshot before the UPDATE. + my $reader = $node->background_psql('postgres'); + $reader->query_safe($idx); + $reader->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + is($reader->query_safe($q), '1', "$t: reader sees the row under k_old"); + + push @lsns, + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + + # The UPDATE moves the row to the rightmost leaf, then delete-marks + # (k_old, TID) on the full leftmost leaf and waits in the no-room path. + my $updater = $node->background_psql('postgres'); + $updater->query_safe( + q{SELECT injection_points_set_local(); + SELECT injection_points_attach('nbtree-deletemark-no-room', 'wait')}); + $updater->query_until( + qr/start/, qq{ +\\echo start +UPDATE $t SET k = 'zzz' WHERE id = $id; +}); + $node->wait_for_event('client backend', 'nbtree-deletemark-no-room'); + + is($reader->query_safe($q), + '1', + "$t: reader still sees the row under k_old during the delete-mark"); + + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('nbtree-deletemark-no-room')"); + $updater->query_safe( + "SELECT injection_points_detach('nbtree-deletemark-no-room')"); + $updater->quit; + + push @lsns, + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + + is($reader->query_safe($q), + '1', "$t: reader sees the row under k_old after the UPDATE"); + $reader->query_safe('COMMIT'); + $reader->quit; + + check_index($t, $kold, 'after the UPDATE'); + return $kold; +} + +sub check_index +{ + my ($t, $kold, $what) = @_; + + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = '$kold'"), + '0', + "$t $what: new snapshot does not see the row under k_old"); + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = 'zzz'"), + '1', + "$t $what: new snapshot sees the row under k_new"); + my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('${t}_k', true)"); + is($rc . $stderr, '0', "$t $what: amcheck heapallindexed"); +} + +my $kleft = run_case('t_left', 'first'); +my $kright = run_case('t_right', 'last'); + +# Each delete-mark was logged as one split record that replaces the entry. +my $wal = $node->data_dir . '/pg_wal'; +my ($out, $err) = run_command( + [ + 'pg_waldump', '-p', $wal, '-s', $lsns[0], '-e', + $lsns[1], '-r', 'Btree' + ]); +like( + $out, + qr/SPLIT_L .*replace: T/, + 't_left: replace split logged as SPLIT_L'); +($out, $err) = run_command( + [ + 'pg_waldump', '-p', $wal, '-s', $lsns[2], '-e', + $lsns[3], '-r', 'Btree' + ]); +like( + $out, + qr/SPLIT_R .*replace: T/, + 't_right: replace split logged as SPLIT_R'); + +# Many key UPDATEs against an index built with full leaves. The new keys go +# to the right end of the index, so the old leaves stay full and the +# delete-marks there need replace splits. The parent level splits as well. A reader +# whose snapshot predates them must still find every row by its old key. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE s (id int, k text) USING flux; +INSERT INTO s SELECT i, md5(i::text) FROM generate_series(1, 20000) i; +CREATE INDEX s_k ON s (k) WITH (deduplicate_items = off, fillfactor = 100); +}); +my $reader = $node->background_psql('postgres'); +$reader->query_safe($idx); +$reader->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +my $allold = q{SELECT count(id) FROM s WHERE k = md5(id::text)}; +is($reader->query_safe($allold), '20000', 's: reader sees every row'); +my $start = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +$node->safe_psql('postgres', "UPDATE s SET k = 'z' || k WHERE id % 4 = $_") + for (0 .. 2); +cmp_ok( + $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM pg_get_wal_records_info('$start', pg_current_wal_insert_lsn()) + WHERE resource_manager = 'Btree' AND description LIKE '%replace: T%'}), + '>', + 50, + 's: delete-marks were done with replace splits'); +cmp_ok( + $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM pg_get_wal_records_info('$start', pg_current_wal_insert_lsn()) + WHERE resource_manager = 'Btree' AND record_type LIKE 'SPLIT%' + AND description LIKE 'level: 1,%'}), + '>', + 0, + 's: the parent level split, too'); +is($reader->query_safe($allold), + '20000', 's: reader still sees every row under its old key'); +$reader->query_safe('COMMIT'); +$reader->quit; +my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('s_k', true)"); +is($rc . $stderr, '0', 's: amcheck heapallindexed'); + +# Crash recovery replays the splits (with wal_consistency_checking, it also +# compares the result against the logged page images). +$node->stop('immediate'); +$node->start; +check_index('t_left', $kleft, 'after crash recovery'); +check_index('t_right', $kright, 'after crash recovery'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(id) FROM s WHERE k = md5(id::text)'), + '5000', + 's after crash recovery: rows not updated keep their key'); +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('s_k', true)"); +is($rc . $stderr, '0', 's after crash recovery: amcheck heapallindexed'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/122_flux_amcheck_deletemark.pl b/src/test/recovery/t/122_flux_amcheck_deletemark.pl new file mode 100644 index 0000000000000..bdc2c0602181d --- /dev/null +++ b/src/test/recovery/t/122_flux_amcheck_deletemark.pl @@ -0,0 +1,71 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# amcheck on a FLUX table whose nbtree indexes hold delete-marked entries. +# +# A FLUX UPDATE that changes an indexed key keeps the row's TID and +# delete-marks the old (k_old, TID) entry. The tombstone is the only entry +# under which a snapshot that still sees k_old reaches the row, and next to it +# there may be a live entry with the same key for a different row. Neither +# state is corruption: +# +# - bt_index_check(heapallindexed) run while the UPDATE is uncommitted sees +# the k_old version, so it must find the tombstone's fingerprint; +# - bt_index_check(checkunique) must not count a tombstone as a live +# duplicate of a new row that took the old key. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_amcheck_deletemark'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE TABLE t (id int, k int) USING flux; +INSERT INTO t SELECT g, g FROM generate_series(1, 100) g; +CREATE INDEX t_k ON t (k); +CREATE TABLE u (id int, k int) USING flux; +INSERT INTO u SELECT g, g FROM generate_series(1, 100) g; +CREATE UNIQUE INDEX u_k ON u (k); +}); + +# heapallindexed while an in-place key UPDATE is in progress. +my $upd = $node->background_psql('postgres'); +$upd->query_safe('BEGIN'); +$upd->query_safe('UPDATE t SET k = k + 1000 WHERE id = 50'); + +my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('t_k', true)"); +is($rc . $stderr, '0', 'heapallindexed passes during an in-place key UPDATE'); + +$upd->query_safe('COMMIT'); +$upd->quit; + +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('t_k', true)"); +is($rc . $stderr, '0', 'heapallindexed passes after the UPDATE commits'); + +# checkunique: row id=7 moves off k=7, a new row takes k=7. The index holds +# a tombstone (7, TID of id=7) next to the live (7, TID of the new row). +$node->safe_psql('postgres', 'UPDATE u SET k = 1007 WHERE id = 7'); +$node->safe_psql('postgres', 'INSERT INTO u VALUES (1000, 7)'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM u WHERE k = 7'), + '1', 'one live row has k = 7'); + +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('u_k', true, true)"); +is( $rc . $stderr, + '0', + 'checkunique ignores a tombstone next to a live entry with the same key'); +($rc, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_parent_check('u_k', true, true, true)"); +is($rc . $stderr, + '0', 'bt_index_parent_check(rootdescend, checkunique) passes'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/123_flux_tombstone_size.pl b/src/test/recovery/t/123_flux_tombstone_size.pl new file mode 100644 index 0000000000000..03a94d80bb8d8 --- /dev/null +++ b/src/test/recovery/t/123_flux_tombstone_size.pl @@ -0,0 +1,83 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# The largest entry a delete-marking table's nbtree index accepts must still +# fit after it becomes a tombstone. Delete-marking grows a leaf entry by a +# MAXALIGN'd heap TID trailer, so an entry right at BTMaxItemSize would turn +# into a leaf tuple larger than the limit (which amcheck reports), or the +# UPDATE that delete-marks it would fail. Such a key has to be rejected when +# it is inserted, like any other key that is too long. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_tombstone_size'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); + +# BTMaxItemSize, assuming MAXALIGN 8: one third of the usable page space, +# rounded down to MAXALIGN, less one MAXALIGN'd heap TID. A text key of L +# bytes (4-byte varlena header, not compressed) makes a leaf entry of +# MAXALIGN(8 + 4 + L) bytes. +my $bs = $node->safe_psql('postgres', "SELECT current_setting('block_size')"); +my $max = int(($bs - 40 - 16) / 3 / 8) * 8 - 8; +my $big = $max - 12; # entry of exactly BTMaxItemSize +my $ok = $big - 8; # entry of BTMaxItemSize - 8: its tombstone fits + +# Random hex does not compress, so the index entry keeps its full size. +$node->safe_psql( + 'postgres', q{ +CREATE FUNCTION rnd(n int) RETURNS text LANGUAGE sql AS +$$ SELECT left(string_agg(md5(random()::text), ''), n) + FROM generate_series(1, n / 32 + 1) $$; +CREATE TABLE t (id int, k text) USING flux; +CREATE INDEX t_k ON t (k); +}); + +my ($rc, $stdout, $stderr) = + $node->psql('postgres', "INSERT INTO t VALUES (1, rnd($big))"); +like( + $stderr, + qr/index row size \d+ exceeds btree version 4 maximum/, + 'a key whose tombstone would not fit is rejected at insert'); + +$node->safe_psql('postgres', "INSERT INTO t VALUES (2, rnd($ok))"); +my $ctid = $node->safe_psql('postgres', 'SELECT ctid FROM t WHERE id = 2'); + +# Change every row's key in place; each old entry becomes a tombstone. +$node->safe_psql('postgres', "UPDATE t SET k = rnd(length(k))"); +is($node->safe_psql('postgres', 'SELECT ctid FROM t WHERE id = 2'), + $ctid, 'the key UPDATE kept the TID'); + +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('t_k', true)"); +is($rc . $stderr, '0', 'bt_index_check passes after the key UPDATE'); + +# CREATE INDEX enforces the same limit. +$node->safe_psql( + 'postgres', qq{ +CREATE TABLE b (id int, k text) USING flux; +INSERT INTO b VALUES (1, rnd($big)); +}); +($rc, $stdout, $stderr) = + $node->psql('postgres', 'CREATE INDEX b_k ON b (k)'); +like( + $stderr, + qr/index row size \d+ exceeds btree version 4 maximum/, + 'CREATE INDEX rejects a key whose tombstone would not fit'); + +# A heap table keeps the full limit. +$node->safe_psql( + 'postgres', qq{ +CREATE TABLE h (id int, k text) USING heap; +CREATE INDEX h_k ON h (k); +INSERT INTO h VALUES (1, rnd($big)); +}); +pass('a heap table accepts a key of exactly BTMaxItemSize'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/124_index_undo_tid_reuse.pl b/src/test/recovery/t/124_index_undo_tid_reuse.pl new file mode 100644 index 0000000000000..066268c9820e1 --- /dev/null +++ b/src/test/recovery/t/124_index_undo_tid_reuse.pl @@ -0,0 +1,206 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Index UNDO applied after the abort is visible must not kill a committed +# entry that reuses the aborted heap TID. +# +# nbtree and hash index UNDO find "their" entry by (heap TID, length, key +# digest). That identifies it only while the aborted heap tuple still holds +# the TID. When the apply is deferred -- ATM instant abort handed to the +# logical revert worker, or crash recovery deferring the chain to the worker +# -- VACUUM can remove the aborted heap tuples and their index entries in +# between, and a committed INSERT can reuse the TIDs with the same keys. The +# deferred apply must then leave those committed entries alone: index scans +# must still find every row, and the primary key must still reject a +# duplicate. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $nrows = 200; + +my $node = PostgreSQL::Test::Cluster->new('index_undo_tid_reuse'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +logical_revert_naptime = 100 +# Put the UNDO into the WAL as it is written, for the crash case. +undo_batch_record_limit = 100 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck') + || !$node->check_extension('test_fileops')) +{ + plan skip_all => + 'injection_points, amcheck or test_fileops not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +CREATE EXTENSION test_fileops; +-- Number of rows with key 1..n found by index scans on column col. +CREATE FUNCTION idx_found(tab regclass, col text, n int) RETURNS int +LANGUAGE plpgsql AS $$ +DECLARE + c int := 0; + f int; +BEGIN + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + FOR i IN 1..n LOOP + EXECUTE format('SELECT count(*) FROM %s WHERE %I = $1', tab, col) + INTO f USING i; + c := c + f; + END LOOP; + RETURN c; +END $$; +)); + +sub create_table +{ + my ($t) = @_; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int PRIMARY KEY, v int) USING heap + WITH (index_undo = on, autovacuum_enabled = off); +CREATE INDEX ${t}_v_hash ON $t USING hash (v); +)); +} + +sub insert_sql +{ + my ($t) = @_; + return "INSERT INTO $t SELECT g, g FROM generate_series(1, $nrows) g " + . "RETURNING ctid;"; +} + +# VACUUM away the aborted rows, then commit the same keys again, which +# reuses the freed line pointers. Returns how many TIDs were reused. +sub vacuum_and_reinsert +{ + my ($t, $aborted_ctids) = @_; + my %aborted = map { $_ => 1 } split /\n/, $aborted_ctids; + + $node->safe_psql('postgres', "VACUUM (INDEX_CLEANUP on) $t"); + is($node->safe_psql('postgres', "SELECT count(*) FROM $t"), + '0', "$t: aborted rows gone after VACUUM"); + my $new = $node->safe_psql('postgres', insert_sql($t)); + return scalar grep { $aborted{$_} } split /\n/, $new; +} + +sub wait_atm_drained +{ + $node->poll_query_until('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NULL') + or die 'timed out waiting for the ATM to drain'; +} + +sub check_table +{ + my ($t, $label) = @_; + + is($node->safe_psql('postgres', "SELECT count(*) FROM $t"), + $nrows, "$label: seqscan sees every row"); + like( + $node->safe_psql( + 'postgres', + "SET enable_seqscan = off; SET enable_bitmapscan = off; + EXPLAIN (COSTS OFF) SELECT * FROM $t WHERE v = 1"), + qr/Index Scan using ${t}_v_hash/, + "$label: hash lookups use the hash index"); + is( $node->safe_psql('postgres', "SELECT idx_found('$t', 'id', $nrows)"), + $nrows, + "$label: btree index scans find every row"); + is($node->safe_psql('postgres', "SELECT idx_found('$t', 'v', $nrows)"), + $nrows, "$label: hash index scans find every row"); + + my ($ret, $stdout, $stderr) = + $node->psql('postgres', "INSERT INTO $t VALUES (1, 1)"); + like( + $stderr, + qr/duplicate key value violates unique constraint/, + "$label: primary key rejects a duplicate of a committed key"); + + ($ret, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_check('${t}_pkey', true, true)"); + is($ret, 0, "$label: bt_index_check passes") + or diag($stderr); +} + +########################################################################### +# 1. ATM instant abort: the logical revert worker applies the chain later. +########################################################################### +create_table('t_atm'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('logical-revert-before-process', 'wait')" +); + +my $aborted = $node->safe_psql( + 'postgres', qq( +SET undo_instant_abort_threshold = 0; +BEGIN; +@{[insert_sql('t_atm')]} +ROLLBACK; +)); +$aborted =~ s/^(SET|BEGIN|ROLLBACK)\n?//mg; +isnt(test_fileops_atm_pending(), 0, 'ATM: rollback went to the ATM'); + +$node->wait_for_event('logical revert worker', + 'logical-revert-before-process'); + +my $reused = vacuum_and_reinsert('t_atm', $aborted); +cmp_ok($reused, '>', 0, "ATM: committed rows reuse $reused aborted TIDs"); + +$node->safe_psql( + 'postgres', q( +SELECT injection_points_detach('logical-revert-before-process'); +SELECT injection_points_wakeup('logical-revert-before-process'); +)); +wait_atm_drained(); +check_table('t_atm', 'ATM'); + +sub test_fileops_atm_pending +{ + return $node->safe_psql('postgres', + 'SELECT (test_fileops_atm_oldest_lsn() IS NOT NULL)::int'); +} + +########################################################################### +# 2. Crash: recovery defers the chain to the worker, which runs only after +# VACUUM and the committed re-insert. +########################################################################### +create_table('t_crash'); +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); + +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +my $crash_ctids = $bg->query_safe(insert_sql('t_crash')); +# Make the inserts and their UNDO durable before the crash. +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); +eval { $bg->quit; }; +$node->start; + +is(test_fileops_atm_pending(), + 1, 'crash: recovery deferred the chain to the ATM'); +$reused = vacuum_and_reinsert('t_crash', $crash_ctids); +cmp_ok($reused, '>', 0, "crash: committed rows reuse $reused aborted TIDs"); + +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +wait_atm_drained(); +check_table('t_crash', 'crash'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl b/src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl new file mode 100644 index 0000000000000..027a0a08e7c24 --- /dev/null +++ b/src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl @@ -0,0 +1,143 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Undo of a temporary, unlogged or WAL-skipping relation goes to an undo log +# that recovery never replays. The undo-log meta-data record +# (XLOG_UNDOLOG_META) that maps a transaction to its undo log must therefore +# only ever be written for the permanent undo log: if it named the +# non-permanent log, replay would map the transaction to that log and the +# replay of the same transaction's later permanent undo would fail. +# +# Each case runs in one transaction: a change to a permanent table (attaches +# the permanent undo log), a checkpoint from another session (so the next undo +# insert is the first after the checkpoint), a change to the non-permanent +# relation, and another change to the permanent table. Crash recovery, and +# for the temporary and unlogged cases a streaming standby, must replay that. +# Heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Wait until $standby replayed up to $lsn or logged a PANIC. +sub standby_replays +{ + my ($standby, $lsn) = @_; + + foreach (1 .. $PostgreSQL::Test::Utils::timeout_default) + { + return 0 if slurp_file($standby->logfile) =~ /PANIC/; + my ($ret, $out) = $standby->psql('postgres', + "SELECT pg_last_wal_replay_lsn() >= '$lsn'::pg_lsn"); + return 1 if $ret == 0 && $out eq 't'; + sleep 1; + } + return 0; +} + +sub run_case +{ + my ($node, $standby, $am, $kind) = @_; + my $s = $node->background_psql('postgres'); + my $nonperm; + + if ($kind eq 'temp') + { + $s->query_safe( + "CREATE TEMP TABLE np (id int, v int) USING $am; INSERT INTO np VALUES (1, 0)" + ); + $nonperm = 'UPDATE np SET v = v + 1'; + } + elsif ($kind eq 'unlogged') + { + $node->safe_psql('postgres', + "CREATE UNLOGGED TABLE np (id int, v int) USING $am; INSERT INTO np VALUES (1, 0)" + ); + $nonperm = 'UPDATE np SET v = v + 1'; + } + else + { + $nonperm = + "CREATE TABLE np (id int, v int) USING $am; INSERT INTO np VALUES (1, 0); UPDATE np SET v = v + 1"; + } + + $s->query_safe('BEGIN'); + $s->query_safe('UPDATE perm SET v = v + 1 WHERE id = 1'); + $node->safe_psql('postgres', 'CHECKPOINT'); + $s->query_safe($nonperm); + $s->query_safe('UPDATE perm SET v = v + 1 WHERE id = 2'); + $s->query_safe('COMMIT'); + $s->quit; + + if ($standby) + { + my $ok = ok( + standby_replays($standby, $node->lsn('insert')), + "$am $kind: standby replays the transaction"); + if (!$ok) + { + my ($panic) = slurp_file($standby->logfile) =~ /(PANIC:.*)/; + diag($panic // 'no PANIC'); + } + else + { + is($standby->safe_psql('postgres', 'SELECT sum(v) FROM perm'), + '2', "$am $kind: standby sees the permanent changes"); + } + } + + $node->stop('immediate'); + my $ret = $node->start(fail_ok => 1); + ok($ret, "$am $kind: crash recovery replays the transaction"); + if (!$ret) + { + my ($panic) = slurp_file($node->logfile) =~ /(PANIC:.*)/; + diag($panic // 'no PANIC'); + return; + } + is($node->safe_psql('postgres', 'SELECT sum(v) FROM perm'), + '2', "$am $kind: permanent changes survive the crash"); +} + +for my $am (qw(heap flux)) +{ + for my $kind (qw(temp unlogged skip-WAL)) + { + my $node = PostgreSQL::Test::Cluster->new("${am}_$kind"); + my $standby; + + if ($kind eq 'skip-WAL') + { + # A permanent relation created in the same transaction skips WAL. + $node->init; + $node->append_conf( + 'postgresql.conf', qq[ +wal_level = minimal +max_wal_senders = 0 +wal_skip_threshold = 0 +]); + } + else + { + $node->init(allows_streaming => 1); + } + $node->append_conf('postgresql.conf', 'autovacuum = off'); + $node->start; + $node->safe_psql('postgres', + "CREATE TABLE perm (id int PRIMARY KEY, v int) USING $am; INSERT INTO perm SELECT g, 0 FROM generate_series(1, 10) g" + ); + if ($kind ne 'skip-WAL') + { + $node->backup('bkp'); + $standby = + PostgreSQL::Test::Cluster->new("${am}_${kind}_standby"); + $standby->init_from_backup($node, 'bkp', has_streaming => 1); + $standby->start; + } + run_case($node, $standby, $am, $kind); + $standby->stop('immediate', fail_ok => 1) if $standby; + $node->stop('immediate', fail_ok => 1); + } +} + +done_testing(); diff --git a/src/test/recovery/t/126_atm_full_abort.pl b/src/test/recovery/t/126_atm_full_abort.pl new file mode 100644 index 0000000000000..0fc0fcb8a01cd --- /dev/null +++ b/src/test/recovery/t/126_atm_full_abort.pl @@ -0,0 +1,125 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# An abort that cannot be recorded in a full ATM must not fail. +# +# ATM instant abort records the aborted transaction in the sLog radix tree, +# a DSA area capped by slog_dsa_max_size_mb. With the logical revert worker +# held, every instant abort adds an entry until the cap is reached. The +# registration runs inside AbortTransaction, so it must not raise an error +# there: the abort falls back (WARNING "ATM full"), the backend and server +# stay up, the aborted rows stay invisible, and they stay so after a crash +# restart. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# The cap is reached after about 4500 entries. +my $naborts = 8000; +my $nclients = 4; + +my $node = PostgreSQL::Test::Cluster->new('atm_full_abort'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +logical_revert_naptime = 100 +slog_dsa_max_size_mb = 2 +undo_instant_abort_threshold = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points or amcheck not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +CREATE TABLE t (id int PRIMARY KEY, v int) USING heap + WITH (index_undo = on, autovacuum_enabled = off); +INSERT INTO t VALUES (0, 0); +SELECT injection_points_attach('logical-revert-before-process', 'wait'); +)); + +# The injection point holds the revert worker at its first entry, whenever +# it starts, so the ATM only grows. + +my $log_offset = -s $node->logfile; + +# Many aborted transactions, each an ATM instant abort, from several +# clients at once, until well past the cap. +my @clients; +for my $c (0 .. $nclients - 1) +{ + my $h = $node->background_psql('postgres', on_error_stop => 0); + $h->query_safe('SET client_min_messages = error'); + $h->query_until( + qr/start/, qq(\\echo start +DO \$\$ +BEGIN + FOR i IN 1..$naborts / $nclients LOOP + INSERT INTO t VALUES (i * $nclients + $c, i); + ROLLBACK; + END LOOP; +END \$\$; +\\echo done +)); + push @clients, $h; +} +my $stderr = ''; +for my $h (@clients) +{ + $h->query_until(qr/done/, ''); + $stderr .= $h->{stderr}; + $h->quit; +} + +ok($node->log_contains(qr/WARNING: .*ATM full/, $log_offset), + 'full ATM reported as a WARNING'); +unlike( + $stderr, + qr/ERROR|FATAL|PANIC|server closed/, + 'no error while aborting into a full ATM'); +ok( !$node->log_contains( + qr/ERROR|PANIC|server process .* was terminated/, $log_offset), + 'no ERROR or PANIC in the server log'); + +# The aborted rows stay invisible, through the index and the heap. +sub check_rows +{ + my ($what) = @_; + is( $node->safe_psql( + 'postgres', qq( +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM t WHERE id BETWEEN -1 AND $naborts + $nclients;)), + '1', + "index scan sees only the committed row $what"); + is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '1', "seq scan sees only the committed row $what"); + $node->safe_psql('postgres', q(SELECT bt_index_check('t_pkey', true))); + pass("t_pkey passes amcheck $what"); +} +check_rows('after the aborts'); + +# Crash and restart: redo must not resurrect anything either. +$node->stop('immediate'); +$node->start; +check_rows('after crash restart'); + +# The injection point did not survive the restart: the revert worker now +# drains what was recorded, and VACUUM removes the rest. +$node->safe_psql('postgres', 'VACUUM t'); +check_rows('after VACUUM'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/127_flux_rollback_prepared_crash.pl b/src/test/recovery/t/127_flux_rollback_prepared_crash.pl new file mode 100644 index 0000000000000..b5dad34d1096d --- /dev/null +++ b/src/test/recovery/t/127_flux_rollback_prepared_crash.pl @@ -0,0 +1,114 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A crash between ROLLBACK PREPARED's abort record and the apply of its +# per-backend UNDO must still roll the transaction back. +# +# ROLLBACK PREPARED applies a prepared transaction's per-backend UNDO in a +# 2PC postabort callback, after the abort record. Once that record is +# durable no prepared transaction is left to roll back again, so after a +# crash in between it is the per-backend UNDO recovery that must find the +# chain and roll it back. FLUX updates in place, so a missed rollback leaves +# the new values on the page. Readers would not see them (the writer did not +# commit), so the test also looks for them in the raw pages: they must be gone +# once the undo apply worker has run. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_rollback_prepared_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 10 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE TABLE t (id int PRIMARY KEY, v text) USING flux; +INSERT INTO t SELECT g, 'origorig' || g FROM generate_series(1, 500) g; +CHECKPOINT; +BEGIN; +-- Same length, so the update stays in place. +UPDATE t SET v = 'zQnewQz_' || id; +DELETE FROM t WHERE id > 450; +PREPARE TRANSACTION 'p1'; +)); + +my $expected = $node->safe_psql('postgres', + q(SELECT count(*), sum(length(v)) FROM t WHERE v LIKE 'origorig%')); +is($expected, '500|5392', + 'prepared changes are invisible before the rollback'); + +# Pages of t that still hold an updated value. +my $dirty_pages = q( +SELECT count(*) FROM generate_series(0, pg_relation_size('t') / 8192 - 1) b +WHERE position('zQnewQz_'::bytea IN get_raw_page('t', b::int)) > 0); +isnt($node->safe_psql('postgres', $dirty_pages), + '0', 'the updated values are on the pages before the rollback'); + +# Hold ROLLBACK PREPARED just after its abort record, then crash. +$node->safe_psql('postgres', + q(SELECT injection_points_attach('rollback-prepared-after-abort-record', 'wait')) +); +my $h = $node->background_psql('postgres', on_error_stop => 0); +$h->query_until( + qr/start/, q(\echo start +ROLLBACK PREPARED 'p1'; +)); +$node->wait_for_event('client backend', + 'rollback-prepared-after-abort-record'); +$node->stop('immediate'); +$h->{run}->finish; + +$node->start; +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '0', 'no prepared transaction after the crash'); + +sub check_rows +{ + my ($what) = @_; + is( $node->safe_psql( + 'postgres', + q(SELECT count(*), sum(length(v)) FROM t WHERE v LIKE 'origorig%') + ), + $expected, + "every row has its old value $what"); + is($node->safe_psql('postgres', q(SELECT count(*) FROM t)), + '500', "no row is missing $what"); + is( $node->safe_psql( + 'postgres', q( +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM t WHERE id BETWEEN 1 AND 500 AND v = 'origorig' || id;)), + '500', + "index scans agree $what"); +} +check_rows('after the restart'); + +# The undo apply worker reverts what recovery deferred. +ok($node->poll_query_until('postgres', "SELECT ($dirty_pages) = 0", 't'), + 'the rollback is applied to the pages'); +check_rows('after the undo apply worker'); + +# And after another clean restart. +$node->restart; +check_rows('after a clean restart'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/128_flux_delete_superseded_redo.pl b/src/test/recovery/t/128_flux_delete_superseded_redo.pl new file mode 100644 index 0000000000000..faf5127e27284 --- /dev/null +++ b/src/test/recovery/t/128_flux_delete_superseded_redo.pl @@ -0,0 +1,155 @@ + + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX DELETE after an aborted out-of-place UPDATE, under +# wal_consistency_checking. +# +# An out-of-place UPDATE leaves the old version DELETED | SUPERSEDED with +# t_ctid naming the new version. If it aborts and its rollback is deferred, +# that image stays on the page. A later DELETE must not act on it: it reverts +# the aborted version first (flux_help_revert), so it deletes the restored +# before-image, which carries neither SUPERSEDED nor the forward link. The +# DELETE and its redo leave SUPERSEDED clear and t_ctid at the tuple's own +# TID; flux_mask compares SUPERSEDED, and t_ctid with it. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); + +# No undo apply worker can start (max_worker_processes is the number of +# static workers, see 076_flux_deferred_rollback.pl), and every rollback is +# deferred to it, so the aborted UPDATE stays on the page. +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +max_worker_processes = 3 +undo_rollback_overflow_size = 0 +)); +$node->start; +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +sub dump_tup +{ + my ($n, $rel, $label, $tid) = @_; + my ($blk, $off) = $tid =~ /\((\d+),(\d+)\)/; + my $hex = $n->safe_psql('postgres', + "SELECT encode(get_raw_page('$rel', $blk), 'hex')"); + my $raw = pack('H*', $hex); + my $lp = unpack('V', substr($raw, 24 + 4 * ($off - 1), 4)); + my $lpoff = $lp & 0x7fff; + my $flags = unpack('v', substr($raw, $lpoff + 32, 2)); + my ($bhi, $blo, $o) = unpack('vvv', substr($raw, $lpoff + 24, 6)); + my $xmax = unpack('V', substr($raw, $lpoff, 4)); + my $ctid = sprintf("(%u,%u)", ($bhi << 16) | $blo, $o); + note sprintf("%s %s: t_flags=0x%04x t_ctid=%s xmax=%u", + $label, $tid, $flags, $ctid, $xmax); + is($ctid, $tid, "$label: t_ctid is the own TID") if !($flags & 0x1000); + return $flags; +} + +# t: a deferrable unique constraint cannot be maintained in place, so a +# change of k takes the out-of-place (new-TID) path. u: the same, on a row a +# committed in-place key UPDATE left KEYS_UPDATED (the constraint is added +# afterwards), for which a keyless uniqueness fetch does not take the +# committed-version path. In both, a uniqueness check meets the aborted +# version before the DELETE. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE t (id int PRIMARY KEY, k int UNIQUE DEFERRABLE, v int) USING flux; +INSERT INTO t SELECT g, g, g * 10 FROM generate_series(1, 10) g; +CREATE TABLE u (id int PRIMARY KEY, k int, v int) USING flux; +CREATE INDEX u_k ON u (k); +INSERT INTO u SELECT g, g, g * 10 FROM generate_series(1, 10) g; +UPDATE u SET k = 1001 WHERE id = 1; +ALTER TABLE u ADD UNIQUE (k) DEFERRABLE; +CHECKPOINT; +}); + +foreach my $tab (qw(t u)) +{ + my $old_tid = + $node->safe_psql('postgres', "SELECT ctid FROM $tab WHERE id = 1"); + + $node->safe_psql('postgres', + "BEGIN; UPDATE $tab SET k = 100 WHERE id = 1; ROLLBACK;"); + ok( dump_tup($node, $tab, "$tab after abort", $old_tid) & 0x1000, + "$tab: the aborted out-of-place UPDATE left SUPERSEDED on the page"); + + # The PK uniqueness check meets the old version: the row is live. + my ($ret, $out, $err) = + $node->psql('postgres', "INSERT INTO $tab VALUES (1, 1000, 0)"); + like($err, qr/duplicate key value/, + "$tab: row 1 is live after the abort"); + dump_tup($node, $tab, "$tab after unique check", $old_tid); + + is( $node->safe_psql( + 'postgres', "DELETE FROM $tab WHERE id = 1 RETURNING ctid"), + $old_tid, + "$tab: DELETE removes the old version"); + ok(!(dump_tup($node, $tab, "$tab after delete", $old_tid) & 0x1000), + "$tab: primary: the DELETE cleared SUPERSEDED"); + + $node->wait_for_replay_catchup($standby); + is( $standby->safe_psql( + 'postgres', "SELECT count(*) FROM $tab WHERE id = 1"), + '0', + "$tab: standby: row 1 is deleted"); + ok( !( dump_tup($standby, $tab, "$tab standby after delete", $old_tid) & + 0x1000), + "$tab: standby: the DELETE redo cleared SUPERSEDED"); +} +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + 'no undo apply worker'); +unlike( + slurp_file($standby->logfile), + qr/inconsistent page found/, + 'standby: FLUX DELETE redo reproduces the primary page'); + +# A non-key move records compatibility on the old version. Exercise both +# the first change after a checkpoint and a later non-FPI SUPERSEDE record. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE nk (id int, v int, PRIMARY KEY (id) INCLUDE (v)) USING flux; +INSERT INTO nk VALUES (1, 1), (2, 1); +CHECKPOINT; +}); +foreach my $id (1, 2) +{ + my $tid = + $node->safe_psql('postgres', "SELECT ctid FROM nk WHERE id=$id"); + $node->safe_psql('postgres', "UPDATE nk SET v=2 WHERE id=$id"); + ok(dump_tup($node, 'nk', "non-key move $id primary", $tid) & 0x2000, + "non-key move $id records key compatibility"); + $node->wait_for_replay_catchup($standby); + ok(dump_tup($standby, 'nk', "non-key move $id standby", $tid) & 0x2000, + "non-key move $id replays key compatibility"); +} +is($standby->safe_psql('postgres', 'SELECT sum(v) FROM nk'), + '4', 'standby sees both moved rows'); +unlike( + slurp_file($standby->logfile), + qr/inconsistent page found/, + 'non-key move redo reproduces the primary page'); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/129_undo_redo_no_fpw.pl b/src/test/recovery/t/129_undo_redo_no_fpw.pl new file mode 100644 index 0000000000000..7c04ce747de44 --- /dev/null +++ b/src/test/recovery/t/129_undo_redo_no_fpw.pl @@ -0,0 +1,102 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Undo redo must not rewrite an undo page that already holds a later change. +# +# FLUX redo re-runs the undo insert carried on each forward record, while +# XLOG_UNDO_AMEND_RECORD patches an inserted record under the page LSN. An +# indexed key that goes A -> B -> A revives the (A, TID) entry the first +# UPDATE delete-marked, and says so in the second UPDATE's undo record +# (FLUX_UNDO_INDEX_REVIVED) by such an amendment. Without full-page writes, +# an undo page written out after the amendment carries the amendment's LSN, +# and recovery from a redo pointer before the insert must neither re-insert +# the record over it nor, of course, skip the amendment and lose it. If it +# did, reverting the second UPDATE would remove (A, TID) instead of +# delete-marking it again, and reverting the first would find nothing to +# make live: the row would drop out of the index. +# +# The undo apply worker cannot start (see 076_flux_deferred_rollback.pl), so +# the crash loser is reverted by the writer that meets it ("helping"). + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +full_page_writes = off +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +max_worker_processes = 3 +undo_rollback_overflow_size = 0 +)); +$node->start; + +if ( !$node->check_extension('pg_buffercache') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'pg_buffercache or amcheck not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pg_buffercache; +CREATE EXTENSION amcheck; +CREATE TABLE f (id int PRIMARY KEY, k int, v int) USING flux; +CREATE INDEX f_k ON f (k); +INSERT INTO f SELECT g, g, g * 10 FROM generate_series(1, 10) g; +CHECKPOINT; +}); + +# The redo pointer is now before the loser's undo inserts. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('UPDATE f SET k = 100, v = 11 WHERE id = 1'); +$bg->query_safe('UPDATE f SET k = 1 WHERE id = 1'); + +# Write the undo pages out, carrying the amendment's LSN, without a +# checkpoint. The undo logs live in database 9 (UndoLogDatabaseOid). +cmp_ok( + $node->safe_psql( + 'postgres', q{ +SELECT count(*) FROM pg_buffercache b +WHERE b.reldatabase = 9 AND (pg_buffercache_evict(b.bufferid)).buffer_flushed} + ), + '>', + 0, + 'undo pages written out'); + +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +my $seq = 'SET enable_indexscan = off; SET enable_bitmapscan = off; '; +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; '; + +is( $node->safe_psql( + 'postgres', + $seq . 'UPDATE f SET v = v + 1 WHERE id = 1 RETURNING k, v'), + '1|11', + 'a writer reverts the loser and updates the committed version'); +is($node->safe_psql('postgres', $idx . 'SELECT v FROM f WHERE k = 1'), + '11', 'index scan finds the row under its key'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM f WHERE k = 100'), + '0', + 'no live entry under the aborted key'); +is( $node->safe_psql('postgres', $idx . 'SELECT count(*) FROM f WHERE k > 0'), + '10', + 'index scan count matches the table'); +my ($ret, $out, $err) = + $node->psql('postgres', "SELECT bt_index_check('f_k', true)"); +is($ret . $err, '0', 'bt_index_check(f_k, heapallindexed)'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/130_flux_single_user_abort.pl b/src/test/recovery/t/130_flux_single_user_abort.pl new file mode 100644 index 0000000000000..b346199bf115c --- /dev/null +++ b/src/test/recovery/t/130_flux_single_user_abort.pl @@ -0,0 +1,86 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# FLUX in single-user mode: a transaction updates rows in place, then +# replaces or drops storage the update touched, then rolls back. There is +# no undo worker, so the backend itself must finish the rollback once the +# transaction's catalog changes are undone. Then the same update applied +# again must give what it gives on heap, and amcheck must find the indexes +# intact. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($windows_os) +{ + plan skip_all => 'single-user mode is not tested on this platform'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_single_user_abort'); +$node->init; +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->stop; + +foreach my $ddl ( + 'DROP INDEX t_k', + 'REINDEX INDEX t_k', + 'ALTER TABLE t ALTER COLUMN k TYPE bigint', + 'TRUNCATE t', 'CLUSTER t USING t_k', + 'DROP TABLE t') +{ + foreach my $am ('heap', 'flux') + { + # With -j, a statement ends with a semicolon and an empty line. + my $sql = <<"SQL"; +DROP TABLE IF EXISTS t; + +CREATE TABLE t (id int PRIMARY KEY, k int) USING $am; + +CREATE INDEX t_k ON t (k); + +INSERT INTO t SELECT g, g FROM generate_series(1, 200) g; + +BEGIN; + +UPDATE t SET k = k + 1000 WHERE id <= 50; + +$ddl; + +ROLLBACK; + +UPDATE t SET k = k + 1000 WHERE id <= 50; + +SELECT 'r:' || count(*) || '|' || sum(k) AS r FROM t; + +SET enable_seqscan = off; + +SELECT 'i:' || count(*) AS i FROM t WHERE k > 1000; + +SELECT 'c:' || bt_index_check('t_k', true)::text || bt_index_check('t_pkey', true)::text AS c; + +SQL + my ($out, $err); + my $ok = IPC::Run::run( + [ + 'postgres', '--single', '-F', '-j', + '-c' => 'exit_on_error=true', + '-D' => $node->data_dir, + 'postgres' + ], + '<' => \$sql, + '>' => \$out, + '2>' => \$err); + ok($ok, "$am, $ddl: single-user session succeeds") + or diag($err); + my @got = $out =~ /"([ric]:[^"]*)"/g; + is( join(' ', @got), + 'r:200|70100 i:50 c:', + "$am, $ddl: rows, index scan and amcheck after the rollback"); + unlike($err, qr/WARNING/, "$am, $ddl: no warning"); + } +} + +done_testing(); diff --git a/src/test/recovery/t/131_flux_vacuum_rounds.pl b/src/test/recovery/t/131_flux_vacuum_rounds.pl new file mode 100644 index 0000000000000..8944defbb3481 --- /dev/null +++ b/src/test/recovery/t/131_flux_vacuum_rounds.pl @@ -0,0 +1,193 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# VACUUM with more dead TIDs than maintenance_work_mem holds. Heap's VACUUM +# stops its table scan when dead_items is full, vacuums the indexes and the +# table pages scanned so far, and scans on. FLUX must do the same: neither +# stop early nor drop TIDs, and free a line pointer only in the round whose +# index vacuum removed its index entries. Each case runs on a heap twin +# with the same expectations. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if (($ENV{enable_injection_points} // '') ne 'yes') +{ + plan skip_all => 'injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_vacuum_rounds'); +$node->init; +$node->append_conf('postgresql.conf', + "autovacuum = off\n" . "shared_preload_libraries = 'injection_points'\n"); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE EXTENSION pageinspect; +CREATE EXTENSION injection_points; +CREATE FUNCTION release_one(p text) RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event = p) THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup(p); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + RETURN false; +END $$; +}); + +# Line pointers on the table that are neither normal nor unused: what a +# VACUUM left behind for the next one. +my $nonunused = sub { + my ($t) = @_; + return $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM generate_series(0, pg_relation_size('$t') / 8192 - 1) b, + heap_page_items(get_raw_page('$t', b::int)) WHERE lp_flags IN (2, 3)}); +}; + +foreach my $am ('heap', 'flux') +{ + my $t = "vr_$am"; + + # 60000 rows with two indexes, one row in two deleted: 30000 dead TIDs + # take several times the 64 kB minimum maintenance_work_mem. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, v int) USING $am WITH (autovacuum_enabled = off); +INSERT INTO $t SELECT g, g FROM generate_series(1, 60000) g; +CREATE INDEX ${t}_id ON $t (id); +CREATE INDEX ${t}_v ON $t (v); +DELETE FROM $t WHERE id % 2 = 0; +}); + + # VACUUM waits at each ambulkdelete: index_vacuum_count says which + # round it is in. In round 1 (the second), delete rows from the part + # of the table the first round already compacted and from the part not + # yet scanned. + $node->safe_psql('postgres', + "SELECT injection_points_attach('vacuum-bulkdel-one-index', 'wait')"); + my $vac = $node->background_psql('postgres'); + $vac->query_until(qr/start/, + "\\echo start\nSET maintenance_work_mem = 64;\nVACUUM (PARALLEL 0) $t;\n" + ); + + # Each stop is at a distinct (round, index) pair, which tells it from + # the stop just released while the wait event is still shown. + $node->wait_for_event('client backend', 'vacuum-bulkdel-one-index'); + my $stops = 0; + my $deleted_late = 0; + my $max_bytes = 0; + my $limit = 0; + while (1) + { + my $prog = $node->safe_psql( + 'postgres', qq{ +SELECT index_vacuum_count, indexes_processed, heap_blks_scanned, + heap_blks_total, dead_tuple_bytes, max_dead_tuple_bytes +FROM pg_stat_progress_vacuum WHERE relid = '$t'::regclass}); + last if $prog eq ''; + my ($ivc, $done, $scanned, $total, $bytes, $max) = split /\|/, $prog; + $max_bytes = $bytes if $bytes > $max_bytes; + $limit = $max; + + if ($ivc == 1 && !$deleted_late) + { + # Rows 1 and 59999: one in a block already vacuumed, one + # not yet scanned. Neither is recorded in this round. + $node->safe_psql('postgres', + "DELETE FROM $t WHERE id IN (1, 59999)"); + $deleted_late = 1; + cmp_ok($scanned, '<', $total, + "$am: round 1 starts before the scan reaches the end"); + } + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('vacuum-bulkdel-one-index')"); + die 'too many index vacuum stops' if ++$stops > 1000; + + # Next stop, or the end of the VACUUM. + $node->poll_query_until( + 'postgres', qq{ +SELECT NOT EXISTS (SELECT FROM pg_stat_progress_vacuum + WHERE relid = '$t'::regclass) + OR EXISTS (SELECT FROM pg_stat_progress_vacuum p + JOIN pg_stat_activity a USING (pid) + WHERE p.relid = '$t'::regclass + AND a.wait_event = 'vacuum-bulkdel-one-index' + AND (p.index_vacuum_count, p.indexes_processed) + <> ($ivc, $done))}) or die 'VACUUM neither stopped nor finished'; + } + note "$am: $stops index vacuum stops, dead items up to $max_bytes bytes"; + + # A round ends once dead_items exceeds the limit, so it can overshoot + # by one page's TIDs, but not grow with the number of dead rows. + cmp_ok($max_bytes, '<=', 2 * $limit, + "$am: dead_items stays within maintenance_work_mem"); + $vac->query_safe('SELECT 1'); + $vac->quit; + $node->safe_psql('postgres', + "SELECT injection_points_detach('vacuum-bulkdel-one-index')"); + + ok($deleted_late, + "$am: VACUUM did more than one round of index vacuuming"); + + # Every row deleted before VACUUM is gone from the table and from both + # indexes. The two late deletes may leave their TIDs dead (not + # unused) for the next VACUUM, but never free a TID an index names. + cmp_ok($nonunused->($t), '<=', 2, + "$am: VACUUM freed every TID deleted before it started"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT count(*), sum(id) FROM $t}), + '29998|899940000', + "$am: rows after VACUUM"); + foreach my $idx ("${t}_id", "${t}_v") + { + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('$idx', true)"), + '', + "$am: bt_index_check heapallindexed on $idx"); + } + + # Reusing the freed TIDs must not resurrect index entries. + $node->safe_psql('postgres', + "INSERT INTO $t SELECT g, -g FROM generate_series(100001, 130000) g"); + is( $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*), sum(id) FROM $t WHERE id <= 60000}), + '29998|899940000', + "$am: index scan after TID reuse sees only the live rows"); + is( $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM $t WHERE v < 0}), + '30000', + "$am: the new rows are reached through the second index"); + + # The next VACUUM frees the late deletes too. INDEX_CLEANUP ON keeps + # heap from bypassing index vacuuming for so few dead items. + $node->safe_psql('postgres', "VACUUM (INDEX_CLEANUP ON) $t"); + is($nonunused->($t), '0', "$am: a second VACUUM frees the rest"); + foreach my $idx ("${t}_id", "${t}_v") + { + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('$idx', true)"), + '', + "$am: bt_index_check heapallindexed on $idx after reuse"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/132_flux_rowlock_store.pl b/src/test/recovery/t/132_flux_rowlock_store.pl new file mode 100644 index 0000000000000..c848dd6a432d7 --- /dev/null +++ b/src/test/recovery/t/132_flux_rowlock_store.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX row locks: no fixed shared memory, no fixed capacity. +# +# FLUX keeps a row lock's owner and strength out of the tuple (the page has +# only an ownerless FLUX_TUPLE_LOCKED bit). They used to live in a shared +# flat hash sized at startup: 1.3 GB at max_connections = 100 whether or not +# FLUX was ever used, and still too small for one transaction to lock 200k +# rows ("could not store row lock in sLog"). The locks now live in a DSA +# created on first use. Heap is the control for every case. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_connections = 20 +)); +$node->start; + +# Nothing is allocated for FLUX row locks until a FLUX row is locked. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_shmem_allocations WHERE name ~* 'slog flat|tuple slog'" + ), + '0', + 'no fixed shared memory for FLUX row locks'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_dsm_registry_allocations WHERE name ~ '^slog_tuple_lock'" + ), + '0', + 'no FLUX row-lock DSA before any FLUX row lock'); + +my $n = 1000000; +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq( + CREATE TABLE par_$am (id int PRIMARY KEY) USING $am; + INSERT INTO par_$am SELECT generate_series(1, $n); + CREATE TABLE chi_$am (pid int REFERENCES par_$am) USING $am; + )); +} + +foreach my $am ('heap', 'flux') +{ + # One transaction locks a million rows and holds them. + my $locker = $node->background_psql('postgres', on_error_stop => 0); + $locker->query_safe('BEGIN'); + my $cnt = $locker->query( + "SELECT count(*) FROM (SELECT 1 FROM par_$am FOR KEY SHARE) s"); + is($cnt, $n, "$am: one transaction locks $n rows FOR KEY SHARE"); + + # A DELETE of a locked row waits for the locker. + my ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; DELETE FROM par_$am WHERE id = " + . ($n - 3)); + like( + $err, + qr/lock timeout/, + "$am: DELETE of a row locked FOR KEY SHARE waits"); + # A non-key UPDATE does not conflict with KEY SHARE. + ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; SELECT 1 FROM par_$am WHERE id = 7 FOR NO KEY UPDATE" + ); + is($ret, 0, + "$am: FOR NO KEY UPDATE of a KEY SHARE locked row does not wait"); + + if ($am eq 'flux') + { + is( $node->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_dsm_registry_allocations WHERE name ~ '^slog_tuple_lock'" + ), + 't', + 'flux: the row-lock DSA exists once a FLUX row is locked'); + } + + $locker->query('COMMIT'); + $locker->quit; + + # After COMMIT no lock remains. + ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; DELETE FROM par_$am WHERE id = " + . ($n - 3)); + is($ret, 0, "$am: after COMMIT the row can be deleted"); + + # A bulk FK insert locks every parent row; a concurrent parent DELETE + # must wait, and no orphan may result. + my $fk = $node->background_psql('postgres', on_error_stop => 0); + $fk->query_safe('BEGIN'); + $fk->query("INSERT INTO chi_$am SELECT generate_series(1, 300000)"); + ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; DELETE FROM par_$am WHERE id = 299997"); + like( + $err, + qr/lock timeout/, + "$am: parent DELETE waits for a 300k-row FK insert"); + $fk->query('COMMIT'); + $fk->quit; + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM chi_$am c WHERE NOT EXISTS (SELECT 1 FROM par_$am p WHERE p.id = c.pid)" + ), + '0', + "$am: no orphaned child rows"); +} + +done_testing(); diff --git a/src/test/recovery/t/133_flux_insert_markers.pl b/src/test/recovery/t/133_flux_insert_markers.pl new file mode 100644 index 0000000000000..a3f9951623215 --- /dev/null +++ b/src/test/recovery/t/133_flux_insert_markers.pl @@ -0,0 +1,115 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX inserts are judged by t_xmin, as heap's are; no per-tuple marker in +# shared memory is needed. Heap is the control for every case. +# +# Redo used to register every uncommitted inserted FLUX tuple in the shared +# tuple sLog, which a standby never needed: t_xmin, CLOG and the +# known-assigned xids decide visibility there. Once that fixed table filled, +# replay logged one "SLOG_LOST_OP" WARNING per tuple (half a million for two +# 400k-row inserts). +# +# A rolled-back subtransaction's inserts, and a speculative insert, were also +# given shared markers. t_xmin is the subtransaction's xid, which CLOG +# records as aborted, and a speculative insert keeps its token in t_ctid as +# heap's does; the cases below check unique and ON CONFLICT inserts that meet +# such rows. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', + "autovacuum = off\nmax_connections = 20"); +$primary->start; +# A slot keeps the WAL the standby still needs: the inserts below write a +# lot of it, more still under wal_consistency_checking. +$primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('standby')"); +$primary->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->append_conf('postgresql.conf', "primary_slot_name = 'standby'"); +$standby->start; + +my $n = 400000; +foreach my $am ('heap', 'flux') +{ + $primary->safe_psql('postgres', + "CREATE TABLE t_$am (id int) USING $am; INSERT INTO t_$am VALUES (0);" + ); + + # Two uncommitted inserts: one will commit, one will roll back. + my $a = $primary->background_psql('postgres'); + my $b = $primary->background_psql('postgres'); + $a->query_safe('BEGIN'); + $a->query_safe("INSERT INTO t_$am SELECT generate_series(1, $n)"); + $b->query_safe('BEGIN'); + $b->query_safe("INSERT INTO t_$am SELECT generate_series(1, $n)"); + $primary->wait_for_replay_catchup($standby); + is($standby->safe_psql('postgres', "SELECT count(*) FROM t_$am"), + '1', "$am: standby does not see uncommitted inserts"); + + $a->query_safe('COMMIT'); + $b->query_safe('ROLLBACK'); + $a->quit; + $b->quit; + $primary->wait_for_replay_catchup($standby); + is($standby->safe_psql('postgres', "SELECT count(*) FROM t_$am"), + $n + 1, "$am: standby sees the committed insert only"); +} + +# Unique checks against rows of an open transaction's rolled-back +# subtransaction, and against an in-progress speculative insert. +foreach my $am ('heap', 'flux') +{ + $primary->safe_psql('postgres', + "CREATE TABLE u_$am (k int PRIMARY KEY) USING $am"); + my $a = $primary->background_psql('postgres', on_error_stop => 0); + my $try = sub { + my ($sql) = @_; + my ($ret, $out, $err) = + $primary->psql('postgres', "SET lock_timeout = '1s'; $sql"); + return $err =~ /lock timeout/ ? 'waits' : $ret == 0 ? 'ok' : $err; + }; + + $a->query_safe('BEGIN'); + $a->query_safe('SAVEPOINT s; INSERT INTO u_' + . $am + . ' VALUES (1), (2); ROLLBACK TO s'); + is($a->query_safe("SELECT count(*) FROM u_$am"), + '0', "$am: rows of a rolled-back savepoint are gone"); + is($try->("INSERT INTO u_$am VALUES (1)"), + 'ok', "$am: unique insert does not wait for a rolled-back savepoint"); + $a->query_safe('SAVEPOINT s; INSERT INTO u_' + . $am + . ' VALUES (4) ON CONFLICT DO NOTHING; ROLLBACK TO s'); + is($try->("INSERT INTO u_$am VALUES (4) ON CONFLICT DO NOTHING"), 'ok', + "$am: ON CONFLICT does not wait for a rolled-back speculative insert" + ); + $a->query_safe("INSERT INTO u_$am VALUES (3) ON CONFLICT DO NOTHING"); + is($try->("INSERT INTO u_$am VALUES (3) ON CONFLICT DO NOTHING"), + 'waits', + "$am: ON CONFLICT waits for an in-progress speculative insert"); + $a->query_safe("INSERT INTO u_$am VALUES (2) ON CONFLICT DO NOTHING"); + $a->query_safe('ROLLBACK'); + is($try->("INSERT INTO u_$am VALUES (3) ON CONFLICT DO NOTHING"), + 'ok', + "$am: ON CONFLICT inserts after the speculative inserter aborts"); + is( $primary->safe_psql( + 'postgres', + "SELECT string_agg(k::text, ',' ORDER BY k) FROM u_$am"), + '1,3,4', + "$am: final contents"); + $a->quit; +} + +my $log = slurp_file($standby->logfile); +my $warnings = () = $log =~ /WARNING/g; +is($warnings, 0, 'standby logged no WARNING during replay'); + +done_testing(); diff --git a/src/test/recovery/t/134_undo_shmem_on_demand.pl b/src/test/recovery/t/134_undo_shmem_on_demand.pl new file mode 100644 index 0000000000000..76f20b19c3972 --- /dev/null +++ b/src/test/recovery/t/134_undo_shmem_on_demand.pl @@ -0,0 +1,97 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# UNDO, the sLog and FLUX allocate no large fixed shared memory. +# +# A cluster that never uses FLUX or UNDO should cost about what master does. +# The transaction sLog (the ATM) reserved a 512 kB DSA region at startup and +# FLUX an 8 MB dirty-page map. The ATM now lives in a DSA created when it is +# first written, and the dirty-page map is gone. The 1 MB bound on what is +# left is a coarse guard, not the goal: see the commit message. The ATM must still work +# from there: an instant abort, the revert worker, a checkpoint and a crash +# restart. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +log_min_messages = debug1 +undo_instant_abort_threshold = 0 +logical_revert_naptime = 100 +)); +$node->start; + +my $fixed = + q{SELECT coalesce(sum(allocated_size), 0) FROM pg_shmem_allocations + WHERE name ~* 'undo|flux|slog|secondary log|aborted|revert'}; +my $created = qr/created the aborted transaction map/; + +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_shmem_allocations WHERE name = 'FLUX DirtyMap'} + ), + '0', + 'no fixed FLUX dirty map'); +cmp_ok( + $node->safe_psql( + 'postgres', + q{SELECT coalesce(sum(allocated_size), 0) FROM pg_shmem_allocations + WHERE name = 'Secondary Log State'}), + '<', + 1024, + 'transaction sLog reserves only its lock and handles'); +cmp_ok( + $node->safe_psql('postgres', $fixed), + '<', + 1024 * 1024, + 'UNDO, sLog and FLUX fixed shared memory below 1 MB'); + +# A checkpoint reads the ATM; that must not create it. Its DSA area is +# created by the first transaction that writes UNDO. +$node->safe_psql('postgres', 'CHECKPOINT'); +ok(!$node->log_contains($created), 'no ATM before use'); + +# An instant abort records the transaction in the ATM, creating its DSA, and +# the revert worker undoes it. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE t (id int PRIMARY KEY, v int) USING heap + WITH (index_undo = on, autovacuum_enabled = off); +INSERT INTO t VALUES (0, 0); +}); +$node->safe_psql('postgres', + 'BEGIN; INSERT INTO t SELECT g, g FROM generate_series(1, 100) g; ROLLBACK;' +); +ok($node->log_contains($created), 'ATM created on use'); +ok( $node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_get_undo_retention() + WHERE holder = 'aborted'}), + 'revert worker drained the ATM'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '1', 'aborted rows invisible'); + +# A deferred abort outlives a checkpoint and a crash. +$node->safe_psql('postgres', + 'BEGIN; INSERT INTO t SELECT g, g FROM generate_series(101, 200) g; ROLLBACK;' +); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$node->start; +is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '1', 'aborted rows stay invisible after a crash'); +$node->safe_psql('postgres', 'VACUUM t'); +is( $node->safe_psql( + 'postgres', + 'SET enable_seqscan = off; SELECT count(*) FROM t WHERE id >= 0'), + '1', + 'index agrees after a crash'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/135_flux_rowlock_filter.pl b/src/test/recovery/t/135_flux_rowlock_filter.pl new file mode 100644 index 0000000000000..7b598e98f7dcf --- /dev/null +++ b/src/test/recovery/t/135_flux_rowlock_filter.pl @@ -0,0 +1,528 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Row-lock conflict probes must never miss a lock, whatever lets them skip +# holders. +# +# A conflict probe skips the holders whose filters say they hold no lock in +# the probed tuple's block. The filters are hashed and saturate, so they +# may say "maybe" wrongly but must never say "no" for a lock that exists. +# One session locks a row on each of many more blocks than the filter has +# bits, in two relations, so that the probes below hit every collision; a +# second session must then find each locked row locked and each other row +# free. Then the same through a rolled-back savepoint, a prepared +# transaction, and a restart with the prepared transaction still there. +# +# A holder keeps its first few locked rows in records of its own slot, read +# without any lock, and the rest in trees; a row's locks are in exactly one +# of the two places. So the probes also run on transactions that lock +# 1, 8 and 9 rows (the records hold 8), with a savepoint level stacked on +# one of them, prepared and restored, after the holder's earlier +# transactions (whose emptied tables it may keep), and under concurrent FK +# inserts and cascading parent deletes, which must leave no orphan. +# +# Past the records a slot keeps a relation's locks in a hash table of its +# own that grows: so also many holders of the same rows and of rows on +# different blocks, released one at a time; a transaction that locks 1600 +# rows, with a savepoint that upgrades 500 of them and rolls back, prepared +# and restored; a savepoint across the boundary between records and table; +# a relation truncated while the transaction holds locks in it and in +# another; and FK inserts of 25 rows per transaction. +# +# Heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 2 +)); +$node->start; + +# 600 blocks per relation at 20 rows per block (fillfactor 10). +my $nblocks = 600; + +# Rows locked: offset 1 of every even block. Not locked: offset 1 of every +# odd block, and offset 2 of every even block (same block as a lock). +sub probe_counts +{ + my ($am) = @_; + my %got; + foreach my $rel ("a_$am", "b_$am") + { + my ($stdout, $stderr); + $node->psql( + 'postgres', qq{ +SET client_min_messages = notice; +DO \$\$ +DECLARE + r record; locked int := 0; free_odd int := 0; free_even int := 0; +BEGIN + FOR r IN SELECT ctid, (ctid::text::point)[0]::int AS blk, + (ctid::text::point)[1]::int AS off + FROM $rel WHERE (ctid::text::point)[1] IN (1, 2) LOOP + BEGIN + PERFORM 1 FROM $rel WHERE ctid = r.ctid FOR UPDATE NOWAIT; + IF r.blk % 2 = 1 THEN free_odd := free_odd + 1; + ELSIF r.off = 2 THEN free_even := free_even + 1; + END IF; + EXCEPTION WHEN lock_not_available THEN + IF r.blk % 2 = 0 AND r.off = 1 THEN locked := locked + 1; END IF; + END; + END LOOP; + RAISE NOTICE 'counts % % %', locked, free_odd, free_even; +END \$\$; +}, + stdout => \$stdout, + stderr => \$stderr); + $stderr =~ /counts (\d+) (\d+) (\d+)/ + or die "no counts for $rel: $stderr"; + $got{$rel} = "$1 $2 $3"; + } + return \%got; +} + +my $half = $nblocks / 2; +foreach my $am ('heap', 'flux') +{ + foreach my $rel ("a_$am", "b_$am") + { + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $rel (id int PRIMARY KEY, pad text) USING $am WITH (fillfactor = 10); +INSERT INTO $rel SELECT g, repeat('x', 300) FROM generate_series(1, $nblocks * 20) g; +}); + } + my $nblk = $node->safe_psql('postgres', + "SELECT count(DISTINCT (ctid::text::point)[0]) FROM a_$am"); + cmp_ok($nblk, '>=', 300, + "$am: relation spans many more blocks than the filter has bits"); + my $even = $node->safe_psql('postgres', + "SELECT count(DISTINCT (ctid::text::point)[0]) FROM a_$am WHERE (ctid::text::point)[0]::int % 2 = 0" + ); + # offsets 1 and 2 of each odd block are free + my $odd = 2 * ($nblk - $even); + my $lock_sql = join( + '', + map { + "SELECT count(*) FROM (SELECT 1 FROM $_ WHERE (ctid::text::point)[1] = 1 " + . "AND (ctid::text::point)[0]::int % 2 = 0 FOR KEY SHARE) s;" + } ("a_$am", "b_$am")); + + # Plain transaction. + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe($lock_sql); + my $c = probe_counts($am, 'plain'); + is( $c->{"a_$am"}, + "$even $odd $even", + "$am a: every lock found, no false lock (plain)"); + is( $c->{"b_$am"}, + "$even $odd $even", + "$am b: every lock found, no false lock (plain)"); + $h->query_safe('COMMIT'); + $c = probe_counts($am, 'after commit'); + is($c->{"a_$am"}, "0 $odd $even", "$am a: locks gone after commit"); + + # Locks taken in a rolled-back savepoint are dead; outer ones stay. + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM a_$am WHERE (ctid::text::point)[1] = 1 AND (ctid::text::point)[0]::int % 2 = 0 FOR KEY SHARE) s" + ); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM b_$am WHERE (ctid::text::point)[1] = 1 AND (ctid::text::point)[0]::int % 2 = 0 FOR UPDATE) s" + ); + $h->query_safe('ROLLBACK TO s'); + $c = probe_counts($am, 'savepoint'); + is( $c->{"a_$am"}, + "$even $odd $even", + "$am a: outer locks kept after ROLLBACK TO"); + is($c->{"b_$am"}, "0 $odd $even", + "$am b: savepoint locks dead after ROLLBACK TO"); + $h->query_safe('ROLLBACK'); + + # Prepared, and across a restart. + $h->query_safe('BEGIN'); + $h->query_safe($lock_sql); + $h->query_safe("PREPARE TRANSACTION 'p_$am'"); + $h->quit; + $c = probe_counts($am, 'prepared'); + is($c->{"a_$am"}, "$even $odd $even", "$am a: prepared locks found"); + is($c->{"b_$am"}, "$even $odd $even", "$am b: prepared locks found"); + $node->restart; + $c = probe_counts($am, 'prepared after restart'); + is( $c->{"a_$am"}, + "$even $odd $even", + "$am a: prepared locks found after restart"); + is( $c->{"b_$am"}, + "$even $odd $even", + "$am b: prepared locks found after restart"); + $node->safe_psql('postgres', "COMMIT PREPARED 'p_$am'"); + $c = probe_counts($am, 'after commit prepared'); + is($c->{"b_$am"}, "0 $odd $even", + "$am b: locks gone after COMMIT PREPARED"); +} + +# Few rows: the first SLOG_TL_NINLINE (8) TIDs of a transaction are kept +# inline, the rest in trees. Lock k rows of c_$am, then probe all of them. +sub probe_rows +{ + my ($am, $lo, $hi, $mode, $rel) = @_; + my ($stdout, $stderr); + $rel //= "c_$am"; + $node->psql( + 'postgres', qq{ +SET client_min_messages = notice; +DO \$\$ +DECLARE i int; blocked int := 0; free int := 0; +BEGIN + FOR i IN $lo..$hi LOOP + BEGIN + PERFORM 1 FROM $rel WHERE id = i FOR $mode NOWAIT; + free := free + 1; + EXCEPTION WHEN lock_not_available THEN blocked := blocked + 1; + END; + END LOOP; + RAISE NOTICE 'rows % %', blocked, free; +END \$\$; +}, + stdout => \$stdout, + stderr => \$stderr); + $stderr =~ /rows (\d+) (\d+)/ or die "no rows for $rel: $stderr"; + return "$1 $2"; +} + +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE c_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO c_$am SELECT g, g FROM generate_series(1, 40) g; +}); + my $h = $node->background_psql('postgres'); + foreach my $k (1, 8, 9, 12) + { + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= $k ORDER BY id FOR KEY SHARE) s" + ); + is( probe_rows($am, 1, 20, 'UPDATE'), + "$k " . (20 - $k), + "$am: $k rows locked, all found, no false lock"); + is(probe_rows($am, 1, 20, 'NO KEY UPDATE'), + "0 20", "$am: $k KEY SHARE rows do not block NO KEY UPDATE"); + $h->query_safe('COMMIT'); + is(probe_rows($am, 1, 20, 'UPDATE'), + "0 20", "$am: $k rows free after commit"); + } + + # A savepoint level on an inline row and on a row in a tree: after + # ROLLBACK TO only the outer KEY SHARE is left. + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 10 ORDER BY id FOR KEY SHARE) s" + ); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id IN (2, 10) FOR UPDATE) s" + ); + is(probe_rows($am, 1, 10, 'NO KEY UPDATE'), + "2 8", + "$am: savepoint FOR UPDATE found on an inline row and a tree row"); + $h->query_safe('ROLLBACK TO s'); + is(probe_rows($am, 1, 10, 'NO KEY UPDATE'), + "0 10", "$am: savepoint levels dead after ROLLBACK TO"); + is(probe_rows($am, 1, 10, 'UPDATE'), + "10 0", "$am: outer KEY SHARE kept after ROLLBACK TO"); + $h->query_safe('ROLLBACK'); + + # Inline-only and mixed prepared transactions, across a restart. + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 3 FOR UPDATE) s" + ); + $h->query_safe("PREPARE TRANSACTION 'q1_$am'"); + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 11 AND 30 FOR KEY SHARE) s" + ); + $h->query_safe("PREPARE TRANSACTION 'q2_$am'"); + $h->quit; + is(probe_rows($am, 1, 40, 'UPDATE'), + "23 17", "$am: prepared locks found (inline only, and mixed)"); + $node->restart; + is(probe_rows($am, 1, 40, 'UPDATE'), + "23 17", "$am: prepared locks found after restart"); + is(probe_rows($am, 11, 30, 'NO KEY UPDATE'), + "0 20", "$am: restored KEY SHARE keeps its strength"); + $node->safe_psql('postgres', "COMMIT PREPARED 'q1_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), + "20 20", "$am: one prepared transaction's locks gone"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'q2_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), + "0 40", "$am: all prepared locks gone"); +} + +# A backend may keep its emptied trees for its next transactions; a tree +# that went to a prepared transaction must not be reused. One session locks +# 10 rows (two of them in a tree) and commits, so that it may keep that tree; +# its next transaction locks 10 rows, the last two in a tree again, and +# prepares; its third locks 10 other rows and commits. The prepared rows +# must stay locked throughout and until ROLLBACK PREPARED. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + my $lock = sub { + my ($lo, $hi) = @_; + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN $lo AND $hi ORDER BY id FOR UPDATE) s" + ); + }; + $lock->(1, 10); + $h->query_safe('COMMIT'); + $lock->(11, 20); + $h->query_safe("PREPARE TRANSACTION 'r_$am'"); + is(probe_rows($am, 11, 20, 'UPDATE'), + "10 0", "$am: prepared rows locked"); + $lock->(21, 30); + is(probe_rows($am, 11, 30, 'UPDATE'), + "20 0", "$am: prepared rows and the next transaction's rows locked"); + $h->query_safe('COMMIT'); + is(probe_rows($am, 11, 30, 'UPDATE'), + "10 10", "$am: prepared rows still locked after the next commit"); + $lock->(31, 40); + $h->query_safe('ROLLBACK'); + is(probe_rows($am, 11, 40, 'UPDATE'), + "10 20", "$am: prepared rows still locked after a rollback"); + $h->quit; + $node->safe_psql('postgres', "ROLLBACK PREPARED 'r_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), "0 40", "$am: all free at the end"); +} + +# Many holders. Five sessions lock the same 20 rows (8 in records, 12 in a +# table each); they stay locked until the last one commits. Then five +# sessions lock 12 rows each on blocks of their own; committing one frees +# exactly its rows. +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE d_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO d_$am SELECT g, g FROM generate_series(1, 3000) g; +}); + my $nblk = $node->safe_psql('postgres', + "SELECT count(DISTINCT (ctid::text::point)[0]) FROM d_$am WHERE id IN (1, 501, 1001, 1501, 2001)" + ); + is($nblk, '5', "$am: the five ranges are on five blocks"); + + my @h = map { $node->background_psql('postgres') } 1 .. 5; + foreach my $s (@h) + { + $s->query_safe('BEGIN'); + $s->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id <= 20 ORDER BY id FOR KEY SHARE) s" + ); + } + is(probe_rows($am, 1, 20, 'UPDATE', "d_$am"), + "20 0", "$am: five holders of the same rows: all found"); + is(probe_rows($am, 1, 20, 'NO KEY UPDATE', "d_$am"), + "0 20", "$am: five KEY SHARE holders do not block NO KEY UPDATE"); + foreach my $k (0 .. 3) + { + $h[$k]->query_safe('COMMIT'); + is( probe_rows($am, 1, 20, 'UPDATE', "d_$am"), + "20 0", + "$am: rows still locked after " + . ($k + 1) + . " of 5 holders commit"); + } + $h[4]->query_safe('COMMIT'); + is(probe_rows($am, 1, 20, 'UPDATE', "d_$am"), + "0 20", "$am: rows free after the last holder commits"); + + foreach my $k (0 .. 4) + { + my $lo = $k * 500 + 1; + my $hi = $lo + 11; + $h[$k]->query_safe('BEGIN'); + $h[$k]->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id BETWEEN $lo AND $hi ORDER BY id FOR UPDATE) s" + ); + } + $h[2]->query_safe('COMMIT'); + my @got = + map { probe_rows($am, $_ * 500 + 1, $_ * 500 + 12, 'UPDATE', "d_$am") } + 0 .. 4; + is( "@got", + "12 0 12 0 0 12 12 0 12 0", + "$am: holders on different blocks: one commit frees exactly its rows" + ); + $h[$_]->query_safe('ROLLBACK') foreach (0, 1, 3, 4); + $_->quit foreach @h; + is(probe_rows($am, 1, 2012, 'UPDATE', "d_$am"), + "0 2012", "$am: all free after the holders end"); +} + +# More than a thousand rows, a savepoint that upgrades some and rolls back, +# then more rows, prepared and restored across a restart. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id <= 1500 FOR KEY SHARE) s" + ); + is(probe_rows($am, 1, 1500, 'UPDATE', "d_$am"), + "1500 0", "$am: 1500 rows locked, all found"); + is(probe_rows($am, 1, 1500, 'NO KEY UPDATE', "d_$am"), + "0 1500", "$am: 1500 KEY SHARE rows do not block NO KEY UPDATE"); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id > 1000 AND id <= 1500 FOR UPDATE) s" + ); + is(probe_rows($am, 1, 1500, 'NO KEY UPDATE', "d_$am"), + "500 1000", "$am: 500 rows upgraded in a savepoint"); + $h->query_safe('ROLLBACK TO s'); + is(probe_rows($am, 1, 1500, 'NO KEY UPDATE', "d_$am"), + "0 1500", "$am: upgrades dead after ROLLBACK TO"); + is(probe_rows($am, 1, 1500, 'UPDATE', "d_$am"), + "1500 0", "$am: 1500 outer locks kept after ROLLBACK TO"); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id BETWEEN 1501 AND 1600 FOR KEY SHARE) s" + ); + $h->query_safe("PREPARE TRANSACTION 'big_$am'"); + $h->quit; + is(probe_rows($am, 1, 1700, 'UPDATE', "d_$am"), + "1600 100", "$am: 1600 prepared locks found"); + $node->restart; + is(probe_rows($am, 1, 1700, 'UPDATE', "d_$am"), + "1600 100", "$am: 1600 prepared locks found after restart"); + is(probe_rows($am, 1, 1700, 'NO KEY UPDATE', "d_$am"), + "0 1700", "$am: 1600 restored locks keep their strength"); + $node->safe_psql('postgres', "COMMIT PREPARED 'big_$am'"); + is(probe_rows($am, 1, 1700, 'UPDATE', "d_$am"), + "0 1700", "$am: 1600 locks gone after COMMIT PREPARED"); +} + +# A savepoint across the boundary between records and table: 6 rows, then +# in a savepoint 6 more (2 in records, 4 in the table) and an upgrade of an +# outer one; after ROLLBACK TO, the same rows locked again. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 6 ORDER BY id FOR KEY SHARE) s" + ); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 7 AND 12 OR id = 3 ORDER BY id FOR UPDATE) s" + ); + is(probe_rows($am, 1, 12, 'UPDATE'), + "12 0", "$am: savepoint across the boundary: all 12 locked"); + is(probe_rows($am, 1, 12, 'NO KEY UPDATE'), + "7 5", "$am: savepoint across the boundary: its 7 FOR UPDATE found"); + $h->query_safe('ROLLBACK TO s'); + is(probe_rows($am, 1, 12, 'NO KEY UPDATE'), + "0 12", "$am: savepoint across the boundary: dead after ROLLBACK TO"); + is(probe_rows($am, 1, 12, 'UPDATE'), + "6 6", "$am: savepoint across the boundary: outer 6 kept"); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 7 AND 12 ORDER BY id FOR KEY SHARE) s" + ); + is(probe_rows($am, 1, 12, 'UPDATE'), + "12 0", "$am: rows locked again after ROLLBACK TO"); + $h->query_safe('COMMIT'); + is(probe_rows($am, 1, 12, 'UPDATE'), "0 12", + "$am: all free after commit"); + $h->quit; +} + +# A relation truncated while the transaction holds locks in it (records and +# table) and in another (records and table): the other's locks stay, before +# and after PREPARE and a restart. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe( + "CREATE TABLE f_$am (id int PRIMARY KEY) USING $am; INSERT INTO f_$am SELECT generate_series(1, 40)" + ); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 4 ORDER BY id FOR UPDATE) s" + ); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM f_$am WHERE id <= 20 ORDER BY id FOR UPDATE) s" + ); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 5 AND 20 ORDER BY id FOR UPDATE) s" + ); + $h->query_safe("TRUNCATE f_$am"); + $h->query_safe("INSERT INTO f_$am SELECT generate_series(1, 40)"); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM f_$am WHERE id <= 10 ORDER BY id FOR KEY SHARE) s" + ); + is(probe_rows($am, 1, 40, 'UPDATE'), + "20 20", "$am: locks in another relation kept across TRUNCATE"); + $h->query_safe("PREPARE TRANSACTION 'tr_$am'"); + $h->quit; + is(probe_rows($am, 1, 40, 'UPDATE'), "20 20", "$am: they are prepared"); + $node->restart; + is(probe_rows($am, 1, 40, 'UPDATE'), "20 20", "$am: they are restored"); + $node->safe_psql('postgres', "COMMIT PREPARED 'tr_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), "0 40", "$am: all free at the end"); + is(probe_rows($am, 1, 40, 'UPDATE', "f_$am"), + "0 40", "$am: truncated relation's rows free at the end"); +} + + +# Concurrency: FK inserts take KEY SHARE on the parent while other sessions +# delete parents, cascading. A parent DELETE that missed a KEY SHARE lock +# would let the child commit without its parent. +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE p_$am (id int PRIMARY KEY) USING $am; +INSERT INTO p_$am SELECT generate_series(1, 3000); +CREATE TABLE k_$am (pid int REFERENCES p_$am ON DELETE CASCADE) USING $am; +CREATE FUNCTION ins_$am(int) RETURNS void LANGUAGE plpgsql AS \$\$ +BEGIN INSERT INTO k_$am VALUES (\$1); +EXCEPTION WHEN foreign_key_violation THEN NULL; END \$\$; +}); + $node->pgbench( + '--no-vacuum --client=8 --jobs=8 --transactions=1500', + 0, + [qr{processed: 12000/12000}], + [qr{^$}], + "$am: concurrent FK inserts and cascading parent deletes", + { + "135_ins_$am" => + "\\set id random(1, 3000)\nSELECT ins_$am(:id);\n", + "135_del_$am\@1" => + "\\set id random(1, 3000)\nDELETE FROM p_$am WHERE id = :id;\n", + "135_ins2_$am\@3" => + "\\set id random(1, 3000)\nBEGIN;\nSELECT ins_$am(:id);\nSELECT ins_$am(:id + 1);\nSELECT ins_$am(:id + 2);\nEND;\n", + "135_insm_$am\@2" => + "\\set id random(1, 2976)\nSELECT count(ins_$am(g)) FROM generate_series(:id, :id + 24) g;\n" + }); + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM k_$am k WHERE NOT EXISTS (SELECT 1 FROM p_$am p WHERE p.id = k.pid)" + ), + '0', + "$am: no orphaned child row"); + cmp_ok($node->safe_psql('postgres', "SELECT count(*) FROM p_$am"), + '<', 3000, "$am: some parents were deleted"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/136_flux_rowlock_oom.pl b/src/test/recovery/t/136_flux_rowlock_oom.pl new file mode 100644 index 0000000000000..f5df19b4cad39 --- /dev/null +++ b/src/test/recovery/t/136_flux_rowlock_oom.pl @@ -0,0 +1,318 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Running the row-lock store out of shared memory fails the statement before +# anything changes, and the cluster carries on. +# +# A FLUX row lock records its holder in the tuple sLog under the tuple's +# buffer lock before it marks the page. An injection point fails the +# allocation of the shared tree that a transaction's further locks go to: +# the failing lock must leave no FLUX_TUPLE_LOCKED bit and no WAL behind, the +# locks the transaction already held must be released by the abort, and a +# later transaction must lock the same rows normally. The first few locks of +# a transaction need no allocation, so they succeed with the injection point +# attached; how many is not part of the contract, so the test finds the first +# failing row rather than assuming it. +# +# Then the store's allocations themselves fail, part way through a lock that +# needs two (a bigger relation array, then a table), and part way through a +# transaction whose table must grow, at a new key and at a stronger lock on +# a key it holds. A savepoint catches each failure; the transaction's +# earlier locks must all still be found, the failed ones must not, and the +# transaction must go on locking, then end with nothing left. Repeated +# failures must not use up shared memory. (Heap has no such store: these +# parts have no heap control.) +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf('postgresql.conf', "autovacuum = off\n"); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect') + || !$node->check_extension('pg_walinspect')) +{ + plan skip_all => + 'injection_points, pageinspect or pg_walinspect not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION pageinspect; CREATE EXTENSION pg_walinspect;' +); +$node->safe_psql( + 'postgres', q{ +CREATE TABLE t (id int PRIMARY KEY, v int) USING flux; +INSERT INTO t SELECT g, g FROM generate_series(1, 40) g; +CHECKPOINT; +}); + +# FLUX_TUPLE_LOCKED is 0x0010 of t_flags, whose low byte is at byte 32 of +# the tuple. +my $locked_expr = "get_byte(get_raw_page('t', 0), lp_off + 32) & 16 <> 0"; + +# Number of tuples on block 0 marked locked +sub locked_bits +{ + return $node->safe_psql('postgres', + "SELECT count(*) FROM heap_page_items(get_raw_page('t', 0)) WHERE lp_flags = 1 AND $locked_expr" + ); +} +my $bits_before = locked_bits(); + +# (a record written now is the start of the range read below) +$node->safe_psql('postgres', 'SELECT pg_switch_wal(); CHECKPOINT;'); +my $lsn_before = + $node->safe_psql('postgres', "SELECT pg_current_wal_insert_lsn()"); + +# Lock rows one by one in one transaction until a lock fails. +my $h = $node->background_psql('postgres', on_error_stop => 0); +$h->query_safe( + "SELECT injection_points_attach('slog-tuple-lock-oom', 'notice')"); +$h->query_safe('BEGIN'); +my $ok = 0; +my $err = ''; +foreach my $i (1 .. 40) +{ + my ($out, $failed) = + $h->query("SELECT id FROM t WHERE id = $i FOR UPDATE"); + if ($failed && $h->{stderr} =~ /out of memory/) + { + $err = $h->{stderr}; + last; + } + $h->{stderr} = ''; + $ok++; +} +like( + $err, + qr/out of memory/, + 'a row lock fails once the store is out of memory'); +note "$ok row locks succeeded before the failure"; +$h->{stderr} = ''; +$h->query_safe('ROLLBACK'); +$h->query_safe("SELECT injection_points_detach('slog-tuple-lock-oom')"); + +# The failed lock changed nothing on the page: the only LOCKED bits are those +# of the locks that succeeded (they stay until the page is next written). +my $failed_id = $ok + 1; +is( $node->safe_psql( + 'postgres', + "SELECT $locked_expr FROM heap_page_items(get_raw_page('t', 0)) WHERE lp = (SELECT (ctid::text::point)[1]::int FROM t WHERE id = $failed_id)" + ), + 'f', + 'the row whose lock failed was not marked locked'); +cmp_ok( + locked_bits(), '<=', + $bits_before + $ok, + 'no row beyond those locked was marked'); + +# No lock record was logged for the failed row. (The transaction rolled +# back, and an abort does not flush WAL: flush it before reading it.) +$node->safe_psql('postgres', 'CHECKPOINT'); +my $nlock = $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM pg_get_wal_records_info('$lsn_before', pg_current_wal_flush_lsn()) +WHERE resource_manager = 'FLUX' AND record_type = 'LOCK'}); +is($nlock, $ok, + 'one lock record per successful lock, none for the failed one'); + +# The aborted transaction holds nothing; a new one locks every row. +my ($ret, $out, $stderr) = $node->psql('postgres', + "SET lock_timeout = '1s'; BEGIN; SELECT count(*) FROM (SELECT 1 FROM t FOR UPDATE) s; COMMIT;" +); +is($ret, 0, 'after the failure every row can be locked again'); +like($out, qr/^40$/m, 'all 40 rows locked by a later transaction'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '40', 'table intact'); + +$h->quit; + +# Count rows lo..hi of rel that another session cannot lock in mode. +sub blocked +{ + my ($rel, $lo, $hi, $mode) = @_; + my ($stdout, $stderr); + $node->psql( + 'postgres', qq{ +SET client_min_messages = notice; +DO \$\$ +DECLARE i int; n int := 0; +BEGIN + FOR i IN $lo..$hi LOOP + BEGIN + PERFORM 1 FROM $rel WHERE id = i FOR $mode NOWAIT; + EXCEPTION WHEN lock_not_available THEN n := n + 1; + END; + END LOOP; + RAISE NOTICE 'blocked %', n; +END \$\$; +}, + stdout => \$stdout, + stderr => \$stderr); + $stderr =~ /blocked (\d+)/ or die "no count for $rel: $stderr"; + return $1; +} + +sub dsa_size +{ + return $node->safe_psql('postgres', + "SELECT coalesce(sum(size), 0) FROM pg_dsm_registry_allocations WHERE name = 'slog_tuple_lock_tables'" + ); +} + +$node->safe_psql( + 'postgres', q{ +CREATE TABLE u (id int PRIMARY KEY, v int) USING flux; +INSERT INTO u SELECT g, g FROM generate_series(1, 300) g; +CREATE TABLE w1 (id int PRIMARY KEY) USING flux; +CREATE TABLE w2 (id int PRIMARY KEY) USING flux; +CREATE TABLE w3 (id int PRIMARY KEY) USING flux; +CREATE TABLE w4 (id int PRIMARY KEY) USING flux; +CREATE TABLE w5 (id int PRIMARY KEY) USING flux; +INSERT INTO w1 SELECT generate_series(1, 10); +INSERT INTO w2 SELECT generate_series(1, 10); +INSERT INTO w3 SELECT generate_series(1, 10); +INSERT INTO w4 SELECT generate_series(1, 10); +INSERT INTO w5 SELECT generate_series(1, 10); +}); + +# Run sql in session h with the injection point attached; return its error. +sub fails +{ + my ($h, $point, $sql) = @_; + $h->query_safe("SELECT injection_points_attach('$point', 'notice')"); + $h->query_safe('SAVEPOINT f'); + $h->{stderr} = ''; + $h->query($sql); + my $err = $h->{stderr}; + $h->{stderr} = ''; + $h->query_safe('ROLLBACK TO f'); + $h->query_safe("SELECT injection_points_detach('$point')"); + return $err; +} + +# A table must grow: 8 rows in records and 192 in the table, which is then +# a 256-entry table 3/4 full (a slot may keep an emptied table of up to 256 +# entries from an earlier transaction, so fewer rows might need no growth). +# Growing fails at a new row and at an upgrade. +foreach my $end ('ROLLBACK', 'COMMIT') +{ + my $g = $node->background_psql('postgres', on_error_stop => 0); + $g->query_safe('BEGIN'); + $g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id <= 200 ORDER BY id FOR KEY SHARE) s" + ); + like( + fails( + $g, 'slog-tuple-lock-table-oom', + 'SELECT 1 FROM u WHERE id = 201 FOR UPDATE'), + qr/out of memory/, + "$end: growing the table for a new row fails"); + like( + fails( + $g, 'slog-tuple-lock-table-oom', + 'SELECT 1 FROM u WHERE id = 150 FOR UPDATE'), + qr/out of memory/, + "$end: growing the table for an upgrade fails"); + is(blocked('u', 1, 300, 'UPDATE'), + 200, "$end: after the failures the 200 earlier locks are all found"); + is(blocked('u', 1, 300, 'NO KEY UPDATE'), + 0, "$end: and the failed FOR UPDATE locks are not"); + $g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id > 200 ORDER BY id FOR UPDATE) s" + ); + $g->query_safe('SELECT 1 FROM u WHERE id = 150 FOR UPDATE'); + is(blocked('u', 1, 300, 'UPDATE'), + 300, "$end: the transaction goes on locking after the failures"); + is(blocked('u', 1, 300, 'NO KEY UPDATE'), + 101, "$end: its FOR UPDATE locks are found"); + $g->query_safe($end); + is(blocked('u', 1, 300, 'UPDATE'), 0, "$end: nothing left after $end"); + + # the next transaction of this backend reuses the emptied table + $g->query_safe('BEGIN'); + $g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id <= 30 ORDER BY id FOR KEY SHARE) s" + ); + is(blocked('u', 1, 300, 'UPDATE'), + 30, "$end: the next transaction's locks are found"); + is(blocked('u', 1, 300, 'NO KEY UPDATE'), + 0, "$end: and only those, at their strength"); + $g->query_safe('COMMIT'); + is(blocked('u', 1, 300, 'UPDATE'), 0, "$end: and released"); + $g->quit; +} + +# A lock that needs a new relation array and a table, whose table fails, and +# a lock whose relation array fails. Five relations need a second array +# (the first has four entries). +my $g = $node->background_psql('postgres', on_error_stop => 0); +$g->query_safe('BEGIN'); +$g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id <= 8 ORDER BY id FOR UPDATE) s" +); +$g->query_safe('SELECT 1 FROM u WHERE id = 9 FOR UPDATE'); +$g->query_safe("SELECT 1 FROM $_ WHERE id = 1 FOR UPDATE") + foreach ('w1', 'w2', 'w3'); +like( + fails( + $g, 'slog-tuple-lock-oom', + 'SELECT 1 FROM w4 WHERE id = 1 FOR UPDATE'), + qr/out of memory/, + 'a lock whose relation array cannot grow fails'); +like( + fails( + $g, 'slog-tuple-lock-table-oom', + 'SELECT 1 FROM w4 WHERE id = 1 FOR UPDATE'), + qr/out of memory/, + 'a lock whose new table cannot be allocated fails'); +is(blocked('w4', 1, 10, 'UPDATE'), 0, 'the failed lock is not found'); +is( blocked('u', 1, 60, 'UPDATE') . ' ' + . join(' ', map { blocked($_, 1, 10, 'UPDATE') } ('w1', 'w2', 'w3')), + '9 1 1 1', + 'the earlier locks in five relations are all found'); + +# The same failure again and again: shared memory must not grow. +my $before = dsa_size(); +cmp_ok($before, '>', 0, 'the lock tables exist'); +$g->query_safe( + "SELECT injection_points_attach('slog-tuple-lock-table-oom', 'notice')"); +$g->query_safe( + q{ +DO $$ +DECLARE n int := 0; +BEGIN + FOR i IN 1..10000 LOOP + BEGIN + PERFORM 1 FROM w4 WHERE id = 1 FOR UPDATE; + EXCEPTION WHEN out_of_memory THEN n := n + 1; + END; + END LOOP; + IF n <> 10000 THEN RAISE 'only % failures', n; END IF; +END $$}); +$g->query_safe("SELECT injection_points_detach('slog-tuple-lock-table-oom')"); +is(dsa_size(), $before, '10000 failed allocations use no shared memory'); +$g->query_safe("SELECT 1 FROM $_ WHERE id = 1 FOR UPDATE") + foreach ('w4', 'w5'); +is( join(' ', + map { blocked($_, 1, 10, 'UPDATE') } ('w1', 'w2', 'w3', 'w4', 'w5')), + '1 1 1 1 1', + 'then the transaction locks rows of five relations past its records'); +$g->query_safe('ROLLBACK'); +is( blocked('u', 1, 60, 'UPDATE') . ' ' + . join(' ', + map { blocked($_, 1, 10, 'UPDATE') } ('w1', 'w2', 'w3', 'w4', 'w5')), + '0 0 0 0 0 0', + 'and nothing is left after its rollback'); +$g->quit; + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/137_flux_vacuum_parallel.pl b/src/test/recovery/t/137_flux_vacuum_parallel.pl new file mode 100644 index 0000000000000..d0aeb94c2717e --- /dev/null +++ b/src/test/recovery/t/137_flux_vacuum_parallel.pl @@ -0,0 +1,275 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX VACUUM vacuums its indexes with parallel workers, as heap's does. +# Parallel and serial VACUUM of the same contents must leave the same +# result: rows, index contents (amcheck heapallindexed, index-only counts +# per index), pg_stat and pg_class counters. The index work includes what +# only FLUX has: delete-marked entries of in-place key UPDATEs, removed in a +# worker through table_index_tombstone_removable, and rows of an aborted +# key UPDATE whose revert is still pending. Also: several rounds under a +# small maintenance_work_mem, a crash while the workers run, and a standby. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if (($ENV{enable_injection_points} // '') ne 'yes') +{ + plan skip_all => 'injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_vacuum_parallel'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +shared_preload_libraries = 'injection_points' +max_worker_processes = 16 +max_parallel_workers = 8 +max_parallel_maintenance_workers = 4 +min_parallel_index_scan_size = 0 +}); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE EXTENSION injection_points; +}); + +# A slot keeps the WAL the standby has not received yet: with +# wal_consistency_checking the tables below write far more than max_wal_size. +$node->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('standby_slot')"); +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('flux_vacuum_parallel_standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "primary_slot_name = 'standby_slot'"); +$standby->start; + +# Two tables of identical contents: four btree indexes, a hash, a brin and a +# partial btree; brin's cleanup, run by a worker, scans the table. In-place key UPDATEs of a and b leave delete-marked +# entries; DELETEs leave dead TIDs; a rolled-back key UPDATE leaves rows +# whose versions are an aborted writer's. +sub make_table +{ + my ($t, $am) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, a int, b int, c text, d int) USING $am + WITH (autovacuum_enabled = off); +INSERT INTO $t SELECT g, g % 997, g % 1009, 'c' || g, -g + FROM generate_series(1, 40000) g; +CREATE INDEX ${t}_id ON $t (id); +CREATE INDEX ${t}_a ON $t (a); +CREATE INDEX ${t}_b ON $t (b); +CREATE INDEX ${t}_ab ON $t (a, b); +CREATE INDEX ${t}_c ON $t USING hash (c); +CREATE INDEX ${t}_d ON $t USING brin (d) WITH (pages_per_range = 4); +CREATE INDEX ${t}_p ON $t (id) WHERE id % 10 = 3; +-- block ranges brin has not summarized, for its cleanup to summarize +INSERT INTO $t SELECT g, g % 997, g % 1009, 'c' || g, -g + FROM generate_series(40001, 44000) g; +UPDATE $t SET a = a + 5000 WHERE id % 7 = 0; +UPDATE $t SET b = b + 7000 WHERE id % 11 = 0; +DELETE FROM $t WHERE id % 5 = 0; +BEGIN; +UPDATE $t SET a = a + 9000 WHERE id % 13 = 0; +ROLLBACK; +}); +} + +my @idx = qw(id a b ab p); + +# Everything parallel and serial VACUUM must agree on. +sub state_of +{ + my ($t) = @_; + my $s = $node->safe_psql( + 'postgres', qq{ +SELECT count(*), sum(id), sum(a), sum(b), sum(length(c)), sum(d) FROM $t}); + foreach my $i (@idx) + { + $s .= "\n$i: " + . $node->safe_psql('postgres', + "SELECT bt_index_check('${t}_$i', true)"); + } + # Every index reaches every row, by its own key. + $s .= "\n" . $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = on; +SET enable_indexscan = off; SET enable_indexonlyscan = off; +SELECT (SELECT count(*) FROM $t WHERE id > 0), + (SELECT count(*) FROM $t WHERE a >= 0), + (SELECT count(*) FROM $t WHERE b >= 0), + (SELECT count(*) FROM $t WHERE a >= 0 AND b >= 0), + (SELECT count(*) FROM $t WHERE c = 'c4001'), + (SELECT count(*) FROM $t WHERE d < 0), + (SELECT count(*) FROM $t WHERE id % 10 = 3 AND id > 0)}); + $s .= "\n" . $node->safe_psql( + 'postgres', qq{ +SELECT n_live_tup, n_dead_tup, vacuum_count FROM pg_stat_user_tables +WHERE relid = '$t'::regclass}); + $s .= "\n" . $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(substr(c.relname, length('$t') + 2) || '=' || + c.relpages || '/' || c.reltuples, ' ' ORDER BY c.relname) +FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid +WHERE i.indrelid = '$t'::regclass}); + $s .= "\n" + . $node->safe_psql('postgres', + "SELECT relpages, reltuples FROM pg_class WHERE oid = '$t'::regclass" + ); + return $s; +} + +# VACUUM VERBOSE; return the number of workers it launched in total. +sub vacuum_launched +{ + my ($opts, $t, $mwm) = @_; + my ($stdout, $stderr); + $node->psql( + 'postgres', "SET maintenance_work_mem = '$mwm'; + SET client_min_messages = info; VACUUM (VERBOSE, $opts) $t; + SELECT pg_stat_force_next_flush();", + stdout => \$stdout, + stderr => \$stderr, + on_error_die => 1); + my $n = 0; + $n += $1 while $stderr =~ /launched (\d+) parallel vacuum worker/g; + return $n; +} + +foreach my $am ('heap', 'flux') +{ + foreach my $mwm ('64MB', '64kB') + { + make_table("vs_$am", $am); + make_table("vp_$am", $am); + my $before = state_of("vs_$am"); + is(state_of("vp_$am"), $before, "$am $mwm: twins start equal"); + + is(vacuum_launched('PARALLEL 0', "vs_$am", $mwm), + 0, "$am $mwm: PARALLEL 0 launches no worker"); + cmp_ok(vacuum_launched('PARALLEL 4', "vp_$am", $mwm), + '>', 0, "$am $mwm: PARALLEL 4 launches workers"); + + my $serial = state_of("vs_$am"); + is(state_of("vp_$am"), $serial, + "$am $mwm: parallel and serial VACUUM leave the same state"); + note "$am $mwm serial state:\n$serial"; + + # Delete-marked entries left by the UPDATEs are gone in both. + my $tomb = sub { + my ($t) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = on; +SELECT count(*) FROM $t WHERE a >= 5000 AND a < 9000}); + }; + is($tomb->("vp_$am"), $tomb->("vs_$am"), + "$am $mwm: same index-only count on the updated key range"); + + # A second VACUUM of each finds nothing more to do, in the same way. + vacuum_launched('PARALLEL 0', "vs_$am", $mwm); + vacuum_launched('PARALLEL 4', "vp_$am", $mwm); + is(state_of("vp_$am"), state_of("vs_$am"), + "$am $mwm: and after a second VACUUM"); + + $node->safe_psql('postgres', "DROP TABLE vs_$am, vp_$am"); + } +} + +# VACUUM (PARALLEL n) of a temporary FLUX table: as heap, a WARNING. +{ + my ($stdout, $stderr); + $node->psql( + 'postgres', q{ +CREATE TEMP TABLE tf (a int) USING flux; +CREATE INDEX ON tf (a); CREATE INDEX ON tf ((a + 1)); +INSERT INTO tf SELECT generate_series(1, 100); +VACUUM (PARALLEL 2) tf;}, + stdout => \$stdout, + stderr => \$stderr); + like( + $stderr, + qr/disabling parallel option of vacuum on "tf" --- cannot vacuum temporary tables in parallel/, + 'temporary FLUX table: parallel VACUUM disabled with a WARNING'); +} + +# Crash while parallel workers vacuum the indexes: the next VACUUM finishes +# the job and the indexes are sound. +make_table('vc', 'flux'); +my $expect = $node->safe_psql('postgres', + 'SELECT count(*), sum(id), sum(a), sum(b) FROM vc'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('vacuum-bulkdel-one-index', 'wait')"); +my $vac = $node->background_psql('postgres', on_error_stop => 0); +$vac->query_until(qr/start/, "\\echo start\nVACUUM (PARALLEL 4) vc;\n"); +$node->poll_query_until( + 'postgres', q{ +SELECT count(*) >= 2 FROM pg_stat_activity +WHERE wait_event = 'vacuum-bulkdel-one-index'} +) or die 'parallel VACUUM did not reach the index phase'; +is( $node->safe_psql( + 'postgres', q{ +SELECT count(*) > 0 FROM pg_stat_activity +WHERE backend_type = 'parallel worker' + AND wait_event = 'vacuum-bulkdel-one-index'}), + 't', + 'a parallel worker is vacuuming an index'); +$node->stop('immediate'); +eval { $vac->quit }; +$node->start; +is( $node->safe_psql( + 'postgres', 'SELECT count(*), sum(id), sum(a), sum(b) FROM vc'), + $expect, + 'rows survive a crash during parallel index vacuum'); +foreach my $i (@idx) +{ + is( $node->safe_psql('postgres', "SELECT bt_index_check('vc_$i', true)"), + '', + "after crash: bt_index_check heapallindexed on vc_$i"); +} +cmp_ok(vacuum_launched('PARALLEL 4', 'vc', '64MB'), + '>', 0, 'parallel VACUUM after the crash'); +foreach my $i (@idx) +{ + is( $node->safe_psql('postgres', "SELECT bt_index_check('vc_$i', true)"), + '', + "after VACUUM: bt_index_check heapallindexed on vc_$i"); +} + +# The standby replays all of it. +$node->wait_for_catchup($standby); +is( $standby->safe_psql( + 'postgres', 'SELECT count(*), sum(id), sum(a), sum(b) FROM vc'), + $expect, + 'standby: same rows'); +is( $standby->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM vc WHERE a >= 0}), + $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM vc WHERE a >= 0}), + 'standby: index scan agrees with the primary'); +foreach my $i (@idx) +{ + is( $standby->safe_psql( + 'postgres', "SELECT bt_index_check('vc_$i', true)"), + '', + "standby: bt_index_check heapallindexed on vc_$i"); +} + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/138_flux_vacuum_toast.pl b/src/test/recovery/t/138_flux_vacuum_toast.pl new file mode 100644 index 0000000000000..daf25cfa5a88c --- /dev/null +++ b/src/test/recovery/t/138_flux_vacuum_toast.pl @@ -0,0 +1,310 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX and heap delete a row's external TOAST values in the deleting +# transaction. TOAST VACUUM reclaims those values and values whose inserter +# aborted. Both AMs must end with the same rows and with no TOAST chunk +# that no row references. +# +# Each case runs on a heap and a FLUX twin and checks that VACUUM succeeds +# (twice), the rows and their values, and, after VACUUM and a VACUUM of the +# TOAST table, that every TOAST chunk left belongs to a live row. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_vacuum_toast'); +$node->init; +my $injection = ($ENV{enable_injection_points} // '') eq 'yes'; +$node->append_conf('postgresql.conf', + "autovacuum = off\n" + . ($injection ? "shared_preload_libraries = 'injection_points'\n" : '') +); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pageinspect'); +$injection = 0 unless $node->check_extension('injection_points'); +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points') + if $injection; + +# Row id's value: about 10 kB, stored uncompressed out of line. +my $val = sub { my ($id) = @_; return "($id || ':' || repeat('x', 10000))" }; + +sub make +{ + my ($t, $am, $ids) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k int, big text) USING $am + WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +CREATE UNIQUE INDEX ${t}_id ON $t (id); +CREATE INDEX ${t}_k ON $t (k); +INSERT INTO $t SELECT g, g, @{[$val->('g')]} FROM unnest('{$ids}'::int[]) g; +}); +} + +sub toast_of +{ + my ($t) = @_; + return $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE oid = '$t'::regclass" + ); +} + +# VACUUM; returns '' on success, else the error. +sub vacuum +{ + my ($what) = @_; + my ($rc, $out, $err) = $node->psql('postgres', "VACUUM $what"); + return $rc == 0 ? '' : $err; +} + +# Rows, each with its value intact, and TOAST state: values an MVCC +# snapshot sees, and whether every chunk on the TOAST pages is one of them. +sub state_of +{ + my ($t) = @_; + my $toast = toast_of($t); + return $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(id || '/' || k || '/' || + (big = @{[$val->('id')]})::text, ' ' ORDER BY id) +FROM $t; +SELECT count(DISTINCT chunk_id) FROM $toast; +SELECT (SELECT count(*) + FROM generate_series(0, pg_relation_size('$toast') / + current_setting('block_size')::int - 1) b, + heap_page_items(get_raw_page('$toast', b::int)) + WHERE lp_flags <> 0) = (SELECT count(*) FROM $toast)}); +} + +# name, the change to make on table T (rows 1-4 exist), the rows expected. +my @cases = ( + [ + 'committed DELETE', + 'DELETE FROM T WHERE id = 4', + '1/1/true 2/2/true 3/3/true' + ], + [ + 'aborted INSERT', + "BEGIN; INSERT INTO T SELECT g, g, @{[$val->('g')]} + FROM generate_series(11, 13) g; ROLLBACK", + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'INSERT rolled back to a savepoint in a committed transaction', + "BEGIN; INSERT INTO T VALUES (21, 21, @{[$val->(21)]}); + SAVEPOINT s; + INSERT INTO T SELECT g, g, @{[$val->('g')]} + FROM generate_series(22, 23) g; + ROLLBACK TO s; COMMIT", + '1/1/true 2/2/true 3/3/true 4/4/true 21/21/true' + ], + [ + 'aborted in-place UPDATE of the TOASTed column', + "BEGIN; UPDATE T SET big = 'new' || repeat('y', 10000) WHERE id = 1; + ROLLBACK", + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + # A hash index has no delete-marking, so FLUX moves a row whose key + # changes to a new TID (delete + insert), as heap does. + [ + 'aborted out-of-place UPDATE (key change, new value)', + "CREATE INDEX ON T USING hash (k); + BEGIN; UPDATE T SET k = 102, big = 'new' || repeat('y', 10000) + WHERE id = 2; ROLLBACK", + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'aborted out-of-place UPDATE (key change, value kept)', + 'CREATE INDEX ON T USING hash (k); + BEGIN; UPDATE T SET k = 103 WHERE id = 3; ROLLBACK', + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'committed out-of-place UPDATE (key change, value kept)', + "CREATE INDEX ON T USING hash (k); + UPDATE T SET k = 103 WHERE id = 3; + DO \$\$ BEGIN IF (SELECT ctid FROM T WHERE id = 3) = '(0,3)' + THEN RAISE 'row did not move'; END IF; END \$\$", + '1/1/true 2/2/true 3/103/true 4/4/true' + ], + [ + 'DELETE rolled back', + 'BEGIN; DELETE FROM T WHERE id IN (1, 2); ROLLBACK', + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'INSERT and DELETE in one committed transaction', + "BEGIN; INSERT INTO T VALUES (31, 31, @{[$val->(31)]}); + DELETE FROM T WHERE id = 31; COMMIT", + '1/1/true 2/2/true 3/3/true 4/4/true' + ],); + +my $n = 0; +foreach my $case (@cases) +{ + my ($name, $sql, $rows) = @$case; + $n++; + foreach my $am ('heap', 'flux') + { + my $t = "c${n}_$am"; + make($t, $am, '1,2,3,4'); + (my $s = $sql) =~ s/\bT\b/$t/g; + $node->safe_psql('postgres', $s); + + is(vacuum($t), '', "$am, $name: VACUUM succeeds"); + is(vacuum(toast_of($t)), '', "$am, $name: VACUUM of the TOAST table"); + my $nrows = () = $rows =~ /\//g; + is( state_of($t), + "$rows\n" . ($nrows / 2) . "\nt", + "$am, $name: rows and TOAST after VACUUM"); + is(vacuum($t), '', "$am, $name: a second VACUUM succeeds"); + } +} + +# A VACUUM that cannot compact a page (a cursor pins it, and a +# non-aggressive VACUUM does not wait) removes none of its rows, so it must +# not delete their TOAST values either: the next VACUUM would find them +# deleted but, while the cursor's transaction holds the horizon, not yet +# removed. +foreach my $am ('heap', 'flux') +{ + my $t = "pin_$am"; + make($t, $am, '1,2,3,4'); + $node->safe_psql('postgres', "DELETE FROM $t WHERE id = 2"); + my $cur = $node->background_psql('postgres'); + $cur->query_safe("BEGIN; DECLARE c CURSOR FOR SELECT id FROM $t"); + $cur->query_safe('FETCH 1 FROM c'); + is(vacuum($t), '', "$am, page pinned: VACUUM succeeds"); + is(vacuum($t), '', "$am, page pinned: a second VACUUM succeeds"); + $cur->query_safe('COMMIT'); + $cur->quit; + is(vacuum($t), '', "$am, unpinned: VACUUM succeeds"); + is(vacuum(toast_of($t)), '', "$am, unpinned: VACUUM of the TOAST table"); + is( state_of($t), + "1/1/true 3/3/true 4/4/true\n3\nt", + "$am, unpinned: rows and TOAST after VACUUM"); +} + +# Opportunistic pruning reclaims a committed-deleted row's storage before +# VACUUM sees it. Its TOAST values must not outlive it. +foreach my $am ('heap', 'flux') +{ + my $t = "prune_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, big text, pad text) USING $am + WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +INSERT INTO $t SELECT g, @{[$val->('g')]}, repeat('p', 200) + FROM generate_series(1, 40) g; +DELETE FROM $t WHERE id % 2 = 0; +}); + # The page is nearly full, so a scan prunes it. + $node->safe_psql('postgres', "SELECT count(*) FROM $t"); + $node->safe_psql('postgres', "SELECT count(*) FROM $t"); + is(vacuum($t), '', "$am, pruned: VACUUM succeeds"); + is(vacuum(toast_of($t)), '', "$am, pruned: VACUUM of the TOAST table"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT (SELECT count(*) FROM $t), + (SELECT count(DISTINCT chunk_id) FROM @{[toast_of($t)]})}), + '20|20', + "$am, pruned: one TOAST value per live row"); +} + +# A crash after VACUUM removed rows, before it committed. The page changes +# are not transactional, so the rows are gone for good; their TOAST values +# must not stay behind. The FLUX VACUUM stops just before truncating the +# table, after it has compacted every page. Heap has no such stop; its twin +# crashes right after the DELETE. +SKIP: +{ + skip 'injection points not supported by this build', 6 + unless $injection; + foreach my $am ('heap', 'flux') + { + my $t = "crash_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, big text) USING $am WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +INSERT INTO $t SELECT g, g || repeat('v', 2100) FROM generate_series(1, 8000) g; +CREATE INDEX ${t}_id ON $t (id); +DELETE FROM $t WHERE id > 100; +}); + my $vac; + if ($am eq 'flux') + { + $vac = $node->background_psql('postgres', on_error_stop => 0); + $vac->query_safe( + "SELECT injection_points_set_local(); + SELECT injection_points_attach('flux-vacuum-before-truncate', 'wait')" + ); + $vac->query_until(qr/start/, "\\echo start\nVACUUM $t;\n"); + $node->wait_for_event('client backend', + 'flux-vacuum-before-truncate'); + } + $node->stop('immediate'); + eval { $vac->quit } if $vac; + $node->start; + is(vacuum($t), '', "$am, after a crash: VACUUM succeeds"); + is(vacuum(toast_of($t)), + '', "$am, after a crash: VACUUM of the TOAST table"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT (SELECT count(*) FROM $t), + (SELECT count(DISTINCT chunk_id) FROM @{[toast_of($t)]})}), + '100|100', + "$am, after a crash: one TOAST value per live row"); + } +} + +# A speculative insertion that loses to a concurrent INSERT is killed with +# its TOAST values (heap_abort_speculative); its transaction commits. +SKIP: +{ + skip 'injection points not supported by this build', 8 + unless $injection; + foreach my $am ('heap', 'flux') + { + my $t = "spec_$am"; + make($t, $am, '1'); + my $s1 = $node->background_psql('postgres'); + $s1->query_safe( + "SELECT injection_points_set_local(); + SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait')" + ); + $s1->query_until( + qr/start/, qq{\\echo start +INSERT INTO $t VALUES (5, 5, @{[$val->(5)]}) ON CONFLICT (id) DO NOTHING; +}); + $node->wait_for_event('client backend', + 'exec-insert-before-insert-speculative'); + $node->safe_psql('postgres', + "INSERT INTO $t VALUES (5, 5, @{[$val->(5)]})"); + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('exec-insert-before-insert-speculative')" + ); + $s1->query_safe( + "SELECT injection_points_detach('exec-insert-before-insert-speculative')" + ); + $s1->quit; + + is(vacuum($t), '', "$am, aborted speculative insertion: VACUUM"); + is(vacuum(toast_of($t)), + '', + "$am, aborted speculative insertion: VACUUM of the TOAST table"); + is( state_of($t), + "1/1/true 5/5/true\n2\nt", + "$am, aborted speculative insertion: rows and TOAST"); + is(vacuum($t), '', + "$am, aborted speculative insertion: a second VACUUM"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/139_flux_toast_delete.pl b/src/test/recovery/t/139_flux_toast_delete.pl new file mode 100644 index 0000000000000..0c865d5d5313a --- /dev/null +++ b/src/test/recovery/t/139_flux_toast_delete.pl @@ -0,0 +1,304 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX deletes a row version's external TOAST values when the version is +# deleted, as heap_delete does: an MVCC delete in the TOAST table by the +# deleting transaction. Every path that retires a version must do so once, +# the values must come back with a rolled-back DELETE, a snapshot that still +# reads the deleted version must still detoast it across VACUUM of the table +# and of its TOAST table, and logical decoding must see what it sees for heap. +# Every case runs on a heap twin with the same expectations. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_toast_delete'); +$node->init(allows_streaming => 'logical'); +$node->append_conf('postgresql.conf', + "autovacuum = off\nmax_prepared_transactions = 5\n"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pageinspect'); + +# Row id's value: about 10 kB, stored uncompressed out of line. +sub val { my ($id) = @_; return "($id || ':' || repeat('x', 10000))" } + +sub make +{ + my ($t, $am, $extra) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k int, big text) USING $am + WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +CREATE UNIQUE INDEX ${t}_id ON $t (id); +@{[$extra // '']} +INSERT INTO $t SELECT g, g, @{[val('g')]} FROM generate_series(1, 4) g; +}); +} + +sub toast_of +{ + my ($t) = @_; + return $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE oid = '$t'::regclass" + ); +} + +# VACUUM the table and its TOAST table. +sub vacuum_both +{ + my ($t) = @_; + $node->safe_psql('postgres', "VACUUM $t"); + $node->safe_psql('postgres', 'VACUUM ' . toast_of($t)); +} + +# Rows with intact values; TOAST values an MVCC snapshot sees; and whether +# the TOAST pages hold nothing else (every deleted chunk was removed). +sub state_of +{ + my ($t) = @_; + my $toast = toast_of($t); + return $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(id || '/' || k || '/' || (big = @{[val('id')]})::text, ' ' + ORDER BY id) FROM $t; +SELECT count(DISTINCT chunk_id) FROM $toast; +SELECT (SELECT count(*) + FROM generate_series(0, pg_relation_size('$toast') / + current_setting('block_size')::int - 1) b, + heap_page_items(get_raw_page('$toast', b::int)) + WHERE lp_flags <> 0) = (SELECT count(*) FROM $toast)}); +} + +my $all4 = '1/1/true 2/2/true 3/3/true 4/4/true'; + +foreach my $am ('heap', 'flux') +{ + # --- A snapshot that reads deleted and superseded versions. --- + # Committed DELETE, in-place UPDATE of the TOASTed column, and a + # key-changing UPDATE that moves the row (a hash index cannot be + # delete-marked, so FLUX moves it as heap does), each with a snapshot + # taken before it that reads the old version across VACUUM of the table + # and of the TOAST table. + my $t = "old_$am"; + make($t, $am, "CREATE INDEX ${t}_k ON $t USING hash (k);"); + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM $t"); + $node->safe_psql( + 'postgres', qq{ +DELETE FROM $t WHERE id = 1; +UPDATE $t SET big = 'new' || repeat('y', 10000) WHERE id = 2; +UPDATE $t SET k = 103 WHERE id = 3; +UPDATE $t SET k = 104, big = 'new' || repeat('z', 10000) WHERE id = 4; +}); + my $read_old = qq{SELECT string_agg(id || '/' || k || '/' || + (big = @{[val('id')]})::text, ' ' ORDER BY id) FROM $t}; + is($rr->query_safe($read_old), + $all4, "$am: old snapshot reads the old versions before VACUUM"); + vacuum_both($t); + is($rr->query_safe($read_old), + $all4, "$am: and after VACUUM of the table and its TOAST table"); + $rr->query_safe('COMMIT'); + $rr->quit; + vacuum_both($t); + is( state_of($t), + "2/2/false 3/103/true 4/104/false\n3\nt", + "$am: once the snapshot is gone the old values are reclaimed"); + + # --- Partition move and MERGE DELETE. --- + my $p = "part_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $p (id int, k int, big text) PARTITION BY RANGE (k); +CREATE TABLE ${p}_1 PARTITION OF $p FOR VALUES FROM (0) TO (100) USING $am; +CREATE TABLE ${p}_2 PARTITION OF $p FOR VALUES FROM (100) TO (1000) USING $am; +ALTER TABLE $p ALTER big SET STORAGE EXTERNAL; +INSERT INTO $p SELECT g, g, @{[val('g')]} FROM generate_series(1, 4) g; +UPDATE $p SET k = 500 WHERE id = 1; +MERGE INTO $p USING (VALUES (2)) s(id) ON $p.id = s.id + WHEN MATCHED THEN DELETE; +}); + vacuum_both("${p}_1"); + vacuum_both("${p}_2"); + is( state_of("${p}_1"), + "3/3/true 4/4/true\n2\nt", + "$am: partition moved from and MERGE DELETE: two values left"); + is(state_of("${p}_2"), "1/500/true\n1\nt", + "$am: partition moved to: one value"); + + # --- DELETE that waits and retries. --- + # The first deleter rolls back: the waiter deletes the row, once. An + # in-place UPDATE of another column commits: the waiter deletes the + # updated version, once. + $t = "wait_$am"; + make($t, $am); + my $s1 = $node->background_psql('postgres'); + my $s2 = $node->background_psql('postgres'); + foreach my $c ( + [ 'BEGIN; DELETE FROM T WHERE id = 1', 'ROLLBACK', 1 ], + [ 'BEGIN; UPDATE T SET k = 20 WHERE id = 2', 'COMMIT', 2 ]) + { + my ($first, $end, $id) = @$c; + (my $f = $first) =~ s/\bT\b/$t/g; + $s1->query_safe($f); + $s2->query_until(qr/start/, + "\\echo start\nDELETE FROM $t WHERE id = $id;\n"); + $node->poll_query_until( + 'postgres', qq{ +SELECT count(*) > 0 FROM pg_stat_activity +WHERE wait_event_type = 'Lock' AND query LIKE 'DELETE FROM $t%'} + ) or die 'DELETE did not wait'; + $s1->query_safe($end); + is($s2->query_safe("SELECT count(*) FROM $t WHERE id = $id"), + '0', "$am: waiting DELETE of row $id went ahead after $end"); + } + $s1->quit; + $s2->quit; + vacuum_both($t); + is( state_of($t), + "3/3/true 4/4/true\n2\nt", + "$am: each waited DELETE deleted its values once"); + + # --- Rollbacks: the values must be live again. --- + $t = "rb_$am"; + make($t, $am); + $node->safe_psql( + 'postgres', qq{ +BEGIN; DELETE FROM $t WHERE id = 1; ROLLBACK; +BEGIN; SAVEPOINT s; DELETE FROM $t WHERE id = 2; ROLLBACK TO s; COMMIT; +BEGIN; DELETE FROM $t WHERE id = 3; PREPARE TRANSACTION 'rb_$am'; +ROLLBACK PREPARED 'rb_$am'; +BEGIN; DELETE FROM $t WHERE id = 4; PREPARE TRANSACTION 'cp_$am'; +COMMIT PREPARED 'cp_$am'; +}); + vacuum_both($t); + is( state_of($t), + "1/1/true 2/2/true 3/3/true\n3\nt", + "$am: aborted, savepoint, prepared DELETEs keep their values; a committed prepared one does not" + ); + + # --- CLUSTER copies a recently dead row. --- + $t = "cl_$am"; + make($t, $am); + $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe('SELECT 1 FROM pg_class LIMIT 1'); + $node->safe_psql('postgres', "DELETE FROM $t WHERE id = 2"); + $node->safe_psql('postgres', "CLUSTER $t USING ${t}_id"); + is($rr->query_safe("SELECT big = @{[val(2)]} FROM $t WHERE id = 2"), + 't', "$am: old snapshot reads the recently dead row after CLUSTER"); + $rr->query_safe('COMMIT'); + $rr->quit; + vacuum_both($t); + my $cl = state_of($t); + note "$am CLUSTER state: $cl"; + like( + $cl, + qr/^1\/1\/true 3\/3\/true 4\/4\/true\n/, + "$am: rows after CLUSTER and VACUUM"); + $node->safe_psql('postgres', "SELECT 1"); +} + +# CLUSTER: FLUX keeps exactly what heap keeps. +is(state_of('cl_flux'), state_of('cl_heap'), + 'CLUSTER of a recently dead row: FLUX TOAST state equals heap'); + +# --- Logical decoding of a DELETE, REPLICA IDENTITY FULL. --- +my %out; +foreach my $am ('heap', 'flux') +{ + my $t = "ld_$am"; + make($t, $am); + $node->safe_psql('postgres', "ALTER TABLE $t REPLICA IDENTITY FULL"); + $node->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('s_$am', 'test_decoding')" + ); + $node->safe_psql('postgres', "DELETE FROM $t WHERE id IN (2, 3)"); + $out{$am} = $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(data, E'\\n') FROM pg_logical_slot_get_changes('s_$am', + NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1')}); + $node->safe_psql('postgres', "SELECT pg_drop_replication_slot('s_$am')"); + $out{$am} =~ s/ld_$am/ld_AM/g; + vacuum_both($t); + is( state_of($t), + "1/1/true 4/4/true\n2\nt", + "$am: decoded DELETE left two values"); +} +like($out{heap}, qr/DELETE: id\[integer\]:2/, 'heap DELETE decoded'); +is($out{flux}, $out{heap}, + 'logical decoding of a DELETE with TOAST values: FLUX output equals heap' +); + +$node->stop; + +# --- A DELETE rolled back while its revert is pending, then helped. --- +# The rollback goes to an undo apply worker (undo_rollback_overflow_size = +# 0), held at an injection point; an UPDATE of the row reverts the aborted +# DELETE itself first. The DELETE's TOAST deletes rolled back with it. +SKIP: +{ + skip 'injection points not supported by this build', 3 + unless ($ENV{enable_injection_points} // '') eq 'yes'; + my $h = PostgreSQL::Test::Cluster->new('flux_toast_helped'); + $h->init; + $h->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +}); + $h->start; + skip 'injection_points not installed', 3 + unless $h->check_extension('injection_points'); + $node = $h; + $h->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION injection_points; +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + RETURN false; +END $$; +}); + make('hp', 'flux'); + $h->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')" + ); + $h->safe_psql('postgres', 'BEGIN; DELETE FROM hp WHERE id = 1; ROLLBACK'); + $h->poll_query_until( + 'postgres', q{ +SELECT count(*) > 0 FROM pg_stat_activity +WHERE wait_event = 'pbu-undo-worker-before-apply'} + ) or die 'the rollback did not go to a worker'; + $h->safe_psql('postgres', 'UPDATE hp SET k = 10 WHERE id = 1'); + is( $h->safe_psql( + 'postgres', "SELECT k, big = @{[val(1)]} FROM hp WHERE id = 1"), + '10|t', + 'flux: the helped row has its value'); + $h->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); + $h->poll_query_until('postgres', 'SELECT release_one()') + or die 'worker not released'; + vacuum_both('hp'); + is( state_of('hp'), + "1/10/true 2/2/true 3/3/true 4/4/true\n4\nt", + 'flux: helped revert of a DELETE keeps every value'); + is($h->safe_psql('postgres', 'SELECT count(*) FROM hp'), + '4', 'flux: no row lost'); + $h->stop; +} + +done_testing(); diff --git a/src/test/recovery/t/140_flux_spec_insert_vm.pl b/src/test/recovery/t/140_flux_spec_insert_vm.pl new file mode 100644 index 0000000000000..2d5f668a38fff --- /dev/null +++ b/src/test/recovery/t/140_flux_spec_insert_vm.pl @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A speculative insertion (INSERT ... ON CONFLICT) adds a tuple to a page, +# so it must clear the page's visibility map bits, as every other insert +# does. A page left all-visible would let an index-only scan count a row +# that no snapshot sees (an uncommitted or aborted insertion), and a page +# left all-frozen is skipped by VACUUM's first pass, which then leaves the +# aborted tuple's index entry behind. heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_spec_insert_vm'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +foreach my $ext ('pg_visibility', 'pageinspect') +{ + plan skip_all => "Extension $ext not installed" + unless $node->check_extension($ext); + $node->safe_psql('postgres', "CREATE EXTENSION $ext"); +} + +foreach my $am ('heap', 'flux') +{ + my $vm = qq{SELECT all_visible || ',' || all_frozen + FROM pg_visibility_map('sp_$am') WHERE blkno = 0}; + + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE sp_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO sp_$am SELECT g, 0 FROM generate_series(1, 10) g; +VACUUM (FREEZE) sp_$am; +}); + is($node->safe_psql('postgres', $vm), + 'true,true', "$am: page is all-visible and all-frozen"); + + # An insertion still in progress. + my $bg = $node->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe( + "INSERT INTO sp_$am VALUES (11, 1) ON CONFLICT DO NOTHING"); + my $xid = $bg->query_safe('SELECT pg_current_xact_id()::xid'); + chomp $xid; + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: in-progress speculative insert clears the VM"); + $bg->query_safe('ROLLBACK'); + $bg->quit; + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: VM stays clear after the insert aborts"); + + # One aggressive VACUUM must remove the aborted tuple and its index + # entry; skipping the page in the first pass leaves both behind. + $node->safe_psql('postgres', "VACUUM (FREEZE) sp_$am"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(lp_flags::text || ':' || n, ',' ORDER BY lp_flags) +FROM (SELECT lp_flags, count(*) n + FROM heap_page_items(get_raw_page('sp_$am', 0)) GROUP BY lp_flags) s}), + '1:10', + "$am: VACUUM removed the aborted tuple"); + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM bt_page_items('sp_${am}_pkey', 1)"), + '10', + "$am: VACUUM removed its index entry"); + # relfrozenxid may pass the aborted xid only once its tuple is gone: + # otherwise CLOG for it can be truncated while the tuple is still there. + is( $node->safe_psql( + 'postgres', qq{ +SELECT age(relfrozenxid) >= age('$xid'::xid) OR + (SELECT count(*) FROM heap_page_items(get_raw_page('sp_$am', 0)) + WHERE lp_flags <> 0) = 10 +FROM pg_class WHERE relname = 'sp_$am'}), + 't', + "$am: relfrozenxid does not pass the xid of a tuple still on the page" + ); + is($node->safe_psql('postgres', $vm), + 'true,true', "$am: VACUUM sets the bits again"); + + # COPY (multi_insert) onto the all-frozen page. + $node->safe_psql('postgres', + "COPY sp_$am FROM PROGRAM 'echo 13,1' (FORMAT csv)"); + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: COPY clears the VM"); + $node->safe_psql('postgres', "VACUUM (FREEZE) sp_$am"); + + # A committed one. + $node->safe_psql('postgres', + "INSERT INTO sp_$am VALUES (12, 1) ON CONFLICT (id) DO UPDATE SET v = 2" + ); + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: committed speculative insert clears the VM"); + is($node->safe_psql('postgres', "SELECT count(*) FROM sp_$am"), + '12', "$am: row count"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/141_flux_index_exact_keys.pl b/src/test/recovery/t/141_flux_index_exact_keys.pl new file mode 100644 index 0000000000000..4bdf975fd620a --- /dev/null +++ b/src/test/recovery/t/141_flux_index_exact_keys.pl @@ -0,0 +1,276 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A FLUX UPDATE that changes an indexed column in place inserts (k_new, TID) +# and delete-marks (k_old, TID). That only describes the change when the two +# entries are different keys. Two changes it cannot describe: +# +# - a change of an INCLUDE column only: the key, and so the entry, stays the +# same, and the entry kept the old INCLUDE value; +# - a key change in an index whose opclass can call two different images +# equal (numeric 3 and 3.00, float8 0 and -0): the new entry duplicated +# the old one's (key, TID), or a tombstone revived under the other image +# hid the row from a snapshot that saw the old one. +# +# Such an UPDATE must move the row to a new TID, as heap's non-HOT UPDATE +# does, so that every entry describes exactly one version. The same holds +# when a row re-enters a partial index: the entry it gets back may be its own +# tombstone from before it left, re-formed from the new version. amcheck +# (heapallindexed, and rootdescend + checkunique) runs after every step; heap +# is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_index_exact_keys'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +-- bt_index_check's error as text, '' if none: a background session can call +-- it in the snapshot it holds. +CREATE FUNCTION amcheck_err(idx regclass) RETURNS text LANGUAGE plpgsql AS $$ +BEGIN + PERFORM bt_index_check(idx, true, false); + RETURN ''; +EXCEPTION WHEN OTHERS THEN + RETURN SQLERRM; +END $$; +}); + +# All checks of one index; '' when amcheck finds nothing. With a writer in +# progress only bt_index_check, as bt_index_parent_check waits for its lock. +sub check_index +{ + my ($idx, $unique, $busy) = @_; + my $sql = "SELECT bt_index_check('$idx', true, $unique)"; + $sql .= "; SELECT bt_index_parent_check('$idx', true, true, $unique)" + unless $busy; + my ($rc, $out, $err) = $node->psql('postgres', $sql); + return $err; +} + +# Rows the index finds for a qual, by an index scan. +sub idx_rows +{ + my ($sql) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; +$sql}); +} + +foreach my $am ('heap', 'flux') +{ + # INCLUDE column changed alone. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE ti_$am (k int, inc int) USING $am; +CREATE INDEX ti_${am}_k ON ti_$am (k) INCLUDE (inc); +INSERT INTO ti_$am SELECT g, g FROM generate_series(1, 5) g; +UPDATE ti_$am SET inc = 30 WHERE k = 3; +}); + is(check_index("ti_${am}_k", 'false'), '', "$am: INCLUDE-only UPDATE"); + $node->safe_psql('postgres', "UPDATE ti_$am SET inc = 3 WHERE k = 3"); + is(check_index("ti_${am}_k", 'false'), + '', "$am: INCLUDE value changed back (A->B->A)"); + + # The same on a unique index, with a rollback and a real duplicate. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tu_$am (k int, inc int) USING $am; +CREATE UNIQUE INDEX tu_${am}_k ON tu_$am (k) INCLUDE (inc); +INSERT INTO tu_$am SELECT g, g FROM generate_series(1, 5) g; +}); + my ($rc, $out, $err) = + $node->psql('postgres', "UPDATE tu_$am SET inc = 30 WHERE k = 3"); + is($rc . $err, '0', "$am: unique INCLUDE-only UPDATE raises no conflict"); + is(check_index("tu_${am}_k", 'true'), + '', "$am: unique INCLUDE-only UPDATE"); + + my $bg = $node->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe("UPDATE tu_$am SET inc = 300 WHERE k = 3"); + is(check_index("tu_${am}_k", 'true', 1), + '', "$am: unique INCLUDE-only UPDATE in progress"); + $bg->query_safe('ROLLBACK'); + $bg->quit; + is(check_index("tu_${am}_k", 'true'), + '', "$am: unique INCLUDE-only UPDATE rolled back"); + is($node->safe_psql('postgres', "SELECT inc FROM tu_$am WHERE k = 3"), + '30', "$am: rollback restored the INCLUDE value"); + + $node->safe_psql('postgres', "UPDATE tu_$am SET inc = 3 WHERE k = 3"); + is(check_index("tu_${am}_k", 'true'), '', "$am: unique INCLUDE A->B->A"); + ($rc, $out, $err) = + $node->psql('postgres', "INSERT INTO tu_$am VALUES (3, 99)"); + like( + $err, + qr/duplicate key value/, + "$am: a real duplicate is still refused"); + + # A key change keeps an int-keyed row at its TID; a change of an INCLUDE + # column moves it, with or without a key change (a later change back to + # the old key would revive that key's entry with the new INCLUDE value). + my $ctid = + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 3"); + $node->safe_psql('postgres', "UPDATE tu_$am SET k = 13 WHERE k = 3"); + is( $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 13") + eq $ctid ? 'same' : 'moved', + $am eq 'flux' ? 'same' : 'moved', + "$am: key change in an int index with INCLUDE columns"); + $ctid = + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 13"); + $node->safe_psql('postgres', + "UPDATE tu_$am SET k = 103, inc = 103 WHERE k = 13"); + isnt( + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 103"), + $ctid, + "$am: a key and INCLUDE change moves the row"); + $ctid = + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 4"); + $node->safe_psql('postgres', "UPDATE tu_$am SET inc = 40 WHERE k = 4"); + isnt($node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 4"), + $ctid, "$am: an INCLUDE-only change moves the row"); + is(check_index("tu_${am}_k", 'true'), '', "$am: key and INCLUDE change"); + is(idx_rows("SELECT k, inc FROM tu_$am WHERE k IN (3, 103)"), + '103|103', "$am: index finds the row under its new key only"); + + # numeric: 3 and 3.00 are equal keys with different images. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tn_$am (id int, n numeric) USING $am; +CREATE INDEX tn_${am}_n ON tn_$am (n); +INSERT INTO tn_$am SELECT g, g FROM generate_series(1, 5) g; +UPDATE tn_$am SET n = 3.00 WHERE id = 3; +}); + is(check_index("tn_${am}_n", 'false'), '', "$am: numeric 3 -> 3.00"); + is(idx_rows("SELECT id, n FROM tn_$am WHERE n = 3"), + '3|3.00', "$am: index scan sees 3.00"); + + # 3 -> 4 -> 3.0 while a snapshot from before both UPDATEs is open. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tr_$am (id int, n numeric) USING $am; +CREATE INDEX tr_${am}_n ON tr_$am (n); +INSERT INTO tr_$am SELECT g, g FROM generate_series(1, 5) g; +}); + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM tr_$am"); + $node->safe_psql( + 'postgres', qq{ +UPDATE tr_$am SET n = 4 WHERE id = 3; +UPDATE tr_$am SET n = 3.0 WHERE id = 3; +}); + is( $rr->query_safe( + qq{SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; +SELECT id, n FROM tr_$am WHERE n = 3}), + '3|3', + "$am: old snapshot finds the old version through the index"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(idx_rows("SELECT id, n FROM tr_$am WHERE n = 3"), + '3|3.0', "$am: new snapshot finds the new version"); + is(check_index("tr_${am}_n", 'false'), '', "$am: numeric 3 -> 4 -> 3.0"); + + # float8: 0 and -0 are equal keys with different images. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tf_$am (id int, f float8) USING $am; +CREATE UNIQUE INDEX tf_${am}_f ON tf_$am (f); +INSERT INTO tf_$am SELECT g, g FROM generate_series(0, 5) g; +UPDATE tf_$am SET f = '-0' WHERE id = 0; +}); + is(check_index("tf_${am}_f", 'true'), '', "$am: float8 0 -> -0"); + is(idx_rows("SELECT id, f FROM tf_$am WHERE f = 0"), + '0|-0', "$am: index scan sees -0"); + + # A row leaves a partial index and comes back with an equal key of + # another image, or another INCLUDE value, while a snapshot from before + # it left is open. Leaving delete-marks its entry; coming back finds + # that tombstone for the same (key, TID) and revives it re-formed from + # the new version, so the old snapshot loses the image it sees. Each + # index is checked through the old snapshot (index scan against seqscan, + # and amcheck's heapallindexed from that snapshot) and afterwards. + # A key that changes and comes back with another INCLUDE value revives + # its tombstone the same way, and so does one in an index with no + # predicate. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE kc_$am (id int, n int, inc int) USING $am; +CREATE INDEX kc_${am}_i ON kc_$am (n) INCLUDE (inc); +INSERT INTO kc_$am SELECT g, g, g FROM generate_series(1, 5) g; +}); + $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM kc_$am"); + $node->safe_psql( + 'postgres', qq{ +UPDATE kc_$am SET n = 4 WHERE id = 3; +UPDATE kc_$am SET n = 3, inc = 30 WHERE id = 3; +}); + is($rr->query_safe("SELECT amcheck_err('kc_${am}_i')"), + '', "$am: key cycle with an INCLUDE change, from the old snapshot"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(check_index("kc_${am}_i", 'false'), + '', "$am: key cycle with an INCLUDE change"); + + foreach my $case ( + [ 'pn', 'n numeric', '(n)', 'n = 3', 'n = 3.0', '3' ], + [ 'pe', 'n numeric', '((n + 0))', 'n + 0 = 3', 'n = 3.0', '3' ], + [ + 'pi', + 'n int, inc int', + '(n) INCLUDE (inc)', + 'n = 3', 'inc = 30', '3|3' + ]) + { + my ($t, $cols, $def, $qual, $set, $old) = @$case; + my $tab = "${t}_$am"; + my $out = $cols =~ /inc/ ? 'n, inc' : 'n'; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $tab (id int, $cols, active bool) USING $am; +CREATE INDEX ${tab}_i ON $tab $def WHERE active; +INSERT INTO $tab (id, n, active) SELECT g, g, true FROM generate_series(1, 5) g; +}); + $node->safe_psql('postgres', "UPDATE $tab SET inc = n") + if $cols =~ /inc/; + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM $tab"); + $node->safe_psql( + 'postgres', qq{ +UPDATE $tab SET active = false WHERE id = 3; +UPDATE $tab SET active = true, $set WHERE id = 3; +}); + my $q = "SELECT $out FROM $tab WHERE $qual AND active"; + is( $rr->query_safe( + qq{SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; $q}), + $old, + "$am $t: old snapshot finds the row it sees through the index"); + is( $rr->query_safe( + qq{SET enable_seqscan = on; SET enable_indexscan = off; +SET enable_bitmapscan = off; $q}), + $old, + "$am $t: old snapshot seqscan"); + is($rr->query_safe("SELECT amcheck_err('${tab}_i')"), + '', "$am $t: heapallindexed from the old snapshot"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(check_index("${tab}_i", 'false'), '', "$am $t: amcheck after"); + is(idx_rows("SELECT count(*) FROM $tab WHERE $qual AND active"), + '1', "$am $t: new snapshot finds the row once"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/142_flux_vm_clear_redo.pl b/src/test/recovery/t/142_flux_vm_clear_redo.pl new file mode 100644 index 0000000000000..56c83d26af2ed --- /dev/null +++ b/src/test/recovery/t/142_flux_vm_clear_redo.pl @@ -0,0 +1,147 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A change to a page clears the page's visibility map bits and its +# PD_ALL_VISIBLE flag in the WAL record of the change, and replay of that +# record clears them too: heap flags the record XLH_*_ALL_VISIBLE_CLEARED. +# Otherwise a standby that has replayed the change, but not yet a later +# record that clears the bits, sees an all-visible page holding a version +# not every snapshot sees, and a page restored by crash recovery or replayed +# on a standby keeps PD_ALL_VISIBLE. An index-only scan trusts the bit. +# +# For each kind of change, on heap (the control) and FLUX: the primary after +# the change, a standby stopped right after the change record (which is also +# what a crash right after that record leaves behind), the standby once caught +# up, and the primary after crash recovery. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', "autovacuum = off\n"); +$primary->start; + +foreach my $ext ('pg_visibility', 'pg_walinspect') +{ + plan skip_all => "Extension $ext not installed" + unless $primary->check_extension($ext); + $primary->safe_psql('postgres', "CREATE EXTENSION $ext"); +} + +my $copyfile = $primary->basedir . '/rows.tsv'; +open(my $fh, '>', $copyfile) or die "could not write $copyfile: $!"; +print $fh "11\t0\tx\n12\t0\tx\n"; +close($fh); + +# Each change touches block 0 of its own table and commits; %t is the table. +my @kinds = ( + [ 'insert', "INSERT INTO %t VALUES (11, 0, 'x')" ], + [ 'copy', "COPY %t FROM '$copyfile'" ], + [ 'spec', "INSERT INTO %t VALUES (11, 0, 'x') ON CONFLICT DO NOTHING" ], + [ 'delete', "DELETE FROM %t WHERE id = 3" ], + [ 'update', "UPDATE %t SET v = 1 WHERE id = 3" ], + [ 'grow', "UPDATE %t SET pad = repeat('y', 200) WHERE id = 3" ], + [ 'key', "UPDATE %t SET id = 100 WHERE id = 3" ],); + +my @cases; +foreach my $am ('heap', 'flux') +{ + foreach my $k (@kinds) + { + my $t = "vm_$k->[0]_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, 0, 'x' FROM generate_series(1, 10) g; +VACUUM (FREEZE) $t; +}); + is( $primary->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)" + ), + 't|t', + "$t: page is all-visible before the change"); + push @cases, { t => $t, sql => ($k->[1] =~ s/%t/$t/gr) }; + } +} + +$primary->backup('bk'); + +# Make the changes, and find the record of each. +foreach my $c (@cases) +{ + my $t = $c->{t}; + my $before = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + $primary->safe_psql('postgres', $c->{sql}); + my $after = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + $c->{lsn} = $primary->safe_psql( + 'postgres', qq{ +SELECT start_lsn FROM pg_get_wal_block_info('$before', '$after', false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND relblocknumber = 0 + AND record_type NOT IN ('VM_SET', 'VM_CLEAR', 'FPI_FOR_HINT') +ORDER BY start_lsn LIMIT 1}); + ok($c->{lsn} ne '', "$t: found the change record"); + is( $primary->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: primary: page not all-visible after the change"); +} + +# A standby that stops right after each change record. The changes were +# made in order, so each restart moves the target forward. +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "recovery_target_inclusive = on\nrecovery_target_action = 'pause'\n"); +foreach my $c (@cases) +{ + my $t = $c->{t}; + $standby->append_conf('postgresql.conf', + "recovery_target_lsn = '$c->{lsn}'\n"); + $standby->start; + $standby->poll_query_until('postgres', + "SELECT pg_get_wal_replay_pause_state() = 'paused'") + or die "standby did not pause at $c->{lsn}"; + is( $standby->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: standby stopped after the change record: page not all-visible"); + $standby->stop; +} + +# The standby caught up. +$standby->append_conf('postgresql.conf', "recovery_target_lsn = ''\n"); +$standby->start; +$primary->wait_for_catchup($standby); +foreach my $c (@cases) +{ + my $t = $c->{t}; + is( $standby->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: standby caught up: page not all-visible"); +} +$standby->stop; + +# Crash recovery replays the changes from the full-page images they took. +$primary->stop('immediate'); +$primary->start; +foreach my $c (@cases) +{ + my $t = $c->{t}; + is( $primary->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: after crash recovery: page not all-visible"); +} + +done_testing(); diff --git a/src/test/recovery/t/143_flux_update_page_lsn.pl b/src/test/recovery/t/143_flux_update_page_lsn.pl new file mode 100644 index 0000000000000..35a80aef90c4b --- /dev/null +++ b/src/test/recovery/t/143_flux_update_page_lsn.pl @@ -0,0 +1,315 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A change WAL-logged for a page sets the page's LSN to its record's, on the +# primary and in replay, so that the buffer manager writes the page only +# after its WAL is flushed and a later change after a checkpoint knows the +# page is logged. A FLUX UPDATE that rewrites the row in place under the +# exclusive lock (it grows or shrinks the row, changes a key, or the +# relation is logically logged) left the page with the LSN of an earlier +# change. heap is the control throughout. +# +# 1. For each kind of change: the page LSN after the change is at least the +# end of its record, on the primary and on a standby that replayed it. +# 2. The consequence: an open transaction rewrites a row in place, the page +# is written out (pg_buffercache_evict) while the UPDATE's WAL is not +# flushed, and the server crashes. The committed row must come back. +# The WAL writer is held at an injection point, and the test checks that +# the UPDATE's record is not flushed before the page is written. +# 3. A checkpoint between UPDATEs of a page makes only the first UPDATE after +# it log a full-page image. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +shared_buffers = 32MB +bgwriter_lru_maxpages = 0 +wal_writer_delay = 10000ms +}); +$primary->start; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; +foreach my $ext ( + 'pageinspect', 'pg_walinspect', + 'pg_buffercache', 'amcheck', + 'injection_points') +{ + plan skip_all => "Extension $ext not installed" + unless $primary->check_extension($ext); + $primary->safe_psql('postgres', "CREATE EXTENSION $ext"); +} + +my $copyfile = $primary->basedir . '/rows.tsv'; +open(my $fh, '>', $copyfile) or die "could not write $copyfile: $!"; +print $fh "11\t0\tx\n12\t0\tx\n"; +close($fh); + +# Each change touches block 0 of its own table; %t is the table. An +# INCLUDE-only change moves a FLUX row to a new TID (out of place). A +# speculative abort is set up below. The relation is logically logged in the +# last case only, while a logical slot exists. +my @kinds = ( + [ 'insert', "INSERT INTO %t VALUES (11, 0, 'x')" ], + [ 'copy', "COPY %t FROM '$copyfile'" ], + [ + 'spec confirm', + "INSERT INTO %t VALUES (11, 0, 'x') ON CONFLICT DO NOTHING" + ], + [ 'spec abort', undef ], + [ 'delete', "DELETE FROM %t WHERE id = 3" ], + [ 'same-size update', "UPDATE %t SET v = 1 WHERE id = 3" ], + [ 'growing update', "UPDATE %t SET pad = repeat('y', 200) WHERE id = 3" ], + [ 'shrinking update', "UPDATE %t SET pad = '' WHERE id = 3" ], + [ 'key update', "UPDATE %t SET id = 100 WHERE id = 3" ], + [ + 'out-of-place update', + "UPDATE %t SET pad = 'q' WHERE id = 3", + "CREATE INDEX ON %t (v) INCLUDE (pad)" + ], + [ 'row lock', "SELECT * FROM %t WHERE id = 3 FOR UPDATE" ], + [ 'logical same-size update', "UPDATE %t SET v = 1 WHERE id = 3" ],); + +# A speculative insertion that finds, after inserting its tuple, that another +# transaction committed the same key in the meantime, and backs out. +sub spec_abort +{ + my ($t) = @_; + $primary->safe_psql('postgres', + "SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait')" + ); + my $bg = $primary->background_psql('postgres'); + $bg->query_until( + qr/started/, qq{ +\\echo started +INSERT INTO $t VALUES (11, 0, 'x') ON CONFLICT DO NOTHING; +}); + $primary->wait_for_event('client backend', + 'exec-insert-before-insert-speculative'); + $primary->safe_psql('postgres', "INSERT INTO $t VALUES (11, 1, 'y')"); + $primary->safe_psql( + 'postgres', qq{ +SELECT injection_points_detach('exec-insert-before-insert-speculative'); +SELECT injection_points_wakeup('exec-insert-before-insert-speculative'); +}); + $bg->quit; +} + +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; +$standby->safe_psql('postgres', 'SELECT 1'); + +my @cases; +foreach my $am ('heap', 'flux') +{ + my $n = 0; + foreach my $k (@kinds) + { + my $t = "ln_${n}_$am"; + $n++; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, 0, 'x' FROM generate_series(1, 10) g; +}); + $primary->safe_psql('postgres', $k->[2] =~ s/%t/$t/gr) + if defined $k->[2]; + push @cases, + { + t => $t, + name => "$am $k->[0]", + sql => (defined $k->[1] ? $k->[1] =~ s/%t/$t/gr : undef), + logical => ($k->[0] =~ /^logical/ ? 1 : 0) + }; + } +} + +$primary->safe_psql('postgres', 'CHECKPOINT'); +foreach my $c (@cases) +{ + my $t = $c->{t}; + if ($c->{logical}) + { + $primary->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('ln_slot', 'test_decoding')" + ); + $primary->poll_query_until('postgres', + "SELECT current_setting('effective_wal_level') = 'logical'") + or die "logical decoding did not turn on"; + } + my $before = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + if (defined $c->{sql}) + { + $primary->safe_psql('postgres', $c->{sql}); + } + else + { + spec_abort($t); + } + $c->{end} = $primary->safe_psql( + 'postgres', qq{ +SELECT max(end_lsn) FROM pg_get_wal_block_info('$before', + pg_current_wal_insert_lsn(), false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND relblocknumber = 0 + AND record_type NOT IN ('VM_SET', 'VM_CLEAR', 'VISIBLE')}); + $primary->safe_psql('postgres', + "SELECT pg_drop_replication_slot('ln_slot')") + if $c->{logical}; + ok($c->{end} ne '', "$c->{name}: found a record for the page"); + is( $primary->safe_psql( + 'postgres', qq{ +SELECT lsn >= '$c->{end}'::pg_lsn FROM page_header(get_raw_page('$t', 0))}), + 't', + "$c->{name}: primary: page LSN covers the record") + or diag $primary->safe_psql( + 'postgres', qq{ +SELECT (SELECT lsn FROM page_header(get_raw_page('$t', 0))) AS page_lsn, + string_agg(start_lsn || '-' || end_lsn || ' ' || record_type, ', ' + ORDER BY start_lsn) +FROM pg_get_wal_block_info('$before', pg_current_wal_insert_lsn(), false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND relblocknumber = 0}); +} + +$primary->wait_for_replay_catchup($standby); +foreach my $c (@cases) +{ + is( $standby->safe_psql( + 'postgres', qq{ +SELECT lsn >= '$c->{end}'::pg_lsn FROM page_header(get_raw_page('$c->{t}', 0))} + ), + 't', + "$c->{name}: standby: page LSN covers the record"); +} +$standby->stop; + +# 2. A page written out before the WAL of its uncommitted in-place UPDATE. +# The table is vacuumed and the pages the queries below read are hinted +# before the checkpoint, so nothing logs a hint-bit image or flushes WAL +# between the UPDATE and the write, and the WAL writer is held at +# walwriter-before-background-flush: the page is written with the UPDATE's +# WAL still unflushed unless the page LSN names the UPDATE's record. The +# server crashes. The row is read right after the restart, and again after +# new write transactions, which take the xids the crash forgot. +$primary->safe_psql('postgres', 'CREATE TABLE evflush (i int)'); +my $evict = q{ +SELECT buffer_evicted FROM pg_buffercache_evict( + (SELECT bufferid FROM pg_buffercache + WHERE relfilenode = pg_relation_filenode('%s') AND relblocknumber = 0 + AND relforknumber = 0))}; +foreach my $am ('heap', 'flux') +{ + my $t = "ev_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, g, 'x' FROM generate_series(1, 10) g; +INSERT INTO evflush VALUES (0); +SELECT count(*) FROM $t; +SELECT count(*) FROM evflush; +VACUUM $t; +CHECKPOINT; +}); + foreach my $i (1 .. 2) + { + $primary->safe_psql('postgres', sprintf($evict, 'evflush')); + $primary->safe_psql('postgres', + "SELECT lsn FROM page_header(get_raw_page('$t', 0))"); + } + $primary->safe_psql('postgres', 'INSERT INTO evflush VALUES (1)'); + $primary->safe_psql('postgres', + "SELECT injection_points_attach('walwriter-before-background-flush', 'wait')" + ); + $primary->wait_for_event('walwriter', + 'walwriter-before-background-flush'); + + my $bg = $primary->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe( + "UPDATE $t SET v = -1, pad = repeat('u', 300) WHERE id = 3"); + my $upd_end = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + is( $primary->safe_psql( + 'postgres', + "SELECT pg_current_wal_flush_lsn() < '$upd_end'::pg_lsn"), + 't', + "$am: the UPDATE's WAL is not flushed yet"); + is($primary->safe_psql('postgres', sprintf($evict, $t)), + 't', "$am: the page was written out"); + note "$am: page written, WAL flushed to " + . $primary->safe_psql('postgres', 'SELECT pg_current_wal_flush_lsn()') + . ", UPDATE ends at $upd_end"; + $primary->stop('immediate'); + $bg->{run}->kill_kill; + $primary->start; + + my $check = sub { + my ($when) = @_; + my ($rc, $out, $err) = $primary->psql('postgres', + "SELECT id, v, pad FROM $t WHERE id = 3"); + is("$rc|$out", '0|3|3|x', "$am: $when: the committed row is back") + or diag "rc $rc, out '" . substr($out, 0, 30) . "', err $err"; + ($rc, $out, $err) = $primary->psql('postgres', + "SET enable_indexscan = off; SET enable_bitmapscan = off;" + . " SELECT count(*), sum(v) FROM $t"); + is("$rc|$out", '0|10|55', "$am: $when: every row is unchanged") + or diag "rc $rc, out '$out', err $err"; + }; + $check->('after the crash'); + foreach my $i (1 .. 3) + { + $primary->safe_psql('postgres', "INSERT INTO evflush VALUES ($i)"); + } + $check->('after new transactions'); + my ($rc, $out, $err) = + $primary->psql('postgres', "SELECT bt_index_check('${t}_pkey', true)"); + is($rc, 0, "$am: amcheck after recovery") or diag $err; +} + +# 3. Full-page images: one per page per checkpoint. The rows are hinted +# before the checkpoint and found through the index, so no hint-bit image +# sets the page LSN in between. Only images that replay applies count: +# wal_consistency_checking adds one to every record. +foreach my $am ('heap', 'flux') +{ + my $t = "fpi_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, 0, 'x' FROM generate_series(1, 10) g; +SELECT count(*) FROM $t; +CHECKPOINT; +}); + my $before = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + foreach my $i (1 .. 5) + { + $primary->safe_psql('postgres', + "SET enable_seqscan = off; SET enable_bitmapscan = off;" + . " UPDATE $t SET pad = repeat('g', 20 * $i) WHERE id = $i"); + } + my $fpis = $primary->safe_psql( + 'postgres', qq{ +SELECT count(*), coalesce(sum(block_fpi_length), 0), + pg_current_wal_insert_lsn() - '$before'::pg_lsn +FROM pg_get_wal_block_info('$before', pg_current_wal_insert_lsn(), false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND 'APPLY' = ANY (block_fpi_info)}); + note "$am: full-page images | their bytes | WAL bytes: $fpis"; + is( (split /\|/, $fpis)[0], + '1', + "$am: five growing UPDATEs after a checkpoint log one full-page image" + ); +} + +done_testing(); diff --git a/src/test/recovery/t/145_flux_slog_storage.pl b/src/test/recovery/t/145_flux_slog_storage.pl new file mode 100644 index 0000000000000..5adf100e6dae6 --- /dev/null +++ b/src/test/recovery/t/145_flux_slog_storage.pl @@ -0,0 +1,57 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# Storage replacement must not alias savepoint-aborted tuple markers. +use strict; +use warnings FATAL => "all"; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +my $node = PostgreSQL::Test::Cluster->new("slog"); +$node->init; +$node->append_conf("postgresql.conf", "autovacuum = off"); +$node->start; + +for my $inplace (0, 1) +{ + for my $ddl ( + "TRUNCATE t", + "ALTER TABLE t ALTER COLUMN id TYPE bigint", + "ALTER TABLE t SET ACCESS METHOD heap; ALTER TABLE t SET ACCESS METHOD AM" + ) + { + my @result; + for my $am ("heap", "flux") + { + my $change = $ddl; + $change =~ s/AM/$am/g; + my $create = "CREATE TABLE t(id int, w text) USING $am;"; + my $sql = ($inplace ? "BEGIN; $create" : "$create BEGIN;") . qq{ +SAVEPOINT a; +INSERT INTO t SELECT -g, repeat('x',100) FROM generate_series(1,20) g; +ROLLBACK TO a; +RELEASE a; +SELECT set_config('v3h.oldfile', pg_relation_filenode('t')::text, true) IS NOT NULL; +$change; +INSERT INTO t SELECT g, repeat('x',100) FROM generate_series(1,20) g; +SELECT pg_relation_filenode('t') = current_setting('v3h.oldfile')::oid; +SELECT count(*), sum(id) FROM t; +SELECT count(*) FROM (SELECT * FROM t FOR UPDATE) s; +WITH s AS (UPDATE t SET w = 'updated' RETURNING *) SELECT count(*) FROM s; +WITH s AS (DELETE FROM t RETURNING *) SELECT count(*) FROM s; +COMMIT; +DROP TABLE t; +}; + push @result, $node->safe_psql("postgres", $sql); + } + my $samefile = $inplace && $ddl eq "TRUNCATE t" ? 't' : 'f'; + # SET AM heap on heap is a no-op, unlike flux -> heap -> flux. + $result[0] =~ s/^t\n[tf]\n//; + like($result[1], qr/^t\n$samefile\n/, + "physical incarnation inplace=$inplace $ddl"); + $result[1] =~ s/^t\n[tf]\n//; + is($result[0], "20|210\n20\n20\n20", + "heap control inplace=$inplace $ddl"); + is($result[1], $result[0], "flux matches heap inplace=$inplace $ddl"); + } +} +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/146_flux_subxact_abort_dropped_storage.pl b/src/test/recovery/t/146_flux_subxact_abort_dropped_storage.pl new file mode 100644 index 0000000000000..6e53b0efb3816 --- /dev/null +++ b/src/test/recovery/t/146_flux_subxact_abort_dropped_storage.pl @@ -0,0 +1,184 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# ROLLBACK TO SAVEPOINT of a subtransaction that gave a relation new storage +# (CREATE TABLE, TRUNCATE) and then updated rows in it. The subtransaction's +# abort record drops that storage on redo, so every UNDO apply record for it +# must come before the abort record. FLUX used to write them after it: a +# streaming standby then replayed an UNDO apply record for a block of a file +# it had just unlinked and stopped with "WAL contains references to invalid +# pages", and so did crash recovery of the primary. Heap is the control, +# except for REINDEX in the subtransaction, which hit heap's index UNDO too. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +)); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE TABLE ${am}_keep (id int PRIMARY KEY, v int) USING $am; +INSERT INTO ${am}_keep SELECT g, 0 FROM generate_series(1, 10) g; +)); + + # Tables created in the subtransaction, written, updated, rolled back; + # the parent commits. This is the join_hash regression test's shape. + $node->safe_psql( + 'postgres', qq( +BEGIN; +INSERT INTO ${am}_keep VALUES (0, 0); +SAVEPOINT s; +CREATE TABLE ${am}_a (id int) USING $am; +CREATE TABLE ${am}_b (id int) USING $am; +INSERT INTO ${am}_a VALUES (1); +INSERT INTO ${am}_b VALUES (2); +UPDATE ${am}_b SET id = 2; +ROLLBACK TO s; +COMMIT; +)); + + # The same, with an index and a whole-transaction ROLLBACK after it. + $node->safe_psql( + 'postgres', qq( +BEGIN; +SAVEPOINT s; +CREATE TABLE ${am}_c (id int PRIMARY KEY, v int) USING $am; +INSERT INTO ${am}_c SELECT g, 0 FROM generate_series(1, 50) g; +UPDATE ${am}_c SET v = v + 1; +ROLLBACK TO s; +ROLLBACK; +)); + + # An existing table TRUNCATEd in the subtransaction: the new file is + # dropped by the subtransaction's abort, the old one comes back. + $node->safe_psql( + 'postgres', qq( +BEGIN; +INSERT INTO ${am}_keep VALUES (-1, 0); +SAVEPOINT s; +TRUNCATE ${am}_keep; +INSERT INTO ${am}_keep SELECT g, 1 FROM generate_series(1, 6) g; +UPDATE ${am}_keep SET v = 2; +ROLLBACK TO s; +COMMIT; +)); + + # Rows written in the subtransaction to the old index files, then a + # REINDEX: the index UNDO names the index by OID, which now maps to the + # REINDEXed file the abort drops. heap's index UNDO (the cluster-wide + # engine) and FLUX's in-place index reversal both have to leave it alone. + $node->safe_psql( + 'postgres', qq( +CREATE INDEX ${am}_keep_v ON ${am}_keep (v); +BEGIN; +INSERT INTO ${am}_keep VALUES (-2, 0); +SAVEPOINT s; +INSERT INTO ${am}_keep SELECT g, g FROM generate_series(100, 110) g; +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +REINDEX TABLE ${am}_keep; +ROLLBACK TO s; +COMMIT; +)); + + foreach my $scope ('top', 'sub') + { + my $savepoint = $scope eq 'sub' ? 'SAVEPOINT s;' : ''; + my $rollback = + $scope eq 'sub' ? 'ROLLBACK TO s; COMMIT;' : 'ROLLBACK;'; + $node->safe_psql( + 'postgres', qq( +BEGIN; +$savepoint +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +REINDEX TABLE ${am}_keep; +$rollback +)); + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('${am}_keep_v', true)"), + '', + "$am: $scope rollback restores the old index file"); + } + + # DROP INDEX: the index UNDO names an index the subtransaction dropped, + # which the relcache cannot open until the subtransaction's catalog + # changes are rolled back. Left unreversed, the index keeps the aborted + # (k_new, TID) entry live, and moving the row there again inserts a + # second entry with the same key and TID. + $node->safe_psql( + 'postgres', qq( +BEGIN; +SAVEPOINT s; +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +DROP INDEX ${am}_keep_v; +ROLLBACK TO s; +COMMIT; +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +UPDATE ${am}_keep SET v = v - 1000 WHERE id BETWEEN 1 AND 5; +)); + is( $node->psql( + 'postgres', "SELECT bt_index_check('${am}_keep_v', true)"), + 0, + "$am: savepoint rollback of DROP INDEX restores the index"); + + is( $node->safe_psql( + 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), + '13|0', + "$am: the parent's rows survive the savepoint rollbacks"); + is( $node->safe_psql( + 'postgres', qq( +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM ${am}_keep WHERE v >= 0; +)), + '13', + "$am: the index has exactly the parent's rows"); +} + +$node->wait_for_replay_catchup($standby); +is($standby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'), + 't', 'standby replayed the savepoint rollbacks'); +foreach my $am ('heap', 'flux') +{ + is( $standby->safe_psql( + 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), + '13|0', + "$am: standby has the parent's rows"); +} +unlike( + slurp_file($standby->logfile), + qr/invalid pages|does not exist.*CONTEXT: WAL redo at .* Undo|PANIC|inconsistent page found/s, + 'standby log has no invalid-page reference or PANIC'); + +# Crash recovery of the primary replays the same WAL from the backup point. +$node->stop('immediate'); +$node->start; +foreach my $am ('heap', 'flux') +{ + is( $node->safe_psql( + 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), + '13|0', + "$am: rows after crash recovery"); +} +unlike( + slurp_file($node->logfile), + qr/invalid pages|PANIC/, + 'primary crash recovery found no invalid page'); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/147_flux_repack_concurrently.pl b/src/test/recovery/t/147_flux_repack_concurrently.pl new file mode 100644 index 0000000000000..c9d45cc5cc064 --- /dev/null +++ b/src/test/recovery/t/147_flux_repack_concurrently.pl @@ -0,0 +1,517 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# REPACK (CONCURRENTLY) of a FLUX table across a crash, on a standby, and at +# wal_level = replica, each next to a heap twin that runs the same statements. +# +# Crash: the server is stopped in immediate mode while REPACK +# - slot: has its decoding worker create the replication slot +# (replication-slot-create-begin), before the initial snapshot; +# - copied: has copied the table and built the new indexes, and waits at +# repack-concurrently-before-lock while other sessions change the table; +# - caught_up: has replayed those changes onto the new table (FLUX changes +# the new rows in place, with undo, as REPACK's transaction) and waits for +# its AccessExclusiveLock; +# - commit: has flushed its commit record (commit-before-clog-update); +# - committed: has returned. +# After recovery the table and its indexes must hold exactly the rows that +# were committed before the crash, in the new file only if REPACK's commit +# record was written, and nothing of the transient table may be left: no +# catalog entry, no file, no slot, no undo holding WAL back. +# +# Standby: a FLUX REPACK (CONCURRENTLY) is replayed. A repeatable read +# transaction on the standby that read the table before the swap holds the +# replay back and keeps reading the old rows; once it ends, the standby has +# the primary's new file, rows and intact indexes. +# +# wal_level = replica: REPACK enables logical decoding itself while a +# transaction that wrote the table's FLUX records without logical images is +# still open. The snapshot builder waits for it; its changes must reach the +# copy and never the decoding, both when it commits and when it aborts. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# Replace the table name T in $sql. +sub sub_t +{ + my ($sql, $t) = @_; + (my $x = $sql) =~ s/\bT(?=\b|_)/$t/g; + return $x; +} + +my $setup = q( +CREATE TABLE T (id int PRIMARY KEY, k int, v text) USING AM; +ALTER TABLE T ALTER COLUMN v SET STORAGE EXTERNAL; +CREATE INDEX T_k_idx ON T (k); +INSERT INTO T SELECT g, g, CASE WHEN g % 10 = 0 THEN repeat(md5(g::text), 100) + ELSE 'v' || g END FROM generate_series(1, 200) g; +DELETE FROM T WHERE id % 7 = 0; +UPDATE T SET v = v || '+' WHERE id % 5 = 0; +UPDATE T SET k = k + 1000 WHERE id % 11 = 0; +); + +# Committed while REPACK runs: INSERT; UPDATE in place, of an indexed +# column, of the key, growing, of a TOAST value; DELETE; and an INSERT that +# is updated and deleted again. +my $dml = q( +INSERT INTO T SELECT g, g, 'n' || g FROM generate_series(301, 320) g; +UPDATE T SET v = 'u' || id WHERE id BETWEEN 1 AND 6; +UPDATE T SET k = k + 2000 WHERE id BETWEEN 8 AND 12; +UPDATE T SET id = id + 1000 WHERE id BETWEEN 15 AND 18; +UPDATE T SET v = repeat('g', 300) WHERE id = 19; +UPDATE T SET v = repeat(md5('t'), 100) WHERE id = 20; +DELETE FROM T WHERE id BETWEEN 22 AND 26; +INSERT INTO T VALUES (400, 400, 'x'); +UPDATE T SET v = 'y' WHERE id = 400; +DELETE FROM T WHERE id = 400; +); + +# More DML once the table has been repacked. +my $after = q( +UPDATE T SET v = v || 'a'; +DELETE FROM T WHERE id % 3 = 0; +INSERT INTO T SELECT g, g, 'a' FROM generate_series(501, 510) g; +UPDATE T SET k = k + 1 WHERE id IN (1, 2, 501); +); + +sub create_twins +{ + my ($node, $prefix) = @_; + foreach my $am ('flux', 'heap') + { + my $t = $prefix . '_' . substr($am, 0, 1); + (my $s = $setup) =~ s/\bAM\b/$am/; + $node->safe_psql('postgres', sub_t($s, $t)); + } +} + +# Rows of a table as count, sum and md5, through a seqscan and through the +# primary key. +sub content_sql +{ + my ($t) = @_; + my $q = + "SELECT count(*) || ':' || coalesce(sum(k), 0) || ':' || md5(coalesce(string_agg(x::text, ',' ORDER BY id), '')) FROM $t x"; + return + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q;\n" + . "SET enable_seqscan = off; SET enable_indexscan = on; $q;\n" + . "RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan;"; +} + +sub content +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', content_sql($t)); +} + +# bt_index_check (heapallindexed) reports corruption with an ERROR: returns +# psql's stderr, empty on success. +sub bt_check +{ + my ($node, $rel) = @_; + my ($out, $err); + my $rc = $node->psql( + 'postgres', + "SELECT bt_index_check('$rel'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $rc == 0 && $err eq '' ? '' : "rc=$rc: $err"; +} + +# Indexes intact, every key found by a unique check (primary only), and the +# heap twin passes verify_heapam. +sub check_table +{ + my ($node, $t, $what) = @_; + my @idx = split /\n/, + $node->safe_psql('postgres', + "SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = '$t'::regclass ORDER BY 1" + ); + is(join(',', @idx), "${t}_k_idx,${t}_pkey", "$t: both indexes, $what"); + is(bt_check($node, $_), '', "$_: bt_index_check, $what") foreach @idx; + is( $node->safe_psql( + 'postgres', + "WITH i AS (INSERT INTO $t SELECT * FROM $t ON CONFLICT DO NOTHING RETURNING 1) SELECT count(*) FROM i" + ), + '0', + "$t: unique checks see every row, $what") + unless $node->safe_psql('postgres', 'SELECT pg_is_in_recovery()') eq + 't'; + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM verify_heapam('$t')"), + '0', + "$t: verify_heapam, $what") if $t =~ /_h$/; +} + +sub filenode +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', "SELECT pg_relation_filenode('$t')"); +} + +# Files of the database that no relation owns, after a checkpoint has +# removed the files of dropped relations. +sub orphans +{ + my ($node) = @_; + $node->safe_psql('postgres', 'CHECKPOINT'); + return $node->safe_psql( + 'postgres', q( +SELECT count(*) FROM pg_ls_dir('base/' || (SELECT oid FROM pg_database + WHERE datname = current_database())) f +WHERE f ~ '^[0-9]+$' AND pg_filenode_relation(0, f::oid) IS NULL)); +} + +sub wait_pid +{ + my ($node, $pid, $cond, $what) = @_; + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 1 FROM pg_stat_activity WHERE pid = $pid AND $cond" + ), + $what); +} + +# Start REPACK (CONCURRENTLY) of $t in a background session that stops at +# the injection points in @points (attached for that session only). +sub start_repack +{ + my ($node, $t, @points) = @_; + my $rp = $node->background_psql('postgres'); + my $pid = $rp->query_safe('SELECT pg_backend_pid()'); + $rp->query_safe('SELECT injection_points_set_local()') if @points; + $rp->query_safe("SELECT injection_points_attach('$_', 'wait')") + foreach @points; + $rp->query_until(qr/started/, + "\\echo started\nREPACK (CONCURRENTLY) $t;\n\\echo repacked\n"); + return ($rp, $pid); +} + +# Wait for the background REPACK to return; true if it succeeded. +sub finish_repack +{ + my ($rp) = @_; + $rp->query_until(qr/repacked/, ''); + my $err = $rp->{stderr}; + $rp->quit; + return $err; +} + +# Is any undo, or any ATM entry, still holding WAL back? +my $retained = q( +SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0 + AND (SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'permanent')); + +########################################################################### +# Crash during REPACK (CONCURRENTLY) +########################################################################### +my $node = PostgreSQL::Test::Cluster->new('crash'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points or amcheck not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION amcheck;'); + +like( + bt_check($node, 'pg_class'), + qr/ERROR: "pg_class" is not an index/, + 'control: bt_index_check of a table fails'); + +my @stages = qw(slot copied caught_up commit committed); +create_twins($node, "c_$_") foreach @stages; + +sub crash_during_repack +{ + my ($stage, $t) = @_; + my $oid = $node->safe_psql('postgres', "SELECT '$t'::regclass::oid"); + my $fn0 = filenode($node, $t); + my $orph0 = orphans($node); + my ($rp, $pid, $lk); + + if ($stage eq 'slot') + { + # The decoding worker creates the slot: attach for every process. + $node->safe_psql('postgres', + "SELECT injection_points_attach('replication-slot-create-begin', 'wait')" + ); + ($rp, $pid) = start_repack($node, $t); + $node->wait_for_event('REPACK decoding worker', + 'replication-slot-create-begin'); + } + else + { + ($rp, $pid) = start_repack( + $node, $t, + 'repack-concurrently-before-lock', + $stage eq 'commit' ? ('commit-before-clog-update') : ()); + wait_pid( + $node, $pid, + "wait_event = 'repack-concurrently-before-lock'", + "$t: REPACK copied the table"); + } + # Control for the slot check below. + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_replication_slots WHERE slot_name LIKE 'pg_repack_%'" + ), + '1', + "$t: control: REPACK's slot exists before the crash" + ) unless $stage eq 'slot'; + $node->safe_psql('postgres', sub_t($dml, $t)); + my $expect = content($node, $t); + + if ($stage eq 'caught_up') + { + $lk = $node->background_psql('postgres'); + $lk->query_safe("BEGIN; LOCK TABLE $t IN ACCESS SHARE MODE"); + } + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('repack-concurrently-before-lock')") + unless $stage eq 'slot'; + if ($stage eq 'caught_up') + { + wait_pid( + $node, $pid, + "wait_event_type = 'Lock' AND wait_event = 'relation'", + "$t: REPACK replayed the changes and waits for its lock"); + is( $node->safe_psql( + 'postgres', + "SELECT phase, heap_tuples_inserted > 0, heap_tuples_updated > 0, heap_tuples_deleted > 0 FROM pg_stat_progress_repack WHERE pid = $pid" + ), + 'catch-up|t|t|t', + "$t: control: REPACK replayed inserts, updates and deletes"); + } + elsif ($stage eq 'commit') + { + wait_pid( + $node, $pid, + "wait_event = 'commit-before-clog-update'", + "$t: REPACK flushed its commit record"); + } + elsif ($stage eq 'committed') + { + is(finish_repack($rp), '', "$t: REPACK succeeded"); + } + + $node->stop('immediate'); + $rp->{run}->kill_kill if $stage ne 'committed'; + $lk->{run}->kill_kill if $lk; + $node->start; + + my $what = "after a crash at $stage"; + if ($stage eq 'commit' || $stage eq 'committed') + { + isnt(filenode($node, $t), $fn0, "$t: REPACK committed, $what"); + } + else + { + is(filenode($node, $t), $fn0, "$t: REPACK rolled back, $what"); + } + is(content($node, $t), $expect, "$t: the committed rows, $what"); + check_table($node, $t, $what); + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_class WHERE relname = 'pg_temp_$oid'"), + '0', + "$t: no transient relation left, $what"); + is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM pg_replication_slots'), + '0', + "$t: no slot left, $what"); + ok( $node->poll_query_until('postgres', $retained), + "$t: no undo holding WAL back, $what"); + return orphans($node) - $orph0; +} + +# A crash leaves the files of a relation that an uncommitted transaction +# created behind (for heap as well; the same holds for a plain CREATE TABLE), +# so only the committed stages must leave nothing. +foreach my $stage (@stages) +{ + my %left = + map { $_ => crash_during_repack($stage, "c_${stage}_$_") } ('f', 'h'); + if ($stage eq 'commit' || $stage eq 'committed') + { + is($left{$_}, 0, "c_${stage}_$_: no file left behind after a crash") + foreach ('f', 'h'); + } + else + { + is($left{f}, $left{h}, + "c_${stage}_f: leaves as many files behind as its heap twin"); + # Control: the transient table's files are counted. + cmp_ok($left{h}, '>', 0, + "c_${stage}_h: control: the transient table's files are left") + unless $stage eq 'slot'; + } + is( content($node, "c_${stage}_f"), + content($node, "c_${stage}_h"), + "c_${stage}_f: same rows as its heap twin after a crash at $stage"); +} + +# Both tables can be repacked again and changed afterwards. +foreach my $stage (@stages) +{ + foreach my $am ('f', 'h') + { + my $t = "c_${stage}_$am"; + $node->safe_psql('postgres', "REPACK (CONCURRENTLY) $t"); + $node->safe_psql('postgres', sub_t($after, $t)); + check_table($node, $t, 'repacked again after the crash'); + } + is( content($node, "c_${stage}_f"), + content($node, "c_${stage}_h"), + "c_${stage}_f: same rows as its heap twin, repacked again"); +} + +########################################################################### +# wal_level = replica: an open transaction wrote the table without logical +# images when REPACK enables logical decoding. +########################################################################### +# Before logical decoding is enabled: a same-size UPDATE (the byte-diff +# path), an UPDATE of an indexed column, an INSERT and a DELETE. +my $xbefore = q( +UPDATE T SET v = 'w' || substr(v, 2) WHERE id BETWEEN 1 AND 4; +UPDATE T SET k = k + 5000 WHERE id = 5; +INSERT INTO T VALUES (601, 601, 'x601'); +DELETE FROM T WHERE id = 6; +); +# The same transaction, after REPACK enabled it. +my $xafter = q( +UPDATE T SET v = 'z' || substr(v, 2) WHERE id BETWEEN 1 AND 2; +INSERT INTO T VALUES (602, 602, 'x602'); +DELETE FROM T WHERE id = 8; +); +foreach my $end ('commit', 'rollback') +{ + create_twins($node, "w_$end"); + foreach my $am ('f', 'h') + { + my $t = "w_${end}_$am"; + ok( $node->poll_query_until( + 'postgres', + "SELECT current_setting('effective_wal_level') = 'replica'"), + "$t: logical decoding is off"); + my $fn0 = filenode($node, $t); + my $x = $node->background_psql('postgres'); + $x->query_safe("BEGIN;\n" . sub_t($xbefore, $t)); + my ($rp, $pid) = start_repack($node, $t); + $node->wait_for_event('REPACK decoding worker', 'transactionid'); + is( $node->safe_psql( + 'postgres', "SELECT current_setting('effective_wal_level')"), + 'logical', + "$t: REPACK enabled logical decoding, waits for the open transaction" + ); + $x->query_safe(sub_t($xafter, $t) . "\n$end"); + $x->quit; + is(finish_repack($rp), '', "$t: REPACK succeeded"); + isnt(filenode($node, $t), $fn0, "$t: repacked"); + check_table($node, $t, + "transaction open across REPACK's start, $end"); + } + is( content($node, "w_${end}_f"), + content($node, "w_${end}_h"), + "w_${end}_f: same rows as its heap twin"); + ok($node->poll_query_until('postgres', $retained), + "w_$end: no undo holding WAL back"); +} + +########################################################################### +# Standby +########################################################################### +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +)); +$primary->start; +$primary->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION amcheck;'); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->append_conf('postgresql.conf', 'max_standby_streaming_delay = -1'); +$standby->start; + +create_twins($primary, 's'); +$primary->wait_for_replay_catchup($standby); +foreach my $am ('f', 'h') +{ + my $t = "s_$am"; + my $sb = $standby->background_psql('postgres'); + $sb->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + my $old = $sb->query_safe(content_sql($t)); + my $fn0 = filenode($primary, $t); + is($old, content($primary, $t), "$t: standby transaction reads the rows"); + + my ($rp, $pid) = + start_repack($primary, $t, 'repack-concurrently-before-lock'); + wait_pid( + $primary, $pid, + "wait_event = 'repack-concurrently-before-lock'", + "$t: REPACK copied the table"); + $primary->safe_psql('postgres', sub_t($dml, $t)); + $primary->safe_psql('postgres', + "SELECT injection_points_wakeup('repack-concurrently-before-lock')"); + is(finish_repack($rp), '', "$t: REPACK succeeded"); + isnt(filenode($primary, $t), $fn0, "$t: repacked"); + + ok( $standby->poll_query_until( + 'postgres', + "SELECT count(*) = 1 FROM pg_stat_activity WHERE backend_type = 'startup' AND wait_event_type = 'Lock' AND wait_event = 'relation'" + ), + "$t: replay of the swap waits for the standby transaction"); + is($sb->query_safe(content_sql($t)), + $old, "$t: the standby transaction still reads the old rows"); + $sb->query_safe('COMMIT'); + $sb->quit; + + $primary->wait_for_replay_catchup($standby); + is( filenode($standby, $t), + filenode($primary, $t), + "$t: standby has the new file"); + is( content($standby, $t), + content($primary, $t), + "$t: standby has the primary's rows"); + check_table($standby, $t, 'on the standby'); + check_table($primary, $t, 'on the primary'); +} +is( content($primary, 's_f'), + content($primary, 's_h'), + 's_f: same rows as its heap twin'); +is( content($standby, 's_f'), + content($standby, 's_h'), + 's_f: same rows as its heap twin on the standby'); + +foreach my $n ($node, $primary, $standby) +{ + unlike( + slurp_file($n->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + $n->name . ': no failed assertion, mismatched or abandoned undo'); +} +$standby->stop; +$primary->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/148_flux_pg_visibility.pl b/src/test/recovery/t/148_flux_pg_visibility.pl new file mode 100644 index 0000000000000..a67ff3fed5bff --- /dev/null +++ b/src/test/recovery/t/148_flux_pg_visibility.pl @@ -0,0 +1,87 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# pg_visibility's pg_check_visible and pg_check_frozen decode each item of a +# page marked all-visible or all-frozen as a heap tuple header. On a FLUX +# table they must refuse, as pgstattuple, pgrowlocks, pg_surgery and +# amcheck's verify_heapam do. They used to read the FLUX items as heap +# tuples: every row of every all-visible page was reported corrupt, and with +# an xid open in another database the backend failed an assertion in +# HeapTupleSatisfiesVacuum (pg_visibility's own 001_concurrent_transaction +# setup). Heap is the control. The functions that read only the visibility +# map and the page header keep working on a FLUX table. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql('postgres', 'CREATE DATABASE other_database'); +my $bsession = $node->background_psql('other_database'); +$bsession->query_safe('BEGIN; SELECT txid_current();'); + +$node->safe_psql('postgres', 'CREATE EXTENSION pg_visibility'); +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE TABLE vt_$am USING $am AS SELECT 42 i; +CREATE TABLE vf_$am USING $am AS SELECT g FROM generate_series(1, 2000) g; +VACUUM (disable_page_skipping) vt_$am; +VACUUM (FREEZE, disable_page_skipping) vf_$am; +)); +} + +# Heap: nothing to report. +foreach my $t ('vt_heap', 'vf_heap') +{ + foreach my $f ('pg_check_visible', 'pg_check_frozen') + { + is($node->safe_psql('postgres', "SELECT * FROM $f('$t')"), + '', "$f on heap table $t reports nothing"); + } +} + +# FLUX: a clean error, and the server stays up. +foreach my $t ('vt_flux', 'vf_flux') +{ + foreach my $f ('pg_check_visible', 'pg_check_frozen') + { + my ($ret, $out, $err) = + $node->psql('postgres', "SELECT * FROM $f('$t')"); + like( + $err, + qr/only heap AM is supported/, + "$f on FLUX table $t is refused"); + is($out, '', "$f on FLUX table $t returns no TIDs"); + } +} +is($node->safe_psql('postgres', 'SELECT 1'), '1', 'server still up'); +my $log = slurp_file($node->logfile); +unlike($log, qr/TRAP:|terminated by signal/, 'no backend crashed'); + +# The visibility map functions read no tuples: they work for FLUX too. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) = pg_relation_size('vf_flux') / current_setting('block_size')::int + FROM pg_visibility('vf_flux')"), + 't', + 'pg_visibility covers every block of a FLUX table'); +is( $node->safe_psql( + 'postgres', + "SELECT all_visible > 0 AND all_frozen > 0 FROM pg_visibility_map_summary('vf_flux')" + ), + 't', + 'pg_visibility_map_summary sees the FLUX table frozen by VACUUM (FREEZE)' +); + +$bsession->query_safe('COMMIT'); +$bsession->quit; +$node->stop; + +done_testing(); diff --git a/src/test/recovery/t/149_flux_partial_index_member.pl b/src/test/recovery/t/149_flux_partial_index_member.pl new file mode 100644 index 0000000000000..db920b1bdca33 --- /dev/null +++ b/src/test/recovery/t/149_flux_partial_index_member.pl @@ -0,0 +1,196 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A partial index answers a query only for rows that satisfy its predicate: +# the planner drops a qual the predicate implies, and a unique or exclusion +# check counts only member rows. A FLUX in-place UPDATE keeps the row at +# its TID, so a row whose membership changes keeps the entry its earlier +# version had, delete-marked, and the reader's recheck compares only the +# entry's key with the visible version's. Every query here has a heap +# control; amcheck runs on every index afterwards. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_partial_index_member'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); + +# Run a query with index scans forced (no seqscan, no bitmap scan). +sub idx +{ + my ($sql) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; SET enable_sort = off; +$sql}); +} + +# The same through a bitmap heap scan. +sub bitmap +{ + my ($sql, $work_mem) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_indexscan = off; +SET enable_indexonlyscan = off; SET work_mem = '$work_mem'; +$sql}); +} + +sub check_indexes +{ + my ($tab) = @_; + my $res = $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(c.relname, ',') FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am a ON a.oid = c.relam + WHERE i.indrelid = '$tab'::regclass AND a.amname = 'btree' + AND NOT i.indisexclusion}); + my $err = ''; + foreach my $idx (split /,/, $res) + { + my ($rc, $out, $e) = $node->psql('postgres', + "SELECT bt_index_parent_check('$idx', true, true, true)"); + $err .= $e; + } + return $err; +} + +foreach my $am ('heap', 'flux') +{ + # A row leaves the index's predicate, its key unchanged. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE l_$am (id int, n int, active bool) USING $am; +CREATE INDEX l_${am}_i ON l_$am (n) WHERE active; +INSERT INTO l_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE l_$am SET active = false WHERE id IN (1, 3); +}); + is(idx("SELECT id, active FROM l_$am WHERE n = 3 AND active"), + '', "$am: a row that left the predicate is not found by its key"); + is( idx("SELECT count(*) FROM l_$am WHERE n BETWEEN 1 AND 5 AND active"), + '3', "$am: nor by a range scan"); + is(idx("SELECT min(n) FROM l_$am WHERE active"), + '2', "$am: min() through the partial index"); + is(idx("SELECT id FROM l_$am WHERE active ORDER BY n LIMIT 1"), + '2', "$am: ORDER BY through the partial index"); + is( idx(qq{SET enable_hashjoin = off; SET enable_nestloop = off; +SELECT count(*) FROM l_$am a JOIN generate_series(1, 5) g ON a.n = g + WHERE a.active}), + '3', + "$am: merge join through the partial index"); + is(bitmap("SELECT count(*) FROM l_$am WHERE n <= 5 AND active", '4MB'), + '3', "$am: exact bitmap scan"); + is(check_indexes("l_$am"), '', "$am: amcheck after leaving"); + + # Lossy bitmap pages: enough rows that 64kB cannot keep every TID. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE lb_$am (id int, n int, active bool) USING $am; +CREATE INDEX lb_${am}_i ON lb_$am (n) WHERE active; +INSERT INTO lb_$am SELECT g, g % 10, true FROM generate_series(1, 100000) g; +UPDATE lb_$am SET active = false WHERE id % 10 = 3; +}); + is( bitmap( + "SELECT count(*), count(*) FILTER (WHERE NOT active) FROM lb_$am WHERE n = 3 AND active", + '64kB'), + '0|0', + "$am: lossy bitmap scan"); + + # A row leaves the predicate and changes its key. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE lk_$am (id int, n int, active bool) USING $am; +CREATE INDEX lk_${am}_i ON lk_$am (n) WHERE active; +INSERT INTO lk_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE lk_$am SET active = false, n = 30 WHERE id = 3; +}); + is(idx("SELECT count(*) FROM lk_$am WHERE n IN (3, 30) AND active"), + '0', "$am: left the predicate with another key"); + is(check_indexes("lk_$am"), '', "$am: amcheck after leaving with a key"); + + # A predicate on another column, through an expression. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE le_$am (id int, n int) USING $am; +CREATE INDEX le_${am}_i ON le_$am (id) WHERE n % 2 = 1; +INSERT INTO le_$am SELECT g, g FROM generate_series(1, 5) g; +UPDATE le_$am SET n = 4 WHERE id = 3; +}); + is(idx("SELECT id, n FROM le_$am WHERE id = 3 AND n % 2 = 1"), + '', "$am: left an expression predicate"); + is(check_indexes("le_$am"), '', "$am: amcheck after an expression left"); + + # A snapshot that sees the row outside the predicate, after it came + # back with the same key. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE r_$am (id int, n int, active bool) USING $am; +CREATE INDEX r_${am}_i ON r_$am (n) WHERE active; +INSERT INTO r_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE r_$am SET active = false WHERE id = 3; +}); + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM r_$am"); + $node->safe_psql('postgres', + "UPDATE r_$am SET active = true WHERE id = 3"); + is( $rr->query_safe( + qq{SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT id, active FROM r_$am WHERE n = 3 AND active}), + '', + "$am: snapshot from outside the predicate after re-entry"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(idx("SELECT id, active FROM r_$am WHERE n = 3 AND active"), + '3|t', "$am: re-entered row is found"); + is(check_indexes("r_$am"), '', "$am: amcheck after re-entry"); + + # A partial unique index counts member rows only. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE u_$am (id int, n int, active bool) USING $am; +CREATE UNIQUE INDEX u_${am}_i ON u_$am (n) WHERE active; +INSERT INTO u_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE u_$am SET active = false WHERE id IN (2, 3); +}); + my ($rc, $out, $err) = + $node->psql('postgres', "INSERT INTO u_$am VALUES (13, 3, true)"); + is($rc . $err, '0', "$am: unique key free after its row left"); + is( $node->safe_psql( + 'postgres', qq{ +INSERT INTO u_$am VALUES (12, 2, true) ON CONFLICT (n) WHERE active DO NOTHING +RETURNING id}), + '12', + "$am: ON CONFLICT finds no conflict for a row that left"); + ($rc, $out, $err) = + $node->psql('postgres', "UPDATE u_$am SET active = true WHERE id = 3"); + like( + $err, + qr/duplicate key value/, + "$am: a row coming back to a taken key conflicts"); + is(check_indexes("u_$am"), '', "$am: amcheck after unique membership"); + + # An exclusion constraint with a predicate, and a hash index. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE x_$am (id int, n int, active bool, + EXCLUDE USING btree (n WITH =) WHERE (active)) USING $am; +CREATE INDEX x_${am}_h ON x_$am USING hash (n) WHERE active; +INSERT INTO x_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE x_$am SET active = false WHERE id = 3; +}); + ($rc, $out, $err) = + $node->psql('postgres', "INSERT INTO x_$am VALUES (13, 3, true)"); + is($rc . $err, '0', "$am: exclusion constraint free after its row left"); + is(idx("SELECT id FROM x_$am WHERE n = 3 AND active"), + '13', "$am: hash partial index finds the member only"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/150_flux_vm_stale_size.pl b/src/test/recovery/t/150_flux_vm_stale_size.pl new file mode 100644 index 0000000000000..bd6cece89123a --- /dev/null +++ b/src/test/recovery/t/150_flux_vm_stale_size.pl @@ -0,0 +1,121 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A change to an all-visible page must clear the page's visibility map bits +# even if the map was created after the changing backend last looked at its +# size. A backend processes the invalidation that announces the new map +# only when it next takes a new lock, so one statement can look at the map +# (it has none), wait while VACUUM creates it and makes pages all-visible, +# and then change those pages. The statement is a plpgsql function whose +# plans are already cached, and it waits on an advisory lock, so it takes +# no relation lock in between (a subtransaction that ends would release +# its lock, and the next statement's would process the invalidation). heap is the control; it pins the map with +# extension, which reads the size again. +# +# A bit left set wrongly would let a VACUUM that skips all-frozen pages +# leave the change's xid behind relfrozenxid; that check is a guard. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_vm_stale_size'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pg_visibility'); + +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE t_$am (id int, v int) USING $am; +INSERT INTO t_$am SELECT g, g FROM generate_series(1, 2000) g; +CREATE FUNCTION change_$am(t1 tid, t2 tid) RETURNS void LANGUAGE plpgsql AS \$\$ +BEGIN + UPDATE t_$am SET v = v + 1 WHERE ctid = '(0,1)'; + PERFORM pg_advisory_lock(1); + UPDATE t_$am SET v = -1 WHERE ctid = t1; + DELETE FROM t_$am WHERE ctid = t2; + PERFORM pg_advisory_unlock(1); +END \$\$; +}); + my %id; + foreach my $blk (1, 2) + { + $id{$blk} = $node->safe_psql('postgres', + "SELECT id FROM t_$am WHERE ctid = '($blk,1)'"); + } + is( $node->safe_psql( + 'postgres', "SELECT pg_relation_size('t_$am', 'vm')"), + '0', + "$am: no visibility map yet"); + + # Cache the function's plans first. + my $w = $node->background_psql('postgres'); + $w->query_safe("BEGIN; SELECT change_$am('(1,2)', '(2,2)'); ROLLBACK"); + + my $holder = $node->background_psql('postgres'); + $holder->query_safe('SELECT pg_advisory_lock(1)'); + $w->query_safe('BEGIN'); + $w->query_until(qr/started/, + "\\echo started\nSELECT change_$am('(1,1)', '(2,1)');\n"); + $node->poll_query_until('postgres', + "SELECT count(*) > 0 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted" + ) or die "the change did not wait"; + $node->safe_psql('postgres', "VACUUM (FREEZE) t_$am"); + is( $node->safe_psql( + 'postgres', + "SELECT all_visible, all_frozen, pd_all_visible FROM pg_visibility('t_$am', 1)" + ), + 't|t|t', + "$am: VACUUM made block 1 all-frozen"); + $holder->query_safe('SELECT pg_advisory_unlock(1)'); + $holder->quit; + $w->query_safe('SELECT 1'); + + foreach my $blk (1, 2) + { + is( $node->safe_psql( + 'postgres', + "SELECT all_visible, all_frozen, pd_all_visible FROM pg_visibility('t_$am', $blk)" + ), + 'f|f|f', + "$am: change in progress cleared block $blk"); + } + is( $node->safe_psql( + 'postgres', + "SELECT count(*), sum(v) FROM t_$am WHERE id IN ($id{1}, $id{2})" + ), + "2|" . ($id{1} + $id{2}), + "$am: a seqscan does not see the changes in progress"); + $w->query_safe('COMMIT'); + $w->quit; + is( $node->safe_psql( + 'postgres', + "SELECT count(*), sum(v) FROM t_$am WHERE id IN ($id{1}, $id{2})" + ), + '1|-1', + "$am: a seqscan sees the committed changes"); + + # A VACUUM that skips all-frozen pages must not skip the page with the + # UPDATE, or relfrozenxid passes its xid. + my ($xmin) = $node->safe_psql('postgres', + "SELECT xmin FROM t_$am WHERE id = $id{1}"); + $node->safe_psql('postgres', "VACUUM t_$am"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT age('$xmin'::xid) <= age(relfrozenxid) FROM pg_class + WHERE relname = 't_$am'}), + 't', + "$am: relfrozenxid does not pass the xid of the UPDATE"); + $node->safe_psql('postgres', "VACUUM (FREEZE) t_$am"); + is( $node->safe_psql( + 'postgres', + "SELECT all_visible, all_frozen FROM pg_visibility('t_$am', 1)"), + 't|t', + "$am: VACUUM froze block 1 again"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/151_flux_vm_set_fpi.pl b/src/test/recovery/t/151_flux_vm_set_fpi.pl new file mode 100644 index 0000000000000..67170bdeee8f9 --- /dev/null +++ b/src/test/recovery/t/151_flux_vm_set_fpi.pl @@ -0,0 +1,91 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# VACUUM's visibility map changes must survive crash recovery and replay. +# The first change to a map page after a checkpoint is logged with a +# full-page image. The map lies between pd_lower and pd_upper, the hole a +# standard page image leaves out, so a map page registered as standard is +# restored without its bits. Heap registers map pages as non-standard. +# +# For each AM: a checkpoint, then VACUUM (FREEZE) sets the bits of a new +# map (its first change since the checkpoint), compared on a standby; then +# a change clears one page's bits, another checkpoint, VACUUM (FREEZE) sets +# them again, compared on the standby and after crash recovery. heap is +# the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', "autovacuum = off\n"); +$primary->start; +$primary->safe_psql('postgres', + 'CREATE EXTENSION pg_visibility; CREATE TABLE flushed (i int)'); +$primary->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->start; + +sub bits +{ + my ($node, $t) = @_; + return $node->safe_psql( + 'postgres', qq{ +SELECT count(*) || ' pages, ' || count(*) FILTER (WHERE all_visible) + || ' all-visible, ' || count(*) FILTER (WHERE all_frozen) || ' all-frozen' + FROM pg_visibility_map('$t')}); +} + +# A VACUUM's WAL is not flushed at its end; a committed write flushes it. +sub flush_wal +{ + $primary->safe_psql('postgres', 'INSERT INTO flushed VALUES (1)'); +} + +my %all; +foreach my $am ('heap', 'flux') +{ + my $t = "vs_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, pad text) USING $am; +INSERT INTO $t SELECT g, repeat('x', 100) FROM generate_series(1, 150) g; +}); + $primary->safe_psql('postgres', 'CHECKPOINT'); + $primary->safe_psql('postgres', "VACUUM (FREEZE) $t"); + $all{$am} = bits($primary, $t); + like( + $all{$am}, + qr/^(\d+) pages, \1 all-visible, \1 all-frozen$/, + "$am: VACUUM set the bits: $all{$am}"); + flush_wal(); + $primary->wait_for_replay_catchup($standby); + is(bits($standby, $t), $all{$am}, "$am: standby replayed the new map"); + + # A change clears block 1; after a checkpoint, VACUUM sets it again. + $primary->safe_psql('postgres', "DELETE FROM $t WHERE id = 80"); + $primary->safe_psql('postgres', 'CHECKPOINT'); + $primary->safe_psql('postgres', "VACUUM (FREEZE) $t"); + is(bits($primary, $t), $all{$am}, "$am: VACUUM set the bits again"); +} +flush_wal(); +$primary->wait_for_replay_catchup($standby); +foreach my $am ('heap', 'flux') +{ + is(bits($standby, "vs_$am"), + $all{$am}, "$am: standby replayed the map after a checkpoint"); +} + +$primary->stop('immediate'); +$primary->start; +foreach my $am ('heap', 'flux') +{ + is(bits($primary, "vs_$am"), + $all{$am}, "$am: crash recovery keeps the bits"); +} + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl b/src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl new file mode 100644 index 0000000000000..c85fa6b8c74f2 --- /dev/null +++ b/src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl @@ -0,0 +1,79 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Guard for a crash-recovery bug in the per-backend UNDO log: the startup +# process died with "xid to undo log number map not initialized" when the first +# undo record of a transaction replayed after a checkpoint had no preceding +# XLOG_UNDOLOG_ATTACH/META for its xid. +# +# Root cause (fixed): LogUndoMetaData() decided whether to emit the xid->logno +# mapping record by comparing the undo log's last-logged LSN against the +# backend-cached redo point from GetFullPageWriteInfo(). That cached copy is +# refreshed only inside XLogInsertRecord, so between a backend's WAL inserts it +# can lag a concurrent checkpoint; the mapping record was then skipped and the +# undo record that followed became the first after the new redo point with +# nothing mapping its xid. The fix compares against the current redo point +# (GetRedoRecPtr()); FLUX redo also re-seeds the map from the undo record +# pointer the forward record already carries, so recovery no longer depends on +# the mapping record's ordering at all. +# +# This is a GUARD, not a reproduction: it passes on the base too, because the +# bug needs a checkpoint to advance the redo point in the narrow window between +# a backend's own WAL inserts, which this cannot force deterministically. The +# reliable reproduction is recovery/137_flux_vacuum_parallel under +# wal_consistency_checking (fails ~1/3 of runs on the base, 0/15 after the fix). +# Here we exercise the shape -- a transaction whose undo spans a checkpoint, +# then an immediate crash -- for both access methods, with the heap run as a +# control (heap writes no undo and so cannot hit this). +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('undo_attach_order'); +$node->init; +$node->append_conf('postgresql.conf', "autovacuum = off\n"); +$node->start; + +sub run_am +{ + my ($am) = @_; + + $node->safe_psql( + 'postgres', qq{ +DROP TABLE IF EXISTS u_$am; +CREATE TABLE u_$am (id int, v int) USING $am WITH (autovacuum_enabled = off); +INSERT INTO u_$am SELECT g, g FROM generate_series(1, 20000) g; +CHECKPOINT; +}); + + my $h = $node->background_psql('postgres', on_error_stop => 0); + $h->query_safe("BEGIN"); + # First undo op of the transaction: emits ATTACH + META, grows the log. + $h->query_safe("DELETE FROM u_$am WHERE id <= 10000"); + + # Advance the redo point from another backend while the held session is + # idle, then do a second undo op in the SAME transaction (no new ATTACH): + # only a META could carry the mapping for its first record after the + # checkpoint. + $node->safe_psql('postgres', "CHECKPOINT"); + $h->query_safe("DELETE FROM u_$am WHERE id = 20000"); + + # Crash before commit; recovery must replay the second op and roll back. + $node->stop('immediate'); + eval { $h->quit }; + + $node->start; + + is( $node->safe_psql('postgres', "SELECT count(*) FROM u_$am"), + 20000, + "$am: recovery survives an undo op after a checkpoint-advanced redo point" + ); +} + +run_am('heap'); +run_am('flux'); + +$node->stop; +done_testing(); diff --git a/src/test/regress/expected/create_am.out b/src/test/regress/expected/create_am.out index c1a951572512c..4018dc0bab665 100644 --- a/src/test/regress/expected/create_am.out +++ b/src/test/regress/expected/create_am.out @@ -131,9 +131,10 @@ ERROR: function bthandler must return type table_am_handler SELECT amname, amhandler, amtype FROM pg_am where amtype = 't' ORDER BY 1, 2; amname | amhandler | amtype --------+----------------------+-------- + flux | flux_tableam_handler | t heap | heap_tableam_handler | t heap2 | heap_tableam_handler | t -(2 rows) +(3 rows) -- First create tables employing the new AM using USING -- plain CREATE TABLE diff --git a/src/test/regress/expected/flux.out b/src/test/regress/expected/flux.out new file mode 100644 index 0000000000000..d6796ab3bdf36 --- /dev/null +++ b/src/test/regress/expected/flux.out @@ -0,0 +1,1436 @@ +-- +-- FLUX table access method: core functional + index-integrity tests. +-- +-- The critical property is that +-- index scans, sequential scans, and bitmap scans agree after key-changing +-- UPDATEs, including A -> B -> A recurrences. FLUX achieves this by doing a +-- non-in-place (new-TID) UPDATE whenever an indexed column changes, so +-- secondary indexes are maintained by the standard heap-TID path. +-- +-- +-- amcheck (bt_index_check) is a contrib extension, so it may be absent when +-- the core regression suite runs without EXTRA_INSTALL=contrib/amcheck (for +-- example when another test module re-runs the schedule, or on a packager's +-- "make check"). Verify indexes with it when present; otherwise skip, so the +-- expected output is identical either way. The idxscan-vs-seqscan checks +-- below are the primary consistency assertion; amcheck is a stronger backstop. +-- +CREATE FUNCTION flux_check_index(regclass) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + PERFORM bt_index_check($1, true); + END IF; + RETURN 'ok'; +END; +$$; +CREATE TABLE flux_basic (id int, k int, v text) USING flux; +INSERT INTO flux_basic SELECT g, g, 'v' || g FROM generate_series(1, 20) g; +CREATE INDEX flux_basic_k_idx ON flux_basic (k); +-- non-key UPDATE (in place): TID and index unchanged +UPDATE flux_basic SET v = 'updated' WHERE id = 5; +SELECT id, k, v FROM flux_basic WHERE id = 5; + id | k | v +----+---+--------- + 5 | 5 | updated +(1 row) + +-- key-changing UPDATEs (out of place): old index entry dies, new one inserted +UPDATE flux_basic SET k = 105 WHERE id = 5; +UPDATE flux_basic SET k = 106 WHERE id = 6; +-- A -> B -> A recurrence on an indexed key +UPDATE flux_basic SET k = 999 WHERE id = 7; +UPDATE flux_basic SET k = 7 WHERE id = 7; +DELETE FROM flux_basic WHERE id = 20; +-- The gate: idxscan == seqscan == bitmapscan. +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_basic; + seq_count | seq_sumk +-----------+---------- + 19 | 390 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_basic WHERE k > -2147483648; + idx_count | idx_sumk +-----------+---------- + 19 | 390 +(1 row) + +SELECT count(*) AS idx_k_eq7 FROM flux_basic WHERE k = 7; -- 1, not 2 + idx_k_eq7 +----------- + 1 +(1 row) + +SELECT count(*) AS idx_k_eq5 FROM flux_basic WHERE k = 5; -- 0 (moved to 105) + idx_k_eq5 +----------- + 0 +(1 row) + +SELECT count(*) AS idx_k_eq105 FROM flux_basic WHERE k = 105; -- 1 + idx_k_eq105 +------------- + 1 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = on; +SELECT count(*) AS bmp_count, sum(k) AS bmp_sumk FROM flux_basic WHERE k > -2147483648; + bmp_count | bmp_sumk +-----------+---------- + 19 | 390 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- VACUUM then amcheck (heapallindexed): no error means the index is consistent +-- with the heap after key churn. +VACUUM flux_basic; +SELECT flux_check_index('flux_basic_k_idx'::regclass); + flux_check_index +------------------ + ok +(1 row) + +-- ROLLBACK restores the old value (non-key) and old key + index entry (key). +BEGIN; +UPDATE flux_basic SET v = 'should_not_persist' WHERE id = 10; +ROLLBACK; +SELECT v FROM flux_basic WHERE id = 10; + v +----- + v10 +(1 row) + +BEGIN; +UPDATE flux_basic SET k = 5000 WHERE id = 11; +ROLLBACK; +SELECT k FROM flux_basic WHERE id = 11; + k +---- + 11 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_k_eq11 FROM flux_basic WHERE k = 11; -- 1 + idx_k_eq11 +------------ + 1 +(1 row) + +SELECT count(*) AS idx_k_eq5000 FROM flux_basic WHERE k = 5000; -- 0 + idx_k_eq5000 +-------------- + 0 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- TOAST: a >8KB value round-trips exactly and the relation gets a TOAST table. +CREATE TABLE flux_toast (id int, big text) USING flux; +INSERT INTO flux_toast VALUES (1, repeat('X', 100000)); +SELECT id, length(big) AS len, (big = repeat('X', 100000)) AS exact FROM flux_toast; + id | len | exact +----+--------+------- + 1 | 100000 | t +(1 row) + +SELECT reltoastrelid <> 0 AS has_toast_table FROM pg_class WHERE relname = 'flux_toast'; + has_toast_table +----------------- + t +(1 row) + +DROP TABLE flux_basic; +DROP TABLE flux_toast; +-- +-- BUG 2 regression: an in-place UPDATE of a NON-indexed column that GROWS the +-- row on a full page must NOT error with "does not fit on page". FLUX falls +-- back to an out-of-place (new-TID) update and rebuilds all index entries, so +-- the row survives and every index stays consistent with the heap. +-- +CREATE TABLE flux_grow (id int, k int, pad text, v text) USING flux + WITH (fillfactor = 100); +CREATE INDEX flux_grow_k_idx ON flux_grow (k); +-- Pack pages tight: many rows with a NULL v (narrowest) so the page is full +-- of committed tuples with no free space and no slack for in-place growth. +INSERT INTO flux_grow SELECT g, g, repeat('p', 200), NULL + FROM generate_series(1, 2000) g; +VACUUM flux_grow; +-- Grow a NON-indexed column (v: NULL -> long string) on rows across full +-- pages. Pre-fix this raised: ERROR updated flux tuple does not fit on page. +UPDATE flux_grow SET v = repeat('w', 400) WHERE id <= 2000; +-- All rows survived with the grown value. +SELECT count(*) AS grown, count(*) FILTER (WHERE v = repeat('w', 400)) AS correct + FROM flux_grow; + grown | correct +-------+--------- + 2000 | 2000 +(1 row) + +-- The non-indexed grow may have moved TIDs; every index must still agree with +-- the heap (idxscan == seqscan) and amcheck must pass. +VACUUM flux_grow; +SELECT flux_check_index('flux_grow_k_idx'::regclass); + flux_check_index +------------------ + ok +(1 row) + +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_grow; + seq_count | seq_sumk +-----------+---------- + 2000 | 2001000 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_grow; + idx_count | idx_sumk +-----------+---------- + 2000 | 2001000 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- A specific indexed lookup after the TID-moving grow returns exactly one row. +SELECT id, k, length(v) AS vlen FROM flux_grow WHERE k = 1000; + id | k | vlen +------+------+------ + 1000 | 1000 | 400 +(1 row) + +DROP TABLE flux_grow; +-- +-- BUG 7/8 regression: SAVEPOINT interaction with in-place UPDATE. +-- (7) Re-UPDATE inside a savepoint a row already updated by an earlier command +-- must NOT self-wait/assert. (8) ROLLBACK TO SAVEPOINT must RESTORE the +-- pre-savepoint value of in-place updates (and undo in-savepoint deletes), +-- not vanish the row or over-revert to the original committed value. +-- +CREATE TABLE flux_sp (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_sp VALUES (1,100),(2,200),(3,300); +BEGIN; +UPDATE flux_sp SET v = 101 WHERE id = 1; -- before savepoint +SAVEPOINT s1; +UPDATE flux_sp SET v = 999 WHERE id = 1; -- re-update same row (bug 7) +DELETE FROM flux_sp WHERE id = 2; -- in-savepoint delete +UPDATE flux_sp SET v = 301 WHERE id = 3; -- in-savepoint update +ROLLBACK TO s1; -- must restore pre-savepoint state +-- Expect 1|101 (pre-savepoint update kept), 2|200 (delete undone), 3|300 (update undone) +SELECT id, v FROM flux_sp ORDER BY id; + id | v +----+----- + 1 | 101 + 2 | 200 + 3 | 300 +(3 rows) + +COMMIT; +SELECT id, v FROM flux_sp ORDER BY id; + id | v +----+----- + 1 | 101 + 2 | 200 + 3 | 300 +(3 rows) + +DROP TABLE flux_sp; +-- +-- ALWAYS-IN-PLACE indexed-column UPDATE via delete-marking. +-- An UPDATE of an indexed column keeps the STABLE TID (ctid unchanged), the +-- index sees the new key (old key delete-marked), unique is respected, and +-- ROLLBACK of an in-place key update restores the old key + removes the new +-- index entry. amcheck stays clean. +-- +CREATE TABLE flux_ip (id int PRIMARY KEY, k int, v text) USING flux; +INSERT INTO flux_ip SELECT g, g, 'v'||g FROM generate_series(1,10) g; +CREATE INDEX flux_ip_k ON flux_ip (k); +CREATE UNIQUE INDEX flux_ip_uk ON flux_ip (k); +-- (a) STABLE TID: ctid identical before and after an indexed-column UPDATE. +SELECT ctid AS ip_before FROM flux_ip WHERE id = 3; + ip_before +----------- + (0,3) +(1 row) + +UPDATE flux_ip SET k = 333 WHERE id = 3; +SELECT ctid AS ip_after, k FROM flux_ip WHERE id = 3; -- same ctid, k=333 + ip_after | k +----------+----- + (0,3) | 333 +(1 row) + +-- index scan sees new key, not old; idxscan == seqscan (no duplicate rows). +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_k3 FROM flux_ip WHERE k = 3; -- 0 (moved) + ip_k3 +------- + 0 +(1 row) + +SELECT count(*) AS ip_k333 FROM flux_ip WHERE k = 333; -- 1 + ip_k333 +--------- + 1 +(1 row) + +SELECT count(*) AS ip_idx_total, sum(k) AS ip_idx_sumk FROM flux_ip WHERE k > -2147483648; + ip_idx_total | ip_idx_sumk +--------------+------------- + 10 | 385 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +SELECT count(*) AS ip_seq_total, sum(k) AS ip_seq_sumk FROM flux_ip; -- must match idx + ip_seq_total | ip_seq_sumk +--------------+------------- + 10 | 385 +(1 row) + +-- (d) UNIQUE respected: another row cannot take the new key. +INSERT INTO flux_ip VALUES (11, 11, 'x'); +UPDATE flux_ip SET k = 333 WHERE id = 11; -- ERROR: duplicate key +ERROR: duplicate key value violates unique constraint "flux_ip_uk" +DETAIL: Key (k)=(333) already exists. +-- (c) ROLLBACK of an in-place key UPDATE restores old key and removes new entry. +SELECT ctid AS ip_roll_before FROM flux_ip WHERE id = 4; + ip_roll_before +---------------- + (0,4) +(1 row) + +BEGIN; +UPDATE flux_ip SET k = 4444 WHERE id = 4; +SELECT ctid AS ip_roll_intxn, k FROM flux_ip WHERE id = 4; -- same ctid, k=4444 + ip_roll_intxn | k +---------------+------ + (0,4) | 4444 +(1 row) + +ROLLBACK; +SELECT ctid AS ip_roll_after, k FROM flux_ip WHERE id = 4; -- same ctid, k=4 restored + ip_roll_after | k +---------------+--- + (0,4) | 4 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_roll_k4 FROM flux_ip WHERE k = 4; -- 1 (restored) + ip_roll_k4 +------------ + 1 +(1 row) + +SELECT count(*) AS ip_roll_k4444 FROM flux_ip WHERE k = 4444; -- 0 (new entry gone) + ip_roll_k4444 +--------------- + 0 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- Unique still works after rollback: k=4444 free to insert, k=4 taken. +INSERT INTO flux_ip VALUES (12, 4444, 'y'); -- ok +INSERT INTO flux_ip VALUES (13, 4, 'z'); -- ERROR: duplicate key (4 live) +ERROR: duplicate key value violates unique constraint "flux_ip_uk" +DETAIL: Key (k)=(4) already exists. +-- (e) amcheck clean on both indexes carrying delete-marks. +SELECT flux_check_index('flux_ip_k'::regclass); + flux_check_index +------------------ + ok +(1 row) + +SELECT flux_check_index('flux_ip_uk'::regclass); + flux_check_index +------------------ + ok +(1 row) + +DROP TABLE flux_ip; +-- +-- Consumer-reported regressions (undo-bug-report): the AFTER-trigger OLD image +-- and the same-transaction snapshot must match heap for an in-place AM. +-- +-- BUG 1: an AFTER ROW UPDATE trigger, and RI, must see the pre-update OLD row. +CREATE TABLE flux_bug1 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug1 VALUES (1, 10); +CREATE FUNCTION flux_bug1_log() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NULL; END $$; +CREATE TRIGGER t AFTER UPDATE ON flux_bug1 FOR EACH ROW + EXECUTE FUNCTION flux_bug1_log(); +UPDATE flux_bug1 SET v = 20; -- expect NOTICE OLD.v=10 NEW.v=20 +NOTICE: OLD.v=10 NEW.v=20 +DROP TABLE flux_bug1; +DROP FUNCTION flux_bug1_log(); +-- BUG 1 (RI): ON UPDATE RESTRICT must refuse a parent-key change that a child +-- still references; the pre-fix in-place AM saw OLD.k==NEW.k and allowed it. +CREATE TABLE flux_pr (k int PRIMARY KEY) USING flux; +INSERT INTO flux_pr VALUES (10); +CREATE TABLE flux_fr (k int REFERENCES flux_pr ON UPDATE RESTRICT); +INSERT INTO flux_fr VALUES (10); +UPDATE flux_pr SET k = 20; -- expect ERROR: RESTRICT violation +ERROR: update or delete on table "flux_pr" violates RESTRICT setting of foreign key constraint "flux_fr_k_fkey" on table "flux_fr" +DETAIL: Key (k)=(10) is referenced from table "flux_fr". +SELECT count(*) AS flux_orphans FROM flux_fr f + WHERE NOT EXISTS (SELECT 1 FROM flux_pr p WHERE p.k = f.k); -- 0 + flux_orphans +-------------- + 0 +(1 row) + +DROP TABLE flux_fr; DROP TABLE flux_pr; +-- BUG 2: a snapshot taken between two in-place UPDATEs in one transaction must +-- see the first UPDATE's effect, not the last committed value. +CREATE TABLE flux_bug2 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug2 VALUES (1, 10); +BEGIN; +DECLARE c1 CURSOR FOR SELECT v FROM flux_bug2; -- before UPDATE 1 +UPDATE flux_bug2 SET v = 20; +DECLARE c2 CURSOR FOR SELECT v FROM flux_bug2; -- between the two UPDATEs +UPDATE flux_bug2 SET v = 30; +FETCH c1; -- 10 + v +---- + 10 +(1 row) + +FETCH c2; -- 20 + v +---- + 20 +(1 row) + +SELECT v FROM flux_bug2; -- 30 + v +---- + 30 +(1 row) + +COMMIT; +DROP TABLE flux_bug2; +-- FLUX: CREATE UNIQUE INDEX must ignore rows of aborted INSERTs. +CREATE TABLE fu (id int) USING flux; +INSERT INTO fu VALUES (1); +BEGIN; INSERT INTO fu VALUES (1); ROLLBACK; +BEGIN; INSERT INTO fu VALUES (2); SAVEPOINT s; INSERT INTO fu VALUES (2); +ROLLBACK TO s; COMMIT; +CREATE UNIQUE INDEX fu_id ON fu (id); +SET enable_seqscan = off; +SELECT id FROM fu WHERE id IN (1, 2) ORDER BY id; + id +---- + 1 + 2 +(2 rows) + +RESET enable_seqscan; +DROP INDEX fu_id; +-- a committed duplicate still fails +INSERT INTO fu VALUES (1); +CREATE UNIQUE INDEX fu_id ON fu (id); +ERROR: could not create unique index "fu_id" +DETAIL: Key (id)=(1) is duplicated. +DROP TABLE fu; +-- A varlena with double alignment followed by one with int alignment: the +-- index-fetch decode must not pad after the first by its own alignment. +CREATE TABLE fal (a int8range, b text) USING flux; +CREATE INDEX fal_b ON fal (b); +INSERT INTO fal VALUES ('[7,8)', 'foo'); +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT a, b FROM fal WHERE b = 'foo'; + a | b +-------+----- + [7,8) | foo +(1 row) + +RESET enable_seqscan; RESET enable_bitmapscan; +DROP TABLE fal; +DROP FUNCTION flux_check_index(regclass); +-- Ordinary VACUUM must not move a live row to another TID. Without an index +-- FLUX's former cross-page relocation moved tail rows forward and freed their +-- slots while their writer still referenced them. +CREATE TABLE flux_vmove (id int, v int, pad text) USING flux + WITH (autovacuum_enabled = off); +INSERT INTO flux_vmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; +DELETE FROM flux_vmove WHERE id <= 300 OR id % 3 = 0; +CREATE TEMP TABLE flux_vmove_before AS SELECT id, ctid AS c FROM flux_vmove; +VACUUM flux_vmove; +VACUUM flux_vmove; +SELECT count(*) AS moved FROM flux_vmove JOIN flux_vmove_before b USING (id) + WHERE flux_vmove.ctid <> b.c; -- 0 + moved +------- + 0 +(1 row) + +SELECT count(*) AS n FROM flux_vmove; -- 67 + n +---- + 67 +(1 row) + +DROP TABLE flux_vmove, flux_vmove_before; +-- VACUUM keeps an empty tail when told to (VACUUM (TRUNCATE false), the +-- vacuum_truncate reloption) and when it is too short to be worth the lock: +-- under 1000 pages and 1/16 of the table. Heap alongside as the control. +CREATE TEMP TABLE flux_tk_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_tk (id int, pad text) USING flux; +INSERT INTO flux_tk_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_tk SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +CREATE TEMP TABLE flux_tk_sz AS + SELECT pg_relation_size('flux_tk_h') AS h, pg_relation_size('flux_tk') AS r; +DELETE FROM flux_tk_h WHERE id > 19900; +DELETE FROM flux_tk WHERE id > 19900; +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; + heap_kept | flux_kept +-----------+----------- + t | t +(1 row) + +DELETE FROM flux_tk_h WHERE id > 1; +DELETE FROM flux_tk WHERE id > 1; +VACUUM (TRUNCATE false) flux_tk_h; +VACUUM (TRUNCATE false) flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; + heap_kept | flux_kept +-----------+----------- + t | t +(1 row) + +ALTER TABLE flux_tk_h SET (vacuum_truncate = false); +ALTER TABLE flux_tk SET (vacuum_truncate = false); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; + heap_kept | flux_kept +-----------+----------- + t | t +(1 row) + +ALTER TABLE flux_tk_h RESET (vacuum_truncate); +ALTER TABLE flux_tk RESET (vacuum_truncate); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = 8192 AS heap_truncated, + pg_relation_size('flux_tk') = 8192 AS flux_truncated; + heap_truncated | flux_truncated +----------------+---------------- + t | t +(1 row) + +DROP TABLE flux_tk_h, flux_tk, flux_tk_sz; +-- VACUUM (DISABLE_PAGE_SKIPPING) still truncates an empty tail, as heap does. +CREATE TEMP TABLE flux_dps_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_dps (id int, pad text) USING flux; +INSERT INTO flux_dps_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_dps SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +DELETE FROM flux_dps_h WHERE id > 1; +DELETE FROM flux_dps WHERE id > 1; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps_h; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps; +SELECT pg_relation_size('flux_dps_h') = 8192 AS heap_truncated, + pg_relation_size('flux_dps') = 8192 AS flux_truncated; + heap_truncated | flux_truncated +----------------+---------------- + t | t +(1 row) + +DROP TABLE flux_dps_h, flux_dps; +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (a int) USING heap; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; + a +--- + 1 + 2 + 3 +(3 rows) + +FETCH BACKWARD 1 FROM c; + a +--- + 2 +(1 row) + +FETCH LAST FROM c; + a +--- + 5 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 4 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; + a +--- + 4 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 3 +(1 row) + +FETCH FIRST FROM c; + a +--- + 2 +(1 row) + +FETCH NEXT FROM c; + a +--- + 3 +(1 row) + +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (a int, pad text) USING heap; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; + QUERY PLAN +---------------------------- + Seq Scan on scan_semantics +(1 row) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; + a +----- + 499 +(1 row) + +FETCH NEXT FROM c; + a +----- + 500 +(1 row) + +FETCH LAST FROM c; + a +------ + 1000 +(1 row) + +FETCH NEXT FROM c; + a +--- +(0 rows) + +FETCH PRIOR FROM c; + a +------ + 1000 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +FETCH PRIOR FROM c; + a +--- +(0 rows) + +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; + QUERY PLAN +---------------------------------------------------------------- + Tid Range Scan on scan_semantics + TID Cond: ((ctid >= '(1,1)'::tid) AND (ctid < '(2,1)'::tid)) +(2 rows) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH LAST FROM c; + found +------- + t +(1 row) + +FETCH NEXT FROM c; + found +------- +(0 rows) + +FETCH PRIOR FROM c; + found +------- + t +(1 row) + +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH PRIOR FROM c; + found +------- +(0 rows) + +FETCH NEXT FROM c; + found +------- + t +(1 row) + +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; + a +--- +(0 rows) + +FETCH FIRST FROM c; + a +--- +(0 rows) + +COMMIT; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (a int) USING flux; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; + a +--- + 1 + 2 + 3 +(3 rows) + +FETCH BACKWARD 1 FROM c; + a +--- + 2 +(1 row) + +FETCH LAST FROM c; + a +--- + 5 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 4 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; + a +--- + 4 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 3 +(1 row) + +FETCH FIRST FROM c; + a +--- + 2 +(1 row) + +FETCH NEXT FROM c; + a +--- + 3 +(1 row) + +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (a int, pad text) USING flux; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; + QUERY PLAN +---------------------------- + Seq Scan on scan_semantics +(1 row) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; + a +----- + 499 +(1 row) + +FETCH NEXT FROM c; + a +----- + 500 +(1 row) + +FETCH LAST FROM c; + a +------ + 1000 +(1 row) + +FETCH NEXT FROM c; + a +--- +(0 rows) + +FETCH PRIOR FROM c; + a +------ + 1000 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +FETCH PRIOR FROM c; + a +--- +(0 rows) + +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; + QUERY PLAN +---------------------------------------------------------------- + Tid Range Scan on scan_semantics + TID Cond: ((ctid >= '(1,1)'::tid) AND (ctid < '(2,1)'::tid)) +(2 rows) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH LAST FROM c; + found +------- + t +(1 row) + +FETCH NEXT FROM c; + found +------- +(0 rows) + +FETCH PRIOR FROM c; + found +------- + t +(1 row) + +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH PRIOR FROM c; + found +------- +(0 rows) + +FETCH NEXT FROM c; + found +------- + t +(1 row) + +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; + a +--- +(0 rows) + +FETCH FIRST FROM c; + a +--- +(0 rows) + +COMMIT; +DROP TABLE scan_semantics; +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics () USING heap; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics () USING flux; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (pad text, a int) USING heap; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SELECT count(*) FROM scan_semantics; + count +------- + 1000 +(1 row) + +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (pad text, a int) USING flux; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SELECT count(*) FROM scan_semantics; + count +------- + 1000 +(1 row) + +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +-- Heap control, then the same operations with FLUX. +CREATE TABLE self_direct (id int, v int) USING heap; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +ERROR: tuple to be deleted was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +UPDATE self_direct SET v = 10; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 1 + 2 + 3 + 4 + 5 +(5 rows) + +delete from self_ref_trigger; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+------+----------- +(0 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 5 + 4 + 3 + 2 + 1 +(5 rows) + +delete from self_ref_trigger; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +CREATE TABLE self_direct (id int, v int) USING flux; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +ERROR: tuple to be deleted was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +UPDATE self_direct SET v = 10; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 1 + 2 + 3 + 4 + 5 +(5 rows) + +delete from self_ref_trigger; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+------+----------- +(0 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 5 + 4 + 3 + 2 + 1 +(5 rows) + +delete from self_ref_trigger; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); diff --git a/src/test/regress/expected/flux_abort_ddl.out b/src/test/regress/expected/flux_abort_ddl.out new file mode 100644 index 0000000000000..0671967b01de9 --- /dev/null +++ b/src/test/regress/expected/flux_abort_ddl.out @@ -0,0 +1,409 @@ +-- +-- FLUX: a transaction updates rows in place, then replaces or drops storage +-- the update touched (DROP INDEX, REINDEX, ALTER COLUMN TYPE, TRUNCATE, +-- CLUSTER), then rolls back, entirely or to a savepoint. +-- +-- The update's undo names the files it changed. While the transaction is +-- aborting, its catalog changes are not rolled back yet, so the relcache +-- maps the new files (or no index at all). The rollback must still leave +-- the table and its indexes as they were, so that the same update applied +-- again gives the same rows and index entries as on heap. Each case runs on +-- a heap twin and on FLUX, permanent and temporary; the two columns of the +-- result must agree. +-- +CREATE SCHEMA ad_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA ad_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; +-- ad_h (heap) and ad_f (FLUX), 200 rows with k = id, an index on k. +CREATE PROCEDURE ad_setup(temp bool) LANGUAGE plpgsql AS $$ +DECLARE + t text; +BEGIN + DROP TABLE IF EXISTS pg_temp.ad_h, pg_temp.ad_f, public.ad_h, public.ad_f; + FOREACH t IN ARRAY ARRAY['ad_h', 'ad_f'] LOOP + EXECUTE format('CREATE %s TABLE %I (id int PRIMARY KEY, k int) USING %s', + CASE WHEN temp THEN 'TEMP' ELSE '' END, t, + CASE WHEN t = 'ad_h' THEN 'heap' ELSE 'flux' END); + EXECUTE format('CREATE INDEX %1$I_k ON %1$I (k)', t); + EXECUTE format('INSERT INTO %I SELECT g, g FROM generate_series(1, 200) g', t); + END LOOP; +END $$; +-- The update: move the first 50 rows' keys, in place on FLUX. +CREATE PROCEDURE ad_upd() LANGUAGE plpgsql AS $$ +BEGIN + UPDATE ad_h SET k = k + 1000 WHERE id <= 50; + UPDATE ad_f SET k = k + 1000 WHERE id <= 50; +END $$; +-- The DDL, on both tables; %1$I is the table. +CREATE PROCEDURE ad_ddl(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format(ddl, 'ad_h'); + EXECUTE format(ddl, 'ad_f'); +END $$; +-- Rows and sum of k, then index scans of the moved and the old keys, then +-- amcheck of every index. +CREATE FUNCTION ad_check1(t text) RETURNS text LANGUAGE plpgsql + SET enable_seqscan = off SET enable_bitmapscan = off AS $$ +DECLARE + chk text := (SELECT format('%I.bt_index_check', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; s bigint; moved bigint; old bigint; idx regclass; problems text := ''; +BEGIN + EXECUTE format('SELECT count(*), sum(k) FROM %I', t) INTO n, s; + EXECUTE format('SELECT count(*) FROM %I WHERE k > 1000', t) INTO moved; + EXECUTE format('SELECT count(*) FROM %I WHERE k BETWEEN 1 AND 50', t) INTO old; + IF chk IS NOT NULL THEN + FOR idx IN SELECT indexrelid FROM pg_index WHERE indrelid = t::regclass ORDER BY 1 LOOP + BEGIN + EXECUTE format('SELECT %s($1, true)', chk) USING idx; + EXCEPTION WHEN OTHERS THEN + problems := problems || ' ' || split_part(SQLERRM, ' for index', 1); + END; + END LOOP; + END IF; + RETURN format('%s|%s idx %s/%s%s', n, s, moved, old, + CASE WHEN problems = '' THEN '' ELSE ' amcheck:' || problems END); +END $$; +CREATE FUNCTION ad_check(OUT heap text, OUT flux text) LANGUAGE sql + RETURN (ad_check1('ad_h'), ad_check1('ad_f')); +-- Top-level ROLLBACK, then the update again in a new transaction. +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- The same with temporary tables. +CALL ad_setup(true); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- ROLLBACK TO SAVEPOINT, then the update again in the same transaction. +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- An error aborts the transaction the same way, in a transaction block or +-- in a single statement. +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); SELECT 1 / 0; ROLLBACK; +ERROR: division by zero +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); SELECT 1 / 0; ROLLBACK; +ERROR: division by zero +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +DO $$ BEGIN CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); PERFORM 1 / 0; END $$; +ERROR: division by zero +CONTEXT: SQL statement "SELECT 1 / 0" +PL/pgSQL function inline_code_block line 1 at PERFORM +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- ROLLBACK in a procedure, and ROLLBACK AND CHAIN: the next transaction +-- starts right away. +CREATE PROCEDURE ad_txn(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + CALL ad_upd(); CALL ad_ddl(ddl); ROLLBACK; + CALL ad_upd(); +END $$; +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +CALL ad_txn('DROP INDEX %1$I_k'); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +CALL ad_txn('TRUNCATE %1$I'); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- Each row updated twice before the DDL: the rollback applies its two +-- records newest first. +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +ROLLBACK; CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +DROP TABLE ad_h, ad_f; +DROP FUNCTION ad_check, ad_check1; +DROP PROCEDURE ad_setup, ad_upd, ad_ddl, ad_txn; +DROP SCHEMA ad_amcheck CASCADE; +NOTICE: drop cascades to extension amcheck diff --git a/src/test/regress/expected/flux_cluster.out b/src/test/regress/expected/flux_cluster.out new file mode 100644 index 0000000000000..3c2bdec4c8719 --- /dev/null +++ b/src/test/regress/expected/flux_cluster.out @@ -0,0 +1,75 @@ +-- +-- CLUSTER orders a FLUX table by the index, whichever plan CLUSTER picks: +-- an index scan, or a sequential scan plus sort (an expression index, or a +-- big table with little maintenance_work_mem, makes it sort). Heap is the +-- control; both halves must give the same answer. +-- +CREATE FUNCTION flux_cluster_misordered(t regclass, key text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM (SELECT %s AS k, lag(%s) OVER (ORDER BY ctid) AS lk FROM %s) s WHERE lk > k', + key, key, t) INTO n; + RETURN n; +END $$; +-- Expression index: CLUSTER always sorts. +CREATE TABLE heap_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING heap; +CREATE TABLE flux_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING flux; +INSERT INTO heap_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +INSERT INTO flux_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +CREATE INDEX heap_clx_minus_a ON heap_clx ((-a), b); +CREATE INDEX flux_clx_minus_a ON flux_clx ((-a), b); +CLUSTER heap_clx USING heap_clx_minus_a; +CLUSTER flux_clx USING flux_clx_minus_a; +SELECT flux_cluster_misordered('heap_clx', '-a') AS heap_misordered, + flux_cluster_misordered('flux_clx', '-a') AS flux_misordered; + heap_misordered | flux_misordered +-----------------+----------------- + 0 | 0 +(1 row) + +SELECT (SELECT count(*) FROM heap_clx) AS heap_rows, (SELECT count(*) FROM flux_clx) AS flux_rows; + heap_rows | flux_rows +-----------+----------- + 133 | 133 +(1 row) + +-- Plain index, sort forced by planner settings. +CREATE TABLE heap_cls (k int, pad text) USING heap; +CREATE TABLE flux_cls (k int, pad text) USING flux; +INSERT INTO heap_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +INSERT INTO flux_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +CREATE INDEX heap_cls_k ON heap_cls (k); +CREATE INDEX flux_cls_k ON flux_cls (k); +ANALYZE heap_cls, flux_cls; +SET enable_indexscan = off; +CLUSTER heap_cls USING heap_cls_k; +CLUSTER flux_cls USING flux_cls_k; +RESET enable_indexscan; +SELECT flux_cluster_misordered('heap_cls', 'k') AS heap_misordered, + flux_cluster_misordered('flux_cls', 'k') AS flux_misordered; + heap_misordered | flux_misordered +-----------------+----------------- + 0 | 0 +(1 row) + +-- Rows deleted but still visible to an open snapshot survive the sort. +CREATE TABLE flux_clr (k int) USING flux; +INSERT INTO flux_clr SELECT g FROM generate_series(1, 20) g; +CREATE INDEX flux_clr_e ON flux_clr ((-k)); +DELETE FROM flux_clr WHERE k > 10; +CLUSTER flux_clr USING flux_clr_e; +SELECT count(*), min(k), max(k) FROM flux_clr; + count | min | max +-------+-----+----- + 10 | 1 | 10 +(1 row) + +SELECT flux_cluster_misordered('flux_clr', '-k') AS flux_misordered; + flux_misordered +----------------- + 0 +(1 row) + +DROP TABLE heap_clx, flux_clx, heap_cls, flux_cls, flux_clr; +DROP FUNCTION flux_cluster_misordered(regclass, text); diff --git a/src/test/regress/expected/flux_deferred_trigger.out b/src/test/regress/expected/flux_deferred_trigger.out new file mode 100644 index 0000000000000..8a623b453c6cd --- /dev/null +++ b/src/test/regress/expected/flux_deferred_trigger.out @@ -0,0 +1,699 @@ +-- +-- AFTER-trigger row events on FLUX tables whose row was rewritten in place +-- before the event fired: deferred constraint triggers, SET CONSTRAINTS, +-- triggers firing after a nested statement, transition tables and deferred +-- foreign keys. Each case runs first on a heap twin (the control) and then +-- on FLUX; the two halves must print the same thing, and the final query +-- compares their table contents. +-- +-- Heap re-fetches an event's row version by TID when the trigger fires, and +-- RI's check skips a version that was since superseded. A FLUX row keeps its +-- TID across UPDATEs, so the event must carry its version. +-- +CREATE SCHEMA dt_heap; +CREATE SCHEMA dt_flux; +CREATE FUNCTION public.dt_show_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '% % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, OLD, NEW; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_fix_pid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.pid = 99 THEN + UPDATE nt SET pid = 1, v = v + 10 WHERE id = NEW.id; + END IF; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, TG_LEVEL, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_new_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % %: new %', TG_TABLE_NAME, TG_NAME, TG_OP, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_old_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + RAISE NOTICE '% % %: old %', TG_TABLE_NAME, TG_NAME, TG_OP, o; + RETURN NULL; +END $$; +-- +-- heap +-- +\set am heap +\set schema dt_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +NOTICE: dt dt_show INSERT: old new (1,0) +NOTICE: dt dt_show UPDATE: old (1,0) new (1,1) +NOTICE: dt dt_show UPDATE: old (1,1) new (1,2) +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; +NOTICE: dt dt_show UPDATE: old (1,2) new (1,3) +NOTICE: dt dt_show DELETE: old (1,3) new +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +NOTICE: dt dt_show INSERT: old new (2,0) +NOTICE: dt dt_show UPDATE: old (2,0) new (2,1) +UPDATE dt SET v = 2 WHERE id = 2; +NOTICE: dt dt_show UPDATE: old (2,1) new (2,2) +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +NOTICE: dt dt_show UPDATE: old (2,2) new (2,3) +NOTICE: dt dt_show UPDATE: old (2,3) new (2,4) +SELECT * FROM dt ORDER BY id; + id | v +----+--- + 2 | 4 +(1 row) + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,99,0) new (1,1,10) +NOTICE: nt z_show UPDATE: old (1,1,0) new (1,99,0) +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,1,10) new (1,1,11) +SELECT * FROM nt ORDER BY id; + id | pid | v +----+-----+---- + 1 | 1 | 11 + 2 | 2 | 0 +(2 rows) + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +NOTICE: tt tt_stmt_ins INSERT: new (1,0) (2,0) (3,0) +UPDATE tt SET v = v + 1 WHERE id <= 2; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,0) (2,0) new (1,1) (2,1) +UPDATE tt SET v = v + 10; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +DELETE FROM tt WHERE id = 1; +NOTICE: tt tt_stmt_del DELETE: old (1,11) +COMMIT; +SELECT * FROM tt ORDER BY id; + id | v +----+---- + 2 | 11 + 3 | 10 +(2 rows) + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "fp". +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(90) is not present in table "fp". +SELECT * FROM fc ORDER BY id; + id | pid | w +----+-----+--- + 10 | 1 | 0 + 13 | 3 | 0 + 15 | 1 | 0 + 16 | 1 | 1 +(4 rows) + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "gc" violates foreign key constraint "gc_pid_fkey" +DETAIL: Key (pid)=(2) is not present in table "gp". +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +ERROR: update or delete on table "gp" violates foreign key constraint "gc_pid_fkey" on table "gc" +DETAIL: Key (id)=(5) is still referenced from table "gc". +SELECT * FROM gp ORDER BY id; + id | v +----+--- + 1 | 5 + 2 | 0 + 3 | 6 + 4 | 0 + 5 | 0 +(5 rows) + +SELECT * FROM gc ORDER BY id; + id | pid +----+----- + 10 | 1 + 30 | 3 + 40 | 4 + 50 | 5 +(4 rows) + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "sc" violates foreign key constraint "sc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "sp". +SELECT * FROM sc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 6 + 2 | 1 | 0 +(2 rows) + +-- +-- flux +-- +\set am flux +\set schema dt_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +NOTICE: dt dt_show INSERT: old new (1,0) +NOTICE: dt dt_show UPDATE: old (1,0) new (1,1) +NOTICE: dt dt_show UPDATE: old (1,1) new (1,2) +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; +NOTICE: dt dt_show UPDATE: old (1,2) new (1,3) +NOTICE: dt dt_show DELETE: old (1,3) new +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +NOTICE: dt dt_show INSERT: old new (2,0) +NOTICE: dt dt_show UPDATE: old (2,0) new (2,1) +UPDATE dt SET v = 2 WHERE id = 2; +NOTICE: dt dt_show UPDATE: old (2,1) new (2,2) +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +NOTICE: dt dt_show UPDATE: old (2,2) new (2,3) +NOTICE: dt dt_show UPDATE: old (2,3) new (2,4) +SELECT * FROM dt ORDER BY id; + id | v +----+--- + 2 | 4 +(1 row) + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,99,0) new (1,1,10) +NOTICE: nt z_show UPDATE: old (1,1,0) new (1,99,0) +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,1,10) new (1,1,11) +SELECT * FROM nt ORDER BY id; + id | pid | v +----+-----+---- + 1 | 1 | 11 + 2 | 2 | 0 +(2 rows) + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +NOTICE: tt tt_stmt_ins INSERT: new (1,0) (2,0) (3,0) +UPDATE tt SET v = v + 1 WHERE id <= 2; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,0) (2,0) new (1,1) (2,1) +UPDATE tt SET v = v + 10; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +DELETE FROM tt WHERE id = 1; +NOTICE: tt tt_stmt_del DELETE: old (1,11) +COMMIT; +SELECT * FROM tt ORDER BY id; + id | v +----+---- + 2 | 11 + 3 | 10 +(2 rows) + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "fp". +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(90) is not present in table "fp". +SELECT * FROM fc ORDER BY id; + id | pid | w +----+-----+--- + 10 | 1 | 0 + 13 | 3 | 0 + 15 | 1 | 0 + 16 | 1 | 1 +(4 rows) + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "gc" violates foreign key constraint "gc_pid_fkey" +DETAIL: Key (pid)=(2) is not present in table "gp". +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +ERROR: update or delete on table "gp" violates foreign key constraint "gc_pid_fkey" on table "gc" +DETAIL: Key (id)=(5) is still referenced from table "gc". +SELECT * FROM gp ORDER BY id; + id | v +----+--- + 1 | 5 + 2 | 0 + 3 | 6 + 4 | 0 + 5 | 0 +(5 rows) + +SELECT * FROM gc ORDER BY id; + id | pid +----+----- + 10 | 1 + 30 | 3 + 40 | 4 + 50 | 5 +(4 rows) + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "sc" violates foreign key constraint "sc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "sp". +SELECT * FROM sc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 6 + 2 | 1 | 0 +(2 rows) + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.dt_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM dt_heap.%1$I EXCEPT ALL SELECT * FROM dt_flux.%1$I)' + ' UNION ALL (SELECT * FROM dt_flux.%1$I EXCEPT ALL SELECT * FROM dt_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.dt_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'dt_heap'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + relname | differing_rows +---------+---------------- + dt | 0 + fc | 0 + fp | 0 + gc | 0 + gp | 0 + np | 0 + nt | 0 + sc | 0 + sp | 0 + tt | 0 +(10 rows) + +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'dt_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + relname | amname +---------+-------- + dt | flux + fc | flux + fp | flux + gc | flux + gp | flux + np | flux + nt | flux + sc | flux + sp | flux + tt | flux +(10 rows) + +DROP SCHEMA dt_heap CASCADE; +NOTICE: drop cascades to 10 other objects +DETAIL: drop cascades to table dt_heap.dt +drop cascades to table dt_heap.np +drop cascades to table dt_heap.nt +drop cascades to table dt_heap.tt +drop cascades to table dt_heap.fp +drop cascades to table dt_heap.fc +drop cascades to table dt_heap.gp +drop cascades to table dt_heap.gc +drop cascades to table dt_heap.sp +drop cascades to table dt_heap.sc +DROP SCHEMA dt_flux CASCADE; +NOTICE: drop cascades to 10 other objects +DETAIL: drop cascades to table dt_flux.dt +drop cascades to table dt_flux.np +drop cascades to table dt_flux.nt +drop cascades to table dt_flux.tt +drop cascades to table dt_flux.fp +drop cascades to table dt_flux.fc +drop cascades to table dt_flux.gp +drop cascades to table dt_flux.gc +drop cascades to table dt_flux.sp +drop cascades to table dt_flux.sc +DROP FUNCTION public.dt_show_row(), public.dt_fix_pid(), public.dt_show_transition(), + public.dt_show_new_table(), public.dt_show_old_table(); diff --git a/src/test/regress/expected/flux_dml_semantics.out b/src/test/regress/expected/flux_dml_semantics.out new file mode 100644 index 0000000000000..3f32cad2d7a7c --- /dev/null +++ b/src/test/regress/expected/flux_dml_semantics.out @@ -0,0 +1,202 @@ +-- Run the same statements against heap and FLUX. +CREATE SCHEMA semantics_heap; +CREATE SCHEMA semantics_flux; +SET search_path = semantics_heap, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING heap; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +ERROR: MERGE command cannot affect row a second time +HINT: Ensure that not more than one source row matches any one target row. +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +CREATE TABLE cc (a int) USING heap; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +---+------+------+----------+--------- + 1 | 0 | 0 | t | t + 2 | 1 | 1 | t | t +(2 rows) + +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +----+------+------+----------+--------- + 11 | 2 | 2 | t | t + 12 | 2 | 2 | t | t +(2 rows) + +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; + a | cmin | cmax +---+------+------ + 3 | 3 | 3 +(1 row) + +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; + a | cmin | cmax +----+------+------ + 13 | 4 | 4 +(1 row) + +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING heap; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +NOTICE: transition: old {3}, new {4} +SET search_path = semantics_flux, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING flux; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +ERROR: MERGE command cannot affect row a second time +HINT: Ensure that not more than one source row matches any one target row. +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +CREATE TABLE cc (a int) USING flux; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +---+------+------+----------+--------- + 1 | 0 | 0 | t | t + 2 | 1 | 1 | t | t +(2 rows) + +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +----+------+------+----------+--------- + 11 | 2 | 2 | t | t + 12 | 2 | 2 | t | t +(2 rows) + +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; + a | cmin | cmax +---+------+------ + 3 | 3 | 3 +(1 row) + +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; + a | cmin | cmax +----+------+------ + 13 | 4 | 4 +(1 row) + +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING flux; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +NOTICE: transition: old {3}, new {4} +RESET search_path; +DROP SCHEMA semantics_heap CASCADE; +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table semantics_heap.m +drop cascades to table semantics_heap.cc +drop cascades to function semantics_heap.check_oid() +drop cascades to table semantics_heap.tw +drop cascades to function semantics_heap.check_transition() +DROP SCHEMA semantics_flux CASCADE; +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table semantics_flux.m +drop cascades to table semantics_flux.cc +drop cascades to function semantics_flux.check_oid() +drop cascades to table semantics_flux.tw +drop cascades to function semantics_flux.check_transition() diff --git a/src/test/regress/expected/flux_estimate.out b/src/test/regress/expected/flux_estimate.out new file mode 100644 index 0000000000000..0924770ecacb8 --- /dev/null +++ b/src/test/regress/expected/flux_estimate.out @@ -0,0 +1,201 @@ +-- +-- FLUX planner size estimates (flux_relation_estimate_size), heap as control. +-- +-- Every result is a boolean so the expected output does not depend on +-- platform details; heap's column shows what the shared block estimator +-- gives for the same shape. +-- +-- Estimated rows and total cost of a whole-table scan. +CREATE FUNCTION est_plan(tbl regclass, what text) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + plan json; +BEGIN + EXECUTE format('EXPLAIN (FORMAT JSON) SELECT * FROM %s', tbl) INTO plan; + RETURN (plan->0->'Plan'->>what)::float8; +END $$; +CREATE FUNCTION est_rows(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT est_plan(tbl, 'Plan Rows') +$$; +CREATE FUNCTION real_rows(tbl regclass) RETURNS float8 LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT count(*) FROM %s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION real_pages(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT pg_relation_size(tbl) / current_setting('block_size')::float8 +$$; +-- The estimate is within [0.5x, 2x] of the real row count. +CREATE FUNCTION est_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT est_rows(tbl) BETWEEN 0.5 * real_rows(tbl) AND 2 * real_rows(tbl) +$$; +-- A never-analyzed table: the rows per page the planner assumes (the +-- estimate over the page count, which is at least 10) is within 10% of the +-- rows a full page really holds (the most rows on any block of a table +-- filled by INSERT alone). +CREATE FUNCTION real_density(tbl regclass) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT max(c) FROM (SELECT count(*) AS c FROM %s + GROUP BY (ctid::text::point)[0]) s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION density_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT abs(est_rows(tbl) / greatest(real_pages(tbl), 10) / + real_density(tbl) - 1) <= 0.10 +$$; +CREATE TABLE est_result (shape text, am text, ok bool); +CREATE TABLE est_am (am text); +INSERT INTO est_am VALUES ('heap'), ('flux'); +-- Run one statement per access method; %1$s is the AM name. The +-- statements are not echoed: only est_result is shown. +\set ECHO none +SELECT h.shape, h.ok AS heap, f.ok AS flux +FROM est_result h JOIN est_result f USING (shape) +WHERE h.am = 'heap' AND f.am = 'flux' +ORDER BY h.ctid; + shape | heap | flux +------------------------------------+------+------ + fresh 2 rows: 10-page rule | t | t + empty after VACUUM: 0 pages | t | t + fresh default fillfactor: density | t | t + fresh 1000 columns: density | t | t + fresh narrow: density | t | t + fresh narrow: rows | t | t + fresh wide: density | t | t + fresh wide: rows | t | t + fresh with nulls: rows | t | t + fresh fillfactor 50: density | t | t + fresh fillfactor 50: rows | t | t + after ANALYZE: exact | t | t + grown 3x since ANALYZE: rows | t | t + 3 non-key UPDATE rounds: rows | f | t + 3 non-key UPDATE rounds: no drift | f | t + key UPDATE + DELETE + VACUUM: rows | t | t +(16 rows) + +-- ANALYZE's average column width is the stored width, as for heap: a +-- compressed value inline, an external value's TOAST pointer. +\set ECHO none +SELECT attname, h.avg_width = f.avg_width AS same_width, + f.avg_width < 100 AS stored_width +FROM pg_stats h JOIN pg_stats f USING (attname) +WHERE h.tablename = 'est_width_heap' AND f.tablename = 'est_width_flux' +ORDER BY attname; + attname | same_width | stored_width +---------+------------+-------------- + a | t | t + e | t | t + m | t | t +(3 rows) + +-- Planning a never-analyzed table reads no table block: 50 plans, 0 reads. +\set ECHO none +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +CREATE TEMP TABLE est_io_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_io_heap', 'est_io_flux'); +DO $$ +BEGIN + FOR i IN 1..50 LOOP + EXECUTE 'EXPLAIN SELECT * FROM est_io_heap WHERE a = 1'; + EXECUTE 'EXPLAIN SELECT * FROM est_io_flux WHERE a = 1'; + END LOOP; +END $$; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks AS blocks_read_by_planning +FROM pg_statio_user_tables s JOIN est_io_before b USING (relname) +ORDER BY s.relname; + relname | blocks_read_by_planning +-------------+------------------------- + est_io_flux | 0 + est_io_heap | 0 +(2 rows) + +-- An index scan of a range in physical order visits each table page once, as +-- the planner's index-correlation costing assumes: 3000 rows, 0 to 30 pages. +\set ECHO none +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +CREATE TEMP TABLE est_corr_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_corr_heap', 'est_corr_flux'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT sum(v) FROM est_corr_heap WHERE id BETWEEN 1001 AND 4000; + sum +--------- + 7501500 +(1 row) + +SELECT sum(v) FROM est_corr_flux WHERE id BETWEEN 1001 AND 4000; + sum +--------- + 7501500 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks BETWEEN 1 AND 30 AS pages_once +FROM pg_statio_user_tables s JOIN est_corr_before b USING (relname) +ORDER BY s.relname; + relname | pages_once +---------------+------------ + est_corr_flux | t + est_corr_heap | t +(2 rows) + +\set ECHO none +DROP TABLE est_result, est_am; +DROP FUNCTION est_ok, density_ok, real_density, est_rows, est_plan, real_rows, + real_pages; diff --git a/src/test/regress/expected/flux_fk.out b/src/test/regress/expected/flux_fk.out new file mode 100644 index 0000000000000..dd37bc1cfa550 --- /dev/null +++ b/src/test/regress/expected/flux_fk.out @@ -0,0 +1,1054 @@ +-- +-- Foreign keys and AFTER triggers on FLUX tables, each case run first on a +-- heap twin (the control) and then on FLUX. The two halves must print the +-- same thing; the final query compares their table contents. +-- +-- An AM that updates rows in place hands the AFTER-trigger machinery copies +-- of the old and new row images, and the RI triggers ask such a copy whether +-- the current transaction created it (slot_is_current_xact_tuple). +-- +CREATE SCHEMA fk_heap; +CREATE SCHEMA fk_flux; +CREATE FUNCTION public.fk_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO log VALUES (nextval('log_seq'), TG_TABLE_NAME, + TG_NAME || ' ' || TG_OP, OLD::text, NEW::text); + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% %: old % new %', TG_TABLE_NAME, TG_OP, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_bump_w() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.w := NEW.w + 100; + IF NEW.w = 99 THEN NEW.pid := 8; END IF; + RETURN NEW; +END $$; +-- +-- heap +-- +\set am heap +\set other flux +\set schema fk_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +ROLLBACK; +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 2 | 6 + 2 | 2 | 1 + 3 | 2 | 2 +(3 rows) + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_na" violates foreign key constraint "c_na_pid_fkey" on table "c_na" +DETAIL: Key (id)=(1) is still referenced from table "c_na". +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_re" violates RESTRICT setting of foreign key constraint "c_re_pid_fkey" on table "c_re" +DETAIL: Key (id)=(1) is referenced from table "c_re". +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +ERROR: update or delete on table "p_sd" violates foreign key constraint "c_sd_pid_fkey" on table "c_sd" +DETAIL: Key (id)=(0) is still referenced from table "c_sd". +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 1 | 11 | 0 + ca | 2 | 11 | 0 + na | 1 | 1 | 0 + na | 2 | 1 | 0 + re | 1 | 1 | 0 + re | 2 | 1 | 0 + sd | 1 | 0 | 0 + sd | 2 | 0 | 0 + sn | 1 | | 0 + sn | 2 | | 0 +(10 rows) + +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 12 | 7 + 2 | 12 | 7 +(2 rows) + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 2 | 2 | 1 + sn | 1 | 2 | 2 + sn | 2 | 2 | 1 + sn | 3 | | 2 +(4 rows) + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(96) is not present in table "fp". +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(92) is not present in table "fp". +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + t | id | pid | w +-------+----+-----+--- + fc | 1 | 2 | 1 + fc | 2 | 2 | 1 + fc_na | 1 | 1 | 0 +(3 rows) + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +ERROR: insert or update on table "tree" violates foreign key constraint "tree_parent_fkey" +DETAIL: Key (parent)=(5) is not present in table "tree". +SELECT * FROM tree ORDER BY id; + id | parent | w +----+--------+--- + 3 | 4 | 1 + 4 | 10 | 1 + 10 | | 1 + 20 | 10 | 1 +(4 rows) + +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + count +------- + 0 +(1 row) + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_d" violates foreign key constraint "xc_d_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_o". +UPDATE xc_o SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_o" violates foreign key constraint "xc_o_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_d". +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + t | id | pid | w +------+----+-----+--- + xc_d | 1 | 5 | 1 + xc_d | 2 | 5 | 1 + xc_o | 1 | 5 | 1 + xc_o | 2 | 5 | 1 +(4 rows) + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "ptc1" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "ptp". +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 1 | 3 | 1 + ptc1 | 2 | 3 | 1 + ptc2 | 101 | 160 | 1 +(3 rows) + +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +ERROR: insert or update on table "ptc2" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(7) is not present in table "ptp". +SELECT tableoid::regclass, * FROM ptp ORDER BY id; + tableoid | id | v +----------+-----+--- + ptp1 | 1 | 1 + ptp2 | 120 | 1 + ptp2 | 160 | 1 +(3 rows) + +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | 120 | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | 120 | 1 +(3 rows) + +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | | 1 +(3 rows) + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(4) is not present in table "pp". +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 7 + 2 | 2 | 9 + 3 | 2 | 2 + 5 | 2 | 0 +(4 rows) + +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 13 | 7 + 2 | 13 | 7 +(2 rows) + +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + sn | 1 | | 2 + sn | 2 | | 1 + sn | 3 | | 2 +(3 rows) + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,0) new (1,1,105) +UPDATE tc SET pid = 2 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,105) new (1,2,205) +UPDATE tc SET pid = 9 WHERE id = 1; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(9) is not present in table "tp". +UPDATE tp SET id = 10 WHERE id = 1; +NOTICE: tp UPDATE: old (1,0) new (10,0) +NOTICE: tc UPDATE: old (3,1,0) new (3,10,100) +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,0) new (2,2,100) +UPDATE tc SET w = 1 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,100) new (2,2,101) +UPDATE tc SET pid = 10 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,101) new (2,10,201) +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(8) is not present in table "tp". +SELECT * FROM tc ORDER BY id; + id | pid | w +----+-----+----- + 1 | 2 | 205 + 2 | 10 | 201 + 3 | 10 | 100 +(3 rows) + +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + tbl | op | old_row | new_row +-----+--------------------+-----------+------------ + tc | tc_after UPDATE | (1,1,0) | (1,1,105) + tc | tc_deferred UPDATE | (1,1,0) | (1,1,105) + tc | tc_after UPDATE | (1,1,105) | (1,2,205) + tc | tc_deferred UPDATE | (1,1,105) | (1,2,205) + tc | tc_after UPDATE | (3,1,0) | (3,10,100) + tc | tc_deferred UPDATE | (3,1,0) | (3,10,100) + tc | tc_after UPDATE | (2,2,0) | (2,2,100) + tc | tc_after UPDATE | (2,2,100) | (2,2,101) + tc | tc_after UPDATE | (2,2,101) | (2,10,201) + tc | tc_deferred UPDATE | (2,2,0) | (2,2,100) + tc | tc_deferred UPDATE | (2,2,100) | (2,2,101) + tc | tc_deferred UPDATE | (2,2,101) | (2,10,201) +(12 rows) + +-- +-- flux +-- +\set am flux +\set other heap +\set schema fk_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +ROLLBACK; +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 2 | 6 + 2 | 2 | 1 + 3 | 2 | 2 +(3 rows) + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_na" violates foreign key constraint "c_na_pid_fkey" on table "c_na" +DETAIL: Key (id)=(1) is still referenced from table "c_na". +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_re" violates RESTRICT setting of foreign key constraint "c_re_pid_fkey" on table "c_re" +DETAIL: Key (id)=(1) is referenced from table "c_re". +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +ERROR: update or delete on table "p_sd" violates foreign key constraint "c_sd_pid_fkey" on table "c_sd" +DETAIL: Key (id)=(0) is still referenced from table "c_sd". +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 1 | 11 | 0 + ca | 2 | 11 | 0 + na | 1 | 1 | 0 + na | 2 | 1 | 0 + re | 1 | 1 | 0 + re | 2 | 1 | 0 + sd | 1 | 0 | 0 + sd | 2 | 0 | 0 + sn | 1 | | 0 + sn | 2 | | 0 +(10 rows) + +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 12 | 7 + 2 | 12 | 7 +(2 rows) + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 2 | 2 | 1 + sn | 1 | 2 | 2 + sn | 2 | 2 | 1 + sn | 3 | | 2 +(4 rows) + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(96) is not present in table "fp". +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(92) is not present in table "fp". +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + t | id | pid | w +-------+----+-----+--- + fc | 1 | 2 | 1 + fc | 2 | 2 | 1 + fc_na | 1 | 1 | 0 +(3 rows) + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +ERROR: insert or update on table "tree" violates foreign key constraint "tree_parent_fkey" +DETAIL: Key (parent)=(5) is not present in table "tree". +SELECT * FROM tree ORDER BY id; + id | parent | w +----+--------+--- + 3 | 4 | 1 + 4 | 10 | 1 + 10 | | 1 + 20 | 10 | 1 +(4 rows) + +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + count +------- + 0 +(1 row) + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_d" violates foreign key constraint "xc_d_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_o". +UPDATE xc_o SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_o" violates foreign key constraint "xc_o_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_d". +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + t | id | pid | w +------+----+-----+--- + xc_d | 1 | 5 | 1 + xc_d | 2 | 5 | 1 + xc_o | 1 | 5 | 1 + xc_o | 2 | 5 | 1 +(4 rows) + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "ptc1" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "ptp". +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 1 | 3 | 1 + ptc1 | 2 | 3 | 1 + ptc2 | 101 | 160 | 1 +(3 rows) + +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +ERROR: insert or update on table "ptc2" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(7) is not present in table "ptp". +SELECT tableoid::regclass, * FROM ptp ORDER BY id; + tableoid | id | v +----------+-----+--- + ptp1 | 1 | 1 + ptp2 | 120 | 1 + ptp2 | 160 | 1 +(3 rows) + +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | 120 | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | 120 | 1 +(3 rows) + +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | | 1 +(3 rows) + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(4) is not present in table "pp". +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 7 + 2 | 2 | 9 + 3 | 2 | 2 + 5 | 2 | 0 +(4 rows) + +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 13 | 7 + 2 | 13 | 7 +(2 rows) + +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + sn | 1 | | 2 + sn | 2 | | 1 + sn | 3 | | 2 +(3 rows) + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,0) new (1,1,105) +UPDATE tc SET pid = 2 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,105) new (1,2,205) +UPDATE tc SET pid = 9 WHERE id = 1; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(9) is not present in table "tp". +UPDATE tp SET id = 10 WHERE id = 1; +NOTICE: tp UPDATE: old (1,0) new (10,0) +NOTICE: tc UPDATE: old (3,1,0) new (3,10,100) +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,0) new (2,2,100) +UPDATE tc SET w = 1 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,100) new (2,2,101) +UPDATE tc SET pid = 10 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,101) new (2,10,201) +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(8) is not present in table "tp". +SELECT * FROM tc ORDER BY id; + id | pid | w +----+-----+----- + 1 | 2 | 205 + 2 | 10 | 201 + 3 | 10 | 100 +(3 rows) + +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + tbl | op | old_row | new_row +-----+--------------------+-----------+------------ + tc | tc_after UPDATE | (1,1,0) | (1,1,105) + tc | tc_deferred UPDATE | (1,1,0) | (1,1,105) + tc | tc_after UPDATE | (1,1,105) | (1,2,205) + tc | tc_deferred UPDATE | (1,1,105) | (1,2,205) + tc | tc_after UPDATE | (3,1,0) | (3,10,100) + tc | tc_deferred UPDATE | (3,1,0) | (3,10,100) + tc | tc_after UPDATE | (2,2,0) | (2,2,100) + tc | tc_after UPDATE | (2,2,100) | (2,2,101) + tc | tc_after UPDATE | (2,2,101) | (2,10,201) + tc | tc_deferred UPDATE | (2,2,0) | (2,2,100) + tc | tc_deferred UPDATE | (2,2,100) | (2,2,101) + tc | tc_deferred UPDATE | (2,2,101) | (2,10,201) +(12 rows) + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.fk_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM fk_heap.%1$I EXCEPT ALL SELECT * FROM fk_flux.%1$I)' + ' UNION ALL (SELECT * FROM fk_flux.%1$I EXCEPT ALL SELECT * FROM fk_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.fk_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'fk_heap'::regnamespace AND c.relkind IN ('r', 'p') + AND NOT c.relispartition + ORDER BY 1; + relname | differing_rows +---------+---------------- + c_ca | 0 + c_na | 0 + c_re | 0 + c_sd | 0 + c_sn | 0 + dc_ca | 0 + dc_sn | 0 + dp | 0 + fc | 0 + fc_na | 0 + fp | 0 + log | 0 + p_ca | 0 + p_na | 0 + p_re | 0 + p_sd | 0 + p_sn | 0 + pc | 0 + pp | 0 + ptc | 0 + ptp | 0 + tc | 0 + tp | 0 + tree | 0 + xc_d | 0 + xc_o | 0 + xp_d | 0 + xp_o | 0 +(28 rows) + +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'fk_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + relname | amname +---------+-------- + c_ca | flux + c_na | flux + c_re | flux + c_sd | flux + c_sn | flux + dc_ca | flux + dc_sn | flux + dp | flux + fc | flux + fc_na | flux + fp | flux + log | flux + p_ca | flux + p_na | flux + p_re | flux + p_sd | flux + p_sn | flux + pc | flux + pp | flux + ptc1 | flux + ptc2 | flux + ptp1 | flux + ptp2 | flux + tc | flux + tp | flux + tree | flux + xc_d | flux + xc_o | heap + xp_d | flux + xp_o | heap +(30 rows) + +DROP SCHEMA fk_heap CASCADE; +NOTICE: drop cascades to 29 other objects +DETAIL: drop cascades to table fk_heap.log +drop cascades to sequence fk_heap.log_seq +drop cascades to table fk_heap.pp +drop cascades to table fk_heap.pc +drop cascades to table fk_heap.p_na +drop cascades to table fk_heap.c_na +drop cascades to table fk_heap.p_re +drop cascades to table fk_heap.c_re +drop cascades to table fk_heap.p_ca +drop cascades to table fk_heap.c_ca +drop cascades to table fk_heap.p_sn +drop cascades to table fk_heap.c_sn +drop cascades to table fk_heap.p_sd +drop cascades to table fk_heap.c_sd +drop cascades to table fk_heap.dp +drop cascades to table fk_heap.dc_ca +drop cascades to table fk_heap.dc_sn +drop cascades to table fk_heap.fp +drop cascades to table fk_heap.fc +drop cascades to table fk_heap.fc_na +drop cascades to table fk_heap.tree +drop cascades to table fk_heap.xp_o +drop cascades to table fk_heap.xc_d +drop cascades to table fk_heap.xp_d +drop cascades to table fk_heap.xc_o +drop cascades to table fk_heap.ptp +drop cascades to table fk_heap.ptc +drop cascades to table fk_heap.tp +drop cascades to table fk_heap.tc +DROP SCHEMA fk_flux CASCADE; +NOTICE: drop cascades to 29 other objects +DETAIL: drop cascades to table fk_flux.log +drop cascades to sequence fk_flux.log_seq +drop cascades to table fk_flux.pp +drop cascades to table fk_flux.pc +drop cascades to table fk_flux.p_na +drop cascades to table fk_flux.c_na +drop cascades to table fk_flux.p_re +drop cascades to table fk_flux.c_re +drop cascades to table fk_flux.p_ca +drop cascades to table fk_flux.c_ca +drop cascades to table fk_flux.p_sn +drop cascades to table fk_flux.c_sn +drop cascades to table fk_flux.p_sd +drop cascades to table fk_flux.c_sd +drop cascades to table fk_flux.dp +drop cascades to table fk_flux.dc_ca +drop cascades to table fk_flux.dc_sn +drop cascades to table fk_flux.fp +drop cascades to table fk_flux.fc +drop cascades to table fk_flux.fc_na +drop cascades to table fk_flux.tree +drop cascades to table fk_flux.xp_o +drop cascades to table fk_flux.xc_d +drop cascades to table fk_flux.xp_d +drop cascades to table fk_flux.xc_o +drop cascades to table fk_flux.ptp +drop cascades to table fk_flux.ptc +drop cascades to table fk_flux.tp +drop cascades to table fk_flux.tc +DROP FUNCTION public.fk_log_row(), public.fk_show_transition(), public.fk_bump_w(); diff --git a/src/test/regress/expected/flux_index_aba.out b/src/test/regress/expected/flux_index_aba.out new file mode 100644 index 0000000000000..05f97c0d548f9 --- /dev/null +++ b/src/test/regress/expected/flux_index_aba.out @@ -0,0 +1,873 @@ +-- +-- FLUX: an indexed column that returns to an earlier value (A -> B -> A) +-- inside one transaction, then is rolled back. +-- +-- An in-place key UPDATE keeps the row at its TID: it inserts (k_new, TID), +-- or revives a delete-marked (k_new, TID) left by an earlier UPDATE of the +-- same row, and delete-marks (k_old, TID). Rolling the UPDATEs back must +-- put every index back as it was: one live entry for the row's key, and +-- nothing that an index scan or amcheck could tell from a heap table that +-- had done the same. Each FLUX table here has a heap twin that receives the +-- same statements; heap never reuses a TID, so it is the control. +-- +-- aba_check() reports 'ok' or what is wrong: for both tables, an index scan +-- through the index under test must return what a sequential scan returns, +-- for every key the row has held; bt_index_check(heapallindexed) must pass +-- on every btree index (when amcheck is available); and the FLUX table must +-- hold what its heap twin holds. +-- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'amcheck') AND + NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck; + CREATE TABLE aba_created_amcheck (); + END IF; +END $$; +-- One FLUX/heap pair per index kind. k is the column that cycles; j stays 0. +CREATE FUNCTION aba_make(kind text) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + EXECUTE format('CREATE TABLE %I (id int PRIMARY KEY, j int, k int) USING %s', t, am); + EXECUTE format('INSERT INTO %I SELECT g, 0, g FROM generate_series(1, 40) g', t); + EXECUTE format(CASE kind + WHEN 'plain' THEN 'CREATE INDEX %I ON %I (k)' + WHEN 'uniq' THEN 'CREATE UNIQUE INDEX %I ON %I (k)' + WHEN 'multi' THEN 'CREATE INDEX %I ON %I (j, k)' + WHEN 'expr' THEN 'CREATE INDEX %I ON %I ((k * 2))' + WHEN 'partial' THEN 'CREATE INDEX %I ON %I (k) WHERE k < 1000' + WHEN 'hash' THEN 'CREATE INDEX %I ON %I USING hash (k)' + END, t || '_ix', t); + END LOOP; +END $$; +CREATE FUNCTION aba_drop(kind text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('DROP TABLE %I, %I', 'flux_' || kind, 'heap_' || kind); +END $$; +-- Set row id's k on both tables of the pair. +CREATE FUNCTION aba_set(kind text, rid int, newk int) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'flux_' || kind) USING newk, rid; + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'heap_' || kind) USING newk, rid; +END $$; +-- Try an UPDATE on one table; report whether it raised unique_violation. +CREATE FUNCTION aba_try(t text, rid int, newk int) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', t) USING newk, rid; + RETURN 'updated'; +EXCEPTION WHEN unique_violation THEN + RETURN 'unique_violation'; +END $$; +CREATE FUNCTION aba_count(q text, idxscan bool) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + PERFORM set_config('enable_seqscan', CASE WHEN idxscan THEN 'off' ELSE 'on' END, true); + PERFORM set_config('enable_indexscan', CASE WHEN idxscan THEN 'on' ELSE 'off' END, true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + EXECUTE 'SELECT count(*) FROM ' || q INTO n; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); + RETURN n; +END $$; +CREATE FUNCTION aba_check(kind text, keys int[]) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; + key int; + q text; + ni bigint; + ns bigint; + i regclass; + res text := ''; + fx text; + hp text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + FOREACH key IN ARRAY keys LOOP + -- a partial index holds only keys < 1000 + CONTINUE WHEN kind = 'partial' AND key >= 1000; + q := format('%I WHERE ', t) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + ni := aba_count(q, true); + ns := aba_count(q, false); + IF ni <> ns THEN + res := res || format('%s k=%s: idxscan %s seqscan %s; ', t, key, ni, ns); + END IF; + END LOOP; + FOR i IN SELECT indexrelid FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid + JOIN pg_am a ON a.oid = c.relam + WHERE x.indrelid = t::regclass AND a.amname = 'btree' ORDER BY 1 LOOP + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + BEGIN + PERFORM bt_index_check(i, true); + EXCEPTION WHEN OTHERS THEN + res := res || format('%s: %s; ', i, SQLERRM); + END; + END IF; + END LOOP; + END LOOP; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'flux_' || kind) INTO fx; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'heap_' || kind) INTO hp; + IF fx IS DISTINCT FROM hp THEN + res := res || 'flux and heap rows differ; '; + END IF; + RETURN CASE WHEN res = '' THEN 'ok' ELSE res END; +END $$; +-- The plan aba_count(q, true) gets: a plain index scan of the index under +-- test. Index-only scans are off, as a heap table could otherwise answer +-- from the index alone, and a FLUX table never does. +CREATE FUNCTION aba_plan(kind text, key int) RETURNS SETOF text +LANGUAGE plpgsql AS $$ +DECLARE + q text; + l text; +BEGIN + q := format('%I WHERE ', 'flux_' || kind) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + FOR l IN EXECUTE 'EXPLAIN (COSTS OFF) SELECT count(*) FROM ' || q LOOP + RETURN NEXT l; + END LOOP; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); +END $$; +CREATE VIEW aba_kinds AS SELECT unnest(ARRAY['plain', 'uniq', 'multi', 'expr', 'partial', 'hash']) AS kind; +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +SELECT kind, aba_plan(kind, 12) FROM aba_kinds; + kind | aba_plan +---------+-------------------------------------------------------- + plain | Aggregate + plain | -> Index Scan using flux_plain_ix on flux_plain + plain | Index Cond: (k = 12) + uniq | Aggregate + uniq | -> Index Scan using flux_uniq_ix on flux_uniq + uniq | Index Cond: (k = 12) + multi | Aggregate + multi | -> Index Scan using flux_multi_ix on flux_multi + multi | Index Cond: ((j = 0) AND (k = 12)) + expr | Aggregate + expr | -> Index Scan using flux_expr_ix on flux_expr + expr | Index Cond: ((k * 2) = 24) + partial | Aggregate + partial | -> Index Scan using flux_partial_ix on flux_partial + partial | Index Cond: (k = 12) + hash | Aggregate + hash | -> Index Scan using flux_hash_ix on flux_hash + hash | Index Cond: (k = 12) +(18 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- Every case starts from fresh tables (amcheck reports only the first +-- missing entry) and checks the indexes before and after a VACUUM, which +-- reclaims the tombstones a case leaves and must keep the indexes intact. +-- B = A + 3000000 and C = A + 4000000 lie outside the partial index. +-- 1. A -> B -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 12, 3000012), aba_set(kind, 12, 12) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 2. A -> B -> C -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 13, 3000013), aba_set(kind, 13, 4000013), aba_set(kind, 13, 13) + FROM aba_kinds; + aba_set | aba_set | aba_set +---------+---------+--------- + | | + | | + | | + | | + | | + | | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 3. A -> B, then B -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 14, 3000014) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +SAVEPOINT s; +SELECT aba_set(kind, 14, 14) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 4. A -> B -> C, then C -> A in a savepoint that is rolled back, COMMIT: C +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 15, 3000015), aba_set(kind, 15, 4000015) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +SAVEPOINT s; +SELECT aba_set(kind, 15, 15) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 5. A -> B, then B -> C -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 16, 3000016) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +SAVEPOINT s; +SELECT aba_set(kind, 16, 4000016), aba_set(kind, 16, 16) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 6. COMMIT controls: A -> B -> A and A -> B -> C -> A +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 17, 3000017), aba_set(kind, 17, 17), + aba_set(kind, 18, 3000018), aba_set(kind, 18, 4000018), aba_set(kind, 18, 18) + FROM aba_kinds; + aba_set | aba_set | aba_set | aba_set | aba_set +---------+---------+---------+---------+--------- + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | +(6 rows) + +COMMIT; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 7. Committed A -> C, then C -> A rolled back: C +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +SELECT aba_set(kind, 19, 4000019) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 19, 19) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 8. The same row cycled and rolled back round after round, then committed +-- back to A: no round may lose an entry or leave a live one behind. +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20), aba_set(kind, 20, 3000020) + FROM aba_kinds; + aba_set | aba_set | aba_set +---------+---------+--------- + | | + | | + | | + | | + | | + | | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +COMMIT; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 9. Unique index. A key the row has left is free for another row, and the +-- row cannot take it back while that row holds it. An aborted A -> B -> A +-- keeps A taken: no other row may take it. +SELECT aba_make('uniq'); + aba_make +---------- + +(1 row) + +SELECT aba_set('uniq', 21, 3000021), aba_set('uniq', 22, 21); + aba_set | aba_set +---------+--------- + | +(1 row) + +SELECT aba_try('flux_uniq', 21, 21) AS flux, aba_try('heap_uniq', 21, 21) AS heap; + flux | heap +------------------+------------------ + unique_violation | unique_violation +(1 row) + +BEGIN; +SELECT aba_set('uniq', 23, 3000023), aba_set('uniq', 23, 23); + aba_set | aba_set +---------+--------- + | +(1 row) + +ROLLBACK; +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; + flux | heap +------------------+------------------ + unique_violation | unique_violation +(1 row) + +-- once the row has moved off it again (committed), the key is free +SELECT aba_set('uniq', 23, 3000023); + aba_set +--------- + +(1 row) + +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; + flux | heap +---------+--------- + updated | updated +(1 row) + +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); + aba_check +----------- + ok +(1 row) + +VACUUM flux_uniq, heap_uniq; +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); + aba_check +----------- + ok +(1 row) + +SELECT aba_drop('uniq'); + aba_drop +---------- + +(1 row) + +DROP VIEW aba_kinds; +DROP FUNCTION aba_make, aba_drop, aba_set, aba_try, aba_count, aba_check, aba_plan; +DO $$ +BEGIN + IF to_regclass('aba_created_amcheck') IS NOT NULL THEN + DROP EXTENSION amcheck; + DROP TABLE aba_created_amcheck; + END IF; +END $$; diff --git a/src/test/regress/expected/flux_merge_delete.out b/src/test/regress/expected/flux_merge_delete.out new file mode 100644 index 0000000000000..b1d4c4689d0ce --- /dev/null +++ b/src/test/regress/expected/flux_merge_delete.out @@ -0,0 +1,136 @@ +-- +-- FLUX: a DELETE (plain or MERGE) must stay deleted when a BEFORE STATEMENT +-- trigger advanced the command counter before the row was deleted. +-- +-- The trigger's PERFORM makes plpgsql run CommandCounterIncrement, so the +-- current command id is past the statement's output cid when the row is +-- deleted. The undo record of the delete must carry the output cid, as +-- heap's cmax does; otherwise the next command treats the delete as a later +-- command of its own transaction, reconstructs the deleted row from undo +-- and serves it again. Heap is the control. +-- +CREATE FUNCTION fmd_trig() RETURNS trigger LANGUAGE plpgsql AS +$$ BEGIN PERFORM 1; RETURN NULL; END $$; +CREATE TABLE fmd_heap (tid int, balance int) USING heap; +CREATE TABLE fmd_flux (tid int, balance int) USING flux; +CREATE TABLE fmd_src (sid int, delta int); +INSERT INTO fmd_src VALUES (2, 5), (3, 20), (4, 40); +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY['fmd_heap', 'fmd_flux'] LOOP + EXECUTE format('INSERT INTO %I VALUES (1, 10), (2, 20), (3, 30)', t); + EXECUTE format('CREATE TRIGGER fmd_bsd BEFORE DELETE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + EXECUTE format('CREATE TRIGGER fmd_bsu BEFORE UPDATE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + END LOOP; +END $$; +-- plain DELETE of a row not yet modified by this transaction +BEGIN; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +ROLLBACK; +-- plain DELETE of a row this transaction updated in an earlier command +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +ROLLBACK; +-- the merge.sql "classic UPSERT, with a DELETE" +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +MERGE INTO fmd_heap t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +MERGE INTO fmd_flux t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 15 + 4 | 40 +(3 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 15 + 4 | 40 +(3 rows) + +ROLLBACK; +-- an UPDATE behind the same trigger must keep its new value, too +BEGIN; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 7 WHERE tid = 3; +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 + 3 | 7 +(3 rows) + +ROLLBACK; +-- after ROLLBACK both tables are back to the original rows +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 + 3 | 30 +(3 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 + 3 | 30 +(3 rows) + +DROP TABLE fmd_heap, fmd_flux, fmd_src; +DROP FUNCTION fmd_trig(); diff --git a/src/test/regress/expected/flux_rewrite_xact.out b/src/test/regress/expected/flux_rewrite_xact.out new file mode 100644 index 0000000000000..952b7113c311e --- /dev/null +++ b/src/test/regress/expected/flux_rewrite_xact.out @@ -0,0 +1,651 @@ +-- +-- FLUX: commit, abort and PREPARE work after the relation's storage was +-- replaced in the same transaction (SET ACCESS METHOD, ALTER COLUMN TYPE, +-- CLUSTER, TRUNCATE). +-- +-- FLUX remembers the tuples a transaction wrote by relation OID and TID, and +-- at commit it clears a hint bit on each of them, or marks rows a rolled-back +-- savepoint inserted as deleted. A rewrite keeps the OID but moves the rows +-- to new storage, where the same TIDs name other tuples (or are past the end +-- of the file, or belong to a heap page). Every case runs on a heap twin and +-- on FLUX, with COMMIT and with ROLLBACK; the rows written before and after +-- the rewrite have equal widths, so their TIDs collide. The two columns of +-- the result must agree. +-- +-- amcheck's verify_heapam checks every table that ends up heap when the +-- extension is available; the result is the same either way. It goes into a +-- schema of its own, off the search path of the tests running alongside. +CREATE SCHEMA rx_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA rx_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; +-- A wide value that compresses inline, as heap and FLUX both store it. +CREATE FUNCTION rx_val(k int) RETURNS text LANGUAGE sql IMMUTABLE + RETURN repeat(md5(k::text), 150); +-- Rows, their ids, the rows whose value is intact, then FOR UPDATE, UPDATE +-- and DELETE counts in the same transaction, and amcheck problems. +CREATE FUNCTION rx_check(t text) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + rel regclass := to_regclass(t); + vh text := (SELECT format('%I.verify_heapam', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; ids bigint; ks bigint; good bigint; lck bigint; upd bigint; + del bigint; bad bigint := 0; +BEGIN + IF rel IS NULL THEN + RETURN 'no table'; + END IF; + IF (SELECT a.amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.oid = rel) = 'heap' + AND vh IS NOT NULL THEN + EXECUTE format('SELECT count(*) FROM %s(%L)', vh, rel) INTO bad; + END IF; + EXECUTE format('SELECT count(*), sum(id), sum(k), + count(*) FILTER (WHERE w = rx_val(k)) + FROM %s', rel) INTO n, ids, ks, good; + EXECUTE format('SELECT count(*) FROM (SELECT 1 FROM %s FOR UPDATE) x', rel) + INTO lck; + EXECUTE format('WITH x AS (UPDATE %s SET w = w RETURNING 1) + SELECT count(*) FROM x', rel) INTO upd; + EXECUTE format('WITH x AS (DELETE FROM %s RETURNING 1) + SELECT count(*) FROM x', rel) INTO del; + RETURN format('rows=%s ids=%s ks=%s good=%s lock=%s upd=%s del=%s amcheck=%s', + n, ids, ks, good, lck, upd, del, bad); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '(pglz|lz4)', 'lzN'); +END $$; +-- Each case: statements before BEGIN, then the transaction body. $T is the +-- table, $AM its access method. The base is 20 committed rows, inserted in +-- descending id order so that CLUSTER moves them. +CREATE TABLE rx_case (n int, name text, setup text[], body text[]); +INSERT INTO rx_case VALUES +-- flux -> heap +(1, 'fh', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- flux -> heap -> flux +(2, 'fhf', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ALTER TABLE $T SET ACCESS METHOD $AM']), +-- flux -> heap, after rows rolled back to a savepoint +(3, 'sp_fh', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- TRUNCATE, then new rows on the same TIDs +(4, 'trunc', NULL, ARRAY[ + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- rows rolled back to a savepoint, then TRUNCATE and new rows on their TIDs +(5, 'sp_trunc', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g']), +-- ALTER COLUMN TYPE +(6, 'act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their +-- TIDs +(7, 'sp_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- CLUSTER after updates and deletes in place (no rows rolled back) +(8, 'cluster', NULL, ARRAY[ + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'DELETE FROM $T WHERE id % 5 = 0', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g', + 'CLUSTER $T USING $T_pkey']), +-- TRUNCATE + ALTER TYPE: the rows before TRUNCATE fill blocks the rewritten +-- relation does not have +(9, 'trunc_act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- the same with those rows rolled back to a savepoint; the new rows go in +-- after the rewrite +(10, 'sp_trunc_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a table created in this transaction is truncated in place, keeping its +-- storage: rows rolled back to a savepoint, then new rows on their TIDs +(11, 'inplace', ARRAY[]::text[], ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(1, 20) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again, and the rows written to it before the savepoint still commit +(12, 'sp_rewrite_rb', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'SAVEPOINT a', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ROLLBACK TO a', + 'SAVEPOINT b', + 'TRUNCATE $T', + 'ROLLBACK TO b']); +CREATE FUNCTION rx_script() RETURNS SETOF text LANGUAGE plpgsql AS $$ +DECLARE + c rx_case; + fin text; + am text; + t text; + s text; +BEGIN + FOR c IN SELECT * FROM rx_case ORDER BY n LOOP + FOREACH fin IN ARRAY ARRAY['COMMIT', 'ROLLBACK'] LOOP + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + t := format('rx_%s_%s_%s', c.name, lower(fin), am); + FOREACH s IN ARRAY coalesce(c.setup, ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g']) + || ARRAY['BEGIN'] || c.body || fin LOOP + RETURN NEXT replace(replace(s, '$T', t), '$AM', am); + END LOOP; + END LOOP; + END LOOP; + END LOOP; +END $$; +SELECT rx_script() \gexec +CREATE TABLE rx_fh_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_commit_heap SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_fh_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_commit_flux SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_fh_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_rollback_heap SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_fh_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_rollback_flux SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_fhf_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fhf_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_commit_heap SET ACCESS METHOD heap +ALTER TABLE rx_fhf_commit_heap SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_fhf_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fhf_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_commit_flux SET ACCESS METHOD heap +ALTER TABLE rx_fhf_commit_flux SET ACCESS METHOD flux +COMMIT +CREATE TABLE rx_fhf_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fhf_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_rollback_heap SET ACCESS METHOD heap +ALTER TABLE rx_fhf_rollback_heap SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_fhf_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fhf_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_rollback_flux SET ACCESS METHOD heap +ALTER TABLE rx_fhf_rollback_flux SET ACCESS METHOD flux +ROLLBACK +CREATE TABLE rx_sp_fh_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_commit_heap SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_sp_fh_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_commit_flux SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_sp_fh_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_rollback_heap SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_sp_fh_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_rollback_flux SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_trunc_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_commit_heap +INSERT INTO rx_trunc_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_trunc_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_commit_flux +INSERT INTO rx_trunc_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_trunc_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_rollback_heap +INSERT INTO rx_trunc_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_trunc_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_rollback_flux +INSERT INTO rx_trunc_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_sp_trunc_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_commit_heap +INSERT INTO rx_sp_trunc_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +COMMIT +CREATE TABLE rx_sp_trunc_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_commit_flux +INSERT INTO rx_sp_trunc_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +COMMIT +CREATE TABLE rx_sp_trunc_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_rollback_heap +INSERT INTO rx_sp_trunc_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +ROLLBACK +CREATE TABLE rx_sp_trunc_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_rollback_flux +INSERT INTO rx_sp_trunc_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +ROLLBACK +CREATE TABLE rx_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_commit_heap ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_commit_flux ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_rollback_heap ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_rollback_flux ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_sp_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_commit_heap ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_sp_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_commit_flux ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_sp_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_rollback_heap ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_sp_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_rollback_flux ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_cluster_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_cluster_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_commit_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_commit_heap WHERE id % 5 = 0 +INSERT INTO rx_cluster_commit_heap SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_commit_heap USING rx_cluster_commit_heap_pkey +COMMIT +CREATE TABLE rx_cluster_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_cluster_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_commit_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_commit_flux WHERE id % 5 = 0 +INSERT INTO rx_cluster_commit_flux SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_commit_flux USING rx_cluster_commit_flux_pkey +COMMIT +CREATE TABLE rx_cluster_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_cluster_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_rollback_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_rollback_heap WHERE id % 5 = 0 +INSERT INTO rx_cluster_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_rollback_heap USING rx_cluster_rollback_heap_pkey +ROLLBACK +CREATE TABLE rx_cluster_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_cluster_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_rollback_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_rollback_flux WHERE id % 5 = 0 +INSERT INTO rx_cluster_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_rollback_flux USING rx_cluster_rollback_flux_pkey +ROLLBACK +CREATE TABLE rx_trunc_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_commit_heap +INSERT INTO rx_trunc_act_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_commit_heap ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_trunc_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_commit_flux +INSERT INTO rx_trunc_act_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_commit_flux ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_trunc_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_rollback_heap +INSERT INTO rx_trunc_act_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_rollback_heap ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_trunc_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_rollback_flux +INSERT INTO rx_trunc_act_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_rollback_flux ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_sp_trunc_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_commit_heap +ALTER TABLE rx_sp_trunc_act_commit_heap ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_sp_trunc_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_commit_flux +ALTER TABLE rx_sp_trunc_act_commit_flux ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_sp_trunc_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_rollback_heap +ALTER TABLE rx_sp_trunc_act_rollback_heap ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_sp_trunc_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_rollback_flux +ALTER TABLE rx_sp_trunc_act_rollback_flux ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +BEGIN +CREATE TABLE rx_inplace_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +SAVEPOINT a +INSERT INTO rx_inplace_commit_heap SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_commit_heap +INSERT INTO rx_inplace_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +BEGIN +CREATE TABLE rx_inplace_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +SAVEPOINT a +INSERT INTO rx_inplace_commit_flux SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_commit_flux +INSERT INTO rx_inplace_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +BEGIN +CREATE TABLE rx_inplace_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +SAVEPOINT a +INSERT INTO rx_inplace_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_rollback_heap +INSERT INTO rx_inplace_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +BEGIN +CREATE TABLE rx_inplace_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +SAVEPOINT a +INSERT INTO rx_inplace_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_rollback_flux +INSERT INTO rx_inplace_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_sp_rewrite_rb_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_rewrite_rb_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_commit_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_commit_heap SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_commit_heap +ROLLBACK TO b +COMMIT +CREATE TABLE rx_sp_rewrite_rb_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_rewrite_rb_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_commit_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_commit_flux SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_commit_flux +ROLLBACK TO b +COMMIT +CREATE TABLE rx_sp_rewrite_rb_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_rewrite_rb_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_rollback_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_rollback_heap SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_rollback_heap +ROLLBACK TO b +ROLLBACK +CREATE TABLE rx_sp_rewrite_rb_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_rewrite_rb_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_rollback_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_rollback_flux SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_rollback_flux +ROLLBACK TO b +ROLLBACK +\pset format unaligned +SELECT name, fin, heap, CASE WHEN flux = heap THEN 'same' ELSE flux END AS flux +FROM (SELECT c.n, c.name, f.fin, + rx_check(format('rx_%s_%s_heap', c.name, lower(f.fin))) AS heap, + rx_check(format('rx_%s_%s_flux', c.name, lower(f.fin))) AS flux + FROM rx_case c, (VALUES ('COMMIT'), ('ROLLBACK')) f(fin)) r +ORDER BY n, fin; +name|fin|heap|flux +fh|COMMIT|rows=40 ids=820 ks=820 good=40 lock=40 upd=40 del=40 amcheck=0|same +fh|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +fhf|COMMIT|rows=40 ids=820 ks=820 good=40 lock=40 upd=40 del=40 amcheck=0|same +fhf|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_fh|COMMIT|rows=40 ids=1220 ks=1220 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_fh|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_trunc|COMMIT|rows=40 ids=820 ks=4820 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_trunc|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +act|COMMIT|rows=40 ids=820 ks=820 good=40 lock=40 upd=40 del=40 amcheck=0|same +act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_act|COMMIT|rows=40 ids=1220 ks=1220 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +cluster|COMMIT|rows=36 ids=770 ks=1570 good=36 lock=36 upd=36 del=36 amcheck=0|same +cluster|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc_act|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc_act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_trunc_act|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_trunc_act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +inplace|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +inplace|ROLLBACK|no table|same +sp_rewrite_rb|COMMIT|rows=40 ids=820 ks=2820 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_rewrite_rb|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +(24 rows) +\pset format aligned +-- Clean up. +SELECT format('DROP TABLE %I', relname) FROM pg_class +WHERE relname ~ '^rx_.*_(heap|flux)$' AND relkind = 'r' ORDER BY 1 \gexec +DROP TABLE rx_act_commit_flux +DROP TABLE rx_act_commit_heap +DROP TABLE rx_act_rollback_flux +DROP TABLE rx_act_rollback_heap +DROP TABLE rx_cluster_commit_flux +DROP TABLE rx_cluster_commit_heap +DROP TABLE rx_cluster_rollback_flux +DROP TABLE rx_cluster_rollback_heap +DROP TABLE rx_fh_commit_flux +DROP TABLE rx_fh_commit_heap +DROP TABLE rx_fh_rollback_flux +DROP TABLE rx_fh_rollback_heap +DROP TABLE rx_fhf_commit_flux +DROP TABLE rx_fhf_commit_heap +DROP TABLE rx_fhf_rollback_flux +DROP TABLE rx_fhf_rollback_heap +DROP TABLE rx_inplace_commit_flux +DROP TABLE rx_inplace_commit_heap +DROP TABLE rx_sp_act_commit_flux +DROP TABLE rx_sp_act_commit_heap +DROP TABLE rx_sp_act_rollback_flux +DROP TABLE rx_sp_act_rollback_heap +DROP TABLE rx_sp_fh_commit_flux +DROP TABLE rx_sp_fh_commit_heap +DROP TABLE rx_sp_fh_rollback_flux +DROP TABLE rx_sp_fh_rollback_heap +DROP TABLE rx_sp_rewrite_rb_commit_flux +DROP TABLE rx_sp_rewrite_rb_commit_heap +DROP TABLE rx_sp_rewrite_rb_rollback_flux +DROP TABLE rx_sp_rewrite_rb_rollback_heap +DROP TABLE rx_sp_trunc_act_commit_flux +DROP TABLE rx_sp_trunc_act_commit_heap +DROP TABLE rx_sp_trunc_act_rollback_flux +DROP TABLE rx_sp_trunc_act_rollback_heap +DROP TABLE rx_sp_trunc_commit_flux +DROP TABLE rx_sp_trunc_commit_heap +DROP TABLE rx_sp_trunc_rollback_flux +DROP TABLE rx_sp_trunc_rollback_heap +DROP TABLE rx_trunc_act_commit_flux +DROP TABLE rx_trunc_act_commit_heap +DROP TABLE rx_trunc_act_rollback_flux +DROP TABLE rx_trunc_act_rollback_heap +DROP TABLE rx_trunc_commit_flux +DROP TABLE rx_trunc_commit_heap +DROP TABLE rx_trunc_rollback_flux +DROP TABLE rx_trunc_rollback_heap +DROP FUNCTION rx_script(), rx_check(text), rx_val(int); +DROP TABLE rx_case; +SET client_min_messages = warning; +DROP SCHEMA rx_amcheck CASCADE; +RESET client_min_messages; diff --git a/src/test/regress/expected/flux_tidbitmap.out b/src/test/regress/expected/flux_tidbitmap.out new file mode 100644 index 0000000000000..9822a68e9c5c5 --- /dev/null +++ b/src/test/regress/expected/flux_tidbitmap.out @@ -0,0 +1,129 @@ +-- +-- FLUX: line-pointer offsets stay within MaxHeapTuplesPerPage. +-- +-- tidbitmap.c sizes each exact page entry for TBM_MAX_TUPLES_PER_PAGE == +-- MaxHeapTuplesPerPage offsets, for every table AM. FLUX must therefore +-- never place a tuple at a higher offset, or a bitmap scan over that page +-- fails with "tuple offset out of range". The narrowest FLUX rows cannot get +-- there by geometry alone (every item carries a 36-byte tuple header), but +-- line pointers left LP_DEAD by pruning can: they are not reused until VACUUM, +-- so repeated insert/delete churn on one page keeps appending new ones. +-- +CREATE FUNCTION tbm_off(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[1]::int $$; +CREATE FUNCTION tbm_blk(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[0]::int $$; +-- MaxHeapTuplesPerPage: (BLCKSZ - page header) / (tuple header + line pointer) +CREATE FUNCTION tbm_max_off() RETURNS int + LANGUAGE sql STABLE AS $$ SELECT (current_setting('block_size')::int - 24) / 28 $$; +-- narrowest rows: a page fills up by space, well under the offset limit +CREATE TABLE tbm_z0 () USING flux; +INSERT INTO tbm_z0 SELECT FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_z0 WHERE tbm_blk(ctid) = 0; + some_on_blk0 | blk0_fits +--------------+----------- + t | t +(1 row) + +CREATE TABLE tbm_b (b bool) USING flux; +CREATE INDEX tbm_b_idx ON tbm_b (b); +INSERT INTO tbm_b SELECT true FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_b WHERE tbm_blk(ctid) = 0; + some_on_blk0 | blk0_fits +--------------+----------- + t | t +(1 row) + +-- insert/delete churn: pruning leaves LP_DEAD line pointers behind +CREATE TABLE tbm_churn (i int) USING flux WITH (autovacuum_enabled = off); +CREATE INDEX tbm_churn_idx ON tbm_churn (i); +CREATE TABLE tbm_maxoff (round int, maxoff int); +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 1, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 2, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 3, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 4, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 5, max(tbm_off(ctid)) FROM tbm_churn; +SELECT round, maxoff <= tbm_max_off() AS fits FROM tbm_maxoff ORDER BY round; + round | fits +-------+------ + 1 | t + 2 | t + 3 | t + 4 | t + 5 | t +(5 rows) + +SELECT count(*) AS beyond_max_off FROM tbm_churn WHERE tbm_off(ctid) > tbm_max_off(); + beyond_max_off +---------------- + 0 +(1 row) + +-- a bitmap scan over every page returns every row +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +EXPLAIN (COSTS OFF) SELECT count(*) FROM tbm_churn WHERE i > 0; + QUERY PLAN +------------------------------------------------ + Aggregate + -> Bitmap Heap Scan on tbm_churn + Recheck Cond: (i > 0) + -> Bitmap Index Scan on tbm_churn_idx + Index Cond: (i > 0) +(5 rows) + +SELECT count(*) FROM tbm_churn WHERE i > 0; + count +------- + 150 +(1 row) + +SELECT count(*) FROM tbm_b WHERE b; + count +------- + 400 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_indexonlyscan; +DROP TABLE tbm_z0, tbm_b, tbm_churn, tbm_maxoff; +DROP FUNCTION tbm_off, tbm_blk, tbm_max_off; diff --git a/src/test/regress/expected/flux_toast_foreign.out b/src/test/regress/expected/flux_toast_foreign.out new file mode 100644 index 0000000000000..0af266677a5c1 --- /dev/null +++ b/src/test/regress/expected/flux_toast_foreign.out @@ -0,0 +1,462 @@ +-- +-- FLUX and foreign TOAST pointers. +-- +-- A value read from another table can reach a table AM as an on-disk TOAST +-- pointer into that table's TOAST relation. The AM must not store such a +-- pointer verbatim: the value has to go into the target's own TOAST relation +-- (or inline), otherwise dropping the source makes the row unreadable. heap +-- does this in heap_toast_insert_or_update. Likewise an oversized inline +-- value must be compressed/externalized the way heap would. +-- +-- Every path below runs against heap (the control) and FLUX. The sources are +-- then dropped and each row is re-read and compared by md5 and length, every +-- external pointer is checked to resolve in the table's own TOAST relation, +-- and the stored shape (size, compression, external or not) of every FLUX row +-- is compared with its heap twin. +-- +\getenv abs_builddir PG_ABS_BUILDDIR +\set tf_file :abs_builddir '/results/flux_toast_foreign.data' +SELECT set_config('tf.file', :'tf_file', false) IS NOT NULL AS ok, + set_config('tf.cut', '0', false) IS NOT NULL AS ok; + ok | ok +----+---- + t | t +(1 row) + +-- Row 1: 100 kB incompressible EXTERNAL v, 1 MB compressible w (external, +-- compressed). Rows 2-3: 100 kB v. Row 4: short v, 5000-byte compressible w +-- (inline compressed in the source). +CREATE TABLE heap_src (id int PRIMARY KEY, v text, w text) USING heap; +ALTER TABLE heap_src ALTER COLUMN v SET STORAGE EXTERNAL; +INSERT INTO heap_src +SELECT g, + CASE WHEN g < 4 THEN (SELECT string_agg(md5((g * 10000 + i)::text), '') + FROM generate_series(1, 3125) i) + ELSE 'short' END, + CASE g WHEN 1 THEN repeat(md5('w'), 32768) WHEN 2 THEN NULL + WHEN 3 THEN 'w3' ELSE repeat('x', 5000) END +FROM generate_series(1, 4) g; +SELECT id, length(v) AS lv, + pg_column_toast_chunk_id(v) IS NOT NULL AS v_ext, + pg_column_compression(v) IS NOT NULL AS v_cmp, + length(w) AS lw, + pg_column_toast_chunk_id(w) IS NOT NULL AS w_ext, + pg_column_compression(w) IS NOT NULL AS w_cmp +FROM heap_src ORDER BY id; + id | lv | v_ext | v_cmp | lw | w_ext | w_cmp +----+--------+-------+-------+---------+-------+------- + 1 | 100000 | t | f | 1048576 | t | t + 2 | 100000 | t | f | | f | f + 3 | 100000 | t | f | 2 | f | f + 4 | 5 | f | f | 5000 | f | t +(4 rows) + +CREATE TABLE tf_ref AS + SELECT id, length(v) AS lv, md5(v) AS mv, length(w) AS lw, md5(w) AS mw + FROM heap_src; +-- The reverse-direction source: FLUX values in FLUX's own TOAST relation. +CREATE TABLE flux_src USING flux AS SELECT * FROM heap_src; +COPY heap_src TO :'tf_file'; +\set tf_file1 :abs_builddir '/results/flux_toast_foreign_1.data' +SELECT set_config('tf.file1', :'tf_file1', false) IS NOT NULL AS ok; + ok +---- + t +(1 row) + +COPY (SELECT 1 FROM generate_series(1, 4)) TO :'tf_file1'; +-- STABLE, so COPY keeps its multi-insert path for a column defaulting to it; +-- the result is heap_src's TOAST pointer, not a detoasted copy. +-- Materialized views read heap_src through dynamic SQL, so that heap_src can +-- be dropped while they exist. +CREATE FUNCTION tf_src(cut int) RETURNS TABLE (id int, v text, w text) + LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY EXECUTE 'SELECT * FROM public.heap_src WHERE id <= $1' USING cut; +END $$; +CREATE FUNCTION tf_def_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_def_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM flux_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM flux_src WHERE id = 1'; +-- Row count, rows whose content differs from tf_ref, and rows holding an +-- external pointer that does not resolve in the table's own TOAST relation. +CREATE FUNCTION tf_check(tbl regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + toast regclass; + nrows int; + nbad int; + nforeign int; +BEGIN + SELECT reltoastrelid::regclass INTO toast FROM pg_class WHERE oid = tbl; + EXECUTE format($q$ + SELECT count(*), + count(*) FILTER (WHERE r.id IS NULL + OR length(t.v) IS DISTINCT FROM r.lv + OR md5(t.v) IS DISTINCT FROM r.mv + OR length(t.w) IS DISTINCT FROM r.lw + OR md5(t.w) IS DISTINCT FROM r.mw), + count(*) FILTER (WHERE + (pg_column_toast_chunk_id(t.v) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.v)::text)) + OR (pg_column_toast_chunk_id(t.w) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.w)::text))) + FROM %1$s t LEFT JOIN tf_ref r USING (id)$q$, tbl, toast) + INTO nrows, nbad, nforeign; + RETURN format('rows=%s bad=%s foreign=%s', nrows, nbad, nforeign); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; +-- How each value is stored: compressed, external, and whether it is held +-- inline at more than the TOAST target. (Exact sizes differ by design: FLUX +-- does not convert short varlenas to 1-byte headers.) +CREATE FUNCTION tf_shape(tbl regclass) +RETURNS TABLE (id int, bv bool, cv bool, ev bool, bw bool, cw bool, ew bool) +LANGUAGE plpgsql AS $$ +BEGIN + IF tbl IS NULL THEN + RETURN; + END IF; + RETURN QUERY EXECUTE format($q$ + SELECT id, pg_column_size(v) > 2000 AND pg_column_toast_chunk_id(v) IS NULL, + pg_column_compression(v) IS NOT NULL, + pg_column_toast_chunk_id(v) IS NOT NULL, + pg_column_size(w) > 2000 AND pg_column_toast_chunk_id(w) IS NULL, + pg_column_compression(w) IS NOT NULL, + pg_column_toast_chunk_id(w) IS NOT NULL + FROM %s$q$, tbl); +END $$; +-- Run one path's statements against table tbl of access method am. A failure +-- is reported as a NOTICE and leaves the table absent from the listings. +CREATE FUNCTION tf_path(tbl text, am text, VARIADIC stmts text[]) +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + s text; +BEGIN + FOREACH s IN ARRAY stmts LOOP + EXECUTE replace(replace(s, '$T', quote_ident(tbl)), '$AM', am); + END LOOP; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE '%: %', tbl, regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; +-- Phase 1: build and load. One transaction. +DO $do$ +DECLARE + am text; + cols text := '(id int, v text, w text)'; + pk text := '(id int PRIMARY KEY, v text, w text)'; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + -- INSERT ... SELECT, one row and several rows + PERFORM tf_path('tf_ins1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src WHERE id = 1'); + PERFORM tf_path('tf_insm_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- INSERT ... ON CONFLICT DO NOTHING (speculative insertion) + PERFORM tf_path('tf_ocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same into a partition + PERFORM tf_path('tf_ocnp_' || am, am, + 'CREATE TABLE $T ' || pk || ' PARTITION BY LIST (id)', + 'CREATE TABLE tf_ocnp_part_$AM PARTITION OF $T DEFAULT USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same with oversized inline values + PERFORM tf_path('tf_ocbig_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, v || '''', w || '''' FROM heap_src ON CONFLICT DO NOTHING'); + -- ON CONFLICT DO UPDATE: row 1 exists (committed before phase 2) + PERFORM tf_path('tf_ocu_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T VALUES (1, ''a'', ''b'')'); + -- UPDATE targets, loaded with placeholders or own values + PERFORM tf_path('tf_upd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + PERFORM tf_path('tf_upd1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', w FROM heap_src'); + PERFORM tf_path('tf_updk_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + -- same-size in-place UPDATE of a MAIN column (CAS fast path in FLUX) + PERFORM tf_path('tf_cas_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'ALTER TABLE $T ALTER COLUMN w SET STORAGE MAIN', + 'INSERT INTO $T SELECT 4, ''short'', left(string_agg(md5(i::text), ''''), 5000) + FROM generate_series(1, 200) i'); + -- COPY FROM a file (multi_insert) + PERFORM tf_path('tf_copy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + format('COPY $T FROM %L', current_setting('tf.file'))); + PERFORM tf_path('tf_pcopy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM'); + -- COPY FROM with defaults that return heap_src's pointers (multi_insert), + -- into a plain table and into a partition + PERFORM tf_path('tf_cdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_cdefp_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) PARTITION BY LIST (id)', + 'CREATE TABLE tf_cdefp_part_$AM PARTITION OF $T DEFAULT USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + -- CREATE TABLE AS + PERFORM tf_path('tf_ctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM heap_src'); + -- materialized views: plain REFRESH, and REFRESH CONCURRENTLY from empty + PERFORM tf_path('tf_mv_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS SELECT * FROM tf_src(4)', + 'REFRESH MATERIALIZED VIEW $T'); + PERFORM tf_path('tf_mvc_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS + SELECT * FROM tf_src(current_setting(''tf.cut'')::int)', + 'CREATE UNIQUE INDEX ON $T (id)', + 'SELECT set_config(''tf.cut'', ''4'', false)', + 'REFRESH MATERIALIZED VIEW CONCURRENTLY $T', + 'SELECT set_config(''tf.cut'', ''0'', false)'); + -- plpgsql: scalar variables and a record variable + PERFORM tf_path('tf_plv_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE xv text; xw text; BEGIN + FOR i IN 1..4 LOOP + SELECT v, w INTO xv, xw FROM heap_src WHERE id = i; + INSERT INTO $T VALUES (i, xv, xw); + END LOOP; END $p$'); + PERFORM tf_path('tf_plr_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE r record; BEGIN + FOR r IN SELECT * FROM heap_src LOOP + INSERT INTO $T VALUES (r.id, r.v, r.w); + END LOOP; END $p$'); + -- SET ACCESS METHOD to the other AM, and there and back + PERFORM tf_path('tf_sam_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END); + PERFORM tf_path('tf_samrt_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END, + 'ALTER TABLE $T SET ACCESS METHOD $AM'); + -- VACUUM FULL target (run below, outside a transaction block) + PERFORM tf_path('tf_vf_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- reverse direction: FLUX values into heap and into another FLUX table + PERFORM tf_path('tf_rins_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src'); + PERFORM tf_path('tf_rocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src ON CONFLICT DO NOTHING'); + PERFORM tf_path('tf_rcdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_rdef_v(), + w text DEFAULT tf_rdef_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_rctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM flux_src'); + PERFORM tf_path('tf_rupd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM flux_src'); + END LOOP; +END +$do$; +-- Phase 2: UPDATEs against committed rows. +DO $do$ +DECLARE + am text; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + PERFORM tf_path('tf_ocu_' || am, am, + 'INSERT INTO $T SELECT * FROM heap_src + ON CONFLICT (id) DO UPDATE SET v = EXCLUDED.v, w = EXCLUDED.w'); + PERFORM tf_path('tf_upd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_upd1_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_updk_' || am, am, + 'UPDATE $T t SET id = -t.id, + v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)', + 'UPDATE $T SET id = -id'); + PERFORM tf_path('tf_rupd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM flux_src s WHERE s.id = t.id), + w = (SELECT w FROM flux_src s WHERE s.id = t.id)'); + END LOOP; +END +$do$; +UPDATE tf_cas_heap SET w = repeat('x', 5000); +UPDATE tf_cas_flux SET w = repeat('x', 5000); +-- psql's \copy takes its arguments verbatim, so use a relative path. +\cd :abs_builddir +\copy tf_pcopy_heap FROM 'results/flux_toast_foreign.data' +\copy tf_pcopy_flux FROM 'results/flux_toast_foreign.data' +VACUUM FULL tf_vf_heap; +VACUUM FULL tf_vf_flux; +-- Before the drop. Each check query lists only failures. +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE (c.relname LIKE 'tf\_%' OR c.relname IN ('heap_src', 'flux_src')) + AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none + relname | amname | res +---------+--------+----- +(0 rows) + +-- Storage shape: every FLUX table must store its rows the way its heap twin +-- does. Lists the rows that differ. +SELECT p.path, d.* +FROM (SELECT left(relname, -5) AS path FROM pg_class + WHERE relname LIKE 'tf\_%\_heap' AND relkind IN ('r', 'm')) p, + LATERAL (SELECT 'heap' AS side, * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_heap')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_flux'))) x + UNION ALL + SELECT 'flux', * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_flux')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_heap'))) y) d +ORDER BY 1, 2, 3; -- expect none + path | side | id | bv | cv | ev | bw | cw | ew +------+------+----+----+----+----+----+----+---- +(0 rows) + +DROP TABLE heap_src, flux_src; +-- After the drop, in a new session. +\c +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE c.relname LIKE 'tf\_%' AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none + relname | amname | res +---------+--------+----- +(0 rows) + +-- 27 paths, each on heap and flux; a path that failed is missing. +SELECT n FROM (SELECT count(*) AS n FROM pg_class + WHERE relname LIKE 'tf\_%' AND relname <> 'tf_ref' + AND relkind IN ('r', 'm')) s +WHERE n <> 54; -- expect none + n +--- +(0 rows) + +-- +-- FLUX commit-time work after the relation's storage was replaced in the same +-- transaction. Rows written to the old storage must not be confused with the +-- tuples at the same TIDs in the new storage. (VACUUM FULL cannot run in a +-- transaction block; CLUSTER is the in-transaction rewrite.) +-- +CREATE TABLE tr_ref AS + SELECT g AS id, repeat(md5(g::text), 150) AS w FROM generate_series(1, 20) g; +CREATE FUNCTION tr_bad(tbl regclass) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM + ((SELECT id::int, w FROM %1$s EXCEPT ALL SELECT * FROM tr_ref) + UNION ALL + (SELECT * FROM tr_ref EXCEPT ALL SELECT id::int, w FROM %1$s)) x', tbl) + INTO n; + RETURN n; +EXCEPTION WHEN OTHERS THEN + RETURN -1; +END $$; +CREATE TABLE tr_fh (id int, w text) USING flux; +CREATE TABLE tr_fhf (id int, w text) USING flux; +CREATE TABLE tr_cl (id int PRIMARY KEY, w text) USING flux; +CREATE TABLE tr_act (id int, w text) USING flux; +CREATE TABLE tr_trunc (id int, w text) USING flux; +CREATE TABLE tr_spa (id int, w text) USING flux; +CREATE TABLE tr_spr (id int, w text) USING flux; +CREATE TABLE tr_spr2 (id int, w text) USING flux; +-- flux -> heap +BEGIN; +INSERT INTO tr_fh SELECT * FROM tr_ref; +ALTER TABLE tr_fh SET ACCESS METHOD heap; +COMMIT; +-- flux -> heap -> flux +BEGIN; +INSERT INTO tr_fhf SELECT * FROM tr_ref; +ALTER TABLE tr_fhf SET ACCESS METHOD heap; +ALTER TABLE tr_fhf SET ACCESS METHOD flux; +COMMIT; +-- CLUSTER (reorders the rows, so TIDs name other rows) +BEGIN; +INSERT INTO tr_cl SELECT * FROM tr_ref ORDER BY id DESC; +CLUSTER tr_cl USING tr_cl_pkey; +COMMIT; +-- ALTER COLUMN TYPE +BEGIN; +INSERT INTO tr_act SELECT * FROM tr_ref; +ALTER TABLE tr_act ALTER COLUMN id TYPE bigint; +COMMIT; +-- rows rolled back to a savepoint, then TRUNCATE and refill the same TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_trunc SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +TRUNCATE tr_trunc; +INSERT INTO tr_trunc SELECT * FROM tr_ref; +COMMIT; +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spa SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spa SELECT * FROM tr_ref; +ALTER TABLE tr_spa ALTER COLUMN id TYPE bigint; +COMMIT; +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again and its commit-time work must still be done +BEGIN; +INSERT INTO tr_spr SELECT * FROM tr_ref; +SAVEPOINT a; +ALTER TABLE tr_spr SET ACCESS METHOD heap; +ROLLBACK TO a; +COMMIT; +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spr2 SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spr2 SELECT * FROM tr_ref; +SAVEPOINT b; +ALTER TABLE tr_spr2 ALTER COLUMN id TYPE bigint; +ROLLBACK TO b; +COMMIT; +SELECT t, tr_bad(t::regclass) AS bad +FROM unnest(ARRAY['tr_fh', 'tr_fhf', 'tr_cl', 'tr_act', 'tr_trunc', 'tr_spa', + 'tr_spr', 'tr_spr2']) t +WHERE tr_bad(t::regclass) <> 0 +ORDER BY 1; -- expect none + t | bad +---+----- +(0 rows) + +DROP TABLE tr_fh, tr_fhf, tr_cl, tr_act, tr_trunc, tr_spa, tr_spr, tr_spr2, tr_ref; +DROP FUNCTION tr_bad(regclass); +-- Clean up. +DO $do$ +DECLARE + r record; +BEGIN + FOR r IN SELECT relname, relkind FROM pg_class + WHERE relname LIKE 'tf\_%' AND relkind IN ('r', 'm', 'p') + AND NOT relispartition LOOP + EXECUTE format('DROP %s %I', + CASE r.relkind WHEN 'm' THEN 'MATERIALIZED VIEW' ELSE 'TABLE' END, + r.relname); + END LOOP; +END +$do$; +DROP FUNCTION tf_check(regclass), tf_shape(regclass), tf_path(text, text, text[]), + tf_def_v(), tf_def_w(), tf_rdef_v(), tf_rdef_w(), tf_src(int); diff --git a/src/test/regress/expected/flux_trigger_memory.out b/src/test/regress/expected/flux_trigger_memory.out new file mode 100644 index 0000000000000..8a551cb17bcd1 --- /dev/null +++ b/src/test/regress/expected/flux_trigger_memory.out @@ -0,0 +1,115 @@ +-- +-- Memory held for AFTER ROW trigger events across a long transaction. +-- +-- A FLUX row event carries copies of its row images (the TID names only the +-- newest version). Those copies must go away with the event: an immediate +-- event is gone at the end of its statement, an event of an aborted +-- subtransaction is gone at its rollback. Each case runs many top-level +-- statements in one transaction and compares the after-trigger event memory +-- after the first statement with that after the last; heap is the control. +-- +CREATE FUNCTION tm_ctx_bytes() RETURNS bigint LANGUAGE sql AS $$ + SELECT coalesce(sum(total_bytes), 0)::bigint FROM pg_backend_memory_contexts + WHERE name LIKE 'AfterTrigger%' +$$; +CREATE TEMP TABLE tm_mem (k text, n int, bytes bigint); +CREATE FUNCTION tm_noop() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NULL; +END $$; +-- Immediate AFTER UPDATE row trigger +CREATE TABLE tm_trig_heap (id int PRIMARY KEY, v int) USING heap; +CREATE TABLE tm_trig_flux (id int PRIMARY KEY, v int) USING flux; +INSERT INTO tm_trig_heap SELECT g, 0 FROM generate_series(1, 1000) g; +INSERT INTO tm_trig_flux SELECT g, 0 FROM generate_series(1, 1000) g; +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_heap + FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_flux + FOR EACH ROW EXECUTE FUNCTION tm_noop(); +-- Immediate foreign key check (RI trigger on the referencing table) +CREATE TABLE tm_pk_heap (id int PRIMARY KEY) USING heap; +CREATE TABLE tm_pk_flux (id int PRIMARY KEY) USING flux; +INSERT INTO tm_pk_heap VALUES (1), (2); +INSERT INTO tm_pk_flux VALUES (1), (2); +CREATE TABLE tm_fk_heap (id int PRIMARY KEY, pid int REFERENCES tm_pk_heap) + USING heap; +CREATE TABLE tm_fk_flux (id int PRIMARY KEY, pid int REFERENCES tm_pk_flux) + USING flux; +INSERT INTO tm_fk_heap SELECT g, 1 FROM generate_series(1, 1000) g; +INSERT INTO tm_fk_flux SELECT g, 1 FROM generate_series(1, 1000) g; +-- Each case: one transaction of 200 top-level UPDATEs of 1000 rows (each in a +-- rolled-back savepoint for the subxact cases), recording the event memory +-- after the first and after the last. +\set ECHO none +-- Deferred trigger events of rolled-back subtransactions +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +\set ECHO none +-- One statement's FLUX images take about 250 kB, so keeping them for the +-- transaction grows the context by ~50 MB here. Heap grows by well under +-- 1 MB (per-statement bookkeeping, e.g. the modified-columns bitmaps). +SELECT a.k, b.bytes - a.bytes < 4 * 1024 * 1024 AS flat +FROM tm_mem a JOIN tm_mem b ON a.k = b.k AND a.n = 1 AND b.n > 1 +ORDER BY a.k; + k | flat +--------------+------ + fk_flux | t + fk_heap | t + subxact_flux | t + subxact_heap | t + trig_flux | t + trig_heap | t +(6 rows) + +-- The deferred events of a committed statement still fire, with their images +CREATE TABLE tm_log (tab text, o int, n int); +CREATE FUNCTION tm_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO tm_log VALUES (TG_TABLE_NAME, OLD.v, NEW.v); + RETURN NULL; +END $$; +UPDATE tm_trig_heap SET v = 0; +UPDATE tm_trig_flux SET v = 0; +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +BEGIN; +UPDATE tm_trig_heap SET v = 1000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 1000 WHERE id <= 3; +SAVEPOINT s1; +UPDATE tm_trig_heap SET v = 2000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 2000 WHERE id <= 3; +ROLLBACK TO s1; +SAVEPOINT s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 3; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 3; +RELEASE s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 2; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 2; +COMMIT; +SELECT tab, o, n, count(*) FROM tm_log GROUP BY 1, 2, 3 ORDER BY 1, 2, 3; + tab | o | n | count +--------------+------+------+------- + tm_trig_flux | 0 | 1000 | 3 + tm_trig_flux | 1000 | 1001 | 3 + tm_trig_flux | 1001 | 1002 | 2 + tm_trig_heap | 0 | 1000 | 3 + tm_trig_heap | 1000 | 1001 | 3 + tm_trig_heap | 1001 | 1002 | 2 +(6 rows) + +SELECT h.id, h.v AS heap_v, f.v AS flux_v +FROM tm_trig_heap h JOIN tm_trig_flux f USING (id) WHERE id <= 3 ORDER BY id; + id | heap_v | flux_v +----+--------+-------- + 1 | 1002 | 1002 + 2 | 1002 | 1002 + 3 | 1001 | 1001 +(3 rows) + +DROP TABLE tm_trig_heap, tm_trig_flux, tm_fk_heap, tm_fk_flux, tm_pk_heap, + tm_pk_flux, tm_log; +DROP FUNCTION tm_ctx_bytes(), tm_noop(), tm_log_row(); diff --git a/src/test/regress/expected/flux_user_catalog.out b/src/test/regress/expected/flux_user_catalog.out new file mode 100644 index 0000000000000..7d70ca67646b6 --- /dev/null +++ b/src/test/regress/expected/flux_user_catalog.out @@ -0,0 +1,38 @@ +-- +-- FLUX: user_catalog_table is refused. +-- +-- A user_catalog_table is read by logical decoding under a historic snapshot +-- that reconstructs a past catalog state from the heap's dead row versions. +-- FLUX keeps old versions in undo and reuses the tuple in place, so it cannot +-- answer those historic-snapshot reads. Setting user_catalog_table on a FLUX +-- relation must therefore be refused, at CREATE, at ALTER TABLE ... SET, and +-- when converting a marked heap table to FLUX with SET ACCESS METHOD. The heap +-- twin of each case must still succeed, which is the control: the refusal is +-- specific to the FLUX access method, not to the option itself. +-- +-- CREATE: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_create (id int) USING heap + WITH (user_catalog_table = true); +CREATE TABLE uct_flux_create (id int) USING flux + WITH (user_catalog_table = true); +ERROR: user_catalog_table is not supported for the "flux" access method +HINT: Put logical-decoding plugin metadata in a table that uses the "heap" access method. +-- ALTER TABLE ... SET: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_set (id int) USING heap; +ALTER TABLE uct_heap_set SET (user_catalog_table = true); +CREATE TABLE uct_flux_set (id int) USING flux; +ALTER TABLE uct_flux_set SET (user_catalog_table = true); +ERROR: user_catalog_table is not supported for the "flux" access method +HINT: Put logical-decoding plugin metadata in a table that uses the "heap" access method. +-- SET ACCESS METHOD: converting a marked heap table to FLUX is refused with the +-- FLUX-specific message, before core's generic "cannot rewrite a catalog table" +-- restriction is reached. +CREATE TABLE uct_conv (id int) USING heap WITH (user_catalog_table = true); +ALTER TABLE uct_conv SET ACCESS METHOD flux; +ERROR: user_catalog_table is not supported for the "flux" access method +HINT: Put logical-decoding plugin metadata in a table that uses the "heap" access method. +-- Clearing the option, and other FLUX reloptions, still work on a FLUX table. +CREATE TABLE uct_flux_clear (id int) USING flux WITH (fillfactor = 90); +ALTER TABLE uct_flux_clear SET (user_catalog_table = false); +ALTER TABLE uct_flux_clear SET (fillfactor = 80); +DROP TABLE uct_heap_create, uct_heap_set, uct_flux_set, uct_conv, uct_flux_clear; diff --git a/src/test/regress/expected/flux_vacuum_stats.out b/src/test/regress/expected/flux_vacuum_stats.out new file mode 100644 index 0000000000000..928f7844318e1 --- /dev/null +++ b/src/test/regress/expected/flux_vacuum_stats.out @@ -0,0 +1,100 @@ +-- A one-page heap twin makes physical page counts comparable despite headers. +CREATE TABLE fvs_heap (a int PRIMARY KEY) USING heap WITH (autovacuum_enabled=false); +CREATE TABLE fvs_flux (a int PRIMARY KEY) USING flux WITH (autovacuum_enabled=false); +INSERT INTO fvs_heap SELECT generate_series(1,20); +INSERT INTO fvs_flux SELECT generate_series(1,20); +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, relpages = 1 AS pages, reltuples = 20 AS tuples, + relallvisible = 1 AS visible +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | pages | tuples | visible +----------+-------+--------+--------- + fvs_flux | t | t | t + fvs_heap | t | t | t +(2 rows) + +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +SELECT relname, vacuum_count = 1 AS vacuum_reported +FROM pg_stat_user_tables WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | vacuum_reported +----------+----------------- + fvs_flux | t + fvs_heap | t +(2 rows) + +-- Burn an xid so a no-op freeze cannot pass on a newly created relation. +SELECT pg_current_xact_id() IS NOT NULL AS assigned; + assigned +---------- + t +(1 row) + +VACUUM (FREEZE) fvs_heap; +VACUUM (FREEZE) fvs_flux; +SELECT relname, age(relfrozenxid) <= 1 AS frozen, + relallfrozen = 1 AS frozen_page +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | frozen | frozen_page +----------+--------+------------- + fvs_flux | t | t + fvs_heap | t | t +(2 rows) + +-- A repeated vacuum must count pages skipped as all-frozen, not report zero. +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | tuples +----------+-------- + fvs_flux | t + fvs_heap | t +(2 rows) + +-- Force bulk-delete so both index AMs return accurate cleanup statistics. +INSERT INTO fvs_heap VALUES (99); +INSERT INTO fvs_flux VALUES (99); +DELETE FROM fvs_heap WHERE a=99; +DELETE FROM fvs_flux WHERE a=99; +VACUUM (INDEX_CLEANUP ON) fvs_heap; +VACUUM (INDEX_CLEANUP ON) fvs_flux; +SELECT relname, relpages > 0 AS pages, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap_pkey', 'fvs_flux_pkey') ORDER BY relname; + relname | pages | tuples +---------------+-------+-------- + fvs_flux_pkey | t | t + fvs_heap_pkey | t | t +(2 rows) + +-- A cursor stores a version identity before DML freezes its before-image. +BEGIN; +DECLARE fvs_h CURSOR FOR SELECT * FROM fvs_heap WHERE a=1; +DECLARE fvs_f CURSOR FOR SELECT * FROM fvs_flux WHERE a=1; +FETCH fvs_h; + a +--- + 1 +(1 row) + +FETCH fvs_f; + a +--- + 1 +(1 row) + +UPDATE fvs_heap SET a=101 WHERE CURRENT OF fvs_h; +UPDATE fvs_flux SET a=101 WHERE CURRENT OF fvs_f; +ROLLBACK; +DROP TABLE fvs_heap, fvs_flux; diff --git a/src/test/regress/expected/psql.out b/src/test/regress/expected/psql.out index e8605dd041f78..bce189fb99dd4 100644 --- a/src/test/regress/expected/psql.out +++ b/src/test/regress/expected/psql.out @@ -5207,13 +5207,14 @@ List of access methods --------+------- brin | Index btree | Index + flux | Table gin | Index gist | Index hash | Index heap | Table heap2 | Table spgist | Index -(8 rows) +(9 rows) \dA * List of access methods @@ -5221,13 +5222,14 @@ List of access methods --------+------- brin | Index btree | Index + flux | Table gin | Index gist | Index hash | Index heap | Table heap2 | Table spgist | Index -(8 rows) +(9 rows) \dA h* List of access methods @@ -5257,13 +5259,14 @@ List of access methods --------+-------+----------------------+---------------------------------------- brin | Index | brinhandler | block range index (BRIN) access method btree | Index | bthandler | b-tree index access method + flux | Table | flux_tableam_handler | flux table access method gin | Index | ginhandler | GIN index access method gist | Index | gisthandler | GiST index access method hash | Index | hashhandler | hash index access method heap | Table | heap_tableam_handler | heap table access method heap2 | Table | heap_tableam_handler | spgist | Index | spghandler | SP-GiST index access method -(8 rows) +(9 rows) \dA+ * List of access methods @@ -5271,13 +5274,14 @@ List of access methods --------+-------+----------------------+---------------------------------------- brin | Index | brinhandler | block range index (BRIN) access method btree | Index | bthandler | b-tree index access method + flux | Table | flux_tableam_handler | flux table access method gin | Index | ginhandler | GIN index access method gist | Index | gisthandler | GiST index access method hash | Index | hashhandler | hash index access method heap | Table | heap_tableam_handler | heap table access method heap2 | Table | heap_tableam_handler | spgist | Index | spghandler | SP-GiST index access method -(8 rows) +(9 rows) \dA+ h* List of access methods diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 75063f87a4a7b..97e8964dcea8e 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -72,6 +72,15 @@ test: brin gin gist spgist privileges init_privs security_label collate matview # Additional BRIN tests # ---------- test: brin_bloom brin_multi +test: flux flux_toast_foreign flux_fk flux_deferred_trigger flux_rewrite_xact +test: flux_index_aba +test: flux_dml_semantics +test: flux_cluster +test: flux_abort_ddl +test: flux_merge_delete +test: flux_tidbitmap +test: flux_trigger_memory +test: flux_user_catalog # ---------- # Another group of parallel tests @@ -146,3 +155,9 @@ test: fast_default # run tablespace test at the end because it drops the tablespace created during # setup that other tests may use. test: tablespace + +# Heap-controlled FLUX VACUUM catalog and cumulative statistics. +test: flux_vacuum_stats + +# FLUX planner size estimates, heap as control. +test: flux_estimate diff --git a/src/test/regress/sql/flux.sql b/src/test/regress/sql/flux.sql new file mode 100644 index 0000000000000..eb48ac70e215d --- /dev/null +++ b/src/test/regress/sql/flux.sql @@ -0,0 +1,790 @@ +-- +-- FLUX table access method: core functional + index-integrity tests. +-- +-- The critical property is that +-- index scans, sequential scans, and bitmap scans agree after key-changing +-- UPDATEs, including A -> B -> A recurrences. FLUX achieves this by doing a +-- non-in-place (new-TID) UPDATE whenever an indexed column changes, so +-- secondary indexes are maintained by the standard heap-TID path. +-- + +-- +-- amcheck (bt_index_check) is a contrib extension, so it may be absent when +-- the core regression suite runs without EXTRA_INSTALL=contrib/amcheck (for +-- example when another test module re-runs the schedule, or on a packager's +-- "make check"). Verify indexes with it when present; otherwise skip, so the +-- expected output is identical either way. The idxscan-vs-seqscan checks +-- below are the primary consistency assertion; amcheck is a stronger backstop. +-- +CREATE FUNCTION flux_check_index(regclass) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + PERFORM bt_index_check($1, true); + END IF; + RETURN 'ok'; +END; +$$; + +CREATE TABLE flux_basic (id int, k int, v text) USING flux; +INSERT INTO flux_basic SELECT g, g, 'v' || g FROM generate_series(1, 20) g; +CREATE INDEX flux_basic_k_idx ON flux_basic (k); + +-- non-key UPDATE (in place): TID and index unchanged +UPDATE flux_basic SET v = 'updated' WHERE id = 5; +SELECT id, k, v FROM flux_basic WHERE id = 5; + +-- key-changing UPDATEs (out of place): old index entry dies, new one inserted +UPDATE flux_basic SET k = 105 WHERE id = 5; +UPDATE flux_basic SET k = 106 WHERE id = 6; +-- A -> B -> A recurrence on an indexed key +UPDATE flux_basic SET k = 999 WHERE id = 7; +UPDATE flux_basic SET k = 7 WHERE id = 7; + +DELETE FROM flux_basic WHERE id = 20; + +-- The gate: idxscan == seqscan == bitmapscan. +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_basic; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_basic WHERE k > -2147483648; +SELECT count(*) AS idx_k_eq7 FROM flux_basic WHERE k = 7; -- 1, not 2 +SELECT count(*) AS idx_k_eq5 FROM flux_basic WHERE k = 5; -- 0 (moved to 105) +SELECT count(*) AS idx_k_eq105 FROM flux_basic WHERE k = 105; -- 1 +SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = on; +SELECT count(*) AS bmp_count, sum(k) AS bmp_sumk FROM flux_basic WHERE k > -2147483648; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; + +-- VACUUM then amcheck (heapallindexed): no error means the index is consistent +-- with the heap after key churn. +VACUUM flux_basic; +SELECT flux_check_index('flux_basic_k_idx'::regclass); + +-- ROLLBACK restores the old value (non-key) and old key + index entry (key). +BEGIN; +UPDATE flux_basic SET v = 'should_not_persist' WHERE id = 10; +ROLLBACK; +SELECT v FROM flux_basic WHERE id = 10; + +BEGIN; +UPDATE flux_basic SET k = 5000 WHERE id = 11; +ROLLBACK; +SELECT k FROM flux_basic WHERE id = 11; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_k_eq11 FROM flux_basic WHERE k = 11; -- 1 +SELECT count(*) AS idx_k_eq5000 FROM flux_basic WHERE k = 5000; -- 0 +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; + +-- TOAST: a >8KB value round-trips exactly and the relation gets a TOAST table. +CREATE TABLE flux_toast (id int, big text) USING flux; +INSERT INTO flux_toast VALUES (1, repeat('X', 100000)); +SELECT id, length(big) AS len, (big = repeat('X', 100000)) AS exact FROM flux_toast; +SELECT reltoastrelid <> 0 AS has_toast_table FROM pg_class WHERE relname = 'flux_toast'; + +DROP TABLE flux_basic; +DROP TABLE flux_toast; + +-- +-- BUG 2 regression: an in-place UPDATE of a NON-indexed column that GROWS the +-- row on a full page must NOT error with "does not fit on page". FLUX falls +-- back to an out-of-place (new-TID) update and rebuilds all index entries, so +-- the row survives and every index stays consistent with the heap. +-- +CREATE TABLE flux_grow (id int, k int, pad text, v text) USING flux + WITH (fillfactor = 100); +CREATE INDEX flux_grow_k_idx ON flux_grow (k); +-- Pack pages tight: many rows with a NULL v (narrowest) so the page is full +-- of committed tuples with no free space and no slack for in-place growth. +INSERT INTO flux_grow SELECT g, g, repeat('p', 200), NULL + FROM generate_series(1, 2000) g; +VACUUM flux_grow; +-- Grow a NON-indexed column (v: NULL -> long string) on rows across full +-- pages. Pre-fix this raised: ERROR updated flux tuple does not fit on page. +UPDATE flux_grow SET v = repeat('w', 400) WHERE id <= 2000; +-- All rows survived with the grown value. +SELECT count(*) AS grown, count(*) FILTER (WHERE v = repeat('w', 400)) AS correct + FROM flux_grow; +-- The non-indexed grow may have moved TIDs; every index must still agree with +-- the heap (idxscan == seqscan) and amcheck must pass. +VACUUM flux_grow; +SELECT flux_check_index('flux_grow_k_idx'::regclass); +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_grow; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_grow; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- A specific indexed lookup after the TID-moving grow returns exactly one row. +SELECT id, k, length(v) AS vlen FROM flux_grow WHERE k = 1000; +DROP TABLE flux_grow; + +-- +-- BUG 7/8 regression: SAVEPOINT interaction with in-place UPDATE. +-- (7) Re-UPDATE inside a savepoint a row already updated by an earlier command +-- must NOT self-wait/assert. (8) ROLLBACK TO SAVEPOINT must RESTORE the +-- pre-savepoint value of in-place updates (and undo in-savepoint deletes), +-- not vanish the row or over-revert to the original committed value. +-- +CREATE TABLE flux_sp (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_sp VALUES (1,100),(2,200),(3,300); +BEGIN; +UPDATE flux_sp SET v = 101 WHERE id = 1; -- before savepoint +SAVEPOINT s1; +UPDATE flux_sp SET v = 999 WHERE id = 1; -- re-update same row (bug 7) +DELETE FROM flux_sp WHERE id = 2; -- in-savepoint delete +UPDATE flux_sp SET v = 301 WHERE id = 3; -- in-savepoint update +ROLLBACK TO s1; -- must restore pre-savepoint state +-- Expect 1|101 (pre-savepoint update kept), 2|200 (delete undone), 3|300 (update undone) +SELECT id, v FROM flux_sp ORDER BY id; +COMMIT; +SELECT id, v FROM flux_sp ORDER BY id; +DROP TABLE flux_sp; + +-- +-- ALWAYS-IN-PLACE indexed-column UPDATE via delete-marking. +-- An UPDATE of an indexed column keeps the STABLE TID (ctid unchanged), the +-- index sees the new key (old key delete-marked), unique is respected, and +-- ROLLBACK of an in-place key update restores the old key + removes the new +-- index entry. amcheck stays clean. +-- +CREATE TABLE flux_ip (id int PRIMARY KEY, k int, v text) USING flux; +INSERT INTO flux_ip SELECT g, g, 'v'||g FROM generate_series(1,10) g; +CREATE INDEX flux_ip_k ON flux_ip (k); +CREATE UNIQUE INDEX flux_ip_uk ON flux_ip (k); +-- (a) STABLE TID: ctid identical before and after an indexed-column UPDATE. +SELECT ctid AS ip_before FROM flux_ip WHERE id = 3; +UPDATE flux_ip SET k = 333 WHERE id = 3; +SELECT ctid AS ip_after, k FROM flux_ip WHERE id = 3; -- same ctid, k=333 +-- index scan sees new key, not old; idxscan == seqscan (no duplicate rows). +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_k3 FROM flux_ip WHERE k = 3; -- 0 (moved) +SELECT count(*) AS ip_k333 FROM flux_ip WHERE k = 333; -- 1 +SELECT count(*) AS ip_idx_total, sum(k) AS ip_idx_sumk FROM flux_ip WHERE k > -2147483648; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +SELECT count(*) AS ip_seq_total, sum(k) AS ip_seq_sumk FROM flux_ip; -- must match idx +-- (d) UNIQUE respected: another row cannot take the new key. +INSERT INTO flux_ip VALUES (11, 11, 'x'); +UPDATE flux_ip SET k = 333 WHERE id = 11; -- ERROR: duplicate key +-- (c) ROLLBACK of an in-place key UPDATE restores old key and removes new entry. +SELECT ctid AS ip_roll_before FROM flux_ip WHERE id = 4; +BEGIN; +UPDATE flux_ip SET k = 4444 WHERE id = 4; +SELECT ctid AS ip_roll_intxn, k FROM flux_ip WHERE id = 4; -- same ctid, k=4444 +ROLLBACK; +SELECT ctid AS ip_roll_after, k FROM flux_ip WHERE id = 4; -- same ctid, k=4 restored +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_roll_k4 FROM flux_ip WHERE k = 4; -- 1 (restored) +SELECT count(*) AS ip_roll_k4444 FROM flux_ip WHERE k = 4444; -- 0 (new entry gone) +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- Unique still works after rollback: k=4444 free to insert, k=4 taken. +INSERT INTO flux_ip VALUES (12, 4444, 'y'); -- ok +INSERT INTO flux_ip VALUES (13, 4, 'z'); -- ERROR: duplicate key (4 live) +-- (e) amcheck clean on both indexes carrying delete-marks. +SELECT flux_check_index('flux_ip_k'::regclass); +SELECT flux_check_index('flux_ip_uk'::regclass); +DROP TABLE flux_ip; + +-- +-- Consumer-reported regressions (undo-bug-report): the AFTER-trigger OLD image +-- and the same-transaction snapshot must match heap for an in-place AM. +-- +-- BUG 1: an AFTER ROW UPDATE trigger, and RI, must see the pre-update OLD row. +CREATE TABLE flux_bug1 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug1 VALUES (1, 10); +CREATE FUNCTION flux_bug1_log() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NULL; END $$; +CREATE TRIGGER t AFTER UPDATE ON flux_bug1 FOR EACH ROW + EXECUTE FUNCTION flux_bug1_log(); +UPDATE flux_bug1 SET v = 20; -- expect NOTICE OLD.v=10 NEW.v=20 +DROP TABLE flux_bug1; +DROP FUNCTION flux_bug1_log(); + +-- BUG 1 (RI): ON UPDATE RESTRICT must refuse a parent-key change that a child +-- still references; the pre-fix in-place AM saw OLD.k==NEW.k and allowed it. +CREATE TABLE flux_pr (k int PRIMARY KEY) USING flux; +INSERT INTO flux_pr VALUES (10); +CREATE TABLE flux_fr (k int REFERENCES flux_pr ON UPDATE RESTRICT); +INSERT INTO flux_fr VALUES (10); +UPDATE flux_pr SET k = 20; -- expect ERROR: RESTRICT violation +SELECT count(*) AS flux_orphans FROM flux_fr f + WHERE NOT EXISTS (SELECT 1 FROM flux_pr p WHERE p.k = f.k); -- 0 +DROP TABLE flux_fr; DROP TABLE flux_pr; + +-- BUG 2: a snapshot taken between two in-place UPDATEs in one transaction must +-- see the first UPDATE's effect, not the last committed value. +CREATE TABLE flux_bug2 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug2 VALUES (1, 10); +BEGIN; +DECLARE c1 CURSOR FOR SELECT v FROM flux_bug2; -- before UPDATE 1 +UPDATE flux_bug2 SET v = 20; +DECLARE c2 CURSOR FOR SELECT v FROM flux_bug2; -- between the two UPDATEs +UPDATE flux_bug2 SET v = 30; +FETCH c1; -- 10 +FETCH c2; -- 20 +SELECT v FROM flux_bug2; -- 30 +COMMIT; +DROP TABLE flux_bug2; + +-- FLUX: CREATE UNIQUE INDEX must ignore rows of aborted INSERTs. +CREATE TABLE fu (id int) USING flux; +INSERT INTO fu VALUES (1); +BEGIN; INSERT INTO fu VALUES (1); ROLLBACK; +BEGIN; INSERT INTO fu VALUES (2); SAVEPOINT s; INSERT INTO fu VALUES (2); +ROLLBACK TO s; COMMIT; +CREATE UNIQUE INDEX fu_id ON fu (id); +SET enable_seqscan = off; +SELECT id FROM fu WHERE id IN (1, 2) ORDER BY id; +RESET enable_seqscan; +DROP INDEX fu_id; +-- a committed duplicate still fails +INSERT INTO fu VALUES (1); +CREATE UNIQUE INDEX fu_id ON fu (id); +DROP TABLE fu; + +-- A varlena with double alignment followed by one with int alignment: the +-- index-fetch decode must not pad after the first by its own alignment. +CREATE TABLE fal (a int8range, b text) USING flux; +CREATE INDEX fal_b ON fal (b); +INSERT INTO fal VALUES ('[7,8)', 'foo'); +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT a, b FROM fal WHERE b = 'foo'; +RESET enable_seqscan; RESET enable_bitmapscan; +DROP TABLE fal; + +DROP FUNCTION flux_check_index(regclass); + +-- Ordinary VACUUM must not move a live row to another TID. Without an index +-- FLUX's former cross-page relocation moved tail rows forward and freed their +-- slots while their writer still referenced them. +CREATE TABLE flux_vmove (id int, v int, pad text) USING flux + WITH (autovacuum_enabled = off); +INSERT INTO flux_vmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; +DELETE FROM flux_vmove WHERE id <= 300 OR id % 3 = 0; +CREATE TEMP TABLE flux_vmove_before AS SELECT id, ctid AS c FROM flux_vmove; +VACUUM flux_vmove; +VACUUM flux_vmove; +SELECT count(*) AS moved FROM flux_vmove JOIN flux_vmove_before b USING (id) + WHERE flux_vmove.ctid <> b.c; -- 0 +SELECT count(*) AS n FROM flux_vmove; -- 67 +DROP TABLE flux_vmove, flux_vmove_before; + +-- VACUUM keeps an empty tail when told to (VACUUM (TRUNCATE false), the +-- vacuum_truncate reloption) and when it is too short to be worth the lock: +-- under 1000 pages and 1/16 of the table. Heap alongside as the control. +CREATE TEMP TABLE flux_tk_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_tk (id int, pad text) USING flux; +INSERT INTO flux_tk_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_tk SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +CREATE TEMP TABLE flux_tk_sz AS + SELECT pg_relation_size('flux_tk_h') AS h, pg_relation_size('flux_tk') AS r; +DELETE FROM flux_tk_h WHERE id > 19900; +DELETE FROM flux_tk WHERE id > 19900; +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; +DELETE FROM flux_tk_h WHERE id > 1; +DELETE FROM flux_tk WHERE id > 1; +VACUUM (TRUNCATE false) flux_tk_h; +VACUUM (TRUNCATE false) flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; +ALTER TABLE flux_tk_h SET (vacuum_truncate = false); +ALTER TABLE flux_tk SET (vacuum_truncate = false); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; +ALTER TABLE flux_tk_h RESET (vacuum_truncate); +ALTER TABLE flux_tk RESET (vacuum_truncate); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = 8192 AS heap_truncated, + pg_relation_size('flux_tk') = 8192 AS flux_truncated; +DROP TABLE flux_tk_h, flux_tk, flux_tk_sz; + +-- VACUUM (DISABLE_PAGE_SKIPPING) still truncates an empty tail, as heap does. +CREATE TEMP TABLE flux_dps_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_dps (id int, pad text) USING flux; +INSERT INTO flux_dps_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_dps SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +DELETE FROM flux_dps_h WHERE id > 1; +DELETE FROM flux_dps WHERE id > 1; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps_h; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps; +SELECT pg_relation_size('flux_dps_h') = 8192 AS heap_truncated, + pg_relation_size('flux_dps') = 8192 AS flux_truncated; +DROP TABLE flux_dps_h, flux_dps; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (a int) USING heap; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; +FETCH BACKWARD 1 FROM c; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (a int, pad text) USING heap; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; +FETCH FIRST FROM c; +COMMIT; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (a int) USING flux; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; +FETCH BACKWARD 1 FROM c; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (a int, pad text) USING flux; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; +FETCH FIRST FROM c; +COMMIT; +DROP TABLE scan_semantics; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics () USING heap; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics () USING flux; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (pad text, a int) USING heap; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; +SELECT count(*) FROM scan_semantics; +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (pad text, a int) USING flux; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; +SELECT count(*) FROM scan_semantics; +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE self_direct (id int, v int) USING heap; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +SELECT * FROM self_direct; +UPDATE self_direct SET v = 10; +SELECT * FROM self_direct; +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); + +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); + +CREATE TABLE self_direct (id int, v int) USING flux; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +SELECT * FROM self_direct; +UPDATE self_direct SET v = 10; +SELECT * FROM self_direct; +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); + +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); diff --git a/src/test/regress/sql/flux_abort_ddl.sql b/src/test/regress/sql/flux_abort_ddl.sql new file mode 100644 index 0000000000000..a3c54248d413d --- /dev/null +++ b/src/test/regress/sql/flux_abort_ddl.sql @@ -0,0 +1,197 @@ +-- +-- FLUX: a transaction updates rows in place, then replaces or drops storage +-- the update touched (DROP INDEX, REINDEX, ALTER COLUMN TYPE, TRUNCATE, +-- CLUSTER), then rolls back, entirely or to a savepoint. +-- +-- The update's undo names the files it changed. While the transaction is +-- aborting, its catalog changes are not rolled back yet, so the relcache +-- maps the new files (or no index at all). The rollback must still leave +-- the table and its indexes as they were, so that the same update applied +-- again gives the same rows and index entries as on heap. Each case runs on +-- a heap twin and on FLUX, permanent and temporary; the two columns of the +-- result must agree. +-- +CREATE SCHEMA ad_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA ad_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; + +-- ad_h (heap) and ad_f (FLUX), 200 rows with k = id, an index on k. +CREATE PROCEDURE ad_setup(temp bool) LANGUAGE plpgsql AS $$ +DECLARE + t text; +BEGIN + DROP TABLE IF EXISTS pg_temp.ad_h, pg_temp.ad_f, public.ad_h, public.ad_f; + FOREACH t IN ARRAY ARRAY['ad_h', 'ad_f'] LOOP + EXECUTE format('CREATE %s TABLE %I (id int PRIMARY KEY, k int) USING %s', + CASE WHEN temp THEN 'TEMP' ELSE '' END, t, + CASE WHEN t = 'ad_h' THEN 'heap' ELSE 'flux' END); + EXECUTE format('CREATE INDEX %1$I_k ON %1$I (k)', t); + EXECUTE format('INSERT INTO %I SELECT g, g FROM generate_series(1, 200) g', t); + END LOOP; +END $$; + +-- The update: move the first 50 rows' keys, in place on FLUX. +CREATE PROCEDURE ad_upd() LANGUAGE plpgsql AS $$ +BEGIN + UPDATE ad_h SET k = k + 1000 WHERE id <= 50; + UPDATE ad_f SET k = k + 1000 WHERE id <= 50; +END $$; + +-- The DDL, on both tables; %1$I is the table. +CREATE PROCEDURE ad_ddl(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format(ddl, 'ad_h'); + EXECUTE format(ddl, 'ad_f'); +END $$; + +-- Rows and sum of k, then index scans of the moved and the old keys, then +-- amcheck of every index. +CREATE FUNCTION ad_check1(t text) RETURNS text LANGUAGE plpgsql + SET enable_seqscan = off SET enable_bitmapscan = off AS $$ +DECLARE + chk text := (SELECT format('%I.bt_index_check', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; s bigint; moved bigint; old bigint; idx regclass; problems text := ''; +BEGIN + EXECUTE format('SELECT count(*), sum(k) FROM %I', t) INTO n, s; + EXECUTE format('SELECT count(*) FROM %I WHERE k > 1000', t) INTO moved; + EXECUTE format('SELECT count(*) FROM %I WHERE k BETWEEN 1 AND 50', t) INTO old; + IF chk IS NOT NULL THEN + FOR idx IN SELECT indexrelid FROM pg_index WHERE indrelid = t::regclass ORDER BY 1 LOOP + BEGIN + EXECUTE format('SELECT %s($1, true)', chk) USING idx; + EXCEPTION WHEN OTHERS THEN + problems := problems || ' ' || split_part(SQLERRM, ' for index', 1); + END; + END LOOP; + END IF; + RETURN format('%s|%s idx %s/%s%s', n, s, moved, old, + CASE WHEN problems = '' THEN '' ELSE ' amcheck:' || problems END); +END $$; + +CREATE FUNCTION ad_check(OUT heap text, OUT flux text) LANGUAGE sql + RETURN (ad_check1('ad_h'), ad_check1('ad_f')); + +-- Top-level ROLLBACK, then the update again in a new transaction. +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + +-- The same with temporary tables. +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + +-- ROLLBACK TO SAVEPOINT, then the update again in the same transaction. +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + +-- An error aborts the transaction the same way, in a transaction block or +-- in a single statement. +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); SELECT 1 / 0; ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); SELECT 1 / 0; ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +DO $$ BEGIN CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); PERFORM 1 / 0; END $$; +CALL ad_upd(); SELECT * FROM ad_check(); + +-- ROLLBACK in a procedure, and ROLLBACK AND CHAIN: the next transaction +-- starts right away. +CREATE PROCEDURE ad_txn(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + CALL ad_upd(); CALL ad_ddl(ddl); ROLLBACK; + CALL ad_upd(); +END $$; +CALL ad_setup(false); +CALL ad_txn('DROP INDEX %1$I_k'); SELECT * FROM ad_check(); +CALL ad_setup(true); +CALL ad_txn('TRUNCATE %1$I'); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + +-- Each row updated twice before the DDL: the rollback applies its two +-- records newest first. +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +ROLLBACK; CALL ad_upd(); SELECT * FROM ad_check(); + +DROP TABLE ad_h, ad_f; +DROP FUNCTION ad_check, ad_check1; +DROP PROCEDURE ad_setup, ad_upd, ad_ddl, ad_txn; +DROP SCHEMA ad_amcheck CASCADE; diff --git a/src/test/regress/sql/flux_cluster.sql b/src/test/regress/sql/flux_cluster.sql new file mode 100644 index 0000000000000..a0f7c9d5db74c --- /dev/null +++ b/src/test/regress/sql/flux_cluster.sql @@ -0,0 +1,54 @@ +-- +-- CLUSTER orders a FLUX table by the index, whichever plan CLUSTER picks: +-- an index scan, or a sequential scan plus sort (an expression index, or a +-- big table with little maintenance_work_mem, makes it sort). Heap is the +-- control; both halves must give the same answer. +-- +CREATE FUNCTION flux_cluster_misordered(t regclass, key text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM (SELECT %s AS k, lag(%s) OVER (ORDER BY ctid) AS lk FROM %s) s WHERE lk > k', + key, key, t) INTO n; + RETURN n; +END $$; + +-- Expression index: CLUSTER always sorts. +CREATE TABLE heap_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING heap; +CREATE TABLE flux_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING flux; +INSERT INTO heap_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +INSERT INTO flux_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +CREATE INDEX heap_clx_minus_a ON heap_clx ((-a), b); +CREATE INDEX flux_clx_minus_a ON flux_clx ((-a), b); +CLUSTER heap_clx USING heap_clx_minus_a; +CLUSTER flux_clx USING flux_clx_minus_a; +SELECT flux_cluster_misordered('heap_clx', '-a') AS heap_misordered, + flux_cluster_misordered('flux_clx', '-a') AS flux_misordered; +SELECT (SELECT count(*) FROM heap_clx) AS heap_rows, (SELECT count(*) FROM flux_clx) AS flux_rows; + +-- Plain index, sort forced by planner settings. +CREATE TABLE heap_cls (k int, pad text) USING heap; +CREATE TABLE flux_cls (k int, pad text) USING flux; +INSERT INTO heap_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +INSERT INTO flux_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +CREATE INDEX heap_cls_k ON heap_cls (k); +CREATE INDEX flux_cls_k ON flux_cls (k); +ANALYZE heap_cls, flux_cls; +SET enable_indexscan = off; +CLUSTER heap_cls USING heap_cls_k; +CLUSTER flux_cls USING flux_cls_k; +RESET enable_indexscan; +SELECT flux_cluster_misordered('heap_cls', 'k') AS heap_misordered, + flux_cluster_misordered('flux_cls', 'k') AS flux_misordered; + +-- Rows deleted but still visible to an open snapshot survive the sort. +CREATE TABLE flux_clr (k int) USING flux; +INSERT INTO flux_clr SELECT g FROM generate_series(1, 20) g; +CREATE INDEX flux_clr_e ON flux_clr ((-k)); +DELETE FROM flux_clr WHERE k > 10; +CLUSTER flux_clr USING flux_clr_e; +SELECT count(*), min(k), max(k) FROM flux_clr; +SELECT flux_cluster_misordered('flux_clr', '-k') AS flux_misordered; + +DROP TABLE heap_clx, flux_clx, heap_cls, flux_cls, flux_clr; +DROP FUNCTION flux_cluster_misordered(regclass, text); diff --git a/src/test/regress/sql/flux_deferred_trigger.sql b/src/test/regress/sql/flux_deferred_trigger.sql new file mode 100644 index 0000000000000..32d9aee6d6951 --- /dev/null +++ b/src/test/regress/sql/flux_deferred_trigger.sql @@ -0,0 +1,500 @@ +-- +-- AFTER-trigger row events on FLUX tables whose row was rewritten in place +-- before the event fired: deferred constraint triggers, SET CONSTRAINTS, +-- triggers firing after a nested statement, transition tables and deferred +-- foreign keys. Each case runs first on a heap twin (the control) and then +-- on FLUX; the two halves must print the same thing, and the final query +-- compares their table contents. +-- +-- Heap re-fetches an event's row version by TID when the trigger fires, and +-- RI's check skips a version that was since superseded. A FLUX row keeps its +-- TID across UPDATEs, so the event must carry its version. +-- +CREATE SCHEMA dt_heap; +CREATE SCHEMA dt_flux; + +CREATE FUNCTION public.dt_show_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '% % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, OLD, NEW; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_fix_pid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.pid = 99 THEN + UPDATE nt SET pid = 1, v = v + 10 WHERE id = NEW.id; + END IF; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, TG_LEVEL, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_new_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % %: new %', TG_TABLE_NAME, TG_NAME, TG_OP, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_old_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + RAISE NOTICE '% % %: old %', TG_TABLE_NAME, TG_NAME, TG_OP, o; + RETURN NULL; +END $$; + +-- +-- heap +-- +\set am heap +\set schema dt_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; + +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +UPDATE dt SET v = 2 WHERE id = 2; +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +SELECT * FROM dt ORDER BY id; + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +SELECT * FROM nt ORDER BY id; + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +UPDATE tt SET v = v + 1 WHERE id <= 2; +UPDATE tt SET v = v + 10; +DELETE FROM tt WHERE id = 1; +COMMIT; +SELECT * FROM tt ORDER BY id; + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +SELECT * FROM fc ORDER BY id; + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +SELECT * FROM gp ORDER BY id; +SELECT * FROM gc ORDER BY id; + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +SELECT * FROM sc ORDER BY id; + +-- +-- flux +-- +\set am flux +\set schema dt_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; + +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +UPDATE dt SET v = 2 WHERE id = 2; +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +SELECT * FROM dt ORDER BY id; + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +SELECT * FROM nt ORDER BY id; + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +UPDATE tt SET v = v + 1 WHERE id <= 2; +UPDATE tt SET v = v + 10; +DELETE FROM tt WHERE id = 1; +COMMIT; +SELECT * FROM tt ORDER BY id; + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +SELECT * FROM fc ORDER BY id; + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +SELECT * FROM gp ORDER BY id; +SELECT * FROM gc ORDER BY id; + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +SELECT * FROM sc ORDER BY id; + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.dt_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM dt_heap.%1$I EXCEPT ALL SELECT * FROM dt_flux.%1$I)' + ' UNION ALL (SELECT * FROM dt_flux.%1$I EXCEPT ALL SELECT * FROM dt_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.dt_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'dt_heap'::regnamespace AND c.relkind = 'r' + ORDER BY 1; +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'dt_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + +DROP SCHEMA dt_heap CASCADE; +DROP SCHEMA dt_flux CASCADE; +DROP FUNCTION public.dt_show_row(), public.dt_fix_pid(), public.dt_show_transition(), + public.dt_show_new_table(), public.dt_show_old_table(); diff --git a/src/test/regress/sql/flux_dml_semantics.sql b/src/test/regress/sql/flux_dml_semantics.sql new file mode 100644 index 0000000000000..125c6ef6d18ff --- /dev/null +++ b/src/test/regress/sql/flux_dml_semantics.sql @@ -0,0 +1,116 @@ +-- Run the same statements against heap and FLUX. +CREATE SCHEMA semantics_heap; +CREATE SCHEMA semantics_flux; +SET search_path = semantics_heap, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING heap; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +CREATE TABLE cc (a int) USING heap; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING heap; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +SET search_path = semantics_flux, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING flux; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +CREATE TABLE cc (a int) USING flux; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING flux; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +RESET search_path; +DROP SCHEMA semantics_heap CASCADE; +DROP SCHEMA semantics_flux CASCADE; diff --git a/src/test/regress/sql/flux_estimate.sql b/src/test/regress/sql/flux_estimate.sql new file mode 100644 index 0000000000000..c820634038bcc --- /dev/null +++ b/src/test/regress/sql/flux_estimate.sql @@ -0,0 +1,240 @@ +-- +-- FLUX planner size estimates (flux_relation_estimate_size), heap as control. +-- +-- Every result is a boolean so the expected output does not depend on +-- platform details; heap's column shows what the shared block estimator +-- gives for the same shape. +-- + +-- Estimated rows and total cost of a whole-table scan. +CREATE FUNCTION est_plan(tbl regclass, what text) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + plan json; +BEGIN + EXECUTE format('EXPLAIN (FORMAT JSON) SELECT * FROM %s', tbl) INTO plan; + RETURN (plan->0->'Plan'->>what)::float8; +END $$; +CREATE FUNCTION est_rows(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT est_plan(tbl, 'Plan Rows') +$$; +CREATE FUNCTION real_rows(tbl regclass) RETURNS float8 LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT count(*) FROM %s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION real_pages(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT pg_relation_size(tbl) / current_setting('block_size')::float8 +$$; +-- The estimate is within [0.5x, 2x] of the real row count. +CREATE FUNCTION est_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT est_rows(tbl) BETWEEN 0.5 * real_rows(tbl) AND 2 * real_rows(tbl) +$$; +-- A never-analyzed table: the rows per page the planner assumes (the +-- estimate over the page count, which is at least 10) is within 10% of the +-- rows a full page really holds (the most rows on any block of a table +-- filled by INSERT alone). +CREATE FUNCTION real_density(tbl regclass) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT max(c) FROM (SELECT count(*) AS c FROM %s + GROUP BY (ctid::text::point)[0]) s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION density_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT abs(est_rows(tbl) / greatest(real_pages(tbl), 10) / + real_density(tbl) - 1) <= 0.10 +$$; + +CREATE TABLE est_result (shape text, am text, ok bool); +CREATE TABLE est_am (am text); +INSERT INTO est_am VALUES ('heap'), ('flux'); + +-- Run one statement per access method; %1$s is the AM name. The +-- statements are not echoed: only est_result is shown. +\set ECHO none +\set per_am 'SELECT format(:''stmt'', am) FROM est_am ORDER BY am DESC \\gexec' + +\set stmt 'CREATE TABLE est_empty_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'CREATE TABLE est_two_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_two_%1$s VALUES (1, 1, 1), (2, 2, 2)' +:per_am +-- A never-vacuumed table is assumed to have at least 10 pages. +\set stmt 'INSERT INTO est_result SELECT ''fresh 2 rows: 10-page rule'', %1$L, est_rows(''est_two_%1$s'') = est_rows(''est_empty_%1$s'') AND est_rows(''est_two_%1$s'') > 100' +:per_am +-- Once vacuumed, an empty table is believed: 0 pages, a zero-cost scan. +\set stmt 'VACUUM est_empty_%1$s' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''empty after VACUUM: 0 pages'', %1$L, est_plan(''est_empty_%1$s'', ''Total Cost'') = 0' +:per_am + +-- Never analyzed: narrow rows, wide rows, NULLs, fillfactor 50. +\set stmt 'CREATE TABLE est_narrow_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_narrow_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'CREATE TABLE est_wide_%1$s (a int, n1 name, n2 name, n3 name, n4 name) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_wide_%1$s SELECT g, g::text, g::text, g::text, g::text FROM generate_series(1, 2000) g' +:per_am +\set stmt 'CREATE TABLE est_nulls_%1$s (a int, b int, c bigint, d bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_nulls_%1$s SELECT g, g, CASE WHEN g %% 2 = 0 THEN g END, CASE WHEN g %% 2 = 0 THEN g END FROM generate_series(1, 5000) g' +:per_am +\set stmt 'CREATE TABLE est_ff50_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off, fillfactor = 50)' +:per_am +\set stmt 'INSERT INTO est_ff50_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +-- No reloptions at all, so the AM's own default fillfactor applies (a temp +-- table, which autovacuum leaves alone). +\set stmt 'CREATE TEMP TABLE est_ffdef_%1$s (a int, b int, c bigint) USING %1$s' +:per_am +\set stmt 'INSERT INTO est_ffdef_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'INSERT INTO est_result VALUES (''fresh default fillfactor: density'', %1$L, density_ok(''est_ffdef_%1$s''))' +:per_am +-- 1000 columns: FLUX stores a null bitmap in every tuple, heap only in a +-- tuple that has a NULL. +SELECT format('CREATE TABLE est_cols_%s (%s) USING %s WITH (autovacuum_enabled = off)', + am, string_agg('c' || i || ' bool', ', '), am) +FROM est_am, generate_series(1, 1000) i GROUP BY am ORDER BY am DESC \gexec +SELECT format('INSERT INTO est_cols_%s SELECT %s FROM generate_series(1, 300)', + am, repeat('true, ', 999) || 'true') +FROM est_am ORDER BY am DESC \gexec +\set stmt 'INSERT INTO est_result VALUES (''fresh 1000 columns: density'', %1$L, density_ok(''est_cols_%1$s''))' +:per_am +\set stmt 'INSERT INTO est_result VALUES (''fresh narrow: density'', %1$L, density_ok(''est_narrow_%1$s'')), (''fresh narrow: rows'', %1$L, est_ok(''est_narrow_%1$s'')), (''fresh wide: density'', %1$L, density_ok(''est_wide_%1$s'')), (''fresh wide: rows'', %1$L, est_ok(''est_wide_%1$s'')), (''fresh with nulls: rows'', %1$L, est_ok(''est_nulls_%1$s'')), (''fresh fillfactor 50: density'', %1$L, density_ok(''est_ff50_%1$s'')), (''fresh fillfactor 50: rows'', %1$L, est_ok(''est_ff50_%1$s''))' +:per_am + +-- Analyzed, then grown 3x. +\set stmt 'ANALYZE est_narrow_%1$s' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''after ANALYZE: exact'', %1$L, est_rows(''est_narrow_%1$s'') = real_rows(''est_narrow_%1$s'')' +:per_am +\set stmt 'INSERT INTO est_narrow_%1$s SELECT g, g, g FROM generate_series(5001, 15000) g' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''grown 3x since ANALYZE: rows'', %1$L, est_ok(''est_narrow_%1$s'')' +:per_am + +-- Analyzed, then 3 rounds of same-width UPDATE of an unindexed column. +\set stmt 'CREATE TABLE est_upd_%1$s (a int PRIMARY KEY, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_upd_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'ANALYZE est_upd_%1$s' +:per_am +CREATE TEMP TABLE est_upd_before AS + SELECT am, est_rows(format('est_upd_%s', am)::regclass) AS est FROM est_am; +\set stmt 'UPDATE est_upd_%1$s SET c = c + 1' +:per_am +:per_am +:per_am +\set stmt 'INSERT INTO est_result VALUES (''3 non-key UPDATE rounds: rows'', %1$L, est_ok(''est_upd_%1$s'')), (''3 non-key UPDATE rounds: no drift'', %1$L, est_rows(''est_upd_%1$s'') = (SELECT est FROM est_upd_before WHERE am = %1$L))' +:per_am + +-- Key UPDATEs and DELETEs, then VACUUM. +\set stmt 'CREATE TABLE est_kvac_%1$s (a int PRIMARY KEY, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_kvac_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'UPDATE est_kvac_%1$s SET a = a + 100000 WHERE a %% 2 = 0' +:per_am +\set stmt 'DELETE FROM est_kvac_%1$s WHERE b %% 3 = 0' +:per_am +\set stmt 'VACUUM est_kvac_%1$s' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''key UPDATE + DELETE + VACUUM: rows'', %1$L, est_ok(''est_kvac_%1$s'')' +:per_am + +\set ECHO all + +SELECT h.shape, h.ok AS heap, f.ok AS flux +FROM est_result h JOIN est_result f USING (shape) +WHERE h.am = 'heap' AND f.am = 'flux' +ORDER BY h.ctid; + +-- ANALYZE's average column width is the stored width, as for heap: a +-- compressed value inline, an external value's TOAST pointer. +\set ECHO none +\set stmt 'CREATE TABLE est_width_%1$s (a int, m text, e text) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'ALTER TABLE est_width_%1$s ALTER e SET STORAGE EXTERNAL' +:per_am +\set stmt 'INSERT INTO est_width_%1$s SELECT g, repeat(md5(g::text), 100), repeat(md5(g::text), 100) FROM generate_series(1, 500) g' +:per_am +\set stmt 'ANALYZE est_width_%1$s' +:per_am +\set ECHO all +SELECT attname, h.avg_width = f.avg_width AS same_width, + f.avg_width < 100 AS stored_width +FROM pg_stats h JOIN pg_stats f USING (attname) +WHERE h.tablename = 'est_width_heap' AND f.tablename = 'est_width_flux' +ORDER BY attname; + +-- Planning a never-analyzed table reads no table block: 50 plans, 0 reads. +\set ECHO none +\set stmt 'CREATE TABLE est_io_%1$s (a int, b int) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_io_%1$s SELECT g, g FROM generate_series(1, 5000) g' +:per_am +\set ECHO all +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +CREATE TEMP TABLE est_io_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_io_heap', 'est_io_flux'); +DO $$ +BEGIN + FOR i IN 1..50 LOOP + EXECUTE 'EXPLAIN SELECT * FROM est_io_heap WHERE a = 1'; + EXECUTE 'EXPLAIN SELECT * FROM est_io_flux WHERE a = 1'; + END LOOP; +END $$; +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks AS blocks_read_by_planning +FROM pg_statio_user_tables s JOIN est_io_before b USING (relname) +ORDER BY s.relname; + +-- An index scan of a range in physical order visits each table page once, as +-- the planner's index-correlation costing assumes: 3000 rows, 0 to 30 pages. +\set ECHO none +\set stmt 'CREATE TABLE est_corr_%1$s (id int PRIMARY KEY, v int) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_corr_%1$s SELECT g, g FROM generate_series(1, 20000) g' +:per_am +\set stmt 'ANALYZE est_corr_%1$s' +:per_am +\set ECHO all +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +CREATE TEMP TABLE est_corr_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_corr_heap', 'est_corr_flux'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT sum(v) FROM est_corr_heap WHERE id BETWEEN 1001 AND 4000; +SELECT sum(v) FROM est_corr_flux WHERE id BETWEEN 1001 AND 4000; +RESET enable_seqscan; +RESET enable_bitmapscan; +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks BETWEEN 1 AND 30 AS pages_once +FROM pg_statio_user_tables s JOIN est_corr_before b USING (relname) +ORDER BY s.relname; + +\set ECHO none +\set stmt 'DROP TABLE est_empty_%1$s, est_two_%1$s, est_narrow_%1$s, est_wide_%1$s, est_nulls_%1$s, est_ff50_%1$s, est_ffdef_%1$s, est_cols_%1$s, est_upd_%1$s, est_kvac_%1$s, est_io_%1$s, est_width_%1$s, est_corr_%1$s' +:per_am +\set ECHO all +DROP TABLE est_result, est_am; +DROP FUNCTION est_ok, density_ok, real_density, est_rows, est_plan, real_rows, + real_pages; diff --git a/src/test/regress/sql/flux_fk.sql b/src/test/regress/sql/flux_fk.sql new file mode 100644 index 0000000000000..1dc482d619a82 --- /dev/null +++ b/src/test/regress/sql/flux_fk.sql @@ -0,0 +1,601 @@ +-- +-- Foreign keys and AFTER triggers on FLUX tables, each case run first on a +-- heap twin (the control) and then on FLUX. The two halves must print the +-- same thing; the final query compares their table contents. +-- +-- An AM that updates rows in place hands the AFTER-trigger machinery copies +-- of the old and new row images, and the RI triggers ask such a copy whether +-- the current transaction created it (slot_is_current_xact_tuple). +-- +CREATE SCHEMA fk_heap; +CREATE SCHEMA fk_flux; + +CREATE FUNCTION public.fk_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO log VALUES (nextval('log_seq'), TG_TABLE_NAME, + TG_NAME || ' ' || TG_OP, OLD::text, NEW::text); + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% %: old % new %', TG_TABLE_NAME, TG_OP, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_bump_w() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.w := NEW.w + 100; + IF NEW.w = 99 THEN NEW.pid := 8; END IF; + RETURN NEW; +END $$; + +-- +-- heap +-- +\set am heap +\set other flux +\set schema fk_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; + +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ROLLBACK; +SELECT * FROM pc ORDER BY id; + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +SELECT * FROM tree ORDER BY id; +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +UPDATE xc_o SET pid = 3 WHERE id = 1; +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptp ORDER BY id; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +SELECT * FROM pc ORDER BY id; +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +UPDATE tc SET pid = 2 WHERE id = 1; +UPDATE tc SET pid = 9 WHERE id = 1; +UPDATE tp SET id = 10 WHERE id = 1; +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +UPDATE tc SET w = 1 WHERE id = 2; +UPDATE tc SET pid = 10 WHERE id = 2; +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +SELECT * FROM tc ORDER BY id; +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + +-- +-- flux +-- +\set am flux +\set other heap +\set schema fk_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; + +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ROLLBACK; +SELECT * FROM pc ORDER BY id; + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +SELECT * FROM tree ORDER BY id; +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +UPDATE xc_o SET pid = 3 WHERE id = 1; +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptp ORDER BY id; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +SELECT * FROM pc ORDER BY id; +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +UPDATE tc SET pid = 2 WHERE id = 1; +UPDATE tc SET pid = 9 WHERE id = 1; +UPDATE tp SET id = 10 WHERE id = 1; +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +UPDATE tc SET w = 1 WHERE id = 2; +UPDATE tc SET pid = 10 WHERE id = 2; +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +SELECT * FROM tc ORDER BY id; +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.fk_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM fk_heap.%1$I EXCEPT ALL SELECT * FROM fk_flux.%1$I)' + ' UNION ALL (SELECT * FROM fk_flux.%1$I EXCEPT ALL SELECT * FROM fk_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.fk_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'fk_heap'::regnamespace AND c.relkind IN ('r', 'p') + AND NOT c.relispartition + ORDER BY 1; +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'fk_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + +DROP SCHEMA fk_heap CASCADE; +DROP SCHEMA fk_flux CASCADE; +DROP FUNCTION public.fk_log_row(), public.fk_show_transition(), public.fk_bump_w(); diff --git a/src/test/regress/sql/flux_index_aba.sql b/src/test/regress/sql/flux_index_aba.sql new file mode 100644 index 0000000000000..f3a2e29eaef75 --- /dev/null +++ b/src/test/regress/sql/flux_index_aba.sql @@ -0,0 +1,321 @@ +-- +-- FLUX: an indexed column that returns to an earlier value (A -> B -> A) +-- inside one transaction, then is rolled back. +-- +-- An in-place key UPDATE keeps the row at its TID: it inserts (k_new, TID), +-- or revives a delete-marked (k_new, TID) left by an earlier UPDATE of the +-- same row, and delete-marks (k_old, TID). Rolling the UPDATEs back must +-- put every index back as it was: one live entry for the row's key, and +-- nothing that an index scan or amcheck could tell from a heap table that +-- had done the same. Each FLUX table here has a heap twin that receives the +-- same statements; heap never reuses a TID, so it is the control. +-- +-- aba_check() reports 'ok' or what is wrong: for both tables, an index scan +-- through the index under test must return what a sequential scan returns, +-- for every key the row has held; bt_index_check(heapallindexed) must pass +-- on every btree index (when amcheck is available); and the FLUX table must +-- hold what its heap twin holds. +-- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'amcheck') AND + NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck; + CREATE TABLE aba_created_amcheck (); + END IF; +END $$; + +-- One FLUX/heap pair per index kind. k is the column that cycles; j stays 0. +CREATE FUNCTION aba_make(kind text) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + EXECUTE format('CREATE TABLE %I (id int PRIMARY KEY, j int, k int) USING %s', t, am); + EXECUTE format('INSERT INTO %I SELECT g, 0, g FROM generate_series(1, 40) g', t); + EXECUTE format(CASE kind + WHEN 'plain' THEN 'CREATE INDEX %I ON %I (k)' + WHEN 'uniq' THEN 'CREATE UNIQUE INDEX %I ON %I (k)' + WHEN 'multi' THEN 'CREATE INDEX %I ON %I (j, k)' + WHEN 'expr' THEN 'CREATE INDEX %I ON %I ((k * 2))' + WHEN 'partial' THEN 'CREATE INDEX %I ON %I (k) WHERE k < 1000' + WHEN 'hash' THEN 'CREATE INDEX %I ON %I USING hash (k)' + END, t || '_ix', t); + END LOOP; +END $$; + +CREATE FUNCTION aba_drop(kind text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('DROP TABLE %I, %I', 'flux_' || kind, 'heap_' || kind); +END $$; + +-- Set row id's k on both tables of the pair. +CREATE FUNCTION aba_set(kind text, rid int, newk int) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'flux_' || kind) USING newk, rid; + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'heap_' || kind) USING newk, rid; +END $$; + +-- Try an UPDATE on one table; report whether it raised unique_violation. +CREATE FUNCTION aba_try(t text, rid int, newk int) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', t) USING newk, rid; + RETURN 'updated'; +EXCEPTION WHEN unique_violation THEN + RETURN 'unique_violation'; +END $$; + +CREATE FUNCTION aba_count(q text, idxscan bool) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + PERFORM set_config('enable_seqscan', CASE WHEN idxscan THEN 'off' ELSE 'on' END, true); + PERFORM set_config('enable_indexscan', CASE WHEN idxscan THEN 'on' ELSE 'off' END, true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + EXECUTE 'SELECT count(*) FROM ' || q INTO n; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); + RETURN n; +END $$; + +CREATE FUNCTION aba_check(kind text, keys int[]) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; + key int; + q text; + ni bigint; + ns bigint; + i regclass; + res text := ''; + fx text; + hp text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + FOREACH key IN ARRAY keys LOOP + -- a partial index holds only keys < 1000 + CONTINUE WHEN kind = 'partial' AND key >= 1000; + q := format('%I WHERE ', t) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + ni := aba_count(q, true); + ns := aba_count(q, false); + IF ni <> ns THEN + res := res || format('%s k=%s: idxscan %s seqscan %s; ', t, key, ni, ns); + END IF; + END LOOP; + FOR i IN SELECT indexrelid FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid + JOIN pg_am a ON a.oid = c.relam + WHERE x.indrelid = t::regclass AND a.amname = 'btree' ORDER BY 1 LOOP + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + BEGIN + PERFORM bt_index_check(i, true); + EXCEPTION WHEN OTHERS THEN + res := res || format('%s: %s; ', i, SQLERRM); + END; + END IF; + END LOOP; + END LOOP; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'flux_' || kind) INTO fx; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'heap_' || kind) INTO hp; + IF fx IS DISTINCT FROM hp THEN + res := res || 'flux and heap rows differ; '; + END IF; + RETURN CASE WHEN res = '' THEN 'ok' ELSE res END; +END $$; + +-- The plan aba_count(q, true) gets: a plain index scan of the index under +-- test. Index-only scans are off, as a heap table could otherwise answer +-- from the index alone, and a FLUX table never does. +CREATE FUNCTION aba_plan(kind text, key int) RETURNS SETOF text +LANGUAGE plpgsql AS $$ +DECLARE + q text; + l text; +BEGIN + q := format('%I WHERE ', 'flux_' || kind) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + FOR l IN EXECUTE 'EXPLAIN (COSTS OFF) SELECT count(*) FROM ' || q LOOP + RETURN NEXT l; + END LOOP; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); +END $$; + +CREATE VIEW aba_kinds AS SELECT unnest(ARRAY['plain', 'uniq', 'multi', 'expr', 'partial', 'hash']) AS kind; + +SELECT aba_make(kind) FROM aba_kinds; +SELECT kind, aba_plan(kind, 12) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- Every case starts from fresh tables (amcheck reports only the first +-- missing entry) and checks the indexes before and after a VACUUM, which +-- reclaims the tombstones a case leaves and must keep the indexes intact. +-- B = A + 3000000 and C = A + 4000000 lie outside the partial index. + +-- 1. A -> B -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 12, 3000012), aba_set(kind, 12, 12) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 2. A -> B -> C -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 13, 3000013), aba_set(kind, 13, 4000013), aba_set(kind, 13, 13) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 3. A -> B, then B -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 14, 3000014) + FROM aba_kinds; +SAVEPOINT s; +SELECT aba_set(kind, 14, 14) + FROM aba_kinds; +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 4. A -> B -> C, then C -> A in a savepoint that is rolled back, COMMIT: C +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 15, 3000015), aba_set(kind, 15, 4000015) + FROM aba_kinds; +SAVEPOINT s; +SELECT aba_set(kind, 15, 15) + FROM aba_kinds; +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 5. A -> B, then B -> C -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 16, 3000016) + FROM aba_kinds; +SAVEPOINT s; +SELECT aba_set(kind, 16, 4000016), aba_set(kind, 16, 16) + FROM aba_kinds; +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 6. COMMIT controls: A -> B -> A and A -> B -> C -> A +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 17, 3000017), aba_set(kind, 17, 17), + aba_set(kind, 18, 3000018), aba_set(kind, 18, 4000018), aba_set(kind, 18, 18) + FROM aba_kinds; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 7. Committed A -> C, then C -> A rolled back: C +SELECT aba_make(kind) FROM aba_kinds; +SELECT aba_set(kind, 19, 4000019) + FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 19, 19) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 8. The same row cycled and rolled back round after round, then committed +-- back to A: no round may lose an entry or leave a live one behind. +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20), aba_set(kind, 20, 3000020) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 9. Unique index. A key the row has left is free for another row, and the +-- row cannot take it back while that row holds it. An aborted A -> B -> A +-- keeps A taken: no other row may take it. +SELECT aba_make('uniq'); +SELECT aba_set('uniq', 21, 3000021), aba_set('uniq', 22, 21); +SELECT aba_try('flux_uniq', 21, 21) AS flux, aba_try('heap_uniq', 21, 21) AS heap; +BEGIN; +SELECT aba_set('uniq', 23, 3000023), aba_set('uniq', 23, 23); +ROLLBACK; +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; +-- once the row has moved off it again (committed), the key is free +SELECT aba_set('uniq', 23, 3000023); +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); +VACUUM flux_uniq, heap_uniq; +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); +SELECT aba_drop('uniq'); + +DROP VIEW aba_kinds; +DROP FUNCTION aba_make, aba_drop, aba_set, aba_try, aba_count, aba_check, aba_plan; +DO $$ +BEGIN + IF to_regclass('aba_created_amcheck') IS NOT NULL THEN + DROP EXTENSION amcheck; + DROP TABLE aba_created_amcheck; + END IF; +END $$; diff --git a/src/test/regress/sql/flux_merge_delete.sql b/src/test/regress/sql/flux_merge_delete.sql new file mode 100644 index 0000000000000..70910ecde7692 --- /dev/null +++ b/src/test/regress/sql/flux_merge_delete.sql @@ -0,0 +1,85 @@ +-- +-- FLUX: a DELETE (plain or MERGE) must stay deleted when a BEFORE STATEMENT +-- trigger advanced the command counter before the row was deleted. +-- +-- The trigger's PERFORM makes plpgsql run CommandCounterIncrement, so the +-- current command id is past the statement's output cid when the row is +-- deleted. The undo record of the delete must carry the output cid, as +-- heap's cmax does; otherwise the next command treats the delete as a later +-- command of its own transaction, reconstructs the deleted row from undo +-- and serves it again. Heap is the control. +-- +CREATE FUNCTION fmd_trig() RETURNS trigger LANGUAGE plpgsql AS +$$ BEGIN PERFORM 1; RETURN NULL; END $$; + +CREATE TABLE fmd_heap (tid int, balance int) USING heap; +CREATE TABLE fmd_flux (tid int, balance int) USING flux; +CREATE TABLE fmd_src (sid int, delta int); +INSERT INTO fmd_src VALUES (2, 5), (3, 20), (4, 40); + +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY['fmd_heap', 'fmd_flux'] LOOP + EXECUTE format('INSERT INTO %I VALUES (1, 10), (2, 20), (3, 30)', t); + EXECUTE format('CREATE TRIGGER fmd_bsd BEFORE DELETE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + EXECUTE format('CREATE TRIGGER fmd_bsu BEFORE UPDATE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + END LOOP; +END $$; + +-- plain DELETE of a row not yet modified by this transaction +BEGIN; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- plain DELETE of a row this transaction updated in an earlier command +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- the merge.sql "classic UPSERT, with a DELETE" +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +MERGE INTO fmd_heap t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +MERGE INTO fmd_flux t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- an UPDATE behind the same trigger must keep its new value, too +BEGIN; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 7 WHERE tid = 3; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- after ROLLBACK both tables are back to the original rows +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; + +DROP TABLE fmd_heap, fmd_flux, fmd_src; +DROP FUNCTION fmd_trig(); diff --git a/src/test/regress/sql/flux_rewrite_xact.sql b/src/test/regress/sql/flux_rewrite_xact.sql new file mode 100644 index 0000000000000..016e05a2274f8 --- /dev/null +++ b/src/test/regress/sql/flux_rewrite_xact.sql @@ -0,0 +1,194 @@ +-- +-- FLUX: commit, abort and PREPARE work after the relation's storage was +-- replaced in the same transaction (SET ACCESS METHOD, ALTER COLUMN TYPE, +-- CLUSTER, TRUNCATE). +-- +-- FLUX remembers the tuples a transaction wrote by relation OID and TID, and +-- at commit it clears a hint bit on each of them, or marks rows a rolled-back +-- savepoint inserted as deleted. A rewrite keeps the OID but moves the rows +-- to new storage, where the same TIDs name other tuples (or are past the end +-- of the file, or belong to a heap page). Every case runs on a heap twin and +-- on FLUX, with COMMIT and with ROLLBACK; the rows written before and after +-- the rewrite have equal widths, so their TIDs collide. The two columns of +-- the result must agree. +-- +-- amcheck's verify_heapam checks every table that ends up heap when the +-- extension is available; the result is the same either way. It goes into a +-- schema of its own, off the search path of the tests running alongside. +CREATE SCHEMA rx_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA rx_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; + +-- A wide value that compresses inline, as heap and FLUX both store it. +CREATE FUNCTION rx_val(k int) RETURNS text LANGUAGE sql IMMUTABLE + RETURN repeat(md5(k::text), 150); + +-- Rows, their ids, the rows whose value is intact, then FOR UPDATE, UPDATE +-- and DELETE counts in the same transaction, and amcheck problems. +CREATE FUNCTION rx_check(t text) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + rel regclass := to_regclass(t); + vh text := (SELECT format('%I.verify_heapam', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; ids bigint; ks bigint; good bigint; lck bigint; upd bigint; + del bigint; bad bigint := 0; +BEGIN + IF rel IS NULL THEN + RETURN 'no table'; + END IF; + IF (SELECT a.amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.oid = rel) = 'heap' + AND vh IS NOT NULL THEN + EXECUTE format('SELECT count(*) FROM %s(%L)', vh, rel) INTO bad; + END IF; + EXECUTE format('SELECT count(*), sum(id), sum(k), + count(*) FILTER (WHERE w = rx_val(k)) + FROM %s', rel) INTO n, ids, ks, good; + EXECUTE format('SELECT count(*) FROM (SELECT 1 FROM %s FOR UPDATE) x', rel) + INTO lck; + EXECUTE format('WITH x AS (UPDATE %s SET w = w RETURNING 1) + SELECT count(*) FROM x', rel) INTO upd; + EXECUTE format('WITH x AS (DELETE FROM %s RETURNING 1) + SELECT count(*) FROM x', rel) INTO del; + RETURN format('rows=%s ids=%s ks=%s good=%s lock=%s upd=%s del=%s amcheck=%s', + n, ids, ks, good, lck, upd, del, bad); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '(pglz|lz4)', 'lzN'); +END $$; + +-- Each case: statements before BEGIN, then the transaction body. $T is the +-- table, $AM its access method. The base is 20 committed rows, inserted in +-- descending id order so that CLUSTER moves them. +CREATE TABLE rx_case (n int, name text, setup text[], body text[]); +INSERT INTO rx_case VALUES +-- flux -> heap +(1, 'fh', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- flux -> heap -> flux +(2, 'fhf', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ALTER TABLE $T SET ACCESS METHOD $AM']), +-- flux -> heap, after rows rolled back to a savepoint +(3, 'sp_fh', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- TRUNCATE, then new rows on the same TIDs +(4, 'trunc', NULL, ARRAY[ + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- rows rolled back to a savepoint, then TRUNCATE and new rows on their TIDs +(5, 'sp_trunc', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g']), +-- ALTER COLUMN TYPE +(6, 'act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their +-- TIDs +(7, 'sp_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- CLUSTER after updates and deletes in place (no rows rolled back) +(8, 'cluster', NULL, ARRAY[ + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'DELETE FROM $T WHERE id % 5 = 0', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g', + 'CLUSTER $T USING $T_pkey']), +-- TRUNCATE + ALTER TYPE: the rows before TRUNCATE fill blocks the rewritten +-- relation does not have +(9, 'trunc_act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- the same with those rows rolled back to a savepoint; the new rows go in +-- after the rewrite +(10, 'sp_trunc_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a table created in this transaction is truncated in place, keeping its +-- storage: rows rolled back to a savepoint, then new rows on their TIDs +(11, 'inplace', ARRAY[]::text[], ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(1, 20) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again, and the rows written to it before the savepoint still commit +(12, 'sp_rewrite_rb', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'SAVEPOINT a', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ROLLBACK TO a', + 'SAVEPOINT b', + 'TRUNCATE $T', + 'ROLLBACK TO b']); + +CREATE FUNCTION rx_script() RETURNS SETOF text LANGUAGE plpgsql AS $$ +DECLARE + c rx_case; + fin text; + am text; + t text; + s text; +BEGIN + FOR c IN SELECT * FROM rx_case ORDER BY n LOOP + FOREACH fin IN ARRAY ARRAY['COMMIT', 'ROLLBACK'] LOOP + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + t := format('rx_%s_%s_%s', c.name, lower(fin), am); + FOREACH s IN ARRAY coalesce(c.setup, ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g']) + || ARRAY['BEGIN'] || c.body || fin LOOP + RETURN NEXT replace(replace(s, '$T', t), '$AM', am); + END LOOP; + END LOOP; + END LOOP; + END LOOP; +END $$; + +SELECT rx_script() \gexec + +\pset format unaligned +SELECT name, fin, heap, CASE WHEN flux = heap THEN 'same' ELSE flux END AS flux +FROM (SELECT c.n, c.name, f.fin, + rx_check(format('rx_%s_%s_heap', c.name, lower(f.fin))) AS heap, + rx_check(format('rx_%s_%s_flux', c.name, lower(f.fin))) AS flux + FROM rx_case c, (VALUES ('COMMIT'), ('ROLLBACK')) f(fin)) r +ORDER BY n, fin; +\pset format aligned + +-- Clean up. +SELECT format('DROP TABLE %I', relname) FROM pg_class +WHERE relname ~ '^rx_.*_(heap|flux)$' AND relkind = 'r' ORDER BY 1 \gexec +DROP FUNCTION rx_script(), rx_check(text), rx_val(int); +DROP TABLE rx_case; +SET client_min_messages = warning; +DROP SCHEMA rx_amcheck CASCADE; +RESET client_min_messages; diff --git a/src/test/regress/sql/flux_tidbitmap.sql b/src/test/regress/sql/flux_tidbitmap.sql new file mode 100644 index 0000000000000..0c8d4743d07d0 --- /dev/null +++ b/src/test/regress/sql/flux_tidbitmap.sql @@ -0,0 +1,71 @@ +-- +-- FLUX: line-pointer offsets stay within MaxHeapTuplesPerPage. +-- +-- tidbitmap.c sizes each exact page entry for TBM_MAX_TUPLES_PER_PAGE == +-- MaxHeapTuplesPerPage offsets, for every table AM. FLUX must therefore +-- never place a tuple at a higher offset, or a bitmap scan over that page +-- fails with "tuple offset out of range". The narrowest FLUX rows cannot get +-- there by geometry alone (every item carries a 36-byte tuple header), but +-- line pointers left LP_DEAD by pruning can: they are not reused until VACUUM, +-- so repeated insert/delete churn on one page keeps appending new ones. +-- +CREATE FUNCTION tbm_off(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[1]::int $$; +CREATE FUNCTION tbm_blk(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[0]::int $$; +-- MaxHeapTuplesPerPage: (BLCKSZ - page header) / (tuple header + line pointer) +CREATE FUNCTION tbm_max_off() RETURNS int + LANGUAGE sql STABLE AS $$ SELECT (current_setting('block_size')::int - 24) / 28 $$; + +-- narrowest rows: a page fills up by space, well under the offset limit +CREATE TABLE tbm_z0 () USING flux; +INSERT INTO tbm_z0 SELECT FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_z0 WHERE tbm_blk(ctid) = 0; + +CREATE TABLE tbm_b (b bool) USING flux; +CREATE INDEX tbm_b_idx ON tbm_b (b); +INSERT INTO tbm_b SELECT true FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_b WHERE tbm_blk(ctid) = 0; + +-- insert/delete churn: pruning leaves LP_DEAD line pointers behind +CREATE TABLE tbm_churn (i int) USING flux WITH (autovacuum_enabled = off); +CREATE INDEX tbm_churn_idx ON tbm_churn (i); +CREATE TABLE tbm_maxoff (round int, maxoff int); +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 1, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 2, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 3, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 4, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 5, max(tbm_off(ctid)) FROM tbm_churn; +SELECT round, maxoff <= tbm_max_off() AS fits FROM tbm_maxoff ORDER BY round; +SELECT count(*) AS beyond_max_off FROM tbm_churn WHERE tbm_off(ctid) > tbm_max_off(); + +-- a bitmap scan over every page returns every row +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +EXPLAIN (COSTS OFF) SELECT count(*) FROM tbm_churn WHERE i > 0; +SELECT count(*) FROM tbm_churn WHERE i > 0; +SELECT count(*) FROM tbm_b WHERE b; +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_indexonlyscan; + +DROP TABLE tbm_z0, tbm_b, tbm_churn, tbm_maxoff; +DROP FUNCTION tbm_off, tbm_blk, tbm_max_off; diff --git a/src/test/regress/sql/flux_toast_foreign.sql b/src/test/regress/sql/flux_toast_foreign.sql new file mode 100644 index 0000000000000..dd9afb6f105dc --- /dev/null +++ b/src/test/regress/sql/flux_toast_foreign.sql @@ -0,0 +1,445 @@ +-- +-- FLUX and foreign TOAST pointers. +-- +-- A value read from another table can reach a table AM as an on-disk TOAST +-- pointer into that table's TOAST relation. The AM must not store such a +-- pointer verbatim: the value has to go into the target's own TOAST relation +-- (or inline), otherwise dropping the source makes the row unreadable. heap +-- does this in heap_toast_insert_or_update. Likewise an oversized inline +-- value must be compressed/externalized the way heap would. +-- +-- Every path below runs against heap (the control) and FLUX. The sources are +-- then dropped and each row is re-read and compared by md5 and length, every +-- external pointer is checked to resolve in the table's own TOAST relation, +-- and the stored shape (size, compression, external or not) of every FLUX row +-- is compared with its heap twin. +-- + +\getenv abs_builddir PG_ABS_BUILDDIR +\set tf_file :abs_builddir '/results/flux_toast_foreign.data' +SELECT set_config('tf.file', :'tf_file', false) IS NOT NULL AS ok, + set_config('tf.cut', '0', false) IS NOT NULL AS ok; + +-- Row 1: 100 kB incompressible EXTERNAL v, 1 MB compressible w (external, +-- compressed). Rows 2-3: 100 kB v. Row 4: short v, 5000-byte compressible w +-- (inline compressed in the source). +CREATE TABLE heap_src (id int PRIMARY KEY, v text, w text) USING heap; +ALTER TABLE heap_src ALTER COLUMN v SET STORAGE EXTERNAL; +INSERT INTO heap_src +SELECT g, + CASE WHEN g < 4 THEN (SELECT string_agg(md5((g * 10000 + i)::text), '') + FROM generate_series(1, 3125) i) + ELSE 'short' END, + CASE g WHEN 1 THEN repeat(md5('w'), 32768) WHEN 2 THEN NULL + WHEN 3 THEN 'w3' ELSE repeat('x', 5000) END +FROM generate_series(1, 4) g; + +SELECT id, length(v) AS lv, + pg_column_toast_chunk_id(v) IS NOT NULL AS v_ext, + pg_column_compression(v) IS NOT NULL AS v_cmp, + length(w) AS lw, + pg_column_toast_chunk_id(w) IS NOT NULL AS w_ext, + pg_column_compression(w) IS NOT NULL AS w_cmp +FROM heap_src ORDER BY id; + +CREATE TABLE tf_ref AS + SELECT id, length(v) AS lv, md5(v) AS mv, length(w) AS lw, md5(w) AS mw + FROM heap_src; + +-- The reverse-direction source: FLUX values in FLUX's own TOAST relation. +CREATE TABLE flux_src USING flux AS SELECT * FROM heap_src; + +COPY heap_src TO :'tf_file'; +\set tf_file1 :abs_builddir '/results/flux_toast_foreign_1.data' +SELECT set_config('tf.file1', :'tf_file1', false) IS NOT NULL AS ok; +COPY (SELECT 1 FROM generate_series(1, 4)) TO :'tf_file1'; + +-- STABLE, so COPY keeps its multi-insert path for a column defaulting to it; +-- the result is heap_src's TOAST pointer, not a detoasted copy. +-- Materialized views read heap_src through dynamic SQL, so that heap_src can +-- be dropped while they exist. +CREATE FUNCTION tf_src(cut int) RETURNS TABLE (id int, v text, w text) + LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY EXECUTE 'SELECT * FROM public.heap_src WHERE id <= $1' USING cut; +END $$; +CREATE FUNCTION tf_def_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_def_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM flux_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM flux_src WHERE id = 1'; + +-- Row count, rows whose content differs from tf_ref, and rows holding an +-- external pointer that does not resolve in the table's own TOAST relation. +CREATE FUNCTION tf_check(tbl regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + toast regclass; + nrows int; + nbad int; + nforeign int; +BEGIN + SELECT reltoastrelid::regclass INTO toast FROM pg_class WHERE oid = tbl; + EXECUTE format($q$ + SELECT count(*), + count(*) FILTER (WHERE r.id IS NULL + OR length(t.v) IS DISTINCT FROM r.lv + OR md5(t.v) IS DISTINCT FROM r.mv + OR length(t.w) IS DISTINCT FROM r.lw + OR md5(t.w) IS DISTINCT FROM r.mw), + count(*) FILTER (WHERE + (pg_column_toast_chunk_id(t.v) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.v)::text)) + OR (pg_column_toast_chunk_id(t.w) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.w)::text))) + FROM %1$s t LEFT JOIN tf_ref r USING (id)$q$, tbl, toast) + INTO nrows, nbad, nforeign; + RETURN format('rows=%s bad=%s foreign=%s', nrows, nbad, nforeign); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; + +-- How each value is stored: compressed, external, and whether it is held +-- inline at more than the TOAST target. (Exact sizes differ by design: FLUX +-- does not convert short varlenas to 1-byte headers.) +CREATE FUNCTION tf_shape(tbl regclass) +RETURNS TABLE (id int, bv bool, cv bool, ev bool, bw bool, cw bool, ew bool) +LANGUAGE plpgsql AS $$ +BEGIN + IF tbl IS NULL THEN + RETURN; + END IF; + RETURN QUERY EXECUTE format($q$ + SELECT id, pg_column_size(v) > 2000 AND pg_column_toast_chunk_id(v) IS NULL, + pg_column_compression(v) IS NOT NULL, + pg_column_toast_chunk_id(v) IS NOT NULL, + pg_column_size(w) > 2000 AND pg_column_toast_chunk_id(w) IS NULL, + pg_column_compression(w) IS NOT NULL, + pg_column_toast_chunk_id(w) IS NOT NULL + FROM %s$q$, tbl); +END $$; + +-- Run one path's statements against table tbl of access method am. A failure +-- is reported as a NOTICE and leaves the table absent from the listings. +CREATE FUNCTION tf_path(tbl text, am text, VARIADIC stmts text[]) +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + s text; +BEGIN + FOREACH s IN ARRAY stmts LOOP + EXECUTE replace(replace(s, '$T', quote_ident(tbl)), '$AM', am); + END LOOP; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE '%: %', tbl, regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; + +-- Phase 1: build and load. One transaction. +DO $do$ +DECLARE + am text; + cols text := '(id int, v text, w text)'; + pk text := '(id int PRIMARY KEY, v text, w text)'; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + -- INSERT ... SELECT, one row and several rows + PERFORM tf_path('tf_ins1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src WHERE id = 1'); + PERFORM tf_path('tf_insm_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- INSERT ... ON CONFLICT DO NOTHING (speculative insertion) + PERFORM tf_path('tf_ocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same into a partition + PERFORM tf_path('tf_ocnp_' || am, am, + 'CREATE TABLE $T ' || pk || ' PARTITION BY LIST (id)', + 'CREATE TABLE tf_ocnp_part_$AM PARTITION OF $T DEFAULT USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same with oversized inline values + PERFORM tf_path('tf_ocbig_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, v || '''', w || '''' FROM heap_src ON CONFLICT DO NOTHING'); + -- ON CONFLICT DO UPDATE: row 1 exists (committed before phase 2) + PERFORM tf_path('tf_ocu_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T VALUES (1, ''a'', ''b'')'); + -- UPDATE targets, loaded with placeholders or own values + PERFORM tf_path('tf_upd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + PERFORM tf_path('tf_upd1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', w FROM heap_src'); + PERFORM tf_path('tf_updk_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + -- same-size in-place UPDATE of a MAIN column (CAS fast path in FLUX) + PERFORM tf_path('tf_cas_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'ALTER TABLE $T ALTER COLUMN w SET STORAGE MAIN', + 'INSERT INTO $T SELECT 4, ''short'', left(string_agg(md5(i::text), ''''), 5000) + FROM generate_series(1, 200) i'); + -- COPY FROM a file (multi_insert) + PERFORM tf_path('tf_copy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + format('COPY $T FROM %L', current_setting('tf.file'))); + PERFORM tf_path('tf_pcopy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM'); + -- COPY FROM with defaults that return heap_src's pointers (multi_insert), + -- into a plain table and into a partition + PERFORM tf_path('tf_cdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_cdefp_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) PARTITION BY LIST (id)', + 'CREATE TABLE tf_cdefp_part_$AM PARTITION OF $T DEFAULT USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + -- CREATE TABLE AS + PERFORM tf_path('tf_ctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM heap_src'); + -- materialized views: plain REFRESH, and REFRESH CONCURRENTLY from empty + PERFORM tf_path('tf_mv_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS SELECT * FROM tf_src(4)', + 'REFRESH MATERIALIZED VIEW $T'); + PERFORM tf_path('tf_mvc_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS + SELECT * FROM tf_src(current_setting(''tf.cut'')::int)', + 'CREATE UNIQUE INDEX ON $T (id)', + 'SELECT set_config(''tf.cut'', ''4'', false)', + 'REFRESH MATERIALIZED VIEW CONCURRENTLY $T', + 'SELECT set_config(''tf.cut'', ''0'', false)'); + -- plpgsql: scalar variables and a record variable + PERFORM tf_path('tf_plv_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE xv text; xw text; BEGIN + FOR i IN 1..4 LOOP + SELECT v, w INTO xv, xw FROM heap_src WHERE id = i; + INSERT INTO $T VALUES (i, xv, xw); + END LOOP; END $p$'); + PERFORM tf_path('tf_plr_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE r record; BEGIN + FOR r IN SELECT * FROM heap_src LOOP + INSERT INTO $T VALUES (r.id, r.v, r.w); + END LOOP; END $p$'); + -- SET ACCESS METHOD to the other AM, and there and back + PERFORM tf_path('tf_sam_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END); + PERFORM tf_path('tf_samrt_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END, + 'ALTER TABLE $T SET ACCESS METHOD $AM'); + -- VACUUM FULL target (run below, outside a transaction block) + PERFORM tf_path('tf_vf_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- reverse direction: FLUX values into heap and into another FLUX table + PERFORM tf_path('tf_rins_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src'); + PERFORM tf_path('tf_rocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src ON CONFLICT DO NOTHING'); + PERFORM tf_path('tf_rcdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_rdef_v(), + w text DEFAULT tf_rdef_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_rctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM flux_src'); + PERFORM tf_path('tf_rupd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM flux_src'); + END LOOP; +END +$do$; + +-- Phase 2: UPDATEs against committed rows. +DO $do$ +DECLARE + am text; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + PERFORM tf_path('tf_ocu_' || am, am, + 'INSERT INTO $T SELECT * FROM heap_src + ON CONFLICT (id) DO UPDATE SET v = EXCLUDED.v, w = EXCLUDED.w'); + PERFORM tf_path('tf_upd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_upd1_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_updk_' || am, am, + 'UPDATE $T t SET id = -t.id, + v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)', + 'UPDATE $T SET id = -id'); + PERFORM tf_path('tf_rupd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM flux_src s WHERE s.id = t.id), + w = (SELECT w FROM flux_src s WHERE s.id = t.id)'); + END LOOP; +END +$do$; + +UPDATE tf_cas_heap SET w = repeat('x', 5000); +UPDATE tf_cas_flux SET w = repeat('x', 5000); + +-- psql's \copy takes its arguments verbatim, so use a relative path. +\cd :abs_builddir +\copy tf_pcopy_heap FROM 'results/flux_toast_foreign.data' +\copy tf_pcopy_flux FROM 'results/flux_toast_foreign.data' + +VACUUM FULL tf_vf_heap; +VACUUM FULL tf_vf_flux; + +-- Before the drop. Each check query lists only failures. +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE (c.relname LIKE 'tf\_%' OR c.relname IN ('heap_src', 'flux_src')) + AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none + +-- Storage shape: every FLUX table must store its rows the way its heap twin +-- does. Lists the rows that differ. +SELECT p.path, d.* +FROM (SELECT left(relname, -5) AS path FROM pg_class + WHERE relname LIKE 'tf\_%\_heap' AND relkind IN ('r', 'm')) p, + LATERAL (SELECT 'heap' AS side, * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_heap')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_flux'))) x + UNION ALL + SELECT 'flux', * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_flux')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_heap'))) y) d +ORDER BY 1, 2, 3; -- expect none + +DROP TABLE heap_src, flux_src; + +-- After the drop, in a new session. +\c +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE c.relname LIKE 'tf\_%' AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none +-- 27 paths, each on heap and flux; a path that failed is missing. +SELECT n FROM (SELECT count(*) AS n FROM pg_class + WHERE relname LIKE 'tf\_%' AND relname <> 'tf_ref' + AND relkind IN ('r', 'm')) s +WHERE n <> 54; -- expect none + +-- +-- FLUX commit-time work after the relation's storage was replaced in the same +-- transaction. Rows written to the old storage must not be confused with the +-- tuples at the same TIDs in the new storage. (VACUUM FULL cannot run in a +-- transaction block; CLUSTER is the in-transaction rewrite.) +-- +CREATE TABLE tr_ref AS + SELECT g AS id, repeat(md5(g::text), 150) AS w FROM generate_series(1, 20) g; +CREATE FUNCTION tr_bad(tbl regclass) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM + ((SELECT id::int, w FROM %1$s EXCEPT ALL SELECT * FROM tr_ref) + UNION ALL + (SELECT * FROM tr_ref EXCEPT ALL SELECT id::int, w FROM %1$s)) x', tbl) + INTO n; + RETURN n; +EXCEPTION WHEN OTHERS THEN + RETURN -1; +END $$; +CREATE TABLE tr_fh (id int, w text) USING flux; +CREATE TABLE tr_fhf (id int, w text) USING flux; +CREATE TABLE tr_cl (id int PRIMARY KEY, w text) USING flux; +CREATE TABLE tr_act (id int, w text) USING flux; +CREATE TABLE tr_trunc (id int, w text) USING flux; +CREATE TABLE tr_spa (id int, w text) USING flux; +CREATE TABLE tr_spr (id int, w text) USING flux; +CREATE TABLE tr_spr2 (id int, w text) USING flux; +-- flux -> heap +BEGIN; +INSERT INTO tr_fh SELECT * FROM tr_ref; +ALTER TABLE tr_fh SET ACCESS METHOD heap; +COMMIT; +-- flux -> heap -> flux +BEGIN; +INSERT INTO tr_fhf SELECT * FROM tr_ref; +ALTER TABLE tr_fhf SET ACCESS METHOD heap; +ALTER TABLE tr_fhf SET ACCESS METHOD flux; +COMMIT; +-- CLUSTER (reorders the rows, so TIDs name other rows) +BEGIN; +INSERT INTO tr_cl SELECT * FROM tr_ref ORDER BY id DESC; +CLUSTER tr_cl USING tr_cl_pkey; +COMMIT; +-- ALTER COLUMN TYPE +BEGIN; +INSERT INTO tr_act SELECT * FROM tr_ref; +ALTER TABLE tr_act ALTER COLUMN id TYPE bigint; +COMMIT; +-- rows rolled back to a savepoint, then TRUNCATE and refill the same TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_trunc SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +TRUNCATE tr_trunc; +INSERT INTO tr_trunc SELECT * FROM tr_ref; +COMMIT; +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spa SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spa SELECT * FROM tr_ref; +ALTER TABLE tr_spa ALTER COLUMN id TYPE bigint; +COMMIT; +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again and its commit-time work must still be done +BEGIN; +INSERT INTO tr_spr SELECT * FROM tr_ref; +SAVEPOINT a; +ALTER TABLE tr_spr SET ACCESS METHOD heap; +ROLLBACK TO a; +COMMIT; +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spr2 SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spr2 SELECT * FROM tr_ref; +SAVEPOINT b; +ALTER TABLE tr_spr2 ALTER COLUMN id TYPE bigint; +ROLLBACK TO b; +COMMIT; +SELECT t, tr_bad(t::regclass) AS bad +FROM unnest(ARRAY['tr_fh', 'tr_fhf', 'tr_cl', 'tr_act', 'tr_trunc', 'tr_spa', + 'tr_spr', 'tr_spr2']) t +WHERE tr_bad(t::regclass) <> 0 +ORDER BY 1; -- expect none +DROP TABLE tr_fh, tr_fhf, tr_cl, tr_act, tr_trunc, tr_spa, tr_spr, tr_spr2, tr_ref; +DROP FUNCTION tr_bad(regclass); + +-- Clean up. +DO $do$ +DECLARE + r record; +BEGIN + FOR r IN SELECT relname, relkind FROM pg_class + WHERE relname LIKE 'tf\_%' AND relkind IN ('r', 'm', 'p') + AND NOT relispartition LOOP + EXECUTE format('DROP %s %I', + CASE r.relkind WHEN 'm' THEN 'MATERIALIZED VIEW' ELSE 'TABLE' END, + r.relname); + END LOOP; +END +$do$; +DROP FUNCTION tf_check(regclass), tf_shape(regclass), tf_path(text, text, text[]), + tf_def_v(), tf_def_w(), tf_rdef_v(), tf_rdef_w(), tf_src(int); diff --git a/src/test/regress/sql/flux_trigger_memory.sql b/src/test/regress/sql/flux_trigger_memory.sql new file mode 100644 index 0000000000000..88f868e709399 --- /dev/null +++ b/src/test/regress/sql/flux_trigger_memory.sql @@ -0,0 +1,143 @@ +-- +-- Memory held for AFTER ROW trigger events across a long transaction. +-- +-- A FLUX row event carries copies of its row images (the TID names only the +-- newest version). Those copies must go away with the event: an immediate +-- event is gone at the end of its statement, an event of an aborted +-- subtransaction is gone at its rollback. Each case runs many top-level +-- statements in one transaction and compares the after-trigger event memory +-- after the first statement with that after the last; heap is the control. +-- +CREATE FUNCTION tm_ctx_bytes() RETURNS bigint LANGUAGE sql AS $$ + SELECT coalesce(sum(total_bytes), 0)::bigint FROM pg_backend_memory_contexts + WHERE name LIKE 'AfterTrigger%' +$$; +CREATE TEMP TABLE tm_mem (k text, n int, bytes bigint); +CREATE FUNCTION tm_noop() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NULL; +END $$; + +-- Immediate AFTER UPDATE row trigger +CREATE TABLE tm_trig_heap (id int PRIMARY KEY, v int) USING heap; +CREATE TABLE tm_trig_flux (id int PRIMARY KEY, v int) USING flux; +INSERT INTO tm_trig_heap SELECT g, 0 FROM generate_series(1, 1000) g; +INSERT INTO tm_trig_flux SELECT g, 0 FROM generate_series(1, 1000) g; +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_heap + FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_flux + FOR EACH ROW EXECUTE FUNCTION tm_noop(); + +-- Immediate foreign key check (RI trigger on the referencing table) +CREATE TABLE tm_pk_heap (id int PRIMARY KEY) USING heap; +CREATE TABLE tm_pk_flux (id int PRIMARY KEY) USING flux; +INSERT INTO tm_pk_heap VALUES (1), (2); +INSERT INTO tm_pk_flux VALUES (1), (2); +CREATE TABLE tm_fk_heap (id int PRIMARY KEY, pid int REFERENCES tm_pk_heap) + USING heap; +CREATE TABLE tm_fk_flux (id int PRIMARY KEY, pid int REFERENCES tm_pk_flux) + USING flux; +INSERT INTO tm_fk_heap SELECT g, 1 FROM generate_series(1, 1000) g; +INSERT INTO tm_fk_flux SELECT g, 1 FROM generate_series(1, 1000) g; + +-- Each case: one transaction of 200 top-level UPDATEs of 1000 rows (each in a +-- rolled-back savepoint for the subxact cases), recording the event memory +-- after the first and after the last. +\set ECHO none +\o /dev/null +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_trig_heap SET v = v + 1; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'trig_heap') AS s + UNION ALL SELECT 2, $q$UPDATE tm_trig_heap SET v = v + 1$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_trig_heap SET v = v + 1; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'trig_heap') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_trig_flux SET v = v + 1; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'trig_flux') AS s + UNION ALL SELECT 2, $q$UPDATE tm_trig_flux SET v = v + 1$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_trig_flux SET v = v + 1; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'trig_flux') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_fk_heap SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'fk_heap') AS s + UNION ALL SELECT 2, $q$UPDATE tm_fk_heap SET pid = 3 - pid$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_fk_heap SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'fk_heap') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_fk_flux SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'fk_flux') AS s + UNION ALL SELECT 2, $q$UPDATE tm_fk_flux SET pid = 3 - pid$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_fk_flux SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'fk_flux') +) s ORDER BY o \gexec +COMMIT; +\o +\set ECHO all + +-- Deferred trigger events of rolled-back subtransactions +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +\set ECHO none +\o /dev/null +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$SAVEPOINT s; UPDATE tm_trig_heap SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'subxact_heap') AS s + UNION ALL SELECT 2, $q$SAVEPOINT s; UPDATE tm_trig_heap SET v = v + 1; ROLLBACK TO s; RELEASE s$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$SAVEPOINT s; UPDATE tm_trig_heap SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'subxact_heap') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$SAVEPOINT s; UPDATE tm_trig_flux SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'subxact_flux') AS s + UNION ALL SELECT 2, $q$SAVEPOINT s; UPDATE tm_trig_flux SET v = v + 1; ROLLBACK TO s; RELEASE s$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$SAVEPOINT s; UPDATE tm_trig_flux SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'subxact_flux') +) s ORDER BY o \gexec +COMMIT; +\o +\set ECHO all + +-- One statement's FLUX images take about 250 kB, so keeping them for the +-- transaction grows the context by ~50 MB here. Heap grows by well under +-- 1 MB (per-statement bookkeeping, e.g. the modified-columns bitmaps). +SELECT a.k, b.bytes - a.bytes < 4 * 1024 * 1024 AS flat +FROM tm_mem a JOIN tm_mem b ON a.k = b.k AND a.n = 1 AND b.n > 1 +ORDER BY a.k; + +-- The deferred events of a committed statement still fire, with their images +CREATE TABLE tm_log (tab text, o int, n int); +CREATE FUNCTION tm_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO tm_log VALUES (TG_TABLE_NAME, OLD.v, NEW.v); + RETURN NULL; +END $$; +UPDATE tm_trig_heap SET v = 0; +UPDATE tm_trig_flux SET v = 0; +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +BEGIN; +UPDATE tm_trig_heap SET v = 1000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 1000 WHERE id <= 3; +SAVEPOINT s1; +UPDATE tm_trig_heap SET v = 2000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 2000 WHERE id <= 3; +ROLLBACK TO s1; +SAVEPOINT s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 3; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 3; +RELEASE s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 2; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 2; +COMMIT; +SELECT tab, o, n, count(*) FROM tm_log GROUP BY 1, 2, 3 ORDER BY 1, 2, 3; +SELECT h.id, h.v AS heap_v, f.v AS flux_v +FROM tm_trig_heap h JOIN tm_trig_flux f USING (id) WHERE id <= 3 ORDER BY id; + +DROP TABLE tm_trig_heap, tm_trig_flux, tm_fk_heap, tm_fk_flux, tm_pk_heap, + tm_pk_flux, tm_log; +DROP FUNCTION tm_ctx_bytes(), tm_noop(), tm_log_row(); diff --git a/src/test/regress/sql/flux_user_catalog.sql b/src/test/regress/sql/flux_user_catalog.sql new file mode 100644 index 0000000000000..3b77acf931970 --- /dev/null +++ b/src/test/regress/sql/flux_user_catalog.sql @@ -0,0 +1,38 @@ +-- +-- FLUX: user_catalog_table is refused. +-- +-- A user_catalog_table is read by logical decoding under a historic snapshot +-- that reconstructs a past catalog state from the heap's dead row versions. +-- FLUX keeps old versions in undo and reuses the tuple in place, so it cannot +-- answer those historic-snapshot reads. Setting user_catalog_table on a FLUX +-- relation must therefore be refused, at CREATE, at ALTER TABLE ... SET, and +-- when converting a marked heap table to FLUX with SET ACCESS METHOD. The heap +-- twin of each case must still succeed, which is the control: the refusal is +-- specific to the FLUX access method, not to the option itself. +-- + +-- CREATE: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_create (id int) USING heap + WITH (user_catalog_table = true); +CREATE TABLE uct_flux_create (id int) USING flux + WITH (user_catalog_table = true); + +-- ALTER TABLE ... SET: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_set (id int) USING heap; +ALTER TABLE uct_heap_set SET (user_catalog_table = true); + +CREATE TABLE uct_flux_set (id int) USING flux; +ALTER TABLE uct_flux_set SET (user_catalog_table = true); + +-- SET ACCESS METHOD: converting a marked heap table to FLUX is refused with the +-- FLUX-specific message, before core's generic "cannot rewrite a catalog table" +-- restriction is reached. +CREATE TABLE uct_conv (id int) USING heap WITH (user_catalog_table = true); +ALTER TABLE uct_conv SET ACCESS METHOD flux; + +-- Clearing the option, and other FLUX reloptions, still work on a FLUX table. +CREATE TABLE uct_flux_clear (id int) USING flux WITH (fillfactor = 90); +ALTER TABLE uct_flux_clear SET (user_catalog_table = false); +ALTER TABLE uct_flux_clear SET (fillfactor = 80); + +DROP TABLE uct_heap_create, uct_heap_set, uct_flux_set, uct_conv, uct_flux_clear; diff --git a/src/test/regress/sql/flux_vacuum_stats.sql b/src/test/regress/sql/flux_vacuum_stats.sql new file mode 100644 index 0000000000000..cee5277cb6c72 --- /dev/null +++ b/src/test/regress/sql/flux_vacuum_stats.sql @@ -0,0 +1,45 @@ +-- A one-page heap twin makes physical page counts comparable despite headers. +CREATE TABLE fvs_heap (a int PRIMARY KEY) USING heap WITH (autovacuum_enabled=false); +CREATE TABLE fvs_flux (a int PRIMARY KEY) USING flux WITH (autovacuum_enabled=false); +INSERT INTO fvs_heap SELECT generate_series(1,20); +INSERT INTO fvs_flux SELECT generate_series(1,20); +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, relpages = 1 AS pages, reltuples = 20 AS tuples, + relallvisible = 1 AS visible +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +SELECT relname, vacuum_count = 1 AS vacuum_reported +FROM pg_stat_user_tables WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +-- Burn an xid so a no-op freeze cannot pass on a newly created relation. +SELECT pg_current_xact_id() IS NOT NULL AS assigned; +VACUUM (FREEZE) fvs_heap; +VACUUM (FREEZE) fvs_flux; +SELECT relname, age(relfrozenxid) <= 1 AS frozen, + relallfrozen = 1 AS frozen_page +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +-- A repeated vacuum must count pages skipped as all-frozen, not report zero. +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +-- Force bulk-delete so both index AMs return accurate cleanup statistics. +INSERT INTO fvs_heap VALUES (99); +INSERT INTO fvs_flux VALUES (99); +DELETE FROM fvs_heap WHERE a=99; +DELETE FROM fvs_flux WHERE a=99; +VACUUM (INDEX_CLEANUP ON) fvs_heap; +VACUUM (INDEX_CLEANUP ON) fvs_flux; +SELECT relname, relpages > 0 AS pages, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap_pkey', 'fvs_flux_pkey') ORDER BY relname; +-- A cursor stores a version identity before DML freezes its before-image. +BEGIN; +DECLARE fvs_h CURSOR FOR SELECT * FROM fvs_heap WHERE a=1; +DECLARE fvs_f CURSOR FOR SELECT * FROM fvs_flux WHERE a=1; +FETCH fvs_h; +FETCH fvs_f; +UPDATE fvs_heap SET a=101 WHERE CURRENT OF fvs_h; +UPDATE fvs_flux SET a=101 WHERE CURRENT OF fvs_f; +ROLLBACK; +DROP TABLE fvs_heap, fvs_flux; diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build index e71e95c6297eb..19a40d4994c83 100644 --- a/src/test/subscription/meson.build +++ b/src/test/subscription/meson.build @@ -49,6 +49,13 @@ tests += { 't/037_except.pl', 't/038_walsnd_shutdown_timeout.pl', 't/100_bugs.pl', + 't/101_inplace_am_logicalrep.pl', + 't/102_inplace_am_apply_locks.pl', + 't/103_inplace_am_replica_identity.pl', + 't/104_inplace_am_update_conflict.pl', + 't/105_inplace_am_update_deleted.pl', + 't/106_inplace_am_key_update_wait.pl', + 't/107_inplace_am_outofplace_update.pl', ], }, } diff --git a/src/test/subscription/t/101_inplace_am_logicalrep.pl b/src/test/subscription/t/101_inplace_am_logicalrep.pl new file mode 100644 index 0000000000000..a0ac0f4dd3745 --- /dev/null +++ b/src/test/subscription/t/101_inplace_am_logicalrep.pl @@ -0,0 +1,66 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# Logical replication of INSERT/UPDATE/DELETE into and out of the FLUX +# in-place table AM, in both directions. Exercises FLUX as a subscriber +# (the apply worker's UPDATE/DELETE/INSERT paths) and as a decoding source +# (the same-size in-place UPDATE path and the batch INSERT path both have to +# produce records the logical decoder can turn into a change). +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub roundtrip +{ + my ($pub_am, $sub_am) = @_; + + my $pub = PostgreSQL::Test::Cluster->new("pub_${pub_am}_${sub_am}"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_${pub_am}_${sub_am}"); + $sub->init; + $sub->start; + + # Same schema, possibly different AM, on each side. + $pub->safe_psql('postgres', + "CREATE TABLE t (id int PRIMARY KEY, v int) USING $pub_am"); + $sub->safe_psql('postgres', + "CREATE TABLE t (id int PRIMARY KEY, v int) USING $sub_am"); + + $pub->safe_psql('postgres', + "INSERT INTO t SELECT g, g FROM generate_series(1, 20) g"); + $pub->safe_psql('postgres', "CREATE PUBLICATION p FOR TABLE t"); + + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + # Initial COPY sync. + is($sub->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'), + '20|210', "$pub_am -> $sub_am: initial sync"); + + # UPDATE (the aliasing / CAS-decode / crit-section paths), DELETE, INSERT. + $pub->safe_psql('postgres', 'UPDATE t SET v = v + 100 WHERE id <= 10'); + $pub->safe_psql('postgres', 'DELETE FROM t WHERE id > 15'); + $pub->safe_psql('postgres', 'INSERT INTO t VALUES (100, 999)'); + $pub->wait_for_catchup('s'); + + is($sub->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'), + '16|2119', "$pub_am -> $sub_am: UPDATE/DELETE/INSERT streamed"); + + # Exact row check for the updated rows. + is($sub->safe_psql('postgres', 'SELECT v FROM t WHERE id = 5'), + '105', "$pub_am -> $sub_am: UPDATE value correct on subscriber"); + + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; +} + +# Into the FLUX AM and out of it, both directions. +roundtrip('heap', 'flux'); +roundtrip('flux', 'heap'); + +done_testing(); diff --git a/src/test/subscription/t/102_inplace_am_apply_locks.pl b/src/test/subscription/t/102_inplace_am_apply_locks.pl new file mode 100644 index 0000000000000..c6d75ca00b05f --- /dev/null +++ b/src/test/subscription/t/102_inplace_am_apply_locks.pl @@ -0,0 +1,216 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# The logical replication apply worker locks the row it is about to update or +# delete (RelationFindReplTupleByIndex, table_tuple_lock without +# TUPLE_LOCK_FLAG_FIND_LAST_VERSION) and, with a BEFORE ROW trigger enabled +# for replication, locks it again from GetTupleForTrigger, under an +# EvalPlanQual state that has no plan. Run the same changes against +# concurrent local transactions on the subscriber, once with heap on both +# sides and once with FLUX on both sides, and require FLUX to end in heap's +# state. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $schema = q{ + CREATE TABLE t (id int PRIMARY KEY, v int, w int) USING %AM%; + CREATE TABLE tt (id int PRIMARY KEY, v int, w int) USING %AM%; +}; +my $sub_extra = q{ + CREATE FUNCTION tt_bu() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER tt_bu BEFORE UPDATE OR DELETE ON tt + FOR EACH ROW EXECUTE FUNCTION tt_bu(); + ALTER TABLE tt ENABLE ALWAYS TRIGGER tt_bu; +}; + +# Wait until the apply worker is blocked on a lock; report whether it was. +sub apply_waits +{ + my ($node) = @_; + for (1 .. 50) + { + return 'waited' + if $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'logical replication apply worker' + AND wait_event_type = 'Lock'}) > 0; + select(undef, undef, undef, 0.1); + } + return 'no wait'; +} + +sub run_am +{ + my ($am) = @_; + my %r; + + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + (my $ddl = $schema) =~ s/%AM%/$am/g; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl . $sub_extra); + $pub->safe_psql( + 'postgres', + q{INSERT INTO t SELECT g, g, 0 FROM generate_series(1, 8) g; + INSERT INTO tt SELECT g, g, 0 FROM generate_series(1, 8) g; + CREATE PUBLICATION p FOR TABLE t, tt}); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + my $q = + 'SELECT string_agg(format(\'(%s,%s,%s)\', id, v, w), \' \' ORDER BY id) FROM '; + + # A replicated change against an in-progress local transaction that + # updates or deletes the same row: the apply worker waits for it, then + # applies the change to the committed version, if one is left. + my @cases = ( + [ + 'update vs local update', + 't', 'UPDATE t SET w = w + 1 WHERE id = 1', + 'COMMIT', 'UPDATE t SET v = v + 100 WHERE id = 1' + ], + [ + 'update vs local rollback', + 't', + 'UPDATE t SET w = w + 1 WHERE id = 2', + 'ROLLBACK', + 'UPDATE t SET v = v + 100 WHERE id = 2' + ], + [ + 'delete vs local update', + 't', 'UPDATE t SET w = w + 1 WHERE id = 3', + 'COMMIT', 'DELETE FROM t WHERE id = 3' + ], + [ + 'update vs local lock + update', + 't', + 'SELECT * FROM t WHERE id = 5 FOR UPDATE; UPDATE t SET w = w + 1 WHERE id = 5', + 'COMMIT', + 'UPDATE t SET v = v + 100 WHERE id = 5' + ], + [ + 'trigger: update, no conflict', 'tt', + undef, undef, + 'UPDATE tt SET v = v + 100 WHERE id = 1' + ], + [ + 'trigger: update vs local update', + 'tt', + 'UPDATE tt SET w = w + 1 WHERE id = 2', + 'COMMIT', + 'UPDATE tt SET v = v + 100 WHERE id = 2' + ], + [ + 'trigger: update vs local update twice', + 'tt', + 'UPDATE tt SET w = w + 1 WHERE id = 3; UPDATE tt SET w = w + 1 WHERE id = 3', + 'COMMIT', + 'UPDATE tt SET v = v + 100 WHERE id = 3' + ], + [ + 'trigger: delete vs local update', + 'tt', + 'UPDATE tt SET w = w + 1 WHERE id = 4', + 'COMMIT', + 'DELETE FROM tt WHERE id = 4' + ], + [ + 'update vs local delete', + 't', 'DELETE FROM t WHERE id = 6', + 'COMMIT', 'UPDATE t SET v = v + 100 WHERE id = 6' + ], + [ + 'trigger: delete, no conflict', 'tt', + undef, undef, + 'DELETE FROM tt WHERE id = 6' + ], + [ + 'trigger: update vs local delete', + 'tt', 'DELETE FROM tt WHERE id = 7', + 'COMMIT', 'UPDATE tt SET v = v + 100 WHERE id = 7' + ],); + + foreach my $c (@cases) + { + my ($name, $tab, $local, $end, $remote) = @$c; + my $wait = ''; + if (defined $local) + { + my $bg = $sub->background_psql('postgres'); + $bg->query_safe("BEGIN; $local"); + $pub->safe_psql('postgres', $remote); + $wait = apply_waits($sub); + $bg->query_safe($end); + $bg->quit; + } + else + { + $pub->safe_psql('postgres', $remote); + } + $pub->wait_for_catchup('s'); + $r{$name} = "$wait | " . $sub->safe_psql('postgres', $q . $tab); + } + + # The replicated columns agree with the publisher, except on the rows + # a local transaction deleted (the publisher's change to them found + # nothing to apply). + foreach my $tk ([ 't', '6' ], [ 'tt', '7' ]) + { + my ($tab, $skip) = @$tk; + my $rq = + "SELECT string_agg(format('(%s,%s)', id, v), ' ' ORDER BY id) " + . "FROM $tab WHERE id NOT IN ($skip)"; + $r{"$tab matches publisher"} = + $sub->safe_psql('postgres', $rq) eq $pub->safe_psql('postgres', $rq) + ? 'yes' + : 'no: ' + . $sub->safe_psql('postgres', $rq) . ' vs ' + . $pub->safe_psql('postgres', $rq); + } + + # Nothing crashed, and the worker that applied all this is still the + # first one. + $r{'server crashes'} = () = + slurp_file($sub->logfile) =~ /terminated by signal/g; + $r{'apply errors'} = () = slurp_file($sub->logfile) =~ /ERROR:/g; + + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; + return \%r; +} + +my $heap = run_am('heap'); +my $flux = run_am('flux'); + +foreach my $k (sort keys %$heap) +{ + is($flux->{$k}, $heap->{$k}, "flux matches heap: $k"); + note "heap $k: $heap->{$k}"; +} +foreach my $am ([ 'heap', $heap ], [ 'flux', $flux ]) +{ + my ($name, $r) = @$am; + is($r->{'server crashes'}, + 0, "$name: no \"terminated by signal\" in the subscriber log"); + is($r->{'t matches publisher'}, 'yes', "$name: t matches the publisher"); + is($r->{'tt matches publisher'}, 'yes', + "$name: tt matches the publisher"); +} + +done_testing(); diff --git a/src/test/subscription/t/103_inplace_am_replica_identity.pl b/src/test/subscription/t/103_inplace_am_replica_identity.pl new file mode 100644 index 0000000000000..3f206cb507896 --- /dev/null +++ b/src/test/subscription/t/103_inplace_am_replica_identity.pl @@ -0,0 +1,87 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Exercise sequential and indexed replica identity lookup with heap controls. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am ('heap', 'flux') +{ + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + my $ddl = qq{ + CREATE TABLE d (id int PRIMARY KEY, v text) USING $am; + CREATE TABLE f (id int, v text) USING $am; + ALTER TABLE f REPLICA IDENTITY FULL; + CREATE TABLE i (id int NOT NULL, v text) USING $am; + CREATE UNIQUE INDEX i_key ON i(id); + ALTER TABLE i REPLICA IDENTITY USING INDEX i_key; + }; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl); + $pub->safe_psql('postgres', 'CREATE PUBLICATION p FOR TABLE d, f, i'); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + for my $tab ('d', 'f', 'i') + { + $pub->safe_psql('postgres', + "INSERT INTO $tab VALUES (1, 'one'), (2, 'two')"); + $pub->wait_for_catchup('s'); + $pub->safe_psql('postgres', "UPDATE $tab SET v = 'new' WHERE id = 1"); + $pub->wait_for_catchup('s'); + is( $sub->safe_psql('postgres', "SELECT id, v FROM $tab ORDER BY id"), + "1|new\n2|two", + "$am $tab update"); + $pub->safe_psql('postgres', "DELETE FROM $tab WHERE id = 2"); + $pub->wait_for_catchup('s'); + is( $sub->safe_psql('postgres', "SELECT id, v FROM $tab ORDER BY id"), + '1|new', + "$am $tab delete"); + } + + # A FULL old image must carry the toasted value needed by a row filter. + for my $node ($pub, $sub) + { + $node->safe_psql( + 'postgres', qq{ + CREATE TABLE toast_filter (id int, b text, c text) USING $am; + ALTER TABLE toast_filter ALTER COLUMN c SET STORAGE EXTERNAL; + ALTER TABLE toast_filter REPLICA IDENTITY FULL; + }); + } + $pub->safe_psql( + 'postgres', q{ + ALTER PUBLICATION p ADD TABLE toast_filter WHERE (length(c) > 1000); + }); + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s REFRESH PUBLICATION'); + $sub->wait_for_subscription_sync($pub, 's'); + $pub->safe_psql( + 'postgres', q{ + INSERT INTO toast_filter VALUES (1, 'old', repeat('toast', 1000)); + }); + $pub->wait_for_catchup('s'); + $pub->safe_psql('postgres', "UPDATE toast_filter SET b = '1'"); + $pub->wait_for_catchup('s'); + is( $sub->safe_psql( + 'postgres', 'SELECT id, b, length(c) FROM toast_filter'), + '1|1|5000', + "$am toasted row filter update"); + $pub->safe_psql('postgres', 'DELETE FROM toast_filter'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT count(*) FROM toast_filter'), + '0', "$am toasted row filter delete"); + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; +} +done_testing(); diff --git a/src/test/subscription/t/104_inplace_am_update_conflict.pl b/src/test/subscription/t/104_inplace_am_update_conflict.pl new file mode 100644 index 0000000000000..3b261460f2dcb --- /dev/null +++ b/src/test/subscription/t/104_inplace_am_update_conflict.pl @@ -0,0 +1,117 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# An UPDATE the apply worker cannot apply because its new key already +# exists must be reported as a conflict (update_exists, or +# multiple_unique_conflicts when it collides in several unique indexes), as +# for heap. An in-place AM maintains the indexes of a key change inside +# table_tuple_update, where a duplicate raises a plain unique violation, so +# the apply worker has to look for the conflict before the update. A key +# update that collides only with the row itself is no conflict. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am ('heap', 'flux') +{ + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + my $ddl = + "CREATE TABLE c (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE) USING $am"; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl); + $pub->safe_psql('postgres', 'CREATE PUBLICATION p FOR TABLE c'); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p WITH (disable_on_error = true)" + ); + $sub->wait_for_subscription_sync($pub, 's'); + + $pub->safe_psql('postgres', 'INSERT INTO c VALUES (5, 5, 5)'); + $pub->wait_for_catchup('s'); + + # Wait for the apply worker to stop on the error, then return the log + # written since $offset. + my $error_log = sub { + my ($offset) = @_; + $sub->poll_query_until('postgres', + "SELECT NOT subenabled FROM pg_subscription WHERE subname = 's'") + or die "timed out waiting for the subscription to be disabled"; + return slurp_file($sub->logfile, $offset); + }; + + # The new row collides with three local rows, one per unique index. + $sub->safe_psql('postgres', + 'INSERT INTO c VALUES (6, 6, 6), (7, 7, 7), (8, 8, 8)'); + my $offset = -s $sub->logfile; + $pub->safe_psql('postgres', + 'UPDATE c SET a = 6, b = 7, c = 8 WHERE a = 5'); + my $log = $error_log->($offset); + like( + $log, + qr/conflict detected on relation "public.c": conflict=multiple_unique_conflicts.* +.*Could not apply remote change: remote row \(6, 7, 8\), replica identity \(a\)=\(5\).* +.*Key already exists in unique index "c_pkey", modified in transaction .*: key \(a\)=\(6\), local row \(6, 6, 6\).* +.*Key already exists in unique index "c_b_key", modified in transaction .*: key \(b\)=\(7\), local row \(7, 7, 7\).* +.*Key already exists in unique index "c_c_key", modified in transaction .*: key \(c\)=\(8\), local row \(8, 8, 8\)/, + "$am: multiple_unique_conflicts detected during update"); + unlike( + $log, + qr/duplicate key value violates unique constraint/, + "$am: no plain unique violation instead of the conflict"); + + # Remove the local rows; the retried transaction applies. + $sub->safe_psql('postgres', 'DELETE FROM c WHERE a IN (6, 7, 8)'); + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s ENABLE'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT a, b, c FROM c'), + '6|7|8', "$am: update applied once the conflicting rows are gone"); + + # One unique index: update_exists. + $sub->safe_psql('postgres', 'INSERT INTO c VALUES (9, 9, 9)'); + $offset = -s $sub->logfile; + $pub->safe_psql('postgres', 'UPDATE c SET a = 9 WHERE a = 6'); + $log = $error_log->($offset); + like( + $log, + qr/conflict detected on relation "public.c": conflict=update_exists.* +.*Could not apply remote change: remote row \(9, 7, 8\), replica identity \(a\)=\(6\).* +.*Key already exists in unique index "c_pkey", modified in transaction .*: key \(a\)=\(9\), local row \(9, 9, 9\)/, + "$am: update_exists detected"); + $sub->safe_psql('postgres', 'DELETE FROM c WHERE a = 9'); + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s ENABLE'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT a, b, c FROM c'), + '9|7|8', "$am: update_exists update applied after the fix-up"); + + # A key update whose other unique keys stay the same matches only the + # row itself in those indexes: no conflict. + $offset = -s $sub->logfile; + $pub->safe_psql('postgres', 'UPDATE c SET b = 70 WHERE a = 9'); + $pub->safe_psql('postgres', 'UPDATE c SET a = 10 WHERE a = 9'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT a, b, c FROM c'), + '10|70|8', + "$am: key updates that collide only with the row itself apply"); + is( $sub->safe_psql( + 'postgres', + "SELECT subenabled FROM pg_subscription WHERE subname = 's'"), + 't', + "$am: no false conflict"); + unlike( + slurp_file($sub->logfile, $offset), + qr/conflict detected/, + "$am: no conflict reported for a self-match"); + + $sub->stop; + $pub->stop; +} + +done_testing(); diff --git a/src/test/subscription/t/105_inplace_am_update_deleted.pl b/src/test/subscription/t/105_inplace_am_update_deleted.pl new file mode 100644 index 0000000000000..4da1b5b2e9108 --- /dev/null +++ b/src/test/subscription/t/105_inplace_am_update_deleted.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# With retain_dead_tuples, a replicated UPDATE of a row that was deleted +# locally must be reported as an update_deleted conflict, naming the local +# deleting transaction, as for heap; not as update_missing. The apply worker +# finds the deleted version with a SnapshotAny scan, through the replica +# identity index or, without a usable one, sequentially. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am ('heap', 'flux') +{ + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + + # retain_dead_tuples needs a replication slot and commit timestamps on + # the subscriber. No autovacuum, so that only the test's own VACUUM runs. + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init(allows_streaming => 'logical'); + $sub->append_conf( + 'postgresql.conf', qq{ +track_commit_timestamp = on +autovacuum = off +}); + $sub->start; + + my $ddl = qq{ + CREATE TABLE pk (a int PRIMARY KEY, b int) USING $am; + CREATE TABLE full_ri (a int, b int) USING $am; + ALTER TABLE full_ri REPLICA IDENTITY FULL; + }; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl); + $pub->safe_psql('postgres', 'CREATE PUBLICATION p FOR TABLE pk, full_ri'); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p WITH (retain_dead_tuples = true)" + ); + $sub->wait_for_subscription_sync($pub, 's'); + ok( $sub->poll_query_until( + 'postgres', + "SELECT xmin IS NOT NULL FROM pg_replication_slots WHERE slot_name = 'pg_conflict_detection'" + ), + "$am: conflict detection slot holds an xmin"); + + $pub->safe_psql('postgres', + 'INSERT INTO pk VALUES (1, 1), (2, 2); INSERT INTO full_ri VALUES (1, 1), (2, 2)' + ); + $pub->wait_for_catchup('s'); + + # Stop the apply worker, so that the remote UPDATEs arrive after the + # local DELETEs. The slot's xmin cannot advance meanwhile. + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s DISABLE'); + $sub->poll_query_until('postgres', + "SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'" + ) or die "timed out waiting for the apply worker to stop"; + + $pub->safe_psql('postgres', + 'UPDATE pk SET b = 10 WHERE a = 1; UPDATE full_ri SET b = 10 WHERE a = 1' + ); + $sub->safe_psql('postgres', + 'DELETE FROM pk WHERE a = 1; DELETE FROM full_ri WHERE a = 1'); + + # The deleted rows are still needed for conflict detection: VACUUM keeps + # them, and says so. + for my $tab ('pk', 'full_ri') + { + my ($ret, $stdout, $stderr) = + $sub->psql('postgres', "VACUUM (VERBOSE) $tab"); + like( + $stderr, + qr/1 are dead but not yet removable/, + "$am: VACUUM keeps the deleted row of $tab"); + } + + my $offset = -s $sub->logfile; + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s ENABLE'); + $pub->wait_for_catchup('s'); + my $log = slurp_file($sub->logfile, $offset); + + # Primary key replica identity: the deleted row is found by index. + like( + $log, + qr/conflict detected on relation "public.pk": conflict=update_deleted.* +.*DETAIL:.* Could not find the row to be updated: remote row \(1, 10\), replica identity \(a\)=\(1\). +.*The row to be updated was deleted locally in transaction [0-9]+ at .*/, + "$am: update_deleted detected through the replica identity index"); + + # REPLICA IDENTITY FULL without an index: found by a sequential scan. + like( + $log, + qr/conflict detected on relation "public.full_ri": conflict=update_deleted.* +.*DETAIL:.* Could not find the row to be updated: remote row \(1, 10\), replica identity full \(1, 1\).* +.*The row to be updated was deleted locally in transaction [0-9]+ at .*/, + "$am: update_deleted detected by a sequential scan"); + + unlike($log, qr/conflict=update_missing/, + "$am: no update_missing reported for a locally deleted row"); + + is( $sub->safe_psql( + 'postgres', + 'SELECT (SELECT count(*) FROM pk) || \',\' || (SELECT count(*) FROM full_ri)' + ), + '1,1', + "$am: the remote updates were skipped"); + + $sub->stop; + $pub->stop; +} + +done_testing(); diff --git a/src/test/subscription/t/106_inplace_am_key_update_wait.pl b/src/test/subscription/t/106_inplace_am_key_update_wait.pl new file mode 100644 index 0000000000000..a07f7d0d24830 --- /dev/null +++ b/src/test/subscription/t/106_inplace_am_key_update_wait.pl @@ -0,0 +1,187 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A replicated UPDATE racing a local transaction that changes the row's +# replica-identity key. Heap's apply worker finds the old version through +# SnapshotDirty with the updater as xmax, waits for it, and searches again; +# when the local key update rolls back, it applies the replicated UPDATE. +# FLUX must do the same: an apply worker that did not wait skipped the +# replicated UPDATE (a lost update). + +# The logical replication apply worker locks the row it is about to update or +# delete (RelationFindReplTupleByIndex, table_tuple_lock without +# TUPLE_LOCK_FLAG_FIND_LAST_VERSION) and, with a BEFORE ROW trigger enabled +# for replication, locks it again from GetTupleForTrigger, under an +# EvalPlanQual state that has no plan. Run the same changes against +# concurrent local transactions on the subscriber, once with heap on both +# sides and once with FLUX on both sides, and require FLUX to end in heap's +# state. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $schema = q{ + CREATE TABLE t (id int PRIMARY KEY, v int, w int) USING %AM%; + CREATE TABLE tt (id int PRIMARY KEY, v int, w int) USING %AM%; +}; +my $sub_extra = q{ + CREATE FUNCTION tt_bu() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER tt_bu BEFORE UPDATE OR DELETE ON tt + FOR EACH ROW EXECUTE FUNCTION tt_bu(); + ALTER TABLE tt ENABLE ALWAYS TRIGGER tt_bu; +}; + +# Wait until the apply worker is blocked on a lock; report whether it was. +sub apply_waits +{ + my ($node) = @_; + for (1 .. 50) + { + return 'waited' + if $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'logical replication apply worker' + AND wait_event_type = 'Lock'}) > 0; + select(undef, undef, undef, 0.1); + } + return 'no wait'; +} + +sub run_am +{ + my ($am) = @_; + my %r; + + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + (my $ddl = $schema) =~ s/%AM%/$am/g; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl . $sub_extra); + $pub->safe_psql( + 'postgres', + q{INSERT INTO t SELECT g, g, 0 FROM generate_series(1, 8) g; + INSERT INTO tt SELECT g, g, 0 FROM generate_series(1, 8) g; + CREATE PUBLICATION p FOR TABLE t, tt}); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + my $q = + 'SELECT string_agg(format(\'(%s,%s,%s)\', id, v, w), \' \' ORDER BY id) FROM '; + + # A replicated change against an in-progress local transaction that + # updates the same row: the apply worker waits for it, then applies + # the change to the committed version. + my @cases = ( + [ + 'update vs local key update', + 't', + 'UPDATE t SET id = 40 WHERE id = 4', + 'COMMIT', + 'UPDATE t SET v = v + 100 WHERE id = 4' + ], + [ + 'update vs local key rollback', + 't', + 'UPDATE t SET id = 70 WHERE id = 7', + 'ROLLBACK', + 'UPDATE t SET v = v + 100 WHERE id = 7' + ], + [ + 'trigger: update vs local key update', + 'tt', + 'UPDATE tt SET id = 50 WHERE id = 5', + 'COMMIT', + 'UPDATE tt SET v = v + 100 WHERE id = 5' + ], + [ + 'trigger: update vs local key rollback', + 'tt', + 'UPDATE tt SET id = 80 WHERE id = 8', + 'ROLLBACK', + 'UPDATE tt SET v = v + 100 WHERE id = 8' + ],); + + foreach my $c (@cases) + { + my ($name, $tab, $local, $end, $remote) = @$c; + my $wait = ''; + if (defined $local) + { + my $bg = $sub->background_psql('postgres'); + $bg->query_safe("BEGIN; $local"); + $pub->safe_psql('postgres', $remote); + $wait = apply_waits($sub); + $bg->query_safe($end); + $bg->quit; + } + else + { + $pub->safe_psql('postgres', $remote); + } + $pub->wait_for_catchup('s'); + $r{$name} = "$wait | " . $sub->safe_psql('postgres', $q . $tab); + } + + # The replicated columns agree with the publisher, except on the rows + # whose key a local transaction changed (the publisher's change to the + # old key found nothing to apply). The rolled-back rows (t 7, tt 8) + # must match: a mismatch there is a lost replicated UPDATE. + foreach my $tk ([ 't', '4, 40' ], [ 'tt', '5, 50' ]) + { + my ($tab, $skip) = @$tk; + my $rq = + "SELECT string_agg(format('(%s,%s)', id, v), ' ' ORDER BY id) " + . "FROM $tab WHERE id NOT IN ($skip)"; + $r{"$tab matches publisher"} = + $sub->safe_psql('postgres', $rq) eq $pub->safe_psql('postgres', $rq) + ? 'yes' + : 'no: ' + . $sub->safe_psql('postgres', $rq) . ' vs ' + . $pub->safe_psql('postgres', $rq); + } + + # Nothing crashed, and the worker that applied all this is still the + # first one. + $r{'server crashes'} = () = + slurp_file($sub->logfile) =~ /terminated by signal/g; + $r{'apply errors'} = () = slurp_file($sub->logfile) =~ /ERROR:/g; + + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; + return \%r; +} + +my $heap = run_am('heap'); +my $flux = run_am('flux'); + +foreach my $k (sort keys %$heap) +{ + is($flux->{$k}, $heap->{$k}, "flux matches heap: $k"); + note "heap $k: $heap->{$k}"; +} +foreach my $am ([ 'heap', $heap ], [ 'flux', $flux ]) +{ + my ($name, $r) = @$am; + is($r->{'server crashes'}, + 0, "$name: no \"terminated by signal\" in the subscriber log"); + is($r->{'t matches publisher'}, 'yes', "$name: t matches the publisher"); + is($r->{'tt matches publisher'}, 'yes', + "$name: tt matches the publisher"); +} + +done_testing(); diff --git a/src/test/subscription/t/107_inplace_am_outofplace_update.pl b/src/test/subscription/t/107_inplace_am_outofplace_update.pl new file mode 100644 index 0000000000000..381fe9a9fb002 --- /dev/null +++ b/src/test/subscription/t/107_inplace_am_outofplace_update.pl @@ -0,0 +1,134 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# An UPDATE that FLUX performs out of place (a changed exclusion-constraint +# key, or a row that cannot grow on its full page, moves to a new TID) must +# replicate as an UPDATE, as heap's cross-page UPDATE does. Were it decoded +# as a DELETE plus an INSERT, a publication WITH (publish = 'update') would +# drop it, and the subscriber's UPDATE triggers would not fire. +# +# Publish a heap table and a FLUX table that receive the same changes, to one +# subscriber with heap tables and one with FLUX tables, and require the FLUX +# table to replicate as the heap one does. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $schema = q{ + CREATE TABLE xt_%T% (id int PRIMARY KEY, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING %AM%; + CREATE TABLE gt_%T% (id int PRIMARY KEY, t text) USING %AM% + WITH (fillfactor = 100); + ALTER TABLE gt_%T% ALTER COLUMN t SET STORAGE PLAIN; +}; +my $sub_extra = q{ + CREATE TABLE ops (tab text, op text, id int); + CREATE FUNCTION log_op() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + INSERT INTO public.ops VALUES (TG_TABLE_NAME, TG_OP, OLD.id); + RETURN OLD; + END IF; + INSERT INTO public.ops VALUES (TG_TABLE_NAME, TG_OP, NEW.id); + RETURN NEW; + END $$; +}; +my $trig = q{ + CREATE TRIGGER log_op BEFORE INSERT OR UPDATE OR DELETE ON %TAB% + FOR EACH ROW EXECUTE FUNCTION log_op(); + ALTER TABLE %TAB% ENABLE ALWAYS TRIGGER log_op; +}; + +sub tables_sql +{ + my ($am) = @_; + my $sql = ''; + for my $t ('heap', 'flux') + { + my $s = $schema; + $s =~ s/%T%/$t/g; + $s =~ s/%AM%/$am eq 'pub' ? $t : $am/ge; + $sql .= $s; + } + return $sql; +} + +my $pub = PostgreSQL::Test::Cluster->new('publisher'); +$pub->init(allows_streaming => 'logical'); +$pub->start; +$pub->safe_psql('postgres', tables_sql('pub')); +for my $t ('heap', 'flux') +{ + $pub->safe_psql( + 'postgres', qq{ + INSERT INTO xt_$t VALUES (1, '[1,10)', 0), (2, '[20,30)', 0); + INSERT INTO gt_$t SELECT g, repeat('a', 100) FROM generate_series(1, 200) g; + }); +} +$pub->safe_psql('postgres', + "CREATE PUBLICATION pub FOR TABLE xt_heap, xt_flux, gt_heap, gt_flux WITH (publish = 'update')" +); +my $connstr = $pub->connstr . ' dbname=postgres'; + +my %subs; +for my $am ('heap', 'flux') +{ + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + $sub->safe_psql('postgres', tables_sql($am) . $sub_extra); + for my $tab ('xt_heap', 'xt_flux', 'gt_heap', 'gt_flux') + { + (my $s = $trig) =~ s/%TAB%/$tab/g; + $sub->safe_psql('postgres', $s); + } + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION sub_$am CONNECTION '$connstr' PUBLICATION pub WITH (slot_name = sub_$am)" + ); + $sub->wait_for_subscription_sync($pub, "sub_$am"); + $sub->safe_psql('postgres', 'TRUNCATE ops'); + $subs{$am} = $sub; +} + +for my $t ('heap', 'flux') +{ + $pub->safe_psql( + 'postgres', qq{ + UPDATE xt_$t SET r = '[1,11)', v = v + 1 WHERE id = 1; + UPDATE xt_$t SET id = 3, r = '[20,31)' WHERE id = 2; + UPDATE gt_$t SET t = repeat('b', 2000) WHERE id = 1; + }); +} +is( $pub->safe_psql( + 'postgres', + "SELECT (ctid::text::point)[0] > 0 FROM gt_flux WHERE id = 1"), + 't', + 'the grown FLUX row moved to another page'); + +for my $am ('heap', 'flux') +{ + my $sub = $subs{$am}; + $pub->wait_for_catchup("sub_$am"); + for my $t ('heap', 'flux') + { + is( $sub->safe_psql( + 'postgres', "SELECT id, r, v FROM xt_$t ORDER BY id"), + "1|[1,11)|1\n3|[20,31)|0", + "$am subscriber: moved rows of xt_$t updated"); + is( $sub->safe_psql( + 'postgres', + "SELECT id, length(t), left(t, 1) FROM gt_$t WHERE id <= 2 ORDER BY id" + ), + "1|2000|b\n2|100|a", + "$am subscriber: grown row of gt_$t updated"); + is( $sub->safe_psql( + 'postgres', + "SELECT string_agg(op || ' ' || id, ', ' ORDER BY op, id) FROM ops WHERE tab IN ('xt_$t', 'gt_$t')" + ), + 'UPDATE 1, UPDATE 1, UPDATE 3', + "$am subscriber: only UPDATE triggers fired for $t"); + } +} + +done_testing(); diff --git a/src/tools/pginclude/headerscheck b/src/tools/pginclude/headerscheck index 785d6f867ad09..29c63abfb2ba8 100755 --- a/src/tools/pginclude/headerscheck +++ b/src/tools/pginclude/headerscheck @@ -129,6 +129,7 @@ do # These files are not meant to be included standalone, because # they contain lists that might have multiple use-cases. test "$f" = src/include/access/rmgrlist.h && continue + test "$f" = src/include/access/undormgrlist.h && continue test "$f" = src/include/parser/kwlist.h && continue test "$f" = src/include/postmaster/proctypelist.h && continue test "$f" = src/include/regex/regerrs.h && continue diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index a7a8dbdd23f79..6834aa833a57e 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -33,6 +33,8 @@ AffixNodeData AfterTriggerEvent AfterTriggerEventChunk AfterTriggerEventData +AfterTriggerEventDataImages +AfterTriggerEventDataOneImage AfterTriggerEventList AfterTriggerShared AfterTriggerSharedData @@ -170,6 +172,8 @@ ArrayType AsyncQueueControl AsyncQueueEntry AsyncRequest +AtmStateHeader +AtmStateRecord AttInMetadata AttStatsSlot AttoptCacheEntry @@ -661,6 +665,7 @@ DecodingWorkerShared DefElem DefElemAction DefaultACLInfo +DeferredUndoXact DefineStmt DefnDumperPtr DeleteStmt @@ -853,8 +858,19 @@ FileBackupMethod FileFdwExecutionState FileFdwPlanState FileNameMap +FileOpsTwoPhaseRecord FileSet FileTag +FileopsUndoChmod +FileopsUndoChown +FileopsUndoCreate +FileopsUndoLink +FileopsUndoMkdir +FileopsUndoRemovexattr +FileopsUndoSetxattr +FileopsUndoSymlink +FileopsUndoTruncate +FileopsUndoWrite FilterCommandType FilterObjectType FilterStateData @@ -870,6 +886,43 @@ Fitness FlagMode Float FlushPosition +FluxChainVersion +FluxCidEntry +FluxCidKey +FluxCompressionAlgoGuc +FluxCompressionHeader +FluxCompressionType +FluxDefragMove +FluxDirtyPageKey +FluxFreeSpaceMap +FluxLogicalImage +FluxMvccShmemData +FluxOffsetMapping +FluxOverflowBuffer +FluxOverflowBuffers +FluxOverflowPtr +FluxOverflowRecordHeader +FluxPageOpaque +FluxPageOpaqueData +FluxPendingVersion +FluxRelationStats +FluxRewriteVersion +FluxScanDesc +FluxScanDescData +FluxSerializeLockEnt +FluxSkippedUpdates +FluxStatsCount +FluxTransactionState +FluxTuple +FluxTupleData +FluxTupleHeader +FluxTupleTableSlot +FluxTwoPhaseRecord +FluxUndoDelta +FluxUndoIndex +FluxUndoIndexWork +FluxUndoPayloadHeader +FluxVacScanState FmgrBuiltin FmgrHookEventType FmgrInfo @@ -1235,6 +1288,7 @@ HashScanPosData HashScanPosItem HashSkewBucket HashState +HashUndoInsert HashValueFunc HbaLine HeadlineJsonState @@ -1322,6 +1376,7 @@ IndexRuntimeKeyInfo IndexScan IndexScanDesc IndexScanDescData +IndexScanFluxData IndexScanHeapData IndexScanInstrumentation IndexScanState @@ -1695,6 +1750,7 @@ LogicalRepTupleData LogicalRepTyp LogicalRepWorker LogicalRepWorkerType +LogicalRevertState LogicalRewriteMappingData LogicalSlotInfo LogicalSlotInfoArr @@ -1791,6 +1847,7 @@ MultiXactStatus MultirangeIOData MultirangeParseState MultirangeType +MyUndoLogState NDBOX NDistinctParseState NDistinctSemanticState @@ -1812,6 +1869,7 @@ NamedLWLockTrancheRequest NamedTuplestoreScan NamedTuplestoreScanState NamespaceInfo +NbtreeUndoInsertLeaf NestLoop NestLoopParam NestLoopState @@ -2226,6 +2284,10 @@ PatternInfo PatternInfoArray Pattern_Prefix_Status Pattern_Type +PbuPendingUndo +PbuTwoPhaseUndoRecord +PendingFileOp +PendingFileOpType PendingFsyncEntry PendingListenAction PendingListenEntry @@ -2398,6 +2460,7 @@ PrefetchBufferResult PrepParallelRestorePtrType PrepareStmt PreparedStatement +PreparedUndoSpace PresortedKeyData PrewarmType PrintExtraTocPtrType @@ -2685,6 +2748,7 @@ ReturningClause ReturningExpr ReturningOption ReturningOptionKind +RevertRetry RevmapContents RevokeRoleGrantAction RewriteMappingDataEntry @@ -2700,6 +2764,8 @@ RoleNameItem RoleSpec RoleSpecType RoleStmtType +RollbackHashEntry +RollbackHashKey RollupData RowCompareExpr RowExpr @@ -2732,6 +2798,20 @@ SID_IDENTIFIER_AUTHORITY SID_NAME_USE SISeg SIZE_T +SLogInsertTidEntry +SLogInsertTidKey +SLogOpType +SLogSharedState +SLogTrackedKey +SLogTrackedKeyInfo +SLogTupleKey +SLogTupleLockCallback +SLogTupleLockEntry +SLogTupleLockInline +SLogTupleLockRel +SLogTupleLockShared +SLogTupleLockSlot +SLogTxnEntry SMgrRelation SMgrRelationData SMgrSortArray @@ -2768,6 +2848,7 @@ SYSTEM_INFO SampleScan SampleScanGetSampleSize_function SampleScanState +SatisfyUndoRecordCallback SavedTransactionCharacteristics ScalarArrayOpExpr ScalarArrayOpExprHashEntry @@ -3307,6 +3388,48 @@ ULONG ULONG_PTR UV UVersionInfo +UndoApplyCtxStruct +UndoApplyResult +UndoApplyWorker +UndoBatchData +UndoBufferStat +UndoBuffers +UndoChainOutcome +UndoChainWalkResult +UndoEngine +UndoErrorQueue +UndoFileMru +UndoLogControl +UndoLogMetaData +UndoLogNumber +UndoLogOffset +UndoLogSharedData +UndoLogStat +UndoLogState +UndoLogStatus +UndoPersistence +UndoPersistenceLevel +UndoRecInfo +UndoRecPtr +UndoRecordBlock +UndoRecordHeader +UndoRecordPayload +UndoRecordRelationDetails +UndoRecordSet +UndoRecordSetChunkHeader +UndoRecordSetType +UndoRecordSize +UndoRecordTransaction +UndoRecoveryEntry +UndoRequestInfo +UndoRmgrApplyFunc +UndoRmgrData +UndoRmgrDescFunc +UndoSizeQueue +UndoWorkerQueue +UndoWorkerQueueType +UndoWorkerShmemData +UndoXidQueue UnicodeNormalizationForm UnicodeNormalizationQC Unique @@ -3314,6 +3437,7 @@ UniquePath UniqueRelInfo UniqueState UnlistenStmt +UnpackedUndoRecord UnresolvedTup UnresolvedTupData UpdateContext @@ -3524,6 +3648,11 @@ XactCallback XactCallbackItem XactEvent XactLockTableWaitInfo +XactUndoContext +XactUndoData +XactUndoInflightSlot +XactUndoRecordInfo +XactUndoSubTransactionState XidBoundsViolation XidCacheStatus XidCommitStatus @@ -3568,6 +3697,7 @@ ambuildphasename_function ambulkdelete_function amcanreturn_function amcostestimate_function +amdeletemark_function amendscan_function amestimateparallelscan_function amgetbitmap_function @@ -3584,6 +3714,7 @@ amrescan_function amrestrpos_function amtranslate_cmptype_function amtranslate_strategy_function +amundomark_function amvacuumcleanup_function amvalidate_function array_iter @@ -4291,6 +4422,7 @@ slist_iter slist_mutable_iter slist_node slock_t +slog_atm_value_t socket_set socklen_t spgBulkDeleteState @@ -4396,6 +4528,7 @@ uint8x16_t uint_fast64_t uintmax_t uintptr_t +undorectype unicodeStyleBorderFormat unicodeStyleColumnFormat unicodeStyleFormat @@ -4448,6 +4581,8 @@ worktable wrap ws_file_info ws_options +xl_atm_abort +xl_atm_forget xl_brin_createidx xl_brin_desummarize xl_brin_insert @@ -4456,6 +4591,7 @@ xl_brin_samepage_update xl_brin_update xl_btree_dedup xl_btree_delete +xl_btree_delete_mark xl_btree_insert xl_btree_mark_page_halfdead xl_btree_metadata @@ -4473,6 +4609,53 @@ xl_dbase_create_file_copy_rec xl_dbase_create_wal_log_rec xl_dbase_drop_rec xl_end_of_recovery +xl_fileops_chmod +xl_fileops_chown +xl_fileops_create +xl_fileops_delete +xl_fileops_link +xl_fileops_mkdir +xl_fileops_removexattr +xl_fileops_rename +xl_fileops_rmdir +xl_fileops_setxattr +xl_fileops_symlink +xl_fileops_truncate +xl_fileops_write +xl_flux_cas_update +xl_flux_cas_update_undo +xl_flux_cas_update_undo_fe +xl_flux_compress +xl_flux_compress_fe +xl_flux_defrag +xl_flux_delete +xl_flux_delete_fe +xl_flux_freeze_page +xl_flux_freeze_tuple +xl_flux_init_page +xl_flux_init_page_fe +xl_flux_insert +xl_flux_insert_fe +xl_flux_lock +xl_flux_lock_fe +xl_flux_multi_insert +xl_flux_multi_insert_fe +xl_flux_multi_insert_tuple +xl_flux_overflow_write +xl_flux_overflow_write_fe +xl_flux_prefix_suffix +xl_flux_supersede +xl_flux_supersede_fe +xl_flux_undo +xl_flux_update +xl_flux_update_fe +xl_flux_vacuum +xl_flux_vacuum_fe +xl_flux_vm_clear +xl_flux_vm_clear_fe +xl_flux_vm_set +xl_flux_vm_set_fe +xl_flux_write_dict xl_hash_add_ovfl_page xl_hash_delete xl_hash_init_bitmap_page @@ -4507,6 +4690,7 @@ xl_multixact_truncate xl_overwrite_contrecord xl_parameter_change xl_relmap_update +xl_relundo_truncate xl_replorigin_drop xl_replorigin_set xl_restore_point @@ -4519,6 +4703,19 @@ xl_standby_locks xl_tblspc_create_rec xl_tblspc_drop_rec xl_testcustomrmgrs_message +xl_undo_apply +xl_undo_batch +xl_undo_chain_state +xl_undo_inflight +xl_undo_inflight_xact +xl_undoapply_progress +xl_undoapply_subxact +xl_undolog_attach +xl_undolog_create +xl_undolog_discard +xl_undolog_extend +xl_undolog_meta +xl_undolog_rewind xl_xact_abort xl_xact_assignment xl_xact_commit