From 6e6da13ab910b426ff8909a351323b0056cf0630 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 14 Jul 2026 18:06:14 +0800 Subject: [PATCH] fix(undo): reset transaction-local segment heads on commit --- src/backend/access/transam/xact.c | 20 ++-- src/backend/cluster/cluster_tt_2pc.c | 13 +-- src/backend/cluster/cluster_undo_record.c | 110 +++++++++--------- .../cluster/storage/cluster_undo_smgr.c | 12 +- src/include/cluster/cluster_tt_2pc.h | 6 +- src/include/cluster/cluster_undo_record_api.h | 25 ++-- src/include/cluster/cluster_undo_smgr.h | 4 +- ...uster_4_12a_undo_record_segment_reclaim.pl | 73 ++++++++++++ 8 files changed, 166 insertions(+), 97 deletions(-) diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 8eda1e6230..4f8e743ab8 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -163,7 +163,7 @@ #include "cluster/cluster_itl_touch.h" /* PGRAC: spec-3.4a D6 pre-commit/abort */ #include "cluster/cluster_tt_2pc.h" /* PGRAC: spec-3.15 PREPARE record */ #include "cluster/cluster_subtrans.h" /* PGRAC: spec-3.5 D7 subxact lifecycle hook */ -#include "cluster/cluster_undo_record_api.h" /* PGRAC: spec-3.7 D16 PREPARE guard */ +#include "cluster/cluster_undo_record_api.h" /* PGRAC: undo xact lifecycle */ #include "cluster/cluster_touched_peers.h" /* PGRAC: spec-5.14 D1 per-tx reset */ #include "cluster/cluster_visibility_resolve.h" /* PGRAC: spec-6.14 D8 depth reset */ #include "cluster/cluster_relmap_lock.h" /* PGRAC: spec-6.14 D5 abort release */ @@ -2958,14 +2958,9 @@ CommitTransaction(void) s->state = TRANS_DEFAULT; #ifdef USE_PGRAC_CLUSTER - /* PGRAC (spec-4.12a D6): release this backend's active-write undo boundary - * at commit so a committed transaction's first_undo_scn stops pinning the - * record-segment drain boundary. The full cluster_undo_record_xact_reset() - * runs only on the PREPARE (PrepareTransaction) and ABORT (CleanupTransaction) - * paths -- a persistent (pooled) connection that commits and stays open would - * otherwise keep its first transaction registered for the life of the backend - * (re-opening the spec-4.13 undo-pool leak). 8.A-safe: only enables - * ACTIVE->COMMITTED; the retention horizon still gates the actual reclaim. */ + /* PGRAC: release the active-write boundary and clear O(1) transaction-local + * undo bookkeeping. Preserve backend caches; PREPARE/ABORT use the full + * teardown. Spec: spec-4.12a-undo-record-segment-reclaim.md */ cluster_undo_record_xact_commit_release(); #endif @@ -3296,8 +3291,8 @@ PrepareTransaction(void) s->state = TRANS_DEFAULT; #ifdef USE_PGRAC_CLUSTER - /* PGRAC (spec-3.7 D16): reset per-backend undo touched flag at - * end of xact (commit path). Backend reuse → next xact starts fresh. */ + /* PGRAC: PostPrepare transfers undo ownership and fully tears down the + * backend-local PREPARE state. */ cluster_undo_record_xact_reset(); /* PGRAC (spec-5.14 D1): clear the touched_peers bitmap on commit. */ cluster_touched_peers_reset(); @@ -3547,8 +3542,7 @@ CleanupTransaction(void) AtEOXact_Snapshot(false, true); /* and release the transaction's snapshots */ #ifdef USE_PGRAC_CLUSTER - /* PGRAC (spec-3.7 D16): reset per-backend undo touched flag at - * end of xact (abort path). Backend reuse → next xact starts fresh. */ + /* PGRAC: fully tear down backend-local undo state on abort. */ cluster_undo_record_xact_reset(); /* PGRAC (spec-5.14 D1): clear the touched_peers bitmap on abort/cleanup. */ cluster_touched_peers_reset(); diff --git a/src/backend/cluster/cluster_tt_2pc.c b/src/backend/cluster/cluster_tt_2pc.c index e944e8d1cb..bfcc3d14f3 100644 --- a/src/backend/cluster/cluster_tt_2pc.c +++ b/src/backend/cluster/cluster_tt_2pc.c @@ -44,7 +44,7 @@ #include "cluster/cluster_tt_local.h" /* binding export/reset */ #include "cluster/cluster_tt_slot.h" /* protected-slot map (D6/V-4) */ #include "cluster/cluster_tt_status.h" /* SUBCOMMITTED overlay rebuild */ -#include "cluster/cluster_undo_record_api.h" /* xact_reset (PostPrepare) */ +#include "cluster/cluster_undo_record_api.h" /* full undo teardown (PostPrepare) */ #include "cluster/cluster_tt_durable.h" /* durable 0x30 commit (prefinish) */ #include "cluster/cluster_tt_status_hint.h" /* cross-node hint emit (prefinish) */ #include "cluster/storage/cluster_undo_alloc.h" /* CLUSTER_UNDO_SEGS_PER_INSTANCE */ @@ -111,7 +111,7 @@ AtPrepare_ClusterTT(void) /* * spec-4.8 D7-A: capture each binding's latest undo-chain head into the v2 * heads[] section. The head is backend-local (cluster_undo_record.c) and is - * reset at xact end, so it must be read here at PREPARE while still live; + * cleared by PostPrepare, so it must be read here at PREPARE while still live; * the durable TT slot header does not persist it (deferred). A binding with * no undo this xact gets InvalidUba (-> D7 physical rollback no-op for it). */ @@ -145,10 +145,9 @@ AtPrepare_ClusterTT(void) * PostPrepare_ClusterTT -- ownership transfer after EndPrepare succeeded. * * The 2PC record is now the single authority (C-P4): drop the backend- - * local TT bindings, the SUBCOMMITTED link list, and the ITL touch - * list (V-2: droppable -- overlay/durable TT are authoritative and the - * 3.4c lazy cleanout re-stamps page ITLs on later reads). Nothing - * here touches shmem or durable state. + * local TT bindings, the SUBCOMMITTED link list, the ITL touch list, and the + * backend-local undo lifecycle state. Undo teardown unregisters the active + * writer from shmem; no durable state is changed here. */ void PostPrepare_ClusterTT(void) @@ -159,7 +158,7 @@ PostPrepare_ClusterTT(void) cluster_tt_local_reset_binding(); cluster_subtrans_reset_local_links(); cluster_itl_touch_reset_at_end_xact(); - cluster_undo_record_xact_reset(); /* touched-undo list + D16 flag */ + cluster_undo_record_xact_reset(); /* full backend-local undo teardown */ cluster_vis_bump_twopc_postprepare_transfers(); } diff --git a/src/backend/cluster/cluster_undo_record.c b/src/backend/cluster/cluster_undo_record.c index 8fb1b326d7..382114fd61 100644 --- a/src/backend/cluster/cluster_undo_record.c +++ b/src/backend/cluster/cluster_undo_record.c @@ -10,15 +10,16 @@ * - cluster_undo_record_alloc() — write one undo record + durable flush * - cluster_undo_get_record() — read one undo record by UBA * - cluster_undo_record_shmem_register/size/init - * - cluster_undo_record_xact_reset() — end-of-xact hook - * - cluster_undo_record_is_touched() — for D16 PREPARE guard + * - cluster_undo_record_xact_commit_release() — normal COMMIT cleanup + * - cluster_undo_record_xact_reset() — PREPARE/ABORT full teardown + * - cluster_undo_record_is_touched() — legacy write marker accessor * * Concurrency model(MVP): * - single per-instance LWLock(`cursor_lock`)protects active segment * cursor advance(active_segment_id / current_block / free_offset / * slot_count).多 backend 写同 instance → serialize on this lock. - * - per-backend `cluster_undo_touched` static bool tracks D16 state; - * reset on xact end. + * - per-backend transaction-local state is reset by the normal-COMMIT + * hook or the PREPARE/ABORT full teardown. * * Block boundary contract(per spec-3.7 §3.2):record 不跨 block。 * block 不够时 advance 到下一 block;segment 不够时 ereport 53R9D @@ -59,7 +60,7 @@ #include #include "miscadmin.h" -#include "access/twophase.h" /* spec-4.12a D1: prepared-xact guard (硬门 6) */ +#include "access/twophase.h" /* spec-4.12a D1: prepared-xact retention guard */ #include "access/xact.h" #include "port/atomics.h" #include "storage/fd.h" @@ -238,28 +239,28 @@ static ClusterUndoRecordShared *UndoRecordShared = NULL; */ static SCN *cluster_undo_write_registry = NULL; -/* This backend already registered first_undo_scn for the current top-xact - * (idempotent guard; cleared in cluster_undo_record_xact_reset). */ +/* This backend already registered first_undo_scn for the current top-xact. + * Cleared by normal-COMMIT cleanup or PREPARE/ABORT full teardown. */ static bool cluster_undo_write_registered = false; /* spec-4.12a Hardening v1.0.1 (P0 8.A): this backend already revalidated its * carried-over residual extent for the current top-xact. The carried residual * is only stale at the txn's first undo alloc (another backend may have rolled * the segment away since the previous xact); after the one locked revalidation - * the extent is current-xact-owned and reused lock-free. Cleared in - * cluster_undo_record_xact_reset. */ + * the extent is current-xact-owned and reused lock-free. Re-armed by + * normal-COMMIT cleanup or PREPARE/ABORT full teardown. */ static bool cluster_undo_residual_validated_this_xact = false; -/* Per-backend state(D16 PREPARE guard support). */ +/* Legacy write marker retained as transaction-local API state. */ static bool cluster_undo_touched_in_xact = false; /* * spec-3.18 D3: the backend's currently-held undo extent. segment_id == * CLUSTER_UNDO_EXTENT_NONE means "no extent held" (claim one on next write). * The cursor (cur_block / cur_free_offset / cur_slot_count) advances lock-free - * within the extent; only claiming a new extent touches lifecycle_lock. Reset - * to NONE at end of xact (cluster_undo_record_xact_reset) -- residual blocks - * are dropped (D3.3 will add the Q2 hybrid residual cache). + * within the extent; only claiming a new extent touches lifecycle_lock. A + * residual extent is deliberately carried across transactions and revalidated + * under lifecycle_lock before the next top-xact reuses it. */ static ClusterUndoExtent cluster_undo_current_extent = { 0 }; @@ -322,7 +323,8 @@ static bool cluster_undo_pending_flush_internal(bool error_on_fail); * * Top-xact aggregation: subxact undo writes append to the same per-backend * list and are flushed by the parent's precommit (no per-savepoint fsync). - * Reset (clear) happens at end of top-xact in cluster_undo_record_xact_reset. + * Normal COMMIT clears the live count in its O(1) cleanup hook. PREPARE and + * ABORT clear it in cluster_undo_record_xact_reset(). * Overflow (> MAX distinct segments in one xact — pathological) degrades to an * inline fsync of the overflowing segment, never back to per-record fsync. */ @@ -380,8 +382,9 @@ cluster_undo_record_touched_segment(uint32 segment_id, uint8 owner_instance) * undo UBA. Existing spec-3.4b DML callers still pass the TT-only UBA as * `prev_uba` on every operation, so the record allocator must maintain the * true per-xact undo chain locally until later specs persist the TT head in - * the undo segment header. This state is reset at xact end together with - * cluster_undo_touched_in_xact. + * the undo segment header. Only entries below cluster_undo_local_head_count + * are live; normal COMMIT resets that count in O(1), while PREPARE/ABORT also + * zero the backing array during full teardown. */ typedef struct ClusterUndoLocalHead { uint16 tt_slot_segment_id; @@ -433,7 +436,7 @@ cluster_undo_local_head_ensure(uint16 tt_slot_segment_id, uint16 tt_slot_offset, * undo-chain head for a TT slot (segment_id, slot_offset), or InvalidUba if * this backend wrote no undo for that slot this transaction. Used by * AtPrepare_ClusterTT to capture the head into the 2PC record while it is - * still in memory (the local head array is reset at xact end). + * still in memory, before PostPrepare performs the full local teardown. */ UBA cluster_undo_local_head_get(uint16 tt_slot_segment_id, uint16 tt_slot_offset) @@ -606,11 +609,10 @@ cluster_undo_active_write_register(SCN first_undo_scn) /* * cluster_undo_active_write_unregister -- drop this backend's registry entry at - * end of top-xact (commit / abort / PREPARE; all route through - * cluster_undo_record_xact_reset). On PREPARE the prepared-xact guard (硬门 - * 6) takes over keeping the undo alive -- see cluster_undo_any_unresolved_ - * prepared(); the GlobalTransaction is already in TwoPhaseState (EndPrepare) - * before PostPrepare_ClusterTT reaches here, so the handoff has no gap. + * end of top-xact. Normal COMMIT calls the dedicated cleanup hook; PREPARE + * and ABORT call the full reset. On PREPARE, the prepared-xact guard takes + * over retention after EndPrepare and before PostPrepare_ClusterTT unregisters + * the active writer, so the handoff has no gap. */ static void cluster_undo_active_write_unregister(void) @@ -633,8 +635,7 @@ cluster_undo_active_write_unregister(void) } /* - * cluster_undo_record_xact_commit_release -- spec-4.12a D6: release this - * backend's active-write boundary slot at COMMIT. + * cluster_undo_record_xact_commit_release -- normal-COMMIT undo cleanup. * * The active-write boundary (cluster_undo_active_write_boundary) keeps a record * segment retained from the ACTIVE -> COMMITTED drain only while an in-flight @@ -643,27 +644,32 @@ cluster_undo_active_write_unregister(void) * still gates COMMITTED -> RECYCLABLE), so the boundary entry must drop here -- * otherwise it pins the drain boundary for the life of the backend. * - * This is the missing commit-path release. cluster_undo_record_xact_reset (the - * full per-xact undo teardown) only runs on the PREPARE (PrepareTransaction) and - * ABORT (CleanupTransaction) paths, NOT CommitTransaction -- so a short-lived - * connection was saved only by backend exit, while a persistent (pooled) - * connection that COMMITs and stays open kept its first transaction's - * first_undo_scn registered forever (register is idempotent per top-xact), which - * pinned the boundary and re-opened the spec-4.13 leak for every long-lived - * connection. Commit needs only the boundary release, not the fd-cache / - * local-head teardown, so this is a dedicated minimal hook (avoids fd-cache - * churn on the commit hot path). + * The full reset only runs on PREPARE and ABORT. Normal COMMIT therefore + * releases the active-write registry entry and clears all O(1) top-transaction + * bookkeeping here: the touched marker, live local-head count, touched-segment + * count, and residual-revalidation gate. The residual extent and fd cache are + * backend caches, not transaction state, and remain available for reuse. * * 8.A-safe: dropping the boundary only enables ACTIVE -> COMMITTED; the horizon * still gates the actual reclaim (COMMITTED -> RECYCLABLE), so a committed xact's * undo that an older snapshot still needs stays retained until the horizon - * passes. Mirrors the PREPARE handoff, where the boundary also drops and the - * prepared-xact guard (硬门 6) takes over keeping the undo alive. + * passes. PREPARE uses the full reset after its prepared-xact retention guard + * has taken over. + * + * Spec: spec-4.12a-undo-record-segment-reclaim.md */ void cluster_undo_record_xact_commit_release(void) { + /* Precommit is the release-build fail-closed boundary; keep a debug check at + * the post-commit cleanup site to catch lifecycle regressions early. */ + Assert(!cluster_undo_pending.active); + cluster_undo_active_write_unregister(); + cluster_undo_touched_in_xact = false; + cluster_undo_local_head_count = 0; + cluster_undo_touched_seg_count = 0; + /* spec-4.12a Hardening v1.0.1: re-arm residual revalidation for the next * top-xact. xact_reset (which also clears this) runs only on PREPARE/ABORT, * so the commit path must re-arm it here -- otherwise a persistent connection @@ -1725,7 +1731,7 @@ cluster_undo_record_alloc(uint8 record_type, const ClusterUndoRecordTarget *targ pg_atomic_fetch_add_u64(&UndoRecordShared->record_alloc_count, 1); - /* Mark backend touched for D16 PREPARE guard. */ + /* Mark transaction-local undo state as touched. */ cluster_undo_touched_in_xact = true; /* P0 perf hardening: record the dirtied undo segment for a single per-xact @@ -1848,8 +1854,9 @@ cluster_undo_get_record(UBA uba, void *out_buffer, size_t buffer_size) * critical section, so the xact aborts cleanly (its un-fsync'd undo blocks are * irrelevant to an aborted xact) — never a silent half-durable commit. * - * No-op for a xact that wrote no undo (DDL-only / read-only). The touched - * list is cleared by cluster_undo_record_xact_reset at end of xact. + * No-op for a xact that wrote no undo (DDL-only / read-only). Normal COMMIT + * clears the touched list in its O(1) cleanup hook; PREPARE/ABORT use the full + * reset. */ void cluster_undo_xact_precommit_flush(void) @@ -1863,6 +1870,11 @@ cluster_undo_xact_precommit_flush(void) * pending image exists exactly on the write-back path. */ (void)cluster_undo_pending_flush_internal(true); + if (unlikely(cluster_undo_pending.active)) + ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("cluster undo block remained pending before transaction finish"), + errhint("Retry the transaction and inspect the server log for an undo WAL " + "flush failure."))); if (cluster_undo_touched_seg_count == 0) return; @@ -1901,14 +1913,11 @@ cluster_undo_xact_precommit_flush(void) /* - * cluster_undo_record_xact_reset -- end-of-xact hook; reset per-backend - * touched flag. Called from xact.c CommitTransaction / AbortTransaction. + * cluster_undo_record_xact_reset -- PREPARE/ABORT full per-backend teardown. * - * Also clears the per-xact touched-undo-segment list. On commit the list was - * already fsync'd by cluster_undo_xact_precommit_flush; on abort it is simply - * dropped (un-fsync'd undo of an aborted xact needs no durability) — this is - * the lazy/best-effort abort path. Clearing here prevents leakage into the - * next xact on this backend. + * PREPARE already flushed the touched undo segments before EndPrepare. On + * ABORT the list is simply dropped because aborted undo needs no durability. + * Normal COMMIT uses cluster_undo_record_xact_commit_release() instead. */ void cluster_undo_record_xact_reset(void) @@ -1933,9 +1942,7 @@ cluster_undo_record_xact_reset(void) } cluster_undo_touched_in_xact = false; - /* spec-4.12a D1: drop this backend's active-write registry entry (commit / - * abort / PREPARE all reach here). On PREPARE the prepared-xact guard (硬门 - * 6) keeps the undo alive past this point. */ + /* PREPARE's durable guard keeps its undo alive after this handoff. */ cluster_undo_active_write_unregister(); /* spec-4.12a Hardening v1.0.1: re-arm residual revalidation for the next * top-xact (its carried residual must be re-checked under lifecycle_lock). */ @@ -1953,14 +1960,13 @@ cluster_undo_record_xact_reset(void) * recycled, so a residual in it is always safe). A block may then hold * records from several transactions -- correct (UBA addresses by row). */ - /* P0 perf hardening: drop the cached undo segment fd at xact end to bound - * stale-fd exposure (e.g. a recycled segment) across transactions. */ + /* Full PREPARE/ABORT teardown closes the cache. Normal COMMIT preserves it. */ cluster_undo_smgr_fd_cache_reset(); } /* - * cluster_undo_record_is_touched -- D16 PREPARE TRANSACTION guard helper. + * cluster_undo_record_is_touched -- legacy transaction-local write marker. */ bool cluster_undo_record_is_touched(void) diff --git a/src/backend/cluster/storage/cluster_undo_smgr.c b/src/backend/cluster/storage/cluster_undo_smgr.c index 6dd82b008f..d456419764 100644 --- a/src/backend/cluster/storage/cluster_undo_smgr.c +++ b/src/backend/cluster/storage/cluster_undo_smgr.c @@ -55,10 +55,10 @@ * (8.5 open + 8.5 close per TPC-B txn). Cache ONE O_RDWR fd for the * most-recently-used (segment, owner) — O_RDWR serves both read_block and * write_block. Self-heals on (segment, owner) mismatch (close old + open - * new), bounding stale-fd exposure (e.g. a recycled segment) further by an - * xact-end reset (cluster_undo_record_xact_reset -> _fd_cache_reset) and a - * before_shmem_exit close. Per-backend fds to a shared segment file are - * independent; each pwrites its own offset range, so no cross-backend hazard. + * new). Normal COMMIT preserves the cache; PREPARE/ABORT full teardown, + * cache-key mismatch, and before_shmem_exit close it. Per-backend fds to a + * shared segment file are independent; each pwrites its own offset range, so + * there is no cross-backend hazard. */ static uint32 cached_fd_segment = 0; /* 0 = empty */ static uint8 cached_fd_owner = 0; @@ -128,8 +128,8 @@ get_segment_fd(ClusterUndoPathIntent intent, uint32 segment_id, uint8 owner_inst } /* - * cluster_undo_smgr_fd_cache_reset -- close the cached fd (xact end / segment - * recycle). Called from cluster_undo_record_xact_reset. + * cluster_undo_smgr_fd_cache_reset -- close the cached fd during full local + * teardown or explicit cache invalidation. Normal COMMIT does not call it. */ void cluster_undo_smgr_fd_cache_reset(void) diff --git a/src/include/cluster/cluster_tt_2pc.h b/src/include/cluster/cluster_tt_2pc.h index b50b0a3191..12664189b0 100644 --- a/src/include/cluster/cluster_tt_2pc.h +++ b/src/include/cluster/cluster_tt_2pc.h @@ -150,9 +150,9 @@ extern bool cluster_tt_2pc_parse_record(const void *recdata, uint32 len, Cluster * chains at step 4). AtPrepare serializes only -- NO state mutation * (PG two-phase contract; an EndPrepare failure must still abort with * the backend-local state intact). PostPrepare transfers ownership to - * the 2PC record: clears the backend-local TT bindings, sub-links and - * the ITL touch list (V-2: the touch list is droppable -- overlay / - * durable TT are authoritative and 3.4c lazy cleanout re-stamps pages). + * the 2PC record: clears the backend-local TT bindings, sub-links, ITL touch + * list, and undo lifecycle state. The undo reset also removes the active-write + * registry entry after the prepared-xact retention guard has taken over. */ extern void AtPrepare_ClusterTT(void); extern void PostPrepare_ClusterTT(void); diff --git a/src/include/cluster/cluster_undo_record_api.h b/src/include/cluster/cluster_undo_record_api.h index 2b68af2169..90db42abde 100644 --- a/src/include/cluster/cluster_undo_record_api.h +++ b/src/include/cluster/cluster_undo_record_api.h @@ -124,8 +124,8 @@ extern size_t cluster_undo_get_record(UBA uba, void *out_buffer, size_t buffer_s /* * cluster_undo_local_head_get -- spec-4.8 D7-A: backend-local latest undo-chain * head for a TT slot (segment_id, slot_offset), or InvalidUba if none this - * xact. Captured into the 2PC record at PREPARE (the local head is reset at - * xact end, so this must be read before PostPrepare/commit). + * xact. Captured into the 2PC record at PREPARE, before PostPrepare performs + * the full local teardown. */ extern UBA cluster_undo_local_head_get(uint16 tt_slot_segment_id, uint16 tt_slot_offset); @@ -141,26 +141,22 @@ extern void cluster_undo_record_shmem_init(void); /* - * cluster_undo_record_xact_reset -- reset per-xid binding at end of xact - * (commit / abort). Called from xact.c CommitTransaction / - * AbortTransaction hook (spec-3.7 D6 integration). + * cluster_undo_record_xact_reset -- full local teardown for PREPARE/ABORT. + * Normal COMMIT uses the dedicated O(1) cleanup hook below. */ extern void cluster_undo_record_xact_reset(void); /* - * cluster_undo_record_xact_commit_release -- spec-4.12a D6: release this - * backend's active-write boundary slot at COMMIT (CommitTransaction hook). - * The full xact_reset only runs on the PREPARE / ABORT paths, so without this - * a persistent connection's committed transaction would pin the undo drain - * boundary forever. 8.A-safe: only enables ACTIVE->COMMITTED; the retention - * horizon still gates the actual reclaim. + * cluster_undo_record_xact_commit_release -- O(1) normal-COMMIT cleanup. + * Releases the active-write boundary and clears transaction-local counts and + * flags while preserving the residual extent and fd cache. The retention + * horizon continues to gate actual reclaim. */ extern void cluster_undo_record_xact_commit_release(void); /* - * cluster_undo_record_is_touched -- did current xact write any undo record? - * Used by D16 PREPARE TRANSACTION guard. + * cluster_undo_record_is_touched -- legacy transaction-local write marker. */ extern bool cluster_undo_record_is_touched(void); @@ -170,7 +166,8 @@ extern bool cluster_undo_record_is_touched(void); * fsync this xact's dirtied undo segment files ONCE, on the commit path, * BEFORE the commit becomes visible (replaces per-record fsync). ereport(ERROR) * on fsync failure (runs before the commit critical section -> clean abort). - * No-op for a xact that wrote no undo. + * No-op for a xact that wrote no undo. A release-build runtime guard rejects + * any deferred block that remains pending after the flush. */ extern void cluster_undo_xact_precommit_flush(void); diff --git a/src/include/cluster/cluster_undo_smgr.h b/src/include/cluster/cluster_undo_smgr.h index 01311e64da..551accfde9 100644 --- a/src/include/cluster/cluster_undo_smgr.h +++ b/src/include/cluster/cluster_undo_smgr.h @@ -140,8 +140,8 @@ extern bool cluster_undo_smgr_fsync_segment_file(uint32 segment_id, uint8 owner_ /* * cluster_undo_smgr_fd_cache_reset -- close the per-backend cached undo - * segment fd (P0 perf hardening). Called at xact end (via - * cluster_undo_record_xact_reset) to bound stale-fd exposure across xacts. + * segment fd during PREPARE/ABORT full teardown or explicit invalidation. + * Normal COMMIT preserves the cache. */ extern void cluster_undo_smgr_fd_cache_reset(void); diff --git a/src/test/cluster_tap/t/270_cluster_4_12a_undo_record_segment_reclaim.pl b/src/test/cluster_tap/t/270_cluster_4_12a_undo_record_segment_reclaim.pl index 9672f2b4bd..89ea0d3dc6 100644 --- a/src/test/cluster_tap/t/270_cluster_4_12a_undo_record_segment_reclaim.pl +++ b/src/test/cluster_tap/t/270_cluster_4_12a_undo_record_segment_reclaim.pl @@ -20,6 +20,8 @@ # L3 quiesce -> after writes stop (registry empty => boundary infinite), # further rollovers keep REUSING the recycled pool rather than creating # new files, i.e. the pool stays bounded with no new transactions. +# L10 persistent backend -> normal COMMIT clears transaction-local undo-head +# bookkeeping while retaining the reusable extent and file descriptor. # # Spec: spec-4.12a-undo-record-segment-reclaim.md use strict; @@ -557,4 +559,75 @@ sub common_conf $nh->stop; } +# =========================================================================== +# L10 -- A persistent backend must not carry transaction-local undo heads across +# normal COMMIT. An older snapshot prevents TT-slot recycling, so 1030 +# autocommit writes visit more than the 1024 local-head capacity and +# force at least 21 retention rollovers. Before the fix, write 1025 +# fails with 53R9D because local-head entries leak between transactions. +# =========================================================================== +{ + my $ni = PgracClusterNode->new('commit_local_head_reset'); + $ni->init; + $ni->append_conf('postgresql.conf', + common_conf(64, 'on') . "cluster.undo_cleaner_enabled = off\n"); + $ni->start; + + $ni->safe_psql('postgres', 'CREATE TABLE t270_commit_pin (id int)'); + $ni->safe_psql('postgres', 'INSERT INTO t270_commit_pin VALUES (1)'); + + # Materialize a snapshot on a cluster table before subject-table setup. A + # catalog-only read does not register the cluster retention horizon. Taking + # the counter baseline after setup excludes recycling of pre-pin setup slots. + my $pin = $ni->background_psql('postgres', on_error_die => 1); + $pin->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $pin->query_safe('SELECT count(*) FROM t270_commit_pin'); + # Consume every pre-pin recyclable slot before taking the evidence baseline. + # One full 48-slot segment plus headroom makes the measured loop all-retained. + $ni->safe_psql('postgres', 'UPDATE t270_commit_pin SET id = id') for (1 .. 64); + + $ni->safe_psql('postgres', + 'CREATE TABLE t270_commit_reset (id int PRIMARY KEY, v int)'); + $ni->safe_psql('postgres', 'INSERT INTO t270_commit_reset VALUES (1, 0)'); + + my $rollovers_before = undo_counter($ni, 'tt_retention_rollover_count'); + my $recycles_before = undo_counter($ni, 'retention_recycle_count'); + + my $persistent = $ni->background_psql('postgres', on_error_die => 0); + my $committed = 0; + my $write_error = ''; + for (1 .. 1030) + { + my $write_ok = eval { + # Each call is one implicit transaction on the same backend. + $persistent->query_safe( + 'UPDATE t270_commit_reset SET v = v + 1 WHERE id = 1'); + 1; + }; + if (!$write_ok) + { + $write_error = $@; + last; + } + $committed++; + } + eval { $persistent->quit; }; + + is($committed, 1030, + 'L10 persistent backend completes 1030 autocommit undo writes'); + diag("L10 persistent write error after $committed commits: $write_error") + if $committed != 1030; + is($ni->safe_psql('postgres', + 'SELECT v FROM t270_commit_reset WHERE id = 1'), + '1030', 'L10 all writes committed without a hidden 53R9D failure'); + cmp_ok(undo_counter($ni, 'tt_retention_rollover_count') - $rollovers_before, + '>=', 21, 'L10 workload crosses at least 21 TT retention rollovers'); + is(undo_counter($ni, 'retention_recycle_count') - $recycles_before, + 0, 'L10 pinned snapshot prevents TT-slot recycling during the workload'); + + $pin->query_safe('ROLLBACK'); + $pin->quit; + $ni->stop; +} + done_testing();