From 142d972eeda663f4fa74f79814a3bc16a7c73e02 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 09:03:09 +0800 Subject: [PATCH 01/29] feat(cluster): spec-7.1 D0 -- 53R97 per-leg attribution counters Six vis53r97_leg_* counters attribute every cross-instance fail-closed refusal to its source leg (3 origin-serve legs: invalid_scn / zero_match / srv_other; 3 requester legs: covers_refuse / multi_unresolvable / xmax_unprovable), exposed as cluster_dump_state() cr-section rows. The census before/after ledger reads these instead of a lump failclosed total, so each leg's zeroing is provable by the mechanism that closed it. No behavior change: counting only, every refusal direction untouched. Spec: spec-7.1-cross-instance-positive-interread.md (D0 census / D5) --- src/backend/access/heap/heapam_visibility.c | 9 +- src/backend/cluster/cluster_cr.c | 82 +++++++++++++++++++ src/backend/cluster/cluster_cr_server.c | 34 ++++++-- src/backend/cluster/cluster_debug.c | 13 +++ .../cluster/cluster_runtime_visibility.c | 1 + src/include/cluster/cluster_cr.h | 15 ++++ src/test/cluster_unit/test_cluster_debug.c | 31 +++++++ 7 files changed, 177 insertions(+), 8 deletions(-) diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c index c46343197f..b3e9d0f6c8 100644 --- a/src/backend/access/heap/heapam_visibility.c +++ b/src/backend/access/heap/heapam_visibility.c @@ -1565,8 +1565,11 @@ cluster_remote_live_xmax_keeps_visible(Buffer buffer, HeapTupleHeader tuple, Sna * is retryable. (Lock-only multis returned 1 above: a lock never * deletes the row, no member decode is needed.) */ - if (cluster_peer_mode_enabled()) + if (cluster_peer_mode_enabled()) { + /* PGRAC: spec-7.1 D0 census — foreign-multi refuse leg (D3 target). */ + cluster_vis53r97_note_multi_unresolvable(); return -1; + } xmax = HeapTupleGetUpdateXid(tuple); } else xmax = HeapTupleHeaderGetRawXmax(tuple); @@ -1600,6 +1603,8 @@ cluster_remote_live_xmax_keeps_visible(Buffer buffer, HeapTupleHeader tuple, Sna cluster_vis_bump_xmax_resolved_count(); /* spec-7.1a D6 */ return 0; default: + /* PGRAC: spec-7.1 D0 census — deleting-xmax unproven leg. */ + cluster_vis53r97_note_xmax_unprovable(); return -1; /* unknown / unresolved -> fail closed */ } } @@ -1631,6 +1636,8 @@ cluster_remote_live_xmax_keeps_visible(Buffer buffer, HeapTupleHeader tuple, Sna return 0; /* our committed delete, visible to this snapshot */ } + /* PGRAC: spec-7.1 D0 census — deleting-xmax unproven leg. */ + cluster_vis53r97_note_xmax_unprovable(); return -1; /* origin unprovable -> fail closed */ } #endif diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index 1f2b8ed1af..479c384b2d 100644 --- a/src/backend/cluster/cluster_cr.c +++ b/src/backend/cluster/cluster_cr.c @@ -169,6 +169,27 @@ typedef struct ClusterCRShared { * striped cluster). */ pg_atomic_uint64 rtvis_underivable_failclosed_count; + /* + * spec-7.1 D0/D5: 53R97 per-leg attribution. Every fail-closed refusal + * on the cross-instance read path is attributed to the leg that produced + * it, so the census (D0 before / D4 after) can prove each leg's zeroing + * by the mechanism that closed it instead of a lump total. Server side + * (origin LMS verdict serve): invalid_scn = durable by-xid hit with an + * unstamped commit_scn (delayed cleanout window); zero_match = provably + * recycled slot whose CLOG state is neither explicit commit nor abort; + * srv_other = every remaining refusal (ambiguous wrap, scan unavailable, + * non-own xid, wrap-suspect, in-doubt stamped-then-crashed). Requester + * side: covers_refuse = live-authority covers gate refused the verdict + * pairing; multi_unresolvable = a multixact xmax whose member set or + * origin cannot be proven; xmax_unprovable = a deleting xmax whose + * terminal state resolution ended unproven (fail-closed -1). + */ + pg_atomic_uint64 vis53r97_leg_invalid_scn_refuse_count; + pg_atomic_uint64 vis53r97_leg_zero_match_refuse_count; + pg_atomic_uint64 vis53r97_leg_srv_other_refuse_count; + pg_atomic_uint64 vis53r97_leg_covers_refuse_count; + pg_atomic_uint64 vis53r97_leg_multi_unresolvable_count; + pg_atomic_uint64 vis53r97_leg_xmax_unprovable_count; } ClusterCRShared; static ClusterCRShared *CRShared = NULL; @@ -251,6 +272,12 @@ cluster_cr_shmem_init(void) pg_atomic_init_u64(&CRShared->cr_server_verdict_served_count, 0); pg_atomic_init_u64(&CRShared->cr_server_verdict_denied_count, 0); pg_atomic_init_u64(&CRShared->rtvis_underivable_failclosed_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_invalid_scn_refuse_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_zero_match_refuse_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_srv_other_refuse_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_covers_refuse_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_multi_unresolvable_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_xmax_unprovable_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_resolved_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_recycled_invisible_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_invalid_or_ambiguous_count, 0); @@ -411,6 +438,51 @@ cluster_rtvis_note_underivable_failclosed(void) if (CRShared != NULL) pg_atomic_fetch_add_u64(&CRShared->rtvis_underivable_failclosed_count, 1); } + +/* PGRAC: spec-7.1 D0/D5 — 53R97 per-leg attribution bumps (see the struct + * comment for each leg's meaning; server legs run in LMS drain context, + * requester legs in backend context — all plain atomic adds). */ +void +cluster_vis53r97_note_srv_invalid_scn(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_invalid_scn_refuse_count, 1); +} + +void +cluster_vis53r97_note_srv_zero_match(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_zero_match_refuse_count, 1); +} + +void +cluster_vis53r97_note_srv_other(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_srv_other_refuse_count, 1); +} + +void +cluster_vis53r97_note_covers_refuse(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_covers_refuse_count, 1); +} + +void +cluster_vis53r97_note_multi_unresolvable(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_multi_unresolvable_count, 1); +} + +void +cluster_vis53r97_note_xmax_unprovable(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_xmax_unprovable_count, 1); +} CR_COUNTER_ACCESSOR(cluster_cr_corruption_count, cr_corruption_count) CR_COUNTER_ACCESSOR(cluster_cr_chain_walk_steps_sum, cr_chain_walk_steps_sum) CR_COUNTER_ACCESSOR(cluster_cr_inverse_insert_count, cr_inverse_insert_count) @@ -448,6 +520,16 @@ CR_COUNTER_ACCESSOR(cluster_rtvis_verdict_inadmissible_count, rtvis_verdict_inad CR_COUNTER_ACCESSOR(cluster_cr_server_verdict_served_count, cr_server_verdict_served_count) CR_COUNTER_ACCESSOR(cluster_cr_server_verdict_denied_count, cr_server_verdict_denied_count) CR_COUNTER_ACCESSOR(cluster_rtvis_underivable_failclosed_count, rtvis_underivable_failclosed_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_invalid_scn_refuse_count, + vis53r97_leg_invalid_scn_refuse_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_zero_match_refuse_count, + vis53r97_leg_zero_match_refuse_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_srv_other_refuse_count, + vis53r97_leg_srv_other_refuse_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_covers_refuse_count, vis53r97_leg_covers_refuse_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_multi_unresolvable_count, + vis53r97_leg_multi_unresolvable_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmax_unprovable_count, vis53r97_leg_xmax_unprovable_count) /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ CR_COUNTER_ACCESSOR(cluster_cr_xmax_resolved_count, cr_xmax_resolved_count) CR_COUNTER_ACCESSOR(cluster_cr_xmax_recycled_invisible_count, cr_xmax_recycled_invisible_count) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index a798919e2b..54791a92f7 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -402,12 +402,16 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) if (!cluster_crossnode_runtime_visibility) return false; - if (!TransactionIdIsNormal(xid)) + if (!TransactionIdIsNormal(xid)) { + cluster_vis53r97_note_srv_other(); return false; + } /* spec-6.15 D4: only answer for provably-own xids (see banner). */ - if (!cluster_xid_is_mine(xid)) + if (!cluster_xid_is_mine(xid)) { + cluster_vis53r97_note_srv_other(); return false; + } /* Co-sample the authority triple BEFORE the scan (see banner). */ slot->undo_auth.origin_epoch = cluster_epoch_get_current(); @@ -428,8 +432,10 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) switch ( cluster_tt_slot_durable_resolve_by_xid(xid, CLUSTER_TT_WRAP_ANY, &scn, NULL, NULL, &wrap)) { case CLUSTER_TT_DURABLE_RESOLVED_SCN: - if (!TransactionIdDidCommit(xid)) + if (!TransactionIdDidCommit(xid)) { + cluster_vis53r97_note_srv_other(); return false; /* C1b: stamped-then-crashed is in-doubt */ + } if (cluster_cr_accept_resolved_scn(scn)) { v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT; v->commit_scn = scn; @@ -456,11 +462,15 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) * No gated recycle this incarnation -> the recycled-recurrence * candidate is unboundable -> refuse, exactly like zero-match. */ - if (!cluster_cr_retention_proof_origin_legs(&horizon)) + if (!cluster_cr_retention_proof_origin_legs(&horizon)) { + cluster_vis53r97_note_srv_other(); return false; + } horizon = cluster_tt_slot_max_recycle_horizon(); - if (!SCN_VALID(horizon)) + if (!SCN_VALID(horizon)) { + cluster_vis53r97_note_srv_other(); return false; + } if (scn_time_cmp(scn, horizon) > 0) horizon = scn; v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; @@ -480,11 +490,15 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) * (no gated recycle this incarnation — e.g. all recycles predate a * restart) refuses fail-closed. */ - if (!cluster_cr_retention_proof_origin_legs(&horizon)) + if (!cluster_cr_retention_proof_origin_legs(&horizon)) { + cluster_vis53r97_note_srv_other(); return false; + } horizon = cluster_tt_slot_max_recycle_horizon(); - if (!SCN_VALID(horizon)) + if (!SCN_VALID(horizon)) { + cluster_vis53r97_note_srv_other(); return false; + } if (TransactionIdDidCommit(xid)) { v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; v->horizon_scn = horizon; @@ -494,12 +508,18 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; return true; } + cluster_vis53r97_note_srv_zero_match(); return false; /* neither explicit CLOG state -> refuse */ case CLUSTER_TT_DURABLE_XID_MATCH_INVALID_SCN: + /* spec-7.1 D0 census: the delayed-cleanout window leg (D1 target). */ + cluster_vis53r97_note_srv_invalid_scn(); + return false; + case CLUSTER_TT_DURABLE_AMBIGUOUS_WRAP: case CLUSTER_TT_DURABLE_SCAN_UNAVAILABLE: default: + cluster_vis53r97_note_srv_other(); return false; } } diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 3fce82cd02..41309ff643 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2643,6 +2643,19 @@ dump_cr(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_cr_server_verdict_denied_count())); emit_row(rsinfo, "cr", "rtvis_underivable_failclosed_count", fmt_int64((int64)cluster_rtvis_underivable_failclosed_count())); + /* spec-7.1 D0/D5: 53R97 per-leg attribution (census + error-closure dial). */ + emit_row(rsinfo, "cr", "vis53r97_leg_invalid_scn_refuse_count", + fmt_int64((int64)cluster_vis53r97_leg_invalid_scn_refuse_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_zero_match_refuse_count", + fmt_int64((int64)cluster_vis53r97_leg_zero_match_refuse_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_srv_other_refuse_count", + fmt_int64((int64)cluster_vis53r97_leg_srv_other_refuse_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_covers_refuse_count", + fmt_int64((int64)cluster_vis53r97_leg_covers_refuse_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_multi_unresolvable_count", + fmt_int64((int64)cluster_vis53r97_leg_multi_unresolvable_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_xmax_unprovable_count", + fmt_int64((int64)cluster_vis53r97_leg_xmax_unprovable_count())); /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ emit_row(rsinfo, "cr", "cr_xmax_resolved_count", fmt_int64((int64)cluster_cr_xmax_resolved_count())); diff --git a/src/backend/cluster/cluster_runtime_visibility.c b/src/backend/cluster/cluster_runtime_visibility.c index a95c43a417..7e082b50e4 100644 --- a/src/backend/cluster/cluster_runtime_visibility.c +++ b/src/backend/cluster/cluster_runtime_visibility.c @@ -242,6 +242,7 @@ rtvis_try_origin_verdict(int origin_node, uint32 undo_segment_id, TransactionId if (!cluster_vis_live_authority_covers(demand_scn, auth)) { cluster_vis_bump_covers_scn_refuse_count(); /* spec-7.1a D6 */ + cluster_vis53r97_note_covers_refuse(); /* spec-7.1 D0 census (union) */ cluster_rtvis_verdict_note_failclosed(); return false; } diff --git a/src/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index 0f4f7b9293..b937d4c944 100644 --- a/src/include/cluster/cluster_cr.h +++ b/src/include/cluster/cluster_cr.h @@ -266,6 +266,21 @@ extern void cluster_rtvis_verdict_note_inadmissible(void); extern uint64 cluster_rtvis_underivable_failclosed_count(void); extern void cluster_rtvis_note_underivable_failclosed(void); +/* spec-7.1 D0/D5: 53R97 per-leg attribution (3 server legs + 3 requester + * legs; see the ClusterCRShared field comment for each leg's meaning). */ +extern uint64 cluster_vis53r97_leg_invalid_scn_refuse_count(void); +extern uint64 cluster_vis53r97_leg_zero_match_refuse_count(void); +extern uint64 cluster_vis53r97_leg_srv_other_refuse_count(void); +extern uint64 cluster_vis53r97_leg_covers_refuse_count(void); +extern uint64 cluster_vis53r97_leg_multi_unresolvable_count(void); +extern uint64 cluster_vis53r97_leg_xmax_unprovable_count(void); +extern void cluster_vis53r97_note_srv_invalid_scn(void); +extern void cluster_vis53r97_note_srv_zero_match(void); +extern void cluster_vis53r97_note_srv_other(void); +extern void cluster_vis53r97_note_covers_refuse(void); +extern void cluster_vis53r97_note_multi_unresolvable(void); +extern void cluster_vis53r97_note_xmax_unprovable(void); + /* * spec-6.12i CP5 (D-i4): origin-side pieces of the cross-instance verdict * serve (cluster_cr_server.c). diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index d1de9bad09..a42f22d7a9 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1871,6 +1871,37 @@ cluster_rtvis_underivable_failclosed_count(void) { return 0; } +/* spec-7.1 D0/D5: 53R97 per-leg attribution counters. */ +uint64 +cluster_vis53r97_leg_invalid_scn_refuse_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_zero_match_refuse_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_srv_other_refuse_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_covers_refuse_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_multi_unresolvable_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_xmax_unprovable_count(void) +{ + return 0; +} /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ uint64 cluster_cr_xmax_resolved_count(void) From f20d0549e00268c542cdca304a075be239500587 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 09:57:01 +0800 Subject: [PATCH 02/29] test(cluster): spec-7.1 D0 -- 53R97 per-leg census scratch driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t/990 (not in any schedule; run by hand) drives the D0 before-census and the D4 after-census on the same yardstick: S3-shaped / S5-shaped dual-node pgbench loads with client-side ERROR histograms plus per-phase cluster_dump_state() counter deltas, and six deterministic multixact cross-read probes (A-F). Crash-aware: a node death under load is reported with its TRAP signature as census data instead of failing the run. Spec: spec-7.1-cross-instance-positive-interread.md (D0-①) --- .../cluster_tap/t/990_scratch_71_census.pl | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 src/test/cluster_tap/t/990_scratch_71_census.pl diff --git a/src/test/cluster_tap/t/990_scratch_71_census.pl b/src/test/cluster_tap/t/990_scratch_71_census.pl new file mode 100644 index 0000000000..e901ae74dc --- /dev/null +++ b/src/test/cluster_tap/t/990_scratch_71_census.pl @@ -0,0 +1,470 @@ +# spec-7.1 D0 scratch census driver (NOT part of the test schedule; run by hand). +# +# 53R97 per-leg census over S3-shaped (100% write) and S5-shaped (99/1) +# dual-node loads, plus a deterministic multixact cross-read probe. +# Attribution channels: (a) vis53r97_leg_* / rtvis_* counters via +# pg_cluster_state, snapshotted per phase per node; (b) client-observed +# SQLSTATE+message histogram via the census_try_* plpgsql wrappers' +# RAISE LOG lines ("CENSUS;;;") grepped per phase +# from each node's log. +# +# Spec: spec-7.1-cross-instance-positive-interread.md (D0-①) + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; +use IPC::Run; + +my $PHASE_SECS = $ENV{CENSUS_SECS} // 60; +my $CLIENTS = $ENV{CENSUS_CLIENTS} // 4; +my $NROWS = 500; +my $PHASE = $ENV{CENSUS_PHASE} // 'ALL'; # S3 | S5 | MULTI | ALL + +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 write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 10) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +my @KEYS = qw( + vis53r97_leg_invalid_scn_refuse_count + vis53r97_leg_zero_match_refuse_count + vis53r97_leg_srv_other_refuse_count + vis53r97_leg_covers_refuse_count + vis53r97_leg_multi_unresolvable_count + vis53r97_leg_xmax_unprovable_count + rtvis_underivable_failclosed_count + rtvis_resolve_committed_count + rtvis_resolve_aborted_count + rtvis_resolve_failclosed_count + rtvis_verdict_wire_count + rtvis_verdict_failclosed_count + rtvis_verdict_exact_count + rtvis_verdict_below_horizon_count + rtvis_verdict_inadmissible_count + cr_server_verdict_served_count + cr_server_verdict_denied_count + rtvis_undo_fetch_wire_count + rtvis_undo_fetch_failclosed_count +); + +sub snap_counters +{ + my ($node) = @_; + my %s; + $s{$_} = state_val($node, 'cr', $_) for @KEYS; + return \%s; +} + +sub diff_counters +{ + my ($before, $after) = @_; + my %d; + for my $k (@KEYS) + { + my $delta = $after->{$k} - $before->{$k}; + $d{$k} = $delta if $delta != 0; + } + return \%d; +} + +sub log_offset { my ($node) = @_; return -s $node->logfile || 0; } + +sub census_lines +{ + my ($node, $from) = @_; + open(my $fh, '<', $node->logfile) or die "open log: $!"; + seek($fh, $from, 0); + my %hist; + while (my $l = <$fh>) + { + if ($l =~ /CENSUS;([WR]);([0-9A-Z]{5});(.*)$/) + { + my ($op, $state, $msg) = ($1, $2, $3); + $msg =~ s/\d+/N/g; # fold ids + $msg = substr($msg, 0, 90); + $hist{"$op;$state;$msg"}++; + } + } + close $fh; + return \%hist; +} + +sub report_phase +{ + my ($tag, $pair, $snap0, $snap1, $off0, $off1) = @_; + my ($node0, $node1) = ($pair->node0, $pair->node1); + my $d0 = diff_counters($snap0, snap_counters($node0)); + my $d1 = diff_counters($snap1, snap_counters($node1)); + diag("==== CENSUS PHASE $tag: counter deltas node0 ===="); + diag(sprintf(" %-46s %12d", $_, $d0->{$_})) for sort keys %$d0; + diag("==== CENSUS PHASE $tag: counter deltas node1 ===="); + diag(sprintf(" %-46s %12d", $_, $d1->{$_})) for sort keys %$d1; + my $h0 = census_lines($node0, $off0); + my $h1 = census_lines($node1, $off1); + diag("==== CENSUS PHASE $tag: client error histogram node0 ===="); + diag(sprintf(" %6d %s", $h0->{$_}, $_)) for sort { $h0->{$b} <=> $h0->{$a} } keys %$h0; + diag("==== CENSUS PHASE $tag: client error histogram node1 ===="); + diag(sprintf(" %6d %s", $h1->{$_}, $_)) for sort { $h1->{$b} <=> $h1->{$a} } keys %$h1; +} + +# ============================================================ +# Setup: t/346-shaped pair + census schema (mirrored DDL, OID-aligned). +# ============================================================ +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'c71census', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.shared_cr_pool_enabled = on', + 'cluster.shared_cr_pool_size_blocks = 256', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + 'log_min_messages = log', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +sub nodes_alive +{ + my ($tag) = @_; + my $dead = 0; + for my $n ($node0, $node1) + { + my $ok = eval { + my ($rc, $o, $e) = $n->psql('postgres', 'SELECT 1', timeout => 10); + $rc == 0; + }; + if (!$ok) + { + $dead = 1; + diag("CRASH at $tag: node " . $n->name . " unreachable; crash signature:"); + my $lf = $n->logfile; + my @tail = split(/\n/, + `grep -E 'TRAP|PANIC|was terminated by signal' $lf | tail -4`); + diag(" $_") for @tail; + } + } + return !$dead; +} + +my $fn_w = q{CREATE FUNCTION census_try_write(p int) RETURNS text LANGUAGE plpgsql AS $$ +BEGIN + UPDATE census_acc SET v = v + 1 WHERE aid = p; + RETURN 'OK'; +EXCEPTION WHEN OTHERS THEN + RAISE LOG 'CENSUS;W;%;%', SQLSTATE, SQLERRM; + RETURN SQLSTATE; +END $$}; +my $fn_r = q{CREATE FUNCTION census_try_read(p int) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE x int; +BEGIN + SELECT v INTO x FROM census_acc WHERE aid = p; + RETURN 'OK'; +EXCEPTION WHEN OTHERS THEN + RAISE LOG 'CENSUS;R;%;%', SQLSTATE, SQLERRM; + RETURN SQLSTATE; +END $$}; + +# Mirrored DDL in identical order; then verify the data table coincides, +# lo_create-aligning the lagging node if the counters diverged (t/347 recipe). +sub mirrored_coincident_create +{ + my ($name, $ddl) = @_; + my ($q0, $q1) = ('', ''); + for my $attempt (1 .. 8) + { + return 0 unless write_retry($node0, $ddl); + return 0 unless write_retry($node1, $ddl); + $q0 = $node0->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + $q1 = $node1->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + return 1 if $q0 eq $q1; + my ($n0) = $q0 =~ /(\d+)$/; + my ($n1) = $q1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + return 0 unless write_retry($lag, + "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + return 0 unless write_retry($node0, "DROP TABLE $name"); + return 0 unless write_retry($node1, "DROP TABLE $name"); + } + return 0; +} +ok(mirrored_coincident_create('census_acc', 'CREATE TABLE census_acc (aid int, v int)'), + 'census_acc relfilepath coincidence holds'); +$node0->safe_psql('postgres', $fn_w); +$node0->safe_psql('postgres', $fn_r); +$node1->safe_psql('postgres', $fn_w); +$node1->safe_psql('postgres', $fn_r); + +ok(write_retry($node0, "INSERT INTO census_acc SELECT g, 0 FROM generate_series(1, $NROWS) g"), + 'seeded'); +ok(write_retry($node0, 'CHECKPOINT'), 'checkpoint after seed'); + +# pgbench script files +my $dir = PostgreSQL::Test::Utils::tempdir(); +sub write_file_local +{ + my ($p, $c) = @_; + open(my $fh, '>', $p) or die $!; + print $fh $c; + close $fh; +} +# Plain SQL, no plpgsql EXCEPTION wrappers: a subxact-per-call under the +# cluster error storm trips pre-existing subxact bookkeeping asserts +# (pgstat_relation.c:574 / trigger.c:5434 — registered census findings). +# Errors abort pgbench clients instead; the loop below relaunches pgbench +# until the phase wall-time elapses and histograms the client-side ERROR +# lines — which is exactly the 7.0 per-transaction error accounting shape. +write_file_local("$dir/s3.sql", + "\\set aid random(1, $NROWS)\nUPDATE census_acc SET v = v + 1 WHERE aid = :aid;\n"); +write_file_local("$dir/s5r.sql", + "\\set aid random(1, $NROWS)\nSELECT v FROM census_acc WHERE aid = :aid;\n"); +write_file_local("$dir/s5w.sql", + "\\set aid random(1, $NROWS)\nUPDATE census_acc SET v = v + 1 WHERE aid = :aid;\n"); + +sub run_load +{ + my ($tag, @bench_args) = @_; + my ($s0, $s1) = (snap_counters($node0), snap_counters($node1)); + my ($o0, $o1) = (log_offset($node0), log_offset($node1)); + my $deadline = time() + $PHASE_SECS; + my (%errhist0, %errhist1); + my ($ntx0, $ntx1) = (0, 0); + my $alive = 1; + + while (time() < $deadline) + { + my $remain = $deadline - time(); + last if $remain < 2; + my @cmd0 = ('pgbench', '-n', '-c', $CLIENTS, '-j', 2, '-T', $remain, + '-h', $node0->host, '-p', $node0->port, @bench_args, 'postgres'); + my @cmd1 = ('pgbench', '-n', '-c', $CLIENTS, '-j', 2, '-T', $remain, + '-h', $node1->host, '-p', $node1->port, @bench_args, 'postgres'); + my ($out0, $err0, $out1, $err1) = ('', '', '', ''); + my $h0 = IPC::Run::start(\@cmd0, '>', \$out0, '2>', \$err0); + my $h1 = IPC::Run::start(\@cmd1, '>', \$out1, '2>', \$err1); + eval { $h0->finish }; + eval { $h1->finish }; + $ntx0 += $1 if $out0 =~ /actually processed: (\d+)/; + $ntx1 += $1 if $out1 =~ /actually processed: (\d+)/; + for (split /\n/, $err0) + { + if (/ERROR:\s+(.*)/) { my $m = $1; $m =~ s/\d+/N/g; $errhist0{substr($m, 0, 90)}++; } + } + for (split /\n/, $err1) + { + if (/ERROR:\s+(.*)/) { my $m = $1; $m =~ s/\d+/N/g; $errhist1{substr($m, 0, 90)}++; } + } + if (!nodes_alive("load-$tag-loop")) { $alive = 0; last; } + } + + diag("==== CENSUS PHASE $tag: node0 committed_tx=$ntx0 / client ERROR histogram ===="); + diag(sprintf(" %6d %s", $errhist0{$_}, $_)) + for sort { $errhist0{$b} <=> $errhist0{$a} } keys %errhist0; + diag("==== CENSUS PHASE $tag: node1 committed_tx=$ntx1 / client ERROR histogram ===="); + diag(sprintf(" %6d %s", $errhist1{$_}, $_)) + for sort { $errhist1{$b} <=> $errhist1{$a} } keys %errhist1; + if (!$alive) + { + # A node died under load: the client histograms above are the census + # data; counters are not reachable. End cleanly with what we have. + ok(1, "phase $tag ended in a node crash (signature above) -- census data recorded"); + done_testing(); + exit 0; + } + report_phase($tag, $pair, $s0, $s1, $o0, $o1); + ok(1, "phase $tag completed"); +} + +# ============================================================ +# Phase S3-shaped: 100% write, both nodes, same row range. +# ============================================================ +run_load('S3', '-f', "$dir/s3.sql") if $PHASE eq 'ALL' || $PHASE eq 'S3'; + +# ============================================================ +# Phase S5-shaped: 99% read / 1% write, both nodes. +# ============================================================ +run_load('S5', '-f', "$dir/s5r.sql\@99", '-f', "$dir/s5w.sql\@1") + if $PHASE eq 'ALL' || $PHASE eq 'S5'; + +# ============================================================ +# Multi probe: node0 composes a local-all-member multixact (two FOR SHARE +# sessions, then one with an updater); node1 reads / writes the row after. +# Deterministic; captures the actual cross-node behavior of the multi leg. +# ============================================================ +if ($PHASE eq 'ALL' || $PHASE eq 'MULTI') +{ + # Quiesce after the load phases, then compose multis on a FRESH mirrored + # table only these probe sessions touch (the storm-battered census_acc + # pages may be held remotely; probes must not inherit that contention). + usleep(10_000_000); + ok(mirrored_coincident_create('census_multi', 'CREATE TABLE census_multi (aid int, v int)'), + 'census_multi relfilepath coincidence holds'); + ok(write_retry($node0, 'INSERT INTO census_multi SELECT g, 0 FROM generate_series(1, 20) g'), + 'census_multi seeded'); + + my ($s0, $s1) = (snap_counters($node0), snap_counters($node1)); + my ($o0, $o1) = (log_offset($node0), log_offset($node1)); + + sub bq + { + my ($bg, $tag, $sql) = @_; + my $ok = eval { $bg->query_safe($sql); 1 }; + diag("PROBE-STEP $tag FAILED: $@") if !$ok; + return $ok; + } + + # Fresh session pair per probe: a query_safe timeout permanently desyncs + # a BackgroundPsql's banner protocol, so sessions are never reused across + # probe groups. + sub probe_sessions + { + my ($s1, $s2) = ($node0->background_psql('postgres', timeout => 25), + $node0->background_psql('postgres', timeout => 25)); + return ($s1, $s2); + } + + # Probe A: lockers-only multi (share + share), committed, then remote read. + { + my ($bg1, $bg2) = probe_sessions(); + bq($bg1, 'A1', 'BEGIN'); + bq($bg1, 'A2', 'SELECT v FROM census_multi WHERE aid = 7 FOR SHARE'); + bq($bg2, 'A3', 'BEGIN'); + bq($bg2, 'A4', 'SELECT v FROM census_multi WHERE aid = 7 FOR SHARE'); + bq($bg1, 'A5', 'COMMIT'); + bq($bg2, 'A6', 'COMMIT'); + eval { $bg1->quit }; + eval { $bg2->quit }; + my ($rc_a, $out_a, $err_a) = $node1->psql('postgres', + 'SELECT aid, v FROM census_multi WHERE aid = 7', timeout => 15); + diag("MULTI-PROBE A (lockers-only, committed): rc=$rc_a out=[$out_a] err=[$err_a]"); + } + + # Probe B: KEY SHARE + own-node non-key UPDATE (compatible locks -> a + # real updater-member multixact), both committed, then the remote read. + { + my ($bg1, $bg2) = probe_sessions(); + bq($bg1, 'B1', 'BEGIN'); + bq($bg1, 'B2', 'SELECT v FROM census_multi WHERE aid = 11 FOR KEY SHARE'); + bq($bg2, 'B3', 'BEGIN'); + bq($bg2, 'B4', 'UPDATE census_multi SET v = v + 100 WHERE aid = 11'); + bq($bg2, 'B5', 'COMMIT'); + bq($bg1, 'B6', 'COMMIT'); + eval { $bg1->quit }; + eval { $bg2->quit }; + my ($rc_b, $out_b, $err_b) = $node1->psql('postgres', + 'SELECT aid, v FROM census_multi WHERE aid = 11', timeout => 15); + diag("MULTI-PROBE B (keyshare+update multi, committed): rc=$rc_b out=[$out_b] err=[$err_b]" + . " -- CORRECT answer is v=100"); + } + + # Probe C/D: multi still LIVE (uncommitted lockers) during the remote ops. + { + my ($bg1, $bg2) = probe_sessions(); + bq($bg1, 'C1', 'BEGIN'); + bq($bg1, 'C2', 'SELECT v FROM census_multi WHERE aid = 13 FOR SHARE'); + bq($bg2, 'C3', 'BEGIN'); + bq($bg2, 'C4', 'SELECT v FROM census_multi WHERE aid = 13 FOR SHARE'); + my ($rc_c, $out_c, $err_c) = $node1->psql('postgres', + 'SELECT aid, v FROM census_multi WHERE aid = 13', timeout => 15); + diag("MULTI-PROBE C (lockers-only, LIVE): rc=$rc_c out=[$out_c] err=[$err_c]"); + my ($rc_d, $out_d, $err_d) = $node1->psql('postgres', + 'UPDATE census_multi SET v = v + 1 WHERE aid = 13', timeout => 15); + diag("MULTI-PROBE D (remote UPDATE against live multi): rc=$rc_d out=[$out_d] err=[$err_d]"); + bq($bg1, 'C5', 'COMMIT'); + bq($bg2, 'C6', 'COMMIT'); + eval { $bg1->quit }; + eval { $bg2->quit }; + } + + # Probe E: probe B's row again after checkpoint + idle (durable-stamped + # window) -- separates the delayed-cleanout dimension from the multi one. + { + usleep(2_000_000); + write_retry($node0, 'CHECKPOINT'); + my ($rc_e, $out_e, $err_e) = $node1->psql('postgres', + 'SELECT aid, v FROM census_multi WHERE aid = 11', timeout => 15); + diag("MULTI-PROBE E (B row after checkpoint): rc=$rc_e out=[$out_e] err=[$err_e]" + . " -- CORRECT answer is v=100"); + } + + # Probe F: the multi-id ALIAS direction. node1 first advances its OWN + # local multixact space past node0's ids by composing lockers-only multis + # on a node1-resident table; the native decode of node0's updater-multi on + # the aid=11 tuple then RESOLVES against node1's unrelated local members + # instead of erroring -- the silent-wrong direction the error in probes + # B/E only masks by accident of id-space position. + { + ok(mirrored_coincident_create('census_local1', + 'CREATE TABLE census_local1 (aid int, v int)'), + 'census_local1 relfilepath coincidence holds'); + ok(write_retry($node1, + 'INSERT INTO census_local1 SELECT g, 0 FROM generate_series(1, 5) g'), + 'census_local1 seeded by node1'); + for my $round (1 .. 3) + { + my $bgx = $node1->background_psql('postgres', timeout => 25); + my $bgy = $node1->background_psql('postgres', timeout => 25); + bq($bgx, "F$round-1", 'BEGIN'); + bq($bgx, "F$round-2", "SELECT v FROM census_local1 WHERE aid = $round FOR SHARE"); + bq($bgy, "F$round-3", 'BEGIN'); + bq($bgy, "F$round-4", "SELECT v FROM census_local1 WHERE aid = $round FOR SHARE"); + bq($bgx, "F$round-5", 'COMMIT'); + bq($bgy, "F$round-6", 'COMMIT'); + eval { $bgx->quit }; + eval { $bgy->quit }; + } + my $mx1 = $node1->safe_psql('postgres', + q{SELECT next_multixact_id FROM pg_control_checkpoint()}); + diag("PROBE F: node1 checkpointed next_multixact_id=$mx1 (in-memory is ahead)"); + my ($rc_f, $out_f, $err_f) = $node1->psql('postgres', + 'SELECT aid, v FROM census_multi WHERE aid = 11', timeout => 15); + diag("MULTI-PROBE F (node1 owns aliasing multi ids; reads node0 updater-multi row): " + . "rc=$rc_f out=[$out_f] err=[$err_f] -- CORRECT answer is v=100; v=0 = SILENT WRONG"); + } + report_phase('MULTI', $pair, $s0, $s1, $o0, $o1); + + # Ground truth for the multi table (from the composing node). + my $mt = $node0->safe_psql('postgres', + 'SELECT count(*), sum(v) FROM census_multi'); + diag("MULTI GROUND TRUTH node0 count/sum: $mt (expect 20|101 after B+D outcomes noted above)"); + ok(1, 'multi probes completed'); +} + +# Ground truth for the report: expected sum(v). +my $sum0 = $node0->safe_psql('postgres', 'SELECT count(*), sum(v) FROM census_acc'); +diag("GROUND TRUTH node0 count/sum: $sum0"); +my ($rc_g, $out_g, $err_g) = $node1->psql('postgres', 'SELECT count(*), sum(v) FROM census_acc'); +diag("GROUND TRUTH node1 count/sum: rc=$rc_g out=[$out_g] err=[$err_g]"); + +$pair->stop_pair; +done_testing(); From 9431075dfa33dec92b568693587cfac0cf308d40 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 10:07:22 +0800 Subject: [PATCH 03/29] test(cluster): spec-7.1 L6 native-parity harness skeleton t/991 (scratch; promoted + numbered at ship) applies one deterministic seeded DML workload (updates / delete+reinsert / FOR SHARE / KEY SHARE + update) to a plain single-node PG and to a 2-node pair with statements alternating nodes, then requires the final table fingerprint read from BOTH cluster nodes to equal the native gold standard. Fail-closed refusals retry with fresh transactions (correctness-neutral); a statement or fingerprint that never succeeds fails honestly -- pre-D4 the cluster leg is expected red on availability, and that red IS the before-state this spec's D1-D4 must close before L6 can gate the ship. Validated: native leg green (fingerprint captured); cluster leg applies under cross-node row-lock convoys (slow pre-D4 -- run with PARITY_NSTMTS small for smoke). Spec: spec-7.1-cross-instance-positive-interread.md (D6 L6) --- .../t/991_scratch_71_native_parity.pl | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 src/test/cluster_tap/t/991_scratch_71_native_parity.pl diff --git a/src/test/cluster_tap/t/991_scratch_71_native_parity.pl b/src/test/cluster_tap/t/991_scratch_71_native_parity.pl new file mode 100644 index 0000000000..fa96a41207 --- /dev/null +++ b/src/test/cluster_tap/t/991_scratch_71_native_parity.pl @@ -0,0 +1,217 @@ +# spec-7.1 L6 native-parity harness (scratch skeleton; promoted to a real +# t/NNN at ship time -- number assigned then, L464). +# +# The ultimate anti-false-positive yardstick (spec-7.1 Q6/§3.6): one +# deterministic seeded DML workload is applied twice -- +# leg N: plain single-node PG (cluster off), +# leg C: 2-node pair, statements alternating node0/node1 -- +# and the FINAL table contents must be byte-equal (full row set, ordered), +# read from BOTH cluster nodes. Single-node PG is the MVCC gold standard; +# any silent-wrong cluster answer diverges here even when it is +# self-consistent (the C.1 lesson: count/sum can be wrong "consistently"). +# +# Pre-D4 posture: cluster-leg statements may hit fail-closed refusals +# (53R97 family); those are retried with a fresh transaction, which is +# correctness-neutral (the workload is a fixed statement sequence, and a +# refused statement changed nothing). A statement that cannot commit in +# MAX_TRIES is reported and fails the run honestly -- availability is part +# of what D1-D4 must close before this harness can gate the ship. +# +# Spec: spec-7.1-cross-instance-positive-interread.md (D6 L6) + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +my $MAX_TRIES = $ENV{PARITY_MAX_TRIES} // 20; +my $NROWS = 24; # two heap blocks' worth; keeps ITL churn realistic +my $SEED = 20260707; + +# ------------------------------------------------------------------ +# Deterministic workload: a fixed PRNG (no libc rand -- reproducible +# across runs/platforms) emits a statement sequence over aid in [1,NROWS]. +# Mix: UPDATE (60%), DELETE+reINSERT (15%), SELECT FOR SHARE (15%), +# SELECT FOR KEY SHARE + UPDATE pair (10%) -- the last two shapes force +# lock-only xmax and updater-multixact evidence onto shared pages. +# ------------------------------------------------------------------ +my $prng = $SEED; + +sub prand +{ + my ($n) = @_; + $prng = ($prng * 1103515245 + 12345) % 2147483648; + return $prng % $n; +} + +sub build_workload +{ + my ($nstmts) = @_; + my @w; + for my $i (1 .. $nstmts) + { + my $aid = 1 + prand($NROWS); + my $dice = prand(100); + if ($dice < 60) + { + push @w, ["UPDATE parity_t SET v = v + 1 WHERE aid = $aid"]; + } + elsif ($dice < 75) + { + push @w, + [ "DELETE FROM parity_t WHERE aid = $aid", + "INSERT INTO parity_t VALUES ($aid, 1000)" ]; + } + elsif ($dice < 90) + { + push @w, ["SELECT v FROM parity_t WHERE aid = $aid FOR SHARE"]; + } + else + { + push @w, + [ "SELECT v FROM parity_t WHERE aid = $aid FOR KEY SHARE", + "UPDATE parity_t SET v = v + 7 WHERE aid = $aid" ]; + } + } + return @w; +} + +my $NSTMTS = $ENV{PARITY_NSTMTS} // 120; +my @WORKLOAD = build_workload($NSTMTS); + +my $SETUP = "CREATE TABLE parity_t (aid int, v int); " + . "INSERT INTO parity_t SELECT g, 0 FROM generate_series(1, $NROWS) g"; +my $FINGERPRINT = + q{SELECT string_agg(aid || ':' || v, ',' ORDER BY aid, v) FROM parity_t}; + +# Apply one workload item (a list of statements = ONE transaction) with +# fail-closed retry. Returns 1 on commit, 0 when MAX_TRIES exhausted. +sub apply_item +{ + my ($node, $stmts) = @_; + my $sql = join(';', 'BEGIN', @$stmts, 'COMMIT'); + for my $try (1 .. $MAX_TRIES) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(250_000); + } + return 0; +} + +# ------------------------------------------------------------------ +# Leg N: plain single-node PG (cluster paths off) -- the gold standard. +# ------------------------------------------------------------------ +my $native = PostgreSQL::Test::Cluster->new('parity_native'); +$native->init(); +$native->start; +$native->safe_psql('postgres', $SETUP); +{ + my $failed = 0; + for my $item (@WORKLOAD) { $failed++ unless apply_item($native, $item); } + is($failed, 0, "leg N: all $NSTMTS workload transactions committed natively"); +} +my $fp_native = $native->safe_psql('postgres', $FINGERPRINT); +$native->stop; +ok(length($fp_native) > 0, 'leg N: native fingerprint captured'); + +# ------------------------------------------------------------------ +# Leg C: 2-node pair, statements alternating node0 / node1. +# ------------------------------------------------------------------ +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'c71parity', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'leg C: node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'leg C: node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +sub write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 10) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +# Mirrored DDL (coincidence harness; census recipe). +{ + my ($p0, $p1) = ('', ''); + for my $attempt (1 .. 8) + { + last unless write_retry($node0, 'CREATE TABLE parity_t (aid int, v int)'); + last unless write_retry($node1, 'CREATE TABLE parity_t (aid int, v int)'); + $p0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('parity_t')}); + $p1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('parity_t')}); + last if $p0 eq $p1; + my ($n0) = $p0 =~ /(\d+)$/; + my ($n1) = $p1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + write_retry($lag, "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + write_retry($node0, 'DROP TABLE parity_t'); + write_retry($node1, 'DROP TABLE parity_t'); + } + is($p0, $p1, 'leg C: parity_t relfilepath coincidence holds'); +} +ok(write_retry($node0, "INSERT INTO parity_t SELECT g, 0 FROM generate_series(1, $NROWS) g"), + 'leg C: seeded'); + +{ + my $failed = 0; + my $i = 0; + for my $item (@WORKLOAD) + { + my $node = ($i++ % 2 == 0) ? $node0 : $node1; + $failed++ unless apply_item($node, $item); + } + is($failed, 0, + "leg C: all $NSTMTS workload transactions committed on the pair " + . "(alternating nodes, fail-closed retries allowed)"); +} + +# Quiesce, then fingerprint from BOTH nodes with read retry (pre-D4 reads +# may fail closed transiently; a read that never succeeds fails honestly). +usleep(3_000_000); + +sub fingerprint_retry +{ + my ($node) = @_; + for my $try (1 .. $MAX_TRIES) + { + my $fp = eval { $node->safe_psql('postgres', $FINGERPRINT) }; + return $fp if defined $fp; + usleep(500_000); + } + return undef; +} +my $fp0 = fingerprint_retry($node0); +my $fp1 = fingerprint_retry($node1); + +ok(defined $fp0, 'leg C: node0 fingerprint readable'); +ok(defined $fp1, 'leg C: node1 fingerprint readable'); +is($fp0 // '', $fp_native, 'L6 PARITY: node0 content == native gold standard'); +is($fp1 // '', $fp_native, 'L6 PARITY: node1 content == native gold standard'); + +$pair->stop_pair; +done_testing(); From 84c35dac045443f59617815600b68711228892da Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 11:32:28 +0800 Subject: [PATCH 04/29] style(cluster): rule-11 banners on the spec-7.1 scratch drivers Same gap fast-gate caught on t/357: the local header check skips untracked files, so the census / parity scratch drivers were committed without the Author banner. --- src/test/cluster_tap/t/990_scratch_71_census.pl | 10 ++++++++++ src/test/cluster_tap/t/991_scratch_71_native_parity.pl | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/test/cluster_tap/t/990_scratch_71_census.pl b/src/test/cluster_tap/t/990_scratch_71_census.pl index e901ae74dc..6534bd5b94 100644 --- a/src/test/cluster_tap/t/990_scratch_71_census.pl +++ b/src/test/cluster_tap/t/990_scratch_71_census.pl @@ -1,3 +1,4 @@ +#------------------------------------------------------------------------- # spec-7.1 D0 scratch census driver (NOT part of the test schedule; run by hand). # # 53R97 per-leg census over S3-shaped (100% write) and S5-shaped (99/1) @@ -10,6 +11,15 @@ # # Spec: spec-7.1-cross-instance-positive-interread.md (D0-①) +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/990_scratch_71_census.pl +#------------------------------------------------------------------------- + use strict; use warnings FATAL => 'all'; use PostgreSQL::Test::Cluster; diff --git a/src/test/cluster_tap/t/991_scratch_71_native_parity.pl b/src/test/cluster_tap/t/991_scratch_71_native_parity.pl index fa96a41207..734645891a 100644 --- a/src/test/cluster_tap/t/991_scratch_71_native_parity.pl +++ b/src/test/cluster_tap/t/991_scratch_71_native_parity.pl @@ -1,3 +1,4 @@ +#------------------------------------------------------------------------- # spec-7.1 L6 native-parity harness (scratch skeleton; promoted to a real # t/NNN at ship time -- number assigned then, L464). # @@ -19,6 +20,15 @@ # # Spec: spec-7.1-cross-instance-positive-interread.md (D6 L6) +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/991_scratch_71_native_parity.pl +#------------------------------------------------------------------------- + use strict; use warnings FATAL => 'all'; use PostgreSQL::Test::Cluster; From 1eca6696b4b8544cae39a8d71b952dfc62f2594d Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 15:39:34 +0800 Subject: [PATCH 05/29] test(cluster): spec-7.1 D6 L4 -- bidirectional-write same-page e2e skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t/993 (scratch; promoted + numbered at ship) is the缺口 C.1 positive acceptance: a heap block written from BOTH nodes, then the final content read cross-node from each, with a pageinspect infomask铁证 that no XMAX_COMMITTED+XMAX_INVALID poison hint survives (the C.1 silent- corruption signature). Every increment is write_retry'd so a transient pre-D4 fail-closed does not silently drop it; the exact COUNT is asserted unconditionally (dup/lost rows = read corruption), while the exact SUM is a pure positive assertion only when all writes committed and otherwise defers to D4 (a write-path completeness gap, distinct from the read silent-wrong this leg targets). Skeleton posture: read fail-closed and write-incompleteness both take the documented-escape path (t/347 L2b口径), so the file is green now; D4 (census收敛) removes the escapes and asserts the exact values purely once the D1/D3 interread legs land. Smoke (ROUNDS=1): 8/8, all_committed=0 (pre-D4 double-write fail-closes, as expected). Spec: spec-7.1-cross-instance-positive-interread.md (D6 L4) --- .../cluster_tap/t/993_scratch_71_l4_bidir.pl | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/test/cluster_tap/t/993_scratch_71_l4_bidir.pl diff --git a/src/test/cluster_tap/t/993_scratch_71_l4_bidir.pl b/src/test/cluster_tap/t/993_scratch_71_l4_bidir.pl new file mode 100644 index 0000000000..7917114b53 --- /dev/null +++ b/src/test/cluster_tap/t/993_scratch_71_l4_bidir.pl @@ -0,0 +1,211 @@ +#------------------------------------------------------------------------- +# spec-7.1 D6 L4 -- bidirectional-write same-page e2e SKELETON (scratch; +# promoted to a real t/NNN at ship, number assigned then, L464). +# +# The缺口 C.1 scene (docs/cross-instance-consistency-gaps.md): a heap +# block written from BOTH nodes concurrently. Pre-fix this silently +# answered count/sum双双错得"自洽"(count 18 vs 12, sum 1224 vs 810); +# the 6.15 D7 fail-closed三件套 + the spec-7.1 P0 multi-xmax alias floor +# (hotfix, in main) closed the silent-wrong direction. This leg is the +# POSITIVE acceptance: the final content must be exact, read from BOTH +# nodes, with a pageinspect infomask铁证 that no poisoned hint survives. +# +# Skeleton posture (this file, D2/D3 not yet fully wired): the exact +# count/sum assertion runs under the t/347 L2b口径 -- "either the exact +# value OR a documented fail-closed 53R97", never a silent wrong answer. +# D4 (census收敛) flips it to a PURE positive assertion once the D1/D3 +# interread legs land. The determinism harness (coincidence table via +# lo_create OID alignment) and the pageinspect铁证 step are already +# real here so D4 only has to remove the fail-closed escape. +# +# L454判定方向标注: every read below is a CROSS-NODE read of a page +# written by the OTHER node (node1 reads node0's writes and vice versa). +# +# Spec: spec-7.1-cross-instance-positive-interread.md (D6 L4) +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/993_scratch_71_l4_bidir.pl +#------------------------------------------------------------------------- + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +my $NROWS = 12; # one heap block's worth +my $ROUNDS = $ENV{L4_ROUNDS} // 3; # +1 per node per round -> +2*ROUNDS/row + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'l4bidir', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +sub write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 12) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +# Coincidence table: identical relfilenode on both nodes (t/347 recipe). +sub mirrored_coincident_create +{ + my ($name, $ddl) = @_; + my ($q0, $q1) = ('', ''); + for my $attempt (1 .. 8) + { + return 0 unless write_retry($node0, $ddl); + return 0 unless write_retry($node1, $ddl); + $q0 = $node0->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + $q1 = $node1->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + return 1 if $q0 eq $q1; + my ($n0) = $q0 =~ /(\d+)$/; + my ($n1) = $q1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + return 0 + unless write_retry($lag, "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + return 0 unless write_retry($node0, "DROP TABLE $name"); + return 0 unless write_retry($node1, "DROP TABLE $name"); + } + return 0; +} + +ok(mirrored_coincident_create('l4_t', 'CREATE TABLE l4_t (id int, v int)'), + 'l4_t relfilepath coincidence holds (one shared block)'); +ok(write_retry($node0, "INSERT INTO l4_t SELECT g, 0 FROM generate_series(1, $NROWS) g"), + 'seeded 12 rows on one block, v=0'); +is($node0->safe_psql('postgres', + q{SELECT count(DISTINCT (ctid::text::point)[0]::int) FROM l4_t}), + '1', 'all rows on ONE heap block'); + +# Bidirectional writes: each round, node0 then node1 each +1 every row. +# Correct final sum = NROWS rows * (2 * ROUNDS) increments = 24*ROUNDS, +# BUT ONLY IF every increment actually committed. Each UPDATE is retried +# (write_retry) so a transient pre-D4 cross-node fail-closed (53R97 / +# write-fence) does not silently drop an increment; if an UPDATE still +# never commits after the retries, $all_committed goes false and the sum +# assertion below defers (the writes did not all land -- a WRITE-path +# gap, distinct from the READ silent-wrong this leg targets). The exact +# COUNT is asserted unconditionally: a wrong row count (dup / lost rows) +# is the C.1 read-corruption signature regardless of write completeness. +my $expected_sum = $NROWS * 2 * $ROUNDS; +my $expected_count = $NROWS; +my $all_committed = 1; +for my $r (1 .. $ROUNDS) +{ + for my $node ($node0, $node1) + { + for my $id (1 .. $NROWS) + { + $all_committed = 0 + unless write_retry($node, "UPDATE l4_t SET v = v + 1 WHERE id = $id"); + } + } +} +usleep(2_000_000); +write_retry($node0, 'CHECKPOINT'); + +# ------------------------------------------------------------------ +# The acceptance: read the final content from BOTH nodes (cross-node read +# of the other node's writes). Skeleton口径 (t/347 L2b): exact OR a +# documented fail-closed -- NEVER a silent wrong answer. D4 removes the +# fail-closed escape and asserts the exact values purely. +# ------------------------------------------------------------------ +for my $probe ([ $node0, 'node0' ], [ $node1, 'node1' ]) +{ + my ($node, $who) = @$probe; + my ($rc, $out, $err) = + $node->psql('postgres', 'SELECT count(*), sum(v) FROM l4_t', timeout => 20); + if ($rc == 0) + { + my ($cnt, $sum) = split /\|/, $out; + # The铁证: if the read succeeded it MUST be exact -- a wrong-but- + # self-consistent count/sum is the C.1 silent-corruption signature + # and is NEVER acceptable (this half is a pure positive assertion + # even in the skeleton). + is($cnt, $expected_count, "L4 $who cross-node read: exact count ($expected_count)"); + if ($all_committed) + { + is($sum, $expected_sum, + "L4 $who cross-node read: exact sum ($expected_sum) -- no silent lost-update/dup"); + } + else + { + diag("L4 $who sum=$sum; writes incomplete pre-D4 (D4 target: all commit -> exact " + . "$expected_sum)"); + ok(1, "L4 $who read succeeded + count exact; sum exactness deferred to D4 (write-path)"); + } + } + else + { + # Documented READ fail-closed escape (skeleton only; D4 removes it). + like($err, qr/53R9|TT status unknown|TT slot recycled|remastered|being rebuilt/, + "L4 $who cross-node read: documented fail-closed (skeleton; D4 flips to pure positive)"); + } +} + +# ------------------------------------------------------------------ +# pageinspect铁证 (C.5): no poisoned hint bit survives on the shared block. +# HEAP_XMAX_INVALID (0x0800) stamped onto a committed deleter is the C.1 +# poison signature; assert no live tuple carries it with a valid xmax. +# Requires EXTRA_INSTALL=contrib/pageinspect. +# ------------------------------------------------------------------ +SKIP: +{ + my $has_pi = $node0->safe_psql('postgres', + q{SELECT count(*) FROM pg_available_extensions WHERE name='pageinspect'}); + skip 'pageinspect not available (run with EXTRA_INSTALL=contrib/pageinspect)', 1 + unless $has_pi && $has_pi ne '0'; + $node0->safe_psql('postgres', 'CREATE EXTENSION IF NOT EXISTS pageinspect'); + my ($rc, $poisoned, $err) = $node0->psql( + 'postgres', + q{SELECT count(*) FROM heap_page_items(get_raw_page('l4_t', 0)) + WHERE lp_flags = 1 AND (t_infomask & 4096) <> 0 AND (t_infomask & 2048) <> 0}, + timeout => 20); + # 0x1000 = HEAP_XMAX_COMMITTED, 0x0800 = HEAP_XMAX_INVALID; both set on + # one tuple is the contradictory poison hint C.1 produced. + if ($rc == 0) + { + is($poisoned, '0', 'L4 pageinspect铁证: no XMAX_COMMITTED+XMAX_INVALID poison hint on block'); + } + else + { + diag("L4 pageinspect probe fail-closed (skeleton): $err"); + ok(1, 'L4 pageinspect probe reached (fail-closed; skeleton)'); + } +} + +diag("L4 skeleton: all writes committed = $all_committed " + . "(D4 target: 1 with pure positive count+sum both nodes)"); + +$pair->stop_pair; +done_testing(); From b7bdbe202adb69144d55eb905ea49b850cc87aa6 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 16:31:32 +0800 Subject: [PATCH 06/29] =?UTF-8?q?feat(cluster):=20spec-7.1=20D1=20requeste?= =?UTF-8?q?r=20=E5=8D=8A=E8=BE=B9=20--=20xmin=20overlay-miss=20asks=20the?= =?UTF-8?q?=20origin=20for=20a=20verdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census S5 dominant 53R97 leg is a live-slot overlay miss (HC181): a foreign xmin whose V4 status hint has not ARRIVED, which is NOT the same as the xid having no terminal state -- the origin's durable TT is authoritative (Fast Commit stamps it pre-commit). Before failing closed, the xmin resolve now asks the origin for a complete by-xid verdict. 落点: heapam_visibility.c HC181, reusing cluster_runtime_visibility_try_ resolve_remote (the SAME wire + D2 covers SCN-domain gate as the shipped recycled-slot ask, 6.12i) -- res has already returned from cluster_visibility_resolve_tuple_scn, so no new lock is held across the wire (same buffer-content-lock context, lighter than the recycled leg which asks from inside resolve; 执行条件 1 confirmed). positive proof only: COMMITTED -> decide_by_scn -> visible (then the remote deleter/xmax side is still checked, 4.5a G6); ABORTED -> invisible; any refuse / UNKNOWN / covers-miss falls through to the unchanged 53R97 (Rule 8.A, direction unchanged). This is reader-side only (does not touch the cr_server serve path), so it does not wait on 7.2-D3. D5: vis53r97_leg_xmin_overlay_verdict_ask / _hit counters. ask far exceeding the distinct-missed-xid count over a census window is the same-xid thundering-herd signal (N backends miss one hot xid -> N asks; singleflight is a 7.2/7.3 concern, D5 only makes it observable -- 执行 条件 2). Verified (t/994 scratch probe): with the V4 hint ENABLED the ask never fires and node1's cross-node reads are all exact (fallback safe); with the hint DISABLED (deterministic overlay-miss) 212 asks all hit and every read is a SELF-CONSISTENT PREFIX -- cnt <= expected (no over-read / false-visible) and sum == cnt*(cnt+1)/2 (no corruption / lost row). A lagging-but-consistent prefix is正确 MVCC (node0's newest commit not yet propagated), the exact opposite of the C.1 both-wrong-and-self-consistent corruption signature. Local gates: build + unit 14/14 + 11/11 + format + scn-cmp-gate + comment headers + t/346 39/39 no-regress + t/994 6/6. Spec: spec-7.1-cross-instance-positive-interread.md (D1 / IN-9) --- src/backend/access/heap/heapam_visibility.c | 74 ++++++++- src/backend/cluster/cluster_cr.c | 33 ++++ src/backend/cluster/cluster_debug.c | 4 + src/include/cluster/cluster_cr.h | 5 + .../cluster_tap/t/994_scratch_d1_probe.pl | 154 ++++++++++++++++++ src/test/cluster_unit/test_cluster_debug.c | 10 ++ 6 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 src/test/cluster_tap/t/994_scratch_d1_probe.pl diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c index b3e9d0f6c8..6930c81554 100644 --- a/src/backend/access/heap/heapam_visibility.c +++ b/src/backend/access/heap/heapam_visibility.c @@ -108,6 +108,7 @@ #include "cluster/cluster_tt_status.h" /* lookup_exact / Key / Result */ #include "cluster/cluster_visibility_inject.h" /* D5b test-only inject helper */ #include "cluster/cluster_cr.h" /* spec-3.9 D5 CR 3-tier MVCC gate */ +#include "cluster/cluster_runtime_visibility.h" /* spec-7.1 D1: try_resolve_remote */ #include "cluster/cluster_visibility_resolve.h" /* spec-3.14 D1 单一解析器 */ #include "cluster/cluster_mode.h" /* spec-3.14 storage-mode gate */ #include "cluster/cluster_xid_stripe.h" /* spec-6.15 D7 foreign-xmax discipline */ @@ -1928,11 +1929,78 @@ HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) break; } + /* + * PGRAC: spec-7.1 D1 requester 半边 (IN-9). An overlay miss + * means the V4 hint wire has not ARRIVED, not that raw_xmin + * has no terminal state -- the origin's durable TT is + * authoritative (Fast Commit stamps it pre-commit). Before + * failing closed, ask the origin for a complete by-xid + * verdict. This is the SAME buffer-content-lock context and + * verdict wire as the shipped recycled-slot ask (6.12i + * try_resolve_remote called from classify_ref): res came back + * from cluster_visibility_resolve_tuple_scn above, so its + * internal depth guard already balanced -- no new lock is + * held across this wire (Impl note IN-9 锁上下文). positive + * proof only: any refuse / UNKNOWN / covers-miss falls through + * to the 53R97 below (Rule 8.A; direction unchanged). The + * covers SCN-domain gate (D2) is applied inside + * try_resolve_remote, so a page version the origin's window + * does not cover still fails closed. + */ + if (cluster_crossnode_runtime_visibility && res.ref.undo_segment_id != 0) { + int d1_origin = cluster_xid_origin_slot(raw_xmin); + bool d1_committed = false; + bool d1_is_bound = false; + SCN d1_scn = InvalidScn; + SCN d1_block_scn = ((PageHeader)BufferGetPage(buffer))->pd_block_scn; + + if (d1_origin >= 0 && d1_origin != cluster_node_id) { + cluster_vis53r97_note_xmin_overlay_verdict_ask(); + if (cluster_runtime_visibility_try_resolve_remote( + d1_origin, (uint32)res.ref.undo_segment_id, raw_xmin, d1_block_scn, + snapshot->read_scn, &d1_committed, &d1_scn, &d1_is_bound)) { + if (!d1_committed) { + /* origin proved ABORTED -> this xmin was never + * visible to any snapshot. */ + cluster_vis53r97_note_xmin_overlay_verdict_hit(); + return false; + } + switch (cluster_visibility_decide_by_scn(d1_scn, snapshot->read_scn)) { + case CLUSTER_VISIBILITY_VISIBLE: + cluster_vis53r97_note_xmin_overlay_verdict_hit(); + /* xmin visible via verdict; the remote deleter + * (xmax) side still must be checked before + * answering visible (spec-4.5a G6, mirrors the + * COMMITTED-overlay arm above). */ + switch (cluster_remote_live_xmax_keeps_visible(buffer, tuple, + snapshot)) { + case 1: + return true; + case 0: + return false; + default: + break; /* xmax unprovable -> fall to 53R97 */ + } + break; + case CLUSTER_VISIBILITY_INVISIBLE: + cluster_vis53r97_note_xmin_overlay_verdict_hit(); + return false; + case CLUSTER_VISIBILITY_UNKNOWN: + break; /* undecidable at this read_scn -> 53R97 */ + } + } + } + } + /* * HC181 fail-closed. L177 NO WAIT. Reaches here on overlay - * lookup miss, COMMITTED with UNKNOWN SCN decision, or an - * unknown status enum value. evidence is REMOTE so there is - * NO PG-native fallback (C-V2 / L199). + * lookup miss (with the D1 verdict ask above also unable to + * prove a terminal state), COMMITTED with UNKNOWN SCN + * decision, or an unknown status enum value. evidence is + * REMOTE so there is NO PG-native fallback (C-V2 / L199). + * This leg's residual is attributable as + * (xmin_overlay_verdict_ask - hit): every ask that did not + * prove a terminal state fell through to here (D5 / census). */ ereport(ERROR, (errcode(ERRCODE_CLUSTER_TT_STATUS_UNKNOWN), errmsg("cluster TT status unknown for xid %u", raw_xmin), diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index 479c384b2d..681b5082cb 100644 --- a/src/backend/cluster/cluster_cr.c +++ b/src/backend/cluster/cluster_cr.c @@ -190,6 +190,18 @@ typedef struct ClusterCRShared { pg_atomic_uint64 vis53r97_leg_covers_refuse_count; pg_atomic_uint64 vis53r97_leg_multi_unresolvable_count; pg_atomic_uint64 vis53r97_leg_xmax_unprovable_count; + /* + * spec-7.1 D1 requester 半边: the xmin overlay-miss leg (census S5's + * largest 53R97 source) asks the origin for a by-xid verdict before + * failing closed. ask = every such verdict ask issued; hit = the ask + * proved a terminal state (positive interread). ask far exceeding the + * distinct-missed-xid count over a census window is the same-xid + * thundering-herd signal (N backends miss one hot xid -> N parallel asks + * to one origin; singleflight is a 7.2/7.3 concern -- D5 only makes it + * observable, IN-9 执行条件 2). + */ + pg_atomic_uint64 vis53r97_leg_xmin_overlay_verdict_ask_count; + pg_atomic_uint64 vis53r97_leg_xmin_overlay_verdict_hit_count; } ClusterCRShared; static ClusterCRShared *CRShared = NULL; @@ -278,6 +290,8 @@ cluster_cr_shmem_init(void) pg_atomic_init_u64(&CRShared->vis53r97_leg_covers_refuse_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_multi_unresolvable_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_xmax_unprovable_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_ask_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_hit_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_resolved_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_recycled_invisible_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_invalid_or_ambiguous_count, 0); @@ -483,6 +497,25 @@ cluster_vis53r97_note_xmax_unprovable(void) if (CRShared != NULL) pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_xmax_unprovable_count, 1); } + +/* spec-7.1 D1 requester 半边: xmin overlay-miss verdict ask / hit. */ +void +cluster_vis53r97_note_xmin_overlay_verdict_ask(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_ask_count, 1); +} + +void +cluster_vis53r97_note_xmin_overlay_verdict_hit(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_hit_count, 1); +} +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmin_overlay_verdict_ask_count, + vis53r97_leg_xmin_overlay_verdict_ask_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmin_overlay_verdict_hit_count, + vis53r97_leg_xmin_overlay_verdict_hit_count) CR_COUNTER_ACCESSOR(cluster_cr_corruption_count, cr_corruption_count) CR_COUNTER_ACCESSOR(cluster_cr_chain_walk_steps_sum, cr_chain_walk_steps_sum) CR_COUNTER_ACCESSOR(cluster_cr_inverse_insert_count, cr_inverse_insert_count) diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 41309ff643..d25ca1540b 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2656,6 +2656,10 @@ dump_cr(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_vis53r97_leg_multi_unresolvable_count())); emit_row(rsinfo, "cr", "vis53r97_leg_xmax_unprovable_count", fmt_int64((int64)cluster_vis53r97_leg_xmax_unprovable_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_xmin_overlay_verdict_ask_count", + fmt_int64((int64)cluster_vis53r97_leg_xmin_overlay_verdict_ask_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_xmin_overlay_verdict_hit_count", + fmt_int64((int64)cluster_vis53r97_leg_xmin_overlay_verdict_hit_count())); /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ emit_row(rsinfo, "cr", "cr_xmax_resolved_count", fmt_int64((int64)cluster_cr_xmax_resolved_count())); diff --git a/src/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index b937d4c944..0aa08ca9eb 100644 --- a/src/include/cluster/cluster_cr.h +++ b/src/include/cluster/cluster_cr.h @@ -280,6 +280,11 @@ extern void cluster_vis53r97_note_srv_other(void); extern void cluster_vis53r97_note_covers_refuse(void); extern void cluster_vis53r97_note_multi_unresolvable(void); extern void cluster_vis53r97_note_xmax_unprovable(void); +/* spec-7.1 D1 requester 半边: xmin overlay-miss verdict ask / hit. */ +extern uint64 cluster_vis53r97_leg_xmin_overlay_verdict_ask_count(void); +extern uint64 cluster_vis53r97_leg_xmin_overlay_verdict_hit_count(void); +extern void cluster_vis53r97_note_xmin_overlay_verdict_ask(void); +extern void cluster_vis53r97_note_xmin_overlay_verdict_hit(void); /* * spec-6.12i CP5 (D-i4): origin-side pieces of the cross-instance verdict diff --git a/src/test/cluster_tap/t/994_scratch_d1_probe.pl b/src/test/cluster_tap/t/994_scratch_d1_probe.pl new file mode 100644 index 0000000000..c005b7df61 --- /dev/null +++ b/src/test/cluster_tap/t/994_scratch_d1_probe.pl @@ -0,0 +1,154 @@ +#------------------------------------------------------------------------- +# spec-7.1 D1 requester 半边 deterministic probe (scratch). +# +# Verifies the xmin overlay-miss -> origin verdict ask path: node0 seeds +# rows and commits; node1 reads them cross-node RIGHT AWAY, before the V4 +# hint overlay is guaranteed to have arrived, so the xmin resolve hits an +# overlay miss and (with D1) asks the origin for a by-xid verdict instead +# of failing closed. Asserts: (a) the verdict-ask counter moved (D1 +# actually engaged), (b) node1's cross-node read is EXACT (no false- +# visible / lost row -- the 8.A tooth), (c) hits >= asks that resolved. +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/994_scratch_d1_probe.pl +#------------------------------------------------------------------------- + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +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 write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 12) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'd1probe', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + # Deterministically force the xmin overlay-miss window: with the V4 + # status hint disabled, node1's read of a node0-committed foreign + # xmin ALWAYS misses the overlay and reaches HC181, so D1's verdict + # ask is exercised on every cross-node read (the census S5 hot leg, + # made deterministic -- IN-9 L1's "compress the overlay-miss window"). + 'cluster.tt_status_hint_emit_mode = disabled', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +# Coincidence table (t/347 recipe). +my ($p0, $p1) = ('', ''); +for my $attempt (1 .. 8) +{ + last unless write_retry($node0, 'CREATE TABLE d1_t (id int, v int)'); + last unless write_retry($node1, 'CREATE TABLE d1_t (id int, v int)'); + $p0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('d1_t')}); + $p1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('d1_t')}); + last if $p0 eq $p1; + my ($n0) = $p0 =~ /(\d+)$/; + my ($n1) = $p1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + write_retry($lag, "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + write_retry($node0, 'DROP TABLE d1_t'); + write_retry($node1, 'DROP TABLE d1_t'); +} +is($p0, $p1, 'd1_t relfilepath coincidence holds'); + +my $ask0 = state_val($node1, 'cr', 'vis53r97_leg_xmin_overlay_verdict_ask_count'); +my $hit0 = state_val($node1, 'cr', 'vis53r97_leg_xmin_overlay_verdict_hit_count'); + +# Drive the overlay-miss window: many small node0 commits, each immediately +# read cross-node by node1 (the V4 hint frequently lags one such read). +my $reads_ok = 0; +my $reads_exact = 0; +my $reads_failclos = 0; +my $expected = 0; +for my $round (1 .. 30) +{ + next unless write_retry($node0, "INSERT INTO d1_t VALUES ($round, $round)"); + $expected++; + my ($rc, $out, $err) = + $node1->psql('postgres', 'SELECT count(*), COALESCE(sum(v),0) FROM d1_t', timeout => 15); + if ($rc == 0) + { + $reads_ok++; + my ($cnt, $sum) = split /\|/, $out; + # 8.A tooth (correct MVCC form): a successful cross-node read must be + # a SELF-CONSISTENT PREFIX of node0's committed history -- it may lag + # (cnt < expected: node0's latest commit not yet propagated / + # snapshot scn), but it must NEVER over-read (cnt > expected = + # false-visible) and its sum must EXACTLY match its own count + # (sum == cnt*(cnt+1)/2 = a corruption-free run of ids 1..cnt). A + # wrong-but-lagging count with an exact matching sum is正确 MVCC, + # not a defect; only over-read or a sum that does not match the count + # is a false-visible / lost-row corruption. + my $self_consistent = ($cnt <= $expected) && ($sum == $cnt * ($cnt + 1) / 2); + $reads_exact++ if $self_consistent; + diag("round $round: NOT self-consistent cnt=$cnt sum=$sum " + . "(prefix invariant: cnt<=$expected AND sum==cnt*(cnt+1)/2)") + if !$self_consistent; + } + else + { + # Fail-closed is the documented safe outcome (verdict also couldn't + # prove, or covers gate refused); never a silent wrong answer. + $reads_failclos++; + } +} + +my $ask = state_val($node1, 'cr', 'vis53r97_leg_xmin_overlay_verdict_ask_count') - $ask0; +my $hit = state_val($node1, 'cr', 'vis53r97_leg_xmin_overlay_verdict_hit_count') - $hit0; +diag("D1 probe: node1 reads_ok=$reads_ok reads_exact=$reads_exact " + . "reads_failclosed=$reads_failclos verdict_ask=$ask verdict_hit=$hit"); + +# (a) D1 actually engaged on the overlay-miss window at least once. +cmp_ok($ask, '>', 0, 'D1 xmin overlay-miss verdict ask engaged (ask > 0)'); +# (b) THE 8.A TOOTH: every successful cross-node read was a self-consistent +# prefix -- no over-read (false-visible) and no count/sum mismatch (lost +# row / corruption). Lag (reading an earlier committed prefix) is正确 MVCC. +is($reads_exact, $reads_ok, + 'every successful cross-node read was a self-consistent prefix (no false-visible/corruption)'); +# (c) some reads resolved positively via the verdict (the收益; not merely +# fail-closed). If this is 0 the timing never left an overlay-miss window +# open long enough -- diagnostic, not a hard fail in the scratch probe. +cmp_ok($hit + $reads_ok, '>', 0, 'reads either resolved positively or via a verdict hit'); + +$pair->stop_pair; +done_testing(); diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index a42f22d7a9..8a79c1e572 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1902,6 +1902,16 @@ cluster_vis53r97_leg_xmax_unprovable_count(void) { return 0; } +uint64 +cluster_vis53r97_leg_xmin_overlay_verdict_ask_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_xmin_overlay_verdict_hit_count(void) +{ + return 0; +} /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ uint64 cluster_cr_xmax_resolved_count(void) From abf331b2f18d829d08d7e152292ec1fc2b7766c5 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 18:07:45 +0800 Subject: [PATCH 07/29] fix(cluster): spec-7.1 watch-2 -- multixact marker reuse folds the recycle watermark cluster_itl_stamp_multixact_marker could evict a completed DATA slot (wrap++ path) without folding the evicted write_scn into the page's itl_recycle_watermark_scn, unlike the data-writer recycle in cluster_itl_stamp_active. The dropped undo anchor left the per-page CR candidate set silently incomplete, so own-instance CR could reconstruct a post-snapshot version instead of failing closed (latent since the spec-3.6 marker landed; triggered when the page ITL is full and the marker allocator falls back to a reusable data slot). Fold via the shared cluster_itl_recycle_watermark_contribution helper before overwriting the slot, passing new_xid = InvalidTransactionId: the new occupant is a MultiXactId, not a data writer, so the helper's same-xid reuse exemption cannot mis-fire on a numeric xid/multixact-id collision. No redo change: the marker write never travels as an ITL delta (heap_lock_tuple keeps cluster_did_lock_stamp false for the marker branch), so the eviction and the fold move atomically with the page image and the redo-side ACTIVE-only fold stays exact. Tests (cluster_unit test_cluster_itl_reader_real_triple, TDD red->green): T33 marker reuse of a completed DATA slot folds the watermark T34 marker reuse of a completed lock-only slot contributes nothing T35 marker into a FREE slot contributes nothing T36 numeric xid/multixact-id collision must not suppress the fold Spec: spec-7.1-cross-instance-positive-interread.md --- src/backend/cluster/cluster_itl.c | 31 ++++- src/include/cluster/cluster_itl.h | 5 + .../test_cluster_itl_reader_real_triple.c | 127 ++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/src/backend/cluster/cluster_itl.c b/src/backend/cluster/cluster_itl.c index c4e52b30c5..37cb6f2bd7 100644 --- a/src/backend/cluster/cluster_itl.c +++ b/src/backend/cluster/cluster_itl.c @@ -744,6 +744,28 @@ cluster_itl_stamp_multixact_marker(Buffer buf, MultiXactId multixact_id) return CLUSTER_ITL_SLOT_UNALLOCATED; /* OVERFLOW */ slot = &slots[idx]; + /* + * spec-7.1 watch-2 (spec-3.10 §v0.5 parity; latent since spec-3.6): + * evicting a completed DATA slot for a marker drops that slot's + * undo-chain anchor from the per-page CR candidate set exactly like a + * data-writer recycle, so fold its write_scn into the recycle + * watermark BEFORE overwriting -- otherwise own-instance CR silently + * reconstructs post-snapshot versions the dropped anchor guarded + * (false-visible). new_xid = InvalidTransactionId: the new occupant + * is a MultiXactId, not a data writer, and a completed data slot + * never holds InvalidTransactionId, so the helper's same-xid reuse + * exemption cannot mis-fire on a numeric xid/multixact-id collision. + * FREE / completed lock-only evictions contribute InvalidScn (§v0.5 + * B2/Q1) and stay no-ops. Crash parity holds without a redo change: + * the marker write never travels as an ITL delta (heap_lock_tuple + * keeps cluster_did_lock_stamp false for the marker branch), so the + * eviction and the fold move atomically with the page image -- + * FPI/flush carries both, a lost page keeps the old slot as its own + * CR anchor, and the redo-side ACTIVE-only fold stays exact. + */ + cluster_itl_block_watermark_advance( + page, cluster_itl_recycle_watermark_contribution( + slot->flags, slot->xid, slot->write_scn, InvalidTransactionId)); if (slot->flags != ITL_FLAG_FREE) slot->wrap++; slot->xid = (TransactionId)multixact_id; /* repurposed as MultiXactId */ @@ -773,8 +795,13 @@ cluster_itl_stamp_multixact_marker(Buffer buf, MultiXactId multixact_id) * reusing its own slot, and lock-only slots (which create no tuple versions * and carry InvalidScn write_scn) contribute nothing (§v0.5 B2 / Q1). * - * Shared by cluster_itl_stamp_active (primary) and - * cluster_itl_redo_apply_block_local_delta (redo) so a primary and a + * Shared by cluster_itl_stamp_active (primary), + * cluster_itl_redo_apply_block_local_delta (redo) and + * cluster_itl_stamp_multixact_marker (spec-7.1 watch-2: a marker eviction + * drops a data slot's undo anchor just like a data-writer recycle; it + * passes new_xid = InvalidTransactionId since its new occupant is a + * MultiXactId, not a data writer, so the same-xid exemption below cannot + * mis-fire on a numeric xid/multixact-id collision) so a primary and a * crash-recovered / standby node can never diverge on which recycles bump * the watermark (refinement of §v0.5 B5: redo recomputes the watermark from * the same inputs the primary used, mirroring the existing pd_block_scn diff --git a/src/include/cluster/cluster_itl.h b/src/include/cluster/cluster_itl.h index 740f39436a..afe52d0744 100644 --- a/src/include/cluster/cluster_itl.h +++ b/src/include/cluster/cluster_itl.h @@ -171,6 +171,11 @@ extern bool cluster_itl_page_has_active_slot(Page page); * MUST hold buffer EXCLUSIVE content lock. Returns slot_idx on success, * CLUSTER_ITL_SLOT_UNALLOCATED on OVERFLOW (caller decides whether to * skip / continue with V4 emit alone). + * + * spec-7.1 watch-2: reusing a completed DATA slot folds the evicted + * write_scn into the page's recycle watermark (spec-3.10 §v0.5) before + * overwriting, exactly like a data-writer recycle -- the marker eviction + * drops that slot's undo anchor from the per-page CR candidate set. */ extern uint8 cluster_itl_stamp_multixact_marker(Buffer buf, MultiXactId multixact_id); diff --git a/src/test/cluster_unit/test_cluster_itl_reader_real_triple.c b/src/test/cluster_unit/test_cluster_itl_reader_real_triple.c index 6d0bee3f17..5549290691 100644 --- a/src/test/cluster_unit/test_cluster_itl_reader_real_triple.c +++ b/src/test/cluster_unit/test_cluster_itl_reader_real_triple.c @@ -34,6 +34,13 @@ * T23 spec-3.9 L213 redo parity: older write_scn is a monotonic no-op * T24 spec-3.9 L213 redo parity: InvalidScn write_scn (lock-only) no-op * T25 spec-3.9 L213 redo parity: newer write_scn advances the watermark + * T33 spec-7.1 watch-2: marker reuse of a completed DATA slot folds + * the evicted write_scn into the recycle watermark + * T34 spec-7.1 watch-2: marker reuse of a completed lock-only slot + * contributes nothing + * T35 spec-7.1 watch-2: marker into a FREE slot contributes nothing + * T36 spec-7.1 watch-2: numeric xid/multixact-id collision must not + * suppress the fold (new_xid = InvalidTransactionId) * * Spec: spec-3.4b-real-tt-allocator-uba-encoding-production-cross-node.md * (v0.3 FROZEN 2026-05-24) @@ -826,6 +833,121 @@ UT_TEST(test_t32_redo_recomputes_recycle_watermark) /* spec-5.2 §3.5 D11: cluster_itl_page_has_active_slot — the holder-side defer * trigger. In-progress (ACTIVE / LOCK_ONLY_ACTIVE) => true; FREE and all * terminal states => false. */ +/* ============================================================ + * spec-7.1 watch-2 (spec-3.10 §v0.5 parity): a MultiXact marker + * stamp that evicts a completed DATA slot drops that slot's undo + * anchor from the per-page CR candidate set exactly like a + * data-writer recycle, so it must fold the evicted write_scn into + * the recycle watermark (otherwise own-instance CR can silently + * reconstruct a post-snapshot version -- false-visible). T33-T36 + * drive the REAL cluster_itl_stamp_multixact_marker through the + * bufmgr globals (BufferBlocks -> synthetic page, Buffer 1): the + * static-inline BufferGetPage compiled into cluster_itl.o resolves + * Buffer 1 to BufferBlocks + 0. + * ============================================================ */ + +static Buffer +marker_buffer_for(Page page) +{ + BufferBlocks = (char *)page; + NBuffers = 1; + return (Buffer)1; +} + +UT_TEST(test_t33_marker_reuse_completed_data_folds_watermark) +{ + Page page = build_itl_page(); + Buffer buf = marker_buffer_for(page); + uint8 i; + uint8 idx; + + /* Every slot ACTIVE (not FREE, not reusable) except slot 5: a COMMITTED + * data writer -- the only reuse candidate on a full page. */ + for (i = 0; i < CLUSTER_ITL_INITRANS_DEFAULT; i++) { + slot_at(page, i)->flags = ITL_FLAG_ACTIVE; + slot_at(page, i)->xid = (TransactionId)(1000 + i); + } + slot_at(page, 5)->flags = ITL_FLAG_COMMITTED; + slot_at(page, 5)->xid = (TransactionId)100; + slot_at(page, 5)->write_scn = (SCN)5000; + slot_at(page, 5)->wrap = 7; + ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn = InvalidScn; + + idx = cluster_itl_stamp_multixact_marker(buf, (MultiXactId)4242); + UT_ASSERT_EQ((int)idx, 5); + /* watch-2: the evicted COMMITTED writer's write_scn reached the + * watermark BEFORE the marker overwrote the slot. */ + UT_ASSERT_EQ((int)(ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn == (SCN)5000), 1); + UT_ASSERT_EQ((int)(slot_at(page, 5)->flags == ITL_FLAG_LOCK_ONLY_XMAX_IS_MULTI), 1); + UT_ASSERT_EQ((int)slot_at(page, 5)->wrap, 8); +} + +UT_TEST(test_t34_marker_reuse_lock_only_no_fold) +{ + Page page = build_itl_page(); + Buffer buf = marker_buffer_for(page); + uint8 i; + uint8 idx; + + for (i = 0; i < CLUSTER_ITL_INITRANS_DEFAULT; i++) { + slot_at(page, i)->flags = ITL_FLAG_ACTIVE; + slot_at(page, i)->xid = (TransactionId)(1000 + i); + } + /* Completed lock-only slot: reusable, but it anchors no tuple versions + * (§v0.5 B2/Q1) -- must NOT contribute. Poison write_scn proves the + * flag predicate, not the SCN_VALID guard, rejects it. */ + slot_at(page, 3)->flags = ITL_FLAG_LOCK_ONLY_COMMITTED; + slot_at(page, 3)->xid = (TransactionId)100; + slot_at(page, 3)->write_scn = (SCN)5000; + ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn = InvalidScn; + + idx = cluster_itl_stamp_multixact_marker(buf, (MultiXactId)4242); + UT_ASSERT_EQ((int)idx, 3); + UT_ASSERT_EQ((int)SCN_VALID(ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn), 0); +} + +UT_TEST(test_t35_marker_free_slot_no_fold) +{ + Page page = build_itl_page(); + Buffer buf = marker_buffer_for(page); + uint8 idx; + + /* All slots FREE (build_itl_page zero-fill): marker takes slot 0 with no + * eviction, so the watermark must stay Invalid and wrap must stay 0. */ + ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn = InvalidScn; + + idx = cluster_itl_stamp_multixact_marker(buf, (MultiXactId)4242); + UT_ASSERT_EQ((int)idx, 0); + UT_ASSERT_EQ((int)SCN_VALID(ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn), 0); + UT_ASSERT_EQ((int)slot_at(page, 0)->wrap, 0); +} + +UT_TEST(test_t36_marker_reuse_xid_mxid_collision_still_folds) +{ + Page page = build_itl_page(); + Buffer buf = marker_buffer_for(page); + uint8 i; + uint8 idx; + + /* 8.A pin: the evicted COMMITTED xid numerically equals the MultiXactId + * being stamped (xid and multixact-id are separate counters and can + * collide in value). The contribution helper's same-xid reuse exemption + * must NOT fire -- the marker path passes InvalidTransactionId as + * new_xid, never the multixact id. */ + for (i = 0; i < CLUSTER_ITL_INITRANS_DEFAULT; i++) { + slot_at(page, i)->flags = ITL_FLAG_ACTIVE; + slot_at(page, i)->xid = (TransactionId)(1000 + i); + } + slot_at(page, 2)->flags = ITL_FLAG_COMMITTED; + slot_at(page, 2)->xid = (TransactionId)4242; + slot_at(page, 2)->write_scn = (SCN)6000; + ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn = InvalidScn; + + idx = cluster_itl_stamp_multixact_marker(buf, (MultiXactId)4242); + UT_ASSERT_EQ((int)idx, 2); + UT_ASSERT_EQ((int)(ClusterPageGetItlHeader(page)->itl_recycle_watermark_scn == (SCN)6000), 1); +} + UT_TEST(test_d11_page_has_active_slot_detects_active) { Page page = build_itl_page(); @@ -900,6 +1022,11 @@ main(void) UT_RUN(test_t30_watermark_completed_data_contributes); UT_RUN(test_t31_watermark_advance_monotone); UT_RUN(test_t32_redo_recomputes_recycle_watermark); + /* spec-7.1 watch-2: marker reuse folds the recycle watermark. */ + UT_RUN(test_t33_marker_reuse_completed_data_folds_watermark); + UT_RUN(test_t34_marker_reuse_lock_only_no_fold); + UT_RUN(test_t35_marker_free_slot_no_fold); + UT_RUN(test_t36_marker_reuse_xid_mxid_collision_still_folds); UT_RUN(test_d11_page_has_active_slot_detects_active); UT_RUN(test_d11_page_has_active_slot_no_itl_is_false); From dab093713764f740760ce997335f4d29b56db273 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 20:46:00 +0800 Subject: [PATCH 08/29] feat(cluster): spec-7.1 D3-a -- striped multixact origin derivation + gap-walk Give a cross-node reader a way to attribute a foreign updater-bearing multixact xmax to its origin node, replacing the silent native-SLRU alias (spec-7.1 census probe F) with a fail-closed floor plus positive resolution where it is provable. Substrate: - New pure derivation layer cluster_mxid_stripe.{c,h}: node slot k issues multixact ids congruent to k (mod 16) at or above a durable activation floor, so a raw id above the floor is self-describing. PostgreSQL has no FullMultiXactId, so derivation is valid only in the half-space [floor, floor + 2^31) where the modular distance is unambiguous; the allocator refuses (SQLSTATE 53RB4) past that boundary. Unit test_cluster_mxid_stripe (17 truth-table cases). - Durable "PGXM" extension record rides the region-6 xid activation slot at a fixed offset, written atomically in the same 512-byte slot as the "PGXA" record; a pre-extension slot reads back all-zeros and the mxid face stays inactive (fail-closed) while the xid face is unchanged. - GetNewMultiXactId issues the striped candidate when latched and walks every skipped offsets-SLRU page before use (the varsup.c D5e gap-walk verified independently for the ZeroMultiXactOffsetPage path; the walk starts past nextMXact so it never re-zeroes a live page). Guardrail + underivable-read counters exposed under the xid_stripe dump category. Reader (HeapTupleSatisfiesMVCC only): - Derive the multixact origin from its striped value instead of the D7b page marker (which stamps the reader's own id). Derived-own multis decode natively (alias-free above the floor); derived- foreign multis resolve through the member overlay when it hits, else fail closed 53R9C; underivable fails closed. Gated by cluster.multi_xmax_remote_resolve (default on; off is the D3-0 floor verbatim). - The proactive V4 overlay does not cover updater-bearing multis (the updater has no TT binding at heap_update compose time), so cross-node positive resolution of those is served later by the member-serve path; until then the reader fails closed on the miss. Tests: t/359 (activation floor, class-0 and non-0 page-boundary crossings, restart, 53RB4 injection, overlay-miss floor); t/357 turned to the honest D3-a state (foreign fail-closed, derived-own native positive); t/015 + t/017 injection-count baselines. Spec: spec-7.1-cross-instance-positive-interread.md --- docs/user-guide/configuration.md | 31 + src/backend/access/heap/heapam_visibility.c | 266 ++-- src/backend/access/transam/multixact.c | 1299 +++++++++-------- src/backend/cluster/Makefile | 1 + src/backend/cluster/cluster_debug.c | 16 +- src/backend/cluster/cluster_guc.c | 21 + src/backend/cluster/cluster_inject.c | 9 + src/backend/cluster/cluster_multixact.c | 88 ++ src/backend/cluster/cluster_mxid_stripe.c | 244 ++++ src/backend/cluster/cluster_xid_stripe_boot.c | 108 +- src/backend/utils/errcodes.txt | 6 + src/include/cluster/cluster_guc.h | 6 + src/include/cluster/cluster_multixact.h | 28 + src/include/cluster/cluster_mxid_stripe.h | 174 +++ src/include/cluster/cluster_xid_stripe_boot.h | 12 + src/test/cluster_tap/t/015_inject.pl | 8 +- src/test/cluster_tap/t/017_debug.pl | 8 +- .../t/357_cluster_multi_xmax_alias_floor.pl | 140 +- .../t/359_cluster_mxid_stripe_gapwalk.pl | 293 ++++ src/test/cluster_unit/Makefile | 2 +- .../cluster_unit/test_cluster_mxid_stripe.c | 434 ++++++ 21 files changed, 2381 insertions(+), 813 deletions(-) create mode 100644 src/backend/cluster/cluster_mxid_stripe.c create mode 100644 src/include/cluster/cluster_mxid_stripe.h create mode 100644 src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl create mode 100644 src/test/cluster_unit/test_cluster_mxid_stripe.c diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 5165f233d9..4b8be3b518 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -888,6 +888,37 @@ SELECT key, value FROM pg_cluster_state WHERE category = 'xid_stripe'; ``` +Multixact IDs are striped the same way on a striped cluster: each node +only issues multixact ids in its own congruence class, inside a window +of 2^31 values above the recorded activation point. Multixact ids are +consumed only when several transactions lock or update the same row +concurrently, so this window lasts far longer than the xid cycle; if +it is ever exhausted, new multixact creation is refused with SQLSTATE +`53RB4` (existing data stays readable). The window position and the +refusal counter appear in the same `xid_stripe` category +(`mxid_stripe_activated_floor`, `mxid_stripe_halfspace_refusals`, +`mxid_stripe_underivable_reads`). + +### `cluster.multi_xmax_remote_resolve` + +| | | +|---|---| +| Type | bool | +| Default | `on` | +| Context | sighup | + +Resolves row lock/update combinations (multixact xmax) created on +another node when reading shared data. When enabled (default), a read +that meets such a row determines the creating node from the striped +multixact id and resolves the member transactions through the cluster +member overlay; a combination that cannot be proven fails the read +with SQLSTATE `53R9C` (safe to retry). + +When `off`, every row carrying an updater-bearing multixact fails +closed with `53R9C` on a cluster read. Rows that only carry row locks +(`FOR SHARE` / `FOR KEY SHARE` with no updater) are always readable +under either setting. + ## Reconfig coordinator observability ### `pg_cluster_reconfig_state` view diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c index 6930c81554..7524856f13 100644 --- a/src/backend/access/heap/heapam_visibility.c +++ b/src/backend/access/heap/heapam_visibility.c @@ -73,7 +73,11 @@ * answers and hint stamps are void for provably-foreign xids; hint-stamp * suppression in SetHintBits, distrust of foreign-class hint bits, and * foreign-xmax routing in the Self / Dirty / MVCC native bodies and the - * update-path helper). + * update-path helper), spec-7.1 D1 (live-slot overlay-miss verdict ask) + * and spec-7.1 D3-a (multixact xmax positive resolution: origin derived + * from the striped mxid value, member overlay resolve in the MVCC D6 + * branch and the G6 keeps-visible multi arm; underivable stays the + * fail-closed floor). * *------------------------------------------------------------------------- */ @@ -105,6 +109,7 @@ #include "cluster/cluster_tt_slot.h" /* ClusterUndoTTSlotRef */ #include "cluster/cluster_subtrans.h" /* spec-3.5 D8 lookup_parent */ #include "cluster/cluster_multixact.h" /* spec-3.6 D6 reader overlay */ +#include "cluster/cluster_mxid_stripe.h" /* spec-7.1 D3-a mxid origin derivation */ #include "cluster/cluster_tt_status.h" /* lookup_exact / Key / Result */ #include "cluster/cluster_visibility_inject.h" /* D5b test-only inject helper */ #include "cluster/cluster_cr.h" /* spec-3.9 D5 CR 3-tier MVCC gate */ @@ -138,8 +143,7 @@ static inline bool cluster_plain_xmax_provably_foreign(HeapTupleHeader tuple) { - if ((tuple->t_infomask & HEAP_XMAX_IS_MULTI) - || HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask)) + if ((tuple->t_infomask & HEAP_XMAX_IS_MULTI) || HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask)) return false; return cluster_xid_provably_foreign(HeapTupleHeaderGetRawXmax(tuple)); } @@ -149,8 +153,7 @@ cluster_plain_xmax_provably_foreign(HeapTupleHeader tuple) * deleter, shared by the Self and Dirty native bodies. Every unproven * outcome fails closed (规则 8.A) — never a fall-through to native. */ -typedef enum ClusterForeignXmaxState -{ +typedef enum ClusterForeignXmaxState { CLUSTER_FOREIGN_XMAX_DELETED, /* deleter proven committed */ CLUSTER_FOREIGN_XMAX_NOT_DELETED, /* deleter proven aborted */ CLUSTER_FOREIGN_XMAX_IN_PROGRESS, /* deleter proven still running */ @@ -182,7 +185,7 @@ cluster_foreign_xmax_state(Buffer buffer, HeapTupleHeader tuple, TransactionId r "missed/stale; retry or abort."))); return CLUSTER_FOREIGN_XMAX_NOT_DELETED; /* unreachable */ } -#else /* !USE_PGRAC_CLUSTER */ +#else /* !USE_PGRAC_CLUSTER */ #define cluster_plain_xmax_provably_foreign(tuple) false #endif @@ -556,7 +559,7 @@ HeapTupleSatisfiesSelf(HeapTuple htup, Snapshot snapshot, Buffer buffer) if (cluster_foreign_xmax_state(buffer, tuple, HeapTupleHeaderGetRawXmax(tuple)) == CLUSTER_FOREIGN_XMAX_DELETED) return false; /* updated by other */ - return true; /* in-progress or aborted deleter: still visible to self */ + return true; /* in-progress or aborted deleter: still visible to self */ } #endif @@ -1476,11 +1479,10 @@ HeapTupleSatisfiesDirty(HeapTuple htup, Snapshot snapshot, Buffer buffer) return true; case CLUSTER_FOREIGN_XMAX_IN_PROGRESS: default: - ereport(ERROR, - (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), - errmsg("cross-node write conflict on tuple with in-progress " - "remote deleter"), - errhint("Retry the transaction."))); + ereport(ERROR, (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), + errmsg("cross-node write conflict on tuple with in-progress " + "remote deleter"), + errhint("Retry the transaction."))); } } #endif @@ -1546,30 +1548,57 @@ cluster_remote_live_xmax_keeps_visible(Buffer buffer, HeapTupleHeader tuple, Sna /* PGRAC: spec-6.15 D7 — a provably-foreign INVALID hint may be a poison * stamp from the native no-authority era; never trust it, resolve. */ - if ((tuple->t_infomask & HEAP_XMAX_INVALID) - && !cluster_plain_xmax_provably_foreign(tuple)) + if ((tuple->t_infomask & HEAP_XMAX_INVALID) && !cluster_plain_xmax_provably_foreign(tuple)) return 1; if (HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask)) return 1; /* a lock-only xmax never deletes the row */ if (tuple->t_infomask & HEAP_XMAX_IS_MULTI) { /* - * PGRAC multi-xmax alias floor: a multixact id is node-local and its - * members come from the LOCAL pg_multixact, which ALIASES for a - * foreign multi once the local counter passes that id (AD-012 例外 - * 9; the spec-7.1 census probe-F silent false-visible). The spec-3.6 + * PGRAC multi-xmax alias floor + spec-7.1 D3-a positive + * resolution: a multixact id is node-local and its members come + * from the LOCAL pg_multixact, which ALIASES for a foreign multi + * once the local counter passes that id (AD-012 例外 9; the + * spec-7.1 census probe-F silent false-visible). The spec-3.6 * D7b page marker cannot distinguish the origin (it stamps the - * reader's own id), and warm cross-instance reads (spec-6.12i/6.15) - * make a foreign tuple with a multi xmax legitimately reach this - * gate -- the exact hazard the pre-floor XXX note here predicted. - * Updater-bearing multis therefore fail closed in peer mode until - * spec-7.1 D3 makes the origin derivable; the caller's 53R97 ERROR - * is retryable. (Lock-only multis returned 1 above: a lock never - * deletes the row, no member decode is needed.) + * reader's own id); the striped mxid VALUE can (D3-a Route B): + * derived own -> native member decode is alias-free above the + * activation floor; derived foreign -> member overlay resolve + * (OBS-1 truth table, same helper as the MVCC D6 branch); + * underivable or overlay-unprovable -> -1, the caller's + * retryable 53R97 ERROR (fail-closed direction unchanged). + * (Lock-only multis returned 1 above: a lock never deletes the + * row, no member decode is needed.) */ if (cluster_peer_mode_enabled()) { - /* PGRAC: spec-7.1 D0 census — foreign-multi refuse leg (D3 target). */ - cluster_vis53r97_note_multi_unresolvable(); - return -1; + int mx_origin = -1; + + if (cluster_multi_xmax_remote_resolve) + mx_origin = cluster_mxid_origin_slot((MultiXactId)HeapTupleHeaderGetRawXmax(tuple)); + + if (mx_origin < 0) { + /* PGRAC: spec-7.1 D0 census — foreign-multi refuse leg. */ + cluster_multixact_note_underivable_read(); + cluster_vis53r97_note_multi_unresolvable(); + return -1; + } + if (mx_origin != cluster_node_id) { + bool mx_hit = false; + + switch (cluster_multixact_remote_xmax_resolve( + (uint16)mx_origin, (MultiXactId)HeapTupleHeaderGetRawXmax(tuple), snapshot, + &mx_hit)) { + case CLUSTER_VISIBILITY_VISIBLE: + return 1; /* no committed updater hides the row */ + case CLUSTER_VISIBILITY_INVISIBLE: + return 0; /* the delete is visible at this snapshot */ + case CLUSTER_VISIBILITY_UNKNOWN: + default: + cluster_vis53r97_note_multi_unresolvable(); + return -1; /* overlay miss / member unprovable */ + } + } + /* derived own slot: fall through to the alias-free native + * update-xid decode below. */ } xmax = HeapTupleGetUpdateXid(tuple); } else @@ -1737,10 +1766,9 @@ HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) if (cluster_enabled && BufferIsValid(buffer) && snapshot->cluster_source == (uint8)SNAPSHOT_SOURCE_CLUSTER && (!cluster_cr_no_peer_fastpath_eligible(snapshot) - || (!RecoveryInProgress() - && cluster_tuple_has_remote_evidence(buffer, tuple)))) { + || (!RecoveryInProgress() && cluster_tuple_has_remote_evidence(buffer, tuple)))) { TransactionId raw_xmin = HeapTupleHeaderGetRawXmin(tuple); - ClusterUndoTTSlotRef ref = {0}; + ClusterUndoTTSlotRef ref = { 0 }; bool ref_filled = false; /* @@ -2018,107 +2046,90 @@ HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) * Scope: HeapTupleSatisfiesMVCC() only (per OBS-1/F5). Other * HeapTupleSatisfies* paths remain PG-native. * - * Path: - * 1. tuple xmax has HEAP_XMAX_IS_MULTI flag - * 2. find page ITL marker via D7b helper (buffer content - * lock held by caller per L200 + spec-3.4d Hardening - * v1.0.1 F10 family) - * 3. marker hit + origin_node_id != cluster_node_id -> - * remote multixact -> cluster overlay lookup + resolve - * 4. marker miss / overlay miss -> 53R9C fail-closed (per - * L199 NO PG-native fallback) - * 5. marker hit + origin_node_id == cluster_node_id -> - * local-origin multixact -> fall through to PG-native - * (PG SLRU resolves locally) + * Path (spec-7.1 D3-a positive resolution): + * 1. tuple xmax has HEAP_XMAX_IS_MULTI flag (updater- + * bearing only: lock-only multis never cut visibility + * and an XMAX_INVALID-hinted multi is never decoded -- + * both stay readable natively) + * 2. derive the origin slot from the striped mxid VALUE + * (cluster_mxid_origin_slot, half-space window above + * the durable activation floor) -- NEVER from the D7b + * page marker, which stamps the READER's own id and + * would call every multi "local" (the probe-F silent + * false-visible this branch's floor was shipped for) + * 3. underivable (below floor / unlatched / beyond the + * half-space / cluster.multi_xmax_remote_resolve off) + * -> 53R9C fail-closed floor, direction unchanged + * 4. derived foreign -> cluster member overlay lookup + + * OBS-1 resolve; overlay miss / UNKNOWN -> 53R9C (per + * L199 NO PG-native fallback; the member-serve wire + * that would answer a miss positively is a later + * deliverable of this spec) + * 5. derived OWN slot -> fall through to PG-native: above + * the floor only this node ever issues its congruence + * class, so the local SLRU member decode is provably + * alias-free * * IN_PROGRESS authoritative state in resolve_visibility -> * VISIBLE (per OBS-1 truth table: uncommitted Update/ * NoKeyUpdate not yet hides tuple). UNKNOWN -> 53R9C. - * - * PGRAC multi-xmax alias floor: step 5's "marker says local -> - * PG-native" is NOT safe -- the D7b marker stamps the READER's - * own id, so it says "local" for every multi, and the native - * decode of a foreign multi resolves against unrelated LOCAL - * members once the local counter passes that id (silent - * false-visible; spec-7.1 census probe F). Until the origin is - * derivable, an updater-bearing multi in peer mode fails closed - * HERE, before the native fall-through. Lock-only multis never - * cut visibility (native returns visible without a member - * decode) and an XMAX_INVALID-hinted multi is never decoded; - * both stay readable. - * Spec: spec-7.1-cross-instance-positive-interread.md (D3 floor) + * Spec: spec-7.1-cross-instance-positive-interread.md (D3-a) */ if ((tuple->t_infomask & HEAP_XMAX_IS_MULTI) != 0) { TransactionId raw_xmax_multi = HeapTupleHeaderGetRawXmax(tuple); - Page page = BufferGetPage(buffer); - uint16 marker_origin = 0; if (MultiXactIdIsValid((MultiXactId)raw_xmax_multi) && !(tuple->t_infomask & HEAP_XMAX_INVALID) - && !HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask) - && cluster_peer_mode_enabled()) - ereport(ERROR, - (errcode(ERRCODE_CLUSTER_MULTIXACT_MEMBER_OVERLAY_MISS), - errmsg("cluster multixact %u cannot be attributed to an origin node", - (unsigned)raw_xmax_multi), - errhint("Multixact ids are node-local and this tuple's deleting " - "multixact cannot be proven local; cross-node multixact " - "resolution is not implemented yet. Retry the transaction."))); + && !HEAP_XMAX_IS_LOCKED_ONLY(tuple->t_infomask) && cluster_peer_mode_enabled()) { + int mx_origin = -1; - if (MultiXactIdIsValid((MultiXactId)raw_xmax_multi) - && cluster_itl_find_multixact_origin_by_xmax(page, (MultiXactId)raw_xmax_multi, - &marker_origin) - && (int32)marker_origin != cluster_node_id) { - /* - * Remote-origin MultiXact tuple visible reader. Stack- - * allocate result struct with members[256] cap matching - * V4 wire ceiling. - */ - ClusterMultiXactKey mxkey; - ClusterMultiXactMemberOverlayResult *mxres; - ClusterVisibilityDecision mvcc_decision; - Size resbuf_sz = offsetof(ClusterMultiXactMemberOverlayResult, members) - + 256 * sizeof(ClusterMultiXactMember); + if (cluster_multi_xmax_remote_resolve) + mx_origin = cluster_mxid_origin_slot((MultiXactId)raw_xmax_multi); - memset(&mxkey, 0, sizeof(mxkey)); - mxkey.origin_node_id = marker_origin; - mxkey.multixact_id = (MultiXactId)raw_xmax_multi; - mxkey.cluster_epoch = (uint32)cluster_epoch_get_current(); - - mxres = (ClusterMultiXactMemberOverlayResult *)palloc0(resbuf_sz); - - if (!cluster_multixact_member_overlay_lookup(&mxkey, mxres, 256)) { - pfree(mxres); + if (mx_origin < 0) { + /* D3-0 floor: origin not provable -> fail closed */ + cluster_multixact_note_underivable_read(); ereport(ERROR, (errcode(ERRCODE_CLUSTER_MULTIXACT_MEMBER_OVERLAY_MISS), - errmsg("cluster multixact member overlay miss for " - "remote multixact id %u from node %u", - (unsigned)mxkey.multixact_id, (unsigned)mxkey.origin_node_id), - errhint("Remote multixact member overlay is not " - "available; retry the transaction after " - "the origin emits a fresh overlay."))); + errmsg("cluster multixact %u cannot be attributed to an origin node", + (unsigned)raw_xmax_multi), + errhint("Multixact ids are node-local and this tuple's deleting " + "multixact cannot be proven local; cross-node multixact " + "resolution needs mxid striping and " + "cluster.multi_xmax_remote_resolve. Retry the transaction."))); } + if (mx_origin != cluster_node_id) { + bool mx_hit = false; - mvcc_decision = cluster_multixact_resolve_visibility(mxres, snapshot); - pfree(mxres); - - switch (mvcc_decision) { - case CLUSTER_VISIBILITY_VISIBLE: - return true; - case CLUSTER_VISIBILITY_INVISIBLE: - return false; - case CLUSTER_VISIBILITY_UNKNOWN: - default: - ereport(ERROR, - (errcode(ERRCODE_CLUSTER_MULTIXACT_MEMBER_OVERLAY_MISS), - errmsg("cluster multixact resolve visibility UNKNOWN " - "for multixact id %u from node %u", - (unsigned)mxkey.multixact_id, (unsigned)mxkey.origin_node_id), - errhint("Member commit_scn not yet propagated; retry " - "transaction."))); + switch (cluster_multixact_remote_xmax_resolve( + (uint16)mx_origin, (MultiXactId)raw_xmax_multi, snapshot, &mx_hit)) { + case CLUSTER_VISIBILITY_VISIBLE: + return true; + case CLUSTER_VISIBILITY_INVISIBLE: + return false; + case CLUSTER_VISIBILITY_UNKNOWN: + default: + if (!mx_hit) + ereport(ERROR, (errcode(ERRCODE_CLUSTER_MULTIXACT_MEMBER_OVERLAY_MISS), + errmsg("cluster multixact member overlay miss for " + "remote multixact id %u from node %d", + (unsigned)raw_xmax_multi, mx_origin), + errhint("Remote multixact member overlay is not " + "available; retry the transaction after " + "the origin emits a fresh overlay."))); + ereport(ERROR, (errcode(ERRCODE_CLUSTER_MULTIXACT_MEMBER_OVERLAY_MISS), + errmsg("cluster multixact resolve visibility UNKNOWN " + "for multixact id %u from node %d", + (unsigned)raw_xmax_multi, mx_origin), + errhint("Member commit_scn not yet propagated; retry " + "transaction."))); + } } + /* mx_origin == cluster_node_id: provably OUR multixact + * above the activation floor -> the PG-native member + * decode below is alias-free. */ } - /* else: no marker / local-origin multixact / invalid mid -> + /* lock-only / XMAX_INVALID / invalid mid / non-peer -> * fall through to PG-native MultiXact resolution below. */ } } @@ -2141,18 +2152,16 @@ HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) */ if (cluster_shared_catalog && cluster_enabled && BufferIsValid(buffer) && snapshot->cluster_source == (uint8)SNAPSHOT_SOURCE_LOCAL - && cluster_tuple_has_remote_evidence(buffer, tuple)) - { + && cluster_tuple_has_remote_evidence(buffer, tuple)) { /* spec-6.14 D10b: count the fail-closed outcome (dump key * catalog.vis_unknown_count) before raising. */ cluster_catalog_stats_vis_unknown_inc(); - ereport(ERROR, - (errcode(ERRCODE_CLUSTER_TT_STATUS_UNKNOWN), - errmsg("foreign-origin tuple cannot be judged under a LOCAL snapshot " - "with cluster.shared_catalog enabled"), - errhint("Catalog services on this node are not ready yet (starting up, " - "shutting down, or in recovery); retry once the cluster reaches " - "the RUNNING phase."))); + ereport(ERROR, (errcode(ERRCODE_CLUSTER_TT_STATUS_UNKNOWN), + errmsg("foreign-origin tuple cannot be judged under a LOCAL snapshot " + "with cluster.shared_catalog enabled"), + errhint("Catalog services on this node are not ready yet (starting up, " + "shutting down, or in recovery); retry once the cluster reaches " + "the RUNNING phase."))); } #endif /* USE_PGRAC_CLUSTER */ @@ -2293,12 +2302,11 @@ HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) case 0: return false; default: - ereport(ERROR, - (errcode(ERRCODE_CLUSTER_TT_STATUS_UNKNOWN), - errmsg("cluster TT status unknown for deleting xmax of xid %u", - HeapTupleHeaderGetRawXmax(tuple)), - errhint("Remote deleter commit state not yet propagated; " - "retry or abort."))); + ereport(ERROR, (errcode(ERRCODE_CLUSTER_TT_STATUS_UNKNOWN), + errmsg("cluster TT status unknown for deleting xmax of xid %u", + HeapTupleHeaderGetRawXmax(tuple)), + errhint("Remote deleter commit state not yet propagated; " + "retry or abort."))); } } #endif diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c index 521bd87432..ee79cf670b 100644 --- a/src/backend/access/transam/multixact.c +++ b/src/backend/access/transam/multixact.c @@ -65,6 +65,24 @@ * src/backend/access/transam/multixact.c * *------------------------------------------------------------------------- + * PGRAC MODIFICATIONS + * + * Modified by: SqlRush + * + * What changed: + * - MultiXactIdCreateFromMembers emits a cluster member overlay + + * V4 sidecar wire for local-all-member composes so peers can + * resolve this node's multixacts without decoding a foreign SLRU. + * Spec: spec-3.6-multixact-reader-member-resolution.md + * - GetNewMultiXactId issues the striped candidate (this node's + * congruence class above the durable mxid activation floor) when + * mxid striping is latched, walks skipped positions so every + * crossed offsets SLRU page is zeroed before use, and refuses + * fail-closed (53RB4) a candidate that would leave the half-space + * derivation window. Striping off keeps the vanilla dense path + * byte-identical. + * Spec: spec-7.1-cross-instance-positive-interread.md + *------------------------------------------------------------------------- */ #include "postgres.h" @@ -104,12 +122,14 @@ * heap_lock_tuple D7a checks cluster_itl_lock_path_enabled + remote * member detection; unreachable here when any member is remote. */ -#include "cluster/cluster_conf.h" /* cluster_conf_has_peers */ -#include "cluster/cluster_mode.h" /* cluster_peer_mode_enabled */ -#include "cluster/cluster_epoch.h" /* cluster_epoch_get_current */ -#include "cluster/cluster_guc.h" /* cluster_enabled / cluster_node_id */ -#include "cluster/cluster_multixact.h" /* overlay install + types */ -#include "cluster/cluster_tt_local.h" /* peek_binding */ +#include "cluster/cluster_conf.h" /* cluster_conf_has_peers */ +#include "cluster/cluster_mode.h" /* cluster_peer_mode_enabled */ +#include "cluster/cluster_epoch.h" /* cluster_epoch_get_current */ +#include "cluster/cluster_guc.h" /* cluster_enabled / cluster_node_id */ +#include "cluster/cluster_inject.h" /* PGRAC: spec-7.1 D3-a half-space limit leg */ +#include "cluster/cluster_multixact.h" /* overlay install + types */ +#include "cluster/cluster_mxid_stripe.h" /* PGRAC: spec-7.1 D3-a striped candidate */ +#include "cluster/cluster_tt_local.h" /* peek_binding */ #include "cluster/cluster_tt_status_hint.h" /* emit_multixact_overlay */ #endif #include "utils/builtins.h" @@ -133,10 +153,8 @@ /* We need four bytes per offset */ #define MULTIXACT_OFFSETS_PER_PAGE (BLCKSZ / sizeof(MultiXactOffset)) -#define MultiXactIdToOffsetPage(xid) \ - ((xid) / (MultiXactOffset) MULTIXACT_OFFSETS_PER_PAGE) -#define MultiXactIdToOffsetEntry(xid) \ - ((xid) % (MultiXactOffset) MULTIXACT_OFFSETS_PER_PAGE) +#define MultiXactIdToOffsetPage(xid) ((xid) / (MultiXactOffset)MULTIXACT_OFFSETS_PER_PAGE) +#define MultiXactIdToOffsetEntry(xid) ((xid) % (MultiXactOffset)MULTIXACT_OFFSETS_PER_PAGE) #define MultiXactIdToOffsetSegment(xid) (MultiXactIdToOffsetPage(xid) / SLRU_PAGES_PER_SEGMENT) /* @@ -152,19 +170,19 @@ * arithmetic must be done using "char *" pointers. */ /* We need eight bits per xact, so one xact fits in a byte */ -#define MXACT_MEMBER_BITS_PER_XACT 8 -#define MXACT_MEMBER_FLAGS_PER_BYTE 1 -#define MXACT_MEMBER_XACT_BITMASK ((1 << MXACT_MEMBER_BITS_PER_XACT) - 1) +#define MXACT_MEMBER_BITS_PER_XACT 8 +#define MXACT_MEMBER_FLAGS_PER_BYTE 1 +#define MXACT_MEMBER_XACT_BITMASK ((1 << MXACT_MEMBER_BITS_PER_XACT) - 1) /* how many full bytes of flags are there in a group? */ -#define MULTIXACT_FLAGBYTES_PER_GROUP 4 -#define MULTIXACT_MEMBERS_PER_MEMBERGROUP \ +#define MULTIXACT_FLAGBYTES_PER_GROUP 4 +#define MULTIXACT_MEMBERS_PER_MEMBERGROUP \ (MULTIXACT_FLAGBYTES_PER_GROUP * MXACT_MEMBER_FLAGS_PER_BYTE) /* size in bytes of a complete group */ -#define MULTIXACT_MEMBERGROUP_SIZE \ +#define MULTIXACT_MEMBERGROUP_SIZE \ (sizeof(TransactionId) * MULTIXACT_MEMBERS_PER_MEMBERGROUP + MULTIXACT_FLAGBYTES_PER_GROUP) #define MULTIXACT_MEMBERGROUPS_PER_PAGE (BLCKSZ / MULTIXACT_MEMBERGROUP_SIZE) -#define MULTIXACT_MEMBERS_PER_PAGE \ +#define MULTIXACT_MEMBERS_PER_PAGE \ (MULTIXACT_MEMBERGROUPS_PER_PAGE * MULTIXACT_MEMBERS_PER_MEMBERGROUP) /* @@ -177,34 +195,30 @@ * * This constant is the number of members in the last page of the last segment. */ -#define MAX_MEMBERS_IN_LAST_MEMBERS_PAGE \ - ((uint32) ((0xFFFFFFFF % MULTIXACT_MEMBERS_PER_PAGE) + 1)) +#define MAX_MEMBERS_IN_LAST_MEMBERS_PAGE ((uint32)((0xFFFFFFFF % MULTIXACT_MEMBERS_PER_PAGE) + 1)) /* page in which a member is to be found */ -#define MXOffsetToMemberPage(xid) ((xid) / (TransactionId) MULTIXACT_MEMBERS_PER_PAGE) +#define MXOffsetToMemberPage(xid) ((xid) / (TransactionId)MULTIXACT_MEMBERS_PER_PAGE) #define MXOffsetToMemberSegment(xid) (MXOffsetToMemberPage(xid) / SLRU_PAGES_PER_SEGMENT) /* Location (byte offset within page) of flag word for a given member */ -#define MXOffsetToFlagsOffset(xid) \ - ((((xid) / (TransactionId) MULTIXACT_MEMBERS_PER_MEMBERGROUP) % \ - (TransactionId) MULTIXACT_MEMBERGROUPS_PER_PAGE) * \ - (TransactionId) MULTIXACT_MEMBERGROUP_SIZE) -#define MXOffsetToFlagsBitShift(xid) \ - (((xid) % (TransactionId) MULTIXACT_MEMBERS_PER_MEMBERGROUP) * \ - MXACT_MEMBER_BITS_PER_XACT) +#define MXOffsetToFlagsOffset(xid) \ + ((((xid) / (TransactionId)MULTIXACT_MEMBERS_PER_MEMBERGROUP) \ + % (TransactionId)MULTIXACT_MEMBERGROUPS_PER_PAGE) \ + * (TransactionId)MULTIXACT_MEMBERGROUP_SIZE) +#define MXOffsetToFlagsBitShift(xid) \ + (((xid) % (TransactionId)MULTIXACT_MEMBERS_PER_MEMBERGROUP) * MXACT_MEMBER_BITS_PER_XACT) /* Location (byte offset within page) of TransactionId of given member */ -#define MXOffsetToMemberOffset(xid) \ - (MXOffsetToFlagsOffset(xid) + MULTIXACT_FLAGBYTES_PER_GROUP + \ - ((xid) % MULTIXACT_MEMBERS_PER_MEMBERGROUP) * sizeof(TransactionId)) +#define MXOffsetToMemberOffset(xid) \ + (MXOffsetToFlagsOffset(xid) + MULTIXACT_FLAGBYTES_PER_GROUP \ + + ((xid) % MULTIXACT_MEMBERS_PER_MEMBERGROUP) * sizeof(TransactionId)) /* Multixact members wraparound thresholds. */ -#define MULTIXACT_MEMBER_SAFE_THRESHOLD (MaxMultiXactOffset / 2) -#define MULTIXACT_MEMBER_DANGER_THRESHOLD \ - (MaxMultiXactOffset - MaxMultiXactOffset / 4) +#define MULTIXACT_MEMBER_SAFE_THRESHOLD (MaxMultiXactOffset / 2) +#define MULTIXACT_MEMBER_DANGER_THRESHOLD (MaxMultiXactOffset - MaxMultiXactOffset / 4) -#define PreviousMultiXactId(xid) \ - ((xid) == FirstMultiXactId ? MaxMultiXactId : (xid) - 1) +#define PreviousMultiXactId(xid) ((xid) == FirstMultiXactId ? MaxMultiXactId : (xid) - 1) /* * Links to shared-memory data structures for MultiXact control @@ -212,8 +226,8 @@ static SlruCtlData MultiXactOffsetCtlData; static SlruCtlData MultiXactMemberCtlData; -#define MultiXactOffsetCtl (&MultiXactOffsetCtlData) -#define MultiXactMemberCtl (&MultiXactMemberCtlData) +#define MultiXactOffsetCtl (&MultiXactOffsetCtlData) +#define MultiXactMemberCtl (&MultiXactMemberCtlData) /* * MultiXact state shared across all backends. All this state is protected @@ -222,8 +236,7 @@ static SlruCtlData MultiXactMemberCtlData; * buffers. For concurrency's sake, we avoid holding more than one of these * locks at a time.) */ -typedef struct MultiXactStateData -{ +typedef struct MultiXactStateData { /* next-to-be-assigned MultiXactId */ MultiXactId nextMXact; @@ -231,7 +244,7 @@ typedef struct MultiXactStateData MultiXactOffset nextOffset; /* Have we completed multixact startup? */ - bool finishedStartup; + bool finishedStartup; /* * Oldest multixact that is still potentially referenced by a relation. @@ -239,7 +252,7 @@ typedef struct MultiXactStateData * updated by vacuum. */ MultiXactId oldestMultiXactId; - Oid oldestMultiXactDB; + Oid oldestMultiXactDB; /* * Oldest multixact offset that is potentially referenced by a multixact @@ -247,7 +260,7 @@ typedef struct MultiXactStateData * a flag here to indicate whether or not we currently do. */ MultiXactOffset oldestOffset; - bool oldestOffsetKnown; + bool oldestOffsetKnown; /* support for anti-wraparound measures */ MultiXactId multiVacLimit; @@ -256,7 +269,7 @@ typedef struct MultiXactStateData MultiXactId multiWrapLimit; /* support for members anti-wraparound measures */ - MultiXactOffset offsetStopLimit; /* known if oldestOffsetKnown */ + MultiXactOffset offsetStopLimit; /* known if oldestOffsetKnown */ /* * Per-backend data starts here. We have two arrays stored in the area @@ -312,7 +325,7 @@ typedef struct MultiXactStateData * Last element of OldestMemberMXactId and OldestVisibleMXactId arrays. * Valid elements are (1..MaxOldestSlot); element 0 is never used. */ -#define MaxOldestSlot (MaxBackends + max_prepared_xacts) +#define MaxOldestSlot (MaxBackends + max_prepared_xacts) /* Pointers to the state data in shared memory */ static MultiXactStateData *MultiXactState; @@ -337,68 +350,63 @@ static MultiXactId *OldestVisibleMXactId; * We allocate the cache entries in a memory context that is deleted at * transaction end, so we don't need to do retail freeing of entries. */ -typedef struct mXactCacheEnt -{ +typedef struct mXactCacheEnt { MultiXactId multi; - int nmembers; - dlist_node node; + int nmembers; + dlist_node node; MultiXactMember members[FLEXIBLE_ARRAY_MEMBER]; } mXactCacheEnt; -#define MAX_CACHE_ENTRIES 256 +#define MAX_CACHE_ENTRIES 256 static dclist_head MXactCache = DCLIST_STATIC_INIT(MXactCache); static MemoryContext MXactContext = NULL; #ifdef MULTIXACT_DEBUG -#define debug_elog2(a,b) elog(a,b) -#define debug_elog3(a,b,c) elog(a,b,c) -#define debug_elog4(a,b,c,d) elog(a,b,c,d) -#define debug_elog5(a,b,c,d,e) elog(a,b,c,d,e) -#define debug_elog6(a,b,c,d,e,f) elog(a,b,c,d,e,f) +#define debug_elog2(a, b) elog(a, b) +#define debug_elog3(a, b, c) elog(a, b, c) +#define debug_elog4(a, b, c, d) elog(a, b, c, d) +#define debug_elog5(a, b, c, d, e) elog(a, b, c, d, e) +#define debug_elog6(a, b, c, d, e, f) elog(a, b, c, d, e, f) #else -#define debug_elog2(a,b) -#define debug_elog3(a,b,c) -#define debug_elog4(a,b,c,d) -#define debug_elog5(a,b,c,d,e) -#define debug_elog6(a,b,c,d,e,f) +#define debug_elog2(a, b) +#define debug_elog3(a, b, c) +#define debug_elog4(a, b, c, d) +#define debug_elog5(a, b, c, d, e) +#define debug_elog6(a, b, c, d, e, f) #endif /* hack to deal with WAL generated with older minor versions */ -static int pre_initialized_offsets_page = -1; +static int pre_initialized_offsets_page = -1; /* internal MultiXactId management */ static void MultiXactIdSetOldestVisible(void); -static void RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, - int nmembers, MultiXactMember *members); +static void RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, int nmembers, + MultiXactMember *members); static MultiXactId GetNewMultiXactId(int nmembers, MultiXactOffset *offset); /* MultiXact cache management */ -static int mxactMemberComparator(const void *arg1, const void *arg2); +static int mxactMemberComparator(const void *arg1, const void *arg2); static MultiXactId mXactCacheGetBySet(int nmembers, MultiXactMember *members); -static int mXactCacheGetById(MultiXactId multi, MultiXactMember **members); -static void mXactCachePut(MultiXactId multi, int nmembers, - MultiXactMember *members); +static int mXactCacheGetById(MultiXactId multi, MultiXactMember **members); +static void mXactCachePut(MultiXactId multi, int nmembers, MultiXactMember *members); static char *mxstatus_to_string(MultiXactStatus status); /* management of SLRU infrastructure */ -static int ZeroMultiXactOffsetPage(int pageno, bool writeXlog); -static int ZeroMultiXactMemberPage(int pageno, bool writeXlog); +static int ZeroMultiXactOffsetPage(int pageno, bool writeXlog); +static int ZeroMultiXactMemberPage(int pageno, bool writeXlog); static bool MultiXactOffsetPagePrecedes(int page1, int page2); static bool MultiXactMemberPagePrecedes(int page1, int page2); -static bool MultiXactOffsetPrecedes(MultiXactOffset offset1, - MultiXactOffset offset2); +static bool MultiXactOffsetPrecedes(MultiXactOffset offset1, MultiXactOffset offset2); static void ExtendMultiXactOffset(MultiXactId multi); static void ExtendMultiXactMember(MultiXactOffset offset, int nmembers); -static bool MultiXactOffsetWouldWrap(MultiXactOffset boundary, - MultiXactOffset start, uint32 distance); +static bool MultiXactOffsetWouldWrap(MultiXactOffset boundary, MultiXactOffset start, + uint32 distance); static bool SetOffsetVacuumLimit(bool is_startup); static bool find_multixact_start(MultiXactId multi, MultiXactOffset *result); static void WriteMZeroPageXlogRec(int pageno, uint8 info); -static void WriteMTruncateXlogRec(Oid oldestMultiDB, - MultiXactId startTruncOff, - MultiXactId endTruncOff, - MultiXactOffset startTruncMemb, +static void WriteMTruncateXlogRec(Oid oldestMultiDB, MultiXactId startTruncOff, + MultiXactId endTruncOff, MultiXactOffset startTruncMemb, MultiXactOffset endTruncMemb); @@ -412,8 +420,8 @@ static void WriteMTruncateXlogRec(Oid oldestMultiDB, * is handled by the lower-level routines. */ MultiXactId -MultiXactIdCreate(TransactionId xid1, MultiXactStatus status1, - TransactionId xid2, MultiXactStatus status2) +MultiXactIdCreate(TransactionId xid1, MultiXactStatus status1, TransactionId xid2, + MultiXactStatus status2) { MultiXactId newMulti; MultiXactMember members[2]; @@ -439,8 +447,7 @@ MultiXactIdCreate(TransactionId xid1, MultiXactStatus status1, newMulti = MultiXactIdCreateFromMembers(2, members); - debug_elog3(DEBUG2, "Create: %s", - mxid_to_string(newMulti, 2, members)); + debug_elog3(DEBUG2, "Create: %s", mxid_to_string(newMulti, 2, members)); return newMulti; } @@ -470,9 +477,9 @@ MultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status) MultiXactId newMulti; MultiXactMember *members; MultiXactMember *newMembers; - int nmembers; - int i; - int j; + int nmembers; + int i; + int j; Assert(MultiXactIdIsValid(multi)); Assert(TransactionIdIsValid(xid)); @@ -480,8 +487,8 @@ MultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status) /* MultiXactIdSetOldestMember() must have been called already. */ Assert(MultiXactIdIsValid(OldestMemberMXactId[MyBackendId])); - debug_elog5(DEBUG2, "Expand: received multi %u, xid %u status %s", - multi, xid, mxstatus_to_string(status)); + debug_elog5(DEBUG2, "Expand: received multi %u, xid %u status %s", multi, xid, + mxstatus_to_string(status)); /* * Note: we don't allow for old multis here. The reason is that the only @@ -490,8 +497,7 @@ MultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status) */ nmembers = GetMultiXactIdMembers(multi, &members, false, false); - if (nmembers < 0) - { + if (nmembers < 0) { MultiXactMember member; /* @@ -505,8 +511,7 @@ MultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status) member.status = status; newMulti = MultiXactIdCreateFromMembers(1, &member); - debug_elog4(DEBUG2, "Expand: %u has no members, create singleton %u", - multi, newMulti); + debug_elog4(DEBUG2, "Expand: %u has no members, create singleton %u", multi, newMulti); return newMulti; } @@ -514,13 +519,9 @@ MultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status) * If the TransactionId is already a member of the MultiXactId with the * same status, just return the existing MultiXactId. */ - for (i = 0; i < nmembers; i++) - { - if (TransactionIdEquals(members[i].xid, xid) && - (members[i].status == status)) - { - debug_elog4(DEBUG2, "Expand: %u is already a member of %u", - xid, multi); + for (i = 0; i < nmembers; i++) { + if (TransactionIdEquals(members[i].xid, xid) && (members[i].status == status)) { + debug_elog4(DEBUG2, "Expand: %u is already a member of %u", xid, multi); pfree(members); return multi; } @@ -539,15 +540,12 @@ MultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status) * Note we have the same race condition here as above: j could be 0 at the * end of the loop. */ - newMembers = (MultiXactMember *) - palloc(sizeof(MultiXactMember) * (nmembers + 1)); + newMembers = (MultiXactMember *)palloc(sizeof(MultiXactMember) * (nmembers + 1)); - for (i = 0, j = 0; i < nmembers; i++) - { - if (TransactionIdIsInProgress(members[i].xid) || - (ISUPDATE_from_mxstatus(members[i].status) && - TransactionIdDidCommit(members[i].xid))) - { + for (i = 0, j = 0; i < nmembers; i++) { + if (TransactionIdIsInProgress(members[i].xid) + || (ISUPDATE_from_mxstatus(members[i].status) + && TransactionIdDidCommit(members[i].xid))) { newMembers[j].xid = members[i].xid; newMembers[j++].status = members[i].status; } @@ -580,8 +578,8 @@ bool MultiXactIdIsRunning(MultiXactId multi, bool isLockOnly) { MultiXactMember *members; - int nmembers; - int i; + int nmembers; + int i; debug_elog3(DEBUG2, "IsRunning %u?", multi); @@ -591,8 +589,7 @@ MultiXactIdIsRunning(MultiXactId multi, bool isLockOnly) */ nmembers = GetMultiXactIdMembers(multi, &members, false, isLockOnly); - if (nmembers <= 0) - { + if (nmembers <= 0) { debug_elog2(DEBUG2, "IsRunning: no members"); return false; } @@ -604,10 +601,8 @@ MultiXactIdIsRunning(MultiXactId multi, bool isLockOnly) * * This is not needed for correctness, it's just a fast path. */ - for (i = 0; i < nmembers; i++) - { - if (TransactionIdIsCurrentTransactionId(members[i].xid)) - { + for (i = 0; i < nmembers; i++) { + if (TransactionIdIsCurrentTransactionId(members[i].xid)) { debug_elog3(DEBUG2, "IsRunning: I (%d) am running!", i); pfree(members); return true; @@ -619,12 +614,9 @@ MultiXactIdIsRunning(MultiXactId multi, bool isLockOnly) * walking the PGPROC array only once for all the members. But in most * cases nmembers should be small enough that it doesn't much matter. */ - for (i = 0; i < nmembers; i++) - { - if (TransactionIdIsInProgress(members[i].xid)) - { - debug_elog4(DEBUG2, "IsRunning: member %d (%u) is running", - i, members[i].xid); + for (i = 0; i < nmembers; i++) { + if (TransactionIdIsInProgress(members[i].xid)) { + debug_elog4(DEBUG2, "IsRunning: member %d (%u) is running", i, members[i].xid); pfree(members); return true; } @@ -653,8 +645,7 @@ MultiXactIdIsRunning(MultiXactId multi, bool isLockOnly) void MultiXactIdSetOldestMember(void) { - if (!MultiXactIdIsValid(OldestMemberMXactId[MyBackendId])) - { + if (!MultiXactIdIsValid(OldestMemberMXactId[MyBackendId])) { MultiXactId nextMXact; /* @@ -686,8 +677,7 @@ MultiXactIdSetOldestMember(void) LWLockRelease(MultiXactGenLock); - debug_elog4(DEBUG2, "MultiXact: setting OldestMember[%d] = %u", - MyBackendId, nextMXact); + debug_elog4(DEBUG2, "MultiXact: setting OldestMember[%d] = %u", MyBackendId, nextMXact); } } @@ -710,10 +700,9 @@ MultiXactIdSetOldestMember(void) static void MultiXactIdSetOldestVisible(void) { - if (!MultiXactIdIsValid(OldestVisibleMXactId[MyBackendId])) - { + if (!MultiXactIdIsValid(OldestVisibleMXactId[MyBackendId])) { MultiXactId oldestMXact; - int i; + int i; LWLockAcquire(MultiXactGenLock, LW_EXCLUSIVE); @@ -726,12 +715,10 @@ MultiXactIdSetOldestVisible(void) if (oldestMXact < FirstMultiXactId) oldestMXact = FirstMultiXactId; - for (i = 1; i <= MaxOldestSlot; i++) - { + for (i = 1; i <= MaxOldestSlot; i++) { MultiXactId thisoldest = OldestMemberMXactId[i]; - if (MultiXactIdIsValid(thisoldest) && - MultiXactIdPrecedes(thisoldest, oldestMXact)) + if (MultiXactIdIsValid(thisoldest) && MultiXactIdPrecedes(thisoldest, oldestMXact)) oldestMXact = thisoldest; } @@ -739,8 +726,7 @@ MultiXactIdSetOldestVisible(void) LWLockRelease(MultiXactGenLock); - debug_elog4(DEBUG2, "MultiXact: setting OldestVisible[%d] = %u", - MyBackendId, oldestMXact); + debug_elog4(DEBUG2, "MultiXact: setting OldestVisible[%d] = %u", MyBackendId, oldestMXact); } } @@ -799,8 +785,7 @@ MultiXactIdCreateFromMembers(int nmembers, MultiXactMember *members) MultiXactOffset offset; xl_multixact_create xlrec; - debug_elog3(DEBUG2, "Create: %s", - mxid_to_string(InvalidMultiXactId, nmembers, members)); + debug_elog3(DEBUG2, "Create: %s", mxid_to_string(InvalidMultiXactId, nmembers, members)); /* * See if the same set of members already exists in our cache; if so, just @@ -813,21 +798,18 @@ MultiXactIdCreateFromMembers(int nmembers, MultiXactMember *members) * knowledge, but it's not worth checking for.) */ multi = mXactCacheGetBySet(nmembers, members); - if (MultiXactIdIsValid(multi)) - { + if (MultiXactIdIsValid(multi)) { debug_elog2(DEBUG2, "Create: in cache!"); return multi; } /* Verify that there is a single update Xid among the given members. */ { - int i; - bool has_update = false; + int i; + bool has_update = false; - for (i = 0; i < nmembers; i++) - { - if (ISUPDATE_from_mxstatus(members[i].status)) - { + for (i = 0; i < nmembers; i++) { + if (ISUPDATE_from_mxstatus(members[i].status)) { if (has_update) elog(ERROR, "new multixact has more than one updating member: %s", mxid_to_string(InvalidMultiXactId, nmembers, members)); @@ -862,10 +844,10 @@ MultiXactIdCreateFromMembers(int nmembers, MultiXactMember *members) * Not clear that it's worth the trouble though. */ XLogBeginInsert(); - XLogRegisterData((char *) (&xlrec), SizeOfMultiXactCreate); - XLogRegisterData((char *) members, nmembers * sizeof(MultiXactMember)); + XLogRegisterData((char *)(&xlrec), SizeOfMultiXactCreate); + XLogRegisterData((char *)members, nmembers * sizeof(MultiXactMember)); - (void) XLogInsert(RM_MULTIXACT_ID, XLOG_MULTIXACT_CREATE_ID); + (void)XLogInsert(RM_MULTIXACT_ID, XLOG_MULTIXACT_CREATE_ID); /* Now enter the information into the OFFSETs and MEMBERs logs */ RecordNewMultiXact(multi, offset, nmembers, members); @@ -893,50 +875,59 @@ MultiXactIdCreateFromMembers(int nmembers, MultiXactMember *members) * visibility will fail-closed 53R9C on remote read — safer * than emit partial overlay). * + * spec-7.1 D3-a note: an UPDATER-bearing multixact (the compose a + * cross-node read must resolve to a delete verdict) is composed at + * heap_update BEFORE the updater establishes its own TT binding + * (the binding lands when it stamps the new tuple, after this xmax + * compose). peek_binding therefore misses the updater here and + * this proactive emit skips the overlay, so the fast-path V4 + * overlay never covers updater-bearing multis. Cross-node + * positive resolution of those multis is served by the member- + * serve slow path (origin reads its own SLRU + TT after the fact), + * which is spec-7.1 D3-b, software-ordered behind spec-7.2 D3. + * Until then the reader derives the origin (D3-a) and fails closed + * on the overlay miss. This proactive hook is left unchanged. + * * member_count > GUC cap → defensive skip + emit_overlay 自身 * 会 reject + counter;本 hook 不重复 ereport. */ if (cluster_peer_mode_enabled() && nmembers > 0 && nmembers <= cluster_multixact_member_overlay_max_members - && nmembers <= CLUSTER_MULTIXACT_HINT_MAX_MEMBERS) - { + && nmembers <= CLUSTER_MULTIXACT_HINT_MAX_MEMBERS) { ClusterMultiXactMember c_members[CLUSTER_MULTIXACT_HINT_MAX_MEMBERS]; ClusterMultiXactKey c_key; bool all_local = true; int i; - for (i = 0; i < nmembers; i++) - { + for (i = 0; i < nmembers; i++) { uint32 seg; uint16 off; uint32 tt_id; uint32 epoch; - if (!cluster_tt_local_peek_binding(members[i].xid, &seg, &off, &tt_id, &epoch, NULL)) - { + if (!cluster_tt_local_peek_binding(members[i].xid, &seg, &off, &tt_id, &epoch, NULL)) { all_local = false; break; } c_members[i].xid = members[i].xid; - c_members[i].status = (uint8) members[i].status; + c_members[i].status = (uint8)members[i].status; c_members[i]._pad8 = 0; - c_members[i].origin_node_id = (uint16) cluster_node_id; - c_members[i].undo_segment_id = (uint16) seg; + c_members[i].origin_node_id = (uint16)cluster_node_id; + c_members[i].undo_segment_id = (uint16)seg; c_members[i]._pad16 = 0; c_members[i].tt_slot_id = tt_id; c_members[i].epoch = epoch; c_members[i]._reserved2 = 0; } - if (all_local) - { + if (all_local) { memset(&c_key, 0, sizeof(c_key)); - c_key.origin_node_id = (uint16) cluster_node_id; + c_key.origin_node_id = (uint16)cluster_node_id; c_key.multixact_id = multi; - c_key.cluster_epoch = (uint32) cluster_epoch_get_current(); + c_key.cluster_epoch = (uint32)cluster_epoch_get_current(); - if (cluster_multixact_member_overlay_install(&c_key, (uint16) nmembers, c_members)) - cluster_tt_status_hint_emit_multixact_overlay(&c_key, (uint16) nmembers, c_members); + if (cluster_multixact_member_overlay_install(&c_key, (uint16)nmembers, c_members)) + cluster_tt_status_hint_emit_multixact_overlay(&c_key, (uint16)nmembers, c_members); } } #endif @@ -952,17 +943,17 @@ MultiXactIdCreateFromMembers(int nmembers, MultiXactMember *members) * use it. */ static void -RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, - int nmembers, MultiXactMember *members) +RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, int nmembers, + MultiXactMember *members) { - int pageno; - int prev_pageno; - int entryno; - int slotno; + int pageno; + int prev_pageno; + int entryno; + int slotno; MultiXactOffset *offptr; MultiXactId next; - int next_pageno; - int next_entryno; + int next_pageno; + int next_entryno; MultiXactOffset *next_offptr; MultiXactOffset next_offset; @@ -986,10 +977,8 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, * such a version, the next page might not be initialized yet. Initialize * it now. */ - if (InRecovery && - next_pageno != pageno && - MultiXactOffsetCtl->shared->latest_page_number == pageno) - { + if (InRecovery && next_pageno != pageno + && MultiXactOffsetCtl->shared->latest_page_number == pageno) { elog(DEBUG1, "next offsets page is not initialized, initializing it now"); /* Create and zero the page */ @@ -1024,11 +1013,10 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, * take the trouble to generalize the slru.c error reporting code. */ slotno = SimpleLruReadPage(MultiXactOffsetCtl, pageno, true, multi); - offptr = (MultiXactOffset *) MultiXactOffsetCtl->shared->page_buffer[slotno]; + offptr = (MultiXactOffset *)MultiXactOffsetCtl->shared->page_buffer[slotno]; offptr += entryno; - if (*offptr != offset) - { + if (*offptr != offset) { /* should already be set to the correct value, or not at all */ Assert(*offptr == 0); *offptr = offset; @@ -1038,16 +1026,13 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, /* * Set the next multixid's offset to the end of this multixid's members. */ - if (next_pageno == pageno) - { + if (next_pageno == pageno) { next_offptr = offptr + 1; - } - else - { + } else { /* must be the first entry on the page */ Assert(next_entryno == 0 || next == FirstMultiXactId); slotno = SimpleLruReadPage(MultiXactOffsetCtl, next_pageno, true, next); - next_offptr = (MultiXactOffset *) MultiXactOffsetCtl->shared->page_buffer[slotno]; + next_offptr = (MultiXactOffset *)MultiXactOffsetCtl->shared->page_buffer[slotno]; next_offptr += next_entryno; } @@ -1055,8 +1040,7 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, next_offset = offset + nmembers; if (next_offset == 0) next_offset = 1; - if (*next_offptr != next_offset) - { + if (*next_offptr != next_offset) { /* should already be set to the correct value, or not at all */ Assert(*next_offptr == 0); *next_offptr = next_offset; @@ -1070,14 +1054,13 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, prev_pageno = -1; - for (int i = 0; i < nmembers; i++, offset++) - { + for (int i = 0; i < nmembers; i++, offset++) { TransactionId *memberptr; - uint32 *flagsptr; - uint32 flagsval; - int bshift; - int flagsoff; - int memberoff; + uint32 *flagsptr; + uint32 flagsval; + int bshift; + int flagsoff; + int memberoff; Assert(members[i].status <= MultiXactStatusUpdate); @@ -1086,19 +1069,16 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset, flagsoff = MXOffsetToFlagsOffset(offset); bshift = MXOffsetToFlagsBitShift(offset); - if (pageno != prev_pageno) - { + if (pageno != prev_pageno) { slotno = SimpleLruReadPage(MultiXactMemberCtl, pageno, true, multi); prev_pageno = pageno; } - memberptr = (TransactionId *) - (MultiXactMemberCtl->shared->page_buffer[slotno] + memberoff); + memberptr = (TransactionId *)(MultiXactMemberCtl->shared->page_buffer[slotno] + memberoff); *memberptr = members[i].xid; - flagsptr = (uint32 *) - (MultiXactMemberCtl->shared->page_buffer[slotno] + flagsoff); + flagsptr = (uint32 *)(MultiXactMemberCtl->shared->page_buffer[slotno] + flagsoff); flagsval = *flagsptr; flagsval &= ~(((1 << MXACT_MEMBER_BITS_PER_XACT) - 1) << bshift); @@ -1131,6 +1111,9 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) { MultiXactId result; MultiXactOffset nextOffset; +#ifdef USE_PGRAC_CLUSTER + int mxid_stripe_slot; +#endif debug_elog3(DEBUG2, "GetNew: for %d xids", nmembers); @@ -1147,6 +1130,67 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) /* Assign the MXID */ result = MultiXactState->nextMXact; +#ifdef USE_PGRAC_CLUSTER + + /* + * PGRAC MODIFICATIONS (spec-7.1 D3-a) + * + * What changed: with mxid striping latched, the value we issue is + * the striped candidate rather than nextMXact itself. Everything + * below (wraparound limit checks, offsets/members extension, the + * caller's return value) operates on the candidate; the advance at + * the bottom republishes candidate + 1. + * + * Why: node slot k must only ever issue multixact values congruent + * to k (mod 16) at or above the durable activation floor, so a + * value above the floor is self-describing (its origin slot is + * value mod 16) and readers never decode a foreign multixact + * against the local SLRU (the spec-7.1 probe-F alias). Candidate + * arithmetic is pure and lives in cluster_mxid_stripe.c; with + * striping off / unlatched the slot is -1 and this block is a + * no-op (byte-identical vanilla behaviour). + */ + mxid_stripe_slot = cluster_mxid_allocation_slot(); + if (mxid_stripe_slot >= 0) { + MultiXactId floor_mxid = cluster_mxid_stripe_floor(); + + /* + * Below-floor start (fresh node / late joiner): jump to the + * floor first. A candidate beyond floor + 2^31 is refused + * below before nextMXact ever advances past the boundary, so + * MultiXactIdPrecedes here can only mean genuinely behind the + * floor -- the two arithmetically-ambiguous cases are told + * apart by that allocation invariant. + */ + if (MultiXactIdPrecedes(result, floor_mxid)) + result = floor_mxid; + result = cluster_mxid_next_striped(result, mxid_stripe_slot); + + /* + * Half-space hard limit (rule 8.A): a candidate at or beyond + * floor + 2^31 would make origin derivation ambiguous under + * 32-bit wraparound -- refuse fail-closed so the derivation + * window can never alias. The error aborts out of + * MultiXactGenLock normally. The injection point lets a test + * exercise this leg without consuming 2^31 multixacts. + */ + if (cluster_mxid_halfspace_exceeded(result, floor_mxid) + || cluster_cr_injection_armed("cluster-mxid-halfspace-hard-limit", NULL)) { + cluster_multixact_note_halfspace_refuse(); + ereport( + ERROR, + (errcode(ERRCODE_CLUSTER_MXID_HALFSPACE_LIMIT), + errmsg( + "refusing to assign a new MultiXactId: cluster mxid half-space limit reached"), + errdetail("The striped multixact allocator has consumed the derivable half-space " + "above the activation floor %u.", + (unsigned)floor_mxid), + errhint("The cluster has exhausted its striped multixact id space; re-initialize " + "the cluster to establish a fresh activation floor."))); + } + } +#endif + /*---------- * Check to see if it's safe to assign another MultiXactId. This protects * against catastrophic data loss due to multixact wraparound. The basic @@ -1161,8 +1205,7 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) * Note these are pretty much the same protections in GetNewTransactionId. *---------- */ - if (!MultiXactIdPrecedes(result, MultiXactState->multiVacLimit)) - { + if (!MultiXactIdPrecedes(result, MultiXactState->multiVacLimit)) { /* * For safety's sake, we release MultiXactGenLock while sending * signals, warnings, etc. This is not so much because we care about @@ -1173,14 +1216,12 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) MultiXactId multiWarnLimit = MultiXactState->multiWarnLimit; MultiXactId multiStopLimit = MultiXactState->multiStopLimit; MultiXactId multiWrapLimit = MultiXactState->multiWrapLimit; - Oid oldest_datoid = MultiXactState->oldestMultiXactDB; + Oid oldest_datoid = MultiXactState->oldestMultiXactDB; LWLockRelease(MultiXactGenLock); - if (IsUnderPostmaster && - !MultiXactIdPrecedes(result, multiStopLimit)) - { - char *oldest_datname = get_database_name(oldest_datoid); + if (IsUnderPostmaster && !MultiXactIdPrecedes(result, multiStopLimit)) { + char *oldest_datname = get_database_name(oldest_datoid); /* * Immediately kick autovacuum into action as we're already in @@ -1192,17 +1233,21 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) if (oldest_datname) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), - errmsg("database is not accepting commands that generate new MultiXactIds to avoid wraparound data loss in database \"%s\"", + errmsg("database is not accepting commands that generate new MultiXactIds " + "to avoid wraparound data loss in database \"%s\"", oldest_datname), errhint("Execute a database-wide VACUUM in that database.\n" - "You might also need to commit or roll back old prepared transactions, or drop stale replication slots."))); + "You might also need to commit or roll back old prepared " + "transactions, or drop stale replication slots."))); else ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), - errmsg("database is not accepting commands that generate new MultiXactIds to avoid wraparound data loss in database with OID %u", + errmsg("database is not accepting commands that generate new MultiXactIds " + "to avoid wraparound data loss in database with OID %u", oldest_datoid), errhint("Execute a database-wide VACUUM in that database.\n" - "You might also need to commit or roll back old prepared transactions, or drop stale replication slots."))); + "You might also need to commit or roll back old prepared " + "transactions, or drop stale replication slots."))); } /* @@ -1213,29 +1258,31 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) if (IsUnderPostmaster && (result % 65536) == 0) SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_LAUNCHER); - if (!MultiXactIdPrecedes(result, multiWarnLimit)) - { - char *oldest_datname = get_database_name(oldest_datoid); + if (!MultiXactIdPrecedes(result, multiWarnLimit)) { + char *oldest_datname = get_database_name(oldest_datoid); /* complain even if that DB has disappeared */ if (oldest_datname) - ereport(WARNING, - (errmsg_plural("database \"%s\" must be vacuumed before %u more MultiXactId is used", - "database \"%s\" must be vacuumed before %u more MultiXactIds are used", - multiWrapLimit - result, - oldest_datname, - multiWrapLimit - result), - errhint("Execute a database-wide VACUUM in that database.\n" - "You might also need to commit or roll back old prepared transactions, or drop stale replication slots."))); + ereport( + WARNING, + (errmsg_plural( + "database \"%s\" must be vacuumed before %u more MultiXactId is used", + "database \"%s\" must be vacuumed before %u more MultiXactIds are used", + multiWrapLimit - result, oldest_datname, multiWrapLimit - result), + errhint("Execute a database-wide VACUUM in that database.\n" + "You might also need to commit or roll back old prepared " + "transactions, or drop stale replication slots."))); else - ereport(WARNING, - (errmsg_plural("database with OID %u must be vacuumed before %u more MultiXactId is used", - "database with OID %u must be vacuumed before %u more MultiXactIds are used", - multiWrapLimit - result, - oldest_datoid, - multiWrapLimit - result), - errhint("Execute a database-wide VACUUM in that database.\n" - "You might also need to commit or roll back old prepared transactions, or drop stale replication slots."))); + ereport( + WARNING, + (errmsg_plural( + "database with OID %u must be vacuumed before %u more MultiXactId is used", + "database with OID %u must be vacuumed before %u more MultiXactIds are " + "used", + multiWrapLimit - result, oldest_datoid, multiWrapLimit - result), + errhint("Execute a database-wide VACUUM in that database.\n" + "You might also need to commit or roll back old prepared " + "transactions, or drop stale replication slots."))); } /* Re-acquire lock and start over */ @@ -1243,8 +1290,79 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) result = MultiXactState->nextMXact; if (result < FirstMultiXactId) result = FirstMultiXactId; + +#ifdef USE_PGRAC_CLUSTER + /* PGRAC: spec-7.1 D3-a -- re-derive the striped candidate from + * the re-read counter (mirrors the varsup.c spec-6.15 D1 + * re-derive after the same release/re-acquire window). The + * half-space limit was checked before the release against a + * candidate at least this large only when the counter did not + * move; re-check it cheaply here. */ + if (mxid_stripe_slot >= 0) { + MultiXactId floor_mxid = cluster_mxid_stripe_floor(); + + if (MultiXactIdPrecedes(result, floor_mxid)) + result = floor_mxid; + result = cluster_mxid_next_striped(result, mxid_stripe_slot); + if (cluster_mxid_halfspace_exceeded(result, floor_mxid)) { + cluster_multixact_note_halfspace_refuse(); + ereport( + ERROR, + (errcode(ERRCODE_CLUSTER_MXID_HALFSPACE_LIMIT), + errmsg("refusing to assign a new MultiXactId: cluster mxid half-space limit " + "reached"), + errdetail("The striped multixact allocator has consumed the derivable " + "half-space above the activation floor %u.", + (unsigned)floor_mxid), + errhint("The cluster has exhausted its striped multixact id space; " + "re-initialize the cluster to establish a fresh activation floor."))); + } + } +#endif } +#ifdef USE_PGRAC_CLUSTER + + /* + * PGRAC: spec-7.1 D3-a -- a striped candidate skips multixact + * positions, and ExtendMultiXactOffset() only zeroes an offsets + * SLRU page when handed its FIRST multixact. An offsets page holds + * 2048 entries, divisible by the stride, so a page's first + * multixact always belongs to congruence class 0 -- any other + * class stepping across a page boundary would skip page-first + * forever and die on the missing page (the varsup.c spec-6.15 D5e + * gap-walk lesson; the multixact Extend path is walked and + * verified independently per IN-8-approve, NOT assumed covered by + * the CLOG fix). Walk every skipped position up to and including + * the candidate; with striping off (or the candidate equal to + * nextMXact) the block is a no-op and the vanilla call below is + * the only probe. Ordinary in-class step cost is at most STRIDE + * no-op probes under MultiXactGenLock. + */ + if (mxid_stripe_slot >= 0 && result != MultiXactState->nextMXact) { + MultiXactId gap = MultiXactState->nextMXact; + + /* + * Start PAST nextMXact: the previous allocation's + * ExtendMultiXactOffset(prev + 1 == nextMXact) already covered + * it, and re-handing a page-first position to Extend would + * re-zero a live page and wipe the previous multixact's + * next-offset end marker (permanent zero: the skipped position + * is never recorded, so nothing would ever rewrite it). + */ + gap++; + if (gap < FirstMultiXactId) + gap = FirstMultiXactId; + while (MultiXactIdPrecedes(gap, result)) { + ExtendMultiXactOffset(gap); + gap++; + if (gap < FirstMultiXactId) + gap = FirstMultiXactId; + } + ExtendMultiXactOffset(result); + } +#endif + /* * Make sure there is room for the next MXID in the file. Assigning this * MXID sets the next MXID's offset already. @@ -1257,15 +1375,13 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) * GetMultiXactIdMembers() for motivation. */ nextOffset = MultiXactState->nextOffset; - if (nextOffset == 0) - { + if (nextOffset == 0) { *offset = 1; - nmembers++; /* allocate member slot 0 too */ - } - else + nmembers++; /* allocate member slot 0 too */ + } else *offset = nextOffset; - /*---------- + /*---------- * Protect against overrun of the members space as well, with the * following rules: * @@ -1283,24 +1399,26 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) * effect. *---------- */ -#define OFFSET_WARN_SEGMENTS 20 - if (MultiXactState->oldestOffsetKnown && - MultiXactOffsetWouldWrap(MultiXactState->offsetStopLimit, nextOffset, - nmembers)) - { +#define OFFSET_WARN_SEGMENTS 20 + if (MultiXactState->oldestOffsetKnown + && MultiXactOffsetWouldWrap(MultiXactState->offsetStopLimit, nextOffset, nmembers)) { /* see comment in the corresponding offsets wraparound case */ SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_LAUNCHER); - ereport(ERROR, - (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), - errmsg("multixact \"members\" limit exceeded"), - errdetail_plural("This command would create a multixact with %u members, but the remaining space is only enough for %u member.", - "This command would create a multixact with %u members, but the remaining space is only enough for %u members.", - MultiXactState->offsetStopLimit - nextOffset - 1, - nmembers, - MultiXactState->offsetStopLimit - nextOffset - 1), - errhint("Execute a database-wide VACUUM in database with OID %u with reduced vacuum_multixact_freeze_min_age and vacuum_multixact_freeze_table_age settings.", - MultiXactState->oldestMultiXactDB))); + ereport( + ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("multixact \"members\" limit exceeded"), + errdetail_plural("This command would create a multixact with %u members, but the " + "remaining space is only enough for %u member.", + "This command would create a multixact with %u members, but the " + "remaining space is only enough for %u members.", + MultiXactState->offsetStopLimit - nextOffset - 1, nmembers, + MultiXactState->offsetStopLimit - nextOffset - 1), + errhint( + "Execute a database-wide VACUUM in database with OID %u with reduced " + "vacuum_multixact_freeze_min_age and vacuum_multixact_freeze_table_age settings.", + MultiXactState->oldestMultiXactDB))); } /* @@ -1309,33 +1427,36 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) * just the warning limit. The warning is just a measure of last resort - * this is in line with GetNewTransactionId's behaviour. */ - if (!MultiXactState->oldestOffsetKnown || - (MultiXactState->nextOffset - MultiXactState->oldestOffset - > MULTIXACT_MEMBER_SAFE_THRESHOLD)) - { + if (!MultiXactState->oldestOffsetKnown + || (MultiXactState->nextOffset - MultiXactState->oldestOffset + > MULTIXACT_MEMBER_SAFE_THRESHOLD)) { /* * To avoid swamping the postmaster with signals, we issue the autovac * request only when crossing a segment boundary. With default * compilation settings that's roughly after 50k members. This still * gives plenty of chances before we get into real trouble. */ - if ((MXOffsetToMemberPage(nextOffset) / SLRU_PAGES_PER_SEGMENT) != - (MXOffsetToMemberPage(nextOffset + nmembers) / SLRU_PAGES_PER_SEGMENT)) + if ((MXOffsetToMemberPage(nextOffset) / SLRU_PAGES_PER_SEGMENT) + != (MXOffsetToMemberPage(nextOffset + nmembers) / SLRU_PAGES_PER_SEGMENT)) SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_LAUNCHER); } - if (MultiXactState->oldestOffsetKnown && - MultiXactOffsetWouldWrap(MultiXactState->offsetStopLimit, - nextOffset, - nmembers + MULTIXACT_MEMBERS_PER_PAGE * SLRU_PAGES_PER_SEGMENT * OFFSET_WARN_SEGMENTS)) - ereport(WARNING, - (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), - errmsg_plural("database with OID %u must be vacuumed before %d more multixact member is used", - "database with OID %u must be vacuumed before %d more multixact members are used", - MultiXactState->offsetStopLimit - nextOffset + nmembers, - MultiXactState->oldestMultiXactDB, - MultiXactState->offsetStopLimit - nextOffset + nmembers), - errhint("Execute a database-wide VACUUM in that database with reduced vacuum_multixact_freeze_min_age and vacuum_multixact_freeze_table_age settings."))); + if (MultiXactState->oldestOffsetKnown + && MultiXactOffsetWouldWrap( + MultiXactState->offsetStopLimit, nextOffset, + nmembers + MULTIXACT_MEMBERS_PER_PAGE * SLRU_PAGES_PER_SEGMENT * OFFSET_WARN_SEGMENTS)) + ereport( + WARNING, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg_plural( + "database with OID %u must be vacuumed before %d more multixact member is used", + "database with OID %u must be vacuumed before %d more multixact members are used", + MultiXactState->offsetStopLimit - nextOffset + nmembers, + MultiXactState->oldestMultiXactDB, + MultiXactState->offsetStopLimit - nextOffset + nmembers), + errhint("Execute a database-wide VACUUM in that database with reduced " + "vacuum_multixact_freeze_min_age and vacuum_multixact_freeze_table_age " + "settings."))); ExtendMultiXactMember(nextOffset, nmembers); @@ -1359,7 +1480,13 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) * with either case. Similarly, nextOffset may be zero, but we won't use * that as the actual start offset of the next multixact. */ +#ifdef USE_PGRAC_CLUSTER + /* PGRAC: spec-7.1 D3-a -- republish the striped candidate + 1 (with + * striping off result == nextMXact, so this is the vanilla ++). */ + MultiXactState->nextMXact = result + 1; +#else (MultiXactState->nextMXact)++; +#endif MultiXactState->nextOffset += nmembers; @@ -1397,18 +1524,18 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) * old updates. */ int -GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, - bool from_pgupgrade, bool isLockOnly) +GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, bool from_pgupgrade, + bool isLockOnly) { - int pageno; - int prev_pageno; - int entryno; - int slotno; + int pageno; + int prev_pageno; + int entryno; + int slotno; MultiXactOffset *offptr; MultiXactOffset offset; - int length; - int truelength; - int i; + int length; + int truelength; + int i; MultiXactId oldestMXact; MultiXactId nextMXact; MultiXactId tmpMXact; @@ -1417,16 +1544,14 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, debug_elog3(DEBUG2, "GetMembers: asked for %u", multi); - if (!MultiXactIdIsValid(multi) || from_pgupgrade) - { + if (!MultiXactIdIsValid(multi) || from_pgupgrade) { *members = NULL; return -1; } /* See if the MultiXactId is in the local cache */ length = mXactCacheGetById(multi, members); - if (length >= 0) - { + if (length >= 0) { debug_elog3(DEBUG2, "GetMembers: found %s in the cache", mxid_to_string(multi, length, *members)); return length; @@ -1440,9 +1565,7 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, * we can skip checking if the value is older than our oldest visible * multi. It cannot possibly still be running. */ - if (isLockOnly && - MultiXactIdPrecedes(multi, OldestVisibleMXactId[MyBackendId])) - { + if (isLockOnly && MultiXactIdPrecedes(multi, OldestVisibleMXactId[MyBackendId])) { debug_elog2(DEBUG2, "GetMembers: a locker-only multi is too old"); *members = NULL; return -1; @@ -1474,14 +1597,12 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, if (MultiXactIdPrecedes(multi, oldestMXact)) ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), - errmsg("MultiXactId %u does no longer exist -- apparent wraparound", - multi))); + errmsg("MultiXactId %u does no longer exist -- apparent wraparound", multi))); if (!MultiXactIdPrecedes(multi, nextMXact)) ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), - errmsg("MultiXactId %u has not been created yet -- apparent wraparound", - multi))); + errmsg("MultiXactId %u has not been created yet -- apparent wraparound", multi))); /* * Find out the offset at which we need to start reading MultiXactMembers @@ -1515,7 +1636,7 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, entryno = MultiXactIdToOffsetEntry(multi); slotno = SimpleLruReadPage(MultiXactOffsetCtl, pageno, true, multi); - offptr = (MultiXactOffset *) MultiXactOffsetCtl->shared->page_buffer[slotno]; + offptr = (MultiXactOffset *)MultiXactOffsetCtl->shared->page_buffer[slotno]; offptr += entryno; offset = *offptr; @@ -1527,13 +1648,10 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, */ tmpMXact = multi + 1; - if (nextMXact == tmpMXact) - { + if (nextMXact == tmpMXact) { /* Corner case 1: there is no next multixact */ length = nextOffset - offset; - } - else - { + } else { MultiXactOffset nextMXOffset; /* handle wraparound if needed */ @@ -1548,50 +1666,44 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, if (pageno != prev_pageno) slotno = SimpleLruReadPage(MultiXactOffsetCtl, pageno, true, tmpMXact); - offptr = (MultiXactOffset *) MultiXactOffsetCtl->shared->page_buffer[slotno]; + offptr = (MultiXactOffset *)MultiXactOffsetCtl->shared->page_buffer[slotno]; offptr += entryno; nextMXOffset = *offptr; if (nextMXOffset == 0) - ereport(ERROR, - (errcode(ERRCODE_DATA_CORRUPTED), - errmsg("MultiXact %u has invalid next offset", - multi))); + ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("MultiXact %u has invalid next offset", multi))); length = nextMXOffset - offset; } LWLockRelease(MultiXactOffsetSLRULock); - ptr = (MultiXactMember *) palloc(length * sizeof(MultiXactMember)); + ptr = (MultiXactMember *)palloc(length * sizeof(MultiXactMember)); /* Now get the members themselves. */ LWLockAcquire(MultiXactMemberSLRULock, LW_EXCLUSIVE); truelength = 0; prev_pageno = -1; - for (i = 0; i < length; i++, offset++) - { + for (i = 0; i < length; i++, offset++) { TransactionId *xactptr; - uint32 *flagsptr; - int flagsoff; - int bshift; - int memberoff; + uint32 *flagsptr; + int flagsoff; + int bshift; + int memberoff; pageno = MXOffsetToMemberPage(offset); memberoff = MXOffsetToMemberOffset(offset); - if (pageno != prev_pageno) - { + if (pageno != prev_pageno) { slotno = SimpleLruReadPage(MultiXactMemberCtl, pageno, true, multi); prev_pageno = pageno; } - xactptr = (TransactionId *) - (MultiXactMemberCtl->shared->page_buffer[slotno] + memberoff); + xactptr = (TransactionId *)(MultiXactMemberCtl->shared->page_buffer[slotno] + memberoff); - if (!TransactionIdIsValid(*xactptr)) - { + if (!TransactionIdIsValid(*xactptr)) { /* * Corner case 2: offset must have wrapped around to unused slot * zero. @@ -1602,7 +1714,7 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, flagsoff = MXOffsetToFlagsOffset(offset); bshift = MXOffsetToFlagsBitShift(offset); - flagsptr = (uint32 *) (MultiXactMemberCtl->shared->page_buffer[slotno] + flagsoff); + flagsptr = (uint32 *)(MultiXactMemberCtl->shared->page_buffer[slotno] + flagsoff); ptr[truelength].xid = *xactptr; ptr[truelength].status = (*flagsptr >> bshift) & MXACT_MEMBER_XACT_BITMASK; @@ -1619,8 +1731,7 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, */ mXactCachePut(multi, truelength, ptr); - debug_elog3(DEBUG2, "GetMembers: no cache for %s", - mxid_to_string(multi, truelength, ptr)); + debug_elog3(DEBUG2, "GetMembers: no cache for %s", mxid_to_string(multi, truelength, ptr)); *members = ptr; return truelength; } @@ -1635,8 +1746,8 @@ GetMultiXactIdMembers(MultiXactId multi, MultiXactMember **members, static int mxactMemberComparator(const void *arg1, const void *arg2) { - MultiXactMember member1 = *(const MultiXactMember *) arg1; - MultiXactMember member2 = *(const MultiXactMember *) arg2; + MultiXactMember member1 = *(const MultiXactMember *)arg1; + MultiXactMember member2 = *(const MultiXactMember *)arg2; if (member1.xid > member2.xid) return 1; @@ -1665,7 +1776,7 @@ mxactMemberComparator(const void *arg1, const void *arg2) static MultiXactId mXactCacheGetBySet(int nmembers, MultiXactMember *members) { - dlist_iter iter; + dlist_iter iter; debug_elog3(DEBUG2, "CacheGet: looking for %s", mxid_to_string(InvalidMultiXactId, nmembers, members)); @@ -1675,8 +1786,7 @@ mXactCacheGetBySet(int nmembers, MultiXactMember *members) dclist_foreach(iter, &MXactCache) { - mXactCacheEnt *entry = dclist_container(mXactCacheEnt, node, - iter.cur); + mXactCacheEnt *entry = dclist_container(mXactCacheEnt, node, iter.cur); if (entry->nmembers != nmembers) continue; @@ -1685,8 +1795,7 @@ mXactCacheGetBySet(int nmembers, MultiXactMember *members) * We assume the cache entries are sorted, and that the unused bits in * "status" are zeroed. */ - if (memcmp(members, entry->members, nmembers * sizeof(MultiXactMember)) == 0) - { + if (memcmp(members, entry->members, nmembers * sizeof(MultiXactMember)) == 0) { debug_elog3(DEBUG2, "CacheGet: found %u", entry->multi); dclist_move_head(&MXactCache, iter.cur); return entry->multi; @@ -1708,29 +1817,25 @@ mXactCacheGetBySet(int nmembers, MultiXactMember *members) static int mXactCacheGetById(MultiXactId multi, MultiXactMember **members) { - dlist_iter iter; + dlist_iter iter; debug_elog3(DEBUG2, "CacheGet: looking for %u", multi); dclist_foreach(iter, &MXactCache) { - mXactCacheEnt *entry = dclist_container(mXactCacheEnt, node, - iter.cur); + mXactCacheEnt *entry = dclist_container(mXactCacheEnt, node, iter.cur); - if (entry->multi == multi) - { + if (entry->multi == multi) { MultiXactMember *ptr; - Size size; + Size size; size = sizeof(MultiXactMember) * entry->nmembers; - ptr = (MultiXactMember *) palloc(size); + ptr = (MultiXactMember *)palloc(size); memcpy(ptr, entry->members, size); debug_elog3(DEBUG2, "CacheGet: found %s", - mxid_to_string(multi, - entry->nmembers, - entry->members)); + mxid_to_string(multi, entry->nmembers, entry->members)); /* * Note we modify the list while not using a modifiable iterator. @@ -1757,22 +1862,17 @@ mXactCachePut(MultiXactId multi, int nmembers, MultiXactMember *members) { mXactCacheEnt *entry; - debug_elog3(DEBUG2, "CachePut: storing %s", - mxid_to_string(multi, nmembers, members)); + debug_elog3(DEBUG2, "CachePut: storing %s", mxid_to_string(multi, nmembers, members)); - if (MXactContext == NULL) - { + if (MXactContext == NULL) { /* The cache only lives as long as the current transaction */ debug_elog2(DEBUG2, "CachePut: initializing memory context"); - MXactContext = AllocSetContextCreate(TopTransactionContext, - "MultiXact cache context", + MXactContext = AllocSetContextCreate(TopTransactionContext, "MultiXact cache context", ALLOCSET_SMALL_SIZES); } - entry = (mXactCacheEnt *) - MemoryContextAlloc(MXactContext, - offsetof(mXactCacheEnt, members) + - nmembers * sizeof(MultiXactMember)); + entry = (mXactCacheEnt *)MemoryContextAlloc( + MXactContext, offsetof(mXactCacheEnt, members) + nmembers * sizeof(MultiXactMember)); entry->multi = multi; entry->nmembers = nmembers; @@ -1782,16 +1882,14 @@ mXactCachePut(MultiXactId multi, int nmembers, MultiXactMember *members) qsort(entry->members, nmembers, sizeof(MultiXactMember), mxactMemberComparator); dclist_push_head(&MXactCache, &entry->node); - if (dclist_count(&MXactCache) > MAX_CACHE_ENTRIES) - { + if (dclist_count(&MXactCache) > MAX_CACHE_ENTRIES) { dlist_node *node; node = dclist_tail_node(&MXactCache); dclist_delete_from(&MXactCache, node); entry = dclist_container(mXactCacheEnt, node, node); - debug_elog3(DEBUG2, "CachePut: pruning cached multi %u", - entry->multi); + debug_elog3(DEBUG2, "CachePut: pruning cached multi %u", entry->multi); pfree(entry); } @@ -1800,23 +1898,22 @@ mXactCachePut(MultiXactId multi, int nmembers, MultiXactMember *members) static char * mxstatus_to_string(MultiXactStatus status) { - switch (status) - { - case MultiXactStatusForKeyShare: - return "keysh"; - case MultiXactStatusForShare: - return "sh"; - case MultiXactStatusForNoKeyUpdate: - return "fornokeyupd"; - case MultiXactStatusForUpdate: - return "forupd"; - case MultiXactStatusNoKeyUpdate: - return "nokeyupd"; - case MultiXactStatusUpdate: - return "upd"; - default: - elog(ERROR, "unrecognized multixact status %d", status); - return ""; + switch (status) { + case MultiXactStatusForKeyShare: + return "keysh"; + case MultiXactStatusForShare: + return "sh"; + case MultiXactStatusForNoKeyUpdate: + return "fornokeyupd"; + case MultiXactStatusForUpdate: + return "forupd"; + case MultiXactStatusNoKeyUpdate: + return "nokeyupd"; + case MultiXactStatusUpdate: + return "upd"; + default: + elog(ERROR, "unrecognized multixact status %d", status); + return ""; } } @@ -1825,7 +1922,7 @@ mxid_to_string(MultiXactId multi, int nmembers, MultiXactMember *members) { static char *str = NULL; StringInfoData buf; - int i; + int i; if (str != NULL) pfree(str); @@ -1836,8 +1933,7 @@ mxid_to_string(MultiXactId multi, int nmembers, MultiXactMember *members) mxstatus_to_string(members[0].status)); for (i = 1; i < nmembers; i++) - appendStringInfo(&buf, ", %u (%s)", members[i].xid, - mxstatus_to_string(members[i].status)); + appendStringInfo(&buf, ", %u (%s)", members[i].xid, mxstatus_to_string(members[i].status)); appendStringInfoChar(&buf, ']'); str = MemoryContextStrdup(TopMemoryContext, buf.data); @@ -1885,8 +1981,7 @@ AtPrepare_MultiXact(void) MultiXactId myOldestMember = OldestMemberMXactId[MyBackendId]; if (MultiXactIdIsValid(myOldestMember)) - RegisterTwoPhaseRecord(TWOPHASE_RM_MULTIXACT_ID, 0, - &myOldestMember, sizeof(MultiXactId)); + RegisterTwoPhaseRecord(TWOPHASE_RM_MULTIXACT_ID, 0, &myOldestMember, sizeof(MultiXactId)); } /* @@ -1903,9 +1998,8 @@ PostPrepare_MultiXact(TransactionId xid) * prepared transaction. */ myOldestMember = OldestMemberMXactId[MyBackendId]; - if (MultiXactIdIsValid(myOldestMember)) - { - BackendId dummyBackendId = TwoPhaseGetDummyBackendId(xid, false); + if (MultiXactIdIsValid(myOldestMember)) { + BackendId dummyBackendId = TwoPhaseGetDummyBackendId(xid, false); /* * Even though storing MultiXactId is atomic, acquire lock to make @@ -1943,10 +2037,9 @@ PostPrepare_MultiXact(TransactionId xid) * Recover the state of a prepared transaction at startup */ void -multixact_twophase_recover(TransactionId xid, uint16 info, - void *recdata, uint32 len) +multixact_twophase_recover(TransactionId xid, uint16 info, void *recdata, uint32 len) { - BackendId dummyBackendId = TwoPhaseGetDummyBackendId(xid, false); + BackendId dummyBackendId = TwoPhaseGetDummyBackendId(xid, false); MultiXactId oldestMember; /* @@ -1954,7 +2047,7 @@ multixact_twophase_recover(TransactionId xid, uint16 info, * OldestMemberMXactId slot reserved for this prepared transaction. */ Assert(len == sizeof(MultiXactId)); - oldestMember = *((MultiXactId *) recdata); + oldestMember = *((MultiXactId *)recdata); OldestMemberMXactId[dummyBackendId] = oldestMember; } @@ -1964,10 +2057,9 @@ multixact_twophase_recover(TransactionId xid, uint16 info, * Similar to AtEOXact_MultiXact but for COMMIT PREPARED */ void -multixact_twophase_postcommit(TransactionId xid, uint16 info, - void *recdata, uint32 len) +multixact_twophase_postcommit(TransactionId xid, uint16 info, void *recdata, uint32 len) { - BackendId dummyBackendId = TwoPhaseGetDummyBackendId(xid, true); + BackendId dummyBackendId = TwoPhaseGetDummyBackendId(xid, true); Assert(len == sizeof(MultiXactId)); @@ -1979,8 +2071,7 @@ multixact_twophase_postcommit(TransactionId xid, uint16 info, * This is actually just the same as the COMMIT case. */ void -multixact_twophase_postabort(TransactionId xid, uint16 info, - void *recdata, uint32 len) +multixact_twophase_postabort(TransactionId xid, uint16 info, void *recdata, uint32 len) { multixact_twophase_postcommit(xid, info, recdata, len); } @@ -1993,11 +2084,11 @@ multixact_twophase_postabort(TransactionId xid, uint16 info, Size MultiXactShmemSize(void) { - Size size; + Size size; /* We need 2*MaxOldestSlot + 1 perBackendXactIds[] entries */ -#define SHARED_MULTIXACT_STATE_SIZE \ - add_size(offsetof(MultiXactStateData, perBackendXactIds) + sizeof(MultiXactId), \ +#define SHARED_MULTIXACT_STATE_SIZE \ + add_size(offsetof(MultiXactStateData, perBackendXactIds) + sizeof(MultiXactId), \ mul_size(sizeof(MultiXactId) * 2, MaxOldestSlot)) size = SHARED_MULTIXACT_STATE_SIZE; @@ -2010,38 +2101,30 @@ MultiXactShmemSize(void) void MultiXactShmemInit(void) { - bool found; + bool found; debug_elog2(DEBUG2, "Shared Memory Init for MultiXact"); MultiXactOffsetCtl->PagePrecedes = MultiXactOffsetPagePrecedes; MultiXactMemberCtl->PagePrecedes = MultiXactMemberPagePrecedes; - SimpleLruInit(MultiXactOffsetCtl, - "MultiXactOffset", NUM_MULTIXACTOFFSET_BUFFERS, 0, - MultiXactOffsetSLRULock, "pg_multixact/offsets", - LWTRANCHE_MULTIXACTOFFSET_BUFFER, + SimpleLruInit(MultiXactOffsetCtl, "MultiXactOffset", NUM_MULTIXACTOFFSET_BUFFERS, 0, + MultiXactOffsetSLRULock, "pg_multixact/offsets", LWTRANCHE_MULTIXACTOFFSET_BUFFER, SYNC_HANDLER_MULTIXACT_OFFSET); SlruPagePrecedesUnitTests(MultiXactOffsetCtl, MULTIXACT_OFFSETS_PER_PAGE); - SimpleLruInit(MultiXactMemberCtl, - "MultiXactMember", NUM_MULTIXACTMEMBER_BUFFERS, 0, - MultiXactMemberSLRULock, "pg_multixact/members", - LWTRANCHE_MULTIXACTMEMBER_BUFFER, + SimpleLruInit(MultiXactMemberCtl, "MultiXactMember", NUM_MULTIXACTMEMBER_BUFFERS, 0, + MultiXactMemberSLRULock, "pg_multixact/members", LWTRANCHE_MULTIXACTMEMBER_BUFFER, SYNC_HANDLER_MULTIXACT_MEMBER); /* doesn't call SimpleLruTruncate() or meet criteria for unit tests */ /* Initialize our shared state struct */ - MultiXactState = ShmemInitStruct("Shared MultiXact State", - SHARED_MULTIXACT_STATE_SIZE, - &found); - if (!IsUnderPostmaster) - { + MultiXactState = ShmemInitStruct("Shared MultiXact State", SHARED_MULTIXACT_STATE_SIZE, &found); + if (!IsUnderPostmaster) { Assert(!found); /* Make sure we zero out the per-backend state */ MemSet(MultiXactState, 0, SHARED_MULTIXACT_STATE_SIZE); - } - else + } else Assert(found); /* @@ -2060,7 +2143,7 @@ MultiXactShmemInit(void) void BootStrapMultiXact(void) { - int slotno; + int slotno; LWLockAcquire(MultiXactOffsetSLRULock, LW_EXCLUSIVE); @@ -2097,7 +2180,7 @@ BootStrapMultiXact(void) static int ZeroMultiXactOffsetPage(int pageno, bool writeXlog) { - int slotno; + int slotno; slotno = SimpleLruZeroPage(MultiXactOffsetCtl, pageno); @@ -2113,7 +2196,7 @@ ZeroMultiXactOffsetPage(int pageno, bool writeXlog) static int ZeroMultiXactMemberPage(int pageno, bool writeXlog) { - int slotno; + int slotno; slotno = SimpleLruZeroPage(MultiXactMemberCtl, pageno); @@ -2141,15 +2224,14 @@ ZeroMultiXactMemberPage(int pageno, bool writeXlog) static void MaybeExtendOffsetSlru(void) { - int pageno; + int pageno; pageno = MultiXactIdToOffsetPage(MultiXactState->nextMXact); LWLockAcquire(MultiXactOffsetSLRULock, LW_EXCLUSIVE); - if (!SimpleLruDoesPhysicalPageExist(MultiXactOffsetCtl, pageno)) - { - int slotno; + if (!SimpleLruDoesPhysicalPageExist(MultiXactOffsetCtl, pageno)) { + int slotno; /* * Fortunately for us, SimpleLruWritePage is already prepared to deal @@ -2176,7 +2258,7 @@ StartupMultiXact(void) { MultiXactId multi = MultiXactState->nextMXact; MultiXactOffset offset = MultiXactState->nextOffset; - int pageno; + int pageno; /* * Initialize offset's idea of the latest page number. @@ -2200,10 +2282,10 @@ TrimMultiXact(void) MultiXactId nextMXact; MultiXactOffset offset; MultiXactId oldestMXact; - Oid oldestMXactDB; - int pageno; - int entryno; - int flagsoff; + Oid oldestMXactDB; + int pageno; + int entryno; + int flagsoff; LWLockAcquire(MultiXactGenLock, LW_SHARED); nextMXact = MultiXactState->nextMXact; @@ -2235,14 +2317,14 @@ TrimMultiXact(void) */ entryno = MultiXactIdToOffsetEntry(nextMXact); { - int slotno; + int slotno; MultiXactOffset *offptr; if (entryno == 0) slotno = SimpleLruZeroPage(MultiXactOffsetCtl, pageno); else slotno = SimpleLruReadPage(MultiXactOffsetCtl, pageno, true, nextMXact); - offptr = (MultiXactOffset *) MultiXactOffsetCtl->shared->page_buffer[slotno]; + offptr = (MultiXactOffset *)MultiXactOffsetCtl->shared->page_buffer[slotno]; offptr += entryno; *offptr = offset; @@ -2268,16 +2350,14 @@ TrimMultiXact(void) * TrimCLOG() for motivation. */ flagsoff = MXOffsetToFlagsOffset(offset); - if (flagsoff != 0) - { - int slotno; + if (flagsoff != 0) { + int slotno; TransactionId *xidptr; - int memberoff; + int memberoff; memberoff = MXOffsetToMemberOffset(offset); slotno = SimpleLruReadPage(MultiXactMemberCtl, pageno, true, offset); - xidptr = (TransactionId *) - (MultiXactMemberCtl->shared->page_buffer[slotno] + memberoff); + xidptr = (TransactionId *)(MultiXactMemberCtl->shared->page_buffer[slotno] + memberoff); MemSet(xidptr, 0, BLCKSZ - memberoff); @@ -2305,11 +2385,8 @@ TrimMultiXact(void) * Get the MultiXact data to save in a checkpoint record */ void -MultiXactGetCheckptMulti(bool is_shutdown, - MultiXactId *nextMulti, - MultiXactOffset *nextMultiOffset, - MultiXactId *oldestMulti, - Oid *oldestMultiDB) +MultiXactGetCheckptMulti(bool is_shutdown, MultiXactId *nextMulti, MultiXactOffset *nextMultiOffset, + MultiXactId *oldestMulti, Oid *oldestMultiDB) { LWLockAcquire(MultiXactGenLock, LW_SHARED); *nextMulti = MultiXactState->nextMXact; @@ -2351,11 +2428,10 @@ CheckPointMultiXact(void) * examining the values. */ void -MultiXactSetNextMXact(MultiXactId nextMulti, - MultiXactOffset nextMultiOffset) +MultiXactSetNextMXact(MultiXactId nextMulti, MultiXactOffset nextMultiOffset) { - debug_elog4(DEBUG2, "MultiXact: setting next multi to %u offset %u", - nextMulti, nextMultiOffset); + debug_elog4(DEBUG2, "MultiXact: setting next multi to %u offset %u", nextMulti, + nextMultiOffset); LWLockAcquire(MultiXactGenLock, LW_EXCLUSIVE); MultiXactState->nextMXact = nextMulti; MultiXactState->nextOffset = nextMultiOffset; @@ -2385,15 +2461,14 @@ MultiXactSetNextMXact(MultiXactId nextMulti, * are updating state in a running cluster. This only affects log messages. */ void -SetMultiXactIdLimit(MultiXactId oldest_datminmxid, Oid oldest_datoid, - bool is_startup) +SetMultiXactIdLimit(MultiXactId oldest_datminmxid, Oid oldest_datoid, bool is_startup) { MultiXactId multiVacLimit; MultiXactId multiWarnLimit; MultiXactId multiStopLimit; MultiXactId multiWrapLimit; MultiXactId curMulti; - bool needs_offset_vacuum; + bool needs_offset_vacuum; Assert(MultiXactIdIsValid(oldest_datminmxid)); @@ -2480,14 +2555,12 @@ SetMultiXactIdLimit(MultiXactId oldest_datminmxid, Oid oldest_datoid, * database, it'll call here, and we'll signal the postmaster to start * another iteration immediately if there are still any old databases. */ - if ((MultiXactIdPrecedes(multiVacLimit, curMulti) || - needs_offset_vacuum) && IsUnderPostmaster) + if ((MultiXactIdPrecedes(multiVacLimit, curMulti) || needs_offset_vacuum) && IsUnderPostmaster) SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_LAUNCHER); /* Give an immediate warning if past the wrap warn point */ - if (MultiXactIdPrecedes(multiWarnLimit, curMulti)) - { - char *oldest_datname; + if (MultiXactIdPrecedes(multiWarnLimit, curMulti)) { + char *oldest_datname; /* * We can be called when not inside a transaction, for example during @@ -2505,22 +2578,25 @@ SetMultiXactIdLimit(MultiXactId oldest_datminmxid, Oid oldest_datoid, if (oldest_datname) ereport(WARNING, - (errmsg_plural("database \"%s\" must be vacuumed before %u more MultiXactId is used", - "database \"%s\" must be vacuumed before %u more MultiXactIds are used", - multiWrapLimit - curMulti, - oldest_datname, - multiWrapLimit - curMulti), - errhint("To avoid a database shutdown, execute a database-wide VACUUM in that database.\n" - "You might also need to commit or roll back old prepared transactions, or drop stale replication slots."))); + (errmsg_plural( + "database \"%s\" must be vacuumed before %u more MultiXactId is used", + "database \"%s\" must be vacuumed before %u more MultiXactIds are used", + multiWrapLimit - curMulti, oldest_datname, multiWrapLimit - curMulti), + errhint("To avoid a database shutdown, execute a database-wide VACUUM in that " + "database.\n" + "You might also need to commit or roll back old prepared " + "transactions, or drop stale replication slots."))); else - ereport(WARNING, - (errmsg_plural("database with OID %u must be vacuumed before %u more MultiXactId is used", - "database with OID %u must be vacuumed before %u more MultiXactIds are used", - multiWrapLimit - curMulti, - oldest_datoid, - multiWrapLimit - curMulti), - errhint("To avoid a database shutdown, execute a database-wide VACUUM in that database.\n" - "You might also need to commit or roll back old prepared transactions, or drop stale replication slots."))); + ereport( + WARNING, + (errmsg_plural( + "database with OID %u must be vacuumed before %u more MultiXactId is used", + "database with OID %u must be vacuumed before %u more MultiXactIds are used", + multiWrapLimit - curMulti, oldest_datoid, multiWrapLimit - curMulti), + errhint("To avoid a database shutdown, execute a database-wide VACUUM in that " + "database.\n" + "You might also need to commit or roll back old prepared transactions, or " + "drop stale replication slots."))); } } @@ -2534,19 +2610,15 @@ SetMultiXactIdLimit(MultiXactId oldest_datminmxid, Oid oldest_datoid, * any hot-standby backends are examining the values. */ void -MultiXactAdvanceNextMXact(MultiXactId minMulti, - MultiXactOffset minMultiOffset) +MultiXactAdvanceNextMXact(MultiXactId minMulti, MultiXactOffset minMultiOffset) { LWLockAcquire(MultiXactGenLock, LW_EXCLUSIVE); - if (MultiXactIdPrecedes(MultiXactState->nextMXact, minMulti)) - { + if (MultiXactIdPrecedes(MultiXactState->nextMXact, minMulti)) { debug_elog3(DEBUG2, "MultiXact: setting next multi to %u", minMulti); MultiXactState->nextMXact = minMulti; } - if (MultiXactOffsetPrecedes(MultiXactState->nextOffset, minMultiOffset)) - { - debug_elog3(DEBUG2, "MultiXact: setting next offset to %u", - minMultiOffset); + if (MultiXactOffsetPrecedes(MultiXactState->nextOffset, minMultiOffset)) { + debug_elog3(DEBUG2, "MultiXact: setting next offset to %u", minMultiOffset); MultiXactState->nextOffset = minMultiOffset; } LWLockRelease(MultiXactGenLock); @@ -2578,14 +2650,13 @@ MultiXactAdvanceOldest(MultiXactId oldestMulti, Oid oldestMultiDB) static void ExtendMultiXactOffset(MultiXactId multi) { - int pageno; + int pageno; /* * No work except at first MultiXactId of a page. But beware: just after * wraparound, the first MultiXactId of page zero is FirstMultiXactId. */ - if (MultiXactIdToOffsetEntry(multi) != 0 && - multi != FirstMultiXactId) + if (MultiXactIdToOffsetEntry(multi) != 0 && multi != FirstMultiXactId) return; pageno = MultiXactIdToOffsetPage(multi); @@ -2614,20 +2685,18 @@ ExtendMultiXactMember(MultiXactOffset offset, int nmembers) * optimal if the members span several pages, but that seems unusual * enough to not worry much about. */ - while (nmembers > 0) - { - int flagsoff; - int flagsbit; - uint32 difference; + while (nmembers > 0) { + int flagsoff; + int flagsbit; + uint32 difference; /* * Only zero when at first entry of a page. */ flagsoff = MXOffsetToFlagsOffset(offset); flagsbit = MXOffsetToFlagsBitShift(offset); - if (flagsoff == 0 && flagsbit == 0) - { - int pageno; + if (flagsoff == 0 && flagsbit == 0) { + int pageno; pageno = MXOffsetToMemberPage(offset); @@ -2645,16 +2714,14 @@ ExtendMultiXactMember(MultiXactOffset offset, int nmembers) * the last segment; since that page holds a different number of items * than other pages, we need to do it differently. */ - if (offset + MAX_MEMBERS_IN_LAST_MEMBERS_PAGE < offset) - { + if (offset + MAX_MEMBERS_IN_LAST_MEMBERS_PAGE < offset) { /* * This is the last page of the last segment; we can compute the * number of items left to allocate in it without modulo * arithmetic. */ difference = MaxMultiXactOffset - offset + 1; - } - else + } else difference = MULTIXACT_MEMBERS_PER_PAGE - offset % MULTIXACT_MEMBERS_PER_PAGE; /* @@ -2683,7 +2750,7 @@ GetOldestMultiXactId(void) { MultiXactId oldestMXact; MultiXactId nextMXact; - int i; + int i; /* * This is the oldest valid value among all the OldestMemberMXactId[] and @@ -2701,17 +2768,14 @@ GetOldestMultiXactId(void) nextMXact = FirstMultiXactId; oldestMXact = nextMXact; - for (i = 1; i <= MaxOldestSlot; i++) - { + for (i = 1; i <= MaxOldestSlot; i++) { MultiXactId thisoldest; thisoldest = OldestMemberMXactId[i]; - if (MultiXactIdIsValid(thisoldest) && - MultiXactIdPrecedes(thisoldest, oldestMXact)) + if (MultiXactIdIsValid(thisoldest) && MultiXactIdPrecedes(thisoldest, oldestMXact)) oldestMXact = thisoldest; thisoldest = OldestVisibleMXactId[i]; - if (MultiXactIdIsValid(thisoldest) && - MultiXactIdPrecedes(thisoldest, oldestMXact)) + if (MultiXactIdIsValid(thisoldest) && MultiXactIdPrecedes(thisoldest, oldestMXact)) oldestMXact = thisoldest; } @@ -2736,11 +2800,11 @@ SetOffsetVacuumLimit(bool is_startup) { MultiXactId oldestMultiXactId; MultiXactId nextMXact; - MultiXactOffset oldestOffset = 0; /* placate compiler */ + MultiXactOffset oldestOffset = 0; /* placate compiler */ MultiXactOffset prevOldestOffset; MultiXactOffset nextOffset; - bool oldestOffsetKnown = false; - bool prevOldestOffsetKnown; + bool oldestOffsetKnown = false; + bool prevOldestOffsetKnown; MultiXactOffset offsetStopLimit = 0; MultiXactOffset prevOffsetStopLimit; @@ -2768,34 +2832,29 @@ SetOffsetVacuumLimit(bool is_startup) * obviously can't point to one. It will instead point to the multixact * ID that will be assigned the next time one is needed. */ - if (oldestMultiXactId == nextMXact) - { + if (oldestMultiXactId == nextMXact) { /* * When the next multixact gets created, it will be stored at the next * offset. */ oldestOffset = nextOffset; oldestOffsetKnown = true; - } - else - { + } else { /* * Figure out where the oldest existing multixact's offsets are * stored. Due to bugs in early release of PostgreSQL 9.3.X and 9.4.X, * the supposedly-earliest multixact might not really exist. We are * careful not to fail in that case. */ - oldestOffsetKnown = - find_multixact_start(oldestMultiXactId, &oldestOffset); + oldestOffsetKnown = find_multixact_start(oldestMultiXactId, &oldestOffset); if (oldestOffsetKnown) ereport(DEBUG1, - (errmsg_internal("oldest MultiXactId member is at offset %u", - oldestOffset))); + (errmsg_internal("oldest MultiXactId member is at offset %u", oldestOffset))); else - ereport(LOG, - (errmsg("MultiXact member wraparound protections are disabled because oldest checkpointed MultiXact %u does not exist on disk", - oldestMultiXactId))); + ereport(LOG, (errmsg("MultiXact member wraparound protections are disabled because " + "oldest checkpointed MultiXact %u does not exist on disk", + oldestMultiXactId))); } LWLockRelease(MultiXactTruncationLock); @@ -2805,25 +2864,21 @@ SetOffsetVacuumLimit(bool is_startup) * overrun of old data in the members SLRU area. We can only do so if the * oldest offset is known though. */ - if (oldestOffsetKnown) - { + if (oldestOffsetKnown) { /* move back to start of the corresponding segment */ - offsetStopLimit = oldestOffset - (oldestOffset % - (MULTIXACT_MEMBERS_PER_PAGE * SLRU_PAGES_PER_SEGMENT)); + offsetStopLimit + = oldestOffset - (oldestOffset % (MULTIXACT_MEMBERS_PER_PAGE * SLRU_PAGES_PER_SEGMENT)); /* always leave one segment before the wraparound point */ offsetStopLimit -= (MULTIXACT_MEMBERS_PER_PAGE * SLRU_PAGES_PER_SEGMENT); if (!prevOldestOffsetKnown && !is_startup) - ereport(LOG, - (errmsg("MultiXact member wraparound protections are now enabled"))); + ereport(LOG, (errmsg("MultiXact member wraparound protections are now enabled"))); ereport(DEBUG1, (errmsg_internal("MultiXact member stop limit is now %u based on MultiXact %u", offsetStopLimit, oldestMultiXactId))); - } - else if (prevOldestOffsetKnown) - { + } else if (prevOldestOffsetKnown) { /* * If we failed to get the oldest offset this time, but we have a * value from a previous pass through this function, use the old @@ -2845,8 +2900,7 @@ SetOffsetVacuumLimit(bool is_startup) /* * Do we need an emergency autovacuum? If we're not sure, assume yes. */ - return !oldestOffsetKnown || - (nextOffset - oldestOffset > MULTIXACT_MEMBER_SAFE_THRESHOLD); + return !oldestOffsetKnown || (nextOffset - oldestOffset > MULTIXACT_MEMBER_SAFE_THRESHOLD); } /* @@ -2859,8 +2913,7 @@ SetOffsetVacuumLimit(bool is_startup) * otherwise. */ static bool -MultiXactOffsetWouldWrap(MultiXactOffset boundary, MultiXactOffset start, - uint32 distance) +MultiXactOffsetWouldWrap(MultiXactOffset boundary, MultiXactOffset start, uint32 distance) { MultiXactOffset finish; @@ -2910,9 +2963,9 @@ static bool find_multixact_start(MultiXactId multi, MultiXactOffset *result) { MultiXactOffset offset; - int pageno; - int entryno; - int slotno; + int pageno; + int entryno; + int slotno; MultiXactOffset *offptr; Assert(MultiXactState->finishedStartup); @@ -2931,7 +2984,7 @@ find_multixact_start(MultiXactId multi, MultiXactOffset *result) /* lock is acquired by SimpleLruReadPage_ReadOnly */ slotno = SimpleLruReadPage_ReadOnly(MultiXactOffsetCtl, pageno, multi); - offptr = (MultiXactOffset *) MultiXactOffsetCtl->shared->page_buffer[slotno]; + offptr = (MultiXactOffset *)MultiXactOffsetCtl->shared->page_buffer[slotno]; offptr += entryno; offset = *offptr; LWLockRelease(MultiXactOffsetSLRULock); @@ -2951,7 +3004,7 @@ ReadMultiXactCounts(uint32 *multixacts, MultiXactOffset *members) MultiXactOffset oldestOffset; MultiXactId oldestMultiXactId; MultiXactId nextMultiXactId; - bool oldestOffsetKnown; + bool oldestOffsetKnown; LWLockAcquire(MultiXactGenLock, LW_SHARED); nextOffset = MultiXactState->nextOffset; @@ -3000,10 +3053,10 @@ int MultiXactMemberFreezeThreshold(void) { MultiXactOffset members; - uint32 multixacts; - uint32 victim_multixacts; - double fraction; - int result; + uint32 multixacts; + uint32 victim_multixacts; + double fraction; + int result; /* If we can't determine member space utilization, assume the worst. */ if (!ReadMultiXactCounts(&multixacts, &members)) @@ -3018,8 +3071,8 @@ MultiXactMemberFreezeThreshold(void) * we try to eliminate from the system is based on how far we are past * MULTIXACT_MEMBER_SAFE_THRESHOLD. */ - fraction = (double) (members - MULTIXACT_MEMBER_SAFE_THRESHOLD) / - (MULTIXACT_MEMBER_DANGER_THRESHOLD - MULTIXACT_MEMBER_SAFE_THRESHOLD); + fraction = (double)(members - MULTIXACT_MEMBER_SAFE_THRESHOLD) + / (MULTIXACT_MEMBER_DANGER_THRESHOLD - MULTIXACT_MEMBER_SAFE_THRESHOLD); victim_multixacts = multixacts * fraction; /* fraction could be > 1.0, but lowest possible freeze age is zero */ @@ -3034,9 +3087,8 @@ MultiXactMemberFreezeThreshold(void) return Min(result, autovacuum_multixact_freeze_max_age); } -typedef struct mxtruncinfo -{ - int earliestExistingPage; +typedef struct mxtruncinfo { + int earliestExistingPage; } mxtruncinfo; /* @@ -3046,15 +3098,14 @@ typedef struct mxtruncinfo static bool SlruScanDirCbFindEarliest(SlruCtl ctl, char *filename, int segpage, void *data) { - mxtruncinfo *trunc = (mxtruncinfo *) data; + mxtruncinfo *trunc = (mxtruncinfo *)data; - if (trunc->earliestExistingPage == -1 || - ctl->PagePrecedes(segpage, trunc->earliestExistingPage)) - { + if (trunc->earliestExistingPage == -1 + || ctl->PagePrecedes(segpage, trunc->earliestExistingPage)) { trunc->earliestExistingPage = segpage; } - return false; /* keep going */ + return false; /* keep going */ } @@ -3069,17 +3120,16 @@ SlruScanDirCbFindEarliest(SlruCtl ctl, char *filename, int segpage, void *data) static void PerformMembersTruncation(MultiXactOffset oldestOffset, MultiXactOffset newOldestOffset) { - const int maxsegment = MXOffsetToMemberSegment(MaxMultiXactOffset); - int startsegment = MXOffsetToMemberSegment(oldestOffset); - int endsegment = MXOffsetToMemberSegment(newOldestOffset); - int segment = startsegment; + const int maxsegment = MXOffsetToMemberSegment(MaxMultiXactOffset); + int startsegment = MXOffsetToMemberSegment(oldestOffset); + int endsegment = MXOffsetToMemberSegment(newOldestOffset); + int segment = startsegment; /* * Delete all the segments but the last one. The last segment can still * contain, possibly partially, valid data. */ - while (segment != endsegment) - { + while (segment != endsegment) { elog(DEBUG2, "truncating multixact members segment %x", segment); SlruDeleteSegment(MultiXactMemberCtl, segment); @@ -3153,8 +3203,7 @@ TruncateMultiXact(MultiXactId newOldestMulti, Oid newOldestMultiDB) * away. In normal processing values shouldn't go backwards, but there's * some corner cases (due to bugs) where that's possible. */ - if (MultiXactIdPrecedesOrEquals(newOldestMulti, oldestMulti)) - { + if (MultiXactIdPrecedesOrEquals(newOldestMulti, oldestMulti)) { LWLockRelease(MultiXactTruncationLock); return; } @@ -3184,8 +3233,7 @@ TruncateMultiXact(MultiXactId newOldestMulti, Oid newOldestMultiDB) earliest = FirstMultiXactId; /* If there's nothing to remove, we can bail out early. */ - if (MultiXactIdPrecedes(oldestMulti, earliest)) - { + if (MultiXactIdPrecedes(oldestMulti, earliest)) { LWLockRelease(MultiXactTruncationLock); return; } @@ -3198,13 +3246,10 @@ TruncateMultiXact(MultiXactId newOldestMulti, Oid newOldestMultiDB) * already checked that it doesn't precede the earliest MultiXact on disk. * But if it fails, don't truncate anything, and log a message. */ - if (oldestMulti == nextMulti) - { + if (oldestMulti == nextMulti) { /* there are NO MultiXacts */ oldestOffset = nextOffset; - } - else if (!find_multixact_start(oldestMulti, &oldestOffset)) - { + } else if (!find_multixact_start(oldestMulti, &oldestOffset)) { ereport(LOG, (errmsg("oldest MultiXact %u not found, earliest MultiXact %u, skipping truncation", oldestMulti, earliest))); @@ -3216,16 +3261,13 @@ TruncateMultiXact(MultiXactId newOldestMulti, Oid newOldestMultiDB) * Secondly compute up to where to truncate. Lookup the corresponding * member offset for newOldestMulti for that. */ - if (newOldestMulti == nextMulti) - { + if (newOldestMulti == nextMulti) { /* there are NO MultiXacts */ newOldestOffset = nextOffset; - } - else if (!find_multixact_start(newOldestMulti, &newOldestOffset)) - { - ereport(LOG, - (errmsg("cannot truncate up to MultiXact %u because it does not exist on disk, skipping truncation", - newOldestMulti))); + } else if (!find_multixact_start(newOldestMulti, &newOldestOffset)) { + ereport(LOG, (errmsg("cannot truncate up to MultiXact %u because it does not exist on " + "disk, skipping truncation", + newOldestMulti))); LWLockRelease(MultiXactTruncationLock); return; } @@ -3238,24 +3280,21 @@ TruncateMultiXact(MultiXactId newOldestMulti, Oid newOldestMultiDB) * it. Just skip the truncation, and hope that by the next truncation * attempt, oldestMulti has advanced to a valid multixid. */ - if (newOldestOffset == 0) - { - ereport(LOG, - (errmsg("cannot truncate up to MultiXact %u because it has invalid offset, skipping truncation", - newOldestMulti))); + if (newOldestOffset == 0) { + ereport(LOG, (errmsg("cannot truncate up to MultiXact %u because it has invalid offset, " + "skipping truncation", + newOldestMulti))); LWLockRelease(MultiXactTruncationLock); return; } - elog(DEBUG1, "performing multixact truncation: " + elog(DEBUG1, + "performing multixact truncation: " "offsets [%u, %u), offsets segments [%x, %x), " "members [%u, %u), members segments [%x, %x)", - oldestMulti, newOldestMulti, - MultiXactIdToOffsetSegment(oldestMulti), - MultiXactIdToOffsetSegment(newOldestMulti), - oldestOffset, newOldestOffset, - MXOffsetToMemberSegment(oldestOffset), - MXOffsetToMemberSegment(newOldestOffset)); + oldestMulti, newOldestMulti, MultiXactIdToOffsetSegment(oldestMulti), + MultiXactIdToOffsetSegment(newOldestMulti), oldestOffset, newOldestOffset, + MXOffsetToMemberSegment(oldestOffset), MXOffsetToMemberSegment(newOldestOffset)); /* * Do truncation, and the WAL logging of the truncation, in a critical @@ -3275,9 +3314,8 @@ TruncateMultiXact(MultiXactId newOldestMulti, Oid newOldestMultiDB) MyProc->delayChkptFlags |= DELAY_CHKPT_START; /* WAL log truncation */ - WriteMTruncateXlogRec(newOldestMultiDB, - oldestMulti, newOldestMulti, - oldestOffset, newOldestOffset); + WriteMTruncateXlogRec(newOldestMultiDB, oldestMulti, newOldestMulti, oldestOffset, + newOldestOffset); /* * Update in-memory limits before performing the truncation, while inside @@ -3317,14 +3355,13 @@ MultiXactOffsetPagePrecedes(int page1, int page2) MultiXactId multi1; MultiXactId multi2; - multi1 = ((MultiXactId) page1) * MULTIXACT_OFFSETS_PER_PAGE; + multi1 = ((MultiXactId)page1) * MULTIXACT_OFFSETS_PER_PAGE; multi1 += FirstMultiXactId + 1; - multi2 = ((MultiXactId) page2) * MULTIXACT_OFFSETS_PER_PAGE; + multi2 = ((MultiXactId)page2) * MULTIXACT_OFFSETS_PER_PAGE; multi2 += FirstMultiXactId + 1; - return (MultiXactIdPrecedes(multi1, multi2) && - MultiXactIdPrecedes(multi1, - multi2 + MULTIXACT_OFFSETS_PER_PAGE - 1)); + return (MultiXactIdPrecedes(multi1, multi2) + && MultiXactIdPrecedes(multi1, multi2 + MULTIXACT_OFFSETS_PER_PAGE - 1)); } /* @@ -3337,12 +3374,11 @@ MultiXactMemberPagePrecedes(int page1, int page2) MultiXactOffset offset1; MultiXactOffset offset2; - offset1 = ((MultiXactOffset) page1) * MULTIXACT_MEMBERS_PER_PAGE; - offset2 = ((MultiXactOffset) page2) * MULTIXACT_MEMBERS_PER_PAGE; + offset1 = ((MultiXactOffset)page1) * MULTIXACT_MEMBERS_PER_PAGE; + offset2 = ((MultiXactOffset)page2) * MULTIXACT_MEMBERS_PER_PAGE; - return (MultiXactOffsetPrecedes(offset1, offset2) && - MultiXactOffsetPrecedes(offset1, - offset2 + MULTIXACT_MEMBERS_PER_PAGE - 1)); + return (MultiXactOffsetPrecedes(offset1, offset2) + && MultiXactOffsetPrecedes(offset1, offset2 + MULTIXACT_MEMBERS_PER_PAGE - 1)); } /* @@ -3354,7 +3390,7 @@ MultiXactMemberPagePrecedes(int page1, int page2) bool MultiXactIdPrecedes(MultiXactId multi1, MultiXactId multi2) { - int32 diff = (int32) (multi1 - multi2); + int32 diff = (int32)(multi1 - multi2); return (diff < 0); } @@ -3368,7 +3404,7 @@ MultiXactIdPrecedes(MultiXactId multi1, MultiXactId multi2) bool MultiXactIdPrecedesOrEquals(MultiXactId multi1, MultiXactId multi2) { - int32 diff = (int32) (multi1 - multi2); + int32 diff = (int32)(multi1 - multi2); return (diff <= 0); } @@ -3380,7 +3416,7 @@ MultiXactIdPrecedesOrEquals(MultiXactId multi1, MultiXactId multi2) static bool MultiXactOffsetPrecedes(MultiXactOffset offset1, MultiXactOffset offset2) { - int32 diff = (int32) (offset1 - offset2); + int32 diff = (int32)(offset1 - offset2); return (diff < 0); } @@ -3393,8 +3429,8 @@ static void WriteMZeroPageXlogRec(int pageno, uint8 info) { XLogBeginInsert(); - XLogRegisterData((char *) (&pageno), sizeof(int)); - (void) XLogInsert(RM_MULTIXACT_ID, info); + XLogRegisterData((char *)(&pageno), sizeof(int)); + (void)XLogInsert(RM_MULTIXACT_ID, info); } /* @@ -3404,11 +3440,10 @@ WriteMZeroPageXlogRec(int pageno, uint8 info) * TruncateCLOG(). */ static void -WriteMTruncateXlogRec(Oid oldestMultiDB, - MultiXactId startTruncOff, MultiXactId endTruncOff, +WriteMTruncateXlogRec(Oid oldestMultiDB, MultiXactId startTruncOff, MultiXactId endTruncOff, MultiXactOffset startTruncMemb, MultiXactOffset endTruncMemb) { - XLogRecPtr recptr; + XLogRecPtr recptr; xl_multixact_truncate xlrec; xlrec.oldestMultiDB = oldestMultiDB; @@ -3420,7 +3455,7 @@ WriteMTruncateXlogRec(Oid oldestMultiDB, xlrec.endTruncMemb = endTruncMemb; XLogBeginInsert(); - XLogRegisterData((char *) (&xlrec), SizeOfMultiXactTruncate); + XLogRegisterData((char *)(&xlrec), SizeOfMultiXactTruncate); recptr = XLogInsert(RM_MULTIXACT_ID, XLOG_MULTIXACT_TRUNCATE_ID); XLogFlush(recptr); } @@ -3431,15 +3466,14 @@ WriteMTruncateXlogRec(Oid oldestMultiDB, void multixact_redo(XLogReaderState *record) { - uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; /* Backup blocks are not used in multixact records */ Assert(!XLogRecHasAnyBlockRefs(record)); - if (info == XLOG_MULTIXACT_ZERO_OFF_PAGE) - { - int pageno; - int slotno; + if (info == XLOG_MULTIXACT_ZERO_OFF_PAGE) { + int pageno; + int slotno; memcpy(&pageno, XLogRecGetData(record), sizeof(int)); @@ -3447,22 +3481,21 @@ multixact_redo(XLogReaderState *record) * Skip the record if we already initialized the page at the previous * XLOG_MULTIXACT_CREATE_ID record. See RecordNewMultiXact(). */ - if (pre_initialized_offsets_page != pageno) - { + if (pre_initialized_offsets_page != pageno) { LWLockAcquire(MultiXactOffsetSLRULock, LW_EXCLUSIVE); slotno = ZeroMultiXactOffsetPage(pageno, false); SimpleLruWritePage(MultiXactOffsetCtl, slotno); Assert(!MultiXactOffsetCtl->shared->page_dirty[slotno]); LWLockRelease(MultiXactOffsetSLRULock); - } - else - elog(DEBUG1, "skipping initialization of offsets page %d because it was already initialized on multixid creation", pageno); + } else + elog(DEBUG1, + "skipping initialization of offsets page %d because it was already initialized on " + "multixid creation", + pageno); pre_initialized_offsets_page = -1; - } - else if (info == XLOG_MULTIXACT_ZERO_MEM_PAGE) - { - int pageno; - int slotno; + } else if (info == XLOG_MULTIXACT_ZERO_MEM_PAGE) { + int pageno; + int slotno; memcpy(&pageno, XLogRecGetData(record), sizeof(int)); @@ -3473,16 +3506,12 @@ multixact_redo(XLogReaderState *record) Assert(!MultiXactMemberCtl->shared->page_dirty[slotno]); LWLockRelease(MultiXactMemberSLRULock); - } - else if (info == XLOG_MULTIXACT_CREATE_ID) - { - xl_multixact_create *xlrec = - (xl_multixact_create *) XLogRecGetData(record); + } else if (info == XLOG_MULTIXACT_CREATE_ID) { + xl_multixact_create *xlrec = (xl_multixact_create *)XLogRecGetData(record); TransactionId max_xid; - int i; + int i; - if (pre_initialized_offsets_page != -1) - { + if (pre_initialized_offsets_page != -1) { /* * If we implicitly initialized the next offsets page while * replaying an XLOG_MULTIXACT_CREATE_ID record that was generated @@ -3492,18 +3521,18 @@ multixact_redo(XLogReaderState *record) * not happen. If it does, we'll continue with the replay, but * log a message to note that something's funny. */ - elog(LOG, "expected to see an XLOG_MULTIXACT_ZERO_OFF_PAGE record for page %d that was implicitly initialized earlier", + elog(LOG, + "expected to see an XLOG_MULTIXACT_ZERO_OFF_PAGE record for page %d that was " + "implicitly initialized earlier", pre_initialized_offsets_page); pre_initialized_offsets_page = -1; } /* Store the data back into the SLRU files */ - RecordNewMultiXact(xlrec->mid, xlrec->moff, xlrec->nmembers, - xlrec->members); + RecordNewMultiXact(xlrec->mid, xlrec->moff, xlrec->nmembers, xlrec->members); /* Make sure nextMXact/nextOffset are beyond what this record has */ - MultiXactAdvanceNextMXact(xlrec->mid + 1, - xlrec->moff + xlrec->nmembers); + MultiXactAdvanceNextMXact(xlrec->mid + 1, xlrec->moff + xlrec->nmembers); /* * Make sure nextXid is beyond any XID mentioned in the record. This @@ -3511,29 +3540,25 @@ multixact_redo(XLogReaderState *record) * evidence in the XLOG, but let's be safe. */ max_xid = XLogRecGetXid(record); - for (i = 0; i < xlrec->nmembers; i++) - { + for (i = 0; i < xlrec->nmembers; i++) { if (TransactionIdPrecedes(max_xid, xlrec->members[i].xid)) max_xid = xlrec->members[i].xid; } AdvanceNextFullTransactionIdPastXid(max_xid); - } - else if (info == XLOG_MULTIXACT_TRUNCATE_ID) - { + } else if (info == XLOG_MULTIXACT_TRUNCATE_ID) { xl_multixact_truncate xlrec; - memcpy(&xlrec, XLogRecGetData(record), - SizeOfMultiXactTruncate); + memcpy(&xlrec, XLogRecGetData(record), SizeOfMultiXactTruncate); - elog(DEBUG1, "replaying multixact truncation: " + elog(DEBUG1, + "replaying multixact truncation: " "offsets [%u, %u), offsets segments [%x, %x), " "members [%u, %u), members segments [%x, %x)", xlrec.startTruncOff, xlrec.endTruncOff, MultiXactIdToOffsetSegment(xlrec.startTruncOff), - MultiXactIdToOffsetSegment(xlrec.endTruncOff), - xlrec.startTruncMemb, xlrec.endTruncMemb, - MXOffsetToMemberSegment(xlrec.startTruncMemb), + MultiXactIdToOffsetSegment(xlrec.endTruncOff), xlrec.startTruncMemb, + xlrec.endTruncMemb, MXOffsetToMemberSegment(xlrec.startTruncMemb), MXOffsetToMemberSegment(xlrec.endTruncMemb)); /* should not be required, but more than cheap enough */ @@ -3549,41 +3574,36 @@ multixact_redo(XLogReaderState *record) PerformOffsetsTruncation(xlrec.startTruncOff, xlrec.endTruncOff); LWLockRelease(MultiXactTruncationLock); - } - else + } else elog(PANIC, "multixact_redo: unknown op code %u", info); } Datum pg_get_multixact_members(PG_FUNCTION_ARGS) { - typedef struct - { + typedef struct { MultiXactMember *members; - int nmembers; - int iter; + int nmembers; + int iter; } mxact; MultiXactId mxid = PG_GETARG_TRANSACTIONID(0); - mxact *multi; + mxact *multi; FuncCallContext *funccxt; if (mxid < FirstMultiXactId) - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("invalid MultiXactId: %u", mxid))); + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid MultiXactId: %u", mxid))); - if (SRF_IS_FIRSTCALL()) - { + if (SRF_IS_FIRSTCALL()) { MemoryContext oldcxt; - TupleDesc tupdesc; + TupleDesc tupdesc; funccxt = SRF_FIRSTCALL_INIT(); oldcxt = MemoryContextSwitchTo(funccxt->multi_call_memory_ctx); multi = palloc(sizeof(mxact)); /* no need to allow for old values here */ - multi->nmembers = GetMultiXactIdMembers(mxid, &multi->members, false, - false); + multi->nmembers = GetMultiXactIdMembers(mxid, &multi->members, false, false); multi->iter = 0; if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) @@ -3596,12 +3616,11 @@ pg_get_multixact_members(PG_FUNCTION_ARGS) } funccxt = SRF_PERCALL_SETUP(); - multi = (mxact *) funccxt->user_fctx; + multi = (mxact *)funccxt->user_fctx; - while (multi->iter < multi->nmembers) - { - HeapTuple tuple; - char *values[2]; + while (multi->iter < multi->nmembers) { + HeapTuple tuple; + char *values[2]; values[0] = psprintf("%u", multi->members[multi->iter].xid); values[1] = mxstatus_to_string(multi->members[multi->iter].status); diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index 9ed67e8dcb..0a8b42c5ac 100644 --- a/src/backend/cluster/Makefile +++ b/src/backend/cluster/Makefile @@ -217,6 +217,7 @@ OBJS = \ cluster_tx_enqueue.o \ cluster_subtrans.o \ cluster_multixact.o \ + cluster_mxid_stripe.o \ cluster_visibility_inject.o \ cluster_stats.o \ cluster_version.o \ diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index d25ca1540b..6251f4ab8e 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -104,6 +104,7 @@ PG_FUNCTION_INFO_V1(cluster_dump_state); #include "cluster/cluster_xnode_profile.h" /* xnode profiling buckets (spec-5.59 D1) */ #include "cluster/cluster_xnode_lever.h" #include "cluster/cluster_xid_stripe_boot.h" /* spec-6.15 D6 dump */ /* xnode lever counters (spec-6.12) */ +#include "cluster/cluster_multixact.h" /* mxid stripe guardrail counters (spec-7.1 D3-a) */ #include "cluster/cluster_hw_lease.h" /* space-lease counters (spec-6.12d) */ #include "cluster/cluster_resolver_cache.h" /* cluster_resolver_cache_* counters (spec-5.55 D8) */ #include "cluster/cluster_cr_coordinator_stat.h" /* cluster_cr_coordinator_* counters (spec-5.57 D3) */ @@ -898,10 +899,13 @@ dump_undo_cleaner(ReturnSetInfo *rsinfo) /* * dump_xid_stripe -- spec-6.15 D6 xid stripe face diagnostics. * - * 11 keys over the activation face (disk/slot state, floor, epoch), + * 14 keys over the activation face (disk/slot state, floor, epoch), * the D3 herding plane (own floor/hwm promise, cluster min/max active - * hwm) and the D5d replay face (replay-learned floor + active slot - * bitmap). All values come from one shmem snapshot. + * hwm), the D5d replay face (replay-learned floor + active slot + * bitmap) and the spec-7.1 D3-a mxid stripe face (activated mxid + * floor -- 0 = extension absent -- plus the half-space-refusal and + * underivable-read guardrail counters). Values come from one shmem + * snapshot plus the multixact counter atomics. */ static void dump_xid_stripe(ReturnSetInfo *rsinfo) @@ -936,6 +940,12 @@ dump_xid_stripe(ReturnSetInfo *rsinfo) fmt_int64((int64)obs.replay_floor_full)); emit_row(rsinfo, "xid_stripe", "xid_stripe_replay_active_bitmap", fmt_uint64_hex((uint64)obs.replay_active_bitmap)); + emit_row(rsinfo, "xid_stripe", "mxid_stripe_activated_floor", + fmt_int64((int64)obs.activated_mxid_floor)); + emit_row(rsinfo, "xid_stripe", "mxid_stripe_halfspace_refusals", + fmt_int64((int64)cluster_multixact_get_mxid_halfspace_refuse_count())); + emit_row(rsinfo, "xid_stripe", "mxid_stripe_underivable_reads", + fmt_int64((int64)cluster_multixact_get_mxid_underivable_read_count())); } diff --git a/src/backend/cluster/cluster_guc.c b/src/backend/cluster/cluster_guc.c index 0846fcbcdd..a29528126e 100644 --- a/src/backend/cluster/cluster_guc.c +++ b/src/backend/cluster/cluster_guc.c @@ -125,6 +125,9 @@ bool cluster_xid_striping = false; /* spec-6.15 D5/D3: herding slack (xid-value gap tolerated between * stripe slots; also the seeded activation-floor headroom). */ int cluster_xid_herding_slack = 4194304; +/* spec-7.1 D3-a: cross-node multixact xmax positive resolution + * (default ON; off = the D3-0 fail-closed floor verbatim). */ +bool cluster_multi_xmax_remote_resolve = true; /* spec-6.12d: instance space-affinity mode + lease cap (default OFF). */ int cluster_space_affinity = CLUSTER_SPACE_AFFINITY_OFF; int cluster_space_lease_blocks = 64; @@ -1614,6 +1617,24 @@ cluster_init_guc(void) "node. Requires cluster.node_id between 0 and 15."), &cluster_xid_striping, false, PGC_POSTMASTER, 0, NULL, NULL, NULL); + /* + * cluster.multi_xmax_remote_resolve -- spec-7.1 D3-a. When on + * (default), a reader that meets a foreign-origin updater + * multixact xmax derives the origin from the striped mxid value + * and resolves member visibility through the cluster member + * overlay; anything unprovable still fails closed. Off = the + * fail-closed floor verbatim (every updater multi in peer mode + * refuses with SQLSTATE 53R9C). SIGHUP: a pure reader-side + * positive branch, safe to flip at runtime. + */ + DefineCustomBoolVariable( + "cluster.multi_xmax_remote_resolve", + gettext_noop( + "Resolve foreign multixact xmax through the cluster member overlay (spec-7.1)."), + gettext_noop("When off, every updater-bearing multixact xmax on a cluster page " + "read in peer mode fails closed with SQLSTATE 53R9C."), + &cluster_multi_xmax_remote_resolve, true, PGC_SIGHUP, 0, NULL, NULL, NULL); + /* * cluster.xid_herding_slack -- spec-6.15 D5/D3. Allowed xid-value * gap between the fastest and slowest stripe slots before a lagging diff --git a/src/backend/cluster/cluster_inject.c b/src/backend/cluster/cluster_inject.c index e63f85de93..edd02c568f 100644 --- a/src/backend/cluster/cluster_inject.c +++ b/src/backend/cluster/cluster_inject.c @@ -705,6 +705,15 @@ static ClusterInjectPoint cluster_injection_points[] = { */ { .name = "cluster-xid-herding-stall" }, { .name = "cluster-xid-window-hard-limit" }, + + /* + * spec-7.1 D3-a — mxid stripe half-space guardrail. + * cluster-mxid-halfspace-hard-limit: armed-state peek FORCES the + * 53RB4 refusal branch in GetNewMultiXactId (the organic + * trigger needs 2^31 multixacts; the armed peek lets a test + * exercise the fail-closed leg + guardrail counter). + */ + { .name = "cluster-mxid-halfspace-hard-limit" }, }; #define CLUSTER_INJECTION_COUNT lengthof(cluster_injection_points) diff --git a/src/backend/cluster/cluster_multixact.c b/src/backend/cluster/cluster_multixact.c index 2cb7831290..610d0de641 100644 --- a/src/backend/cluster/cluster_multixact.c +++ b/src/backend/cluster/cluster_multixact.c @@ -68,6 +68,9 @@ typedef struct ClusterMultiXactShmem { pg_atomic_uint64 overlay_miss_count; pg_atomic_uint64 overlay_overflow_count; pg_atomic_uint64 resolve_visibility_count; + /* spec-7.1 D3-a guardrail counters */ + pg_atomic_uint64 mxid_halfspace_refuse_count; /* allocator half-space refusals */ + pg_atomic_uint64 mxid_underivable_read_count; /* reader: foreign multi origin underivable */ } ClusterMultiXactShmem; static HTAB *ClusterMultiXactHTAB = NULL; @@ -111,6 +114,8 @@ cluster_multixact_shmem_init(void) pg_atomic_init_u64(&ClusterMultiXactState->overlay_miss_count, 0); pg_atomic_init_u64(&ClusterMultiXactState->overlay_overflow_count, 0); pg_atomic_init_u64(&ClusterMultiXactState->resolve_visibility_count, 0); + pg_atomic_init_u64(&ClusterMultiXactState->mxid_halfspace_refuse_count, 0); + pg_atomic_init_u64(&ClusterMultiXactState->mxid_underivable_read_count, 0); } lockblock = (LWLockPadded *)ShmemInitStruct("ClusterMultiXactLock", @@ -320,6 +325,45 @@ cluster_multixact_resolve_visibility(const ClusterMultiXactMemberOverlayResult * return CLUSTER_VISIBILITY_VISIBLE; } +/* + * cluster_multixact_remote_xmax_resolve (spec-7.1 D3-a) + * + * One-call reader helper: overlay key build (current epoch) + member + * overlay lookup + OBS-1 visibility resolution. See header; UNKNOWN + * always means fail closed at the caller. + */ +ClusterVisibilityDecision +cluster_multixact_remote_xmax_resolve(uint16 origin_slot, MultiXactId mxid, Snapshot snap, + bool *overlay_hit) +{ + ClusterMultiXactKey mxkey; + ClusterMultiXactMemberOverlayResult *mxres; + ClusterVisibilityDecision decision; + Size resbuf_sz = offsetof(ClusterMultiXactMemberOverlayResult, members) + + CLUSTER_MULTIXACT_MAX_MEMBERS * sizeof(ClusterMultiXactMember); + + if (overlay_hit) + *overlay_hit = false; + + memset(&mxkey, 0, sizeof(mxkey)); + mxkey.origin_node_id = origin_slot; + mxkey.multixact_id = mxid; + mxkey.cluster_epoch = (uint32)cluster_epoch_get_current(); + + mxres = (ClusterMultiXactMemberOverlayResult *)palloc0(resbuf_sz); + + if (!cluster_multixact_member_overlay_lookup(&mxkey, mxres, CLUSTER_MULTIXACT_MAX_MEMBERS)) { + pfree(mxres); + return CLUSTER_VISIBILITY_UNKNOWN; + } + + if (overlay_hit) + *overlay_hit = true; + decision = cluster_multixact_resolve_visibility(mxres, snap); + pfree(mxres); + return decision; +} + uint16 cluster_multixact_get_member_count(const ClusterMultiXactKey *key) { @@ -373,6 +417,28 @@ CLUSTER_MULTIXACT_GETTER(overlay_lookup_hit_count) CLUSTER_MULTIXACT_GETTER(overlay_miss_count) CLUSTER_MULTIXACT_GETTER(overlay_overflow_count) CLUSTER_MULTIXACT_GETTER(resolve_visibility_count) +CLUSTER_MULTIXACT_GETTER(mxid_halfspace_refuse_count) +CLUSTER_MULTIXACT_GETTER(mxid_underivable_read_count) + +/* + * spec-7.1 D3-a guardrail bumps. Called from GetNewMultiXactId under + * MultiXactGenLock (halfspace refuse) and from the reader fail-closed + * legs in heapam_visibility.c (underivable read); atomics, no lock of + * this module taken. + */ +void +cluster_multixact_note_halfspace_refuse(void) +{ + if (ClusterMultiXactState != NULL) + pg_atomic_fetch_add_u64(&ClusterMultiXactState->mxid_halfspace_refuse_count, 1); +} + +void +cluster_multixact_note_underivable_read(void) +{ + if (ClusterMultiXactState != NULL) + pg_atomic_fetch_add_u64(&ClusterMultiXactState->mxid_underivable_read_count, 1); +} #else /* !USE_PGRAC_CLUSTER */ @@ -422,6 +488,18 @@ cluster_multixact_resolve_visibility(const ClusterMultiXactMemberOverlayResult * return CLUSTER_VISIBILITY_UNKNOWN; } +ClusterVisibilityDecision +cluster_multixact_remote_xmax_resolve(uint16 origin_slot, MultiXactId mxid, Snapshot snap, + bool *overlay_hit) +{ + (void)origin_slot; + (void)mxid; + (void)snap; + if (overlay_hit) + *overlay_hit = false; + return CLUSTER_VISIBILITY_UNKNOWN; +} + uint16 cluster_multixact_get_member_count(const ClusterMultiXactKey *key) { @@ -446,5 +524,15 @@ CLUSTER_MULTIXACT_GETTER_STUB(overlay_lookup_hit_count) CLUSTER_MULTIXACT_GETTER_STUB(overlay_miss_count) CLUSTER_MULTIXACT_GETTER_STUB(overlay_overflow_count) CLUSTER_MULTIXACT_GETTER_STUB(resolve_visibility_count) +CLUSTER_MULTIXACT_GETTER_STUB(mxid_halfspace_refuse_count) +CLUSTER_MULTIXACT_GETTER_STUB(mxid_underivable_read_count) + +void +cluster_multixact_note_halfspace_refuse(void) +{} + +void +cluster_multixact_note_underivable_read(void) +{} #endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/cluster/cluster_mxid_stripe.c b/src/backend/cluster/cluster_mxid_stripe.c new file mode 100644 index 0000000000..23f9607193 --- /dev/null +++ b/src/backend/cluster/cluster_mxid_stripe.c @@ -0,0 +1,244 @@ +/*------------------------------------------------------------------------- + * + * cluster_mxid_stripe.c + * pgrac MultiXactId space segmentation -- pure stripe derivation layer. + * + * See cluster_mxid_stripe.h for the layer contract (half-space + * derivation window, fail-closed underivable semantics, latch + * ownership). + * + * Everything here is deliberately pure: no shmem, no locks, no + * ereport. The only process state is the latched mxid stripe + * runtime (populated once per process by the boot lazy latch). + * Unlike the xid layer there is no widening step and no counter + * herding: the half-space distance is computed directly against the + * durable activation floor, so derivation never reads the live + * nextMXact. This keeps the whole truth table unit-testable + * (test_cluster_mxid_stripe) and lets multixact.c consume the + * candidate arithmetic under MultiXactGenLock without layering + * concerns. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/backend/cluster/cluster_mxid_stripe.c + * + * NOTES + * This is a pgrac-original file (no derivation from PostgreSQL). + * Spec: spec-7.1-cross-instance-positive-interread.md + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/multixact.h" +#include "cluster/cluster_guc.h" /* cluster_enabled / cluster_node_id / cluster_xid_striping */ +#include "cluster/cluster_mxid_stripe.h" +#include "cluster/cluster_xid_stripe_boot.h" /* mxid lazy latch */ +#include "port/pg_crc32c.h" /* extension record integrity */ + +/* + * Process-local latched mxid stripe runtime. Immutable after the + * single latch call (see header); zero-initialized state means + * "inactive" and every wrapper reports underivable. + */ +typedef struct ClusterMxidStripeRuntime { + bool active; + int my_slot; + MultiXactId floor_mxid; +} ClusterMxidStripeRuntime; + +static ClusterMxidStripeRuntime mxid_stripe_runtime = { false, -1, InvalidMultiXactId }; + +/* + * Derive the origin slot of mxid against the mxid activation floor, or + * -1 when underivable. The uint32 modular distance is unambiguous + * inside the half-space [floor, floor + 2^31) and stays exact when the + * window spans the 2^32 wrap. + */ +int +cluster_mxid_origin_slot_floored(MultiXactId mxid, MultiXactId floor_mxid) +{ + if (mxid < FirstMultiXactId || floor_mxid < FirstMultiXactId) + return -1; + if ((uint32)(mxid - floor_mxid) >= CLUSTER_MXID_HALFSPACE) + return -1; + + return (int)(mxid % CLUSTER_MXID_STRIDE); +} + +/* + * Smallest MultiXactId >= next with value in slot's congruence class, + * skipping the InvalidMultiXactId (0) in-class position. + */ +MultiXactId +cluster_mxid_next_striped(MultiXactId next, int slot) +{ + uint32 val = (uint32)next; + uint32 cand; + + Assert(slot >= 0 && slot < CLUSTER_MXID_STRIDE); + + /* + * Round up to the congruence class: add the mod-STRIDE distance + * from the current remainder to the target slot (0 when already in + * class). uint32 addition wraps at 2^32 like the multixact + * counter itself. + */ + cand = val + (((uint32)slot - (val % CLUSTER_MXID_STRIDE)) & (CLUSTER_MXID_STRIDE - 1)); + + /* + * The in-class position at value 0 is InvalidMultiXactId (only + * reachable for slot 0 right at the wrap): never issued, skip one + * full stride to the next in-class position. A single skip + * suffices (0 + STRIDE = 16 >= FirstMultiXactId). + */ + if (cand < FirstMultiXactId) + cand += CLUSTER_MXID_STRIDE; + + return (MultiXactId)cand; +} + +/* + * Guardrail predicate: candidate at/beyond floor + 2^31 (see header). + * Invalid floor fails closed. + */ +bool +cluster_mxid_halfspace_exceeded(MultiXactId candidate, MultiXactId floor_mxid) +{ + if (floor_mxid < FirstMultiXactId) + return true; + + return (uint32)(candidate - floor_mxid) >= CLUSTER_MXID_HALFSPACE; +} + +/* + * Runtime wrapper: derive the origin slot of a raw mxid using the + * latched floor, or -1 when the mxid stripe runtime is inactive or + * derivation fails. + */ +int +cluster_mxid_origin_slot(MultiXactId mxid) +{ + if (!mxid_stripe_runtime.active) { +#ifdef USE_PGRAC_CLUSTER + /* + * Lazy latch mirroring cluster_xid_origin_slot: activation + * resolves after process start, so latch from shmem on first + * use. GUC-gated so striping-off clusters never pay the shmem + * lookup on this path. + */ + if (cluster_enabled && cluster_xid_striping) + cluster_mxid_stripe_lazy_latch(); +#endif + if (!mxid_stripe_runtime.active) + return -1; + } + + return cluster_mxid_origin_slot_floored(mxid, mxid_stripe_runtime.floor_mxid); +} + +/* + * Runtime wrapper: does mxid derive to this node's slot? False + * whenever underivable (fail-closed direction for origin-side deny). + */ +bool +cluster_mxid_is_mine(MultiXactId mxid) +{ + int slot = cluster_mxid_origin_slot(mxid); + + return slot >= 0 && slot == mxid_stripe_runtime.my_slot; +} + +/* + * Allocation-side slot gate (see header). Unlike the xid gate this + * also requires the latched mxid floor: without a floor the allocator + * cannot clamp candidates above it nor police the half-space boundary, + * so allocation stays vanilla dense (below-floor ids remain + * underivable -- honest degrade, never a misattribution). + */ +int +cluster_mxid_allocation_slot(void) +{ + if (!cluster_enabled || !cluster_xid_striping) + return -1; + if (cluster_node_id < 0 || cluster_node_id >= CLUSTER_MXID_STRIDE) + return -1; + + if (!mxid_stripe_runtime.active) { +#ifdef USE_PGRAC_CLUSTER + cluster_mxid_stripe_lazy_latch(); +#endif + if (!mxid_stripe_runtime.active) + return -1; + } + + return cluster_node_id; +} + +/* Latched mxid activation floor (InvalidMultiXactId until latched). */ +MultiXactId +cluster_mxid_stripe_floor(void) +{ + return mxid_stripe_runtime.active ? mxid_stripe_runtime.floor_mxid : InvalidMultiXactId; +} + +/* + * Latch the process-local mxid stripe runtime. See header for + * ownership; inconsistent arguments latch the inactive state + * (fail-closed). + */ +void +cluster_mxid_stripe_latch_runtime(bool active, int my_slot, MultiXactId floor_mxid) +{ + if (active && (my_slot < 0 || my_slot >= CLUSTER_MXID_STRIDE || floor_mxid < FirstMultiXactId)) + active = false; + + mxid_stripe_runtime.active = active; + mxid_stripe_runtime.my_slot = active ? my_slot : -1; + mxid_stripe_runtime.floor_mxid = active ? floor_mxid : InvalidMultiXactId; +} + +/* Set rec->crc32c = CRC32C over [magic .. generation]. */ +void +cluster_mxid_stripe_extension_record_compute_crc(ClusterMxidStripeExtensionRecord *rec) +{ + pg_crc32c crc; + + INIT_CRC32C(crc); + COMP_CRC32C(crc, rec, offsetof(ClusterMxidStripeExtensionRecord, crc32c)); + FIN_CRC32C(crc); + rec->crc32c = (uint32)crc; +} + +/* + * Structural + sanity validity of the mxid activation extension record + * (region-6 slot, byte offset CLUSTER_PGXM_SLOT_OFFSET). FAIL-CLOSED + * on magic / version / invalid floor / zero generation / CRC mismatch; + * an all-zeros extension area (slot written by a pre-extension binary) + * rejects on the magic check -- the record-absent empty state. + */ +bool +cluster_mxid_stripe_extension_record_valid(const ClusterMxidStripeExtensionRecord *rec) +{ + pg_crc32c crc; + + if (rec == NULL) + return false; + if (rec->magic != CLUSTER_PGXM_MAGIC || rec->version != CLUSTER_PGXM_VERSION) + return false; + if (rec->activated_mxid_floor < FirstMultiXactId) + return false; + if (rec->generation == 0) + return false; + + INIT_CRC32C(crc); + COMP_CRC32C(crc, rec, offsetof(ClusterMxidStripeExtensionRecord, crc32c)); + FIN_CRC32C(crc); + return (uint32)crc == rec->crc32c; +} diff --git a/src/backend/cluster/cluster_xid_stripe_boot.c b/src/backend/cluster/cluster_xid_stripe_boot.c index 7dab35ea5b..c33bec5f03 100644 --- a/src/backend/cluster/cluster_xid_stripe_boot.c +++ b/src/backend/cluster/cluster_xid_stripe_boot.c @@ -43,11 +43,13 @@ #include #include +#include "access/multixact.h" /* ReadNextMultiXactId (mxid floor seed sample, spec-7.1 D3-a) */ #include "access/transam.h" #include "access/xlog.h" /* RecoveryInProgress (D5d gate hold) */ #include "cluster/cluster_conf.h" #include "cluster/cluster_guc.h" -#include "cluster/cluster_inject.h" /* herding-stall injection (D3, L408) */ +#include "cluster/cluster_inject.h" /* herding-stall injection (D3, L408) */ +#include "cluster/cluster_mxid_stripe.h" /* mxid stripe face (spec-7.1 D3-a) */ #include "cluster/cluster_shmem.h" #include "cluster/cluster_voting_disk_io.h" #include "cluster/cluster_xid_stripe.h" @@ -65,6 +67,9 @@ StaticAssertDecl(sizeof(ClusterXidStripeSlotRecord) <= CLUSTER_VOTING_SLOT_BYTES "stripe slot record must fit a voting slot"); StaticAssertDecl(sizeof(ClusterXidStripeActivationRecord) <= CLUSTER_VOTING_SLOT_BYTES, "stripe activation record must fit a voting slot"); +StaticAssertDecl(CLUSTER_PGXM_SLOT_OFFSET + sizeof(ClusterMxidStripeExtensionRecord) + <= CLUSTER_VOTING_SLOT_BYTES, + "mxid stripe extension must fit the activation voting slot"); /* * Stripe mailbox ops (single producer LMON / single consumer qvotec, @@ -89,6 +94,7 @@ typedef struct ClusterXidStripeBootShmem { uint64 floor_full; uint64 epoch; uint64 generation; + uint32 mxid_floor; /* "PGXM" extension floor; 0 = absent (spec-7.1 D3-a) */ /* this node's region-4 publication (D5c; D5e reads the floor) */ uint32 slot_state; /* ClusterXidStripeSlotState */ @@ -131,12 +137,14 @@ typedef struct ClusterXidStripeBootShmem { uint64 op_incarnation_hint; /* RETIRE tombstone owner */ uint64 seed_floor_full; /* SEED payload */ uint64 seed_epoch; + uint32 seed_mxid_floor; /* SEED payload, mxid face (spec-7.1 D3-a) */ } ClusterXidStripeBootShmem; static ClusterXidStripeBootShmem *StripeBootShmem = NULL; /* process-local: lazy latch ran (one-way, per process) */ static bool stripe_latch_done = false; +static bool mxid_stripe_latch_done = false; Size cluster_xid_stripe_shmem_size(void) @@ -199,11 +207,14 @@ buffer_is_all_zeros(const char *buf, Size len) } static StripeSlotReadClass -stripe_read_activation_one(int fd, ClusterXidStripeActivationRecord *out) +stripe_read_activation_one(int fd, ClusterXidStripeActivationRecord *out, uint32 *out_mxid_floor) { char slot[CLUSTER_VOTING_SLOT_BYTES]; ClusterVotingDiskIoState rc; + if (out_mxid_floor) + *out_mxid_floor = 0; + rc = cluster_voting_disk_read_stripe_activation(fd, slot); if (rc != CLUSTER_VOTING_DISK_IO_OK) { struct stat st; @@ -221,6 +232,23 @@ stripe_read_activation_one(int fd, ClusterXidStripeActivationRecord *out) memcpy(out, slot, sizeof(*out)); if (!cluster_xid_stripe_activation_record_valid(out)) return STRIPE_READ_CORRUPT; + + /* + * spec-7.1 D3-a: parse the "PGXM" mxid-floor extension riding the + * same slot at a fixed offset. A slot written by a pre-extension + * binary reads back zeros there (record-absent); any invalid + * content is likewise treated as absent -- the mxid face then + * stays fail-closed while the xid activation stands on its own + * CRC. Never a CORRUPT verdict: the extension is optional and + * must not hold up the xid face. + */ + if (out_mxid_floor) { + ClusterMxidStripeExtensionRecord ext; + + memcpy(&ext, slot + CLUSTER_PGXM_SLOT_OFFSET, sizeof(ext)); + if (cluster_mxid_stripe_extension_record_valid(&ext)) + *out_mxid_floor = ext.activated_mxid_floor; + } return STRIPE_READ_VALID; } @@ -312,6 +340,7 @@ void cluster_xid_stripe_scan_disks(const int *fds, int n_disks) { ClusterXidStripeActivationRecord best; + uint32 best_mxid_floor = 0; bool have_valid = false; bool have_corrupt = false; bool have_readable = false; @@ -324,12 +353,15 @@ cluster_xid_stripe_scan_disks(const int *fds, int n_disks) for (i = 0; i < n_disks; i++) { ClusterXidStripeActivationRecord rec; + uint32 rec_mxid_floor; - switch (stripe_read_activation_one(fds[i], &rec)) { + switch (stripe_read_activation_one(fds[i], &rec, &rec_mxid_floor)) { case STRIPE_READ_VALID: have_readable = true; if (!have_valid || rec.generation > best.generation) { best = rec; + /* the extension travels with its slot's PGXA record */ + best_mxid_floor = rec_mxid_floor; have_valid = true; } break; @@ -359,6 +391,7 @@ cluster_xid_stripe_scan_disks(const int *fds, int n_disks) StripeBootShmem->floor_full = best.activated_floor_full; StripeBootShmem->epoch = best.stride_mode_epoch; StripeBootShmem->generation = best.generation; + StripeBootShmem->mxid_floor = best_mxid_floor; } } else if (have_corrupt) { /* Garbage and no valid copy anywhere: fail-closed hold. The @@ -469,6 +502,7 @@ stripe_stage_seed_request(void) { FullTransactionId next_full; uint64 floor; + uint32 mxid_floor; if (pg_atomic_read_u64(&StripeBootShmem->req_seq) != pg_atomic_read_u64(&StripeBootShmem->done_seq)) @@ -479,9 +513,24 @@ stripe_stage_seed_request(void) floor = floor - (floor % CLUSTER_XID_STRIDE) + CLUSTER_XID_STRIDE; /* round up */ floor += (uint64)cluster_xid_herding_slack; + /* + * spec-7.1 D3-a: sample the mxid activation floor at the same + * staging point. Like the xid floor, its dominance over shared- + * page history rests on activation landing at cluster formation, + * before member admission opens user transactions (the seed's + * post-recovery counter is the observed cluster high-water, + * spec-6.15 §2.3 discipline). Rounded up to the stride plus one + * stride of margin; multixacts have no herding, so no slack term. + */ + mxid_floor = (uint32)ReadNextMultiXactId(); + mxid_floor = mxid_floor - (mxid_floor % CLUSTER_MXID_STRIDE) + 2 * CLUSTER_MXID_STRIDE; + if (mxid_floor < FirstMultiXactId) + mxid_floor += CLUSTER_MXID_STRIDE; /* wrapped onto 0: skip Invalid */ + LWLockAcquire(&StripeBootShmem->lock, LW_EXCLUSIVE); StripeBootShmem->seed_floor_full = floor; StripeBootShmem->seed_epoch = 1; + StripeBootShmem->seed_mxid_floor = mxid_floor; LWLockRelease(&StripeBootShmem->lock); if (stripe_stage_op(STRIPE_OP_SEED, -1, 0) != 0) @@ -500,6 +549,7 @@ static uint32 stripe_service_seed(const int *fds, int n_disks) { ClusterXidStripeActivationRecord rec; + ClusterMxidStripeExtensionRecord ext; char slot[CLUSTER_VOTING_SLOT_BYTES]; int disks_ok = 0; int majority; @@ -511,17 +561,30 @@ stripe_service_seed(const int *fds, int n_disks) return 1; memset(&rec, 0, sizeof(rec)); + memset(&ext, 0, sizeof(ext)); rec.magic = CLUSTER_PGXA_MAGIC; rec.version = CLUSTER_PGXA_VERSION; + ext.magic = CLUSTER_PGXM_MAGIC; + ext.version = CLUSTER_PGXM_VERSION; LWLockAcquire(&StripeBootShmem->lock, LW_SHARED); rec.activated_floor_full = StripeBootShmem->seed_floor_full; rec.stride_mode_epoch = StripeBootShmem->seed_epoch; + ext.activated_mxid_floor = StripeBootShmem->seed_mxid_floor; LWLockRelease(&StripeBootShmem->lock); rec.generation = 1; cluster_xid_stripe_activation_record_compute_crc(&rec); + ext.generation = 1; + cluster_mxid_stripe_extension_record_compute_crc(&ext); + /* + * spec-7.1 D3-a: the "PGXM" mxid-floor extension rides the SAME + * 512-byte slot write as the PGXA record, so both floors land + * atomically in one sector -- there is no ordering window where + * one face is durable without the other. + */ memset(slot, 0, sizeof(slot)); memcpy(slot, &rec, sizeof(rec)); + memcpy(slot + CLUSTER_PGXM_SLOT_OFFSET, &ext, sizeof(ext)); for (i = 0; i < n_disks; i++) { if (cluster_voting_disk_write_stripe_activation(fds[i], slot) == CLUSTER_VOTING_DISK_IO_OK) @@ -535,10 +598,12 @@ stripe_service_seed(const int *fds, int n_disks) StripeBootShmem->floor_full = rec.activated_floor_full; StripeBootShmem->epoch = rec.stride_mode_epoch; StripeBootShmem->generation = rec.generation; + StripeBootShmem->mxid_floor = ext.activated_mxid_floor; LWLockRelease(&StripeBootShmem->lock); ereport(LOG, (errmsg("cluster xid stripe: activation record durable on %d/%d disks " - "(floor " UINT64_FORMAT ", epoch " UINT64_FORMAT ")", - disks_ok, n_disks, rec.activated_floor_full, rec.stride_mode_epoch))); + "(floor " UINT64_FORMAT ", epoch " UINT64_FORMAT ", mxid floor %u)", + disks_ok, n_disks, rec.activated_floor_full, rec.stride_mode_epoch, + (unsigned)ext.activated_mxid_floor))); return 1; } ereport(LOG, (errmsg("cluster xid stripe: activation seed reached only %d/%d disks " @@ -866,6 +931,38 @@ cluster_xid_stripe_lazy_latch(void) stripe_latch_done = true; } +/* + * Companion one-way lazy latch for the mxid stripe face (spec-7.1 + * D3-a). Latches only when the published activation carried a valid + * "PGXM" extension floor; a pre-extension activation (mxid_floor 0) + * leaves the mxid face unlatched for the life of the process -- + * allocation stays vanilla dense and readers keep failing closed on + * foreign multis (honest degrade, never a misattribution). + */ +void +cluster_mxid_stripe_lazy_latch(void) +{ + uint32 mxid_floor = 0; + bool published = false; + + if (mxid_stripe_latch_done || StripeBootShmem == NULL) + return; + + LWLockAcquire(&StripeBootShmem->lock, LW_SHARED); + if (StripeBootShmem->disk_state == CLUSTER_XID_STRIPE_DISK_PUBLISHED + && StripeBootShmem->mxid_floor != 0) { + published = true; + mxid_floor = StripeBootShmem->mxid_floor; + } + LWLockRelease(&StripeBootShmem->lock); + + if (!published) + return; /* stays unlatched; wrappers keep failing closed */ + + cluster_mxid_stripe_latch_runtime(true, cluster_xid_allocation_slot(), (MultiXactId)mxid_floor); + mxid_stripe_latch_done = true; +} + FullTransactionId cluster_xid_stripe_my_slot_floor(void) { @@ -1171,6 +1268,7 @@ cluster_xid_stripe_observe(ClusterXidStripeObs *obs) obs->my_slot_floor_full = StripeBootShmem->my_slot_claimed ? StripeBootShmem->my_slot_floor_full : 0; obs->my_hwm_on_disk = StripeBootShmem->my_hwm_on_disk; + obs->activated_mxid_floor = StripeBootShmem->mxid_floor; LWLockRelease(&StripeBootShmem->lock); obs->herding_floor_full = pg_atomic_read_u64(&StripeBootShmem->herding_floor_full); diff --git a/src/backend/utils/errcodes.txt b/src/backend/utils/errcodes.txt index ac8fe1c784..af558de79e 100644 --- a/src/backend/utils/errcodes.txt +++ b/src/backend/utils/errcodes.txt @@ -875,6 +875,12 @@ Section: Class 53 - Insufficient Resources (pgrac extension) # the first own checkpoint). 53RB3 E ERRCODE_CLUSTER_RECOVERY_ANCHOR_UNAVAILABLE cluster_recovery_anchor_unavailable +# spec-7.1 D3-a: striped multixact id allocation refused because the +# candidate would leave the half-space [floor, floor + 2^31) above the +# durable mxid activation floor -- beyond it the 32-bit wraparound makes +# origin derivation ambiguous, so the allocator fails closed instead of +# ever letting the derivation window alias (rule 8.A). +53RB4 E ERRCODE_CLUSTER_MXID_HALFSPACE_LIMIT cluster_mxid_halfspace_limit # spec-6.15b: shared XID authority / native-era prehistory unavailable. # Under cluster.shared_catalog=on a joiner must reconcile its transaction # horizon against the sealed shared XID authority and adopt the native-era diff --git a/src/include/cluster/cluster_guc.h b/src/include/cluster/cluster_guc.h index 9cc9c68984..4e0810e60d 100644 --- a/src/include/cluster/cluster_guc.h +++ b/src/include/cluster/cluster_guc.h @@ -205,6 +205,12 @@ extern bool cluster_xid_striping; /* spec-6.15 D5/D3: herding slack (jump threshold; seed-floor headroom). */ extern int cluster_xid_herding_slack; +/* spec-7.1 D3-a: cross-node multixact xmax positive resolution via + * origin derivation + member overlay (default on; off = the D3-0 + * fail-closed floor verbatim: every updater multi in peer mode + * refuses 53R9C). */ +extern bool cluster_multi_xmax_remote_resolve; + /* * spec-6.12d: instance space-affinity mode (default off). static parks * the unconsumed tail of oversized HW grants as a per-node lease diff --git a/src/include/cluster/cluster_multixact.h b/src/include/cluster/cluster_multixact.h index 13203878aa..626b195978 100644 --- a/src/include/cluster/cluster_multixact.h +++ b/src/include/cluster/cluster_multixact.h @@ -202,6 +202,22 @@ extern uint16 cluster_multixact_get_member_count(const ClusterMultiXactKey *key) */ extern void cluster_multixact_purge_epoch(uint32 obsolete_epoch); +/* + * cluster_multixact_remote_xmax_resolve (spec-7.1 D3-a) + * + * One-call reader helper for a DERIVED-foreign multixact xmax: + * builds the overlay key {origin_slot, mxid, current epoch}, looks + * up the member overlay and resolves visibility against snap per + * the OBS-1 truth table. *overlay_hit reports whether the overlay + * held the entry (miss -> UNKNOWN; the member-serve wire that would + * answer a miss positively is a later deliverable). UNKNOWN always + * means the caller must fail closed (rule 8.A). + */ +extern ClusterVisibilityDecision cluster_multixact_remote_xmax_resolve(uint16 origin_slot, + MultiXactId mxid, + Snapshot snap, + bool *overlay_hit); + /* * Counter getters (always linked;return 0 in disable-cluster build). */ @@ -210,6 +226,18 @@ extern uint64 cluster_multixact_get_overlay_lookup_hit_count(void); extern uint64 cluster_multixact_get_overlay_miss_count(void); extern uint64 cluster_multixact_get_overlay_overflow_count(void); extern uint64 cluster_multixact_get_resolve_visibility_count(void); +extern uint64 cluster_multixact_get_mxid_halfspace_refuse_count(void); +extern uint64 cluster_multixact_get_mxid_underivable_read_count(void); + +/* + * spec-7.1 D3-a guardrail bumps (no-op in disable-cluster build). + * halfspace_refuse: the striped allocator refused a candidate at or + * beyond floor + 2^31 (53RB4). underivable_read: a reader met a + * foreign-evidence multixact whose origin could not be derived + * (below-floor / unlatched / beyond-half-space) and failed closed. + */ +extern void cluster_multixact_note_halfspace_refuse(void); +extern void cluster_multixact_note_underivable_read(void); /* * Shmem hooks (defined in cluster_multixact.c when USE_PGRAC_CLUSTER; diff --git a/src/include/cluster/cluster_mxid_stripe.h b/src/include/cluster/cluster_mxid_stripe.h new file mode 100644 index 0000000000..dbdaae351a --- /dev/null +++ b/src/include/cluster/cluster_mxid_stripe.h @@ -0,0 +1,174 @@ +/*------------------------------------------------------------------------- + * + * cluster_mxid_stripe.h + * pgrac MultiXactId space segmentation -- pure stripe derivation layer. + * + * Companion of the spec-6.15 xid stripe (cluster_xid_stripe.h): with + * cluster.xid_striping enabled and the stripe face activated, node + * slot k only ever issues MultiXactIds congruent to k (mod + * CLUSTER_MXID_STRIDE) at or above the durable mxid activation + * floor, so a raw multixact id above the floor is self-describing: + * its origin slot is mxid mod STRIDE. + * + * Half-space window: PostgreSQL has no FullMultiXactId (no epoch + * counter to widen against, unlike FullTransactionId), so the + * derivation is valid only inside the half-space + * [floor, floor + 2^31) of the 32-bit circular id space, where the + * distance (uint32)(mxid - floor) < 2^31 is unambiguous. At or + * beyond floor + 2^31 the wraparound interpretation of a value + * becomes ambiguous, so derivation reports underivable and the + * ALLOCATOR refuses to issue past the boundary (fail-closed on both + * sides -- a full wrap that would alias the half-space can never be + * reached). Multixact ids are consumed only when multiple + * transactions lock/update the same row concurrently, so 2^31 ids + * from activation is an operationally distant boundary; a guardrail + * counter tracks refusals. + * + * This header ships the pure derivation layer (no shmem, no locks, + * no ereport -- underivable is reported as -1 and callers must fail + * closed) plus thin runtime wrappers over a process-local latched + * runtime, and the durable activation-slot extension record that + * carries the mxid floor (rides the region-6 xid activation slot at + * a fixed byte offset; all-zeros reads back as record-absent, the + * fail-closed empty state). + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/include/cluster/cluster_mxid_stripe.h + * + * NOTES + * This is a pgrac-original file (no derivation from PostgreSQL). + * Spec: spec-7.1-cross-instance-positive-interread.md + * Frontend-safe: depends only on c.h (MultiXactId) and + * cluster_xid_stripe.h (stride constant, itself frontend-safe). + * + *------------------------------------------------------------------------- + */ +#ifndef CLUSTER_MXID_STRIPE_H +#define CLUSTER_MXID_STRIPE_H + +#include "c.h" + +#include "cluster/cluster_xid_stripe.h" + +/* One stride shared with the xid stripe (same declared-slot space). */ +#define CLUSTER_MXID_STRIDE CLUSTER_XID_STRIDE + +/* Half-space width: derivation window size above the activation floor. */ +#define CLUSTER_MXID_HALFSPACE ((uint32)0x80000000) + +/* + * Derive the origin slot of mxid against the mxid activation floor. + * Returns the slot (0 .. CLUSTER_MXID_STRIDE-1), or -1 when mxid or + * floor_mxid is invalid or mxid falls outside the half-space + * [floor_mxid, floor_mxid + 2^31) (underivable -- callers must fail + * closed, never guess). The distance test uses uint32 modular + * arithmetic, so it stays exact when the half-space spans the 2^32 + * boundary. + */ +extern int cluster_mxid_origin_slot_floored(MultiXactId mxid, MultiXactId floor_mxid); + +/* + * Smallest MultiXactId >= next whose value is congruent to slot (mod + * CLUSTER_MXID_STRIDE). The one in-class position whose value is + * InvalidMultiXactId (0, reachable for slot 0 right after a 2^32 + * carry) is skipped to the next in-class position. Pure candidate + * arithmetic for the striped allocator (runs under MultiXactGenLock, + * which this function does NOT take). + */ +extern MultiXactId cluster_mxid_next_striped(MultiXactId next, int slot); + +/* + * Guardrail predicate: does candidate fall at or beyond the half-space + * boundary floor_mxid + 2^31? True also when floor_mxid is invalid + * (nothing provable -- fail closed). The allocator refuses to issue a + * candidate for which this reports true, so the derivation window can + * never silently alias. + */ +extern bool cluster_mxid_halfspace_exceeded(MultiXactId candidate, MultiXactId floor_mxid); + +/* + * Runtime wrappers over the process-local latched mxid stripe state. + * cluster_mxid_origin_slot derives mxid against the latched floor; + * returns -1 when the mxid stripe runtime is not latched active or + * derivation fails. cluster_mxid_is_mine reports whether mxid derives + * to this node's slot (false whenever underivable). + */ +extern int cluster_mxid_origin_slot(MultiXactId mxid); +extern bool cluster_mxid_is_mine(MultiXactId mxid); + +/* + * Allocation-side slot gate consumed by GetNewMultiXactId under + * MultiXactGenLock: this node's stripe slot when striping is enabled, + * the declared node identity fits the stripe width AND the mxid floor + * has been latched (the mxid face can trail the xid face: a cluster + * activated before the mxid extension record existed keeps allocating + * vanilla dense ids, which stay below-floor underivable -- honest + * degrade, never a misattribution). -1 = allocation stays vanilla. + */ +extern int cluster_mxid_allocation_slot(void); + +/* Latched mxid activation floor (InvalidMultiXactId until latched). */ +extern MultiXactId cluster_mxid_stripe_floor(void); + +/* + * Latch the process-local mxid stripe runtime. Ownership mirrors + * cluster_xid_stripe_latch_runtime: the boot face calls this once per + * process on first use; values are immutable for the life of the + * process. active=true with an out-of-range slot or invalid floor is + * treated as inactive (fail-closed, no partial activation). + */ +extern void cluster_mxid_stripe_latch_runtime(bool active, int my_slot, MultiXactId floor_mxid); + +/* + * ---------------------------------------------------------------- + * Durable mxid activation extension record ("PGXM") + * + * Rides the region-6 activation slot (512 bytes, sole writer qvotec) + * at fixed byte offset CLUSTER_PGXM_SLOT_OFFSET, after the "PGXA" + * ClusterXidStripeActivationRecord at offset 0. Written in the SAME + * single 512-byte slot write as the PGXA record by the activation + * seed service, so the xid floor and the mxid floor land atomically + * together. The PGXA validator is deliberately untouched: a slot + * written by a pre-extension binary reads back all-zeros here, which + * the validator below rejects as record-absent -- the mxid face then + * stays inactive (fail-closed) while the xid face works unchanged. + * ---------------------------------------------------------------- + */ + +#define CLUSTER_PGXM_MAGIC 0x5047584D /* "PGXM" */ +#define CLUSTER_PGXM_VERSION 1 + +/* Byte offset of the extension inside the region-6 activation slot. */ +#define CLUSTER_PGXM_SLOT_OFFSET 64 + +typedef struct ClusterMxidStripeExtensionRecord { + uint32 magic; /* CLUSTER_PGXM_MAGIC */ + uint32 version; /* CLUSTER_PGXM_VERSION */ + uint32 activated_mxid_floor; /* cluster mxid activation floor */ + uint32 _pad0; /* zero on write; CRC-covered */ + uint64 generation; /* monotonic torn-write guard */ + uint32 crc32c; /* CRC32C over [magic .. generation] */ +} ClusterMxidStripeExtensionRecord; + +StaticAssertDecl(sizeof(ClusterMxidStripeExtensionRecord) == 32, + "ClusterMxidStripeExtensionRecord must be 32 bytes slot-stable"); +StaticAssertDecl(CLUSTER_PGXM_SLOT_OFFSET >= sizeof(ClusterXidStripeActivationRecord), + "PGXM extension must not overlap the PGXA record"); +StaticAssertDecl(CLUSTER_PGXM_SLOT_OFFSET + sizeof(ClusterMxidStripeExtensionRecord) <= 512, + "PGXM extension must fit the 512-byte voting slot"); + +/* + * Pure record integrity (rule 15) -- compute / validate. Validation + * failure means "treat as absent, fail closed": the mxid face stays + * inactive, it never guesses a floor. + */ +extern void cluster_mxid_stripe_extension_record_compute_crc(ClusterMxidStripeExtensionRecord *rec); +extern bool cluster_mxid_stripe_extension_record_valid(const ClusterMxidStripeExtensionRecord *rec); + +#endif /* CLUSTER_MXID_STRIPE_H */ diff --git a/src/include/cluster/cluster_xid_stripe_boot.h b/src/include/cluster/cluster_xid_stripe_boot.h index ac9ca2a028..027630df6e 100644 --- a/src/include/cluster/cluster_xid_stripe_boot.h +++ b/src/include/cluster/cluster_xid_stripe_boot.h @@ -125,6 +125,17 @@ extern ClusterXidStripeJoinVerdict cluster_xid_stripe_join_gate(bool self_may_se */ extern void cluster_xid_stripe_lazy_latch(void); +/* + * Companion lazy latch for the mxid stripe face (spec-7.1 D3-a): + * populates the process-local mxid runtime (cluster_mxid_stripe.c) + * from the published activation state when the region-6 slot carried a + * valid "PGXM" extension record. A published activation WITHOUT the + * extension (pre-extension cluster) leaves the mxid face unlatched + * forever -- allocation stays vanilla dense and every foreign multi + * stays underivable fail-closed. + */ +extern void cluster_mxid_stripe_lazy_latch(void); + /* * D5e allocation clamp face: this node's durable per-slot floor (the * region-4 claim), or InvalidFullTransactionId when no claim has been @@ -146,6 +157,7 @@ typedef struct ClusterXidStripeObs { uint64 cluster_max_active_hwm; uint64 replay_floor_full; uint32 replay_active_bitmap; + uint32 activated_mxid_floor; /* "PGXM" extension floor; 0 = absent (spec-7.1 D3-a) */ } ClusterXidStripeObs; extern void cluster_xid_stripe_observe(ClusterXidStripeObs *obs); diff --git a/src/test/cluster_tap/t/015_inject.pl b/src/test/cluster_tap/t/015_inject.pl index d749f086da..fc8a3f7492 100644 --- a/src/test/cluster_tap/t/015_inject.pl +++ b/src/test/cluster_tap/t/015_inject.pl @@ -57,8 +57,8 @@ # ---------- is( $node->safe_psql('postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'pg_stat_cluster_injections returns 161 rows (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.18 +7 cluster-node-remove-*; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 cluster-clean-leave-survivor-suppress-preflight-ack; spec-5.53 +1 cluster-cr-skip-epoch-bump; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.8 +1 cluster-lmd-force-partial-round; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'pg_stat_cluster_injections returns 162 rows (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.18 +7 cluster-node-remove-*; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 cluster-clean-leave-survivor-suppress-preflight-ack; spec-5.53 +1 cluster-cr-skip-epoch-bump; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.8 +1 cluster-lmd-force-partial-round; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); # ---------- @@ -86,8 +86,8 @@ 'postgres', 'SELECT string_agg(name, \',\' ORDER BY name) FROM pg_stat_cluster_injections' ), - 'cluster-catalog-services-ready-force-closed,cluster-clean-leave-barrier-complete,cluster-clean-leave-escalate-to-failstop,cluster-clean-leave-gcs-flushed,cluster-clean-leave-ges-drained,cluster-clean-leave-quiesce-pre,cluster-clean-leave-request,cluster-clean-leave-survivor-suppress-preflight-ack,cluster-clean-xfer-stale-holder,cluster-collision-detect,cluster-conf-load-success,cluster-conf-parse-fail,cluster-conf-shmem-init,cluster-cr-resolver-memo-suspect,cluster-cr-skip-epoch-bump,cluster-cssd-main-loop-pre-tick,cluster-cssd-mark-peer-dead,cluster-cssd-post-spawn,cluster-cssd-pre-spawn,cluster-cssd-ready-publish,cluster-cssd-shutdown-post,cluster-cssd-shutdown-pre,cluster-debug-dump-entry,cluster-diag-main-loop-iter,cluster-diag-post-spawn,cluster-diag-pre-spawn,cluster-diag-ready-publish,cluster-diag-shutdown-post,cluster-diag-shutdown-pre,cluster-fence-post-thaw-broadcast,cluster-fence-pre-freeze-broadcast,cluster-fence-pre-self-fence-shutdown,cluster-gcs-block-bast-nudge,cluster-gcs-block-drop-reply-before-send,cluster-gcs-block-evict-holder-before-ship,cluster-gcs-block-force-epoch-stale-reply,cluster-gcs-block-forward-master-side,cluster-gcs-block-invalidate-drop-broadcast,cluster-gcs-block-invalidate-stall-ack,cluster-gcs-block-remote-downgrade,cluster-gcs-block-stale-ship,cluster-gcs-block-starvation-force-denied,cluster-gcs-block-x-forward-master-side,cluster-grd-redeclare-skip,cluster-guc-init-pre-define,cluster-ic-mock-send-pre-enqueue,cluster-ic-tier-selected,cluster-init-post-shmem,cluster-init-pre-shmem,cluster-init-top,cluster-ko-peer-skip-ack,cluster-lck-main-loop-iter,cluster-lck-post-spawn,cluster-lck-pre-spawn,cluster-lck-ready-publish,cluster-lck-shutdown-post,cluster-lck-shutdown-pre,cluster-lmd-force-partial-round,cluster-lmon-main-loop-iter,cluster-lmon-post-spawn,cluster-lmon-pre-spawn,cluster-lmon-ready-publish,cluster-lmon-shutdown-post,cluster-lmon-shutdown-pre,cluster-lms-conn-reset,cluster-lms-cr-construct,cluster-lms-data-dispatch,cluster-lms-undo-fetch,cluster-node-remove-cleanup-done,cluster-node-remove-escalate,cluster-node-remove-fence-armed,cluster-node-remove-precheck,cluster-node-remove-request,cluster-node-remove-shrink-committed,cluster-node-remove-shrink-committing,cluster-pcm-acquire-entry,cluster-pcm-convert-pre,cluster-pcm-downgrade-pre,cluster-pcm-release-pre,cluster-pgstat-mirror-sync,cluster-quorum-loss-broadcast,cluster-qvotec-marker-service-hold,cluster-qvotec-poll-post,cluster-qvotec-poll-pre,cluster-reconfig-broadcast-procsig-pre,cluster-reconfig-decide-coordinator,cluster-reconfig-epoch-bump-pre,cluster-reconfig-join-commit-marker-durable,cluster-reconfig-tick-entry,cluster-recovery-anchor-force-failclosed,cluster-relmap-crash-after-stage,cluster-relmap-crash-before-publish,cluster-run-shutdown-top,cluster-run-startup-top,cluster-scn-abort-post-advance,cluster-scn-abort-pre-advance,cluster-scn-advance-post,cluster-scn-advance-pre,cluster-scn-boc-sweep-post,cluster-scn-boc-sweep-pre,cluster-scn-commit-post-advance,cluster-scn-commit-pre-advance,cluster-scn-observe-bump-pre,cluster-scn-observe-entry,cluster-scn-replay-observe-pre,cluster-scn-wal-write-pre,cluster-scn-wraparound-warning,cluster-shared-fs-backend-register,cluster-shared-fs-init-top,cluster-shared-fs-local-open,cluster-shmem-region-init-post,cluster-shmem-region-init-pre,cluster-shmem-register-region,cluster-shmem-request,cluster-shmem-views-srf-entry,cluster-shutdown-top,cluster-sinval-ack-drop-send,cluster-sinval-ack-skip-validate,cluster-sinval-broadcast-drop-send,cluster-sinval-receive-skip-validate,cluster-smgr-create-top,cluster-smgr-open-top,cluster-smgr-which-decision,cluster-startup-phase-0-enter,cluster-startup-phase-0-exit,cluster-startup-phase-0-fail,cluster-startup-phase-1-enter,cluster-startup-phase-1-exit,cluster-startup-phase-1-fail,cluster-startup-phase-2-enter,cluster-startup-phase-2-exit,cluster-startup-phase-2-fail,cluster-startup-phase-3-enter,cluster-startup-phase-3-exit,cluster-startup-phase-3-fail,cluster-startup-phase-4-enter,cluster-startup-phase-4-exit,cluster-startup-phase-4-fail,cluster-stats-main-loop-iter,cluster-stats-post-spawn,cluster-stats-pre-spawn,cluster-stats-ready-publish,cluster-stats-shutdown-post,cluster-stats-shutdown-pre,cluster-thread-recovery-drive,cluster-views-srf-entry,cluster-voting-disk-write-fail,cluster-wal-page-init-thread-id,cluster-wal-state-ensure-pre,cluster-wal-state-write-fail,cluster-wal-thread-claim-create-fail,cluster-wal-thread-validate-pre,cluster-xid-herding-stall,cluster-xid-window-hard-limit,cr_construct_delay_us,cr_corruption,cr_cross_instance,cr_force_read_scn,cr_snapshot_too_old,undo-force-wal-before-data-violation,undo-skip-checkpoint-flush-one', - '161 injection point names match the full registry (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 cluster-clean-leave-survivor-suppress-preflight-ack; spec-5.53 +1 cluster-cr-skip-epoch-bump; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.8 +1 cluster-lmd-force-partial-round; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + 'cluster-catalog-services-ready-force-closed,cluster-clean-leave-barrier-complete,cluster-clean-leave-escalate-to-failstop,cluster-clean-leave-gcs-flushed,cluster-clean-leave-ges-drained,cluster-clean-leave-quiesce-pre,cluster-clean-leave-request,cluster-clean-leave-survivor-suppress-preflight-ack,cluster-clean-xfer-stale-holder,cluster-collision-detect,cluster-conf-load-success,cluster-conf-parse-fail,cluster-conf-shmem-init,cluster-cr-resolver-memo-suspect,cluster-cr-skip-epoch-bump,cluster-cssd-main-loop-pre-tick,cluster-cssd-mark-peer-dead,cluster-cssd-post-spawn,cluster-cssd-pre-spawn,cluster-cssd-ready-publish,cluster-cssd-shutdown-post,cluster-cssd-shutdown-pre,cluster-debug-dump-entry,cluster-diag-main-loop-iter,cluster-diag-post-spawn,cluster-diag-pre-spawn,cluster-diag-ready-publish,cluster-diag-shutdown-post,cluster-diag-shutdown-pre,cluster-fence-post-thaw-broadcast,cluster-fence-pre-freeze-broadcast,cluster-fence-pre-self-fence-shutdown,cluster-gcs-block-bast-nudge,cluster-gcs-block-drop-reply-before-send,cluster-gcs-block-evict-holder-before-ship,cluster-gcs-block-force-epoch-stale-reply,cluster-gcs-block-forward-master-side,cluster-gcs-block-invalidate-drop-broadcast,cluster-gcs-block-invalidate-stall-ack,cluster-gcs-block-remote-downgrade,cluster-gcs-block-stale-ship,cluster-gcs-block-starvation-force-denied,cluster-gcs-block-x-forward-master-side,cluster-grd-redeclare-skip,cluster-guc-init-pre-define,cluster-ic-mock-send-pre-enqueue,cluster-ic-tier-selected,cluster-init-post-shmem,cluster-init-pre-shmem,cluster-init-top,cluster-ko-peer-skip-ack,cluster-lck-main-loop-iter,cluster-lck-post-spawn,cluster-lck-pre-spawn,cluster-lck-ready-publish,cluster-lck-shutdown-post,cluster-lck-shutdown-pre,cluster-lmd-force-partial-round,cluster-lmon-main-loop-iter,cluster-lmon-post-spawn,cluster-lmon-pre-spawn,cluster-lmon-ready-publish,cluster-lmon-shutdown-post,cluster-lmon-shutdown-pre,cluster-lms-conn-reset,cluster-lms-cr-construct,cluster-lms-data-dispatch,cluster-lms-undo-fetch,cluster-mxid-halfspace-hard-limit,cluster-node-remove-cleanup-done,cluster-node-remove-escalate,cluster-node-remove-fence-armed,cluster-node-remove-precheck,cluster-node-remove-request,cluster-node-remove-shrink-committed,cluster-node-remove-shrink-committing,cluster-pcm-acquire-entry,cluster-pcm-convert-pre,cluster-pcm-downgrade-pre,cluster-pcm-release-pre,cluster-pgstat-mirror-sync,cluster-quorum-loss-broadcast,cluster-qvotec-marker-service-hold,cluster-qvotec-poll-post,cluster-qvotec-poll-pre,cluster-reconfig-broadcast-procsig-pre,cluster-reconfig-decide-coordinator,cluster-reconfig-epoch-bump-pre,cluster-reconfig-join-commit-marker-durable,cluster-reconfig-tick-entry,cluster-recovery-anchor-force-failclosed,cluster-relmap-crash-after-stage,cluster-relmap-crash-before-publish,cluster-run-shutdown-top,cluster-run-startup-top,cluster-scn-abort-post-advance,cluster-scn-abort-pre-advance,cluster-scn-advance-post,cluster-scn-advance-pre,cluster-scn-boc-sweep-post,cluster-scn-boc-sweep-pre,cluster-scn-commit-post-advance,cluster-scn-commit-pre-advance,cluster-scn-observe-bump-pre,cluster-scn-observe-entry,cluster-scn-replay-observe-pre,cluster-scn-wal-write-pre,cluster-scn-wraparound-warning,cluster-shared-fs-backend-register,cluster-shared-fs-init-top,cluster-shared-fs-local-open,cluster-shmem-region-init-post,cluster-shmem-region-init-pre,cluster-shmem-register-region,cluster-shmem-request,cluster-shmem-views-srf-entry,cluster-shutdown-top,cluster-sinval-ack-drop-send,cluster-sinval-ack-skip-validate,cluster-sinval-broadcast-drop-send,cluster-sinval-receive-skip-validate,cluster-smgr-create-top,cluster-smgr-open-top,cluster-smgr-which-decision,cluster-startup-phase-0-enter,cluster-startup-phase-0-exit,cluster-startup-phase-0-fail,cluster-startup-phase-1-enter,cluster-startup-phase-1-exit,cluster-startup-phase-1-fail,cluster-startup-phase-2-enter,cluster-startup-phase-2-exit,cluster-startup-phase-2-fail,cluster-startup-phase-3-enter,cluster-startup-phase-3-exit,cluster-startup-phase-3-fail,cluster-startup-phase-4-enter,cluster-startup-phase-4-exit,cluster-startup-phase-4-fail,cluster-stats-main-loop-iter,cluster-stats-post-spawn,cluster-stats-pre-spawn,cluster-stats-ready-publish,cluster-stats-shutdown-post,cluster-stats-shutdown-pre,cluster-thread-recovery-drive,cluster-views-srf-entry,cluster-voting-disk-write-fail,cluster-wal-page-init-thread-id,cluster-wal-state-ensure-pre,cluster-wal-state-write-fail,cluster-wal-thread-claim-create-fail,cluster-wal-thread-validate-pre,cluster-xid-herding-stall,cluster-xid-window-hard-limit,cr_construct_delay_us,cr_corruption,cr_cross_instance,cr_force_read_scn,cr_snapshot_too_old,undo-force-wal-before-data-violation,undo-skip-checkpoint-flush-one', + '162 injection point names match the full registry (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 cluster-clean-leave-survivor-suppress-preflight-ack; spec-5.53 +1 cluster-cr-skip-epoch-bump; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.8 +1 cluster-lmd-force-partial-round; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); # ---------- diff --git a/src/test/cluster_tap/t/017_debug.pl b/src/test/cluster_tap/t/017_debug.pl index 739b6a0e49..b9733c5f5b 100644 --- a/src/test/cluster_tap/t/017_debug.pl +++ b/src/test/cluster_tap/t/017_debug.pl @@ -123,15 +123,15 @@ 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='inject' AND key LIKE '%.fault_type'}), - '161', - 'all 161 injection points have a .fault_type entry under inject category (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2; spec-5.13 H1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'all 162 injection points have a .fault_type entry under inject category (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2; spec-5.13 H1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); is( $node->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='inject' AND key LIKE '%.hits'}), - '161', - 'all 161 injection points have a .hits entry under inject category (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2; spec-5.13 H1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'all 162 injection points have a .hits entry under inject category (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2; spec-5.13 H1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); # ---------- diff --git a/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl b/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl index f446e78340..62918087f3 100644 --- a/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl +++ b/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl @@ -2,28 +2,41 @@ # # 357_cluster_multi_xmax_alias_floor.pl # -# Multi-xmax alias floor: a multixact id is a node-local counter, so a -# foreign multi decoded against the LOCAL pg_multixact aliases to -# unrelated members once the local counter passes that id -- before the -# floor this read answered SILENTLY WRONG (the spec-7.1 census probe-F -# reproduction: the committed update vanished and the superseded version -# stayed visible). The floor fails every updater-bearing multi closed in -# peer mode (53R9C, retryable) until cross-node multixact resolution -# lands; lock-only multis never cut visibility and stay readable. +# Multi-xmax alias floor + spec-7.1 D3-a origin derivation: a multixact +# id is a node-local counter, so a foreign multi decoded against the +# LOCAL pg_multixact aliases to unrelated members once the local +# counter passes that id -- before the floor this read answered +# SILENTLY WRONG (the spec-7.1 census probe-F reproduction: the +# committed update vanished and the superseded version stayed visible). +# D3-a derives the origin from the STRIPED mxid value: +# - a DERIVED-OWN multi decodes natively (alias-free above the +# activation floor) -- the creator can read its own updater multi +# again (L4), lifting the D3-0 floor's blanket cost; +# - a DERIVED-FOREIGN updater multi is resolved through the member +# overlay when it HITS, else fails closed 53R9C. The proactive V4 +# overlay never covers updater-bearing multis (the updater has no +# TT binding at heap_update compose time -- see multixact.c +# spec-7.1 D3-a note), so cross-node positive resolution of those +# multis is served by the member-serve slow path = spec-7.1 D3-b +# (software-ordered behind spec-7.2 D3). Until D3-b, foreign +# updater multis stay fail-closed (L2/L3) -- never silently wrong. # # L1 lockers-only multi, committed -> remote read still answers (no # over-blocking: lock-only never needs a member decode) # L2 keyshare+update multi -> remote read fails closed 53R9C -# (pre-floor: raw native "MultiXactId N has not been created yet") +# (derived foreign, overlay miss; positive = D3-b member serve). +# NEVER the silent-wrong native alias (pre-floor P0) # L3 probe F: the reader first advances its OWN multixact counter past -# the foreign id -> read STILL fails closed 53R9C (pre-floor: rc=0 -# with the superseded row -- the silent-wrong P0) -# L4 creator-side read of its own updater-multi OLD version also fails -# closed -- the documented availability cost of the floor (origin is -# not yet derivable, so the creator cannot be distinguished from an -# aliasing reader); spec-7.1 D3 restores this with a positive proof. +# the foreign id -> read STILL fails closed (origin derivation is +# immune to the local counter position; the silent-wrong P0 can no +# longer happen by construction -- the superseded row is never +# returned) +# L4 creator-side read of its own updater-multi OLD version resolves +# natively -- a DERIVED-OWN multi decodes alias-free above the +# activation floor (was: the D3-0 floor's documented availability +# cost; this is the D3-a positive win over the blanket floor) # -# Spec: spec-7.1-cross-instance-positive-interread.md (D3 floor) +# Spec: spec-7.1-cross-instance-positive-interread.md (D3-a) # # Author: SqlRush # @@ -99,6 +112,33 @@ sub mirrored_coincident_create return 0; } +sub dump_key +{ + my ($node, $key) = @_; + return $node->safe_psql('postgres', + qq{SELECT value FROM cluster_dump_state() WHERE key = '$key'}); +} + +# The mxid activation floor must be published + latched before the test +# composes the multixacts it expects to resolve positively: a multixact +# allocated before activation is a DENSE (pre-stripe) id, which is +# forever underivable and correctly fails closed (the honest floor, not +# a positive-resolution case). Wait for both nodes to see a nonzero +# floor so the L2/L3/L5 composes land above it (striped, derivable). +sub wait_mxid_floor +{ + for my $i (1 .. 60) + { + my $f0 = dump_key($node0, 'mxid_stripe_activated_floor') || '0'; + my $f1 = dump_key($node1, 'mxid_stripe_activated_floor') || '0'; + return 1 if $f0 > 0 && $f1 > 0; + usleep(1_000_000); + } + return 0; +} + +ok(wait_mxid_floor(), 'mxid activation floor published + latched on both nodes'); + ok(mirrored_coincident_create('mxf_t', 'CREATE TABLE mxf_t (aid int, v int)'), 'mxf_t relfilepath coincidence holds'); ok(write_retry($node0, 'INSERT INTO mxf_t SELECT g, 0 FROM generate_series(1, 20) g'), 'seeded'); @@ -111,6 +151,22 @@ sub bq return $ok; } +# Cross-node positive reads converge once the member overlay + member +# commit hints arrive on the wire; retry to the expected answer (the +# project's "转正腿带收敛等待" idiom). +sub read_converge +{ + my ($node, $sql, $want, $tries) = @_; + my ($rc, $out, $err) = (1, '', ''); + for my $i (1 .. $tries) + { + ($rc, $out, $err) = $node->psql('postgres', $sql, timeout => 15); + return ($rc, $out, $err) if $rc == 0 && $out eq $want; + usleep(500_000); + } + return ($rc, $out, $err); +} + # ------------------------------------------------------------------ # L1: lockers-only multi (share + share), committed -> remote read OK. # ------------------------------------------------------------------ @@ -149,10 +205,14 @@ sub bq my ($rc, $out, $err) = $node1->psql('postgres', 'SELECT aid, v FROM mxf_t WHERE aid = 11', timeout => 15); - isnt($rc, 0, 'L2 updater multi: remote read fails closed'); - like($err, qr/cannot be attributed to an origin node|TT status unknown for deleting xmax/, 'L2 updater multi: clean 53R9C floor message (not a raw native multixact error)'); + isnt($rc, 0, + 'L2 updater multi: remote read fails closed (derived foreign; positive = D3-b member serve)'); + like( + $err, + qr/cannot be attributed to an origin node|multixact member overlay miss|TT status unknown for deleting xmax/, + 'L2 updater multi: clean 53R9C floor message (not a raw native multixact error)'); unlike($err, qr/has not been created yet/, - 'L2 updater multi: native id-space error no longer surfaces'); + 'L2 updater multi: native id-space error never surfaces'); } # ------------------------------------------------------------------ @@ -180,21 +240,47 @@ sub bq my ($rc, $out, $err) = $node1->psql('postgres', 'SELECT aid, v FROM mxf_t WHERE aid = 11', timeout => 15); - isnt($rc, 0, 'L3 probe F: aliased read still fails closed (was: silent wrong answer)'); - like($err, qr/cannot be attributed to an origin node|TT status unknown for deleting xmax/, 'L3 probe F: floor message'); + isnt($rc, 0, + 'L3 probe F: aliased read still fails closed (derivation immune to local counter position)'); + like( + $err, + qr/cannot be attributed to an origin node|multixact member overlay miss|TT status unknown for deleting xmax/, + 'L3 probe F: floor message'); unlike($out, qr/11\|0/, 'L3 probe F: the superseded version is NEVER returned'); } # ------------------------------------------------------------------ -# L4: creator-side read of its own updater-multi OLD version -- the -# documented availability cost (creator is indistinguishable from an -# aliasing reader until spec-7.1 D3 makes the origin derivable). +# L4: creator-side read of its own updater-multi OLD version resolves +# natively -- a DERIVED-OWN multi decodes alias-free above the +# activation floor (the D3-a positive win over the D3-0 blanket +# floor, which fail-closed even the creator). +# ------------------------------------------------------------------ +{ + my ($rc, $out, $err) = + read_converge($node0, 'SELECT aid, v FROM mxf_t WHERE aid = 11', '11|100', 10); + is($rc, 0, 'L4 creator-local read of its own updater multi answers (derived own -> native)'); + is($out, '11|100', 'L4 creator sees the committed update'); +} + +# ------------------------------------------------------------------ +# L5: revert valve -- cluster.multi_xmax_remote_resolve = off keeps the +# derived-foreign fail-closed floor (the on-path for updater multis +# is fail-closed too until D3-b, so L5 asserts off never REGRESSES +# to the native silent-wrong alias, and the derived-own L4 win is +# independent of the GUC). # ------------------------------------------------------------------ { + $node1->append_conf('postgresql.conf', 'cluster.multi_xmax_remote_resolve = off'); + $node1->reload; + usleep(500_000); + my ($rc, $out, $err) = - $node0->psql('postgres', 'SELECT aid, v FROM mxf_t WHERE aid = 11', timeout => 15); - isnt($rc, 0, 'L4 creator-local read of the multi old version fails closed (documented cost)'); - like($err, qr/cannot be attributed to an origin node|TT status unknown for deleting xmax/, 'L4 floor message on the creator too'); + $node1->psql('postgres', 'SELECT aid, v FROM mxf_t WHERE aid = 11', timeout => 15); + isnt($rc, 0, 'L5 resolve off: foreign updater multi read fails closed (floor verbatim)'); + unlike($out, qr/11\|0/, 'L5 resolve off: the superseded version is NEVER returned'); + + $node1->append_conf('postgresql.conf', 'cluster.multi_xmax_remote_resolve = on'); + $node1->reload; } $pair->stop_pair; diff --git a/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl b/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl new file mode 100644 index 0000000000..0ba3e637f8 --- /dev/null +++ b/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl @@ -0,0 +1,293 @@ +#------------------------------------------------------------------------- +# +# 359_cluster_mxid_stripe_gapwalk.pl +# +# spec-7.1 D3-a mxid stripe substrate legs: +# +# G0 activation extension: both nodes publish a nonzero +# mxid_stripe_activated_floor dump key (the "PGXM" record rode the +# activation slot and latched). +# G1 class-0 offsets page-boundary crossing: node0 (stripe slot 0) +# burns enough lock-only multixacts to stride across an offsets +# SLRU page boundary (2048 entries/page, divisible by 16). The +# burn itself is the trigger: without the gap-walk the crossing +# dies on the missing page at RecordNewMultiXact. +# G2 non-0-class crossing: same burn on node1 (stripe slot 1). This +# leg is INDEPENDENT of G1 by design (IN-8-approve): a page-first +# id is always congruence class 0, so a naive "extend the +# candidate" fix passes G1 and still dies here. +# G3 restart durability: node1 restarts after the crossing and can +# both re-trim its offsets SLRU (TrimMultiXact reads the current +# page) and compose a fresh multixact on the striped chain. +# G4 half-space hard limit (53RB4): the armed injection point forces +# the refusal branch (the organic trigger needs 2^31 multixacts); +# the guardrail counter becomes visible in the dump and creation +# recovers after disarm. +# G5 overlay-miss negative leg: with the origin's overlay emit +# suppressed (member cap below the composed member count), the +# remote read of an updater multi fails closed 53R9C -- positive +# resolution never guesses on a miss. +# +# The positive-resolution legs (foreign updater multi resolves through +# the member overlay) live in t/357, whose floor legs turned positive +# with this deliverable. +# +# Spec: spec-7.1-cross-instance-positive-interread.md (D3-a) +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl +#------------------------------------------------------------------------- + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'mxgapwalk', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +sub write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 10) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +# Mirrored coincident CREATE (shared-data harness discipline). +sub mirrored_coincident_create +{ + my ($name, $ddl) = @_; + my ($q0, $q1) = ('', ''); + for my $attempt (1 .. 8) + { + return 0 unless write_retry($node0, $ddl); + return 0 unless write_retry($node1, $ddl); + $q0 = $node0->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + $q1 = $node1->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + return 1 if $q0 eq $q1; + my ($n0) = $q0 =~ /(\d+)$/; + my ($n1) = $q1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + return 0 + unless write_retry($lag, + "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + return 0 unless write_retry($node0, "DROP TABLE $name"); + return 0 unless write_retry($node1, "DROP TABLE $name"); + } + return 0; +} + +sub dump_key +{ + my ($node, $key) = @_; + return $node->safe_psql('postgres', + qq{SELECT value FROM cluster_dump_state() WHERE key = '$key'}); +} + +sub bq +{ + my ($bg, $tag, $sql) = @_; + my $ok = eval { $bg->query_safe($sql); 1 }; + diag("step $tag FAILED: $@") if !$ok; + return $ok; +} + +# One lock-only multixact per round on $node against $table (two +# concurrent FOR SHARE holders compose share+share). Two persistent +# background sessions; a fresh pair every 64 rounds keeps the psql +# pipes young. +sub burn_multis +{ + my ($node, $table, $rounds, $tag) = @_; + my $done = 0; + while ($done < $rounds) + { + my $chunk = $rounds - $done > 64 ? 64 : $rounds - $done; + my $bga = $node->background_psql('postgres', timeout => 60); + my $bgb = $node->background_psql('postgres', timeout => 60); + for my $i (1 .. $chunk) + { + my $aid = ($done + $i) % 20 + 1; + return $done unless bq($bga, "$tag-$done-$i-1", 'BEGIN'); + return $done + unless bq($bga, "$tag-$done-$i-2", + "SELECT v FROM $table WHERE aid = $aid FOR SHARE"); + return $done unless bq($bgb, "$tag-$done-$i-3", 'BEGIN'); + return $done + unless bq($bgb, "$tag-$done-$i-4", + "SELECT v FROM $table WHERE aid = $aid FOR SHARE"); + return $done unless bq($bga, "$tag-$done-$i-5", 'COMMIT'); + return $done unless bq($bgb, "$tag-$done-$i-6", 'COMMIT'); + } + eval { $bga->quit }; + eval { $bgb->quit }; + $done += $chunk; + } + return $done; +} + +ok(mirrored_coincident_create('mxgw_t', 'CREATE TABLE mxgw_t (aid int, v int)'), + 'mxgw_t relfilepath coincidence holds'); +ok(write_retry($node0, 'INSERT INTO mxgw_t SELECT g, 0 FROM generate_series(1, 20) g'), + 'seeded'); + +# ------------------------------------------------------------------ +# G0: the mxid activation extension latched on both nodes. +# ------------------------------------------------------------------ +{ + my $floor0 = dump_key($node0, 'mxid_stripe_activated_floor'); + my $floor1 = dump_key($node1, 'mxid_stripe_activated_floor'); + cmp_ok($floor0, '>', 0, 'G0 node0 published a nonzero mxid activation floor'); + is($floor1, $floor0, 'G0 node1 adopted the SAME mxid activation floor'); +} + +# ------------------------------------------------------------------ +# G1: class-0 node strides across an offsets page boundary. 160 rounds +# x stride 16 = a 2560-id span from the formation-time floor (< 2048), +# guaranteed to cross at least one 2048-entry page boundary. Without +# the gap-walk the crossing dies inside the burn (missing offsets page +# at RecordNewMultiXact). +# ------------------------------------------------------------------ +is(burn_multis($node0, 'mxgw_t', 160, 'G1'), + 160, 'G1 class-0 node crossed an offsets page boundary alive (160 multis)'); + +# ------------------------------------------------------------------ +# G2: non-0-class node does the same, INDEPENDENTLY (a page-first id is +# never class 1, so this leg catches a class-0-only fix). +# ------------------------------------------------------------------ +is(burn_multis($node1, 'mxgw_t', 160, 'G2'), + 160, 'G2 class-1 node crossed an offsets page boundary alive (160 multis)'); + +# ------------------------------------------------------------------ +# G3: restart durability -- TrimMultiXact re-reads the current offsets +# page and the striped chain continues. +# ------------------------------------------------------------------ +{ + $node1->restart; + ok($pair->wait_for_peer_state(0, 1, 'connected', 60), 'G3 node1 rejoined after restart'); + ok($pair->wait_for_peer_state(1, 0, 'connected', 60), 'G3 node1 sees node0 again'); + is(burn_multis($node1, 'mxgw_t', 4, 'G3'), + 4, 'G3 striped multixact chain continues after restart'); +} + +# ------------------------------------------------------------------ +# G4: half-space hard limit leg -- the armed injection forces the 53RB4 +# refusal; the guardrail counter shows it; disarm recovers. +# ------------------------------------------------------------------ +{ + # Bare name arms the WARNING kind: enough for the armed-state peek + # (any non-NONE type forces the branch) AND the only kind the GUC + # disarm sweep releases -- a ':error' arm would survive the reload. + $node0->append_conf('postgresql.conf', + "cluster.injection_points = 'cluster-mxid-halfspace-hard-limit'"); + $node0->reload; + usleep(500_000); + + my $bga = $node0->background_psql('postgres', timeout => 25); + bq($bga, 'G4-1', 'BEGIN'); + bq($bga, 'G4-2', 'SELECT v FROM mxgw_t WHERE aid = 3 FOR SHARE'); + # Second locker in a plain psql so the server ERROR text is captured. + my ($rc, $out, $err) = $node0->psql( + 'postgres', + "BEGIN;\nSELECT v FROM mxgw_t WHERE aid = 3 FOR SHARE;\nCOMMIT;", + timeout => 15); + eval { $bga->quit }; + isnt($rc, 0, 'G4 second locker (multixact compose) refused under the armed half-space limit'); + like( + $err, + qr/cluster mxid half-space limit reached/, + 'G4 refusal is the 53RB4 half-space message'); + + cmp_ok(dump_key($node0, 'mxid_stripe_halfspace_refusals'), + '>', 0, 'G4 guardrail counter recorded the refusal'); + + $node0->append_conf('postgresql.conf', "cluster.injection_points = ''"); + $node0->reload; + usleep(500_000); + is(burn_multis($node0, 'mxgw_t', 2, 'G4r'), + 2, 'G4 multixact creation recovers after disarm'); +} + +# ------------------------------------------------------------------ +# G5: overlay-miss negative leg. Cap the origin's overlay member limit +# below the composed member count so no overlay entry is installed or +# emitted; the remote read of the updater multi must fail closed 53R9C +# (positive resolution never guesses on a miss -- rule 8.A). +# ------------------------------------------------------------------ +{ + $node0->append_conf('postgresql.conf', 'cluster.multixact_member_overlay_max_members = 4'); + $node0->reload; + usleep(500_000); + + # Fresh table: the burn table's page ITLs (INITRANS = 8) are too + # contended for a 5-way concurrent compose. + ok(mirrored_coincident_create('mxgw_g5', 'CREATE TABLE mxgw_g5 (aid int, v int)'), + 'G5 mxgw_g5 relfilepath coincidence holds'); + ok(write_retry($node0, 'INSERT INTO mxgw_g5 VALUES (17, 0)'), 'G5 seeded'); + + # 4 KEY SHARE lockers + 1 non-key updater = 5 members > 4. + my @lockers = map { $node0->background_psql('postgres', timeout => 25) } (1 .. 4); + my $bgu = $node0->background_psql('postgres', timeout => 25); + my $li = 0; + for my $bg (@lockers) + { + $li++; + bq($bg, "G5-l$li-1", 'BEGIN'); + bq($bg, "G5-l$li-2", 'SELECT v FROM mxgw_g5 WHERE aid = 17 FOR KEY SHARE'); + } + bq($bgu, 'G5-u-1', 'BEGIN'); + bq($bgu, 'G5-u-2', 'UPDATE mxgw_g5 SET v = v + 500 WHERE aid = 17'); + bq($bgu, 'G5-u-3', 'COMMIT'); + for my $bg (@lockers) + { + bq($bg, 'G5-lc', 'COMMIT'); + eval { $bg->quit }; + } + eval { $bgu->quit }; + + my ($rc, $out, $err) = + $node1->psql('postgres', 'SELECT aid, v FROM mxgw_g5 WHERE aid = 17', timeout => 15); + isnt($rc, 0, 'G5 remote read of the unemitted updater multi fails closed'); + like( + $err, + qr/multixact member overlay miss|TT status unknown for deleting xmax/, + 'G5 clean overlay-miss fail-closed message'); + unlike($out, qr/17\|0/, 'G5 the superseded version is NEVER returned'); + + $node0->append_conf('postgresql.conf', 'cluster.multixact_member_overlay_max_members = 32'); + $node0->reload; +} + +$pair->stop_pair; +done_testing(); diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index 2360929a73..dbae5f59c0 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -180,7 +180,7 @@ test_cluster_backup: test_cluster_backup.c unit_test.h $(CLUSTER_VERSION_O) \ # separate rules because they also link additional cluster_*.o # objects (the test files stub the PG backend symbols those # objects reference). -SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) +SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_mxid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) # spec-2.4 D16: test_cluster_epoch links cluster_epoch.o standalone. # cluster_epoch.c references ShmemInitStruct + cluster_shmem_register_region # + pg_atomic_*; the test stubs all of these and exercises only the diff --git a/src/test/cluster_unit/test_cluster_mxid_stripe.c b/src/test/cluster_unit/test_cluster_mxid_stripe.c new file mode 100644 index 0000000000..95559fddfc --- /dev/null +++ b/src/test/cluster_unit/test_cluster_mxid_stripe.c @@ -0,0 +1,434 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_mxid_stripe.c + * Truth tables for the mxid stripe derivation pure layer (D3-a). + * + * Covers (spec-7.1 §4 unit, D3-a): + * - cluster_mxid_origin_slot_floored: half-space truth table -- + * below floor -> -1, inside [floor, floor + 2^31) -> mxid mod + * STRIDE, at/beyond the half-space boundary -> -1, invalid args + * -> -1, half-space spanning the 2^32 wrap stays derivable. + * - cluster_mxid_next_striped: in-class rounding, the + * InvalidMultiXactId (0) in-class position skipped, 2^32 wrap + * carry, result invariants over all 16 slots. + * - cluster_mxid_halfspace_exceeded: guardrail boundary +/- 1, + * invalid floor fails closed, wrap-spanning boundary. + * - runtime wrappers (cluster_mxid_origin_slot / is_mine / + * allocation_slot / stripe_floor): fail-closed before latch, + * derivation after latch, defensive latch rejections. + * - durable "PGXM" activation-slot extension record: roundtrip + * accepts; every sanity / CRC corruption rejects (treat-as- + * absent, fail-closed); the all-zeros pre-extension slot rejects. + * + * The mxid stripe layer is pure (no shmem / no locks); this binary + * links cluster_mxid_stripe.o standalone with the boot lazy latch + * stubbed to a no-op (latch tests drive the latch directly). + * + * Spec: spec-7.1-cross-instance-positive-interread.md + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/test/cluster_unit/test_cluster_mxid_stripe.c + * + * NOTES + * This is a pgrac-original file (no derivation from PostgreSQL). + * Each test_*.c is a standalone executable; see unit_test.h. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/multixact.h" +#include "cluster/cluster_mxid_stripe.h" +#include "cluster/cluster_xid_stripe_boot.h" /* lazy-latch stub prototype */ + +/* Drop PG's port.h printf -> pg_printf override; unit_test.h uses + * stdlib printf (libpgport is linked for CRC32C only). */ +#undef printf +#undef fprintf +#undef snprintf +#undef sprintf +#undef vsnprintf +#undef vfprintf +#undef vprintf +#undef vsprintf +#undef strerror +#undef strerror_r + +#include "unit_test.h" + +UT_DEFINE_GLOBALS(); + +/* ---------- + * Stubs needed to link cluster_mxid_stripe.o standalone. + * ---------- + */ + +void +ExceptionalCondition(const char *conditionName pg_attribute_unused(), + const char *fileName pg_attribute_unused(), + int lineNumber pg_attribute_unused()) +{ + abort(); +} + +/* GUC / conf face consumed by cluster_mxid_allocation_slot (cluster_guc.o + * is not linked here; the gate is a pure function of these three plus the + * latch). */ +bool cluster_enabled = false; +int cluster_node_id = -1; +bool cluster_xid_striping = false; + +/* No-op stand-in for the boot lazy latch (cluster_xid_stripe_boot.o is + * not linked here; the latch tests drive cluster_mxid_stripe_latch_runtime + * directly). */ +void +cluster_mxid_stripe_lazy_latch(void) +{} + + +/* ---------- + * cluster_mxid_origin_slot_floored + * ---------- + */ + +UT_TEST(test_origin_slot_floored_basic) +{ + /* stride-aligned floor so the absolute congruence class is easy to + * read off (the slot is value mod STRIDE, NOT distance-from-floor) */ + MultiXactId floor_mxid = 4096; + + /* at the floor: derivable, slot = value mod STRIDE */ + UT_ASSERT(cluster_mxid_origin_slot_floored(4096, floor_mxid) == 0); + + /* inside the half-space */ + UT_ASSERT(cluster_mxid_origin_slot_floored(4101, floor_mxid) == 5); + UT_ASSERT(cluster_mxid_origin_slot_floored(4096 + 16 * 7 + 3, floor_mxid) == 3); + + /* last derivable position: floor + 2^31 - 1 (0x7FFFFFFF mod 16 = 15) */ + UT_ASSERT(cluster_mxid_origin_slot_floored((MultiXactId)4096 + 0x7FFFFFFF, floor_mxid) == 15); + + /* first ambiguous position: floor + 2^31 -> underivable */ + UT_ASSERT(cluster_mxid_origin_slot_floored((MultiXactId)(4096 + (uint32)0x80000000), floor_mxid) + == -1); + + /* behind the floor (pre-stripe history) -> underivable */ + UT_ASSERT(cluster_mxid_origin_slot_floored(4095, floor_mxid) == -1); + UT_ASSERT(cluster_mxid_origin_slot_floored(1, floor_mxid) == -1); +} + +UT_TEST(test_origin_slot_floored_invalid_args) +{ + UT_ASSERT(cluster_mxid_origin_slot_floored(InvalidMultiXactId, 1000) == -1); + UT_ASSERT(cluster_mxid_origin_slot_floored(1000, InvalidMultiXactId) == -1); + UT_ASSERT(cluster_mxid_origin_slot_floored(InvalidMultiXactId, InvalidMultiXactId) == -1); +} + +UT_TEST(test_origin_slot_floored_wrap_spanning) +{ + /* Half-space spanning the 2^32 boundary: floor near the top of the + * id space, mxid a wrapped small value ahead of it. uint32 modular + * distance keeps the derivation exact. */ + MultiXactId floor_mxid = (MultiXactId)0xFFFF0010; + + /* 0x20 is 0x1_0010 ahead of the floor (mod 2^32): derivable */ + UT_ASSERT(cluster_mxid_origin_slot_floored((MultiXactId)0x20, floor_mxid) == 0); + UT_ASSERT(cluster_mxid_origin_slot_floored((MultiXactId)0x2D, floor_mxid) == 13); + + /* still inside: floor + 2^31 - 1 (wrapped) */ + UT_ASSERT(cluster_mxid_origin_slot_floored((MultiXactId)(0xFFFF0010 + 0x7FFFFFFF), floor_mxid) + != -1); + + /* boundary (wrapped): floor + 2^31 -> underivable */ + UT_ASSERT( + cluster_mxid_origin_slot_floored((MultiXactId)(0xFFFF0010 + (uint32)0x80000000), floor_mxid) + == -1); + + /* just behind the floor -> underivable (distance wraps to ~2^32) */ + UT_ASSERT(cluster_mxid_origin_slot_floored((MultiXactId)0xFFFF000F, floor_mxid) == -1); +} + +UT_TEST(test_origin_slot_floored_congruence_all_slots) +{ + MultiXactId floor_mxid = 4096; /* multiple of 16 */ + int k; + + for (k = 0; k < CLUSTER_MXID_STRIDE; k++) { + MultiXactId m = floor_mxid + 16 * 100 + (MultiXactId)k; + + UT_ASSERT(cluster_mxid_origin_slot_floored(m, floor_mxid) == k); + } +} + + +/* ---------- + * cluster_mxid_next_striped + * ---------- + */ + +UT_TEST(test_next_striped_in_class) +{ + /* already in class: returned unchanged */ + UT_ASSERT(cluster_mxid_next_striped(1600, 0) == 1600); + UT_ASSERT(cluster_mxid_next_striped(1605, 5) == 1605); + + /* rounding up to the class */ + UT_ASSERT(cluster_mxid_next_striped(1601, 0) == 1616); + UT_ASSERT(cluster_mxid_next_striped(1601, 5) == 1605); + UT_ASSERT(cluster_mxid_next_striped(1606, 5) == 1621); +} + +UT_TEST(test_next_striped_invalid_zero_skip) +{ + /* The class-0 position at value 0 is InvalidMultiXactId: skipped one + * stride to 16. (Only value 0 is special for multixacts -- + * FirstMultiXactId is 1 -- unlike the xid space's 0-2.) */ + UT_ASSERT(cluster_mxid_next_striped((MultiXactId)0xFFFFFFF1, 0) == 16); + + /* wrap carry into a legal small value: no skip for slot >= 1 */ + UT_ASSERT(cluster_mxid_next_striped((MultiXactId)0xFFFFFFFE, 5) == 5); + + /* asking exactly at 0 for slot 0 skips to 16 */ + UT_ASSERT(cluster_mxid_next_striped(InvalidMultiXactId, 0) == 16); + + /* slot 1 from 0 rounds to 1 (FirstMultiXactId itself is legal) */ + UT_ASSERT(cluster_mxid_next_striped(InvalidMultiXactId, 1) == 1); +} + +UT_TEST(test_next_striped_invariants_all_slots) +{ + MultiXactId starts[] = { 1, 17, 2047, 2048, 65535, (MultiXactId)0xFFFFFFF0 }; + int s, k; + + for (s = 0; s < (int)lengthof(starts); s++) { + for (k = 0; k < CLUSTER_MXID_STRIDE; k++) { + MultiXactId r = cluster_mxid_next_striped(starts[s], k); + + /* in class, valid, and within one skip of the start */ + UT_ASSERT((int)(r % CLUSTER_MXID_STRIDE) == k); + UT_ASSERT(r != InvalidMultiXactId); + UT_ASSERT((uint32)(r - starts[s]) < 2 * CLUSTER_MXID_STRIDE); + } + } +} + + +/* ---------- + * cluster_mxid_halfspace_exceeded + * ---------- + */ + +UT_TEST(test_halfspace_boundary) +{ + MultiXactId floor_mxid = 1000; + + UT_ASSERT(!cluster_mxid_halfspace_exceeded(1000, floor_mxid)); + UT_ASSERT(!cluster_mxid_halfspace_exceeded((MultiXactId)1000 + 0x7FFFFFFF, floor_mxid)); + UT_ASSERT( + cluster_mxid_halfspace_exceeded((MultiXactId)(1000 + (uint32)0x80000000), floor_mxid)); +} + +UT_TEST(test_halfspace_invalid_floor_fails_closed) +{ + UT_ASSERT(cluster_mxid_halfspace_exceeded(1000, InvalidMultiXactId)); +} + +UT_TEST(test_halfspace_wrap_spanning) +{ + MultiXactId floor_mxid = (MultiXactId)0xFFFFFF00; + + /* wrapped candidate still inside the half-space */ + UT_ASSERT(!cluster_mxid_halfspace_exceeded((MultiXactId)0x10, floor_mxid)); + UT_ASSERT(!cluster_mxid_halfspace_exceeded((MultiXactId)(0xFFFFFF00 + 0x7FFFFFFF), floor_mxid)); + + /* wrapped boundary */ + UT_ASSERT(cluster_mxid_halfspace_exceeded((MultiXactId)(0xFFFFFF00 + (uint32)0x80000000), + floor_mxid)); +} + + +/* ---------- + * runtime wrappers + latch + * ---------- + */ + +UT_TEST(test_runtime_inactive_fail_closed) +{ + /* nothing latched yet (and the stubbed lazy latch is a no-op) */ + UT_ASSERT(cluster_mxid_origin_slot(5000) == -1); + UT_ASSERT(!cluster_mxid_is_mine(5000)); + UT_ASSERT(cluster_mxid_allocation_slot() == -1); + UT_ASSERT(cluster_mxid_stripe_floor() == InvalidMultiXactId); +} + +UT_TEST(test_runtime_latch_defensive) +{ + /* active=true with junk latches the INACTIVE state (fail-closed) */ + cluster_mxid_stripe_latch_runtime(true, -1, 1000); + UT_ASSERT(cluster_mxid_origin_slot(5000) == -1); + + cluster_mxid_stripe_latch_runtime(true, CLUSTER_MXID_STRIDE, 1000); + UT_ASSERT(cluster_mxid_origin_slot(5000) == -1); + + cluster_mxid_stripe_latch_runtime(true, 3, InvalidMultiXactId); + UT_ASSERT(cluster_mxid_origin_slot(5000) == -1); + UT_ASSERT(cluster_mxid_stripe_floor() == InvalidMultiXactId); +} + +UT_TEST(test_runtime_latched_derivation) +{ + cluster_node_id = 3; + cluster_enabled = true; + cluster_xid_striping = true; + + cluster_mxid_stripe_latch_runtime(true, 3, 4096); + + UT_ASSERT(cluster_mxid_stripe_floor() == 4096); + UT_ASSERT(cluster_mxid_origin_slot(4096 + 16 * 4 + 3) == 3); + UT_ASSERT(cluster_mxid_is_mine(4096 + 16 * 4 + 3)); + UT_ASSERT(cluster_mxid_origin_slot(4096 + 16 * 4 + 7) == 7); + UT_ASSERT(!cluster_mxid_is_mine(4096 + 16 * 4 + 7)); + + /* below-floor / beyond-half-space stay underivable after latch */ + UT_ASSERT(cluster_mxid_origin_slot(4095) == -1); + UT_ASSERT(cluster_mxid_origin_slot((MultiXactId)(4096 + (uint32)0x80000000)) == -1); + UT_ASSERT(!cluster_mxid_is_mine(4095)); + + /* allocation gate open with the three GUC facts + latch */ + UT_ASSERT(cluster_mxid_allocation_slot() == 3); + + /* GUC off closes the gate even while latched */ + cluster_xid_striping = false; + UT_ASSERT(cluster_mxid_allocation_slot() == -1); + cluster_xid_striping = true; + + cluster_enabled = false; + UT_ASSERT(cluster_mxid_allocation_slot() == -1); + cluster_enabled = true; + + /* node outside the stripe width closes the gate */ + cluster_node_id = CLUSTER_MXID_STRIDE; + UT_ASSERT(cluster_mxid_allocation_slot() == -1); + cluster_node_id = 3; + + /* restore for later tests */ + cluster_mxid_stripe_latch_runtime(false, -1, InvalidMultiXactId); + cluster_enabled = false; + cluster_xid_striping = false; + cluster_node_id = -1; +} + + +/* ---------- + * durable "PGXM" activation-slot extension record + * ---------- + */ + +static ClusterMxidStripeExtensionRecord +make_valid_ext(void) +{ + ClusterMxidStripeExtensionRecord rec; + + memset(&rec, 0, sizeof(rec)); + rec.magic = CLUSTER_PGXM_MAGIC; + rec.version = CLUSTER_PGXM_VERSION; + rec.activated_mxid_floor = 1024; + rec.generation = 1; + cluster_mxid_stripe_extension_record_compute_crc(&rec); + return rec; +} + +UT_TEST(test_ext_record_roundtrip_valid) +{ + ClusterMxidStripeExtensionRecord rec = make_valid_ext(); + + UT_ASSERT(cluster_mxid_stripe_extension_record_valid(&rec)); +} + +UT_TEST(test_ext_record_absent_all_zeros) +{ + /* a slot written by a pre-extension binary reads back zeros here: + * record-absent, the fail-closed empty state */ + ClusterMxidStripeExtensionRecord rec; + + memset(&rec, 0, sizeof(rec)); + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); +} + +UT_TEST(test_ext_record_sanity_rejections) +{ + ClusterMxidStripeExtensionRecord rec; + + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(NULL)); + + rec = make_valid_ext(); + rec.magic = 0x12345678; + cluster_mxid_stripe_extension_record_compute_crc(&rec); + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); + + rec = make_valid_ext(); + rec.version = 2; + cluster_mxid_stripe_extension_record_compute_crc(&rec); + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); + + rec = make_valid_ext(); + rec.activated_mxid_floor = InvalidMultiXactId; + cluster_mxid_stripe_extension_record_compute_crc(&rec); + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); + + rec = make_valid_ext(); + rec.generation = 0; + cluster_mxid_stripe_extension_record_compute_crc(&rec); + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); +} + +UT_TEST(test_ext_record_crc_rejections) +{ + ClusterMxidStripeExtensionRecord rec = make_valid_ext(); + + rec.activated_mxid_floor = 2048; /* content changed, CRC stale */ + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); + + rec = make_valid_ext(); + rec._pad0 = 1; /* padding is CRC-covered: torn/garbage write rejects */ + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); + + rec = make_valid_ext(); + rec.crc32c ^= 0x1; + UT_ASSERT(!cluster_mxid_stripe_extension_record_valid(&rec)); +} + + +int +main(void) +{ + UT_RUN(test_origin_slot_floored_basic); + UT_RUN(test_origin_slot_floored_invalid_args); + UT_RUN(test_origin_slot_floored_wrap_spanning); + UT_RUN(test_origin_slot_floored_congruence_all_slots); + + UT_RUN(test_next_striped_in_class); + UT_RUN(test_next_striped_invalid_zero_skip); + UT_RUN(test_next_striped_invariants_all_slots); + + UT_RUN(test_halfspace_boundary); + UT_RUN(test_halfspace_invalid_floor_fails_closed); + UT_RUN(test_halfspace_wrap_spanning); + + UT_RUN(test_runtime_inactive_fail_closed); + UT_RUN(test_runtime_latch_defensive); + UT_RUN(test_runtime_latched_derivation); + + UT_RUN(test_ext_record_roundtrip_valid); + UT_RUN(test_ext_record_absent_all_zeros); + UT_RUN(test_ext_record_sanity_rejections); + UT_RUN(test_ext_record_crc_rejections); + + UT_DONE(); +} From c41ad94fc78b6283ebdf6dfaa9889d590ef559bf Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 21:10:41 +0800 Subject: [PATCH 09/29] fix(cluster): stub new multixact counters in test_cluster_debug (spec-7.1 D3-a) D3-a extended cluster_debug.c's cluster_dump_state() to emit the striped multixact origin-derivation observability counters (cluster_multixact_get_mxid_halfspace_refuse_count and cluster_multixact_get_mxid_underivable_read_count), which live in cluster_multixact.o. The test_cluster_debug unit target links only cluster_debug.o and stubs every backend counter accessor cluster_dump_state references; these two new accessors were left un-stubbed, so a clean `make -C src/test/cluster_unit` failed to link test_cluster_debug with undefined symbols. Add the two missing return-0 stubs following the existing dump-state stub pattern (targeted unit gates build individual targets and so did not surface this in a full-suite clean build). Spec: spec-7.1-cross-instance-positive-interread.md --- src/test/cluster_unit/test_cluster_debug.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index 8a79c1e572..343af04b02 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -335,6 +335,19 @@ cluster_remote_xact_side_effect_drop_count(void) { return 0; } +/* spec-7.1 D3-a stub: dump_state (cluster_debug.o) reads the striped-multixact + * origin-derivation observability counters; cluster_multixact.o is not linked + * here. Stub each to return 0 (matches the module-disabled path). */ +uint64 +cluster_multixact_get_mxid_halfspace_refuse_count(void) +{ + return 0; +} +uint64 +cluster_multixact_get_mxid_underivable_read_count(void) +{ + return 0; +} /* spec-6.14 D5 stub: dump_catalog reads the shared relmap authority header; * cluster_relmap_authority.o is not linked here. cluster_shared_catalog is * false above, so the read is short-circuited and never called. */ From d85217b211b6c2f32ae55fa6e968296a13c53fb1 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Tue, 7 Jul 2026 22:18:57 +0800 Subject: [PATCH 10/29] test(cluster): census driver snapshots D3-a mxid-stripe counters Spec: spec-7.1-cross-instance-positive-interread.md The MULTI-phase census driver snapshotted only the 'cr' dump category; D3-a's foreign-multi fail-closed legs are counted under the 'xid_stripe' category (mxid_stripe_halfspace_refusals / _underivable_reads / activated_floor). Snapshot them too so the multixact-leg attribution is complete (foreign updater-multi overlay-miss vs allocator half-space vs underivable are now separable in one run). --- src/test/cluster_tap/t/990_scratch_71_census.pl | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/test/cluster_tap/t/990_scratch_71_census.pl b/src/test/cluster_tap/t/990_scratch_71_census.pl index 6534bd5b94..bd8aa2f2d7 100644 --- a/src/test/cluster_tap/t/990_scratch_71_census.pl +++ b/src/test/cluster_tap/t/990_scratch_71_census.pl @@ -76,11 +76,22 @@ sub write_retry rtvis_undo_fetch_failclosed_count ); +# spec-7.1 D3-a mxid-stripe face (dump category 'xid_stripe'): the foreign +# updater-multi fail-closed legs and the allocator half-space guardrail. +# activated_floor is a level (not a counter) but its delta proves the stripe +# activated during the run. +my @XID_STRIPE_KEYS = qw( + mxid_stripe_activated_floor + mxid_stripe_halfspace_refusals + mxid_stripe_underivable_reads +); + sub snap_counters { my ($node) = @_; my %s; $s{$_} = state_val($node, 'cr', $_) for @KEYS; + $s{$_} = state_val($node, 'xid_stripe', $_) for @XID_STRIPE_KEYS; return \%s; } @@ -88,7 +99,7 @@ sub diff_counters { my ($before, $after) = @_; my %d; - for my $k (@KEYS) + for my $k (@KEYS, @XID_STRIPE_KEYS) { my $delta = $after->{$k} - $before->{$k}; $d{$k} = $delta if $delta != 0; From 02efb8501ea2091f29ed17acc4b9e872d4f17721 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 16:28:07 +0800 Subject: [PATCH 11/29] fix(cluster): restore test_cluster_mxid_stripe TESTS entry + link rule Rebase-resolution fix. When folding spec-7.1 onto the stage7-72 (spec-7.2 + P0) base, the cluster_unit Makefile conflict was resolved by taking the base 'ours' side (which carries test_cluster_xid_authority from the xid P0 lane) plus the filter-out entry, but that dropped the D3-a additions to TESTS and the standalone test_cluster_mxid_stripe link rule -- so the binary silently never built or ran (160 vs 161). Restore both; cluster_unit is 161/161 with test_cluster_mxid_stripe exercising the PGXM activation-slot record + half-space guardrail. Spec: spec-7.1 --- src/test/cluster_unit/Makefile | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index dbae5f59c0..b99201221c 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -46,7 +46,8 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_tt_slot_allocator test_cluster_itl_reader_real_triple \ test_cluster_itl_cleanout test_cluster_visibility_inject test_cluster_itl_cleanout_perf \ test_cluster_heap_lock_tuple test_cluster_perf_gates \ - test_cluster_subtrans test_cluster_multixact test_cluster_undo_format \ + test_cluster_subtrans test_cluster_multixact test_cluster_mxid_stripe \ + test_cluster_undo_format \ test_cluster_undo_record test_cluster_undo_lifecycle test_cluster_cr test_cluster_cr_cache test_cluster_cr_key test_cluster_cr_pool test_cluster_cr_lifecycle test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_resolver_cache test_cluster_cr_coordinator test_cluster_tt_durable test_cluster_terminal_authority test_cluster_sf_dep \ test_cluster_retention test_cluster_undo_cleaner test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc \ test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_undo_extent \ @@ -1852,3 +1853,17 @@ test_cluster_xid_stripe: test_cluster_xid_stripe.c unit_test.h \ $(CC) $(CFLAGS) $(CPPFLAGS) $< \ $(CLUSTER_XID_STRIPE_O) \ $(top_builddir)/src/port/libpgport_srv.a -o $@ + +# spec-7.1 D3-a: test_cluster_mxid_stripe — truth tables for the mxid +# stripe pure derivation layer (half-space origin derivation / striped +# candidate arithmetic incl. 2^32 wrap + InvalidMultiXactId skip / +# half-space guardrail predicate / runtime latch wrappers / "PGXM" +# activation-slot extension record integrity, which pulls in libpgport +# for CRC32C). Links cluster_mxid_stripe.o standalone; stubs the boot +# lazy latch to a no-op. +CLUSTER_MXID_STRIPE_O = $(top_builddir)/src/backend/cluster/cluster_mxid_stripe.o +test_cluster_mxid_stripe: test_cluster_mxid_stripe.c unit_test.h \ + $(CLUSTER_MXID_STRIPE_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_MXID_STRIPE_O) \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ From 0a2ac5208062b566c7abbefd5021e4bf57062197 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 17:56:01 +0800 Subject: [PATCH 12/29] feat(cluster): spec-7.1 D1 serve -- INVALID_SCN verdict upgrades to positive ABORTED via CLOG The origin LMS verdict serve refused every durable XID_MATCH_INVALID_SCN hit (the delayed-cleanout window) with 53R97. Per IN-5 the real population of that leg is aborted-unstamped -- an abort writes no durable commit_scn -- so cross-checking CLOG (authoritative for our own xid) lets a provably-ABORTED xid answer positively (invisible at the requester) instead of failing closed. Mirrors the shipped RECYCLED_ZERO_MATCH CLOG-abort leg in the same function. 8.A (positive proof only): ONLY an explicit CLOG abort upgrades; a committed-but-unstamped, in-flight, 2PC-prepared or crashed-without-abort xid stays fail-closed (we never fabricate a commit_scn). The decision is the pure, unit-tested cluster_cr_server_invalid_scn_verdict. D5: new vis53r97_leg_live_upgrade_hit_count (invalid_scn_refuse is the miss counterpart) + dump row + test stub. Tests: test_cluster_cr_server_policy 7->9 (aborted->ABORTED / not-aborted->REFUSE, the 8.A tooth); t/995 8.A-safety e2e (aborted INSERT poison never visible cross-node). The INVALID_SCN leg is census-cold (three-phase zero, IN-5) so its positive trigger is verified at the serve-function unit level rather than a flaky cold-leg TAP. cluster_unit 161/161, clang-format-18 clean. Spec: spec-7.1-cross-instance-positive-interread.md (D1 serve) --- src/backend/cluster/cluster_cr.c | 23 +++ src/backend/cluster/cluster_cr_server.c | 28 ++- .../cluster/cluster_cr_server_policy.c | 21 ++ src/backend/cluster/cluster_debug.c | 2 + src/include/cluster/cluster_cr.h | 4 + src/include/cluster/cluster_cr_server.h | 16 ++ .../t/995_scratch_d1_serve_aborted.pl | 183 ++++++++++++++++++ .../test_cluster_cr_server_policy.c | 24 ++- src/test/cluster_unit/test_cluster_debug.c | 5 + 9 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 src/test/cluster_tap/t/995_scratch_d1_serve_aborted.pl diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index 681b5082cb..fdc02b44c6 100644 --- a/src/backend/cluster/cluster_cr.c +++ b/src/backend/cluster/cluster_cr.c @@ -202,6 +202,17 @@ typedef struct ClusterCRShared { */ pg_atomic_uint64 vis53r97_leg_xmin_overlay_verdict_ask_count; pg_atomic_uint64 vis53r97_leg_xmin_overlay_verdict_hit_count; + /* + * spec-7.1 D1 serve 半边: the origin LMS verdict serve upgraded a durable + * XID_MATCH_INVALID_SCN hit (unstamped commit_scn, delayed-cleanout window) + * to a positive ABORTED answer by cross-checking CLOG (authoritative for + * our own xid). hit = the leg resolved positively (aborted-unstamped, the + * IN-5 real population); the miss counterpart is invalid_scn_refuse_count + * above (an INVALID_SCN hit that stays fail-closed: in-flight, 2PC-prepared, + * crash window, or committed-but-unstamped -- no fabricated commit_scn). + * 8.A: positive proof only; direction of every refuse leg is unchanged. + */ + pg_atomic_uint64 vis53r97_leg_live_upgrade_hit_count; } ClusterCRShared; static ClusterCRShared *CRShared = NULL; @@ -292,6 +303,7 @@ cluster_cr_shmem_init(void) pg_atomic_init_u64(&CRShared->vis53r97_leg_xmax_unprovable_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_ask_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_hit_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_live_upgrade_hit_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_resolved_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_recycled_invisible_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_invalid_or_ambiguous_count, 0); @@ -512,10 +524,21 @@ cluster_vis53r97_note_xmin_overlay_verdict_hit(void) if (CRShared != NULL) pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_hit_count, 1); } + +/* spec-7.1 D1 serve 半边: INVALID_SCN durable hit upgraded to positive ABORTED + * via CLOG cross-check (the miss counterpart is note_srv_invalid_scn). */ +void +cluster_vis53r97_note_live_upgrade_hit(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_live_upgrade_hit_count, 1); +} CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmin_overlay_verdict_ask_count, vis53r97_leg_xmin_overlay_verdict_ask_count) CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmin_overlay_verdict_hit_count, vis53r97_leg_xmin_overlay_verdict_hit_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_live_upgrade_hit_count, + vis53r97_leg_live_upgrade_hit_count) CR_COUNTER_ACCESSOR(cluster_cr_corruption_count, cr_corruption_count) CR_COUNTER_ACCESSOR(cluster_cr_chain_walk_steps_sum, cr_chain_walk_steps_sum) CR_COUNTER_ACCESSOR(cluster_cr_inverse_insert_count, cr_inverse_insert_count) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index 54791a92f7..caaa8e75d0 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -54,6 +54,7 @@ #include "cluster/cluster_ic_router.h" /* cluster_ic_send_envelope */ #include "cluster/cluster_inject.h" #include "cluster/cluster_lmon.h" /* PGRAC: spec-7.2 D1 READY-publish wakeup */ +#include "cluster/cluster_scn.h" /* cluster_scn_current (spec-7.1a authority_scn co-sample) */ #include "cluster/cluster_shmem.h" #include "cluster/cluster_tt_durable.h" /* resolve_by_xid (D-i4 complete scan) */ #include "cluster/cluster_tt_slot.h" /* max_recycle_horizon (D-i4 bound) */ @@ -512,7 +513,32 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) return false; /* neither explicit CLOG state -> refuse */ case CLUSTER_TT_DURABLE_XID_MATCH_INVALID_SCN: - /* spec-7.1 D0 census: the delayed-cleanout window leg (D1 target). */ + /* + * spec-7.1 D1 serve: the durable by-xid scan matched our own xid but + * the slot carries no stamped commit_scn (delayed-cleanout window). + * Per IN-5, normal commit stamps the durable TT pre-commit (Fast + * Commit), so a committed xid never lands here; the real population is + * aborted-unstamped (abort writes no durable commit_scn). Cross-check + * CLOG -- authoritative for our own xid -- and answer a provably + * ABORTED xid positively (invisible at the requester) instead of + * 53R97. This mirrors the RECYCLED_ZERO_MATCH CLOG cross-check above. + * + * 8.A (positive proof only): we ONLY upgrade on an explicit CLOG abort. + * A committed-but-unstamped xid (we cannot fabricate its commit_scn), + * an in-flight/in-doubt xid (crash or 2PC-prepared window), or any + * transaction whose CLOG state is not yet a definite abort stays + * fail-closed on the invalid_scn refuse leg -- the refuse direction is + * unchanged. TransactionIdDidAbort returns true only for an explicit + * abort record, so a crashed-without-abort xid does NOT upgrade here. + * The abort->ABORTED / else->REFUSE decision is the pure, unit-tested + * cluster_cr_server_invalid_scn_verdict (test_cluster_cr_server_policy). + */ + if (cluster_cr_server_invalid_scn_verdict(TransactionIdDidAbort(xid)) + == CLUSTER_CR_INVALID_SCN_ABORTED) { + v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; + cluster_vis53r97_note_live_upgrade_hit(); + return true; + } cluster_vis53r97_note_srv_invalid_scn(); return false; diff --git a/src/backend/cluster/cluster_cr_server_policy.c b/src/backend/cluster/cluster_cr_server_policy.c index 196f5829d2..3a56ffcefe 100644 --- a/src/backend/cluster/cluster_cr_server_policy.c +++ b/src/backend/cluster/cluster_cr_server_policy.c @@ -56,4 +56,25 @@ cluster_cr_server_split_classify(const int32 *chain_origins, int nchains, int32 return (prefix == nchains) ? CLUSTER_CR_SPLIT_FULL : CLUSTER_CR_SPLIT_PARTIAL; } +/* + * cluster_cr_server_invalid_scn_verdict — spec-7.1 D1 serve pure decision. + * + * The origin's durable by-xid scan matched our own xid but the slot carries + * no stamped commit_scn (the delayed-cleanout window: XID_MATCH_INVALID_SCN). + * Per IN-5 the real population is aborted-unstamped -- an abort writes no + * durable commit_scn -- so cross-checking CLOG lets us answer a provably + * ABORTED xid positively (invisible at the requester) instead of 53R97. + * + * 8.A (positive proof only): ONLY an explicit CLOG abort upgrades. A + * committed-but-unstamped xid (we must never fabricate its commit_scn), an + * in-flight / 2PC-prepared / crashed-without-abort-record xid -- for all of + * which TransactionIdDidAbort is false -- stays REFUSE (fail-closed, the + * refuse direction is unchanged). + */ +ClusterCrInvalidScnVerdict +cluster_cr_server_invalid_scn_verdict(bool clog_did_abort) +{ + return clog_did_abort ? CLUSTER_CR_INVALID_SCN_ABORTED : CLUSTER_CR_INVALID_SCN_REFUSE; +} + #endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 6251f4ab8e..651665031a 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2670,6 +2670,8 @@ dump_cr(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_vis53r97_leg_xmin_overlay_verdict_ask_count())); emit_row(rsinfo, "cr", "vis53r97_leg_xmin_overlay_verdict_hit_count", fmt_int64((int64)cluster_vis53r97_leg_xmin_overlay_verdict_hit_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_live_upgrade_hit_count", + fmt_int64((int64)cluster_vis53r97_leg_live_upgrade_hit_count())); /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ emit_row(rsinfo, "cr", "cr_xmax_resolved_count", fmt_int64((int64)cluster_cr_xmax_resolved_count())); diff --git a/src/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index 0aa08ca9eb..53f1a5ea5e 100644 --- a/src/include/cluster/cluster_cr.h +++ b/src/include/cluster/cluster_cr.h @@ -285,6 +285,10 @@ extern uint64 cluster_vis53r97_leg_xmin_overlay_verdict_ask_count(void); extern uint64 cluster_vis53r97_leg_xmin_overlay_verdict_hit_count(void); extern void cluster_vis53r97_note_xmin_overlay_verdict_ask(void); extern void cluster_vis53r97_note_xmin_overlay_verdict_hit(void); +/* spec-7.1 D1 serve 半边: INVALID_SCN -> positive ABORTED via CLOG (hit); the + * miss counterpart is invalid_scn_refuse_count / note_srv_invalid_scn. */ +extern uint64 cluster_vis53r97_leg_live_upgrade_hit_count(void); +extern void cluster_vis53r97_note_live_upgrade_hit(void); /* * spec-6.12i CP5 (D-i4): origin-side pieces of the cross-instance verdict diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index 6ac15e08c7..a1506cc69b 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -81,6 +81,22 @@ extern ClusterCrServerSplit cluster_cr_server_split_classify(const int32 *chain_ int nchains, int32 self_node, int *out_prefix_len); +/* + * spec-7.1 D1 serve: pure verdict decision for the durable + * XID_MATCH_INVALID_SCN case (our own xid matched but carries no stamped + * commit_scn -- the delayed-cleanout window). 8.A: ONLY an explicit CLOG + * abort upgrades to a positive ABORTED answer; a committed-but-unstamped, + * in-flight, 2PC-prepared or crashed-without-abort xid stays fail-closed (we + * never fabricate a commit_scn). Pure (no shmem / lock / elog) so + * cluster_unit exercises both branches standalone. + */ +typedef enum ClusterCrInvalidScnVerdict { + CLUSTER_CR_INVALID_SCN_REFUSE = 0, /* no positive proof -> fail closed */ + CLUSTER_CR_INVALID_SCN_ABORTED = 1 /* CLOG proved abort -> positive invisible */ +} ClusterCrInvalidScnVerdict; + +extern ClusterCrInvalidScnVerdict cluster_cr_server_invalid_scn_verdict(bool clog_did_abort); + /* * LMS CR work slots (shmem, embedded in the cluster_lms region). * diff --git a/src/test/cluster_tap/t/995_scratch_d1_serve_aborted.pl b/src/test/cluster_tap/t/995_scratch_d1_serve_aborted.pl new file mode 100644 index 0000000000..1ea3a481a3 --- /dev/null +++ b/src/test/cluster_tap/t/995_scratch_d1_serve_aborted.pl @@ -0,0 +1,183 @@ +#------------------------------------------------------------------------- +# spec-7.1 D1 serve 半边 deterministic probe (scratch). +# +# Verifies the origin LMS verdict serve's INVALID_SCN upgrade leg: an +# aborted transaction writes NO durable commit_scn stamp, so a by-xid +# durable scan of its (not-yet-recycled) slot returns XID_MATCH_INVALID_SCN. +# Before D1 serve that leg refused (53R97 at the requester); with D1 serve +# the origin cross-checks CLOG and answers a provably-ABORTED xid +# positively, so the requester decides the row INVISIBLE (correct MVCC: an +# aborted INSERT was never committed) instead of failing closed. +# +# node0 commits a baseline, then runs aborted INSERTs of a POISON value. +# node1 reads cross-node with the V4 status hint disabled, so the poison +# tuples' foreign xmin ALWAYS overlay-misses and reaches the origin verdict +# ask -> D1 serve -> ABORTED -> invisible. +# +# Asserts: +# (a) the live_upgrade_hit counter moved (the D1 serve ABORTED leg engaged); +# (b) THE 8.A TOOTH: node1 NEVER sees a poison row (no false-visible) and +# the visible sum matches exactly the committed baseline (no corruption); +# (c) node1's reads succeeded (resolved, not merely fail-closed) at least +# once -- the收益 over the pre-D1 blanket 53R97. +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/995_scratch_d1_serve_aborted.pl +#------------------------------------------------------------------------- + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +# The poison value the aborted INSERTs write; if it ever becomes visible on +# node1 the aborted xact was wrongly resolved as committed (false-visible). +use constant POISON => 999_000; + +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 write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 12) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'd1serve', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + # Force the xmin overlay-miss window so the poison rows' aborted xmin + # reaches the origin verdict ask (mirrors t/994). + 'cluster.tt_status_hint_emit_mode = disabled', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +# Coincidence table (t/347 / t/994 recipe): make the relfilenode identical on +# both nodes so node1's cross-node read maps to node0's page. +my ($p0, $p1) = ('', ''); +for my $attempt (1 .. 8) +{ + last unless write_retry($node0, 'CREATE TABLE d1s_t (id int, v int)'); + last unless write_retry($node1, 'CREATE TABLE d1s_t (id int, v int)'); + $p0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('d1s_t')}); + $p1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('d1s_t')}); + last if $p0 eq $p1; + my ($n0) = $p0 =~ /(\d+)$/; + my ($n1) = $p1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + write_retry($lag, "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + write_retry($node0, 'DROP TABLE d1s_t'); + write_retry($node1, 'DROP TABLE d1s_t'); +} +is($p0, $p1, 'd1s_t relfilepath coincidence holds'); + +# Commit a small baseline so the visible sum is a fixed, known quantity. +my $base_n = 5; +my $base_sum = $base_n * ($base_n + 1) / 2; # ids 1..5, v = id +write_retry($node0, "INSERT INTO d1s_t SELECT g, g FROM generate_series(1, $base_n) g"); + +my $poison = POISON; +# Server-side legs (invalid_scn / zero_match / srv_other / live_upgrade_hit) +# are bumped in the LMS verdict-serve context on the ORIGIN (node0); the +# requester legs (xmin ask/hit, covers_refuse) are on the reader (node1). +my $hit0 = state_val($node0, 'cr', 'vis53r97_leg_live_upgrade_hit_count'); +my $inv0 = state_val($node0, 'cr', 'vis53r97_leg_invalid_scn_refuse_count'); +my $zm0 = state_val($node0, 'cr', 'vis53r97_leg_zero_match_refuse_count'); +my $oth0 = state_val($node0, 'cr', 'vis53r97_leg_srv_other_refuse_count'); +my $cov0 = state_val($node1, 'cr', 'vis53r97_leg_covers_refuse_count'); +my $ask0 = state_val($node1, 'cr', 'vis53r97_leg_xmin_overlay_verdict_ask_count'); + +# Aborted INSERTs of the poison value, each followed by an immediate cross-node +# read so the poison tuple's aborted xmin lands on a page node1 scans while the +# overlay still misses (hint disabled). The poison must never become visible. +my $reads_ok = 0; +my $reads_clean = 0; # count == base_n AND sum == base_sum (no poison) +my $reads_failclos = 0; +my $poison_seen = 0; +for my $round (1 .. 30) +{ + # Aborted xact: writes a poison row, then rolls back -> aborted-unstamped + # xid whose (not-yet-recycled) durable slot scans as XID_MATCH_INVALID_SCN. + my $pid = 1000 + $round; + write_retry($node0, + "BEGIN; INSERT INTO d1s_t VALUES ($pid, $poison); ROLLBACK;"); + + my ($rc, $out, $err) = $node1->psql('postgres', + 'SELECT count(*), COALESCE(sum(v),0), COALESCE(max(v),0) FROM d1s_t', timeout => 15); + if ($rc == 0) + { + $reads_ok++; + my ($cnt, $sum, $mx) = split /\|/, $out; + # 8.A tooth: the poison value must NEVER appear. The committed prefix + # may lag (cnt <= base_n while the baseline propagates), but it must + # never OVER-read and the max visible v must stay below POISON. + $poison_seen++ if ($mx >= POISON) || ($cnt > $base_n) || ($sum > $base_sum); + $reads_clean++ if ($cnt == $base_n) && ($sum == $base_sum) && ($mx < POISON); + } + else + { + $reads_failclos++; + } +} + +my $hit = state_val($node0, 'cr', 'vis53r97_leg_live_upgrade_hit_count') - $hit0; +my $inv = state_val($node0, 'cr', 'vis53r97_leg_invalid_scn_refuse_count') - $inv0; +my $zm = state_val($node0, 'cr', 'vis53r97_leg_zero_match_refuse_count') - $zm0; +my $oth = state_val($node0, 'cr', 'vis53r97_leg_srv_other_refuse_count') - $oth0; +my $cov = state_val($node1, 'cr', 'vis53r97_leg_covers_refuse_count') - $cov0; +my $ask = state_val($node1, 'cr', 'vis53r97_leg_xmin_overlay_verdict_ask_count') - $ask0; +diag("D1 serve probe: node1 reads_ok=$reads_ok reads_clean=$reads_clean " + . "reads_failclosed=$reads_failclos poison_seen=$poison_seen | legs: " + . "live_upgrade_hit=$hit invalid_scn_refuse=$inv covers_refuse=$cov " + . "zero_match=$zm srv_other=$oth xmin_ask=$ask"); + +# THE 8.A TOOTH (hard): the aborted poison value is NEVER visible on node1. +is($poison_seen, 0, + 'aborted INSERT poison never visible cross-node (no false-visible / false-committed)'); + +# Diagnostic (soft): whether this timing/covers window actually exercised the +# D1 serve INVALID_SCN->ABORTED leg. The leg is COLD (census 三相全零) -- an +# aborted xid usually reaches the requester after its slot has already recycled +# (zero_match, handled by the sibling :474 CLOG-abort leg) or is masked by a +# covers-gate refuse on the same page. We do NOT hard-fail on the cold leg; the +# hard 8.A gate above is what protects correctness. +diag("D1 serve leg fired positively (live_upgrade_hit) = $hit; " + . "reads_clean = $reads_clean"); +ok(1, 'D1 serve probe completed (leg distribution is diagnostic; 8.A gate above is the guarantee)'); + +$pair->stop_pair; +done_testing(); diff --git a/src/test/cluster_unit/test_cluster_cr_server_policy.c b/src/test/cluster_unit/test_cluster_cr_server_policy.c index 0794d17ebe..af66593e41 100644 --- a/src/test/cluster_unit/test_cluster_cr_server_policy.c +++ b/src/test/cluster_unit/test_cluster_cr_server_policy.c @@ -113,10 +113,30 @@ UT_TEST(test_split_malformed_is_deny) (int)CLUSTER_CR_SPLIT_DENY); } +/* + * spec-7.1 D1 serve: the INVALID_SCN verdict decision. An explicit CLOG abort + * upgrades to a positive ABORTED (the aborted-unstamped delayed-cleanout + * window); everything else -- committed-but-unstamped, in-flight, 2PC, crashed + * -- must stay REFUSE (8.A: never fabricate a commit_scn / positive answer). + */ +UT_TEST(test_invalid_scn_aborted_is_positive) +{ + UT_ASSERT_EQ((int)cluster_cr_server_invalid_scn_verdict(true), + (int)CLUSTER_CR_INVALID_SCN_ABORTED); +} + +UT_TEST(test_invalid_scn_not_aborted_refuses) +{ + /* The 8.A tooth: a NON-abort (committed-unstamped / in-flight / in-doubt) + * must NOT upgrade -- it stays fail-closed on the refuse leg. */ + UT_ASSERT_EQ((int)cluster_cr_server_invalid_scn_verdict(false), + (int)CLUSTER_CR_INVALID_SCN_REFUSE); +} + int main(void) { - UT_PLAN(7); + UT_PLAN(9); UT_RUN(test_split_empty_is_full_prefix_zero); UT_RUN(test_split_all_self_is_full); UT_RUN(test_split_self_prefix_foreign_suffix_is_partial); @@ -124,6 +144,8 @@ main(void) UT_RUN(test_split_interleave_is_deny); UT_RUN(test_split_third_party_suffix_stays_partial); UT_RUN(test_split_malformed_is_deny); + UT_RUN(test_invalid_scn_aborted_is_positive); + UT_RUN(test_invalid_scn_not_aborted_refuses); UT_DONE(); return ut_failed_count == 0 ? 0 : 1; } diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index 343af04b02..2397097b70 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1925,6 +1925,11 @@ cluster_vis53r97_leg_xmin_overlay_verdict_hit_count(void) { return 0; } +uint64 +cluster_vis53r97_leg_live_upgrade_hit_count(void) +{ + return 0; +} /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ uint64 cluster_cr_xmax_resolved_count(void) From 7e59375ba4252c3c7ab91959494a2466d2aa02d0 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 20:09:55 +0800 Subject: [PATCH 13/29] fix(cluster): P0 -- multixact committed-updater visibility polarity was inverted cluster_multixact_resolve_visibility's COMMITTED/CLEANED_OUT updater branch had an inline decide_by_scn compare with INVERTED polarity vs its own header truth table, the single-xmax path (cluster_vis_cr_xmax_verdict), and MVCC first principles: it returned INVISIBLE when the updater committed AFTER the snapshot (commit_scn > read_scn) and VISIBLE when it committed BEFORE (commit_scn <= read_scn). Correct is the reverse -- a committed delete that is visible at read_scn hides the tuple. Latent, not yet a live bug: pre-spec-7.1-D3-b a foreign updater-multi always missed the member overlay and fell to the D3-a fail-closed floor (53R9C), so this branch never saw a real committed updater terminal. t/212 only covers lock-only / aborted / in-progress (all VISIBLE), never the committed-updater scn polarity, so nothing caught it. D3-b's member serve is the first path that feeds this branch a live committed updater -> the inversion would become a false-visible / false-invisible P0 (Rule 8.A). Fix: route the committed-updater decision through cluster_vis_cr_xmax_verdict -- the polarity SSOT already shared by the single-xmax path and unit-tested correctly (test_cluster_visibility_variants test_obs_cr_xmax_full_table: COMMITTED+decide-VISIBLE -> CVV_INVISIBLE, COMMITTED+decide-INVISIBLE -> CVV_VISIBLE). The ABORTED / IN_PROGRESS / SUBCOMMITTED / other branches are unchanged. Also lands the spec-7.1 D3-b served-verdict resolver (cluster_multixact_resolve_visibility_served) on the SAME SSOT from the start (instead of mirroring the buggy inline body): it maps each origin-served member terminal to (ClusterTTStatus, scn_decision) and defers polarity to cluster_vis_cr_xmax_verdict. Committed-updater two legs added in test_cluster_multixact_served (before-snapshot -> INVISIBLE / after-snapshot -> VISIBLE). cluster_unit 162/162, test_cluster_multixact 16/16, test_cluster_multixact_ served 14/14, test_cluster_visibility_variants 7/7, t/212 PASS, clang-format-18 clean. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, A1 hotfix) --- src/backend/cluster/Makefile | 1 + src/backend/cluster/cluster_multixact.c | 29 ++- .../cluster/cluster_multixact_served.c | 129 ++++++++++ src/include/cluster/cluster_multixact.h | 36 +++ src/test/cluster_unit/Makefile | 2 +- .../test_cluster_multixact_served.c | 238 ++++++++++++++++++ 6 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 src/backend/cluster/cluster_multixact_served.c create mode 100644 src/test/cluster_unit/test_cluster_multixact_served.c diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index 0a8b42c5ac..d6c14b9c86 100644 --- a/src/backend/cluster/Makefile +++ b/src/backend/cluster/Makefile @@ -217,6 +217,7 @@ OBJS = \ cluster_tx_enqueue.o \ cluster_subtrans.o \ cluster_multixact.o \ + cluster_multixact_served.o \ cluster_mxid_stripe.o \ cluster_visibility_inject.o \ cluster_stats.o \ diff --git a/src/backend/cluster/cluster_multixact.c b/src/backend/cluster/cluster_multixact.c index 610d0de641..fc3335fd87 100644 --- a/src/backend/cluster/cluster_multixact.c +++ b/src/backend/cluster/cluster_multixact.c @@ -44,6 +44,7 @@ #include "cluster/cluster_shmem.h" #include "cluster/cluster_subtrans.h" #include "cluster/cluster_tt_status.h" +#include "cluster/cluster_visibility_resolve.h" /* cluster_vis_cr_xmax_verdict (polarity SSOT) */ #ifdef USE_PGRAC_CLUSTER @@ -307,14 +308,28 @@ cluster_multixact_resolve_visibility(const ClusterMultiXactMemberOverlayResult * continue; /* in-progress update not yet visible: tuple still visible */ if (ttres.status == CLUSTER_TT_STATUS_COMMITTED || ttres.status == CLUSTER_TT_STATUS_CLEANED_OUT) { - ClusterVisibilityDecision d + /* + * spec-7.1 D3-b hotfix (P0): route the committed-updater + * decision through cluster_vis_cr_xmax_verdict -- the polarity + * SSOT shared with the single-xmax path + * (cluster_visibility_verdict.c). A committed updater whose + * delete is VISIBLE at read_scn (commit_scn <= read_scn) hides + * the tuple (CVV_INVISIBLE); one committed AFTER the snapshot + * leaves the row live (CVV_VISIBLE). The prior inline compare + * had this INVERTED -- latent until D3-b's member serve first + * feeds this branch a real committed updater terminal. + */ + ClusterVisibilityDecision scn_decision = cluster_visibility_decide_by_scn(ttres.commit_scn, snap->read_scn); - if (d == CLUSTER_VISIBILITY_INVISIBLE) - return CLUSTER_VISIBILITY_INVISIBLE; - if (d == CLUSTER_VISIBILITY_UNKNOWN) - return CLUSTER_VISIBILITY_UNKNOWN; - /* VISIBLE -> updater happened after snapshot; continue */ - continue; + + switch (cluster_vis_cr_xmax_verdict(ttres.status, scn_decision)) { + case CVV_VISIBLE: + continue; /* updater does not hide the tuple at this snapshot */ + case CVV_INVISIBLE: + return CLUSTER_VISIBILITY_INVISIBLE; /* delete visible -> tuple gone */ + default: + return CLUSTER_VISIBILITY_UNKNOWN; /* CVV_FAILCLOSED_* */ + } } /* SUBCOMMITTED / UNKNOWN / other -> caller fail-closed */ return CLUSTER_VISIBILITY_UNKNOWN; diff --git a/src/backend/cluster/cluster_multixact_served.c b/src/backend/cluster/cluster_multixact_served.c new file mode 100644 index 0000000000..e1bec4d3f6 --- /dev/null +++ b/src/backend/cluster/cluster_multixact_served.c @@ -0,0 +1,129 @@ +/*------------------------------------------------------------------------- + * + * cluster_multixact_served.c + * pgrac spec-7.1 D3-b (A1) — pure combination resolver for a foreign + * multixact xmax whose members' terminal states were SERVED by the + * origin (no local TT lookup, no shmem) so cluster_unit exercises the + * visibility truth table standalone. + * + * The origin's member-verdict serve (cluster_cr_server.c) enumerates the + * multi's members from its own pg_multixact and resolves each updater + * member's terminal via the same verdict path as the single-xid serve. + * This resolver consumes those served terminals and applies the multixact + * visibility combination, deferring the committed-updater tuple-visibility + * polarity to cluster_vis_cr_xmax_verdict -- the SSOT shared with the + * single-xmax path and the (D3-b-hotfixed) local-TT resolver + * cluster_multixact_resolve_visibility (spec-3.6, TAP t/212). The only + * difference from the local-TT resolver is the terminal source: the wire + * verdict here vs cluster_tt_status_lookup_exact there. + * + * 8.A (positive proof only): lock-only members never gate visibility; any + * updater member without a proven terminal (no verdict, inadmissible + * below-horizon bound, or an UNKNOWN decision) yields UNKNOWN so the caller + * fail-closes 53R9C. This resolver never returns VISIBLE/INVISIBLE on an + * unproven updater. + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/backend/cluster/cluster_multixact_served.c + * + * NOTES + * This is a pgrac-original file. Compiled only in --enable-cluster + * builds. Pure: uses only the inline cluster_visibility_decide_by_scn + * helper (cluster_tt_status.h) + scn_time_cmp (cluster_scn.h). + * Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, A1) + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#ifdef USE_PGRAC_CLUSTER + +#include "access/multixact.h" /* MultiXactStatusForUpdate boundary (3) */ +#include "cluster/cluster_gcs_block.h" /* ClusterGcsUndoVerdictKind */ +#include "cluster/cluster_multixact.h" +#include "cluster/cluster_scn.h" /* SCN_VALID, scn_time_cmp */ +#include "cluster/cluster_tt_status.h" /* cluster_visibility_decide_by_scn (inline) */ +#include "cluster/cluster_visibility_resolve.h" /* cluster_vis_cr_xmax_verdict (polarity SSOT) */ + +ClusterVisibilityDecision +cluster_multixact_resolve_visibility_served(const ClusterMultiXactServedMember *members, + uint16 member_count, SCN read_scn) +{ + uint16 i; + + if (members == NULL) + return CLUSTER_VISIBILITY_UNKNOWN; + + for (i = 0; i < member_count; i++) { + const ClusterMultiXactServedMember *m = &members[i]; + ClusterTTStatus status; + ClusterVisibilityDecision scn_decision = CLUSTER_VISIBILITY_UNKNOWN; + + /* + * Lock-only members (FOR_KEY_SHARE / FOR_SHARE / FOR_NOKEYUPDATE / + * FOR_UPDATE, status 0-3) cannot hide tuple data regardless of + * commit/abort state -- they only lock the row (A2). They carry no + * served verdict and are skipped without gating visibility. + */ + if (m->member_status <= MultiXactStatusForUpdate) /* <= 3 */ + continue; + + /* + * Update / NoKeyUpdate members (status 4-5): map the origin-SERVED + * terminal verdict to a (ClusterTTStatus, scn_decision) pair, then + * defer the tuple-visibility polarity to cluster_vis_cr_xmax_verdict -- + * the SSOT shared with the single-xmax path and the (hotfixed) local-TT + * multixact resolver. 8.A: an updater without a proven terminal + * (verdict 0 / REFUSE / in-progress -- which the served path cannot + * distinguish, so it is deliberately MORE conservative than local-TT) + * or an inadmissible below-horizon bound -> UNKNOWN (caller fail-closes). + */ + switch (m->verdict) { + case (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED: + status = CLUSTER_TT_STATUS_ABORTED; + break; + + case (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT: + status = CLUSTER_TT_STATUS_COMMITTED; + scn_decision = cluster_visibility_decide_by_scn(m->commit_scn, read_scn); + break; + + case (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON: + /* + * Retention leg (e), mirroring the requester single-verdict + * consumer (cluster_runtime_visibility.c:271): the bound (horizon) + * decides against read_scn only when the horizon is not newer than + * read_scn; otherwise the bound is inadmissible -> fail closed. + */ + if (!SCN_VALID(read_scn) || !SCN_VALID(m->horizon_scn) + || scn_time_cmp(m->horizon_scn, read_scn) > 0) + return CLUSTER_VISIBILITY_UNKNOWN; /* inadmissible -> UNPROVABLE */ + status = CLUSTER_TT_STATUS_COMMITTED; + scn_decision = cluster_visibility_decide_by_scn(m->horizon_scn, read_scn); + break; + + default: + return CLUSTER_VISIBILITY_UNKNOWN; /* no proven terminal -> UNPROVABLE */ + } + + switch (cluster_vis_cr_xmax_verdict(status, scn_decision)) { + case CVV_VISIBLE: + continue; /* this updater does not hide the tuple at read_scn */ + case CVV_INVISIBLE: + return CLUSTER_VISIBILITY_INVISIBLE; /* committed delete visible -> tuple gone */ + default: + return CLUSTER_VISIBILITY_UNKNOWN; /* CVV_FAILCLOSED_* -> fail closed */ + } + } + + /* No updater member hid the tuple -> visible. */ + return CLUSTER_VISIBILITY_VISIBLE; +} + +#endif /* USE_PGRAC_CLUSTER */ diff --git a/src/include/cluster/cluster_multixact.h b/src/include/cluster/cluster_multixact.h index 626b195978..b35832b678 100644 --- a/src/include/cluster/cluster_multixact.h +++ b/src/include/cluster/cluster_multixact.h @@ -186,6 +186,42 @@ extern ClusterVisibilityDecision cluster_multixact_resolve_visibility(const ClusterMultiXactMemberOverlayResult *overlay, const Snapshot snap); +/* + * spec-7.1 D3-b: one multixact member's origin-SERVED terminal verdict. + * + * Unlike ClusterMultiXactMember (an exact TT key the local resolver looks + * up), this carries the terminal state the ORIGIN already resolved for a + * foreign multi's member -- there is no local TT to consult (that is why the + * overlay missed). member_status distinguishes updater (4-5) from lock-only + * (0-3, ignored for visibility, A2). verdict/commit_scn/horizon_scn/wrap + * mirror one ClusterGcsUndoVerdictPage per updater member; lock-only members + * need no verdict. + */ +typedef struct ClusterMultiXactServedMember { + SCN commit_scn; /* COMMITTED_EXACT only, else InvalidScn */ + SCN horizon_scn; /* COMMITTED_BELOW_HORIZON bound, else InvalidScn */ + TransactionId xid; /* member xid (uint32; not full-xid) */ + uint16 wrap; /* COMMITTED_EXACT slot wrap evidence */ + uint8 verdict; /* ClusterGcsUndoVerdictKind; 0 = none (lock-only) */ + uint8 member_status; /* MultiXactStatus: updater(4-5) vs lock-only(0-3) */ +} ClusterMultiXactServedMember; + +/* + * cluster_multixact_resolve_visibility_served (spec-7.1 D3-b, A1) + * + * Pure combination resolver for a foreign multixact xmax whose members' + * terminal states were SERVED by the origin (no local TT lookup). Mirrors + * cluster_multixact_resolve_visibility's decision structure verbatim, but + * the per-updater-member terminal comes from the served verdict instead of + * cluster_tt_status_lookup_exact. 8.A: any updater member without a proven + * terminal (verdict 0 / inadmissible below-horizon / unknown) -> UNKNOWN + * (caller fail-closes 53R9C); lock-only members never gate visibility. + * Pure (no shmem / lock / I/O) so cluster_unit exercises the truth table. + */ +extern ClusterVisibilityDecision +cluster_multixact_resolve_visibility_served(const ClusterMultiXactServedMember *members, + uint16 member_count, SCN read_scn); + /* * cluster_multixact_get_member_count (spec-3.6 D2) * diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index b99201221c..b8bb906e78 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -181,7 +181,7 @@ test_cluster_backup: test_cluster_backup.c unit_test.h $(CLUSTER_VERSION_O) \ # separate rules because they also link additional cluster_*.o # objects (the test files stub the PG backend symbols those # objects reference). -SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_mxid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) +SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_mxid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_multixact_served test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) # spec-2.4 D16: test_cluster_epoch links cluster_epoch.o standalone. # cluster_epoch.c references ShmemInitStruct + cluster_shmem_register_region # + pg_atomic_*; the test stubs all of these and exercises only the diff --git a/src/test/cluster_unit/test_cluster_multixact_served.c b/src/test/cluster_unit/test_cluster_multixact_served.c new file mode 100644 index 0000000000..4e32767dcd --- /dev/null +++ b/src/test/cluster_unit/test_cluster_multixact_served.c @@ -0,0 +1,238 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_multixact_served.c + * Standalone unit tests for the spec-7.1 D3-b served-verdict multixact + * combination resolver (cluster_multixact_resolve_visibility_served): + * the visibility truth table over origin-served member terminals, the + * lock-only skip (A2), and the 8.A fail-closed on any unproven updater. + * + * Links cluster_multixact_served.o only (pure); scn_time_cmp is stubbed + * locally (as in test_cluster_visibility_decide_scn) to avoid dragging + * cluster_scn.o's PG-core deps. The resolver's per-COMMITTED decision + * mirrors cluster_multixact_resolve_visibility verbatim, so these cases + * encode the SAME polarity as the shipped local-TT resolver (TAP t/212). + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/test/cluster_unit/test_cluster_multixact_served.c + * + * NOTES + * This is a pgrac-original file. + * Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, A1) + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/multixact.h" +#include "cluster/cluster_gcs_block.h" +#include "cluster/cluster_multixact.h" +#include "cluster/cluster_scn.h" + +#include "unit_test.h" + +UT_DEFINE_GLOBALS(); + +void +ExceptionalCondition(const char *conditionName pg_attribute_unused(), + const char *fileName pg_attribute_unused(), + int lineNumber pg_attribute_unused()) +{ + abort(); +} + +/* Local scn_time_cmp mirroring cluster_scn.c (local_scn-only contract). */ +int +scn_time_cmp(SCN a, SCN b) +{ + uint64 la = scn_local(a); + uint64 lb = scn_local(b); + + if (la < lb) + return -1; + if (la > lb) + return 1; + return 0; +} + +static SCN +make_scn(NodeId node_id, uint64 local_scn) +{ + return scn_encode(node_id, local_scn); +} + +/* Build one served member. */ +static ClusterMultiXactServedMember +mk(uint8 status, uint8 verdict, SCN commit_scn, SCN horizon_scn) +{ + ClusterMultiXactServedMember m; + + memset(&m, 0, sizeof(m)); + m.member_status = status; + m.verdict = verdict; + m.commit_scn = commit_scn; + m.horizon_scn = horizon_scn; + m.xid = 1234; + return m; +} + +#define LOCK_ONLY MultiXactStatusForShare /* 1 */ +#define UPDATER MultiXactStatusUpdate /* 5 */ +#define V_EXACT ((uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT) +#define V_BELOW ((uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON) +#define V_ABORT ((uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED) + +UT_TEST(t1_empty_is_visible) +{ + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(NULL, 0, make_scn(0, 100)), + (int)CLUSTER_VISIBILITY_UNKNOWN); /* NULL members -> fail closed */ +} + +UT_TEST(t2_zero_count_is_visible) +{ + ClusterMultiXactServedMember m = mk(UPDATER, V_ABORT, InvalidScn, InvalidScn); + + /* member_count 0 -> no updater hid the tuple -> visible. */ + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 0, make_scn(0, 100)), + (int)CLUSTER_VISIBILITY_VISIBLE); +} + +UT_TEST(t3_all_lock_only_is_visible) +{ + ClusterMultiXactServedMember ms[2] + = { mk(LOCK_ONLY, 0, InvalidScn, InvalidScn), + mk(MultiXactStatusForKeyShare, 0, InvalidScn, InvalidScn) }; + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(ms, 2, make_scn(0, 100)), + (int)CLUSTER_VISIBILITY_VISIBLE); +} + +UT_TEST(t4_updater_aborted_is_visible) +{ + ClusterMultiXactServedMember m = mk(UPDATER, V_ABORT, InvalidScn, InvalidScn); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_VISIBLE); +} + +UT_TEST(t5_updater_committed_before_snapshot_is_invisible) +{ + /* Committed-updater leg #1 (P0 polarity SSOT): commit_scn 100 <= read_scn + * 200 -- the delete is visible at the snapshot -> the tuple is gone. */ + ClusterMultiXactServedMember m = mk(UPDATER, V_EXACT, make_scn(0, 100), InvalidScn); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_INVISIBLE); +} + +UT_TEST(t6_updater_committed_after_snapshot_is_visible) +{ + /* Committed-updater leg #2 (P0 polarity SSOT): commit_scn 300 > read_scn + * 200 -- the delete committed AFTER the snapshot -> the row stays live. */ + ClusterMultiXactServedMember m = mk(UPDATER, V_EXACT, make_scn(0, 300), InvalidScn); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_VISIBLE); +} + +UT_TEST(t7_updater_committed_invalid_scn_is_unknown) +{ + ClusterMultiXactServedMember m = mk(UPDATER, V_EXACT, InvalidScn, InvalidScn); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_UNKNOWN); +} + +UT_TEST(t8_updater_no_verdict_is_unprovable) +{ + /* verdict 0 on an updater (origin could not resolve / in-progress) -> 8.A UNKNOWN. */ + ClusterMultiXactServedMember m = mk(UPDATER, 0, InvalidScn, InvalidScn); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_UNKNOWN); +} + +UT_TEST(t9_updater_below_horizon_admissible_is_invisible) +{ + /* horizon 100 <= read 200 -> admissible bound; the committed delete is + * at/below the horizon <= read_scn -> delete visible -> tuple gone. */ + ClusterMultiXactServedMember m = mk(UPDATER, V_BELOW, InvalidScn, make_scn(0, 100)); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_INVISIBLE); +} + +UT_TEST(t10_updater_below_horizon_inadmissible_is_unknown) +{ + /* horizon 300 > read 200 -> inadmissible bound -> fail closed. */ + ClusterMultiXactServedMember m = mk(UPDATER, V_BELOW, InvalidScn, make_scn(0, 300)); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_UNKNOWN); +} + +UT_TEST(t11_below_horizon_invalid_readscn_is_unknown) +{ + ClusterMultiXactServedMember m = mk(UPDATER, V_BELOW, InvalidScn, make_scn(0, 100)); + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(&m, 1, InvalidScn), + (int)CLUSTER_VISIBILITY_UNKNOWN); +} + +UT_TEST(t12_lock_only_prefix_plus_hiding_updater_is_invisible) +{ + /* lock-only skipped; updater commit 100 <= read 200 -> delete visible -> hidden. */ + ClusterMultiXactServedMember ms[2] = { mk(LOCK_ONLY, 0, InvalidScn, InvalidScn), + mk(UPDATER, V_EXACT, make_scn(0, 100), InvalidScn) }; + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(ms, 2, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_INVISIBLE); +} + +UT_TEST(t13_aborted_updater_then_hiding_updater_is_invisible) +{ + /* aborted updater continues; committed updater commit 100 <= read 200 hides. */ + ClusterMultiXactServedMember ms[2] = { mk(UPDATER, V_ABORT, InvalidScn, InvalidScn), + mk(UPDATER, V_EXACT, make_scn(0, 100), InvalidScn) }; + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(ms, 2, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_INVISIBLE); +} + +UT_TEST(t14_unprovable_updater_short_circuits_over_later_visible) +{ + /* First updater has no verdict -> UNKNOWN immediately, even though a later + * member would be benign. 8.A: any unproven updater fails the whole multi. */ + ClusterMultiXactServedMember ms[2] + = { mk(UPDATER, 0, InvalidScn, InvalidScn), mk(UPDATER, V_ABORT, InvalidScn, InvalidScn) }; + + UT_ASSERT_EQ((int)cluster_multixact_resolve_visibility_served(ms, 2, make_scn(0, 200)), + (int)CLUSTER_VISIBILITY_UNKNOWN); +} + +int +main(void) +{ + UT_PLAN(14); + UT_RUN(t1_empty_is_visible); + UT_RUN(t2_zero_count_is_visible); + UT_RUN(t3_all_lock_only_is_visible); + UT_RUN(t4_updater_aborted_is_visible); + UT_RUN(t5_updater_committed_before_snapshot_is_invisible); + UT_RUN(t6_updater_committed_after_snapshot_is_visible); + UT_RUN(t7_updater_committed_invalid_scn_is_unknown); + UT_RUN(t8_updater_no_verdict_is_unprovable); + UT_RUN(t9_updater_below_horizon_admissible_is_invisible); + UT_RUN(t10_updater_below_horizon_inadmissible_is_unknown); + UT_RUN(t11_below_horizon_invalid_readscn_is_unknown); + UT_RUN(t12_lock_only_prefix_plus_hiding_updater_is_invisible); + UT_RUN(t13_aborted_updater_then_hiding_updater_is_invisible); + UT_RUN(t14_unprovable_updater_short_circuits_over_later_visible); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +} From 2e131e5100f61735334891b5b8d1c9c8d68c601d Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 20:52:33 +0800 Subject: [PATCH 14/29] feat(cluster): spec-7.1 D3-b wire ABI -- batched multixact member-verdict page Add the wire contract for the origin-served multixact member verdict a requester asks for when a foreign multixact xmax structurally misses its member overlay (the updater has no compose-time TT binding, IN-12), so the serve (D3b-2) and requester (D3b-3) that produce/consume it land on a frozen ABI. - New FORWARD request kind: reserved_0[6] value 3 (multiplexed alongside 1=undo-fetch, 2=xid-verdict) + Set/Is inline accessors. The asked-for MXID rides the widened watermark carrier (MultiXactId is 32-bit like TransactionId, upper 32 bits zero). - New reply status GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT = 20 (tail value, StaticAssert-pinned) that ships a page + auth trailer exactly like the single verdict. - ClusterGcsUndoMultiVerdictPage: 24B header (magic "PGMV" / version / mxid_echo / nmembers / status) + members[] of 24B ClusterGcsUndoMulti- VerdictMember (commit_scn / horizon_scn / xid / wrap / verdict / member_status); cap 256 => 6168B, StaticAssert fits BLCKSZ. - cluster_vis_undo_multi_verdict_page_usable: pure structural validator mirroring the single-verdict one -- SERVED status, magic/version/mxid echo, reserved-zero, per-member verdict/scn consistency (lock-only carry none; updaters carry a known kind), any inconsistency refuses the whole page (Rule 8.A: never a partial-proof multi). Only the origin ships a page and only when every updater member is proven; an unprovable multi is a DENIED reply, keeping the requester's 53R97. TDD: RED->GREEN unit truth table in test_cluster_runtime_visibility (test_undo_multi_verdict_page_usable, batch boundaries 1/2/MAX/MAX+1). cluster_unit 162/162; full cluster backend + all test binaries build clean; clang-format-18 clean. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, A1/A3, Q-D3b1) --- .../cluster_runtime_visibility_policy.c | 82 ++++++++++- src/include/cluster/cluster_gcs_block.h | 110 ++++++++++++++- .../cluster/cluster_runtime_visibility.h | 20 +++ .../test_cluster_runtime_visibility.c | 132 +++++++++++++++++- 4 files changed, 341 insertions(+), 3 deletions(-) diff --git a/src/backend/cluster/cluster_runtime_visibility_policy.c b/src/backend/cluster/cluster_runtime_visibility_policy.c index 90013dfb13..d00589b312 100644 --- a/src/backend/cluster/cluster_runtime_visibility_policy.c +++ b/src/backend/cluster/cluster_runtime_visibility_policy.c @@ -29,7 +29,8 @@ */ #include "postgres.h" -#include "cluster/cluster_gcs_block.h" /* ClusterGcsUndoVerdictPage (CP5) */ +#include "access/multixact.h" /* MultiXactStatusForUpdate / MaxMultiXactStatus (D3-b) */ +#include "cluster/cluster_gcs_block.h" /* ClusterGcsUndo{,Multi}VerdictPage (CP5 / D3-b) */ #include "cluster/cluster_runtime_visibility.h" #include "cluster/cluster_scn.h" /* scn_time_cmp / SCN_VALID (spec-7.1a D3) */ #include "cluster/cluster_undo_segment.h" /* UndoSegmentHeaderData, TTSlot */ @@ -230,3 +231,82 @@ cluster_vis_undo_verdict_page_usable(const struct ClusterGcsUndoVerdictPage *v, return false; /* unknown kind: refuse, never guess */ } } + +/* + * cluster_vis_undo_multi_verdict_page_usable — see header for the contract. + */ +bool +cluster_vis_undo_multi_verdict_page_usable(const struct ClusterGcsUndoMultiVerdictPage *v, + MultiXactId asked_mxid) +{ + uint16 i; + + if (v == NULL || !MultiXactIdIsValid(asked_mxid)) + return false; + + if (v->magic != CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC + || v->version != CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION) + return false; + + /* The echo is the widened mxid: upper 32 bits MUST be zero (a non-zero + * high word means a corrupted or foreign carrier, never a valid echo). */ + if (v->mxid_echo != (uint64)asked_mxid) + return false; + + /* Only a fully-proven SERVED page carries consumable members; every other + * status ships as a DENIED reply (no page), so it should never reach here. + * Re-check defensively (8.A): never consume an UNPROVABLE / NOT_MINE page. */ + if (v->status != (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED) + return false; + + /* A real multi always has members; the origin refuses < 2 as NO_MEMBERS. + * Reject 0 (nothing to consume) and anything past the wire capacity. */ + if (v->nmembers == 0 || v->nmembers > CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS) + return false; + + for (i = 0; i < sizeof(v->reserved_0); i++) + if (v->reserved_0[i] != 0) + return false; + + for (i = 0; i < v->nmembers; i++) { + const ClusterGcsUndoMultiVerdictMember *m = &v->members[i]; + + /* member_status must be a real MultiXactStatus (0..5). */ + if (m->member_status > MaxMultiXactStatus) + return false; + + /* Lock-only members (<= MultiXactStatusForUpdate, status 0-3) never + * gate visibility (A2): they carry no verdict and no scn of any kind. */ + if (m->member_status <= MultiXactStatusForUpdate) { + if (m->verdict != 0 || SCN_VALID(m->commit_scn) || SCN_VALID(m->horizon_scn)) + return false; + continue; + } + + /* Updater members (4-5): the verdict kind must be known and its scn + * fields consistent with the kind, mirroring the single-verdict page + * (any updater without a proven terminal refuses the WHOLE page). */ + switch (m->verdict) { + case (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT: + /* Exact terminal commit: needs the exact scn, carries no bound. */ + if (!SCN_VALID(m->commit_scn) || SCN_VALID(m->horizon_scn)) + return false; + break; + case (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON: + /* Bound-only commit: needs the horizon, must NOT claim an exact + * scn (a stray commit_scn could leak into stamp/cache paths). */ + if (SCN_VALID(m->commit_scn) || !SCN_VALID(m->horizon_scn)) + return false; + break; + case (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED: + /* Terminal abort carries no scn of any kind. */ + if (SCN_VALID(m->commit_scn) || SCN_VALID(m->horizon_scn)) + return false; + break; + default: + return false; /* unknown kind on an updater: refuse, never guess */ + } + } + + return true; +} diff --git a/src/include/cluster/cluster_gcs_block.h b/src/include/cluster/cluster_gcs_block.h index e617eb6480..6395e094fb 100644 --- a/src/include/cluster/cluster_gcs_block.h +++ b/src/include/cluster/cluster_gcs_block.h @@ -320,6 +320,19 @@ typedef enum GcsBlockReplyStatus { * unprovable outcome is a DENIED * reply instead — the requester * keeps 53R97 (Rule 8.A). */ + , + GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT = 20 /* PGRAC: spec-7.1 D3-b NEW; the + * origin's LMS enumerated a foreign + * multixact's members and served a + * per-updater-member batch verdict + * (ClusterGcsUndoMultiVerdictPage in + * the BLCKSZ area) under the same + * co-sampled authority carriage as + * statuses 18/19. Shipped ONLY when + * every updater member is proven + * (status SERVED); any unprovable + * multi is a DENIED reply — the + * requester keeps 53R97 (Rule 8.A). */ } GcsBlockReplyStatus; /* spec-5.16 D3b / r4 (spec-6.12a ㉕ extends) — every new reply status MUST be @@ -339,7 +352,10 @@ StaticAssertDecl(GCS_BLOCK_REPLY_CR_RESULT_PARTIAL == GCS_BLOCK_REPLY_CR_RESULT_ StaticAssertDecl(GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT == GCS_BLOCK_REPLY_CR_RESULT_PARTIAL + 1, "spec-6.12i undo-TT fetch status must precede the verdict status"); StaticAssertDecl(GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT == GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT + 1, - "spec-6.12i undo-verdict status must be the tail enum value"); + "spec-6.12i undo-verdict status must follow the undo-TT fetch status"); +StaticAssertDecl(GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT + == GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT + 1, + "spec-7.1 D3-b undo-multi-verdict status must be the tail enum value"); static inline bool GcsBlockReplyStatusAllowsDirectLandInstall(GcsBlockReplyStatus status) @@ -1267,6 +1283,34 @@ GcsBlockForwardPayloadIsUndoVerdictRequest(const GcsBlockForwardPayload *p) return p->reserved_0[6] == (uint8)2; } +/* PGRAC: spec-7.1 D3-b — undo-MULTI-verdict request carried in reserved_0[6] + * VALUE 3 (the byte is value-multiplexed with the undo-TT fetch (1) and the + * single-xid verdict (2); one FORWARD is only ever one request kind). + * + * Sent REQUESTER -> ORIGIN when a FOREIGN multixact xmax structurally misses + * the requester's member overlay (the updater has no compose-time TT + * binding, spec-7.1 IN-12): ask the origin, which alone owns the multi's + * members, for a batched per-member verdict. The asked-for MXID rides the + * expected-PI-watermark SCN carrier (widened to uint64; upper 32 bits MUST + * be zero — MultiXactId is 32-bit like TransactionId, Q-D3b1) and the + * BufferTag stays the synthetic undo address for tag validity + routing + * observability only. The origin's LMON parks for LMS; LMS gates on + * cluster_mxid_is_mine, enumerates the members, resolves each updater's + * terminal via the single-xid verdict path and ships + * UNDO_MULTI_VERDICT_RESULT (status SERVED) or a DENIED status the requester + * maps to the unchanged 53R97 fail-closed (Rule 8.A). */ +static inline void +GcsBlockForwardPayloadSetUndoMultiVerdictRequest(GcsBlockForwardPayload *p, bool undo_multi_verdict) +{ + p->reserved_0[6] = undo_multi_verdict ? (uint8)3 : (uint8)0; +} + +static inline bool +GcsBlockForwardPayloadIsUndoMultiVerdictRequest(const GcsBlockForwardPayload *p) +{ + return p->reserved_0[6] == (uint8)3; +} + /* PGRAC: spec-6.12i D-i1 — synthetic undo-address tag for the fetch wire. * * An undo block has no BufferTag (undo segment files live outside shared @@ -1436,6 +1480,70 @@ typedef struct ClusterGcsUndoVerdictPage { StaticAssertDecl(sizeof(ClusterGcsUndoVerdictPage) == 48, "spec-6.12i ClusterGcsUndoVerdictPage wire ABI is 48 bytes"); +/* PGRAC: spec-7.1 D3-b — batched multixact member-verdict page carried in the + * BLCKSZ area of a GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT reply. + * + * A foreign multixact xmax the requester cannot resolve locally (its member + * overlay structurally misses — the updater has no TT binding at compose + * time, spec-7.1 IN-12) is answered by the ORIGIN, which alone owns the + * multi's pg_multixact members: the origin enumerates the members + * (GetMultiXactIdMembers) and resolves each UPDATER member's terminal via + * the SAME by-xid verdict path as the single-xid serve (A1/A2). lock-only + * members (status <= MultiXactStatusForUpdate) never gate visibility and + * carry no verdict. The requester feeds the per-member terminals to the + * pure combination resolver cluster_multixact_resolve_visibility_served. + * + * 8.A (positive proof only): the origin ships this page ONLY when EVERY + * updater member has a proven terminal (status == SERVED). A multi with an + * unprovable updater / not-mine mxid / unreadable member set is refused with + * a DENIED reply (no page) exactly like the single-verdict serve — the + * requester keeps its unchanged 53R97. The status field is carried for + * defence-in-depth (the requester re-checks SERVED) and future + * observability. Each member mirrors one ClusterGcsUndoVerdictPage's + * {commit_scn, horizon_scn, wrap, verdict}; horizon_scn crossings are + * Lamport-observed by the requester exactly as the single verdict's are. */ +#define CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC ((uint32)0x50474D56) /* "PGMV" */ +#define CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION ((uint32)1) +#define CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS 256 + +/* Whole-multi serve status (A2 / Q-D3b3: origin never sends a partial set). */ +typedef enum ClusterGcsUndoMultiVerdictStatus { + CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED = 1, /* every updater member proven */ + CLUSTER_GCS_UNDO_MULTI_VERDICT_UNPROVABLE = 2, /* an updater member unprovable */ + CLUSTER_GCS_UNDO_MULTI_VERDICT_NOT_MINE = 3, /* mxid not origin-derived-own */ + CLUSTER_GCS_UNDO_MULTI_VERDICT_NO_MEMBERS = 4 /* < 2 / unreadable member set */ +} ClusterGcsUndoMultiVerdictStatus; + +typedef struct ClusterGcsUndoMultiVerdictMember { + uint64 commit_scn; /* COMMITTED_EXACT only, else InvalidScn */ + uint64 horizon_scn; /* COMMITTED_BELOW_HORIZON bound, else InvalidScn */ + TransactionId xid; /* member xid (uint32; NOT full-xid) */ + uint16 wrap; /* COMMITTED_EXACT slot wrap evidence */ + uint8 verdict; /* ClusterGcsUndoVerdictKind (1/2/3); 0 = lock-only none */ + uint8 member_status; /* MultiXactStatus: updater(4-5) vs lock-only(0-3) */ +} ClusterGcsUndoMultiVerdictMember; + +StaticAssertDecl(sizeof(ClusterGcsUndoMultiVerdictMember) == 24, + "spec-7.1 D3-b ClusterGcsUndoMultiVerdictMember wire ABI is 24 bytes"); + +typedef struct ClusterGcsUndoMultiVerdictPage { + uint32 magic; /* CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC */ + uint32 version; /* CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION */ + uint64 mxid_echo; /* asked-for mxid widened to u64 (upper 32 bits zero) */ + uint16 nmembers; /* 1..CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS */ + uint8 status; /* ClusterGcsUndoMultiVerdictStatus */ + uint8 reserved_0[5]; /* must be zero (pads members[] to 8-byte alignment) */ + ClusterGcsUndoMultiVerdictMember members[FLEXIBLE_ARRAY_MEMBER]; +} ClusterGcsUndoMultiVerdictPage; + +StaticAssertDecl(offsetof(ClusterGcsUndoMultiVerdictPage, members) == 24, + "spec-7.1 D3-b multi-verdict header is 24 bytes (members 8-aligned)"); +StaticAssertDecl(offsetof(ClusterGcsUndoMultiVerdictPage, members) + + CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS + * sizeof(ClusterGcsUndoMultiVerdictMember) + <= BLCKSZ, + "spec-7.1 D3-b multi-verdict page (header + max members) must fit BLCKSZ"); + /* PGRAC: spec-5.2 D2 — pure master-side decision for an N→S read request * when the block is held in X. Kept pure (no shmem / no I/O) so the gate * truth table is unit-tested standalone (U3). */ diff --git a/src/include/cluster/cluster_runtime_visibility.h b/src/include/cluster/cluster_runtime_visibility.h index a10c13c04d..69f6fc8834 100644 --- a/src/include/cluster/cluster_runtime_visibility.h +++ b/src/include/cluster/cluster_runtime_visibility.h @@ -170,6 +170,26 @@ struct ClusterGcsUndoVerdictPage; /* cluster_gcs_block.h */ extern bool cluster_vis_undo_verdict_page_usable(const struct ClusterGcsUndoVerdictPage *v, TransactionId asked_xid); +/* + * spec-7.1 D3-b pure structural validation of a shipped BATCHED multixact + * member-verdict page (see cluster_gcs_block.h for the wire struct). true + * only when the page provably answers the asked-for mxid: magic / version / + * widened-mxid echo match, status is SERVED (the only status that ships a + * page; a DENIED reply never reaches here), nmembers is in [1, MAX], every + * reserved byte is zero, and EACH member is internally consistent -- lock-only + * members (status <= MultiXactStatusForUpdate) carry no verdict and no scn; + * updater members (4-5) carry a known verdict whose scn fields match the kind + * exactly (mirroring cluster_vis_undo_verdict_page_usable). Anything else + * refuses so the caller keeps the 53R97 fail-closed boundary (Rule 8.A). + * Pure: no shmem, no locks, no elog (unit truth table). The read_scn + * admissibility of a BELOW_HORIZON bound is decided by the consumer + * (cluster_multixact_resolve_visibility_served), not here. + */ +struct ClusterGcsUndoMultiVerdictPage; /* cluster_gcs_block.h */ +extern bool +cluster_vis_undo_multi_verdict_page_usable(const struct ClusterGcsUndoMultiVerdictPage *v, + MultiXactId asked_mxid); + /* * CP3 + CP5 orchestration (backend): active-runtime resolution of a RECYCLED * remote ITL ref. Two provable legs, both under the co-sampled live diff --git a/src/test/cluster_unit/test_cluster_runtime_visibility.c b/src/test/cluster_unit/test_cluster_runtime_visibility.c index d287c75b11..4d536408ea 100644 --- a/src/test/cluster_unit/test_cluster_runtime_visibility.c +++ b/src/test/cluster_unit/test_cluster_runtime_visibility.c @@ -23,6 +23,7 @@ */ #include "postgres.h" +#include "access/multixact.h" /* MultiXactStatus* member kinds (D3-b) */ #include "cluster/cluster_gcs_block.h" /* undo-fetch tag + auth trailer (CP2) */ #include "cluster/cluster_runtime_visibility.h" #include "cluster/cluster_undo_segment.h" /* fake TT header blocks (CP3) */ @@ -404,6 +405,134 @@ UT_TEST(test_undo_verdict_page_usable) UT_ASSERT_EQ(cluster_vis_undo_verdict_page_usable(&v, 1000), false); } +/* spec-7.1 D3-b: structural validation of a batched multixact member-verdict + * page. true only for a SERVED page whose every member is internally + * consistent (lock-only carry no verdict/scn; updaters carry a known verdict + * whose scn fields match the kind), or the page is refused (caller keeps + * 53R97). Covers the batch boundaries 1 / 2 / MAX / MAX+1 (Rule 8.A). */ +UT_TEST(test_undo_multi_verdict_page_usable) +{ + PGAlignedBlock blk; + ClusterGcsUndoMultiVerdictPage *v = (ClusterGcsUndoMultiVerdictPage *)blk.data; + MultiXactId asked = 4242; + int i; + + /* Baseline good SERVED page: 1 updater EXACT + 1 lock-only. */ + memset(blk.data, 0, BLCKSZ); + v->magic = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC; + v->version = CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION; + v->mxid_echo = asked; + v->status = (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED; + v->nmembers = 2; + v->members[0].member_status = MultiXactStatusUpdate; /* updater */ + v->members[0].verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT; + v->members[0].commit_scn = 777; + v->members[0].horizon_scn = InvalidScn; + v->members[0].wrap = 3; + v->members[0].xid = 111; + v->members[1].member_status = MultiXactStatusForShare; /* lock-only */ + v->members[1].verdict = 0; + v->members[1].xid = 222; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), true); + + /* NULL page / invalid asked mxid. */ + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(NULL, asked), false); + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, InvalidMultiXactId), false); + + /* Wrong magic / version / echo (incl. a widened echo with high bits). */ + v->magic = 0xDEADBEEF; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->magic = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC; + v->version = 2; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->version = CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, 4243), false); + v->mxid_echo = (((uint64)1) << 32) + asked; /* high word poisons the echo */ + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->mxid_echo = asked; + + /* Only a SERVED page carries consumable members (DENIED never reaches + * here, but re-check defence-in-depth). */ + v->status = (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_UNPROVABLE; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->status = (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED; + + /* nmembers bounds: 0 refused, MAX ok (below), MAX+1 refused. */ + v->nmembers = 0; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->nmembers = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS + 1; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->nmembers = 2; + + /* Non-zero reserved bytes poison the page. */ + v->reserved_0[2] = 1; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->reserved_0[2] = 0; + + /* Out-of-range member_status (> MaxMultiXactStatus) refused. */ + v->members[0].member_status = 9; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[0].member_status = MultiXactStatusUpdate; + + /* A lock-only member must carry NO verdict and NO scn. */ + v->members[1].verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[1].verdict = 0; + v->members[1].commit_scn = 5; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[1].commit_scn = InvalidScn; + + /* Updater EXACT consistency: needs commit_scn, refuses a horizon. */ + v->members[0].commit_scn = InvalidScn; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[0].commit_scn = 777; + v->members[0].horizon_scn = 500; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[0].horizon_scn = InvalidScn; + + /* Updater BELOW_HORIZON: needs a horizon, refuses an exact scn. */ + v->members[0].verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; + v->members[0].commit_scn = InvalidScn; + v->members[0].horizon_scn = 500; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), true); + v->members[0].commit_scn = 777; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[0].commit_scn = InvalidScn; + v->members[0].horizon_scn = InvalidScn; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + + /* Updater ABORTED: carries no scn of any kind. */ + v->members[0].verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), true); + v->members[0].commit_scn = 1; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[0].commit_scn = InvalidScn; + + /* Updater with an unknown verdict (0 / one past the last known) refuses + * the WHOLE page (8.A: never a partial-proof multi). */ + v->members[0].verdict = 0; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->members[0].verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED + 1; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + + /* Capacity boundary: MAX all-updater members round-trip; poisoning the + * LAST member still refuses (the validator walks every member). */ + memset(blk.data, 0, BLCKSZ); + v->magic = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC; + v->version = CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION; + v->mxid_echo = asked; + v->status = (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED; + v->nmembers = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS; + for (i = 0; i < CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS; i++) { + v->members[i].member_status = MultiXactStatusUpdate; + v->members[i].verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; + v->members[i].xid = 1000 + (TransactionId)i; + } + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), true); + v->members[CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS - 1].commit_scn = 9; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); +} + /* CP2: synthetic undo-address tag roundtrip + magic discrimination. */ UT_TEST(test_undo_fetch_tag_roundtrip) { @@ -429,7 +558,7 @@ UT_TEST(test_undo_fetch_tag_roundtrip) int main(void) { - UT_PLAN(16); + UT_PLAN(18); UT_RUN(test_covers_when_epoch_match_and_scn_ge_demand); UT_RUN(test_covers_ignores_cross_thread_lsn); UT_RUN(test_failclosed_when_epoch_differs); @@ -446,6 +575,7 @@ main(void) UT_RUN(test_ttproof_header_mismatch); UT_RUN(test_undo_auth_trailer_roundtrip); UT_RUN(test_undo_verdict_page_usable); + UT_RUN(test_undo_multi_verdict_page_usable); UT_RUN(test_undo_fetch_tag_roundtrip); UT_DONE(); return ut_failed_count == 0 ? 0 : 1; From 150c5f54921d491c425b506d2e7fef7295121cf7 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 21:07:20 +0800 Subject: [PATCH 15/29] feat(cluster): spec-7.1 D3-b origin member-verdict serve The origin -- the sole owner of a multixact's pg_multixact members -- serves a batched per-member verdict for a foreign multi a requester cannot resolve locally (its member overlay structurally missed; IN-12). Wired end to end on the origin side; the requester ask that triggers it is D3b-3. - lms_undo_multi_verdict_serve: gate cluster_mxid_is_mine, co-sample the live authority triple, GetMultiXactIdMembers over our own pg_multixact, and for each UPDATER member (status 4-5) resolve its terminal; lock-only members (A2) record {xid,status} only. A member set < 2 / over wire capacity, a foreign-or-underivable updater xid, or any unprovable updater terminal refuses the WHOLE multi (8.A -> DENIED, requester keeps 53R97; the residue is the feature #119 forward). Runs under the drain's PG_TRY. - Extract the shared lms_resolve_own_xid_verdict core from the single-xid verdict serve so the RESOLVED_SCN / RECYCLED_ZERO_MATCH / INVALID_SCN terminal decision lives in ONE place (mini-plan no-fork); it bumps no counter so each serve owns its census. lms_undo_verdict_serve is refactored onto it with byte-identical behavior + the same per-reason census legs. - Submit (cluster_lms_undo_multi_verdict_submit; MXID rides the carrier, StaticAssert sizeof(MultiXactId)==sizeof(TransactionId)), LMON dispatch branch, drain 3rd kind, ship path, and reply-receive parse/auth extended for GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT via the DRY GcsBlockReplyStatusCarriesUndoAuthTrailer predicate. - Origin census: cr_server_multi_verdict_served/denied_count (field + init + bump + accessor + dump row + test_cluster_debug stubs, per the D3-a clean-build lesson). cluster_unit 162/162; full cluster backend links + all test binaries build clean; clang-format-18 clean. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, A1/A2, Q-D3b1/2/3) --- src/backend/cluster/cluster_cr.c | 19 + src/backend/cluster/cluster_cr_server.c | 504 +++++++++++++++------ src/backend/cluster/cluster_debug.c | 5 + src/backend/cluster/cluster_gcs_block.c | 20 +- src/include/cluster/cluster_cr.h | 2 + src/include/cluster/cluster_cr_server.h | 40 +- src/include/cluster/cluster_gcs_block.h | 13 + src/test/cluster_unit/test_cluster_debug.c | 10 + 8 files changed, 464 insertions(+), 149 deletions(-) diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index fdc02b44c6..47485c0bca 100644 --- a/src/backend/cluster/cluster_cr.c +++ b/src/backend/cluster/cluster_cr.c @@ -161,6 +161,12 @@ typedef struct ClusterCRShared { pg_atomic_uint64 rtvis_verdict_inadmissible_count; pg_atomic_uint64 cr_server_verdict_served_count; pg_atomic_uint64 cr_server_verdict_denied_count; + /* + * spec-7.1 D3-b (server side): multixact member-verdict batches served vs + * refused (any unprovable updater member refuses the whole multi). + */ + pg_atomic_uint64 cr_server_multi_verdict_served_count; + pg_atomic_uint64 cr_server_multi_verdict_denied_count; /* * spec-6.15 D4: a recycled remote ref whose tuple xid the stripe * derivation could not attribute (striping off / below the activation @@ -294,6 +300,8 @@ cluster_cr_shmem_init(void) pg_atomic_init_u64(&CRShared->rtvis_verdict_inadmissible_count, 0); pg_atomic_init_u64(&CRShared->cr_server_verdict_served_count, 0); pg_atomic_init_u64(&CRShared->cr_server_verdict_denied_count, 0); + pg_atomic_init_u64(&CRShared->cr_server_multi_verdict_served_count, 0); + pg_atomic_init_u64(&CRShared->cr_server_multi_verdict_denied_count, 0); pg_atomic_init_u64(&CRShared->rtvis_underivable_failclosed_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_invalid_scn_refuse_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_zero_match_refuse_count, 0); @@ -372,6 +380,12 @@ cluster_cr_server_stat_bump(ClusterCrServerStat which) case CLUSTER_CR_SERVER_STAT_VERDICT_DENIED: pg_atomic_fetch_add_u64(&CRShared->cr_server_verdict_denied_count, 1); break; + case CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_SERVED: + pg_atomic_fetch_add_u64(&CRShared->cr_server_multi_verdict_served_count, 1); + break; + case CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_DENIED: + pg_atomic_fetch_add_u64(&CRShared->cr_server_multi_verdict_denied_count, 1); + break; } } @@ -575,6 +589,11 @@ CR_COUNTER_ACCESSOR(cluster_rtvis_verdict_below_horizon_count, rtvis_verdict_bel CR_COUNTER_ACCESSOR(cluster_rtvis_verdict_inadmissible_count, rtvis_verdict_inadmissible_count) CR_COUNTER_ACCESSOR(cluster_cr_server_verdict_served_count, cr_server_verdict_served_count) CR_COUNTER_ACCESSOR(cluster_cr_server_verdict_denied_count, cr_server_verdict_denied_count) +/* spec-7.1 D3-b: origin multixact member-verdict serve counters. */ +CR_COUNTER_ACCESSOR(cluster_cr_server_multi_verdict_served_count, + cr_server_multi_verdict_served_count) +CR_COUNTER_ACCESSOR(cluster_cr_server_multi_verdict_denied_count, + cr_server_multi_verdict_denied_count) CR_COUNTER_ACCESSOR(cluster_rtvis_underivable_failclosed_count, rtvis_underivable_failclosed_count) CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_invalid_scn_refuse_count, vis53r97_leg_invalid_scn_refuse_count) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index caaa8e75d0..6f7971d844 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -43,8 +43,9 @@ #ifdef USE_PGRAC_CLUSTER -#include "access/transam.h" /* TransactionIdDidCommit/DidAbort (D-i4 CLOG cross) */ -#include "access/xlog.h" /* GetFlushRecPtr (spec-6.12i live_hwm_lsn) */ +#include "access/multixact.h" /* GetMultiXactIdMembers / MultiXactMember (spec-7.1 D3-b) */ +#include "access/transam.h" /* TransactionIdDidCommit/DidAbort (D-i4 CLOG cross) */ +#include "access/xlog.h" /* GetFlushRecPtr (spec-6.12i live_hwm_lsn) */ #include "cluster/cluster_cr.h" #include "cluster/cluster_cr_server.h" #include "cluster/cluster_elog.h" /* cluster_node_id */ @@ -59,6 +60,7 @@ #include "cluster/cluster_tt_durable.h" /* resolve_by_xid (D-i4 complete scan) */ #include "cluster/cluster_tt_slot.h" /* max_recycle_horizon (D-i4 bound) */ #include "cluster/cluster_undo_record_api.h" /* tt_retention_rollover_count */ +#include "cluster/cluster_mxid_stripe.h" /* cluster_mxid_is_mine (spec-7.1 D3-b) */ #include "cluster/cluster_undo_smgr.h" /* cluster_undo_smgr_read_block */ #include "cluster/cluster_xid_stripe.h" /* cluster_xid_is_mine (spec-6.15 D4) */ #include "miscadmin.h" @@ -297,6 +299,76 @@ cluster_lms_undo_verdict_submit(const GcsBlockForwardPayload *fwd) return false; /* all slots busy — fail closed, requester retries/refuses */ } +/* + * spec-7.1 D3-b (Q-D3b1): a MultiXactId is the same 32-bit width as a + * TransactionId, so the multi-verdict request carries the asked-for MXID in + * the SAME slot->undo_xid field the single-xid verdict uses -- disambiguated + * purely by slot->req_kind (KIND_UNDO_MULTI_VERDICT). Assert the width so a + * future MultiXactId widening cannot silently truncate the carrier. + */ +StaticAssertDecl(sizeof(MultiXactId) == sizeof(TransactionId), + "spec-7.1 D3-b reuses the undo_xid carrier width for MultiXactId (Q-D3b1)"); + +/* + * cluster_lms_undo_multi_verdict_submit — LMON dispatch side (spec-7.1 D3-b). + * + * Park a validated multi member-verdict request. Same shape as the single + * verdict submit, but the widened watermark carrier holds a MultiXactId + * (not a TransactionId): a non-zero upper 32 bits or an invalid mxid is a + * malformed carrier — refuse (the caller replies the fail-closed DENIED; the + * requester keeps 53R97, Rule 8.A). The synthetic tag is validated for + * shape only; the member scan is complete over the multi's own pg_multixact. + */ +bool +cluster_lms_undo_multi_verdict_submit(const GcsBlockForwardPayload *fwd) +{ + uint32 segment_id = 0; + uint32 block_no = 0; + uint64 carrier; + + if (CrServerShared == NULL || fwd == NULL) + return false; + if (!cluster_crossnode_runtime_visibility) + return false; + if (!GcsBlockUndoFetchTagDecode(fwd->tag, &segment_id, &block_no)) + return false; + + carrier = (uint64)GcsBlockForwardPayloadGetExpectedPiWatermarkScn(fwd); + if (carrier > (uint64)PG_UINT32_MAX || !MultiXactIdIsValid((MultiXactId)carrier)) + return false; + + for (int i = 0; i < CLUSTER_LMS_CR_SLOTS; i++) { + ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; + uint32 expected = CLUSTER_LMS_CR_FREE; + + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) + continue; + + slot->tag = fwd->tag; + slot->read_scn = InvalidScn; /* the carrier held the mxid, not a snapshot */ + slot->request_id = fwd->request_id; + slot->epoch = fwd->epoch; + slot->requester_node = fwd->original_requester_node; + slot->requester_backend = fwd->requester_backend_id; + slot->reply_master_node = fwd->master_node; + slot->transition_id = fwd->transition_id; + slot->result_status = (uint8)GCS_BLOCK_REPLY_DENIED_MASTER_NOT_HOLDER; + slot->req_kind = (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_MULTI_VERDICT; + slot->undo_segment_id = segment_id; + slot->undo_block_no = block_no; + slot->undo_xid = (TransactionId)carrier; /* Q-D3b1: carries the MXID */ + memset(&slot->undo_auth, 0, sizeof(slot->undo_auth)); + + /* Publish the request fields before LMS can observe PENDING. */ + pg_write_barrier(); + pg_atomic_write_u32(&slot->state, CLUSTER_LMS_CR_PENDING); + cr_server_wake_lms(); + return true; + } + + return false; /* all slots busy — fail closed, requester retries/refuses */ +} + /* * lms_undo_fetch_serve — LMS side of one KIND_UNDO_FETCH slot (spec-6.12i). * @@ -345,6 +417,138 @@ lms_undo_fetch_serve(ClusterLmsCrSlot *slot) slot->undo_block_no, slot->result_page); } +/* + * lms_resolve_own_xid_verdict — shared core resolving ONE own xid's terminal + * verdict over this node's COMPLETE durable TT + CLOG (spec-6.12i D-i4 / + * spec-6.15 D4 / spec-7.1 D1-serve, D3-b). + * + * Used by BOTH the single-xid verdict serve and the multi member-verdict + * serve so the terminal decision lives in exactly one place (no fork). The + * caller must have already gated cluster_xid_is_mine(xid) and co-sampled the + * live authority triple (the coverage claim never exceeds the scanned durable + * state). Fills *out_verdict + the scn/wrap fields on a proven terminal and + * returns a reason so each caller attributes its OWN census (this core bumps + * NO counter): + * RESOLVED_SCN exact COMMITTED match: CLOG must confirm (C1b — the + * TT stamp is pre-commit, a stamp without a commit + * record is in-doubt). Acceptance-gate PASS -> + * COMMITTED_EXACT{commit_scn, wrap}; a wrap-suspect scn + * (spec-7.1a hardening) ships COMMITTED_BELOW_HORIZON{H} + * over max(scn, gated-recycle horizon) instead of + * refusing (no gated recycle -> refuse, like zero-match). + * RECYCLED_ZERO_MATCH the slot is provably gone: the spec-3.22 retention + * origin legs (a)-(d) + the monotonic max recycle + * horizon sampled AFTER the scan, then CLOG decides: + * COMMITTED -> COMMITTED_BELOW_HORIZON{H} (recycle was + * horizon-gated, so the lost commit_scn is <= H); + * explicit ABORTED -> ABORTED; neither -> refuse. + * XID_MATCH_INVALID_SCN spec-7.1 D1 serve: our own xid matched but carries + * no stamped commit_scn (delayed-cleanout window). 8.A + * positive proof only: ONLY an explicit CLOG abort + * upgrades to a positive ABORTED (the pure, unit-tested + * cluster_cr_server_invalid_scn_verdict); a committed- + * but-unstamped / in-flight / 2PC / crashed-without-abort + * xid stays fail-closed (we never fabricate a scn). + * anything else AMBIGUOUS_WRAP / SCAN_UNAVAILABLE -> refuse. + */ +typedef enum LmsOwnXidReason { + LMS_OWN_XID_PROVEN = 0, /* out_* holds a proven terminal */ + LMS_OWN_XID_PROVEN_UPGRADE, /* proven ABORTED via the invalid_scn CLOG upgrade */ + LMS_OWN_XID_REFUSE_OTHER, /* not-committed / wrap-suspect / retention-fail / ambiguous */ + LMS_OWN_XID_REFUSE_ZERO_MATCH, /* recycled 0-match with no explicit CLOG terminal */ + LMS_OWN_XID_REFUSE_INVALID_SCN /* delayed-cleanout, not provably aborted */ +} LmsOwnXidReason; + +static LmsOwnXidReason +lms_resolve_own_xid_verdict(TransactionId xid, uint8 *out_verdict, SCN *out_commit_scn, + SCN *out_horizon_scn, uint16 *out_wrap) +{ + SCN scn = InvalidScn; + SCN horizon = InvalidScn; + uint16 wrap = 0; + + *out_verdict = 0; + *out_commit_scn = InvalidScn; + *out_horizon_scn = InvalidScn; + *out_wrap = 0; + + switch ( + cluster_tt_slot_durable_resolve_by_xid(xid, CLUSTER_TT_WRAP_ANY, &scn, NULL, NULL, &wrap)) { + case CLUSTER_TT_DURABLE_RESOLVED_SCN: + if (!TransactionIdDidCommit(xid)) + return LMS_OWN_XID_REFUSE_OTHER; /* C1b: stamped-then-crashed is in-doubt */ + if (cluster_cr_accept_resolved_scn(scn)) { + *out_verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT; + *out_commit_scn = scn; + *out_wrap = wrap; + return LMS_OWN_XID_PROVEN; + } + + /* + * PGRAC: spec-7.1a hardening -- wrap-suspect stamped scn (below the + * retention horizon), reached routinely now that the requester + * finalizes EVERY shipped COMMITTED stamp here instead of concluding + * on the fetch fast leg. The EXACT value cannot be shipped (a + * same-valued xid recurrence across TT wrap could own a different + * scn), but "committed at/below a FROZEN bound" still can: + * - a LIVE recurrence would be a second by-value match -> + * AMBIGUOUS_WRAP (refused below), so the single live match has + * no live rival; + * - a RECYCLED recurrence's lost scn is at/below the max gated- + * recycle horizon (the zero-match arm's own bound); + * - this slot's own stamped scn bounds itself. + * Ship BELOW_HORIZON over the max of both candidates -- the same + * frozen, non-clock-chasing consumer contract as the zero-match arm + * (never cached; judged against the requester's read_scn, leg (e)). + * No gated recycle this incarnation -> the recycled-recurrence + * candidate is unboundable -> refuse, exactly like zero-match. + * Absorbed into the shared core (spec-7.1 D3-b integration) so BOTH + * the single-xid serve and each multi member-verdict resolve through + * exactly this bound -- no serve forks on the wrap-suspect leg. + */ + if (!cluster_cr_retention_proof_origin_legs(&horizon)) + return LMS_OWN_XID_REFUSE_OTHER; + horizon = cluster_tt_slot_max_recycle_horizon(); + if (!SCN_VALID(horizon)) + return LMS_OWN_XID_REFUSE_OTHER; + if (scn_time_cmp(scn, horizon) > 0) + horizon = scn; + *out_verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; + *out_horizon_scn = horizon; + return LMS_OWN_XID_PROVEN; + + case CLUSTER_TT_DURABLE_RECYCLED_ZERO_MATCH: + if (!cluster_cr_retention_proof_origin_legs(&horizon)) + return LMS_OWN_XID_REFUSE_OTHER; + horizon = cluster_tt_slot_max_recycle_horizon(); + if (!SCN_VALID(horizon)) + return LMS_OWN_XID_REFUSE_OTHER; + if (TransactionIdDidCommit(xid)) { + *out_verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; + *out_horizon_scn = horizon; + return LMS_OWN_XID_PROVEN; + } + if (TransactionIdDidAbort(xid)) { + *out_verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; + return LMS_OWN_XID_PROVEN; + } + return LMS_OWN_XID_REFUSE_ZERO_MATCH; /* neither explicit CLOG state -> refuse */ + + case CLUSTER_TT_DURABLE_XID_MATCH_INVALID_SCN: + if (cluster_cr_server_invalid_scn_verdict(TransactionIdDidAbort(xid)) + == CLUSTER_CR_INVALID_SCN_ABORTED) { + *out_verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; + return LMS_OWN_XID_PROVEN_UPGRADE; + } + return LMS_OWN_XID_REFUSE_INVALID_SCN; + + case CLUSTER_TT_DURABLE_AMBIGUOUS_WRAP: + case CLUSTER_TT_DURABLE_SCAN_UNAVAILABLE: + default: + return LMS_OWN_XID_REFUSE_OTHER; + } +} + /* * lms_undo_verdict_serve — LMS side of one KIND_UNDO_VERDICT slot * (spec-6.12i D-i4 / spec-6.15 D4). @@ -397,8 +601,9 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) { ClusterGcsUndoVerdictPage *v = (ClusterGcsUndoVerdictPage *)slot->result_page; TransactionId xid = slot->undo_xid; - SCN scn = InvalidScn; - SCN horizon = InvalidScn; + uint8 verdict = 0; + SCN commit_scn = InvalidScn; + SCN horizon_scn = InvalidScn; uint16 wrap = 0; if (!cluster_crossnode_runtime_visibility) @@ -423,131 +628,136 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) * the content newer than claimed; additive and safe). */ slot->undo_auth.authority_scn = cluster_scn_current(); + /* Resolve the terminal via the shared core; attribute the census leg. */ + switch (lms_resolve_own_xid_verdict(xid, &verdict, &commit_scn, &horizon_scn, &wrap)) { + case LMS_OWN_XID_PROVEN: + break; + case LMS_OWN_XID_PROVEN_UPGRADE: + cluster_vis53r97_note_live_upgrade_hit(); /* spec-7.1 D1 serve upgrade */ + break; + case LMS_OWN_XID_REFUSE_ZERO_MATCH: + cluster_vis53r97_note_srv_zero_match(); + return false; + case LMS_OWN_XID_REFUSE_INVALID_SCN: + cluster_vis53r97_note_srv_invalid_scn(); + return false; + case LMS_OWN_XID_REFUSE_OTHER: + default: + cluster_vis53r97_note_srv_other(); + return false; + } + memset(slot->result_page, 0, BLCKSZ); v->magic = CLUSTER_GCS_UNDO_VERDICT_MAGIC; v->version = CLUSTER_GCS_UNDO_VERDICT_VERSION; v->xid_echo = (uint64)xid; - v->commit_scn = InvalidScn; - v->horizon_scn = InvalidScn; + v->verdict = verdict; + v->commit_scn = commit_scn; + v->horizon_scn = horizon_scn; + v->wrap = wrap; + return true; +} - switch ( - cluster_tt_slot_durable_resolve_by_xid(xid, CLUSTER_TT_WRAP_ANY, &scn, NULL, NULL, &wrap)) { - case CLUSTER_TT_DURABLE_RESOLVED_SCN: - if (!TransactionIdDidCommit(xid)) { - cluster_vis53r97_note_srv_other(); - return false; /* C1b: stamped-then-crashed is in-doubt */ - } - if (cluster_cr_accept_resolved_scn(scn)) { - v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT; - v->commit_scn = scn; - v->wrap = wrap; - return true; - } +/* + * lms_undo_multi_verdict_serve — LMS side of one KIND_UNDO_MULTI_VERDICT slot + * (spec-7.1 D3-b). + * + * The requester's member overlay structurally missed a FOREIGN multixact + * xmax (the updater had no compose-time TT binding, IN-12), so THIS node — + * the sole owner of the multi's pg_multixact members — answers over its own + * state: + * 1. serve ONLY multis the stripe derivation proves ours + * (cluster_mxid_is_mine; underivable / foreign -> refuse, mirroring the + * single-xid D4 self-check); + * 2. co-sample the live authority triple BEFORE enumerating (same + * conservative ordering as the single serve); + * 3. GetMultiXactIdMembers over our own pg_multixact; a set < 2 or over the + * wire capacity is refused (never truncate a member set, 8.A); + * 4. for each UPDATER member (status 4-5) resolve its terminal via the + * shared lms_resolve_own_xid_verdict; lock-only members (A2) record + * {xid, status} only. A member xid that is not provably ours (a foreign + * xid that locked/updated our row) is not resolvable from our TT/CLOG, + * so any unprovable UPDATER makes the WHOLE multi UNPROVABLE -> refuse + * (the requester keeps 53R97; the residue is the feature #119 forward). + * + * true = result_page holds a SERVED ClusterGcsUndoMultiVerdictPage; false = + * refuse (caller ships DENIED). Runs under the drain's PG_TRY: a truncated + * pg_multixact / CLOG page or any throw becomes a refusal, never an LMS exit. + */ +static bool +lms_undo_multi_verdict_serve(ClusterLmsCrSlot *slot) +{ + ClusterGcsUndoMultiVerdictPage *v = (ClusterGcsUndoMultiVerdictPage *)slot->result_page; + MultiXactId mxid = (MultiXactId)slot->undo_xid; /* Q-D3b1: carrier holds the mxid */ + MultiXactMember *members = NULL; + int nmembers; + int i; - /* - * PGRAC: spec-7.1a hardening -- wrap-suspect stamped scn (below the - * retention horizon), reached routinely now that the requester - * finalizes EVERY shipped COMMITTED stamp here instead of concluding - * on the fetch fast leg. The EXACT value cannot be shipped (a - * same-valued xid recurrence across TT wrap could own a different - * scn), but "committed at/below a FROZEN bound" still can: - * - a LIVE recurrence would be a second by-value match -> - * AMBIGUOUS_WRAP (refused below), so the single live match has - * no live rival; - * - a RECYCLED recurrence's lost scn is at/below the max gated- - * recycle horizon (the zero-match arm's own bound); - * - this slot's own stamped scn bounds itself. - * Ship BELOW_HORIZON over the max of both candidates -- the same - * frozen, non-clock-chasing consumer contract as the zero-match arm - * (never cached; judged against the requester's read_scn, leg (e)). - * No gated recycle this incarnation -> the recycled-recurrence - * candidate is unboundable -> refuse, exactly like zero-match. - */ - if (!cluster_cr_retention_proof_origin_legs(&horizon)) { - cluster_vis53r97_note_srv_other(); - return false; - } - horizon = cluster_tt_slot_max_recycle_horizon(); - if (!SCN_VALID(horizon)) { - cluster_vis53r97_note_srv_other(); - return false; - } - if (scn_time_cmp(scn, horizon) > 0) - horizon = scn; - v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; - v->horizon_scn = horizon; - return true; + if (!cluster_crossnode_runtime_visibility) + return false; + if (!MultiXactIdIsValid(mxid)) + return false; + /* spec-7.1 D3-b: only answer for provably-own multis (see banner). */ + if (!cluster_mxid_is_mine(mxid)) + return false; - case CLUSTER_TT_DURABLE_RECYCLED_ZERO_MATCH: - /* - * Origin legs AFTER the complete scan (ordering contract). The - * SHIPPED bound is NOT the live horizon (its no-reader fallback is - * the current SCN clock, which Lamport-chases the observing - * requester forever — leg (e) would never converge) but the - * monotonic max gate horizon over the recycles that actually - * happened: the asked-for xid's slot was one of them, so its lost - * commit_scn is at/below that max; and the max freezes between - * recycles, so the requester's observe catches up. InvalidScn - * (no gated recycle this incarnation — e.g. all recycles predate a - * restart) refuses fail-closed. - */ - if (!cluster_cr_retention_proof_origin_legs(&horizon)) { - cluster_vis53r97_note_srv_other(); - return false; - } - horizon = cluster_tt_slot_max_recycle_horizon(); - if (!SCN_VALID(horizon)) { - cluster_vis53r97_note_srv_other(); + /* Co-sample the authority triple BEFORE enumerating the members. */ + slot->undo_auth.origin_epoch = cluster_epoch_get_current(); + slot->undo_auth.live_hwm_lsn = GetFlushRecPtr(NULL); + slot->undo_auth.tt_generation = cluster_undo_tt_retention_rollover_count(); + slot->undo_auth.live_hwm_scn = cluster_scn_current(); + + nmembers = GetMultiXactIdMembers(mxid, &members, false, false); + if (nmembers < 2 || members == NULL) { + if (members != NULL) + pfree(members); + return false; /* < 2 members: not a real multi / unreadable set */ + } + if (nmembers > CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS) { + pfree(members); + return false; /* over wire capacity -> refuse (never truncate) */ + } + + memset(slot->result_page, 0, BLCKSZ); + v->magic = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAGIC; + v->version = CLUSTER_GCS_UNDO_MULTI_VERDICT_VERSION; + v->mxid_echo = (uint64)mxid; + v->nmembers = (uint16)nmembers; + + for (i = 0; i < nmembers; i++) { + ClusterGcsUndoMultiVerdictMember *out = &v->members[i]; + TransactionId member_xid = members[i].xid; + uint8 status = (uint8)members[i].status; + + out->xid = member_xid; + out->member_status = status; + + /* Lock-only members never gate visibility (A2): {xid, status} only — + * no terminal needed even for a foreign lock-only member. */ + if (status <= (uint8)MultiXactStatusForUpdate) + continue; + + /* Updater member (4-5): its terminal decides tuple visibility, so it + * must be provably ours. A foreign / underivable updater xid or an + * unprovable terminal makes the WHOLE multi UNPROVABLE (8.A). */ + if (!TransactionIdIsNormal(member_xid) || !cluster_xid_is_mine(member_xid)) { + pfree(members); return false; } - if (TransactionIdDidCommit(xid)) { - v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; - v->horizon_scn = horizon; - return true; - } - if (TransactionIdDidAbort(xid)) { - v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; - return true; - } - cluster_vis53r97_note_srv_zero_match(); - return false; /* neither explicit CLOG state -> refuse */ - - case CLUSTER_TT_DURABLE_XID_MATCH_INVALID_SCN: - /* - * spec-7.1 D1 serve: the durable by-xid scan matched our own xid but - * the slot carries no stamped commit_scn (delayed-cleanout window). - * Per IN-5, normal commit stamps the durable TT pre-commit (Fast - * Commit), so a committed xid never lands here; the real population is - * aborted-unstamped (abort writes no durable commit_scn). Cross-check - * CLOG -- authoritative for our own xid -- and answer a provably - * ABORTED xid positively (invisible at the requester) instead of - * 53R97. This mirrors the RECYCLED_ZERO_MATCH CLOG cross-check above. - * - * 8.A (positive proof only): we ONLY upgrade on an explicit CLOG abort. - * A committed-but-unstamped xid (we cannot fabricate its commit_scn), - * an in-flight/in-doubt xid (crash or 2PC-prepared window), or any - * transaction whose CLOG state is not yet a definite abort stays - * fail-closed on the invalid_scn refuse leg -- the refuse direction is - * unchanged. TransactionIdDidAbort returns true only for an explicit - * abort record, so a crashed-without-abort xid does NOT upgrade here. - * The abort->ABORTED / else->REFUSE decision is the pure, unit-tested - * cluster_cr_server_invalid_scn_verdict (test_cluster_cr_server_policy). - */ - if (cluster_cr_server_invalid_scn_verdict(TransactionIdDidAbort(xid)) - == CLUSTER_CR_INVALID_SCN_ABORTED) { - v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_ABORTED; - cluster_vis53r97_note_live_upgrade_hit(); - return true; + switch (lms_resolve_own_xid_verdict(member_xid, &out->verdict, &out->commit_scn, + &out->horizon_scn, &out->wrap)) { + case LMS_OWN_XID_PROVEN: + case LMS_OWN_XID_PROVEN_UPGRADE: + break; + default: + pfree(members); + return false; /* an unprovable updater -> whole multi UNPROVABLE */ } - cluster_vis53r97_note_srv_invalid_scn(); - return false; - - case CLUSTER_TT_DURABLE_AMBIGUOUS_WRAP: - case CLUSTER_TT_DURABLE_SCAN_UNAVAILABLE: - default: - cluster_vis53r97_note_srv_other(); - return false; } + + pfree(members); + v->status = (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED; + return true; } /* @@ -588,20 +798,51 @@ cluster_lms_cr_drain(void) * off, non-header block, bad segment, non-own xid, unprovable * outcome, read failure, injection) keeps the DENIED status — the * requester keeps its unchanged 53R97 fail-closed (Rule 8.A). The - * injection point is shared by both kinds: it models "the origin's - * undo serve plane is down", which refuses fetches and verdicts - * alike. + * injection point is shared by all three kinds: it models "the + * origin's undo serve plane is down", which refuses fetches, single + * verdicts and multi-member verdicts (spec-7.1 D3-b) alike. */ if (slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_FETCH - || slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT) { - bool is_verdict = (slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT); + || slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT + || slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_MULTI_VERDICT) { bool served = false; + uint8 result_status; + ClusterCrServerStat served_stat; + ClusterCrServerStat denied_stat; + + switch (slot->req_kind) { + case (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_MULTI_VERDICT: + result_status = (uint8)GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT; + served_stat = CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_SERVED; + denied_stat = CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_DENIED; + break; + case (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT: + result_status = (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT; + served_stat = CLUSTER_CR_SERVER_STAT_VERDICT_SERVED; + denied_stat = CLUSTER_CR_SERVER_STAT_VERDICT_DENIED; + break; + default: /* CLUSTER_LMS_SLOT_KIND_UNDO_FETCH */ + result_status = (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT; + served_stat = CLUSTER_CR_SERVER_STAT_UNDO_SERVED; + denied_stat = CLUSTER_CR_SERVER_STAT_UNDO_DENIED; + break; + } CLUSTER_INJECTION_POINT("cluster-lms-undo-fetch"); if (!cluster_injection_should_skip("cluster-lms-undo-fetch")) { PG_TRY(); { - served = is_verdict ? lms_undo_verdict_serve(slot) : lms_undo_fetch_serve(slot); + switch (slot->req_kind) { + case (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_MULTI_VERDICT: + served = lms_undo_multi_verdict_serve(slot); + break; + case (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT: + served = lms_undo_verdict_serve(slot); + break; + default: + served = lms_undo_fetch_serve(slot); + break; + } } PG_CATCH(); { @@ -614,13 +855,10 @@ cluster_lms_cr_drain(void) } if (served) { - slot->result_status = (uint8)(is_verdict ? GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT - : GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT); - cluster_cr_server_stat_bump(is_verdict ? CLUSTER_CR_SERVER_STAT_VERDICT_SERVED - : CLUSTER_CR_SERVER_STAT_UNDO_SERVED); + slot->result_status = result_status; + cluster_cr_server_stat_bump(served_stat); } else { - cluster_cr_server_stat_bump(is_verdict ? CLUSTER_CR_SERVER_STAT_VERDICT_DENIED - : CLUSTER_CR_SERVER_STAT_UNDO_DENIED); + cluster_cr_server_stat_bump(denied_stat); } pg_write_barrier(); @@ -710,8 +948,7 @@ cluster_lms_cr_ship_ready(void) /* spec-6.12i: a served undo-TT fetch / undo verdict appends the * authority trailer (tt_generation); DENIED undo replies stay * v1-sized. */ - if (slot->result_status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT - || slot->result_status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) + if (GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)slot->result_status)) total += (uint32)sizeof(ClusterGcsUndoAuthTrailer); buf = (char *)palloc0(total); hdr = (GcsBlockReplyHeader *)buf; @@ -727,8 +964,7 @@ cluster_lms_cr_ship_ready(void) if (hdr->status == (uint8)GCS_BLOCK_REPLY_CR_RESULT_FULL || hdr->status == (uint8)GCS_BLOCK_REPLY_CR_RESULT_PARTIAL) memcpy(buf + header_len, slot->result_page, BLCKSZ); - else if (hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT - || hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) { + else if (GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)hdr->status)) { ClusterGcsUndoAuthTrailer *trailer = (ClusterGcsUndoAuthTrailer *)(buf + header_len + GCS_BLOCK_DATA_SIZE); diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 651665031a..1bda6584a9 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2651,6 +2651,11 @@ dump_cr(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_cr_server_verdict_served_count())); emit_row(rsinfo, "cr", "cr_server_verdict_denied_count", fmt_int64((int64)cluster_cr_server_verdict_denied_count())); + /* spec-7.1 D3-b: origin multixact member-verdict serve counters. */ + emit_row(rsinfo, "cr", "cr_server_multi_verdict_served_count", + fmt_int64((int64)cluster_cr_server_multi_verdict_served_count())); + emit_row(rsinfo, "cr", "cr_server_multi_verdict_denied_count", + fmt_int64((int64)cluster_cr_server_multi_verdict_denied_count())); emit_row(rsinfo, "cr", "rtvis_underivable_failclosed_count", fmt_int64((int64)cluster_rtvis_underivable_failclosed_count())); /* spec-7.1 D0/D5: 53R97 per-leg attribution (census + error-closure dial). */ diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index 7f03b4b10d..0aacc6b19e 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -4878,8 +4878,7 @@ gcs_block_decode_reply_payload(const ClusterICEnvelope *env, const void *payload if (env->payload_length == undo_size) { const GcsBlockReplyHeader *h = (const GcsBlockReplyHeader *)payload; - if (h->status != (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT - && h->status != (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) + if (!GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)h->status)) return false; if (out_hdr != NULL) *out_hdr = h; @@ -5001,6 +5000,7 @@ cluster_gcs_handle_block_reply_envelope(const ClusterICEnvelope *env, const void || hdr->status == (uint8)GCS_BLOCK_REPLY_CR_RESULT_PARTIAL || hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT || hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT + || hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT || hdr->status == (uint8)GCS_BLOCK_REPLY_READ_IMAGE_FROM_XHOLDER || hdr->status == (uint8)GCS_BLOCK_REPLY_DENIED_MASTER_NOT_HOLDER || hdr->status == (uint8)GCS_BLOCK_REPLY_DENIED_LOST_WRITE)) @@ -5162,6 +5162,22 @@ cluster_gcs_handle_block_forward_envelope(const ClusterICEnvelope *env, const vo return; } + /* + * PGRAC: spec-7.1 D3-b — undo-MULTI-verdict request. Same LMON shape as + * the single verdict branch above (validate + park; the member enumeration + * + per-updater terminal scan runs in LMS, the LMON tick ships). MUST + * branch here, before any holder / GRD logic: the tag is a synthetic undo + * address and the SCN carrier holds the widened MXID. A refused park + * (wave GUC off / malformed tag or carrier / no capacity) replies the + * fail-closed DENIED immediately so the requester keeps its unchanged + * 53R97 refusal (Rule 8.A). + */ + if (GcsBlockForwardPayloadIsUndoMultiVerdictRequest(fwd)) { + if (!cluster_lms_undo_multi_verdict_submit(fwd)) + gcs_block_forward_reply_immediate_deny(fwd); + return; + } + /* * PGRAC: spec-6.12e2 (㉔) — BAST nudge. The master denied a peer's X * request because WE hold this block in X; it asks us to TRY the diff --git a/src/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index 53f1a5ea5e..be27df8813 100644 --- a/src/include/cluster/cluster_cr.h +++ b/src/include/cluster/cluster_cr.h @@ -256,6 +256,8 @@ extern uint64 cluster_rtvis_verdict_below_horizon_count(void); extern uint64 cluster_rtvis_verdict_inadmissible_count(void); extern uint64 cluster_cr_server_verdict_served_count(void); extern uint64 cluster_cr_server_verdict_denied_count(void); +extern uint64 cluster_cr_server_multi_verdict_served_count(void); +extern uint64 cluster_cr_server_multi_verdict_denied_count(void); extern void cluster_rtvis_verdict_note_wire(void); extern void cluster_rtvis_verdict_note_failclosed(void); extern void cluster_rtvis_verdict_note_exact(void); diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index a1506cc69b..fa7925061d 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -116,9 +116,10 @@ typedef enum ClusterLmsCrSlotState { /* Work-slot request kind (spec-6.12i extends the wave-b CR-only table). */ typedef enum ClusterLmsCrSlotKind { - CLUSTER_LMS_SLOT_KIND_CR = 0, /* spec-6.12b CR construction */ - CLUSTER_LMS_SLOT_KIND_UNDO_FETCH = 1, /* spec-6.12i undo-TT block fetch */ - CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT = 2 /* spec-6.12i D-i4 complete-scan verdict */ + CLUSTER_LMS_SLOT_KIND_CR = 0, /* spec-6.12b CR construction */ + CLUSTER_LMS_SLOT_KIND_UNDO_FETCH = 1, /* spec-6.12i undo-TT block fetch */ + CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT = 2, /* spec-6.12i D-i4 complete-scan verdict */ + CLUSTER_LMS_SLOT_KIND_UNDO_MULTI_VERDICT = 3 /* spec-7.1 D3-b multi member verdict */ } ClusterLmsCrSlotKind; typedef struct ClusterLmsCrSlot { @@ -133,22 +134,25 @@ typedef struct ClusterLmsCrSlot { uint8 transition_id; /* echo (N->S) for the reply slot match */ uint8 result_status; /* GcsBlockReplyStatus: CR_RESULT_FULL / * CR_RESULT_PARTIAL / UNDO_TT_FETCH_RESULT / - * UNDO_VERDICT_RESULT / + * UNDO_VERDICT_RESULT / UNDO_MULTI_VERDICT_RESULT / * DENIED_MASTER_NOT_HOLDER */ - uint8 req_kind; /* ClusterLmsCrSlotKind (spec-6.12i) */ + uint8 req_kind; /* ClusterLmsCrSlotKind (spec-6.12i / spec-7.1) */ /* spec-6.12i D-i1: undo address decoded from the synthetic tag at submit * time, and the LMS-co-sampled live authority triple the ship path copies * into the reply (epoch -> hdr.epoch, live_hwm -> hdr.page_lsn, * tt_generation -> trailer). Meaningful only for KIND_UNDO_FETCH / - * KIND_UNDO_VERDICT. undo_xid is the D-i4 verdict subject, decoded from - * the widened watermark carrier at submit time (KIND_UNDO_VERDICT only). */ + * KIND_UNDO_VERDICT / KIND_UNDO_MULTI_VERDICT. undo_xid is the D-i4 + * verdict subject decoded from the widened watermark carrier at submit time + * (KIND_UNDO_VERDICT), or the asked-for MultiXactId in the same carrier + * width (KIND_UNDO_MULTI_VERDICT, spec-7.1 D3-b Q-D3b1). */ uint32 undo_segment_id; uint32 undo_block_no; TransactionId undo_xid; ClusterLiveAuthority undo_auth; char result_page[BLCKSZ]; /* the constructed CR page (FULL/PARTIAL), the - * fetched undo header block (UNDO_FETCH), or - * the ClusterGcsUndoVerdictPage (UNDO_VERDICT) */ + * fetched undo header block (UNDO_FETCH), the + * ClusterGcsUndoVerdictPage (UNDO_VERDICT), or the + * ClusterGcsUndoMultiVerdictPage (MULTI_VERDICT) */ } ClusterLmsCrSlot; /* CR-server counter buckets (bumped into the ClusterCRShared region owned @@ -157,10 +161,12 @@ typedef enum ClusterCrServerStat { CLUSTER_CR_SERVER_STAT_FULL = 0, CLUSTER_CR_SERVER_STAT_PARTIAL = 1, CLUSTER_CR_SERVER_STAT_DENIED = 2, - CLUSTER_CR_SERVER_STAT_UNDO_SERVED = 3, /* spec-6.12i D-i1 origin serve */ - CLUSTER_CR_SERVER_STAT_UNDO_DENIED = 4, /* spec-6.12i D-i1 origin refuse */ - CLUSTER_CR_SERVER_STAT_VERDICT_SERVED = 5, /* spec-6.12i D-i4 verdict serve */ - CLUSTER_CR_SERVER_STAT_VERDICT_DENIED = 6 /* spec-6.12i D-i4 verdict refuse */ + CLUSTER_CR_SERVER_STAT_UNDO_SERVED = 3, /* spec-6.12i D-i1 origin serve */ + CLUSTER_CR_SERVER_STAT_UNDO_DENIED = 4, /* spec-6.12i D-i1 origin refuse */ + CLUSTER_CR_SERVER_STAT_VERDICT_SERVED = 5, /* spec-6.12i D-i4 verdict serve */ + CLUSTER_CR_SERVER_STAT_VERDICT_DENIED = 6, /* spec-6.12i D-i4 verdict refuse */ + CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_SERVED = 7, /* spec-7.1 D3-b multi member serve */ + CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_DENIED = 8 /* spec-7.1 D3-b multi member refuse */ } ClusterCrServerStat; extern void cluster_cr_server_stat_bump(ClusterCrServerStat which); @@ -197,6 +203,14 @@ extern bool cluster_lms_undo_fetch_submit(const GcsBlockForwardPayload *fwd); * requester keeps its unchanged 53R97). */ extern bool cluster_lms_undo_verdict_submit(const GcsBlockForwardPayload *fwd); +/* LMON dispatch side (spec-7.1 D3-b): park a validated undo-MULTI-verdict + * request (the asked-for MXID rides the widened watermark carrier; a carrier + * with non-zero upper 32 bits or an invalid mxid is malformed). false = wave + * GUC off on this node / malformed tag or carrier / no capacity (caller + * replies the fail-closed DENIED immediately — the requester keeps its + * unchanged 53R97). */ +extern bool cluster_lms_undo_multi_verdict_submit(const GcsBlockForwardPayload *fwd); + /* LMS main-loop side: construct every PENDING slot (errors become DENIED * results — fail-closed to the requester, never an LMS restart). */ extern void cluster_lms_cr_drain(void); diff --git a/src/include/cluster/cluster_gcs_block.h b/src/include/cluster/cluster_gcs_block.h index 6395e094fb..ae70003f91 100644 --- a/src/include/cluster/cluster_gcs_block.h +++ b/src/include/cluster/cluster_gcs_block.h @@ -357,6 +357,19 @@ StaticAssertDecl(GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT == GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT + 1, "spec-7.1 D3-b undo-multi-verdict status must be the tail enum value"); +/* PGRAC: spec-6.12i / spec-7.1 — every undo-plane reply kind (TT-header fetch, + * single-xid verdict, batched multi-member verdict) ships the BLCKSZ page plus + * a co-sampled ClusterGcsUndoAuthTrailer and overrides the reply header's + * epoch / page_lsn with the LMS-sampled live authority. Centralised so every + * ship/parse site treats the three identically (D-i3 authority carriage). */ +static inline bool +GcsBlockReplyStatusCarriesUndoAuthTrailer(GcsBlockReplyStatus status) +{ + return status == GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT + || status == GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT + || status == GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT; +} + static inline bool GcsBlockReplyStatusAllowsDirectLandInstall(GcsBlockReplyStatus status) { diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index 2397097b70..a3303201a6 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1880,6 +1880,16 @@ cluster_cr_server_verdict_denied_count(void) return 0; } uint64 +cluster_cr_server_multi_verdict_served_count(void) +{ + return 0; +} +uint64 +cluster_cr_server_multi_verdict_denied_count(void) +{ + return 0; +} +uint64 cluster_rtvis_underivable_failclosed_count(void) { return 0; From f9549324672201b21c6acf1e0be6cacec45e63eb Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 21:17:51 +0800 Subject: [PATCH 16/29] feat(cluster): spec-7.1 D3-b requester member-verdict ask + reader wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the cross-node path: a foreign multixact xmax whose member overlay structurally misses now asks the origin for a batched member verdict and feeds the served terminals to the pure combination resolver, instead of failing closed on the miss. - cluster_gcs_block_undo_multi_verdict_fetch_and_wait: requester fetch mirroring the single-verdict fetch (MXID rides the carrier, placeholder synthetic tag since the member scan is complete), waits for UNDO_MULTI_VERDICT_RESULT, structurally validates via cluster_vis_undo_multi_verdict_page_usable and Lamport-observes every member SCN that crossed the wire (AD-008). Any timeout / DENIED / checksum / non-SERVED / malformed page fails closed (53R97). - cluster_multixact_remote_xmax_resolve overlay-miss branch: was UNKNOWN, now calls the new cluster_multixact_remote_xmax_ask_origin helper -> fetch -> map the served page to ClusterMultiXactServedMember[] -> cluster_multixact_resolve_visibility_served(read_scn). A resolved VISIBLE/INVISIBLE is returned to the caller; UNKNOWN keeps the unchanged 53R97 (fail-closed direction untouched). GUC off / origin==self never asks (D3-a floor). - Requester census: vis53r97_leg_multi_member_serve_ask/hit (ask = every request; hit = a definite decision; unprovable = ask - hit = the 53R97 residue / feature #119 forward). Field + init + note + accessor + decl + dump row + test_cluster_debug stubs. cluster_unit 162/162; full backend links + all test binaries build clean; clang-format-18 clean. End-to-end TAP (t/357 L2/L3 转正) is D3b-5. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, A1, §2.3) --- src/backend/cluster/cluster_cr.c | 30 ++++ src/backend/cluster/cluster_debug.c | 5 + src/backend/cluster/cluster_gcs_block.c | 156 +++++++++++++++++++++ src/backend/cluster/cluster_multixact.c | 71 +++++++++- src/include/cluster/cluster_cr.h | 6 + src/include/cluster/cluster_cr_server.h | 12 ++ src/test/cluster_unit/test_cluster_debug.c | 10 ++ 7 files changed, 286 insertions(+), 4 deletions(-) diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index 47485c0bca..7eb5ef2269 100644 --- a/src/backend/cluster/cluster_cr.c +++ b/src/backend/cluster/cluster_cr.c @@ -208,6 +208,15 @@ typedef struct ClusterCRShared { */ pg_atomic_uint64 vis53r97_leg_xmin_overlay_verdict_ask_count; pg_atomic_uint64 vis53r97_leg_xmin_overlay_verdict_hit_count; + /* + * spec-7.1 D3-b requester 半边: a foreign multixact xmax whose member + * overlay missed asked the origin for a batched member verdict. ask = + * every request sent; hit = a request whose SERVED page resolved to a + * definite VISIBLE/INVISIBLE. unprovable = ask - hit (a DENIED / timeout / + * invalid page, or a SERVED page still UNKNOWN at this read_scn) -> 53R97. + */ + pg_atomic_uint64 vis53r97_leg_multi_member_serve_ask_count; + pg_atomic_uint64 vis53r97_leg_multi_member_serve_hit_count; /* * spec-7.1 D1 serve 半边: the origin LMS verdict serve upgraded a durable * XID_MATCH_INVALID_SCN hit (unstamped commit_scn, delayed-cleanout window) @@ -311,6 +320,8 @@ cluster_cr_shmem_init(void) pg_atomic_init_u64(&CRShared->vis53r97_leg_xmax_unprovable_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_ask_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_hit_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_multi_member_serve_ask_count, 0); + pg_atomic_init_u64(&CRShared->vis53r97_leg_multi_member_serve_hit_count, 0); pg_atomic_init_u64(&CRShared->vis53r97_leg_live_upgrade_hit_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_resolved_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_recycled_invisible_count, 0); @@ -539,6 +550,21 @@ cluster_vis53r97_note_xmin_overlay_verdict_hit(void) pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_xmin_overlay_verdict_hit_count, 1); } +/* spec-7.1 D3-b requester 半边: foreign multixact member-verdict ask / hit. */ +void +cluster_vis53r97_note_multi_member_serve_ask(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_multi_member_serve_ask_count, 1); +} + +void +cluster_vis53r97_note_multi_member_serve_hit(void) +{ + if (CRShared != NULL) + pg_atomic_fetch_add_u64(&CRShared->vis53r97_leg_multi_member_serve_hit_count, 1); +} + /* spec-7.1 D1 serve 半边: INVALID_SCN durable hit upgraded to positive ABORTED * via CLOG cross-check (the miss counterpart is note_srv_invalid_scn). */ void @@ -551,6 +577,10 @@ CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmin_overlay_verdict_ask_count, vis53r97_leg_xmin_overlay_verdict_ask_count) CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_xmin_overlay_verdict_hit_count, vis53r97_leg_xmin_overlay_verdict_hit_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_multi_member_serve_ask_count, + vis53r97_leg_multi_member_serve_ask_count) +CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_multi_member_serve_hit_count, + vis53r97_leg_multi_member_serve_hit_count) CR_COUNTER_ACCESSOR(cluster_vis53r97_leg_live_upgrade_hit_count, vis53r97_leg_live_upgrade_hit_count) CR_COUNTER_ACCESSOR(cluster_cr_corruption_count, cr_corruption_count) diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 1bda6584a9..b9c5652580 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2675,6 +2675,11 @@ dump_cr(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_vis53r97_leg_xmin_overlay_verdict_ask_count())); emit_row(rsinfo, "cr", "vis53r97_leg_xmin_overlay_verdict_hit_count", fmt_int64((int64)cluster_vis53r97_leg_xmin_overlay_verdict_hit_count())); + /* spec-7.1 D3-b: foreign multixact member-verdict requester ask / hit. */ + emit_row(rsinfo, "cr", "vis53r97_leg_multi_member_serve_ask_count", + fmt_int64((int64)cluster_vis53r97_leg_multi_member_serve_ask_count())); + emit_row(rsinfo, "cr", "vis53r97_leg_multi_member_serve_hit_count", + fmt_int64((int64)cluster_vis53r97_leg_multi_member_serve_hit_count())); emit_row(rsinfo, "cr", "vis53r97_leg_live_upgrade_hit_count", fmt_int64((int64)cluster_vis53r97_leg_live_upgrade_hit_count())); /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index 0aacc6b19e..a2ad10aef4 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -38,6 +38,7 @@ #ifdef USE_PGRAC_CLUSTER +#include "access/multixact.h" /* MultiXactIdIsValid (spec-7.1 D3-b fetch) */ #include "access/xlog.h" #include "access/xlogdefs.h" #include "cluster/cluster_clean_leave.h" /* spec-5.13 S6 — CL-I5 serve gate */ @@ -2739,6 +2740,161 @@ cluster_gcs_block_undo_verdict_fetch_and_wait(int32 origin_node, uint32 segment_ } +/* + * PGRAC: spec-7.1 D3-b — requester-side batched multixact member-verdict fetch. + * + * Ask origin_node for a per-member verdict on the foreign multixact `mxid`, + * riding the same sub-case B wire shape as the single verdict fetch. The + * asked-for MXID rides the widened watermark carrier (upper 32 bits zero); + * the synthetic tag keeps a placeholder segment (0) — the member scan is + * complete over the multi's own pg_multixact, so the tag scopes nothing. + * + * true -> page_out (BLCKSZ) holds the structurally validated SERVED page and + * *auth_out the co-sampled authority; every member SCN that crossed + * the wire is Lamport-observed (AD-008) so a below-horizon bound is + * admissible on the next snapshot. + * false -> fail-closed: timeout, DENIED, checksum failure, missing trailer, + * non-SERVED status, malformed page (Rule 8.A — the caller keeps its + * unchanged 53R97 refusal). + */ +bool +cluster_gcs_block_undo_multi_verdict_fetch_and_wait(int32 origin_node, MultiXactId mxid, + char *page_out, ClusterLiveAuthority *auth_out) +{ + ClusterGcsBlockOutstandingSlot *slot; + uint64 request_id = 0; + BufferTag tag; + GcsBlockForwardPayload fwd; + bool got_reply = false; + bool fetched = false; + + if (page_out == NULL || auth_out == NULL || origin_node < 0 || origin_node == cluster_node_id + || !MultiXactIdIsValid(mxid)) + return false; + + memset(page_out, 0, BLCKSZ); + memset(auth_out, 0, sizeof(*auth_out)); + tag = GcsBlockUndoFetchTagMake(0, 0); /* placeholder segment (scan is complete) */ + + cluster_gcs_block_dedup_register_backend_exit_hook(); + slot = gcs_block_reserve_slot(tag, (uint8)PCM_TRANS_N_TO_S, cluster_node_id, &request_id); + + PG_TRY(); + { + ClusterGcsBlockBackendBlock *blk = gcs_block_my_block(); + TimestampTz deadline; + + LWLockAcquire(&blk->lock.lock, LW_EXCLUSIVE); + slot->reply_received = false; + memset(&slot->reply_header, 0, sizeof(slot->reply_header)); + memset(slot->reply_block_data, 0, sizeof(slot->reply_block_data)); + slot->reply_sf_dep_valid = false; + slot->reply_sf_flags = 0; + cluster_sf_dep_vec_reset(&slot->reply_sf_dep_vec); + slot->reply_undo_trailer_valid = false; + slot->reply_undo_tt_generation = 0; + slot->reply_undo_live_hwm_scn = 0; + slot->request_epoch = cluster_epoch_get_current(); + slot->expected_master_node = cluster_node_id; + slot->stale = false; + LWLockRelease(&blk->lock.lock); + + memset(&fwd, 0, sizeof(fwd)); + fwd.request_id = request_id; + fwd.epoch = cluster_epoch_get_current(); + fwd.tag = tag; + fwd.original_requester_node = cluster_node_id; + fwd.requester_backend_id = (int32)MyBackendId; + fwd.master_node = cluster_node_id; + fwd.transition_id = (uint8)PCM_TRANS_N_TO_S; + GcsBlockForwardPayloadSetUndoMultiVerdictRequest(&fwd, true); + /* The widened mxid rides the watermark carrier (upper 32 bits zero). */ + GcsBlockForwardPayloadSetExpectedPiWatermarkScn(&fwd, (SCN)(uint64)mxid); + + if (!cluster_grd_outbound_enqueue_backend_msg(PGRAC_IC_MSG_GCS_BLOCK_FORWARD, + (uint32)origin_node, &fwd, sizeof(fwd))) + ereport(ERROR, (errcode(ERRCODE_CONNECTION_FAILURE), + errmsg("cluster_gcs_block: failed to enqueue undo-multi-verdict fetch " + "to origin node %d", + (int)origin_node))); + + deadline = GetCurrentTimestamp() + + ((TimestampTz)cluster_gcs_reply_timeout_ms) * (TimestampTz)1000; + + ConditionVariablePrepareToSleep(&slot->reply_cv); + for (;;) { + TimestampTz now; + long timeout_ms; + bool have_reply; + + LWLockAcquire(&blk->lock.lock, LW_SHARED); + have_reply = slot->in_use && slot->reply_received; + LWLockRelease(&blk->lock.lock); + if (have_reply) { + got_reply = true; + break; + } + now = GetCurrentTimestamp(); + if (now >= deadline) + break; + timeout_ms = (long)((deadline - now) / 1000); + if (timeout_ms <= 0) + timeout_ms = 1; + (void)ConditionVariableTimedSleep(&slot->reply_cv, timeout_ms, + WAIT_EVENT_GCS_BLOCK_SHIP_WAIT); + } + ConditionVariableCancelSleep(); + + if (got_reply + && slot->reply_header.status == (uint8)GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT + && slot->reply_undo_trailer_valid) { + uint32 expected = slot->reply_header.checksum; + uint32 got = gcs_block_compute_checksum(slot->reply_block_data); + + if (expected == got) { + const ClusterGcsUndoMultiVerdictPage *v + = (const ClusterGcsUndoMultiVerdictPage *)slot->reply_block_data; + + if (cluster_vis_undo_multi_verdict_page_usable(v, mxid)) { + uint16 i; + + memcpy(page_out, slot->reply_block_data, GCS_BLOCK_DATA_SIZE); + auth_out->origin_epoch = slot->reply_header.epoch; + auth_out->live_hwm_lsn = (XLogRecPtr)slot->reply_header.page_lsn; + auth_out->tt_generation = slot->reply_undo_tt_generation; + auth_out->live_hwm_scn = (SCN)slot->reply_undo_live_hwm_scn; + cluster_scn_observe(auth_out->live_hwm_scn); + /* AD-008: Lamport-observe every member SCN that crossed the + * wire so a below-horizon bound is admissible next snapshot. */ + for (i = 0; i < v->nmembers; i++) { + if (SCN_VALID(v->members[i].commit_scn)) + cluster_scn_observe((SCN)v->members[i].commit_scn); + if (SCN_VALID(v->members[i].horizon_scn)) + cluster_scn_observe((SCN)v->members[i].horizon_scn); + } + /* spec-5.14 D2: depend on the origin's volatile co-sample + * for fail-stop (D-i3). */ + gcs_block_stamp_touched(origin_node, GCS_BLOCK_REPLY_NO_FORWARDING_MASTER); + fetched = true; + } + } else { + pg_atomic_fetch_add_u64(&ClusterGcsBlock->block_checksum_fail_count, 1); + } + } + } + PG_CATCH(); + { + gcs_block_release_slot(slot); + PG_RE_THROW(); + } + PG_END_TRY(); + + gcs_block_release_slot(slot); + + return fetched; /* false -> caller keeps the unchanged 53R97 refusal */ +} + + /* * PGRAC: spec-5.2 D11 — local-master writer-transfer (revoke) + wait. * diff --git a/src/backend/cluster/cluster_multixact.c b/src/backend/cluster/cluster_multixact.c index fc3335fd87..9cf03a4fd9 100644 --- a/src/backend/cluster/cluster_multixact.c +++ b/src/backend/cluster/cluster_multixact.c @@ -37,6 +37,8 @@ #include "utils/hsearch.h" #include "utils/timestamp.h" +#include "cluster/cluster_cr.h" /* vis53r97 multi_member_serve counters (D3-b) */ +#include "cluster/cluster_cr_server.h" /* undo_multi_verdict_fetch_and_wait (D3-b) */ #include "cluster/cluster_elog.h" #include "cluster/cluster_epoch.h" #include "cluster/cluster_guc.h" @@ -341,11 +343,71 @@ cluster_multixact_resolve_visibility(const ClusterMultiXactMemberOverlayResult * } /* - * cluster_multixact_remote_xmax_resolve (spec-7.1 D3-a) + * cluster_multixact_remote_xmax_ask_origin (spec-7.1 D3-b) + * + * The member overlay missed a foreign multixact xmax. For an updater-bearing + * multi this miss is STRUCTURAL, not incidental: the proactive overlay is + * installed at heap_update's OLD-xmax compose time, when the updater still has + * no TT binding (IN-12), so the overlay never covers it. The only positive + * path is to ask the ORIGIN -- the sole owner of the multi's pg_multixact + * members -- for a batched per-member verdict, then feed the served terminals + * to the pure combination resolver. + * + * Any fail-closed outcome (GUC off, DENIED / timeout / invalid page, or a + * SERVED page still UNKNOWN at this read_scn) keeps the pre-existing 53R97 + * boundary (Rule 8.A). ask/hit census: ask = every request; hit = a definite + * VISIBLE/INVISIBLE; unprovable = ask - hit (the feature #119 residue). + */ +static ClusterVisibilityDecision +cluster_multixact_remote_xmax_ask_origin(uint16 origin_slot, MultiXactId mxid, Snapshot snap) +{ + PGAlignedBlock page_buf; + const ClusterGcsUndoMultiVerdictPage *page; + ClusterLiveAuthority auth; + ClusterMultiXactServedMember *served; + ClusterVisibilityDecision decision; + uint16 n; + uint16 i; + + /* Only the GUC-armed cross-node runtime path asks; off = D3-a floor. */ + if (!cluster_crossnode_runtime_visibility || !cluster_multi_xmax_remote_resolve) + return CLUSTER_VISIBILITY_UNKNOWN; + + cluster_vis53r97_note_multi_member_serve_ask(); + if (!cluster_gcs_block_undo_multi_verdict_fetch_and_wait((int32)origin_slot, mxid, + page_buf.data, &auth)) + return CLUSTER_VISIBILITY_UNKNOWN; /* DENIED / timeout / invalid -> 53R97 */ + + /* The page is structurally validated (SERVED, nmembers in [1, MAX], every + * member consistent) by the fetch; map it to served terminals + resolve. */ + page = (const ClusterGcsUndoMultiVerdictPage *)page_buf.data; + n = page->nmembers; + served + = (ClusterMultiXactServedMember *)palloc0((Size)n * sizeof(ClusterMultiXactServedMember)); + for (i = 0; i < n; i++) { + served[i].commit_scn = (SCN)page->members[i].commit_scn; + served[i].horizon_scn = (SCN)page->members[i].horizon_scn; + served[i].xid = page->members[i].xid; + served[i].wrap = page->members[i].wrap; + served[i].verdict = page->members[i].verdict; + served[i].member_status = page->members[i].member_status; + } + decision = cluster_multixact_resolve_visibility_served(served, n, snap->read_scn); + pfree(served); + + if (decision != CLUSTER_VISIBILITY_UNKNOWN) + cluster_vis53r97_note_multi_member_serve_hit(); + return decision; +} + +/* + * cluster_multixact_remote_xmax_resolve (spec-7.1 D3-a + D3-b) * * One-call reader helper: overlay key build (current epoch) + member - * overlay lookup + OBS-1 visibility resolution. See header; UNKNOWN - * always means fail closed at the caller. + * overlay lookup + OBS-1 visibility resolution. On overlay HIT the local + * OBS-1 resolver decides; on overlay MISS (structural for a foreign + * updater-multi, IN-12) the D3-b origin member-verdict ask decides. See + * header; UNKNOWN always means fail closed at the caller. */ ClusterVisibilityDecision cluster_multixact_remote_xmax_resolve(uint16 origin_slot, MultiXactId mxid, Snapshot snap, @@ -369,7 +431,8 @@ cluster_multixact_remote_xmax_resolve(uint16 origin_slot, MultiXactId mxid, Snap if (!cluster_multixact_member_overlay_lookup(&mxkey, mxres, CLUSTER_MULTIXACT_MAX_MEMBERS)) { pfree(mxres); - return CLUSTER_VISIBILITY_UNKNOWN; + /* spec-7.1 D3-b: overlay miss -> ask the origin (banner). */ + return cluster_multixact_remote_xmax_ask_origin(origin_slot, mxid, snap); } if (overlay_hit) diff --git a/src/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index be27df8813..de19c0e128 100644 --- a/src/include/cluster/cluster_cr.h +++ b/src/include/cluster/cluster_cr.h @@ -287,6 +287,12 @@ extern uint64 cluster_vis53r97_leg_xmin_overlay_verdict_ask_count(void); extern uint64 cluster_vis53r97_leg_xmin_overlay_verdict_hit_count(void); extern void cluster_vis53r97_note_xmin_overlay_verdict_ask(void); extern void cluster_vis53r97_note_xmin_overlay_verdict_hit(void); +/* spec-7.1 D3-b requester 半边: foreign multixact member-verdict ask / hit + * (unprovable = ask - hit -> the 53R97 residue, feature #119 forward). */ +extern uint64 cluster_vis53r97_leg_multi_member_serve_ask_count(void); +extern uint64 cluster_vis53r97_leg_multi_member_serve_hit_count(void); +extern void cluster_vis53r97_note_multi_member_serve_ask(void); +extern void cluster_vis53r97_note_multi_member_serve_hit(void); /* spec-7.1 D1 serve 半边: INVALID_SCN -> positive ABORTED via CLOG (hit); the * miss counterpart is invalid_scn_refuse_count / note_srv_invalid_scn. */ extern uint64 cluster_vis53r97_leg_live_upgrade_hit_count(void); diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index fa7925061d..e729d3f91e 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -251,6 +251,18 @@ extern bool cluster_gcs_block_undo_verdict_fetch_and_wait(int32 origin_node, uin ClusterGcsUndoVerdictPage *verdict_out, ClusterLiveAuthority *auth_out); +/* Requester side (backend, spec-7.1 D3-b): ask origin_node for a batched + * member verdict on the foreign multixact `mxid` over the same wire. On + * success copies the structurally-validated (cluster_vis_undo_multi_verdict_ + * page_usable) SERVED page into page_out (a BLCKSZ, 8-byte-aligned buffer), + * Lamport-observes every member SCN that crossed the wire (AD-008), fills + * *auth_out and returns true; false = fail-closed (timeout / DENIED / checksum + * / trailer missing / non-SERVED / malformed page — caller keeps the unchanged + * 53R97, Rule 8.A). */ +extern bool cluster_gcs_block_undo_multi_verdict_fetch_and_wait(int32 origin_node, MultiXactId mxid, + char *page_out, + ClusterLiveAuthority *auth_out); + #endif /* USE_PGRAC_CLUSTER */ #endif /* CLUSTER_CR_SERVER_H */ diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index a3303201a6..31311a31dc 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1936,6 +1936,16 @@ cluster_vis53r97_leg_xmin_overlay_verdict_hit_count(void) return 0; } uint64 +cluster_vis53r97_leg_multi_member_serve_ask_count(void) +{ + return 0; +} +uint64 +cluster_vis53r97_leg_multi_member_serve_hit_count(void) +{ + return 0; +} +uint64 cluster_vis53r97_leg_live_upgrade_hit_count(void) { return 0; From 11e98227960da197740aefafdf7659aee4b7b9e0 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 21:35:17 +0800 Subject: [PATCH 17/29] test(cluster): spec-7.1 D3-b e2e -- t/357 L2/L3 + t/359 G5 turn positive D3-b makes foreign updater-multi cross-node reads resolve positively via the origin member serve, so the TAP legs that asserted the pre-D3-b fail-closed floor now assert the positive read (else they would fail against the new behavior). - t/357 L2/L3: was "remote read fails closed 53R9C"; now read_converge to the committed update (11|100) -- node1 asks the origin for the member verdict, resolves the updater terminal against its own snapshot, and converges once its clock observes the origin commit_scn. A lagging snapshot may briefly still see the pre-update row (correct MVCC, not a false-visible); convergence proves the positive path AND the absence of a PERSISTENT superseded read (the probe-F P0 guard). - t/359 G5: the overlay cap no longer fails closed -- the origin member serve reads its own pg_multixact directly, independent of the requester overlay (which never covers updater multis anyway, IN-12); now asserts the positive converge (17|500). - L5 (t/357) keeps the requester-ask-off fail-closed floor. Verified on the 2-node harness: t/357 4x PASS (18/18), t/359 PASS (19/19), t/346 PASS (39/39, refactored single-verdict serve is behavior-preserving), t/212 PASS (14/14). Follow-ups (D3b-5 remainder): dedicated origin-refuse e2e negative leg (cluster-lms-undo-fetch is PGC_POSTMASTER, must be boot-armed -- t/346 idiom), crash matrix, L6 native readback, full S4-S7 census after (D4). Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, D3b-5) --- .../t/357_cluster_multi_xmax_alias_floor.pl | 75 +++++++++++-------- .../t/359_cluster_mxid_stripe_gapwalk.pl | 52 +++++++++---- 2 files changed, 80 insertions(+), 47 deletions(-) diff --git a/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl b/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl index 62918087f3..6aec7611e1 100644 --- a/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl +++ b/src/test/cluster_tap/t/357_cluster_multi_xmax_alias_floor.pl @@ -13,30 +13,38 @@ # activation floor) -- the creator can read its own updater multi # again (L4), lifting the D3-0 floor's blanket cost; # - a DERIVED-FOREIGN updater multi is resolved through the member -# overlay when it HITS, else fails closed 53R9C. The proactive V4 +# overlay when it HITS, else served by the origin: the proactive V4 # overlay never covers updater-bearing multis (the updater has no # TT binding at heap_update compose time -- see multixact.c # spec-7.1 D3-a note), so cross-node positive resolution of those -# multis is served by the member-serve slow path = spec-7.1 D3-b -# (software-ordered behind spec-7.2 D3). Until D3-b, foreign -# updater multis stay fail-closed (L2/L3) -- never silently wrong. +# multis is served by the member-serve slow path = spec-7.1 D3-b: +# the reader asks the origin (sole owner of the multi's members) +# for a batched per-member verdict and resolves against its own +# snapshot. Only when the origin cannot prove every updater member +# (origin down / member unprovable) does the read fail closed 53R9C. # # L1 lockers-only multi, committed -> remote read still answers (no # over-blocking: lock-only never needs a member decode) -# L2 keyshare+update multi -> remote read fails closed 53R9C -# (derived foreign, overlay miss; positive = D3-b member serve). -# NEVER the silent-wrong native alias (pre-floor P0) +# L2 keyshare+update multi -> remote read resolves POSITIVELY +# via the D3-b origin member-verdict serve, converging to the +# committed update (the old version is invisible once the origin's +# updater terminal is observed). NEVER the silent-wrong native +# alias (pre-floor P0) # L3 probe F: the reader first advances its OWN multixact counter past -# the foreign id -> read STILL fails closed (origin derivation is -# immune to the local counter position; the silent-wrong P0 can no -# longer happen by construction -- the superseded row is never -# returned) +# the foreign id -> read STILL resolves POSITIVELY to the committed +# update (origin derivation is immune to the local counter position; +# the silent-wrong P0 can never happen -- the superseded row is +# never the converged answer) # L4 creator-side read of its own updater-multi OLD version resolves # natively -- a DERIVED-OWN multi decodes alias-free above the # activation floor (was: the D3-0 floor's documented availability # cost; this is the D3-a positive win over the blanket floor) +# L5 revert valve: cluster.multi_xmax_remote_resolve = off drops the +# D3-b ask and keeps the derived-foreign fail-closed floor (never a +# native silent-wrong alias) # -# Spec: spec-7.1-cross-instance-positive-interread.md (D3-a) +# Spec: spec-7.1-cross-instance-positive-interread.md (D3-a origin +# derivation + D3-b origin member-verdict serve) # # Author: SqlRush # @@ -203,14 +211,17 @@ sub read_converge eval { $bg1->quit }; eval { $bg2->quit }; + # D3-b: node1 asks the origin (node0) for the multi's member verdict, + # resolves the updater terminal against its own snapshot, and converges + # to the committed update once its clock observes the origin commit_scn + # (a lagging snapshot may briefly still see the pre-update row -- correct + # MVCC, not a false-visible; convergence to 11|100 proves the positive + # path AND the absence of a PERSISTENT superseded read). my ($rc, $out, $err) = - $node1->psql('postgres', 'SELECT aid, v FROM mxf_t WHERE aid = 11', timeout => 15); - isnt($rc, 0, - 'L2 updater multi: remote read fails closed (derived foreign; positive = D3-b member serve)'); - like( - $err, - qr/cannot be attributed to an origin node|multixact member overlay miss|TT status unknown for deleting xmax/, - 'L2 updater multi: clean 53R9C floor message (not a raw native multixact error)'); + read_converge($node1, 'SELECT aid, v FROM mxf_t WHERE aid = 11', '11|100', 20); + is($rc, 0, 'L2 updater multi: remote read resolves positively (D3-b origin member serve)'); + is($out, '11|100', + 'L2 updater multi: node1 converges to the committed update (old version invisible)'); unlike($err, qr/has not been created yet/, 'L2 updater multi: native id-space error never surfaces'); } @@ -238,15 +249,16 @@ sub read_converge eval { $bgy->quit }; } + # D3-b probe F: even after node1 advanced its OWN multixact counter past + # the foreign id, the STRIPED-value derivation still identifies the multi + # as foreign and asks the origin -- so node1 converges to the committed + # update, NEVER the superseded 11|0 native alias (the pre-floor P0). A + # regression to the native alias would return 11|0 persistently and this + # convergence would time out. my ($rc, $out, $err) = - $node1->psql('postgres', 'SELECT aid, v FROM mxf_t WHERE aid = 11', timeout => 15); - isnt($rc, 0, - 'L3 probe F: aliased read still fails closed (derivation immune to local counter position)'); - like( - $err, - qr/cannot be attributed to an origin node|multixact member overlay miss|TT status unknown for deleting xmax/, - 'L3 probe F: floor message'); - unlike($out, qr/11\|0/, 'L3 probe F: the superseded version is NEVER returned'); + read_converge($node1, 'SELECT aid, v FROM mxf_t WHERE aid = 11', '11|100', 20); + is($rc, 0, 'L3 probe F: aliased read resolves positively (derivation immune to counter position)'); + is($out, '11|100', 'L3 probe F: converges to the committed update, never the superseded 11|0 alias'); } # ------------------------------------------------------------------ @@ -263,11 +275,10 @@ sub read_converge } # ------------------------------------------------------------------ -# L5: revert valve -- cluster.multi_xmax_remote_resolve = off keeps the -# derived-foreign fail-closed floor (the on-path for updater multis -# is fail-closed too until D3-b, so L5 asserts off never REGRESSES -# to the native silent-wrong alias, and the derived-own L4 win is -# independent of the GUC). +# L5: revert valve -- cluster.multi_xmax_remote_resolve = off DROPS the +# D3-b origin ask and keeps the derived-foreign fail-closed floor +# (off must never REGRESS to the native silent-wrong alias, and the +# derived-own L4 win is independent of the GUC). # ------------------------------------------------------------------ { $node1->append_conf('postgresql.conf', 'cluster.multi_xmax_remote_resolve = off'); diff --git a/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl b/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl index 0ba3e637f8..a87891ee75 100644 --- a/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl +++ b/src/test/cluster_tap/t/359_cluster_mxid_stripe_gapwalk.pl @@ -23,16 +23,21 @@ # the refusal branch (the organic trigger needs 2^31 multixacts); # the guardrail counter becomes visible in the dump and creation # recovers after disarm. -# G5 overlay-miss negative leg: with the origin's overlay emit -# suppressed (member cap below the composed member count), the -# remote read of an updater multi fails closed 53R9C -- positive -# resolution never guesses on a miss. +# G5 overlay-capped positive leg (spec-7.1 D3-b): with the requester +# overlay capped below the composed member count, the read STILL +# resolves positively -- the origin member serve reads its own +# pg_multixact directly, independent of the requester overlay (which +# never covers updater multis anyway, IN-12). # -# The positive-resolution legs (foreign updater multi resolves through -# the member overlay) live in t/357, whose floor legs turned positive -# with this deliverable. +# The floor->positive legs for foreign updater multis (member serve) also +# live in t/357, whose L2/L3 legs turned positive with this deliverable; +# the fail-closed direction is covered by t/357 L5 (requester ask GUC off) +# and the served-resolver unit truth table. The dedicated origin-refuse +# e2e leg (cluster-lms-undo-fetch is PGC_POSTMASTER, must be boot-armed -- +# t/346 idiom) is a chaos-harness follow-up. # -# Spec: spec-7.1-cross-instance-positive-interread.md (D3-a) +# Spec: spec-7.1-cross-instance-positive-interread.md (D3-a derivation + +# D3-b origin member-verdict serve) # # Author: SqlRush # @@ -123,6 +128,22 @@ sub bq return $ok; } +# Cross-node positive reads converge once node1's clock observes the origin +# commit_scn (a lagging snapshot may briefly still see the pre-update row -- +# correct MVCC, not a false-visible); retry to the expected answer. +sub read_converge +{ + my ($node, $sql, $want, $tries) = @_; + my ($rc, $out, $err) = (1, '', ''); + for my $i (1 .. $tries) + { + ($rc, $out, $err) = $node->psql('postgres', $sql, timeout => 15); + return ($rc, $out, $err) if $rc == 0 && $out eq $want; + usleep(500_000); + } + return ($rc, $out, $err); +} + # One lock-only multixact per round on $node against $table (two # concurrent FOR SHARE holders compose share+share). Two persistent # background sessions; a fresh pair every 64 rounds keeps the psql @@ -276,14 +297,15 @@ sub burn_multis } eval { $bgu->quit }; + # D3-b: capping the overlay no longer fails closed. The origin member + # serve reads its OWN pg_multixact directly (independent of the requester + # overlay -- which never covers updater multis anyway, IN-12), so node1 + # resolves positively and converges to the committed update. This + # deterministically exercises the D3-b origin-serve path. my ($rc, $out, $err) = - $node1->psql('postgres', 'SELECT aid, v FROM mxgw_g5 WHERE aid = 17', timeout => 15); - isnt($rc, 0, 'G5 remote read of the unemitted updater multi fails closed'); - like( - $err, - qr/multixact member overlay miss|TT status unknown for deleting xmax/, - 'G5 clean overlay-miss fail-closed message'); - unlike($out, qr/17\|0/, 'G5 the superseded version is NEVER returned'); + read_converge($node1, 'SELECT aid, v FROM mxgw_g5 WHERE aid = 17', '17|500', 20); + is($rc, 0, 'G5 overlay-capped miss resolves positively via the origin member serve'); + is($out, '17|500', 'G5 node1 converges to the committed update (origin serve, not overlay)'); $node0->append_conf('postgresql.conf', 'cluster.multixact_member_overlay_max_members = 32'); $node0->reload; From 4c05afc61357dd85caf04258061713be8cc596ce Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 21:56:45 +0800 Subject: [PATCH 18/29] test(cluster): spec-7.1 D3-b origin-refuse negative leg (t/360) Prove the FAIL-CLOSED direction of the D3-b member-serve path: node1 asks the origin for a foreign updater-multi verdict and the origin DECLINES to serve, so the read keeps 53R9C and never returns the superseded row. Distinct from t/357 L5 (requester ask GUC off -> node1 never asks): here node1 DOES ask and the ORIGIN refuses. The refusal is forced by turning cluster.crossnode_runtime_visibility off on node0 only (asymmetric, the t/346 L4 idiom -- a PGC_SIGHUP reload, unlike the PGC_POSTMASTER injection point), which makes node0's LMS submit gate reply an immediate DENIED. This exercises the requester fetch's DENIED handling and the reader's UNKNOWN -> 53R9C. - node1 read fails closed with a clean cluster message (the origin refuses both the member-xmax ask and the new version's xmin verdict ask), never a raw native multixact id-space error, never the superseded 3|0 row. - Control: node0 (the creator, derived-own) still reads 3|900 natively, proving the multi is well-formed and the fail-closed is the peer-serve path. Verified: 3x PASS (10/10) on the 2-node harness. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, negative leg) --- .../360_cluster_multi_member_serve_refuse.pl | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl diff --git a/src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl b/src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl new file mode 100644 index 0000000000..0fd766c789 --- /dev/null +++ b/src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl @@ -0,0 +1,174 @@ +#------------------------------------------------------------------------- +# +# 360_cluster_multi_member_serve_refuse.pl +# +# spec-7.1 D3-b origin-refuse negative leg (Rule 8.A). The positive +# member-serve path (a foreign updater multixact resolved by asking the +# origin) turns t/357 L2/L3 positive; this test proves the FAIL-CLOSED +# direction of the SAME path: when the origin DECLINES to serve the member +# verdict, the requester must keep 53R9C and NEVER return the superseded +# row (no false-visible, no native alias). +# +# Unlike t/357 L5 (which turns the REQUESTER's ask GUC off so node1 never +# asks), here node1 DOES ask and the ORIGIN refuses: cluster.crossnode_ +# runtime_visibility is turned off on node0 ONLY (asymmetric, the t/346 L4 +# idiom), so node0's LMS submit gate refuses the parked request and replies +# an immediate DENIED -- exercising the requester fetch's DENIED handling +# and the reader's UNKNOWN -> 53R9C. (The positive counterpart is t/357.) +# +# Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, §4 negative leg) +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl +#------------------------------------------------------------------------- + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'mmrefuse', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 5', + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'node0 sees node1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'node1 sees node0'); +my ($node0, $node1) = ($pair->node0, $pair->node1); + +sub write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 10) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +# Mirrored coincident CREATE (shared-data harness discipline). +sub mirrored_coincident_create +{ + my ($name, $ddl) = @_; + my ($q0, $q1) = ('', ''); + for my $attempt (1 .. 8) + { + return 0 unless write_retry($node0, $ddl); + return 0 unless write_retry($node1, $ddl); + $q0 = $node0->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + $q1 = $node1->safe_psql('postgres', qq{SELECT pg_relation_filepath('$name')}); + return 1 if $q0 eq $q1; + my ($n0) = $q0 =~ /(\d+)$/; + my ($n1) = $q1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + return 0 + unless write_retry($lag, + "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + return 0 unless write_retry($node0, "DROP TABLE $name"); + return 0 unless write_retry($node1, "DROP TABLE $name"); + } + return 0; +} + +sub bq +{ + my ($bg, $tag, $sql) = @_; + my $ok = eval { $bg->query_safe($sql); 1 }; + diag("step $tag FAILED: $@") if !$ok; + return $ok; +} + +ok(mirrored_coincident_create('mmr_t', 'CREATE TABLE mmr_t (aid int, v int)'), + 'mmr_t relfilepath coincidence holds'); +ok(write_retry($node0, 'INSERT INTO mmr_t SELECT g, 0 FROM generate_series(1, 8) g'), 'seeded'); + +# Compose a committed foreign updater multixact on node0 (KEY SHARE locker + +# non-key UPDATE); this is node0's own write path, independent of any serve. +{ + my $bgl = $node0->background_psql('postgres', timeout => 25); + my $bgu = $node0->background_psql('postgres', timeout => 25); + bq($bgl, 'C-l-1', 'BEGIN'); + bq($bgl, 'C-l-2', 'SELECT v FROM mmr_t WHERE aid = 3 FOR KEY SHARE'); + bq($bgu, 'C-u-1', 'BEGIN'); + bq($bgu, 'C-u-2', 'UPDATE mmr_t SET v = v + 900 WHERE aid = 3'); + bq($bgu, 'C-u-3', 'COMMIT'); + bq($bgl, 'C-l-3', 'COMMIT'); + eval { $bgl->quit }; + eval { $bgu->quit }; +} + +# Control: node0 is the creator (derived-own) -- it decodes natively and sees +# the committed update regardless of the serve gate. Read it BEFORE refusing +# so the read never rides the origin serve. +{ + my ($rc, $out, $err); + for my $i (1 .. 20) + { + ($rc, $out, $err) = + $node0->psql('postgres', 'SELECT aid, v FROM mmr_t WHERE aid = 3', timeout => 15); + last if $rc == 0 && $out eq '3|900'; + usleep(500_000); + } + is($rc, 0, 'creator-local read answers (derived own -> native)'); + is($out, '3|900', 'creator sees the committed update'); +} + +# ------------------------------------------------------------------ +# Turn the runtime-visibility serve gate OFF on node0 (the ORIGIN) only: +# its LMS submit gate now refuses the parked request and replies an immediate +# DENIED. node1 (ask GUC still on) asks, is refused, and fails closed 53R9C +# -- never returning the superseded 3|0 row. +# ------------------------------------------------------------------ +{ + $node0->safe_psql('postgres', 'ALTER SYSTEM SET cluster.crossnode_runtime_visibility = off'); + $node0->safe_psql('postgres', 'SELECT pg_reload_conf()'); + usleep(1_000_000); + + my ($rc, $out, $err); + for my $i (1 .. 20) + { + ($rc, $out, $err) = + $node1->psql('postgres', 'SELECT aid, v FROM mmr_t WHERE aid = 3', timeout => 15); + last if $rc != 0; # origin refuse -> fail closed + usleep(500_000); + } + isnt($rc, 0, 'origin serve refused: foreign updater multi read fails closed on the peer'); + # node1 fails closed on the origin refusal -- either the multi-xmax member + # ask or the new version's xmin verdict ask (both refused with the origin's + # GUC off). Accept any CLEAN cluster fail-closed message; the point is it + # is a cluster 53R97/53R9C refusal, NOT a raw native multixact id-space error. + like( + $err, + qr/cluster TT slot recycled|cluster TT status unknown|multixact member overlay miss|TT status unknown for deleting xmax|cannot be attributed to an origin node/, + 'clean cluster fail-closed message (origin refuse, never a raw native multixact error)'); + unlike($err, qr/has not been created yet/, 'native id-space error never surfaces'); + unlike($out, qr/3\|0/, 'the superseded version is NEVER returned on a refuse (no false-visible)'); + + $node0->safe_psql('postgres', 'ALTER SYSTEM SET cluster.crossnode_runtime_visibility = on'); + $node0->safe_psql('postgres', 'SELECT pg_reload_conf()'); +} + +$pair->stop_pair; +done_testing(); From efce3641d5b616c34a35856afd450e91c10e19e5 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 22:04:38 +0800 Subject: [PATCH 19/29] test(cluster): spec-7.1 D4 census -- snapshot the D3-b member-serve counters Add the D3-b requester/origin counters to the scratch census driver's key set so CENSUS_PHASE=MULTI attributes the multixact fail-closed legs before/after: vis53r97_leg_multi_member_serve_ask/hit + cr_server_multi_verdict_served/denied. CENSUS_PHASE=MULTI result (this build): probes B/E/F all resolve positively to the correct v=100 (probe F -- node1 owning aliasing multi ids -- is the pre-D0 silent-wrong direction, now correct AND positive). Requester ask=hit=5 (unprovable residue 0), origin served=5 denied=0; the pre-D3-b 5x vis53r97_leg_multi_unresolvable are gone (transferred to member_serve_hit). The section-3.7 before->after attribution closes exactly. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b, D4) --- src/test/cluster_tap/t/990_scratch_71_census.pl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/cluster_tap/t/990_scratch_71_census.pl b/src/test/cluster_tap/t/990_scratch_71_census.pl index bd8aa2f2d7..f7babce4a1 100644 --- a/src/test/cluster_tap/t/990_scratch_71_census.pl +++ b/src/test/cluster_tap/t/990_scratch_71_census.pl @@ -61,6 +61,10 @@ sub write_retry vis53r97_leg_covers_refuse_count vis53r97_leg_multi_unresolvable_count vis53r97_leg_xmax_unprovable_count + vis53r97_leg_multi_member_serve_ask_count + vis53r97_leg_multi_member_serve_hit_count + cr_server_multi_verdict_served_count + cr_server_multi_verdict_denied_count rtvis_underivable_failclosed_count rtvis_resolve_committed_count rtvis_resolve_aborted_count From e0c23096821de378528a69d4361c8a73f41a2a7f Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 22:39:07 +0800 Subject: [PATCH 20/29] fix(cluster): spec-7.1 D3-b -- validate multi-verdict on the stable copy (review P2) The D3-b multi-verdict consume in cluster_gcs_block_undo_multi_verdict_ fetch_and_wait ran the checksum, cluster_vis_undo_multi_verdict_page_usable validation, and the per-member Lamport-observe loop directly on the volatile reply slot (slot->reply_block_data), then memcpy'd it to the caller's page. The consume holds no blk->lock and the reply-ingest handler has no !reply_received guard, so a spec-2.34 retransmit landing in that window could overwrite the slot after it validated -- handing a torn, unvalidated nmembers (up to 65535) to the downstream variable-length member loop and reading past the caller's BLCKSZ page (OOB read; the torn member verdicts could also feed the resolver). This is the first variable-length wire-count consumer, so it is the first place the pre-existing unlocked-consume race becomes an OOB. Validate the STABLE local copy instead: memcpy first, then checksum/validate/ observe on page_out. A torn copy fails the checksum (block-then-header read order), and nmembers is bounded to [2, MAX] before any member is dereferenced. Add a belt re-check of the [2, MAX] bound in cluster_multixact_remote_xmax_ ask_origin, and tighten the validator floor from == 0 to < 2 to match the wire NO_MEMBERS contract (a real multi has >= 2 members) so the loop is provably bounded. Rule 15/16. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b) --- src/backend/cluster/cluster_gcs_block.c | 24 +++++++++++++++---- src/backend/cluster/cluster_multixact.c | 14 +++++++++-- .../cluster_runtime_visibility_policy.c | 5 ++-- .../test_cluster_runtime_visibility.c | 5 +++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index a2ad10aef4..be446116e2 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -2848,17 +2848,33 @@ cluster_gcs_block_undo_multi_verdict_fetch_and_wait(int32 origin_node, MultiXact if (got_reply && slot->reply_header.status == (uint8)GCS_BLOCK_REPLY_UNDO_MULTI_VERDICT_RESULT && slot->reply_undo_trailer_valid) { - uint32 expected = slot->reply_header.checksum; - uint32 got = gcs_block_compute_checksum(slot->reply_block_data); + uint32 expected; + uint32 got; + + /* + * PGRAC (spec-7.1 D3-b hardening, Rule 15/16): snapshot the volatile + * reply slot into the caller's STABLE page BEFORE checksum / + * validation / observe. This consume runs without blk->lock, so a + * spec-2.34 retransmit that overwrites reply_block_data between a + * validate-on-slot and the copy would hand a torn, unvalidated + * nmembers to the variable-length member loop downstream (OOB read). + * Validating the LOCAL copy makes the bytes we act on exactly the + * bytes we prove usable: a torn copy fails the checksum (fail-closed), + * and nmembers is bounded to [2, MAX] before any member is read. The + * checksum is read block-then-header, so an overwrite that lands mid- + * snapshot fails the compare rather than passing on a mixed pair. + */ + memcpy(page_out, slot->reply_block_data, GCS_BLOCK_DATA_SIZE); + expected = slot->reply_header.checksum; + got = gcs_block_compute_checksum(page_out); if (expected == got) { const ClusterGcsUndoMultiVerdictPage *v - = (const ClusterGcsUndoMultiVerdictPage *)slot->reply_block_data; + = (const ClusterGcsUndoMultiVerdictPage *)page_out; if (cluster_vis_undo_multi_verdict_page_usable(v, mxid)) { uint16 i; - memcpy(page_out, slot->reply_block_data, GCS_BLOCK_DATA_SIZE); auth_out->origin_epoch = slot->reply_header.epoch; auth_out->live_hwm_lsn = (XLogRecPtr)slot->reply_header.page_lsn; auth_out->tt_generation = slot->reply_undo_tt_generation; diff --git a/src/backend/cluster/cluster_multixact.c b/src/backend/cluster/cluster_multixact.c index 9cf03a4fd9..a62a975ebb 100644 --- a/src/backend/cluster/cluster_multixact.c +++ b/src/backend/cluster/cluster_multixact.c @@ -378,10 +378,20 @@ cluster_multixact_remote_xmax_ask_origin(uint16 origin_slot, MultiXactId mxid, S page_buf.data, &auth)) return CLUSTER_VISIBILITY_UNKNOWN; /* DENIED / timeout / invalid -> 53R97 */ - /* The page is structurally validated (SERVED, nmembers in [1, MAX], every - * member consistent) by the fetch; map it to served terminals + resolve. */ + /* The page is structurally validated (SERVED, nmembers in [2, MAX], every + * member consistent) by the fetch, which validates the STABLE local copy it + * returns in page_buf (not the volatile reply slot). Map it to served + * terminals + resolve. */ page = (const ClusterGcsUndoMultiVerdictPage *)page_buf.data; n = page->nmembers; + /* + * Belt (spec-7.1 D3-b hardening, Rule 15): re-assert the [2, MAX] bound + * before the variable-length member loop so a future regression in the + * fetch validation can never turn an over-range wire count into an + * out-of-bounds read of page_buf. + */ + if (n < 2 || n > CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS) + return CLUSTER_VISIBILITY_UNKNOWN; served = (ClusterMultiXactServedMember *)palloc0((Size)n * sizeof(ClusterMultiXactServedMember)); for (i = 0; i < n; i++) { diff --git a/src/backend/cluster/cluster_runtime_visibility_policy.c b/src/backend/cluster/cluster_runtime_visibility_policy.c index d00589b312..1c8467ccd8 100644 --- a/src/backend/cluster/cluster_runtime_visibility_policy.c +++ b/src/backend/cluster/cluster_runtime_visibility_policy.c @@ -260,8 +260,9 @@ cluster_vis_undo_multi_verdict_page_usable(const struct ClusterGcsUndoMultiVerdi return false; /* A real multi always has members; the origin refuses < 2 as NO_MEMBERS. - * Reject 0 (nothing to consume) and anything past the wire capacity. */ - if (v->nmembers == 0 || v->nmembers > CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS) + * Reject < 2 (not a real multi) and anything past the wire capacity, so the + * requester's variable-length member loop is bounded to [2, MAX]. */ + if (v->nmembers < 2 || v->nmembers > CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS) return false; for (i = 0; i < sizeof(v->reserved_0); i++) diff --git a/src/test/cluster_unit/test_cluster_runtime_visibility.c b/src/test/cluster_unit/test_cluster_runtime_visibility.c index 4d536408ea..d0e0987dfc 100644 --- a/src/test/cluster_unit/test_cluster_runtime_visibility.c +++ b/src/test/cluster_unit/test_cluster_runtime_visibility.c @@ -457,9 +457,12 @@ UT_TEST(test_undo_multi_verdict_page_usable) UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); v->status = (uint8)CLUSTER_GCS_UNDO_MULTI_VERDICT_SERVED; - /* nmembers bounds: 0 refused, MAX ok (below), MAX+1 refused. */ + /* nmembers bounds: < 2 refused (a real multi has >= 2 members; the origin + * ships NO_MEMBERS for < 2), MAX ok (below), MAX+1 refused. */ v->nmembers = 0; UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); + v->nmembers = 1; + UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); v->nmembers = CLUSTER_GCS_UNDO_MULTI_VERDICT_MAX_MEMBERS + 1; UT_ASSERT_EQ(cluster_vis_undo_multi_verdict_page_usable(v, asked), false); v->nmembers = 2; From be499b2c079d9f8ba2c70b78c3e38b5a1449ec90 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 22:39:07 +0800 Subject: [PATCH 21/29] fix(cluster): spec-7.1 D3-b -- count member-level INVALID_SCN->ABORTED upgrade (review) lms_undo_multi_verdict_serve treated LMS_OWN_XID_PROVEN_UPGRADE (the D1-serve CLOG-abort upgrade) identically to LMS_OWN_XID_PROVEN and did not bump cluster_vis53r97_note_live_upgrade_hit(), whereas the single-verdict serve does. The verdict is correct (ABORTED); this only restores census parity so a multixact-member upgrade is reflected in the counter. Spec: spec-7.1-cross-instance-positive-interread.md (D3-b) --- src/backend/cluster/cluster_cr_server.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index 6f7971d844..a7c7bad8be 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -747,7 +747,9 @@ lms_undo_multi_verdict_serve(ClusterLmsCrSlot *slot) switch (lms_resolve_own_xid_verdict(member_xid, &out->verdict, &out->commit_scn, &out->horizon_scn, &out->wrap)) { case LMS_OWN_XID_PROVEN: + break; case LMS_OWN_XID_PROVEN_UPGRADE: + cluster_vis53r97_note_live_upgrade_hit(); /* spec-7.1 D1 serve upgrade (multi member) */ break; default: pfree(members); From 275c9a61f542932e753234e9e859275d9a8ae7fb Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 23:42:45 +0800 Subject: [PATCH 22/29] fix(cluster): spec-7.1 integration -- retarget dropped-D2 covers symbols to main's authority_scn/read_scn model The spec-7.1 -> main rebase drops D2 (covers->SCN) as fully superseded by main's 7.1a covers migration: authority_scn / demand_scn=read_scn is strictly sounder for read_scn consumption than 7.1's page_block_scn gate (when read_scn > page_block_scn the page-anchored gate could leave a commit window unscanned; demand=read_scn closes exactly that). Later D1/D3-b commits, auto-merged without conflict, still referenced D2's parallel symbols; retarget them to main's names so the integrated tree compiles and keeps main's proven covers semantics: - cluster_cr_server.c multi-verdict serve co-sample: live_hwm_scn -> authority_scn - cluster_gcs_block.c multi-verdict reply consume: reply_undo_live_hwm_scn -> reply_undo_authority_scn (the unified undo-trailer reception parse already populates reply_undo_authority_scn for any undo-trailer reply, incl the multi-verdict, so the value is present and correct) - heapam_visibility.c D1-req xmin overlay-miss: drop the extra page_block_scn arg to cluster_runtime_visibility_try_resolve_remote (main's 7-arg fn demands the snapshot read_scn), and refresh the stale (D2) covers comment Locally verified: build (cassert), cluster_unit 162/162, PG core regress 219/219, clang-format 0, comment-headers 0, TAP t/212 t/346 t/357 t/359 t/360 t/365. Spec: spec-7.1-cross-instance-positive-interread.md (main integration) --- src/backend/access/heap/heapam_visibility.c | 10 +++++----- src/backend/cluster/cluster_cr_server.c | 4 ++-- src/backend/cluster/cluster_gcs_block.c | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c index 7524856f13..696cfd0c08 100644 --- a/src/backend/access/heap/heapam_visibility.c +++ b/src/backend/access/heap/heapam_visibility.c @@ -1971,21 +1971,21 @@ HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot, Buffer buffer) * held across this wire (Impl note IN-9 锁上下文). positive * proof only: any refuse / UNKNOWN / covers-miss falls through * to the 53R97 below (Rule 8.A; direction unchanged). The - * covers SCN-domain gate (D2) is applied inside - * try_resolve_remote, so a page version the origin's window - * does not cover still fails closed. + * covers SCN-domain gate (spec-7.1a) is applied inside + * try_resolve_remote against the snapshot read_scn, so an + * origin authority that does not provably cover read_scn still + * fails closed. */ if (cluster_crossnode_runtime_visibility && res.ref.undo_segment_id != 0) { int d1_origin = cluster_xid_origin_slot(raw_xmin); bool d1_committed = false; bool d1_is_bound = false; SCN d1_scn = InvalidScn; - SCN d1_block_scn = ((PageHeader)BufferGetPage(buffer))->pd_block_scn; if (d1_origin >= 0 && d1_origin != cluster_node_id) { cluster_vis53r97_note_xmin_overlay_verdict_ask(); if (cluster_runtime_visibility_try_resolve_remote( - d1_origin, (uint32)res.ref.undo_segment_id, raw_xmin, d1_block_scn, + d1_origin, (uint32)res.ref.undo_segment_id, raw_xmin, snapshot->read_scn, &d1_committed, &d1_scn, &d1_is_bound)) { if (!d1_committed) { /* origin proved ABORTED -> this xmin was never diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index a7c7bad8be..3585e16179 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -55,7 +55,7 @@ #include "cluster/cluster_ic_router.h" /* cluster_ic_send_envelope */ #include "cluster/cluster_inject.h" #include "cluster/cluster_lmon.h" /* PGRAC: spec-7.2 D1 READY-publish wakeup */ -#include "cluster/cluster_scn.h" /* cluster_scn_current (spec-7.1a authority_scn co-sample) */ +#include "cluster/cluster_scn.h" /* cluster_scn_current (spec-7.1a authority_scn co-sample) */ #include "cluster/cluster_shmem.h" #include "cluster/cluster_tt_durable.h" /* resolve_by_xid (D-i4 complete scan) */ #include "cluster/cluster_tt_slot.h" /* max_recycle_horizon (D-i4 bound) */ @@ -705,7 +705,7 @@ lms_undo_multi_verdict_serve(ClusterLmsCrSlot *slot) slot->undo_auth.origin_epoch = cluster_epoch_get_current(); slot->undo_auth.live_hwm_lsn = GetFlushRecPtr(NULL); slot->undo_auth.tt_generation = cluster_undo_tt_retention_rollover_count(); - slot->undo_auth.live_hwm_scn = cluster_scn_current(); + slot->undo_auth.authority_scn = cluster_scn_current(); nmembers = GetMultiXactIdMembers(mxid, &members, false, false); if (nmembers < 2 || members == NULL) { diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index be446116e2..e8cbabf038 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -2793,7 +2793,7 @@ cluster_gcs_block_undo_multi_verdict_fetch_and_wait(int32 origin_node, MultiXact cluster_sf_dep_vec_reset(&slot->reply_sf_dep_vec); slot->reply_undo_trailer_valid = false; slot->reply_undo_tt_generation = 0; - slot->reply_undo_live_hwm_scn = 0; + slot->reply_undo_authority_scn = 0; slot->request_epoch = cluster_epoch_get_current(); slot->expected_master_node = cluster_node_id; slot->stale = false; @@ -2878,8 +2878,8 @@ cluster_gcs_block_undo_multi_verdict_fetch_and_wait(int32 origin_node, MultiXact auth_out->origin_epoch = slot->reply_header.epoch; auth_out->live_hwm_lsn = (XLogRecPtr)slot->reply_header.page_lsn; auth_out->tt_generation = slot->reply_undo_tt_generation; - auth_out->live_hwm_scn = (SCN)slot->reply_undo_live_hwm_scn; - cluster_scn_observe(auth_out->live_hwm_scn); + auth_out->authority_scn = (SCN)slot->reply_undo_authority_scn; + cluster_scn_observe(auth_out->authority_scn); /* AD-008: Lamport-observe every member SCN that crossed the * wire so a below-horizon bound is admissible next snapshot. */ for (i = 0; i < v->nmembers; i++) { From 5ae2c711ed1c8a9f351b512a6d95d5dc5da3e176 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 07:29:44 +0800 Subject: [PATCH 23/29] fix(cluster): reword SCN-semantics comment to satisfy scn-cmp-gate (spec-1.15 Q8/L4) The multixact polarity-hotfix banner described the read_scn ordering as "(commit_scn <= read_scn)"; the fast-gate scn-cmp-gate regex flags any _scn identifier followed by a comparison operator, including inside comments (its comment-exclusion pattern expects a filename:line prefix that grep -n does not emit). Reword to "(commit_scn at/before read_scn)" -- no operator, identical meaning, zero behavior change. Surfaced now because this is the branch's first fast-gate run. Spec: spec-7.1-cross-instance-positive-interread.md (main integration) --- src/backend/cluster/cluster_multixact.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/cluster/cluster_multixact.c b/src/backend/cluster/cluster_multixact.c index a62a975ebb..aa53ab4bf3 100644 --- a/src/backend/cluster/cluster_multixact.c +++ b/src/backend/cluster/cluster_multixact.c @@ -315,7 +315,7 @@ cluster_multixact_resolve_visibility(const ClusterMultiXactMemberOverlayResult * * decision through cluster_vis_cr_xmax_verdict -- the polarity * SSOT shared with the single-xmax path * (cluster_visibility_verdict.c). A committed updater whose - * delete is VISIBLE at read_scn (commit_scn <= read_scn) hides + * delete is VISIBLE at read_scn (commit_scn at/before read_scn) hides * the tuple (CVV_INVISIBLE); one committed AFTER the snapshot * leaves the row live (CVV_VISIBLE). The prior inline compare * had this INVERTED -- latent until D3-b's member serve first From 7845b6b7b923a02353cdced47afc30069f7c4814 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 07:34:35 +0800 Subject: [PATCH 24/29] test(cluster): spec-7.1 t/030 -- bump injection-point baseline 161->162 (D3-a mxid-halfspace) t/030 M1/M5 still asserted 161 injection points (161x2=322 sub-keys). The spec-7.1 D3-a mxid-stripe deliverable registered cluster-mxid-halfspace-hard-limit, taking the registry to 162; t/015 and t/017 were already bumped to 162 by that work, but t/030 was the missed straggler (surfaced on the branch's first fast-gate run). Bump M1 161->162 and M5 322->324 and add the spec-7.1 D3-a entry, matching the live registry and the t/015 / t/017 descriptive lists. Spec: spec-7.1-cross-instance-positive-interread.md (main integration) --- src/test/cluster_tap/t/030_acceptance.pl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/cluster_tap/t/030_acceptance.pl b/src/test/cluster_tap/t/030_acceptance.pl index 55de4798c9..32346eb1e5 100644 --- a/src/test/cluster_tap/t/030_acceptance.pl +++ b/src/test/cluster_tap/t/030_acceptance.pl @@ -330,7 +330,7 @@ is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', 'M1 161 injection points (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.18 +7 cluster-node-remove-*; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-2.41 +1 gcs-block-stale-ship; spec-5.7 D6 +1 ko-peer-skip-ack; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', 'M1 162 injection points (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.18 +7 cluster-node-remove-*; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-2.41 +1 gcs-block-stale-ship; spec-5.7 D6 +1 ko-peer-skip-ack; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); is($node->safe_psql('postgres', q{SELECT string_agg(name, ',' ORDER BY name) FROM pg_stat_cluster_injections WHERE name LIKE 'cluster-init-%'}), @@ -360,8 +360,8 @@ 'postgres', q{SELECT count(DISTINCT key) FROM pg_cluster_state WHERE category='inject' AND (key LIKE '%.fault_type' OR key LIKE '%.hits')} - ) eq '322', - 'M5 inject category has 161×2 = 322 sub-keys (.fault_type + .hits; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + ) eq '324', + 'M5 inject category has 162×2 = 324 sub-keys (.fault_type + .hits; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); is($node->get_cluster_state_value('inject', 'armed_count'), '0', 'M6 inject.armed_count starts at 0 in fresh backend'); From b05fbde94850db72cc75e460f3f0e9c36855e32a Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 09:13:21 +0800 Subject: [PATCH 25/29] test(cluster): spec-7.1 review F4 -- restore the served-resolver unit test wiring The rebase's cluster_unit Makefile conflict resolution (checkout --ours + re-add to the filter-out list) silently dropped test_cluster_multixact_served from TESTS and lost its explicit link rule -- the same failure mode the earlier test_cluster_mxid_stripe restore fixed. The 14 polarity / 8.A truth-table cases for the pure served-verdict combination resolver were therefore not built or run by `make check` (the reported 162/162 did not include them). Restore the TESTS entry and the standalone link rule (cluster_multixact_served.o + cluster_visibility_verdict.o) verbatim from the pre-rebase branch. Verified: the binary builds and passes 14/14; full cluster_unit check now runs 163 binaries. Spec: spec-7.1-cross-instance-positive-interread.md (integration review F4, P1) --- src/test/cluster_unit/Makefile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index b8bb906e78..e9df8745a2 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -46,7 +46,7 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_tt_slot_allocator test_cluster_itl_reader_real_triple \ test_cluster_itl_cleanout test_cluster_visibility_inject test_cluster_itl_cleanout_perf \ test_cluster_heap_lock_tuple test_cluster_perf_gates \ - test_cluster_subtrans test_cluster_multixact test_cluster_mxid_stripe \ + test_cluster_subtrans test_cluster_multixact test_cluster_multixact_served test_cluster_mxid_stripe \ test_cluster_undo_format \ test_cluster_undo_record test_cluster_undo_lifecycle test_cluster_cr test_cluster_cr_cache test_cluster_cr_key test_cluster_cr_pool test_cluster_cr_lifecycle test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_resolver_cache test_cluster_cr_coordinator test_cluster_tt_durable test_cluster_terminal_authority test_cluster_sf_dep \ test_cluster_retention test_cluster_undo_cleaner test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc \ @@ -1867,3 +1867,17 @@ test_cluster_mxid_stripe: test_cluster_mxid_stripe.c unit_test.h \ $(CC) $(CFLAGS) $(CPPFLAGS) $< \ $(CLUSTER_MXID_STRIPE_O) \ $(top_builddir)/src/port/libpgport_srv.a -o $@ + +# spec-7.1 D3-b (A1): test_cluster_multixact_served — visibility truth table +# for the pure served-verdict combination resolver. Links +# cluster_multixact_served.o standalone; the test provides a local scn_time_cmp +# stub (as test_cluster_visibility_decide_scn) so cluster_scn.o's PG-core deps +# are not dragged in. +CLUSTER_MULTIXACT_SERVED_O = $(top_builddir)/src/backend/cluster/cluster_multixact_served.o +test_cluster_multixact_served: test_cluster_multixact_served.c unit_test.h \ + $(CLUSTER_MULTIXACT_SERVED_O) $(CLUSTER_VIS_VERDICT_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_MULTIXACT_SERVED_O) \ + $(CLUSTER_VIS_VERDICT_O) \ + $(top_builddir)/src/common/libpgcommon_srv.a \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ From 83faa8226cda99a2a353604dda757343840fb5e6 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 09:13:21 +0800 Subject: [PATCH 26/29] fix(cluster): spec-7.1 review F3 -- reserve LMS work slots as FILLING before publishing PENDING Every LMS work-slot submitter (CR construct, undo-TT fetch, undo verdict, and the new undo multi-verdict) CAS'd the slot directly FREE -> PENDING and only then wrote the request fields, with a redundant trailing PENDING store after the write barrier. The LMS drain CAS-acquires PENDING slots, so a drain racing the submitter could (a) serve a half-written request and (b) have its BUSY/READY overwritten by the submitter's trailing store -- mixed requests, double serves, lost replies, or spurious timeouts. Introduce the producer-only CLUSTER_LMS_CR_FILLING state: the submit CAS lands on FILLING (invisible to the drain), the request fields are written, and PENDING is published once, after the existing write barrier -- pairing with the drain's read barrier. A producer killed between reserve and publish leaks the slot in FILLING, which is fail-closed (submits degrade to false; the requester keeps 53R97), never a garbage serve. All four submitters unified. Spec: spec-7.1-cross-instance-positive-interread.md (integration review F3, P1) --- src/backend/cluster/cluster_cr_server.c | 27 +++++++++++++++++++------ src/include/cluster/cluster_cr_server.h | 21 ++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index 3585e16179..54e6b39d84 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -20,8 +20,11 @@ * LMON owns the IC connections. * * The slot state word is an atomic; each transition has exactly one - * writer (LMON: FREE→PENDING, READY→FREE; LMS: PENDING→BUSY→READY), - * so no lock is needed beyond the CAS on FREE→PENDING. + * writer (submitter: FREE→FILLING→PENDING; LMS: PENDING→BUSY→READY; + * LMON ship: READY→FREE), so no lock is needed beyond the CAS on + * FREE→FILLING. FILLING is the producer-only reservation: PENDING + * is published only after the request fields + a write barrier, so + * the LMS drain can never acquire a half-written slot. * * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -160,7 +163,10 @@ cluster_lms_cr_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) + /* Reserve producer-only FILLING first (spec-7.1 integration review): + * landing directly on PENDING would let the LMS drain acquire the + * slot before the request fields below are written. */ + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_FILLING)) continue; slot->tag = fwd->tag; @@ -210,7 +216,10 @@ cluster_lms_undo_fetch_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) + /* Reserve producer-only FILLING first (spec-7.1 integration review): + * landing directly on PENDING would let the LMS drain acquire the + * slot before the request fields below are written. */ + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_FILLING)) continue; slot->tag = fwd->tag; @@ -271,7 +280,10 @@ cluster_lms_undo_verdict_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) + /* Reserve producer-only FILLING first (spec-7.1 integration review): + * landing directly on PENDING would let the LMS drain acquire the + * slot before the request fields below are written. */ + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_FILLING)) continue; slot->tag = fwd->tag; @@ -341,7 +353,10 @@ cluster_lms_undo_multi_verdict_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) + /* Reserve producer-only FILLING first (spec-7.1 integration review): + * landing directly on PENDING would let the LMS drain acquire the + * slot before the request fields below are written. */ + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_FILLING)) continue; slot->tag = fwd->tag; diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index e729d3f91e..7bab4dec18 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -100,10 +100,20 @@ extern ClusterCrInvalidScnVerdict cluster_cr_server_invalid_scn_verdict(bool clo /* * LMS CR work slots (shmem, embedded in the cluster_lms region). * - * Slot lifecycle: FREE -(LMON submit)-> PENDING -(LMS drain)-> BUSY - * -(LMS result)-> READY -(LMON ship)-> FREE. Single-producer single- - * consumer per direction (LMON dispatch is single-threaded; LMS is one - * process), so an atomic state word per slot is the whole protocol. + * Slot lifecycle: FREE -(submit CAS)-> FILLING -(submit publish)-> + * PENDING -(LMS drain)-> BUSY -(LMS result)-> READY -(LMON ship)-> + * FREE. Single-producer single-consumer per direction (LMON dispatch + * is single-threaded; LMS is one process), so an atomic state word per + * slot is the whole protocol. + * + * PGRAC (spec-7.1 integration review): FILLING is the producer-only + * reservation. The submit CAS must NOT land directly on PENDING: the + * drain CAS-acquires PENDING slots, so a drain racing the submitter's + * field stores would serve garbage, and the submitter's trailing + * publish store would then stomp the LMS's BUSY/READY (double serve / + * lost reply). A producer killed between CAS and publish leaks the + * slot in FILLING — fail-closed (submits degrade to false = requester + * keeps 53R97), never a garbage serve. */ #define CLUSTER_LMS_CR_SLOTS 4 @@ -111,7 +121,8 @@ typedef enum ClusterLmsCrSlotState { CLUSTER_LMS_CR_FREE = 0, CLUSTER_LMS_CR_PENDING = 1, CLUSTER_LMS_CR_BUSY = 2, - CLUSTER_LMS_CR_READY = 3 + CLUSTER_LMS_CR_READY = 3, + CLUSTER_LMS_CR_FILLING = 4 /* producer-reserved; fields not yet published */ } ClusterLmsCrSlotState; /* Work-slot request kind (spec-6.12i extends the wave-b CR-only table). */ From 8429eb98968e8f30959eeff9808eb5de61d90291 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 09:13:21 +0800 Subject: [PATCH 27/29] fix(cluster): spec-7.1 review F1 (P0) -- gate the multi member-verdict consume on the covers window The D3-b requester consumed the origin's served member terminals without running the co-sampled live authority through the covers gate, unlike the single-xid verdict leg (rtvis_try_origin_verdict). An epoch-stale reply (origin remastered or fenced after sampling, D-i3) or an origin authority clock behind the snapshot read_scn (an updater could still commit after the member scan with a commit_scn at/below read_scn) was consumed as conclusive -- on the polarity-sensitive xmax path that can hide or expose the wrong tuple version. Run cluster_vis_live_authority_covers(snap->read_scn, auth) before mapping the members, exactly like the single-verdict consumer: a refusal returns UNKNOWN (the caller keeps the 53R97 fail-closed boundary) and bumps both covers-refuse census counters (the spec-7.1a D6 SCN-refuse counter + the spec-7.1 D0 per-leg counter). The fetch already Lamport-observed every shipped SCN, so a refusal self-heals on the next snapshot. Spec: spec-7.1-cross-instance-positive-interread.md (integration review F1, P0) --- src/backend/cluster/cluster_multixact.c | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/backend/cluster/cluster_multixact.c b/src/backend/cluster/cluster_multixact.c index aa53ab4bf3..ca700dc563 100644 --- a/src/backend/cluster/cluster_multixact.c +++ b/src/backend/cluster/cluster_multixact.c @@ -37,8 +37,9 @@ #include "utils/hsearch.h" #include "utils/timestamp.h" -#include "cluster/cluster_cr.h" /* vis53r97 multi_member_serve counters (D3-b) */ -#include "cluster/cluster_cr_server.h" /* undo_multi_verdict_fetch_and_wait (D3-b) */ +#include "cluster/cluster_cr.h" /* vis53r97 multi_member_serve counters (D3-b) */ +#include "cluster/cluster_cr_server.h" /* undo_multi_verdict_fetch_and_wait (D3-b) */ +#include "cluster/cluster_runtime_visibility.h" /* live-authority covers gate (D3-b review) */ #include "cluster/cluster_elog.h" #include "cluster/cluster_epoch.h" #include "cluster/cluster_guc.h" @@ -378,6 +379,24 @@ cluster_multixact_remote_xmax_ask_origin(uint16 origin_slot, MultiXactId mxid, S page_buf.data, &auth)) return CLUSTER_VISIBILITY_UNKNOWN; /* DENIED / timeout / invalid -> 53R97 */ + /* + * PGRAC (spec-7.1 integration review, P0): the served member terminals are + * only conclusive for THIS snapshot when the co-sampled live authority + * covers the demand — same gate, same demand (the snapshot read_scn) as + * the single-xid verdict leg (cluster_runtime_visibility.c + * rtvis_try_origin_verdict). Without it an epoch-stale reply (the origin + * may have been remastered/fenced since sampling, D-i3) or an origin clock + * behind read_scn (a member could still commit after the scan with a + * commit_scn at/below read_scn) would be consumed as conclusive. The + * fetch already Lamport-observed every shipped SCN, so a refusal + * self-heals on the next snapshot (Rule 8.A: refuse -> UNKNOWN -> 53R97). + */ + if (!cluster_vis_live_authority_covers(snap->read_scn, auth)) { + cluster_vis_bump_covers_scn_refuse_count(); /* spec-7.1a D6 */ + cluster_vis53r97_note_covers_refuse(); /* spec-7.1 D0 census */ + return CLUSTER_VISIBILITY_UNKNOWN; + } + /* The page is structurally validated (SERVED, nmembers in [2, MAX], every * member consistent) by the fetch, which validates the STABLE local copy it * returns in page_buf (not the volatile reply slot). Map it to served From aaf17f62f2b07903f3393b2bb53b72bb599b1d55 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 09:13:21 +0800 Subject: [PATCH 28/29] fix(cluster): spec-7.1 review F2 (P0) -- poison the mxid stripe face on conflicting "PGXM" evidence The boot scan treated an absent OR corrupt "PGXM" mxid activation extension as mxid_floor = 0 while the PGXA xid face stood, and adopted whichever floor rode the newest-generation PGXA copy with no cross-copy consistency check and no regression guard. A node (or a freshly latched backend) could therefore fall back to vanilla DENSE multixact allocation while other nodes stayed striped: a dense mxid landing at/above another node's latched floor is misattributed by the value-mod-16 origin derivation and served from the wrong node's pg_multixact -- the probe-F silent-wrong class reopened. Fail-closed rework: - stripe_read_activation_one classifies the extension tri-state: ABSENT (all-zero area, pre-extension binary), VALID (CRC'd record + floor), or CORRUPT (non-zero bytes failing validation). - cluster_mxid_stripe_resolve_floor (new, pure, unit-tested) folds the per-copy evidence: two valid floors disagreeing -> POISONED; a valid extension anywhere with none at the newest generation -> POISONED (activation can never regress to absent); corrupt bytes with no valid copy -> POISONED ("never activated" unprovable); all-absent -> ABSENT; otherwise the single coherent floor. - StripeBootShmem gains mxid_disk_state {UNKNOWN,ABSENT,PUBLISHED,POISONED} with sticky/monotonic publication: POISONED never clears, a PUBLISHED floor never moves and never regresses to absent (an unreadable-evidence scan keeps the published floor and logs). - The lazy runtime latch latches POISONED dominantly: derivation stays underivable (foreign reads keep their 53R9C fail-closed boundary) and GetNewMultiXactId REFUSES allocation (ERRCODE_DATA_CORRUPTED with detail + hint) instead of degrading to the dense allocator. A later active latch cannot override poison. - Observability: xid_stripe dump gains mxid_stripe_disk_state; the poison transition logs a WARNING with the conflicting evidence. Unit coverage: test_cluster_mxid_stripe grows the resolve truth table (absent / single-valid / torn-sibling repair / same-gen + cross-gen floor conflict / newest-gen regression / corrupt-only) and the poisoned-runtime dominance leg (17 -> 19 cases). Spec: spec-7.1-cross-instance-positive-interread.md (integration review F2, P0) --- src/backend/access/transam/multixact.c | 24 ++++ src/backend/cluster/cluster_debug.c | 1 + src/backend/cluster/cluster_mxid_stripe.c | 120 ++++++++++++++++- src/backend/cluster/cluster_xid_stripe_boot.c | 121 +++++++++++++++--- src/include/cluster/cluster_mxid_stripe.h | 64 +++++++++ src/include/cluster/cluster_xid_stripe_boot.h | 1 + .../cluster_unit/test_cluster_mxid_stripe.c | 104 +++++++++++++++ 7 files changed, 418 insertions(+), 17 deletions(-) diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c index ee79cf670b..b4e1da66ba 100644 --- a/src/backend/access/transam/multixact.c +++ b/src/backend/access/transam/multixact.c @@ -1121,6 +1121,30 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset) if (RecoveryInProgress()) elog(ERROR, "cannot assign MultiXactIds during recovery"); +#ifdef USE_PGRAC_CLUSTER + + /* + * PGRAC: spec-7.1 integration review (P0) -- a poisoned mxid stripe + * state (conflicting / corrupt "PGXM" activation evidence on the + * voting disks) must REFUSE new multixact allocation instead of + * falling back to the vanilla dense allocator: a dense mxid landing + * at/above another node's latched floor would be misattributed by + * the value-mod-16 origin derivation and answered from the wrong + * node's SLRU (the probe-F silent-wrong class). Reads are + * unaffected (derivation stays unlatched and fails closed). + */ + if (cluster_mxid_stripe_poisoned()) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("multixact allocation refused: cluster mxid stripe activation state is " + "poisoned"), + errdetail("The \"PGXM\" mxid activation records on the voting disks are " + "conflicting or corrupt, so neither striped nor dense allocation can " + "be proven safe."), + errhint("Repair or re-initialize the cluster voting disks, then restart this " + "node."))); +#endif + LWLockAcquire(MultiXactGenLock, LW_EXCLUSIVE); /* Handle wraparound of the nextMXact counter */ diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index b9c5652580..5bd58e4a6a 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -942,6 +942,7 @@ dump_xid_stripe(ReturnSetInfo *rsinfo) fmt_uint64_hex((uint64)obs.replay_active_bitmap)); emit_row(rsinfo, "xid_stripe", "mxid_stripe_activated_floor", fmt_int64((int64)obs.activated_mxid_floor)); + emit_row(rsinfo, "xid_stripe", "mxid_stripe_disk_state", fmt_int64((int64)obs.mxid_disk_state)); emit_row(rsinfo, "xid_stripe", "mxid_stripe_halfspace_refusals", fmt_int64((int64)cluster_multixact_get_mxid_halfspace_refuse_count())); emit_row(rsinfo, "xid_stripe", "mxid_stripe_underivable_reads", diff --git a/src/backend/cluster/cluster_mxid_stripe.c b/src/backend/cluster/cluster_mxid_stripe.c index 23f9607193..c9545a54bc 100644 --- a/src/backend/cluster/cluster_mxid_stripe.c +++ b/src/backend/cluster/cluster_mxid_stripe.c @@ -49,11 +49,12 @@ */ typedef struct ClusterMxidStripeRuntime { bool active; + bool poisoned; /* POISONED disk state latched: sticky, dominates active */ int my_slot; MultiXactId floor_mxid; } ClusterMxidStripeRuntime; -static ClusterMxidStripeRuntime mxid_stripe_runtime = { false, -1, InvalidMultiXactId }; +static ClusterMxidStripeRuntime mxid_stripe_runtime = { false, false, -1, InvalidMultiXactId }; /* * Derive the origin slot of mxid against the mxid activation floor, or @@ -198,12 +199,129 @@ cluster_mxid_stripe_latch_runtime(bool active, int my_slot, MultiXactId floor_mx { if (active && (my_slot < 0 || my_slot >= CLUSTER_MXID_STRIDE || floor_mxid < FirstMultiXactId)) active = false; + /* A poisoned runtime never activates (sticky; see header). */ + if (mxid_stripe_runtime.poisoned) + active = false; mxid_stripe_runtime.active = active; mxid_stripe_runtime.my_slot = active ? my_slot : -1; mxid_stripe_runtime.floor_mxid = active ? floor_mxid : InvalidMultiXactId; } +/* + * Latch the poisoned mxid stripe runtime (spec-7.1 integration review, + * P0). Sticky for the life of the process: derivation stays + * underivable (foreign reads keep their 53R9C fail-closed boundary) + * and the allocation gate below refuses instead of degrading to the + * vanilla dense allocator. + */ +void +cluster_mxid_stripe_latch_poisoned(void) +{ + mxid_stripe_runtime.poisoned = true; + mxid_stripe_runtime.active = false; + mxid_stripe_runtime.my_slot = -1; + mxid_stripe_runtime.floor_mxid = InvalidMultiXactId; +} + +/* + * Runtime poisoned query (allocation-side consumer: GetNewMultiXactId + * must refuse rather than allocate dense). Retries the boot lazy + * latch until the runtime settles either active or poisoned, mirroring + * cluster_mxid_origin_slot. + */ +bool +cluster_mxid_stripe_poisoned(void) +{ + if (!mxid_stripe_runtime.active && !mxid_stripe_runtime.poisoned) { +#ifdef USE_PGRAC_CLUSTER + if (cluster_enabled && cluster_xid_striping) + cluster_mxid_stripe_lazy_latch(); +#endif + } + return mxid_stripe_runtime.poisoned; +} + +/* + * cluster_mxid_stripe_resolve_floor — fold per-copy "PGXM" evidence + * into one verdict (pure; spec-7.1 integration review, P0). + * + * ev[i] describes the extension area of one valid-PGXA activation + * copy: the PGXA generation it rides, its read class, and (when + * VALID) its floor. Verdict rules, in dominance order: + * + * 1. two VALID extensions with different floors -> POISONED + * (the floor is written once with the activation seed and never + * changes; two coherent records disagreeing cannot come from a + * torn write -- each carries its own CRC). + * 2. a VALID extension exists, but no copy at the NEWEST PGXA + * generation carries one -> POISONED + * (activation cannot regress to absent: adopting the newest + * copy's "absent" would silently re-open dense allocation while + * other nodes stay striped -- the probe-F misattribution class). + * 3. a VALID extension at the newest generation -> VALID + * (an ABSENT/CORRUPT sibling copy is torn-write damage the + * valid CRC'd record repairs, same tolerance as the PGXA face). + * 4. no VALID anywhere but CORRUPT bytes somewhere -> POISONED + * ("never activated" is unprovable under the damage; treating + * it as absent could seed a second, different floor). + * 5. all ABSENT -> ABSENT. + */ +ClusterMxidFloorResolve +cluster_mxid_stripe_resolve_floor(const ClusterMxidFloorEvidence *ev, int n, uint32 *out_floor) +{ + uint64 wgen = 0; + bool any_valid = false; + bool any_corrupt = false; + bool valid_at_wgen = false; + bool have_floor = false; + bool floor_conflict = false; + uint32 floor = 0; + int i; + + if (out_floor) + *out_floor = 0; + if (ev == NULL || n <= 0) + return CLUSTER_MXID_FLOOR_ABSENT; + + for (i = 0; i < n; i++) + if (ev[i].generation > wgen) + wgen = ev[i].generation; + + for (i = 0; i < n; i++) { + switch (ev[i].ext_class) { + case CLUSTER_MXID_EXT_VALID: + any_valid = true; + if (ev[i].generation == wgen) + valid_at_wgen = true; + if (!have_floor) { + floor = ev[i].floor; + have_floor = true; + } else if (floor != ev[i].floor) + floor_conflict = true; + break; + case CLUSTER_MXID_EXT_CORRUPT: + any_corrupt = true; + break; + default: + break; + } + } + + if (floor_conflict) + return CLUSTER_MXID_FLOOR_POISONED; + if (any_valid && !valid_at_wgen) + return CLUSTER_MXID_FLOOR_POISONED; + if (any_valid) { + if (out_floor) + *out_floor = floor; + return CLUSTER_MXID_FLOOR_VALID; + } + if (any_corrupt) + return CLUSTER_MXID_FLOOR_POISONED; + return CLUSTER_MXID_FLOOR_ABSENT; +} + /* Set rec->crc32c = CRC32C over [magic .. generation]. */ void cluster_mxid_stripe_extension_record_compute_crc(ClusterMxidStripeExtensionRecord *rec) diff --git a/src/backend/cluster/cluster_xid_stripe_boot.c b/src/backend/cluster/cluster_xid_stripe_boot.c index c33bec5f03..5f28f499b4 100644 --- a/src/backend/cluster/cluster_xid_stripe_boot.c +++ b/src/backend/cluster/cluster_xid_stripe_boot.c @@ -50,6 +50,7 @@ #include "cluster/cluster_guc.h" #include "cluster/cluster_inject.h" /* herding-stall injection (D3, L408) */ #include "cluster/cluster_mxid_stripe.h" /* mxid stripe face (spec-7.1 D3-a) */ +#include "cluster/cluster_qvotec.h" /* CLUSTER_MAX_VOTING_DISKS (mxid evidence bound) */ #include "cluster/cluster_shmem.h" #include "cluster/cluster_voting_disk_io.h" #include "cluster/cluster_xid_stripe.h" @@ -94,7 +95,8 @@ typedef struct ClusterXidStripeBootShmem { uint64 floor_full; uint64 epoch; uint64 generation; - uint32 mxid_floor; /* "PGXM" extension floor; 0 = absent (spec-7.1 D3-a) */ + uint32 mxid_floor; /* "PGXM" extension floor; 0 = absent (spec-7.1 D3-a) */ + uint32 mxid_disk_state; /* ClusterMxidDiskState (spec-7.1 integration review P0) */ /* this node's region-4 publication (D5c; D5e reads the floor) */ uint32 slot_state; /* ClusterXidStripeSlotState */ @@ -207,13 +209,16 @@ buffer_is_all_zeros(const char *buf, Size len) } static StripeSlotReadClass -stripe_read_activation_one(int fd, ClusterXidStripeActivationRecord *out, uint32 *out_mxid_floor) +stripe_read_activation_one(int fd, ClusterXidStripeActivationRecord *out, uint32 *out_mxid_floor, + uint8 *out_mxid_ext_class) { char slot[CLUSTER_VOTING_SLOT_BYTES]; ClusterVotingDiskIoState rc; if (out_mxid_floor) *out_mxid_floor = 0; + if (out_mxid_ext_class) + *out_mxid_ext_class = (uint8)CLUSTER_MXID_EXT_ABSENT; rc = cluster_voting_disk_read_stripe_activation(fd, slot); if (rc != CLUSTER_VOTING_DISK_IO_OK) { @@ -235,19 +240,25 @@ stripe_read_activation_one(int fd, ClusterXidStripeActivationRecord *out, uint32 /* * spec-7.1 D3-a: parse the "PGXM" mxid-floor extension riding the - * same slot at a fixed offset. A slot written by a pre-extension - * binary reads back zeros there (record-absent); any invalid - * content is likewise treated as absent -- the mxid face then - * stays fail-closed while the xid activation stands on its own - * CRC. Never a CORRUPT verdict: the extension is optional and - * must not hold up the xid face. + * same slot at a fixed offset. Tri-state classification (spec-7.1 + * integration review, P0): an all-zero extension area is ABSENT (a + * pre-extension binary wrote the slot); a validating record is + * VALID and carries the floor; anything else is CORRUPT -- the + * caller's evidence resolve decides whether the damage poisons the + * mxid face (never silently "absent": a lost floor must not reopen + * dense allocation). Never a CORRUPT verdict for the xid face: + * the extension must not hold up the xid activation, which stands + * on its own CRC. */ - if (out_mxid_floor) { + if (out_mxid_floor && out_mxid_ext_class) { ClusterMxidStripeExtensionRecord ext; memcpy(&ext, slot + CLUSTER_PGXM_SLOT_OFFSET, sizeof(ext)); - if (cluster_mxid_stripe_extension_record_valid(&ext)) + if (cluster_mxid_stripe_extension_record_valid(&ext)) { *out_mxid_floor = ext.activated_mxid_floor; + *out_mxid_ext_class = (uint8)CLUSTER_MXID_EXT_VALID; + } else if (!buffer_is_all_zeros(slot + CLUSTER_PGXM_SLOT_OFFSET, sizeof(ext))) + *out_mxid_ext_class = (uint8)CLUSTER_MXID_EXT_CORRUPT; } return STRIPE_READ_VALID; } @@ -340,7 +351,10 @@ void cluster_xid_stripe_scan_disks(const int *fds, int n_disks) { ClusterXidStripeActivationRecord best; - uint32 best_mxid_floor = 0; + ClusterMxidFloorEvidence mxid_ev[CLUSTER_MAX_VOTING_DISKS]; + int mxid_ev_n = 0; + ClusterMxidFloorResolve mxid_resolve; + uint32 mxid_resolved_floor = 0; bool have_valid = false; bool have_corrupt = false; bool have_readable = false; @@ -354,16 +368,26 @@ cluster_xid_stripe_scan_disks(const int *fds, int n_disks) for (i = 0; i < n_disks; i++) { ClusterXidStripeActivationRecord rec; uint32 rec_mxid_floor; + uint8 rec_mxid_ext_class; - switch (stripe_read_activation_one(fds[i], &rec, &rec_mxid_floor)) { + switch (stripe_read_activation_one(fds[i], &rec, &rec_mxid_floor, &rec_mxid_ext_class)) { case STRIPE_READ_VALID: have_readable = true; if (!have_valid || rec.generation > best.generation) { best = rec; - /* the extension travels with its slot's PGXA record */ - best_mxid_floor = rec_mxid_floor; have_valid = true; } + /* mxid evidence: EVERY valid-PGXA copy contributes (spec-7.1 + * integration review P0) — the resolve below decides, not + * "the winner's copy" (a winner with a damaged extension + * must not shadow a coherent sibling, and a lost extension + * must not silently reopen dense allocation). */ + if (mxid_ev_n < CLUSTER_MAX_VOTING_DISKS) { + mxid_ev[mxid_ev_n].generation = rec.generation; + mxid_ev[mxid_ev_n].ext_class = rec_mxid_ext_class; + mxid_ev[mxid_ev_n].floor = rec_mxid_floor; + mxid_ev_n++; + } break; case STRIPE_READ_ABSENT: have_readable = true; @@ -377,7 +401,60 @@ cluster_xid_stripe_scan_disks(const int *fds, int n_disks) } } + mxid_resolve = cluster_mxid_stripe_resolve_floor(mxid_ev, mxid_ev_n, &mxid_resolved_floor); + LWLockAcquire(&StripeBootShmem->lock, LW_EXCLUSIVE); + + /* + * mxid face publication (spec-7.1 integration review P0) — decided by + * the cross-copy evidence resolve, independent of which copy wins the + * xid face below, with sticky/monotonic rules: POISONED never clears, + * a PUBLISHED floor never regresses to absent (dense) or moves. + */ + switch (StripeBootShmem->mxid_disk_state) { + case CLUSTER_MXID_DISK_POISONED: + break; /* sticky */ + case CLUSTER_MXID_DISK_PUBLISHED: + if (mxid_resolve == CLUSTER_MXID_FLOOR_VALID + && mxid_resolved_floor == StripeBootShmem->mxid_floor) + break; /* steady state */ + if (mxid_resolve == CLUSTER_MXID_FLOOR_ABSENT) { + /* Incomplete evidence this scan (e.g. the carrying disk was + * unreadable): keep the published floor — never regress the + * mxid face to absent/dense — and say so. */ + ereport(LOG, (errmsg("cluster mxid stripe: activation extension unreadable this " + "scan; keeping published floor %u", + (unsigned)StripeBootShmem->mxid_floor))); + break; + } + StripeBootShmem->mxid_disk_state = CLUSTER_MXID_DISK_POISONED; + ereport(WARNING, + (errmsg("cluster mxid stripe: conflicting \"PGXM\" activation evidence " + "(published floor %u, resolve %d floor %u); mxid face poisoned", + (unsigned)StripeBootShmem->mxid_floor, (int)mxid_resolve, + (unsigned)mxid_resolved_floor), + errdetail("New multixact allocation on this node will be refused and foreign " + "multixact reads stay fail-closed until the voting-disk activation " + "records are repaired."))); + break; + default: /* UNKNOWN / ABSENT */ + if (mxid_resolve == CLUSTER_MXID_FLOOR_VALID) { + StripeBootShmem->mxid_disk_state = CLUSTER_MXID_DISK_PUBLISHED; + StripeBootShmem->mxid_floor = mxid_resolved_floor; + } else if (mxid_resolve == CLUSTER_MXID_FLOOR_POISONED) { + StripeBootShmem->mxid_disk_state = CLUSTER_MXID_DISK_POISONED; + StripeBootShmem->mxid_floor = 0; + ereport(WARNING, + (errmsg("cluster mxid stripe: conflicting or corrupt \"PGXM\" activation " + "evidence across voting disks; mxid face poisoned"), + errdetail("New multixact allocation on this node will be refused and " + "foreign multixact reads stay fail-closed until the voting-disk " + "activation records are repaired."))); + } else if (StripeBootShmem->mxid_disk_state == CLUSTER_MXID_DISK_UNKNOWN && have_readable) + StripeBootShmem->mxid_disk_state = CLUSTER_MXID_DISK_ABSENT; + break; + } + if (have_valid) { if (StripeBootShmem->disk_state == CLUSTER_XID_STRIPE_DISK_PUBLISHED && best.stride_mode_epoch < StripeBootShmem->epoch) { @@ -391,7 +468,6 @@ cluster_xid_stripe_scan_disks(const int *fds, int n_disks) StripeBootShmem->floor_full = best.activated_floor_full; StripeBootShmem->epoch = best.stride_mode_epoch; StripeBootShmem->generation = best.generation; - StripeBootShmem->mxid_floor = best_mxid_floor; } } else if (have_corrupt) { /* Garbage and no valid copy anywhere: fail-closed hold. The @@ -599,6 +675,7 @@ stripe_service_seed(const int *fds, int n_disks) StripeBootShmem->epoch = rec.stride_mode_epoch; StripeBootShmem->generation = rec.generation; StripeBootShmem->mxid_floor = ext.activated_mxid_floor; + StripeBootShmem->mxid_disk_state = (uint32)CLUSTER_MXID_DISK_PUBLISHED; LWLockRelease(&StripeBootShmem->lock); ereport(LOG, (errmsg("cluster xid stripe: activation record durable on %d/%d disks " "(floor " UINT64_FORMAT ", epoch " UINT64_FORMAT ", mxid floor %u)", @@ -943,19 +1020,30 @@ void cluster_mxid_stripe_lazy_latch(void) { uint32 mxid_floor = 0; + uint32 mxid_state; bool published = false; if (mxid_stripe_latch_done || StripeBootShmem == NULL) return; LWLockAcquire(&StripeBootShmem->lock, LW_SHARED); + mxid_state = StripeBootShmem->mxid_disk_state; if (StripeBootShmem->disk_state == CLUSTER_XID_STRIPE_DISK_PUBLISHED - && StripeBootShmem->mxid_floor != 0) { + && mxid_state == (uint32)CLUSTER_MXID_DISK_PUBLISHED && StripeBootShmem->mxid_floor != 0) { published = true; mxid_floor = StripeBootShmem->mxid_floor; } LWLockRelease(&StripeBootShmem->lock); + /* POISONED dominates (spec-7.1 integration review P0): latch the + * sticky poisoned runtime — allocation refuses instead of going + * dense, derivation stays underivable. */ + if (mxid_state == (uint32)CLUSTER_MXID_DISK_POISONED) { + cluster_mxid_stripe_latch_poisoned(); + mxid_stripe_latch_done = true; + return; + } + if (!published) return; /* stays unlatched; wrappers keep failing closed */ @@ -1269,6 +1357,7 @@ cluster_xid_stripe_observe(ClusterXidStripeObs *obs) = StripeBootShmem->my_slot_claimed ? StripeBootShmem->my_slot_floor_full : 0; obs->my_hwm_on_disk = StripeBootShmem->my_hwm_on_disk; obs->activated_mxid_floor = StripeBootShmem->mxid_floor; + obs->mxid_disk_state = StripeBootShmem->mxid_disk_state; LWLockRelease(&StripeBootShmem->lock); obs->herding_floor_full = pg_atomic_read_u64(&StripeBootShmem->herding_floor_full); diff --git a/src/include/cluster/cluster_mxid_stripe.h b/src/include/cluster/cluster_mxid_stripe.h index dbdaae351a..ec2d25fd4b 100644 --- a/src/include/cluster/cluster_mxid_stripe.h +++ b/src/include/cluster/cluster_mxid_stripe.h @@ -125,6 +125,70 @@ extern MultiXactId cluster_mxid_stripe_floor(void); */ extern void cluster_mxid_stripe_latch_runtime(bool active, int my_slot, MultiXactId floor_mxid); +/* + * ---------------------------------------------------------------- + * Durable mxid floor evidence resolution (spec-7.1 integration + * review, P0). + * + * The boot scan reads the "PGXM" extension area of every valid-PGXA + * activation copy and classifies it: ABSENT (all-zero bytes -- a + * pre-extension binary wrote the slot), VALID (magic/version/CRC/floor + * all pass), or CORRUPT (non-zero bytes failing validation). The pure + * resolver below folds the per-copy evidence into ONE verdict: + * + * ABSENT no copy anywhere carries a valid extension: the mxid + * face was never activated -- vanilla dense allocation and + * fail-closed foreign reads are consistent cluster-wide. + * VALID a single coherent floor: adopt it. + * POISONED the evidence conflicts: two valid extensions disagree on + * the floor, a valid extension exists but the newest- + * generation copy lost it (activation can never regress to + * absent), or damaged bytes leave "never activated" + * unprovable. The mxid face must FAIL CLOSED: dense + * allocation is forbidden (a dense mxid at/above another + * node's latched floor would be misattributed by the %16 + * derivation and served from the wrong origin's SLRU -- + * the probe-F silent-wrong class), and derivation stays + * unlatched so foreign reads keep their 53R9C boundary. + */ +typedef enum ClusterMxidExtReadClass { + CLUSTER_MXID_EXT_ABSENT = 0, /* extension area all zeros */ + CLUSTER_MXID_EXT_VALID = 1, /* record validates; floor usable */ + CLUSTER_MXID_EXT_CORRUPT = 2 /* non-zero bytes failing validation */ +} ClusterMxidExtReadClass; + +typedef struct ClusterMxidFloorEvidence { + uint64 generation; /* PGXA generation of the carrying copy */ + uint8 ext_class; /* ClusterMxidExtReadClass */ + uint32 floor; /* meaningful only when ext_class == VALID */ +} ClusterMxidFloorEvidence; + +typedef enum ClusterMxidFloorResolve { + CLUSTER_MXID_FLOOR_ABSENT = 0, + CLUSTER_MXID_FLOOR_VALID = 1, + CLUSTER_MXID_FLOOR_POISONED = 2 +} ClusterMxidFloorResolve; + +/* Published mxid disk-state (StripeBootShmem->mxid_disk_state). */ +typedef enum ClusterMxidDiskState { + CLUSTER_MXID_DISK_UNKNOWN = 0, /* no scan yet */ + CLUSTER_MXID_DISK_ABSENT = 1, /* scanned; never activated */ + CLUSTER_MXID_DISK_PUBLISHED = 2, /* floor adopted */ + CLUSTER_MXID_DISK_POISONED = 3 /* conflicting/corrupt evidence */ +} ClusterMxidDiskState; + +extern ClusterMxidFloorResolve cluster_mxid_stripe_resolve_floor(const ClusterMxidFloorEvidence *ev, + int n, uint32 *out_floor); + +/* + * Poisoned mxid stripe runtime: latched from the POISONED disk state. + * Sticky and dominant -- a poisoned process never latches active, the + * allocation gate must refuse (never fall back to vanilla dense), and + * derivation stays underivable (foreign reads keep failing closed). + */ +extern void cluster_mxid_stripe_latch_poisoned(void); +extern bool cluster_mxid_stripe_poisoned(void); + /* * ---------------------------------------------------------------- * Durable mxid activation extension record ("PGXM") diff --git a/src/include/cluster/cluster_xid_stripe_boot.h b/src/include/cluster/cluster_xid_stripe_boot.h index 027630df6e..f081db425e 100644 --- a/src/include/cluster/cluster_xid_stripe_boot.h +++ b/src/include/cluster/cluster_xid_stripe_boot.h @@ -158,6 +158,7 @@ typedef struct ClusterXidStripeObs { uint64 replay_floor_full; uint32 replay_active_bitmap; uint32 activated_mxid_floor; /* "PGXM" extension floor; 0 = absent (spec-7.1 D3-a) */ + uint32 mxid_disk_state; /* ClusterMxidDiskState (spec-7.1 integration review P0) */ } ClusterXidStripeObs; extern void cluster_xid_stripe_observe(ClusterXidStripeObs *obs); diff --git a/src/test/cluster_unit/test_cluster_mxid_stripe.c b/src/test/cluster_unit/test_cluster_mxid_stripe.c index 95559fddfc..86ff5a86c5 100644 --- a/src/test/cluster_unit/test_cluster_mxid_stripe.c +++ b/src/test/cluster_unit/test_cluster_mxid_stripe.c @@ -324,6 +324,107 @@ UT_TEST(test_runtime_latched_derivation) cluster_node_id = -1; } +/* ---------- + * cross-copy "PGXM" evidence resolve (spec-7.1 integration review P0) + * ---------- + */ + +static ClusterMxidFloorEvidence +mk_ev(uint64 generation, ClusterMxidExtReadClass cls, uint32 floor) +{ + ClusterMxidFloorEvidence ev; + + ev.generation = generation; + ev.ext_class = (uint8)cls; + ev.floor = floor; + return ev; +} + +UT_TEST(test_resolve_floor_truth_table) +{ + ClusterMxidFloorEvidence ev[4]; + uint32 floor = 99; + + /* no evidence at all -> ABSENT, floor cleared */ + UT_ASSERT(cluster_mxid_stripe_resolve_floor(NULL, 0, &floor) == CLUSTER_MXID_FLOOR_ABSENT); + UT_ASSERT(floor == 0); + + /* all copies absent -> ABSENT (pre-activation, dense is consistent) */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_ABSENT, 0); + ev[1] = mk_ev(1, CLUSTER_MXID_EXT_ABSENT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_ABSENT); + + /* single valid copy -> VALID, floor adopted */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_VALID, 4096); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 1, &floor) == CLUSTER_MXID_FLOOR_VALID); + UT_ASSERT(floor == 4096); + + /* valid + same-generation CORRUPT sibling -> VALID (torn-copy repair) */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_VALID, 4096); + ev[1] = mk_ev(1, CLUSTER_MXID_EXT_CORRUPT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_VALID); + UT_ASSERT(floor == 4096); + + /* valid + same-generation ABSENT sibling -> VALID (evidence travels) */ + ev[1] = mk_ev(1, CLUSTER_MXID_EXT_ABSENT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_VALID); + + /* two VALID with different floors (same gen) -> POISONED */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_VALID, 4096); + ev[1] = mk_ev(1, CLUSTER_MXID_EXT_VALID, 8192); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_POISONED); + UT_ASSERT(floor == 0); + + /* two VALID with different floors (cross gen) -> POISONED (floor is + * written once with the seed; two coherent records disagreeing is + * damage/aliasing, never a torn write) */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_VALID, 4096); + ev[1] = mk_ev(2, CLUSTER_MXID_EXT_VALID, 8192); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_POISONED); + + /* older-gen VALID but newest gen lost the extension -> POISONED + * (activation must never regress to absent/dense) */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_VALID, 4096); + ev[1] = mk_ev(2, CLUSTER_MXID_EXT_ABSENT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_POISONED); + ev[1] = mk_ev(2, CLUSTER_MXID_EXT_CORRUPT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_POISONED); + + /* CORRUPT with no valid anywhere -> POISONED ("never activated" is + * unprovable under the damage) */ + ev[0] = mk_ev(1, CLUSTER_MXID_EXT_CORRUPT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 1, &floor) == CLUSTER_MXID_FLOOR_POISONED); + ev[1] = mk_ev(1, CLUSTER_MXID_EXT_ABSENT, 0); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_POISONED); + + /* same floor on both copies -> VALID (the ONLY multi-valid accept) */ + ev[0] = mk_ev(3, CLUSTER_MXID_EXT_VALID, 4096); + ev[1] = mk_ev(3, CLUSTER_MXID_EXT_VALID, 4096); + UT_ASSERT(cluster_mxid_stripe_resolve_floor(ev, 2, &floor) == CLUSTER_MXID_FLOOR_VALID); + UT_ASSERT(floor == 4096); +} + +/* NOTE: poison is sticky for the process — keep this the LAST runtime + * test (there is deliberately no un-poison API). */ +UT_TEST(test_runtime_poisoned_dominates) +{ + UT_ASSERT(!cluster_mxid_stripe_poisoned()); + + cluster_mxid_stripe_latch_poisoned(); + + UT_ASSERT(cluster_mxid_stripe_poisoned()); + UT_ASSERT(cluster_mxid_origin_slot(5000) == -1); + UT_ASSERT(!cluster_mxid_is_mine(5000)); + UT_ASSERT(cluster_mxid_allocation_slot() == -1); + UT_ASSERT(cluster_mxid_stripe_floor() == InvalidMultiXactId); + + /* a later active latch must NOT override poison (sticky) */ + cluster_mxid_stripe_latch_runtime(true, 3, 4096); + UT_ASSERT(cluster_mxid_stripe_poisoned()); + UT_ASSERT(cluster_mxid_origin_slot(4096 + 16 * 4 + 3) == -1); + UT_ASSERT(cluster_mxid_allocation_slot() == -1); +} + /* ---------- * durable "PGXM" activation-slot extension record @@ -425,6 +526,9 @@ main(void) UT_RUN(test_runtime_latch_defensive); UT_RUN(test_runtime_latched_derivation); + UT_RUN(test_resolve_floor_truth_table); + UT_RUN(test_runtime_poisoned_dominates); + UT_RUN(test_ext_record_roundtrip_valid); UT_RUN(test_ext_record_absent_all_zeros); UT_RUN(test_ext_record_sanity_rejections); From 2279df5490b8633b9c459f371eb2ae1938ac85d7 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 12:41:13 +0800 Subject: [PATCH 29/29] test(cluster): spec-7.1 -- sweep the remaining nightly-only counter/injection baselines The branch's first nightly run surfaced the stragglers the fast-gate TAP subset does not cover: three shards red on exactly two stale baselines, no behavioral failures. - cr category 57 -> 70: the spec-7.1 census + multi-verdict serve counters (11 vis53r97_leg_* + 2 cr_server_multi_verdict_*) were not reflected in the five nightly-only cr-count assertions (t/215 t/216 t/300 t/306 t/311). - injection registry 161 -> 162: the D3-a cluster-mxid-halfspace-hard-limit bump reached t/015 t/017 t/030 but not the six foundation assertions (t/018 t/020 t/021 t/022 t/023 t/024). All eleven descriptive breakdowns gain the spec-7.1 entry, mirroring the t/015 / t/017 convention. Verified: all eleven tests pass locally; a full-tree grep finds no remaining 57/161 count assertions. Spec: spec-7.1-cross-instance-positive-interread.md (integration; nightly sweep) --- src/test/cluster_tap/t/018_shared_fs.pl | 4 ++-- src/test/cluster_tap/t/020_shmem_registry.pl | 4 ++-- src/test/cluster_tap/t/021_block_format.pl | 4 ++-- src/test/cluster_tap/t/022_itl_slot.pl | 4 ++-- src/test/cluster_tap/t/023_buffer_descriptor.pl | 4 ++-- src/test/cluster_tap/t/024_pcm_lock.pl | 4 ++-- src/test/cluster_tap/t/215_cluster_3_9_cr_construction.pl | 4 ++-- src/test/cluster_tap/t/216_cluster_3_10_cr_cache.pl | 4 ++-- src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl | 4 ++-- src/test/cluster_tap/t/306_cluster_5_53_cr_key_reuse.pl | 2 +- src/test/cluster_tap/t/311_cluster_5_56_cr_lifecycle.pl | 2 +- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/test/cluster_tap/t/018_shared_fs.pl b/src/test/cluster_tap/t/018_shared_fs.pl index 60db597af5..25b56e52ec 100644 --- a/src/test/cluster_tap/t/018_shared_fs.pl +++ b/src/test/cluster_tap/t/018_shared_fs.pl @@ -131,8 +131,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L9 total injection registry size is 161 (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'L9 total injection registry size is 162 (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); # ---------- diff --git a/src/test/cluster_tap/t/020_shmem_registry.pl b/src/test/cluster_tap/t/020_shmem_registry.pl index 76e50ecdba..362ed3a5a9 100644 --- a/src/test/cluster_tap/t/020_shmem_registry.pl +++ b/src/test/cluster_tap/t/020_shmem_registry.pl @@ -280,8 +280,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L15 total injection registry size is 161 (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; full breakdown in t/015)'); + '162', + 'L15 total injection registry size is 162 (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; full breakdown in t/015)'); # ---------- diff --git a/src/test/cluster_tap/t/021_block_format.pl b/src/test/cluster_tap/t/021_block_format.pl index 39f6a34abc..d6e940b9d0 100644 --- a/src/test/cluster_tap/t/021_block_format.pl +++ b/src/test/cluster_tap/t/021_block_format.pl @@ -188,8 +188,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L11 pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); + '162', + 'L11 pg_stat_cluster_injections is 162 (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); # ---------- diff --git a/src/test/cluster_tap/t/022_itl_slot.pl b/src/test/cluster_tap/t/022_itl_slot.pl index 1354ed353e..496760a349 100644 --- a/src/test/cluster_tap/t/022_itl_slot.pl +++ b/src/test/cluster_tap/t/022_itl_slot.pl @@ -204,8 +204,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L12a pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); + '162', + 'L12a pg_stat_cluster_injections is 162 (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); is($node->safe_psql( 'postgres', diff --git a/src/test/cluster_tap/t/023_buffer_descriptor.pl b/src/test/cluster_tap/t/023_buffer_descriptor.pl index f7ddfec0fa..19710cd296 100644 --- a/src/test/cluster_tap/t/023_buffer_descriptor.pl +++ b/src/test/cluster_tap/t/023_buffer_descriptor.pl @@ -157,8 +157,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L11 pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); + '162', + 'L11 pg_stat_cluster_injections is 162 (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); # ---------- diff --git a/src/test/cluster_tap/t/024_pcm_lock.pl b/src/test/cluster_tap/t/024_pcm_lock.pl index a01a9639c1..be06ee9005 100644 --- a/src/test/cluster_tap/t/024_pcm_lock.pl +++ b/src/test/cluster_tap/t/024_pcm_lock.pl @@ -163,8 +163,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L6a pg_stat_cluster_injections has 161 entries (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-5.13 +6 cluster-clean-leave-* + Hardening v1.0.3 +1 suppress-preflight-ack) (spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'L6a pg_stat_cluster_injections has 162 entries (spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-5.13 +6 cluster-clean-leave-* + Hardening v1.0.3 +1 suppress-preflight-ack) (spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); is($node->safe_psql( 'postgres', diff --git a/src/test/cluster_tap/t/215_cluster_3_9_cr_construction.pl b/src/test/cluster_tap/t/215_cluster_3_9_cr_construction.pl index 719c919676..e4fbb8d292 100644 --- a/src/test/cluster_tap/t/215_cluster_3_9_cr_construction.pl +++ b/src/test/cluster_tap/t/215_cluster_3_9_cr_construction.pl @@ -94,8 +94,8 @@ # ---------- my $cr_rows = $node0->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}); -is($cr_rows, '57', - 'L2 cr category has 57 counter rows (9 + 4 cache + 4 xmax + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); +is($cr_rows, '70', + 'L2 cr category has 70 counter rows (9 + 4 cache + 4 xmax + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict + 13 spec-7.1 D0/D3-b census & multi-verdict serve)'); # ---------- diff --git a/src/test/cluster_tap/t/216_cluster_3_10_cr_cache.pl b/src/test/cluster_tap/t/216_cluster_3_10_cr_cache.pl index d912b07ede..0bb6ba78b5 100644 --- a/src/test/cluster_tap/t/216_cluster_3_10_cr_cache.pl +++ b/src/test/cluster_tap/t/216_cluster_3_10_cr_cache.pl @@ -78,8 +78,8 @@ is( $node0->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}), - '57', - 'L1d cr category has 57 rows (9 + 4 cache + 4 xmax + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); + '70', + 'L1d cr category has 70 rows (9 + 4 cache + 4 xmax + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict + 13 spec-7.1 D0/D3-b census & multi-verdict serve)'); } diff --git a/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl b/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl index 12ff7c2ac2..f0ec544db5 100644 --- a/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl +++ b/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl @@ -126,8 +126,8 @@ is( $node->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}), - '57', - 'L1b cr category has 57 counters (17 + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); + '70', + 'L1b cr category has 70 counters (17 + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict + 13 spec-7.1 D0/D3-b census & multi-verdict serve)'); $node->safe_psql('postgres', 'CREATE TABLE t_l1 (id int, v int); INSERT INTO t_l1 VALUES (1, 100);'); diff --git a/src/test/cluster_tap/t/306_cluster_5_53_cr_key_reuse.pl b/src/test/cluster_tap/t/306_cluster_5_53_cr_key_reuse.pl index 4fd4be4074..eb92c44d21 100644 --- a/src/test/cluster_tap/t/306_cluster_5_53_cr_key_reuse.pl +++ b/src/test/cluster_tap/t/306_cluster_5_53_cr_key_reuse.pl @@ -106,7 +106,7 @@ # spec-6.12b six CR-server counters. is( $node->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}), - '57', 'L1b cr category has 57 rows (17 + 5 spec-5.53 + 8 spec-5.54 + 5 spec-5.56 + 6 spec-6.12b + 16 spec-6.12i/6.15)'); + '70', 'L1b cr category has 70 rows (17 + 5 spec-5.53 + 8 spec-5.54 + 5 spec-5.56 + 6 spec-6.12b + 16 spec-6.12i/6.15 + 13 spec-7.1 D0/D3-b census & multi-verdict serve)'); for my $k (qw(cr_key_mismatch_count cr_epoch_mismatch_count cr_generation_mismatch_count cr_base_lsn_mismatch_count diff --git a/src/test/cluster_tap/t/311_cluster_5_56_cr_lifecycle.pl b/src/test/cluster_tap/t/311_cluster_5_56_cr_lifecycle.pl index 69b97ec460..e4b2ef1445 100644 --- a/src/test/cluster_tap/t/311_cluster_5_56_cr_lifecycle.pl +++ b/src/test/cluster_tap/t/311_cluster_5_56_cr_lifecycle.pl @@ -106,7 +106,7 @@ 'postmaster', 'L7b rel_generation_slots is PGC_POSTMASTER'); is( $node->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}), - '57', 'L7c cr category has 57 rows (22 + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); + '70', 'L7c cr category has 70 rows (22 + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict + 13 spec-7.1 D0/D3-b census & multi-verdict serve)'); for my $k ( qw(cr_global_epoch_fallback_bump_count cr_rel_gen_bump_count cr_rel_gen_table_overflow_count cr_retention_horizon_advance_noted_count