diff --git a/src/backend/cluster/cluster_tt_slot.c b/src/backend/cluster/cluster_tt_slot.c index a0887706dd..9a1fbbb55e 100644 --- a/src/backend/cluster/cluster_tt_slot.c +++ b/src/backend/cluster/cluster_tt_slot.c @@ -46,6 +46,7 @@ #include "access/transam.h" #include "cluster/cluster_guc.h" /* cluster_undo_retention_horizon_enabled */ +#include "cluster/cluster_mode.h" /* cluster_peer_mode_enabled */ #include "cluster/cluster_scn.h" /* SCN_MAX_VALID_NODE_ID */ #include "cluster/cluster_shmem.h" /* ClusterShmemRegion */ #include "cluster/cluster_tt_slot.h" @@ -322,6 +323,12 @@ tt_slot_entry_recycle_locked(ClusterTTSlotAllocEntry *e, TransactionId new_owner * the horizon * (cluster_tt_slot_recyclable); ABORTED is always eligible (C7). When the * retention GUC is off, the gate is bypassed (spec-3.11 immediate recycle, C6). + * In peer mode, allocator Pass 2 never recycles COMMITTED directly: the local + * ProcArray horizon does not cover remote snapshots. The cluster undo cleaner + * is the sole COMMITTED recycler there; it folds every required peer report + * into an epoch-paired cluster floor and fences each COMMITTED -> FREE mutation. + * Allocator Pass 1 may then consume that proven FREE slot. ABORTED remains + * immediately reusable because it is invisible to every snapshot. * * Returns INVALID_TT_SLOT_OFFSET when no slot can be handed out. In that * case *out_retained_pressure (when non-NULL) distinguishes the two reasons: @@ -342,6 +349,7 @@ cluster_tt_slot_alloc_ext(uint32 segment_id, TransactionId top_xid, bool *out_re int free_idx = -1; bool retained_pressure = false; bool gate_enabled; + bool peer_mode; SCN horizon = InvalidScn; uint16 chosen; uint64 retain_skip_seen = 0; /* spec-3.12 D5 */ @@ -359,6 +367,7 @@ cluster_tt_slot_alloc_ext(uint32 segment_id, TransactionId top_xid, bool *out_re * GUC is off we skip the scan entirely and bypass the gate. */ gate_enabled = cluster_undo_retention_horizon_enabled; + peer_mode = cluster_peer_mode_enabled(); if (gate_enabled) { horizon = cluster_undo_retention_horizon(); /* spec-3.12 D5 / C16: sample the horizon gauge at the decision point. */ @@ -382,9 +391,20 @@ cluster_tt_slot_alloc_ext(uint32 segment_id, TransactionId top_xid, bool *out_re if (free_idx < 0 && !cluster_tt_slot_is_protected(segment_id, (uint16)i)) free_idx = i; } else if (e->status == CTS_COMMITTED || e->status == CTS_ABORTED) { - bool recyclable = gate_enabled - ? cluster_tt_slot_recyclable(e->status, e->commit_scn, horizon) - : true; /* GUC off: spec-3.11 immediate recycle (C6) */ + /* + * A local ProcArray horizon cannot authorize destruction of evidence + * still needed by a peer snapshot. In peer mode COMMITTED reclamation + * is delegated to cluster_tt_slot_gc_current_pass(), whose production + * caller supplies the epoch-paired proven cluster floor and rechecks + * the epoch at the mutation. This also overrides the diagnostic GUC-off + * bypass: without a cluster proof the safe outcome is rollover/fail-closed. + */ + bool recyclable + = (e->status == CTS_COMMITTED && peer_mode) + ? false + : (gate_enabled + ? cluster_tt_slot_recyclable(e->status, e->commit_scn, horizon) + : true); /* single-node GUC off: immediate recycle (C6) */ /* D5: wrap-retired entries are never re-selected this generation; * they read as pressure so the caller can roll over (C3b family). */ @@ -398,7 +418,7 @@ cluster_tt_slot_alloc_ext(uint32 segment_id, TransactionId top_xid, bool *out_re if (reusable_idx < 0 && !cluster_tt_slot_is_protected(segment_id, (uint16)i)) reusable_idx = i; } else { - /* COMMITTED and not older than horizon: retention keeps it alive. */ + /* COMMITTED lacks a cluster recycle proof, or is not older than horizon. */ retained_pressure = true; retain_skip_seen++; /* C16: per skip event, not de-duped */ } diff --git a/src/backend/cluster/cluster_undo_cleaner.c b/src/backend/cluster/cluster_undo_cleaner.c index f924fb1a87..1a2fff59f5 100644 --- a/src/backend/cluster/cluster_undo_cleaner.c +++ b/src/backend/cluster/cluster_undo_cleaner.c @@ -353,15 +353,28 @@ undo_cleaner_advance_liveness_tick(void) * - storage-mode gate: no cluster storage mode -> no work (HC4); * - cluster.undo_cleaner_enabled=off -> no work (diagnostic * parity with 3.12 lazy-only mode). + * + * Returns true when the recycle stage could NOT run to completion + * against a proven cluster floor (fold stalled, member set unstable, + * or the F-D2 epoch fence aborted the pass). These causes normally + * clear at the horizon-report cadence (~one LMON tick: epoch views + * converge and every peer re-publishes), so the caller retries the + * pass at that cadence instead of sleeping a full recycle interval — + * the S3 undo-pool exhaustion amplifier (a ~1s transient froze + * cluster-wide COMMITTED recycling for 30-60s). Rule 8.A unchanged: + * a retry still recycles ONLY on a proven floor; a persistent cause + * keeps every retry stalled. */ -static void +static bool undo_cleaner_run_pass(void) { ClusterUndoCleanerPassStats stats; + bool floor_retry_needed = false; + if (!cluster_undo_cleaner_enabled) - return; + return false; if (!cluster_storage_mode_enabled()) - return; + return false; /* * D2 (step 3): horizon ONCE per pass, BEFORE any seg->lock (C17). @@ -410,6 +423,7 @@ undo_cleaner_run_pass(void) } if (stalled) { + floor_retry_needed = true; cluster_undo_horizon_note_stall(); if (stall_blame >= 0 && stall_blame != cluster_node_id) cluster_undo_horizon_note_peer_stale(); @@ -434,6 +448,7 @@ undo_cleaner_run_pass(void) cluster_undo_horizon_note_floor(floor.scn); if (!cluster_tt_slot_gc_current_pass(horizon, floor.epoch, &stats)) { + floor_retry_needed = true; cluster_undo_horizon_note_pass_abort(); goto pass_account; /* F-D2: epoch moved mid-scan; abort the pass */ } @@ -522,8 +537,10 @@ undo_cleaner_run_pass(void) */ undo_cleaner_state->scan_resume_seg = seg; - if (fence_aborted) + if (fence_aborted) { + floor_retry_needed = true; cluster_undo_horizon_note_pass_abort(); + } } } @@ -559,6 +576,8 @@ undo_cleaner_run_pass(void) pinned_logged = false; } } + + return floor_retry_needed; } @@ -614,6 +633,7 @@ UndoCleanerMain(void) for (;;) { int rc; int timeout_ms; + bool floor_retry; CHECK_FOR_INTERRUPTS(); @@ -629,13 +649,30 @@ UndoCleanerMain(void) CLUSTER_INJECTION_POINT("undo-cleaner-main-loop-iter"); - undo_cleaner_run_pass(); + floor_retry = undo_cleaner_run_pass(); /* * interval 0 = pressure-wakeup only (Q8): block without * timeout; otherwise wake at the configured cadence. + * + * TT lane (P1#4): a pass that could not prove the cluster floor + * (stall / member churn / F-D2 abort) re-arms at the horizon- + * report cadence instead — that is when new reports and epoch + * convergence can actually arrive (one LMON tick). Sleeping the + * full recycle interval here turned ~1s transients into 30-60s + * cluster-wide COMMITTED-recycle freezes (S3 undo-pool + * exhaustion, 25,715 x 53R9E on node0). Fail-closed is + * untouched: the retried pass re-proves or re-stalls; this also + * bounds the interval=0 mode, whose stalled pass previously + * waited on a latch nobody was obliged to set. */ timeout_ms = cluster_undo_cleaner_interval_ms; + if (floor_retry) { + int retry_ms = Max(cluster_lmon_main_loop_interval, 200); + + if (timeout_ms <= 0 || retry_ms < timeout_ms) + timeout_ms = retry_ms; + } rc = WaitLatch( MyLatch, WL_LATCH_SET | WL_EXIT_ON_PM_DEATH | (timeout_ms > 0 ? WL_TIMEOUT : 0), timeout_ms > 0 ? timeout_ms : -1L, WAIT_EVENT_CLUSTER_BGPROC_UNDO_CLEANER_MAIN_LOOP); diff --git a/src/include/cluster/cluster_tt_slot.h b/src/include/cluster/cluster_tt_slot.h index 7f225ff76a..9a913f8d26 100644 --- a/src/include/cluster/cluster_tt_slot.h +++ b/src/include/cluster/cluster_tt_slot.h @@ -309,6 +309,9 @@ cluster_tt_slot_id_to_offset(uint32 tt_slot_id) * 1) reuse a slot already owned by `top_xid` (idempotent) * 2) take any FREE slot * 3) recycle a COMMITTED / ABORTED slot, wrap++ + * In peer mode, direct Pass-2 reuse is limited to ABORTED. COMMITTED + * evidence is first recycled to FREE by the epoch-fenced cluster-horizon + * cleaner, then consumed through Pass 1. * Returns offset in [0, TT_SLOTS_PER_SEGMENT) on success, or * INVALID_TT_SLOT_OFFSET when all slots are ACTIVE. Caller MUST be * outside critical section (function takes LWLock). diff --git a/src/test/cluster_tap/t/402_undo_cleaner_stall_retry_2node.pl b/src/test/cluster_tap/t/402_undo_cleaner_stall_retry_2node.pl new file mode 100644 index 0000000000..548038745d --- /dev/null +++ b/src/test/cluster_tap/t/402_undo_cleaner_stall_retry_2node.pl @@ -0,0 +1,258 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 402_undo_cleaner_stall_retry_2node.pl +# TT lane (P1#4) — undo cleaner stall-retry cadence. +# +# Root cause under repair (S3 25,715 x "TT retention rollover +# failed" on node0): when the undo cleaner's recycle stage stalls +# (cluster horizon unproven: missing/stale/epoch/malformed), the +# pass exits and the cleaner sleeps its FULL +# cluster.undo_cleaner_interval_ms before re-evaluating the fold. +# Stall causes normally clear at the horizon-report cadence (~one +# LMON tick: epoch views converge, every peer re-publishes), so a +# ~1s transient freezes cluster-wide COMMITTED recycling for up to +# a whole interval (30s default; VM evidence: boot "malformed" +# stall healed after exactly 60s = two passes, the 12:24 "epoch" +# stall after exactly 30s = one pass). Under an S3 write storm +# each freeze burns segment-pool headroom until the 256-segment +# hard cap (53R9E). +# +# The fix: a stalled pass re-arms the next pass at the report +# cadence (max(lmon interval, floor)) instead of the recycle +# cadence. Rule 8.A unchanged: every retry still recycles ONLY on +# a proven cluster floor — a persistent cause keeps stalling every +# retry (leg L4 proves the protection is NOT weakened). +# +# L1 ClusterPair startup baseline; initial floor proven +# L2 report-drop injection stalls the fold on node0 +# (undo.horizon_stall_count moves; floor freezes) +# L3 RECOVERY LATENCY (the fix's contract): after the cause is +# cleared (disarm; reports resume within ~1 LMON tick) the +# floor is re-proven within RETRY_BUDGET_S seconds — far below +# the 15s test interval a stalled pass used to sleep. +# L4 PROTECTION NEGATIVE: with the drop KEPT armed the stall +# persists across multiple retry windows (stall_count keeps +# growing, floor stays frozen) — retries never recycle +# unproven; disarm then heals again. +# L5 disarm + restart: cluster stays usable. +# +# Spec: spec-5.22e (cluster undo retention brake) stall semantics + +# S3 step-2 forensics (TT lane P1#4). +# +# Author: SqlRush +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../lib"; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Test::More; +use Time::HiRes qw(usleep time); + +# The cleaner interval is set LARGE relative to the retry cadence so the +# old behaviour (sleep a full interval while stalled) is cleanly +# distinguishable from the fixed behaviour (retry at the report cadence). +# This parameterizes the TEST ONLY; the fix itself is mechanism, not +# configuration. +my $CLEANER_INTERVAL_S = 15; +my $RETRY_BUDGET_S = 6; # fixed behaviour: heal well under this +my $HOLD_S = 5; # L4: keep the cause armed this long + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'undo_stall_retry', + quorum_voting_disks => 3, + shared_data => 1, + data_port_span => 2, + extra_conf => [ + 'autovacuum = off', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 10', + "cluster.undo_cleaner_interval_ms = ${\($CLEANER_INTERVAL_S * 1000)}", + ]); +$pair->start_pair; + +usleep(3_000_000); + +is($pair->node0->safe_psql('postgres', 'SELECT 1'), + '1', 'L1 node0 postmaster alive'); +is($pair->node1->safe_psql('postgres', 'SELECT 1'), + '1', 'L1 node1 postmaster alive'); + +ok($pair->wait_for_peer_state(0, 1, 'connected', 60) + && $pair->wait_for_peer_state(1, 0, 'connected', 60), + 'L1 peers connected'); + + +sub state_val +{ + my ($node, $cat, $key) = @_; + my $v = $node->safe_psql('postgres', + qq{SELECT value FROM pg_cluster_state WHERE category='$cat' AND key='$key'}); + return defined($v) && $v ne '' ? $v + 0 : 0; +} + +sub wait_for +{ + my ($cond, $timeout_s, $step_us) = @_; + $step_us //= 500_000; + my $deadline = time() + $timeout_s; + while (time() < $deadline) + { + return 1 if $cond->(); + usleep($step_us); + } + return $cond->() ? 1 : 0; +} + +# t/370 conf-face injection helpers (colon-typed arms need an explicit +# ':none' re-arm before RESET, L477). +sub arm_conf_injection +{ + my ($node, $spec) = @_; + $node->safe_psql('postgres', + "ALTER SYSTEM SET cluster.injection_points = '$spec'"); + $node->safe_psql('postgres', 'SELECT pg_reload_conf()'); + usleep(1_500_000); +} + +sub disarm_conf_injection +{ + my ($node, $point) = @_; + $node->safe_psql('postgres', + "ALTER SYSTEM SET cluster.injection_points = '$point:none'"); + $node->safe_psql('postgres', 'SELECT pg_reload_conf()'); + usleep(1_500_000); + $node->safe_psql('postgres', 'ALTER SYSTEM RESET cluster.injection_points'); + $node->safe_psql('postgres', 'SELECT pg_reload_conf()'); +} + +# Single-reload disarm for the L3 latency measurement. pg_reload_conf's +# SIGHUP latch-wakes EVERY process — including the cleaner, which runs an +# immediate pass. That first forced pass still sees the peer slot stale +# (the next genuine report only lands on the peer's ~1s LMON tick), so it +# stalls; what we then measure is how long the cleaner takes to re- +# evaluate ON ITS OWN. The stock two-reload helper above would wake the +# cleaner a second time ~1.5s later (after the fresh report landed) and +# heal it by test-harness side effect — masking exactly the cadence bug +# under test. +sub disarm_single_reload +{ + my ($node, $point) = @_; + $node->safe_psql('postgres', + "ALTER SYSTEM SET cluster.injection_points = '$point:none'"); + $node->safe_psql('postgres', 'SELECT pg_reload_conf()'); +} + +# Local SCN churn so a healed floor lands measurably ABOVE the frozen one. +$pair->node0->safe_psql('postgres', 'CREATE TABLE t402 (v int)'); +sub churn +{ + my ($node, $n) = @_; + $node->safe_psql('postgres', 'INSERT INTO t402 VALUES (1)') for (1 .. $n); +} + + +# ============================================================ +# L1b: initial floor proven (boot stall heals; generous window — +# the pre-fix binary needs up to ~2 intervals). +# ============================================================ +ok(wait_for(sub { state_val($pair->node0, 'undo', 'horizon_last_floor_scn') > 0 }, + 3 * $CLEANER_INTERVAL_S + 10), + 'L1b initial cluster floor proven on node0'); + + +# ============================================================ +# L2: report-drop stalls the fold on node0. +# ============================================================ +my $stall_pre = state_val($pair->node0, 'undo', 'horizon_stall_count'); +arm_conf_injection($pair->node0, 'cluster-undo-horizon-report-drop:skip'); + +ok(wait_for(sub { state_val($pair->node0, 'undo', 'horizon_stall_count') > $stall_pre }, + 2 * $CLEANER_INTERVAL_S + 15), + 'L2 undo.horizon_stall_count moved under report-drop (fold stalled)'); + +my $floor_frozen = state_val($pair->node0, 'undo', 'horizon_last_floor_scn'); +churn($pair->node0, 8); # SCN moves on while the floor is frozen + + +# ============================================================ +# L3: recovery latency after the cause clears (the fix's contract). +# +# The stalled pass just ran (stall_count moved above). Disarm now: +# fresh reports land within ~1 LMON tick (1s). The FIXED cleaner +# re-evaluates at the report cadence and re-proves the floor within +# RETRY_BUDGET_S. The OLD cleaner slept the full interval, healing +# only at the next 15s boundary — this assertion is RED on it. +# ============================================================ +my $t_cleared = time(); +disarm_single_reload($pair->node0, 'cluster-undo-horizon-report-drop'); + +my $healed = wait_for( + sub { state_val($pair->node0, 'undo', 'horizon_last_floor_scn') > $floor_frozen }, + $CLEANER_INTERVAL_S + 10, 200_000); +my $heal_latency = time() - $t_cleared; + +ok($healed, 'L3 floor re-proven after the stall cause cleared'); +diag(sprintf("L3 heal latency after disarm: %.1fs (budget %ds, interval %ds)", + $heal_latency, $RETRY_BUDGET_S, $CLEANER_INTERVAL_S)); +cmp_ok($heal_latency, '<=', $RETRY_BUDGET_S, + 'L3 HARD ASSERT: stalled cleaner re-evaluates at the report cadence, ' + . 'not the recycle interval'); + +my $n0log = PostgreSQL::Test::Utils::slurp_file($pair->node0->logfile); +like($n0log, qr/cluster horizon proven again; recycling resumes/, + 'L3b "recycling resumes" logged on node0'); + + +# ============================================================ +# L4: protection negative — a PERSISTENT cause keeps stalling every +# retry; fast retries never recycle unproven (Rule 8.A intact). +# ============================================================ +my $stall_l4_pre = state_val($pair->node0, 'undo', 'horizon_stall_count'); +arm_conf_injection($pair->node0, 'cluster-undo-horizon-report-drop:skip'); + +ok(wait_for(sub { state_val($pair->node0, 'undo', 'horizon_stall_count') > $stall_l4_pre }, + 2 * $CLEANER_INTERVAL_S + 15), + 'L4 fold stalled again under re-armed report-drop'); + +my $floor_l4 = state_val($pair->node0, 'undo', 'horizon_last_floor_scn'); +my $stall_mid = state_val($pair->node0, 'undo', 'horizon_stall_count'); +churn($pair->node0, 8); +sleep($HOLD_S); + +cmp_ok(state_val($pair->node0, 'undo', 'horizon_stall_count'), '>=', $stall_mid, + 'L4b stall accounting keeps running while the cause persists'); +is(state_val($pair->node0, 'undo', 'horizon_last_floor_scn'), $floor_l4, + 'L4c HARD ASSERT: floor did NOT advance while unproven — no retry ' + . 'recycles without a proven cluster floor'); + +disarm_conf_injection($pair->node0, 'cluster-undo-horizon-report-drop'); +churn($pair->node0, 4); +ok(wait_for(sub { state_val($pair->node0, 'undo', 'horizon_last_floor_scn') > $floor_l4 }, + $CLEANER_INTERVAL_S + 10), + 'L4d floor heals after the persistent cause is removed'); + + +# ============================================================ +# L5: restart clean. +# ============================================================ +$pair->node0->stop('fast'); +$pair->node1->stop('fast'); +$pair->node0->start; +$pair->node1->start; +usleep(3_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 60) + && $pair->wait_for_peer_state(1, 0, 'connected', 60), + 'L5 cluster restarted after both legs'); +is($pair->node0->safe_psql('postgres', 'SELECT count(*) >= 0 FROM t402'), + 't', 'L5 table readable after both legs'); + + +done_testing(); diff --git a/src/test/cluster_unit/test_cluster_tt_slot_allocator.c b/src/test/cluster_unit/test_cluster_tt_slot_allocator.c index 7a8df4d90b..30dd9c00f9 100644 --- a/src/test/cluster_unit/test_cluster_tt_slot_allocator.c +++ b/src/test/cluster_unit/test_cluster_tt_slot_allocator.c @@ -63,6 +63,7 @@ #include #include "access/transam.h" +#include "cluster/cluster_conf.h" /* ClusterConfShmem (peer-mode test topology) */ #include "cluster/cluster_scn.h" #include "cluster/cluster_tt_slot.h" #include "cluster/cluster_undo_cleaner.h" /* gc pass + stats (spec-3.13) */ @@ -222,6 +223,10 @@ ExceptionalCondition(const char *conditionName pg_attribute_unused(), SCN mock_retention_horizon = InvalidScn; bool cluster_undo_retention_horizon_enabled = true; +/* cluster_mode.h derives peer mode from this topology SSOT. */ +static ClusterConf mock_cluster_conf; +ClusterConf *ClusterConfShmem = &mock_cluster_conf; + /* spec-3.15 D6 stub: protected-map spinlock (single-threaded unit). */ int s_lock(volatile slock_t *lock, const char *file, int line, const char *func) @@ -279,6 +284,7 @@ reset_allocator(void) /* Default: no retention constraint (cluster-disabled sentinel), gate on. */ mock_retention_horizon = InvalidScn; cluster_undo_retention_horizon_enabled = true; + mock_cluster_conf.node_count = 1; } @@ -1049,6 +1055,98 @@ UT_TEST(test_t47_protect_idempotent_and_conflict) (void)cluster_tt_slot_unprotect_xid((TransactionId)902); } +UT_TEST(test_t48_peer_mode_committed_recycle_waits_for_cluster_floor_cleaner) +{ + /* + * A peer can hold a read_scn below this node's LOCAL ProcArray horizon. + * Therefore allocator Pass 2 must not consume a locally-eligible + * COMMITTED slot in peer mode. Only the cleaner, after it receives an + * epoch-paired proven CLUSTER floor, may turn that slot into FREE. + */ + ClusterUndoCleanerPassStats stats = { 0 }; + uint16 off; + uint16 result; + bool retained = false; + int i; + + reset_allocator(); + mock_cluster_conf.node_count = 2; /* cluster_peer_mode_enabled() */ + off = cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)100); + cluster_tt_slot_mark_committed(NODE0_SEG, off, (TransactionId)100, (SCN)10); + mock_retention_horizon = (SCN)100; /* local-only floor would recycle */ + + for (i = 1; i < TT_SLOTS_PER_SEGMENT; i++) + (void)cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)(2000 + i)); + + /* No cluster-floor proof has run: retain old xid/wrap and roll over. */ + result = cluster_tt_slot_alloc_ext(NODE0_SEG, (TransactionId)99999, &retained); + UT_ASSERT_EQ((int)result, (int)INVALID_TT_SLOT_OFFSET); + UT_ASSERT_EQ((int)retained, 1); + UT_ASSERT_EQ((int)cluster_tt_slot_get_wrap(NODE0_SEG, off), 0); + + /* A remote-reader floor below the commit still retains it. */ + cluster_tt_slot_gc_current_pass((SCN)5, (uint64)7, &stats); + UT_ASSERT_EQ((int)stats.shmem_tt_slots_gcd, 0); + retained = false; + result = cluster_tt_slot_alloc_ext(NODE0_SEG, (TransactionId)99999, &retained); + UT_ASSERT_EQ((int)result, (int)INVALID_TT_SLOT_OFFSET); + UT_ASSERT_EQ((int)retained, 1); + UT_ASSERT_EQ((int)cluster_tt_slot_get_wrap(NODE0_SEG, off), 0); + + /* Once the proven cluster floor passes the commit, cleaner owns recycle. */ + memset(&stats, 0, sizeof(stats)); + cluster_tt_slot_gc_current_pass((SCN)20, (uint64)7, &stats); + UT_ASSERT_EQ((int)stats.shmem_tt_slots_gcd, 1); + result = cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)99999); + UT_ASSERT_EQ((int)result, (int)off); + UT_ASSERT_EQ((int)cluster_tt_slot_get_wrap(NODE0_SEG, off), 1); +} + +UT_TEST(test_t49_peer_mode_aborted_remains_directly_recyclable) +{ + /* ABORTED is invisible to every local or remote snapshot, so the peer-mode + * COMMITTED proof gate must not force unnecessary rollover for it. */ + uint16 off; + uint16 result; + int i; + + reset_allocator(); + mock_cluster_conf.node_count = 2; + off = cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)100); + cluster_tt_slot_mark_aborted(NODE0_SEG, off, (TransactionId)100); + + for (i = 1; i < TT_SLOTS_PER_SEGMENT; i++) + (void)cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)(2000 + i)); + + result = cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)99999); + UT_ASSERT_EQ((int)result, (int)off); + UT_ASSERT_EQ((int)cluster_tt_slot_get_wrap(NODE0_SEG, off), 1); +} + +UT_TEST(test_t50_peer_mode_guc_off_cannot_bypass_cluster_floor) +{ + /* The diagnostic local-retention bypass is not a proof about peer + * snapshots. In peer mode its safe result is rollover/fail-closed. */ + uint16 off; + uint16 result; + bool retained = false; + int i; + + reset_allocator(); + mock_cluster_conf.node_count = 2; + cluster_undo_retention_horizon_enabled = false; + off = cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)100); + cluster_tt_slot_mark_committed(NODE0_SEG, off, (TransactionId)100, (SCN)10); + + for (i = 1; i < TT_SLOTS_PER_SEGMENT; i++) + (void)cluster_tt_slot_alloc(NODE0_SEG, (TransactionId)(2000 + i)); + + result = cluster_tt_slot_alloc_ext(NODE0_SEG, (TransactionId)99999, &retained); + UT_ASSERT_EQ((int)result, (int)INVALID_TT_SLOT_OFFSET); + UT_ASSERT_EQ((int)retained, 1); + UT_ASSERT_EQ((int)cluster_tt_slot_get_wrap(NODE0_SEG, off), 0); +} + int main(void) @@ -1104,6 +1202,9 @@ main(void) UT_RUN(test_t45_protected_map_basics); UT_RUN(test_t46_alloc_gate_skips_protected_free_slot); UT_RUN(test_t47_protect_idempotent_and_conflict); + UT_RUN(test_t48_peer_mode_committed_recycle_waits_for_cluster_floor_cleaner); + UT_RUN(test_t49_peer_mode_aborted_remains_directly_recyclable); + UT_RUN(test_t50_peer_mode_guc_off_cannot_bypass_cluster_floor); return ut_failed_count == 0 ? 0 : 1; }