diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6eac8597a0..07b3eecedf 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -183,20 +183,35 @@ jobs: # the same commit; see docs/code-review-checklist.md). - { name: stage6-crossnode-a, ranges: "346-350", unit: false, regress: false } - { name: stage6-crossnode-b, ranges: "351-357 361", unit: false, regress: false } - # t/362 spec-4.6a 4-node shared_catalog kill self-heal (t/358-360 - # reserved by the parallel spec-7.2 lane; L464 occupancy verified - # against origin branches 2026-07-08). Own shard: 4-node formation - # + kill + convergence needs the wall clock. + # t/358 spec-7.2 data-plane flip e2e + t/360 data-plane fault legs + # (conn-reset injection / KNOWN-BLOCKED docs). Closes the review + # P1-3 shard hole: both files predate this shard and were in NO CI + # matrix. Own shard: the t/360 value gate + reconnect deadline + # need the wall clock. (t/359 gapwalk runs in stage7-multixact.) + - { name: stage7-data-plane, ranges: "358 360", unit: false, regress: false } + # t/362 spec-4.6a 4-node shared_catalog kill self-heal (L464 + # occupancy verified against origin branches 2026-07-08). Own + # shard: 4-node formation + kill + convergence needs the wall + # clock. - { name: stage7-reconfig-liveness, ranges: "362-362", unit: false, regress: false } # t/363 spec-2.29a marker-async LMON liveness. - { name: stage7-marker-async, ranges: "363-363", unit: false, regress: false } + # t/364 + t/367 spec-7.3 LMS worker-pool family (single-node pool + # smoke + 2-node topology / multi-tag routing / recycle / N=1 + # sentinel). Every new t/ file lands in a shard the same commit + # (L342). + - { name: stage7-lms-pool, ranges: "364 367", unit: false, regress: false } # t/365 spec-7.1a cross-instance write-write MVCC coordination. - { name: stage7-write-write, ranges: "365-365", unit: false, regress: false } # t/366 spec-7.2a GCS block dedup capacity + eager reclaim. Own shard: # 2-node shared_catalog bring-up + cross-node distinct-read dedup # pressure + injected retransmit-dedup correctness needs the wall - # clock. (t/364 reserved by the parallel spec-7.3 LMS-pool lane.) + # clock. - { name: stage7-gcs-dedup-capacity, ranges: "366-366", unit: false, regress: false } + # t/359 mxid-stripe gapwalk + t/368 multixact member-serve refuse + # (spec-7.1 family; 368 renamed from the double-occupied t/360 per + # the hub number ledger -- the lms data-plane faults file keeps 360). + - { name: stage7-multixact, ranges: "359 368", unit: false, regress: false } steps: - name: Checkout uses: actions/checkout@v4 diff --git a/scripts/perf/run_73_pool_scaling.pl b/scripts/perf/run_73_pool_scaling.pl new file mode 100644 index 0000000000..208fa77ec3 --- /dev/null +++ b/scripts/perf/run_73_pool_scaling.pl @@ -0,0 +1,465 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# run_73_pool_scaling.pl +# spec-7.3 D9 (L5) -- LMS worker-pool scaling gate harness. +# +# Boots a 2-node ClusterPair (shared_data + block_device) twice -- +# cluster.lms_workers = 1 (the spec-7.2 single-LMS topology) and +# cluster.lms_workers = 2 (the default pool) -- and measures, per leg: +# +# MULTI-TAG node0 runs pgbench UPDATE churn over a ~40-block +# shared table (deep undo chains) while node1 readers +# scan it under a lagged REPEATABLE READ snapshot -- +# every dirtied block must be CR-CONSTRUCTED (undo +# walk) by node0's LMS pool and shipped: the spec-7.3 +# motivating head-of-line workload. Aggregate serve +# throughput = delta(lms.lms_direct_reply_count, both +# nodes) / seconds; per-worker inline-serve deltas are +# the fan-out evidence. +# +# SINGLE-TAG node0 updates / node1 reads ONE single-block table +# (1 client each). Requester ship-latency p99 from the +# gcs.ship_hist_us_* histogram delta (both nodes). +# +# Q9 value gate (spec-7.3 §8 / §7 DoD): +# - 2-worker multi-tag aggregate ship throughput >= 1.6x 1-worker +# - single-tag p99 degradation <= 10% +# Medians over PS_ROUNDS (default 3) rounds per leg (H-3 variance +# discipline); numbers are 本机 loopback 口径 -- trend datapoints for +# the gate read, not cross-machine absolutes. The runner reports and +# exits 0 on harness success; the gate verdict is a human/DoD read. +# +# Env knobs: PS_SECS (20) multi-tag seconds; PS_SINGLE_SECS (10) +# single-tag seconds; PS_ROUNDS (3); PS_CLIENTS (8) pgbench clients +# per node; PS_OUT report path (default scripts/perf/results/ +# 73-pool-scaling-.md); PS_INSTALL install prefix prepended +# to PATH; PGBENCH pgbench binary override. +# +# Run from repo root in a built tree: +# PS_INSTALL=/tmp/pgrac-install-73 perl scripts/perf/run_73_pool_scaling.pl +# +# Author: SqlRush +# Portions Copyright (c) 2026, pgrac contributors +# +# Spec: spec-7.3-lms-worker-pool.md (D9/Q9) +# +# IDENTIFICATION +# scripts/perf/run_73_pool_scaling.pl +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../../src/test/perl"; + +use File::Basename qw(dirname); +use File::Path qw(make_path); +use File::Spec; +use List::Util qw(sum min max); +use Time::HiRes (); + +our ($REAL_STDOUT, $have_fixtures, $fixtures_err); + +BEGIN +{ + open($REAL_STDOUT, '>&', \*STDOUT) + or die "cannot dup STDOUT: $!\n"; + my $prev = select($REAL_STDOUT); + $| = 1; + select($prev); + + if (defined $ENV{PS_INSTALL} && length $ENV{PS_INSTALL}) + { + $ENV{PATH} = "$ENV{PS_INSTALL}/bin:$ENV{PATH}"; + for my $var (qw(LD_LIBRARY_PATH DYLD_LIBRARY_PATH)) + { + $ENV{$var} = join(':', "$ENV{PS_INSTALL}/lib", + grep { defined && length } $ENV{$var}); + } + } + + { + my $repo = File::Spec->rel2abs("$FindBin::RealBin/../.."); + + $ENV{PG_REGRESS} = "$repo/src/test/regress/pg_regress" + unless defined $ENV{PG_REGRESS} && length $ENV{PG_REGRESS}; + $ENV{TESTDATADIR} = "$repo/tmp_check" + unless defined $ENV{TESTDATADIR} && length $ENV{TESTDATADIR}; + $ENV{TESTLOGDIR} = "$repo/tmp_check/log" + unless defined $ENV{TESTLOGDIR} && length $ENV{TESTLOGDIR}; + } + + eval { + require PostgreSQL::Test::Utils; + PostgreSQL::Test::Utils->import(); + require PostgreSQL::Test::Cluster; + require PostgreSQL::Test::ClusterPair; + require IPC::Run; + $have_fixtures = 1; + } or $fixtures_err = $@; +} + +die "TAP fixtures unavailable: $fixtures_err\n" unless $have_fixtures; + +my $PS_SECS = $ENV{PS_SECS} // 20; +my $PS_SINGLE_SECS = $ENV{PS_SINGLE_SECS} // 10; +my $PS_ROUNDS = $ENV{PS_ROUNDS} // 3; +my $PS_CLIENTS = $ENV{PS_CLIENTS} // 8; +my $PGBENCH = $ENV{PGBENCH} // 'pgbench'; + +my $REPO = File::Spec->rel2abs("$FindBin::RealBin/../.."); +my $outdir = File::Spec->catfile($REPO, 'scripts', 'perf', 'results'); +my $stamp = time(); +my $PS_OUT = $ENV{PS_OUT} + // File::Spec->catfile($outdir, "73-pool-scaling-$stamp.md"); + +my @HIST_BOUNDS = (500, 1000, 2000, 5000, 10000, 20000, 50000, 100000, + 200000, 500000, 1000000, 2000000, 5000000, 10000000, 30000000); + +my @base_conf = ( + 'autovacuum = off', + 'fsync = off', + 'shared_buffers = 64MB', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.online_join = on', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + 'cluster.crossnode_cr_data_plane = on', + 'cluster.block_self_contained = on'); + +# pgbench per-tx scripts (written once under TESTDATADIR). +my $sqldir = File::Spec->catfile($ENV{TESTDATADIR}, "ps73-$stamp"); +make_path($sqldir); +# Multi-tag workload: DISJOINT write stripes (node0 owns aid 1-2500, +# node1 owns 2501-5000) so writes never row-conflict, while every tx also +# reads one random row of the PEER's stripe -- pulling a remote-dirty +# block across the DATA plane (a real image ship) each time. This keeps +# the pipeline ship-bound instead of GES-lock-bound (the naive both-sides +# random UPDATE degenerates into cross-node X convoys: tps ~ 16, ships +# ~30/s, and a retry/verdict dispatch storm -- measured, not assumed). +# v4 workload -- faithful to the spec-7.3 motivating bottleneck (CR +# construction head-of-line, spec §背景): node0 runs pure own-table +# UPDATE churn (deep undo chains, no cross reads, no TT-hot races); +# node1 readers open a REPEATABLE READ snapshot, lag 200 ms while node0 +# commits on top, then scan the table -- every dirtied block must be CR +# CONSTRUCTED (undo walk) by node0's LMS pool and shipped. The scan is +# DO-wrapped (subtransaction) so a fail-closed retryable visibility +# error (TT slot recycled et al.) rolls back the probe, not the client: +# pgbench --max-tries only retries 40001/40P01. Both legs identical. +my %SQL = ( + writer_churn => "\\set aid random(1, 5000)\n" + . "UPDATE pool_t SET bal = bal + 1 WHERE aid = :aid;\n", + reader_cr => "BEGIN ISOLATION LEVEL REPEATABLE READ;\n" + . "SELECT 1;\n" + . "\\sleep 200 ms\n" + . "DO \$\$ BEGIN PERFORM sum(bal) FROM pool_t; " + . "EXCEPTION WHEN OTHERS THEN NULL; END \$\$;\n" + . "COMMIT;\n", + single_update => "UPDATE pool_one SET bal = bal + 1 WHERE aid = 1;\n", + single_select => + "DO \$\$ BEGIN PERFORM bal FROM pool_one WHERE aid = 1; " + . "EXCEPTION WHEN OTHERS THEN NULL; END \$\$;\n", +); +for my $k (keys %SQL) +{ + open(my $fh, '>', "$sqldir/$k.sql") or die "write $k.sql: $!\n"; + print $fh $SQL{$k}; + close $fh; +} + +sub logline { print $REAL_STDOUT @_, "\n"; } + +sub state_int +{ + 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 '' ? int($v) : 0; +} + +sub poll_write_ok +{ + my ($node, $sql, $timeout_s) = @_; + $timeout_s //= 90; + my $deadline = time + $timeout_s; + while (time < $deadline) + { + return 1 if eval { $node->safe_psql('postgres', $sql); 1 }; + Time::HiRes::usleep(500_000); + } + return 0; +} + +sub snapshot_hist +{ + my ($node0, $node1) = @_; + my @c; + for my $b (0 .. $#HIST_BOUNDS) + { + $c[$b] = state_int($node0, 'gcs', "ship_hist_us_le_$HIST_BOUNDS[$b]") + + state_int($node1, 'gcs', "ship_hist_us_le_$HIST_BOUNDS[$b]"); + } + my $inf = state_int($node0, 'gcs', 'ship_hist_us_inf') + + state_int($node1, 'gcs', 'ship_hist_us_inf'); + return { counts => \@c, inf => $inf }; +} + +# p99 upper bound (us) of the delta histogram; undef = +inf overflow. +sub hist_p99 +{ + my ($pre, $post) = @_; + my @d = map { $post->{counts}[$_] - $pre->{counts}[$_] } 0 .. $#HIST_BOUNDS; + my $dinf = $post->{inf} - $pre->{inf}; + my $total = sum(@d) + $dinf; + return (undef, 0) if $total <= 0; + my $target = 0.99 * $total; + my $cum = 0; + for my $b (0 .. $#HIST_BOUNDS) + { + $cum += $d[$b]; + return ($HIST_BOUNDS[$b], $total) if $cum >= $target; + } + return (undef, $total); +} + +sub run_pgbench_pair +{ + my ($node0, $script0, $clients0, $node1, $script1, $clients1, $secs) = @_; + my (@out0, @out1); + my @cmd0 = ($PGBENCH, '-h', $node0->host, '-p', $node0->port, '-n', + '-M', 'simple', '-c', $clients0, '-j', min($clients0, 4), + '-T', $secs, '--max-tries=20', '-f', "$sqldir/$script0.sql", + 'postgres'); + my @cmd1 = ($PGBENCH, '-h', $node1->host, '-p', $node1->port, '-n', + '-M', 'simple', '-c', $clients1, '-j', min($clients1, 4), + '-T', $secs, '--max-tries=20', '-f', "$sqldir/$script1.sql", + 'postgres'); + my ($o0, $e0, $o1, $e1) = ('', '', '', ''); + my $h0 = IPC::Run::start(\@cmd0, '>', \$o0, '2>', \$e0); + my $h1 = IPC::Run::start(\@cmd1, '>', \$o1, '2>', \$e1); + $h0->finish; + $h1->finish; + my ($tps0) = $o0 =~ /tps = ([\d.]+)/; + my ($tps1) = $o1 =~ /tps = ([\d.]+)/; + die "pgbench node0 produced no tps:\n$o0\n$e0\n" unless defined $tps0; + die "pgbench node1 produced no tps:\n$o1\n$e1\n" unless defined $tps1; + return ($tps0, $tps1); +} + +sub run_leg +{ + my ($workers, $round) = @_; + my $name = "ps${workers}r${round}s" . ($stamp % 100000); + logline("== leg lms_workers=$workers round $round: forming pair $name"); + + my @extra = grep { length } + map { s/^\s+|\s+\z//gr } split /;/, ($ENV{PS_EXTRA_GUC} // ''); + my $pair = PostgreSQL::Test::ClusterPair->new_pair( + $name, + quorum_voting_disks => 3, + shared_data => 1, + storage_backend => 'block_device', + data_port_span => $workers, + extra_conf => [ @base_conf, @extra, "cluster.lms_workers = $workers" ]); + $pair->start_pair; + Time::HiRes::usleep(2_000_000); + $pair->wait_for_peer_state(0, 1, 'connected', 30) + or die "$name: CONTROL 0->1 never connected\n"; + $pair->wait_for_peer_state(1, 0, 'connected', 30) + or die "$name: CONTROL 1->0 never connected\n"; + my ($node0, $node1) = ($pair->node0, $pair->node1); + + poll_write_ok($node0, 'CREATE TABLE wg0 (x int)') + or die "$name: node0 write gate never opened\n"; + poll_write_ok($node1, 'CREATE TABLE wg1 (x int)') + or die "$name: node1 write gate never opened\n"; + + # shared-table relfilenode coincidence (t/347 OID-align pattern) + my ($p0, $p1, $q0, $q1) = ('', '', '', ''); + for my $attempt (1 .. 8) + { + for my $n ($node0, $node1) + { + $n->safe_psql('postgres', + 'CREATE TABLE pool_t (aid int, bal int) WITH (fillfactor = 50)'); + $n->safe_psql('postgres', 'CREATE TABLE pool_one (aid int, bal int)'); + } + $p0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_t')}); + $p1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_t')}); + $q0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_one')}); + $q1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_one')}); + last if $p0 eq $p1 && $q0 eq $q1; + my ($n0) = $p0 =~ /(\d+)$/; + my ($n1) = $p1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + $burn = 1 if $burn <= 0; + $lag->safe_psql('postgres', + "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + for my $n ($node0, $node1) + { + $n->safe_psql('postgres', 'DROP TABLE pool_t'); + $n->safe_psql('postgres', 'DROP TABLE pool_one'); + } + } + die "$name: shared-table coincidence failed ($p0 vs $p1 / $q0 vs $q1)\n" + unless $p0 eq $p1 && $q0 eq $q1; + + $node0->safe_psql('postgres', + 'INSERT INTO pool_t SELECT g, 0 FROM generate_series(1, 5000) g'); + $node0->safe_psql('postgres', 'INSERT INTO pool_one VALUES (1, 0)'); + $node0->safe_psql('postgres', 'CHECKPOINT'); + $node1->safe_psql('postgres', 'CHECKPOINT'); + + # warmup: one full-table swap each way heats the GRD / lock state. + $node0->safe_psql('postgres', 'UPDATE pool_t SET bal = bal + 1'); + $node1->safe_psql('postgres', 'UPDATE pool_t SET bal = bal + 1'); + $node1->safe_psql('postgres', 'SELECT count(*) FROM pool_one'); + + # ---- multi-tag concurrent phase ---- + my %pre; + for my $i (0, 1) + { + my $n = ($node0, $node1)[$i]; + $pre{"reply$i"} = state_int($n, 'gcs', 'block_reply_count'); + $pre{"serve$i"} = state_int($n, 'lms', 'lms_direct_reply_count'); + $pre{"sw0_$i"} = state_int($n, 'lms', 'lms_inline_serve_count_w0'); + $pre{"sw1_$i"} = state_int($n, 'lms', 'lms_inline_serve_count_w1'); + } + my $t0 = Time::HiRes::time(); + my ($tps0, $tps1) = run_pgbench_pair($node0, 'writer_churn', $PS_CLIENTS, + $node1, 'reader_cr', $PS_CLIENTS, $PS_SECS); + my $elapsed = Time::HiRes::time() - $t0; + + my %post; + for my $i (0, 1) + { + my $n = ($node0, $node1)[$i]; + $post{"reply$i"} = state_int($n, 'gcs', 'block_reply_count'); + $post{"serve$i"} = state_int($n, 'lms', 'lms_direct_reply_count'); + $post{"sw0_$i"} = state_int($n, 'lms', 'lms_inline_serve_count_w0'); + $post{"sw1_$i"} = state_int($n, 'lms', 'lms_inline_serve_count_w1'); + } + my $ship = ($post{serve0} - $pre{serve0}) + ($post{serve1} - $pre{serve1}); + my $ship_rate = $ship / $elapsed; + my $img_ship = ($post{reply0} - $pre{reply0}) + ($post{reply1} - $pre{reply1}); + + # ---- single-tag phase ---- + my $hpre = snapshot_hist($node0, $node1); + my ($stps0, $stps1) = run_pgbench_pair($node0, 'single_update', 1, + $node1, 'single_select', 1, $PS_SINGLE_SECS); + my $hpost = snapshot_hist($node0, $node1); + my ($p99, $samples) = hist_p99($hpre, $hpost); + + my %m = ( + workers => $workers, + round => $round, + ship => $ship, + secs => $elapsed, + ship_rate => $ship_rate, + img_ship => $img_ship, + tps_w => $tps0, + tps_r => $tps1, + tps_sum => $tps0 + $tps1, + disp_w0 => ($post{sw0_0} - $pre{sw0_0}) + ($post{sw0_1} - $pre{sw0_1}), + disp_w1 => ($post{sw1_0} - $pre{sw1_0}) + ($post{sw1_1} - $pre{sw1_1}), + p99_us => $p99, + p99_n => $samples, + stps_sum => $stps0 + $stps1, + ); + logline(sprintf( + "== leg w=%d r%d: serve-ship=%d in %.1fs -> %.1f/s img_ship=%d " + . "tps w=%.1f r=%.1f serve w0=%d w1=%d single-tag p99<=%s us (n=%d)", + $workers, $round, $ship, $elapsed, $ship_rate, $img_ship, + $tps0, $tps1, $m{disp_w0}, $m{disp_w1}, $p99 // 'inf', $samples)); + + $pair->stop_pair; + return \%m; +} + +sub median +{ + my @s = sort { $a <=> $b } grep { defined } @_; + return undef unless @s; + return $s[int(@s / 2)]; +} + +# ---- drive both legs ---- +my %runs; # workers -> [round metrics...] +for my $workers (1, 2) +{ + for my $round (1 .. $PS_ROUNDS) + { + push @{ $runs{$workers} }, run_leg($workers, $round); + } +} + +my %med; +for my $workers (1, 2) +{ + $med{$workers}{ship_rate} = median(map { $_->{ship_rate} } @{ $runs{$workers} }); + $med{$workers}{tps_sum} = median(map { $_->{tps_sum} } @{ $runs{$workers} }); + $med{$workers}{p99_us} = + median(map { $_->{p99_us} } grep { defined $_->{p99_us} } @{ $runs{$workers} }); +} + +my $ratio = ($med{1}{ship_rate} && $med{1}{ship_rate} > 0) + ? $med{2}{ship_rate} / $med{1}{ship_rate} + : undef; +my $p99_ratio = (defined $med{1}{p99_us} && $med{1}{p99_us} > 0 && defined $med{2}{p99_us}) + ? $med{2}{p99_us} / $med{1}{p99_us} + : undef; + +my $gate_ship = (defined $ratio && $ratio >= 1.6) ? 'PASS' : 'FAIL'; +my $gate_p99 = (defined $p99_ratio && $p99_ratio <= 1.10) ? 'PASS' + : (defined $p99_ratio ? 'FAIL' : 'n/a (hist bucket resolution)'); + +# ---- report ---- +make_path(dirname($PS_OUT)); +open(my $fh, '>', $PS_OUT) or die "cannot write $PS_OUT: $!\n"; +print $fh "# spec-7.3 D9/Q9 pool-scaling gate (本机 loopback 口径)\n\n"; +print $fh "- date: " . scalar(localtime($stamp)) . "\n"; +print $fh "- knobs: PS_SECS=$PS_SECS PS_SINGLE_SECS=$PS_SINGLE_SECS " + . "PS_ROUNDS=$PS_ROUNDS PS_CLIENTS=$PS_CLIENTS/node " + . "PS_EXTRA_GUC=" . ($ENV{PS_EXTRA_GUC} // '(none)') . "\n"; +print $fh "- workload: multi-tag = node0 UPDATE churn (deep undo chains) +\n" + . " node1 lagged-REPEATABLE-READ full scans -- every dirtied block is\n" + . " CR-CONSTRUCTED by node0's LMS pool and shipped (the spec-7.3\n" + . " motivating head-of-line); single-tag = 1-client update/read\n" + . " ping-pong on a single-block table. serve-ships = the D8\n" + . " lms_direct_reply_count aggregate (CR/undo/verdict serve replies).\n\n"; +print $fh "| leg | round | serve-ships/s | img-ships | tps(w+r) | serve w0 | serve w1 | 1-tag p99(us) |\n"; +print $fh "|---|---|---|---|---|---|---|\n"; +for my $workers (1, 2) +{ + for my $m (@{ $runs{$workers} }) + { + printf $fh "| w=%d | %d | %.1f | %d | %.1f | %d | %d | %s |\n", + $workers, $m->{round}, $m->{ship_rate}, $m->{img_ship}, + $m->{tps_sum}, $m->{disp_w0}, $m->{disp_w1}, $m->{p99_us} // 'inf'; + } +} +print $fh "\n## medians + gate\n\n"; +printf $fh "- serve-ship/s median: w1=%.1f w2=%.1f ratio=%s (gate >= 1.6x: **%s**)\n", + $med{1}{ship_rate}, $med{2}{ship_rate}, + defined $ratio ? sprintf('%.2fx', $ratio) : 'n/a', $gate_ship; +printf $fh "- tps(sum) median: w1=%.1f w2=%.1f\n", $med{1}{tps_sum}, $med{2}{tps_sum}; +printf $fh + "- single-tag p99 median: w1=%s us w2=%s us ratio=%s (gate <= 1.10x: **%s**)\n", + $med{1}{p99_us} // 'inf', $med{2}{p99_us} // 'inf', + defined $p99_ratio ? sprintf('%.2fx', $p99_ratio) : 'n/a', $gate_p99; +print $fh "\n> HONESTY: loopback numbers on one host; the two pgbench\n" + . "> drivers and both postmasters share the same cores, so the ratio is\n" + . "> a lower bound on isolated-hardware scaling. ships/s counts every\n" + . "> block-family REPLY (image ships + CR/undo/verdict serves). p99 is\n" + . "> a histogram upper bound (bucket edge), not an exact percentile.\n"; +close $fh; + +logline("== report: $PS_OUT"); +logline(sprintf("== GATE ship>=1.6x: %s (%s) p99<=1.10x: %s (%s)", + $gate_ship, defined $ratio ? sprintf('%.2fx', $ratio) : 'n/a', + $gate_p99, defined $p99_ratio ? sprintf('%.2fx', $p99_ratio) : 'n/a')); +exit 0; diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index d6c14b9c86..4aafcf0b59 100644 --- a/src/backend/cluster/Makefile +++ b/src/backend/cluster/Makefile @@ -78,6 +78,7 @@ OBJS = \ cluster_gcs.o \ cluster_gcs_block.o \ cluster_gcs_block_dedup.o \ + cluster_gcs_block_shard.o \ cluster_reconfig.o \ cluster_recovery_plan.o \ cluster_recovery_worker.o \ @@ -145,6 +146,7 @@ OBJS = \ cluster_lms.o \ cluster_lms_data_plane.o \ cluster_lms_outbound.o \ + cluster_lms_shard.o \ cluster_lns.o \ cluster_membership.o \ cluster_mrp.o \ diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index 7eb5ef2269..4c379c2778 100644 --- a/src/backend/cluster/cluster_cr.c +++ b/src/backend/cluster/cluster_cr.c @@ -161,6 +161,7 @@ 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; + pg_atomic_uint64 cr_server_fence_refused_count; /* spec-7.3 D7 fence ×N refuse */ /* * spec-7.1 D3-b (server side): multixact member-verdict batches served vs * refused (any unprovable updater member refuses the whole multi). @@ -311,6 +312,7 @@ cluster_cr_shmem_init(void) 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->cr_server_fence_refused_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); @@ -397,6 +399,9 @@ cluster_cr_server_stat_bump(ClusterCrServerStat which) case CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_DENIED: pg_atomic_fetch_add_u64(&CRShared->cr_server_multi_verdict_denied_count, 1); break; + case CLUSTER_CR_SERVER_STAT_FENCE_REFUSED: + pg_atomic_fetch_add_u64(&CRShared->cr_server_fence_refused_count, 1); + break; } } @@ -624,6 +629,7 @@ 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_cr_server_fence_refused_count, cr_server_fence_refused_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 54e6b39d84..9bf21e25a4 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -1,30 +1,33 @@ /*------------------------------------------------------------------------- * * cluster_cr_server.c - * pgrac spec-6.12b — CR-server runtime: the LMON↔LMS slot handoff and - * the LMON-side result shipping for cross-instance CR construction. + * pgrac spec-6.12b / spec-7.3 D6 — cross-instance CR-server runtime. * - * Split of labour (see cluster_cr_server.h banner): - * LMON (IC dispatch) validates + parks the CR request in a shmem - * slot (cluster_lms_cr_submit) — light work - * only, the dispatch loop never walks undo. - * LMS (aux process) drains PENDING slots and constructs the CR - * page (cluster_lms_cr_drain → - * cluster_cr_construct_page_for_server); every - * construction error becomes a DENIED result, - * never an LMS exit (fail-closed at the - * requester, Rule 8.A). - * LMON (tick) ships READY results direct to the requester - * (cluster_lms_cr_ship_ready) — the 72-byte - * outbound ring cannot carry a page and only - * LMON owns the IC connections. + * spec-6.12b split the origin serve across LMON (validate + park in a + * shmem slot) → LMS (drain + construct) → LMON (tick-ship), because the + * IC dispatch loop could not walk undo I/O, the 72-byte outbound ring + * could not carry a page, and only LMON owned the IC connections. + * + * spec-7.3 D6: when the GCS block family is on the DATA plane (the LMS + * worker pool owns the DATA connections + a page-carrying outbound ring), + * the worker[shard] that receives a GCS_BLOCK_FORWARD serves the request + * INLINE (cluster_gcs_block_forward_serve_inline) — no park → poll → ship + * indirection, no worker-0 handoff, no 100 ms idle latency; a slow + * construction only stalls that worker's shard (1/N). The park-serve + * path is RETAINED for the CONTROL-plane fallback: a node whose data + * plane is off (no data_addr) still dispatches the FORWARD in LMON, and + * LMON must not walk undo I/O in its tight IC loop — so it parks, LMS + * worker 0 drains, and LMON ships (the light-work rule, unchanged). + * + * Both paths share cr_serve_slot() (the PG_TRY → DENIED serve envelope) + * and cr_build_and_send_reply() (the reply build). 8.A envelope: CR + * construction (cluster_cr.c) re-throws on any uncertainty; the wrapper + * converts it to a fail-closed DENIED, never a worker/LMS exit or a + * wrong-order construction. * * The slot state word is an atomic; each transition has exactly one - * 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. + * writer (LMON: FREE→PENDING, READY→FREE; LMS: PENDING→BUSY→READY), + * so no lock is needed beyond the CAS on FREE→PENDING. * * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -38,7 +41,9 @@ * NOTES * This is a pgrac-original file. Compiled only in --enable-cluster * builds. - * Spec: spec-6.12-crossnode-cache-fusion-perf-optimization.md (wave b) + * Spec: spec-6.12-crossnode-cache-fusion-perf-optimization.md (wave b/i) + * Spec: spec-7.3-lms-worker-pool.md (D6 — inline serve on the DATA plane; + * D7 — fence ×N: the inline serve refuses to ship on a write-fenced node) * *------------------------------------------------------------------------- */ @@ -53,18 +58,23 @@ #include "cluster/cluster_cr_server.h" #include "cluster/cluster_elog.h" /* cluster_node_id */ #include "cluster/cluster_epoch.h" +#include "cluster/cluster_gcs_block_dedup.h" /* PGRAC: spec-7.3 P2-1 — note_misroute */ #include "cluster/cluster_guc.h" #include "cluster/cluster_ic_envelope.h" #include "cluster/cluster_ic_router.h" /* cluster_ic_send_envelope */ +#include "cluster/cluster_ic_tier1.h" /* PGRAC: spec-7.3 P2-1 — my DATA channel */ #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_lmon.h" /* PGRAC: spec-7.2 D1 READY-publish wakeup */ +#include "cluster/cluster_lms.h" /* PGRAC: spec-7.3 D8 per-worker serve counters */ +#include "cluster/cluster_lms_shard.h" /* PGRAC: spec-7.3 P2-1 — tag->worker shard */ +#include "cluster/cluster_mxid_stripe.h" /* cluster_mxid_is_mine (spec-7.1 D3-b) */ +#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) */ #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_write_fence.h" /* PGRAC: spec-7.3 D7 fence ×N gate */ #include "cluster/cluster_xid_stripe.h" /* cluster_xid_is_mine (spec-6.15 D4) */ #include "miscadmin.h" #include "storage/latch.h" @@ -72,12 +82,14 @@ #include "storage/shmem.h" #include "utils/elog.h" #include "utils/memutils.h" +#include "utils/timestamp.h" /* PGRAC: spec-7.3 D8 serve duration (GetCurrentTimestamp) */ /* * Shmem: the slot table + the published LMS latch pointer (set by LmsMain * at entry so the LMON submit path can cut the 100 ms idle-poll latency; * a stale pointer after an LMS crash only risks a spurious latch set on a - * reused PGPROC — benign). + * reused PGPROC — benign). Used by the CONTROL-plane park-serve path only + * (spec-7.3 D6: the DATA plane serves inline and never parks). */ typedef struct ClusterCrServerShared { pg_atomic_uint64 lms_latch_ptr; /* (uintptr_t) Latch*; 0 = not running */ @@ -144,12 +156,11 @@ cr_server_wake_lms(void) } /* - * cluster_lms_cr_submit — LMON dispatch side. Park a CR request. - * - * The caller (the GCS_BLOCK_FORWARD handler) has already range-checked - * the transition id and knows the payload carries the CR flag. false = - * data plane off / no capacity: the caller replies the fail-closed - * DENIED immediately (the requester keeps 53R9G — Rule 8.A). + * cluster_lms_cr_submit — CONTROL-plane park. The caller (the GCS_BLOCK_ + * FORWARD handler running in LMON when the family is on the control plane) + * has already range-checked the transition id and knows the payload carries + * the CR flag. false = data plane off / no capacity: the caller replies the + * fail-closed DENIED immediately (the requester keeps 53R9G — Rule 8.A). */ bool cluster_lms_cr_submit(const GcsBlockForwardPayload *fwd) @@ -163,10 +174,7 @@ cluster_lms_cr_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - /* 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)) + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) continue; slot->tag = fwd->tag; @@ -191,13 +199,13 @@ cluster_lms_cr_submit(const GcsBlockForwardPayload *fwd) } /* - * cluster_lms_undo_fetch_submit — LMON dispatch side (spec-6.12i D-i1). + * cluster_lms_undo_fetch_submit — CONTROL-plane park (spec-6.12i D-i1). * - * Park an undo-TT fetch request. The caller (the GCS_BLOCK_FORWARD - * handler) branches on the undo-fetch flag BEFORE any GRD / holder logic - * can interpret the synthetic tag. false = wave GUC off on this node / - * malformed synthetic tag / no capacity: the caller replies the - * fail-closed DENIED immediately (the requester keeps 53R97 — Rule 8.A). + * Park an undo-TT fetch request. The caller branches on the undo-fetch + * flag BEFORE any GRD / holder logic can interpret the synthetic tag. + * false = wave GUC off on this node / malformed synthetic tag / no capacity: + * the caller replies the fail-closed DENIED immediately (the requester keeps + * 53R97 — Rule 8.A). */ bool cluster_lms_undo_fetch_submit(const GcsBlockForwardPayload *fwd) @@ -216,10 +224,7 @@ cluster_lms_undo_fetch_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - /* 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)) + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) continue; slot->tag = fwd->tag; @@ -248,7 +253,7 @@ cluster_lms_undo_fetch_submit(const GcsBlockForwardPayload *fwd) } /* - * cluster_lms_undo_verdict_submit — LMON dispatch side (spec-6.12i D-i4 / + * cluster_lms_undo_verdict_submit — CONTROL-plane park (spec-6.12i D-i4 / * spec-6.15 D4). * * Park a complete-scan verdict request. The asked-for xid rides the @@ -280,10 +285,7 @@ cluster_lms_undo_verdict_submit(const GcsBlockForwardPayload *fwd) ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; uint32 expected = CLUSTER_LMS_CR_FREE; - /* 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)) + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_PENDING)) continue; slot->tag = fwd->tag; @@ -778,116 +780,97 @@ lms_undo_multi_verdict_serve(ClusterLmsCrSlot *slot) } /* - * cluster_lms_cr_drain — LMS main-loop side. Construct every PENDING slot. - * - * The current block must be resident here (this node's undo holds the - * newest chains, so it was — or recently was — the writer); a stable copy - * is taken with the raw-pin ship helper. Every failure (not resident, - * interleaved homes, snapshot-too-old, corruption, injection) becomes a - * DENIED result: the requester keeps its unchanged 53R9G fail-closed and - * LMS itself NEVER exits over a serve (PG_TRY + FlushErrorState). + * cr_serve_slot — serve one populated request carrier. Shared by the + * CONTROL-plane park drain and the D6 DATA-plane inline serve. Sets + * slot->result_status and fills result_page / undo_auth; every failure + * (interleaved homes, snapshot-too-old, corruption, non-own xid, read + * failure, injection, wave GUC off) becomes a DENIED result under the + * PG_TRY -> DENIED envelope — the worker/LMS NEVER exits over a serve and + * NEVER constructs out of order (Rule 8.A). Does not touch the slot state + * word or ship (callers own those). Assumes the carrier is already + * validated + populated (submit / serve_inline decode the synthetic address + * + carrier before calling). */ -void -cluster_lms_cr_drain(void) +static void +cr_serve_slot(ClusterLmsCrSlot *slot) { - if (CrServerShared == NULL) - return; - - for (int i = 0; i < CLUSTER_LMS_CR_SLOTS; i++) { - ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; - uint32 expected = CLUSTER_LMS_CR_PENDING; - PGAlignedBlock cur_copy; - XLogRecPtr page_lsn = InvalidXLogRecPtr; - bool partial = false; - bool constructed = false; - - if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_BUSY)) - continue; - pg_read_barrier(); /* pair with the submit-side publish barrier */ - - slot->result_status = (uint8)GCS_BLOCK_REPLY_DENIED_MASTER_NOT_HOLDER; - - /* - * spec-6.12i D-i1 / D-i4 — undo-TT fetch + undo-verdict kinds. No - * construction: read the self-owned TT header block (FETCH), or run - * the complete own-TT by-xid scan + CLOG cross-check (VERDICT); both - * co-sample the live authority triple. Every refusal (GUC raced - * 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 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 - || 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; - } + slot->result_status = (uint8)GCS_BLOCK_REPLY_DENIED_MASTER_NOT_HOLDER; + + /* + * spec-6.12i D-i1 / D-i4 — undo-TT fetch + undo-verdict kinds. No + * construction: read the self-owned TT header block (FETCH), or run the + * complete own-TT by-xid scan + CLOG cross-check (VERDICT); both co-sample + * the live authority triple. The injection point is shared by both kinds: + * it models "the origin's undo serve plane is down", refusing fetches and + * verdicts alike. + */ + if (slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_FETCH + || 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(); - { - 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(); - { - /* Fail-closed serve; keep LMS alive (as the CR kind). */ - served = false; - MemoryContextSwitchTo(TopMemoryContext); - FlushErrorState(); + CLUSTER_INJECTION_POINT("cluster-lms-undo-fetch"); + if (!cluster_injection_should_skip("cluster-lms-undo-fetch")) { + PG_TRY(); + { + 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_END_TRY(); } - - if (served) { - slot->result_status = result_status; - cluster_cr_server_stat_bump(served_stat); - } else { - cluster_cr_server_stat_bump(denied_stat); + PG_CATCH(); + { + /* Fail-closed serve; keep the worker/LMS alive. */ + served = false; + MemoryContextSwitchTo(TopMemoryContext); + FlushErrorState(); } + PG_END_TRY(); + } - pg_write_barrier(); - pg_atomic_write_u32(&slot->state, CLUSTER_LMS_CR_READY); - /* PGRAC: spec-7.2 D1 -- wake the shipper (LMON) right away; - * without this the READY result sat until LMON's next natural - * wakeup (typ. 100-250ms, worst case one heartbeat). - * Publish-before-signal: READY store above precedes the kick. */ - cluster_lmon_duty_mark_dirty(CLUSTER_LMON_DUTY_SHIP_READY); - cluster_lmon_wakeup(); - continue; + if (served) { + slot->result_status = result_status; + cluster_cr_server_stat_bump(served_stat); + } else { + cluster_cr_server_stat_bump(denied_stat); } + return; + } + + /* spec-6.12b CR construction. */ + { + PGAlignedBlock cur_copy; + XLogRecPtr page_lsn = InvalidXLogRecPtr; + bool partial = false; + bool constructed = false; /* spec-6.12b injection — force the DENIED serve path. */ CLUSTER_INJECTION_POINT("cluster-lms-cr-construct"); @@ -903,10 +886,10 @@ cluster_lms_cr_drain(void) PG_CATCH(); { /* - * Fail-closed serve: keep the DENIED status and keep LMS - * alive. The taxonomy counters were already bumped by the - * construction wrapper; drop the error state entirely (an - * aux-process ERROR would otherwise escalate to exit). + * Fail-closed serve: keep the DENIED status and keep the + * worker/LMS alive. The taxonomy counters were already bumped + * by the construction wrapper; drop the error state entirely + * (an aux-process ERROR would otherwise escalate to exit). */ constructed = false; MemoryContextSwitchTo(TopMemoryContext); @@ -923,23 +906,104 @@ cluster_lms_cr_drain(void) } else { cluster_cr_server_stat_bump(CLUSTER_CR_SERVER_STAT_DENIED); } + } +} + +/* + * cr_build_and_send_reply — build the standard GCS_BLOCK_REPLY (header + + * BLCKSZ page, + ClusterGcsUndoAuthTrailer for served undo kinds) with the + * HC109 forwarding-master echo the requester's HC108 chain expects, and send + * it to the requester. A DENIED result ships a zero page under a matching + * checksum (the requester never consumes DENIED bytes). Shared by the + * CONTROL-plane LMON ship and the D6 DATA-plane inline serve. + */ +static void +cr_build_and_send_reply(const ClusterLmsCrSlot *slot) +{ + uint32 header_len = (uint32)sizeof(GcsBlockReplyHeader); + uint32 total = header_len + GCS_BLOCK_DATA_SIZE; + char *buf; + GcsBlockReplyHeader *hdr; + + /* spec-6.12i: a served undo-TT fetch / undo verdict appends the authority + * trailer (tt_generation); DENIED undo replies stay v1-sized. */ + if (GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)slot->result_status)) + total += (uint32)sizeof(ClusterGcsUndoAuthTrailer); + buf = (char *)palloc0(total); + hdr = (GcsBlockReplyHeader *)buf; + hdr->request_id = slot->request_id; + hdr->page_lsn = 0; + hdr->epoch = cluster_epoch_get_current(); + hdr->sender_node = cluster_node_id; + hdr->requester_backend_id = slot->requester_backend; + hdr->transition_id = slot->transition_id; + hdr->status = slot->result_status; + GcsBlockReplyHeaderSetForwardingMasterNode(hdr, slot->reply_master_node); + + 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 (GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)hdr->status)) { + ClusterGcsUndoAuthTrailer *trailer + = (ClusterGcsUndoAuthTrailer *)(buf + header_len + GCS_BLOCK_DATA_SIZE); + + /* + * spec-6.12i co-sample carriage: the authority sampled ATOMICALLY + * with the content read overrides the ship-time header fields — + * epoch carries the sampled origin epoch (the HC100 `hdr.epoch >= + * request_epoch` check then drops a mid-reconfig reply, which IS the + * D-i3 fail-closed) and page_lsn carries live_hwm_lsn. + */ + hdr->epoch = slot->undo_auth.origin_epoch; + hdr->page_lsn = slot->undo_auth.live_hwm_lsn; + memcpy(buf + header_len, slot->result_page, BLCKSZ); + ClusterGcsUndoAuthTrailerSetTtGeneration(trailer, slot->undo_auth.tt_generation); + ClusterGcsUndoAuthTrailerSetAuthorityScn(trailer, (uint64)slot->undo_auth.authority_scn); + } + hdr->checksum = cluster_gcs_block_compute_checksum(buf + header_len); + + (void)cluster_ic_send_envelope(PGRAC_IC_MSG_GCS_BLOCK_REPLY, slot->requester_node, buf, total); + pfree(buf); +} + +/* + * cluster_lms_cr_drain — CONTROL-plane park drain (LMS worker 0 main loop). + * Serve every PENDING slot into a READY result (errors become DENIED; LMS + * never exits over a serve). DATA-plane requests are served inline and never + * reach this table, so on a data-plane node the loop is a no-op. + */ +void +cluster_lms_cr_drain(void) +{ + if (CrServerShared == NULL) + return; + + for (int i = 0; i < CLUSTER_LMS_CR_SLOTS; i++) { + ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; + uint32 expected = CLUSTER_LMS_CR_PENDING; + + if (!pg_atomic_compare_exchange_u32(&slot->state, &expected, CLUSTER_LMS_CR_BUSY)) + continue; + pg_read_barrier(); /* pair with the submit-side publish barrier */ + + cr_serve_slot(slot); pg_write_barrier(); pg_atomic_write_u32(&slot->state, CLUSTER_LMS_CR_READY); - /* PGRAC: spec-7.2 D1 -- wake the shipper (see the undo/verdict - * READY publish above for the rationale). */ + /* PGRAC: spec-7.2 D1 -- wake the shipper (LMON) right away; without + * this the READY result sat until LMON's next natural wakeup (typ. + * 100-250ms). Publish-before-signal: READY store above precedes the + * kick. */ cluster_lmon_duty_mark_dirty(CLUSTER_LMON_DUTY_SHIP_READY); cluster_lmon_wakeup(); } } /* - * cluster_lms_cr_ship_ready — LMON tick side. Ship READY results. - * - * Builds the standard GCS_BLOCK_REPLY (header + BLCKSZ page) with the - * HC109 forwarding-master echo the requester's HC108 chain expects, and - * frees the slot. A DENIED result ships a zero page under a matching - * checksum (the requester never consumes DENIED bytes). + * cluster_lms_cr_ship_ready — CONTROL-plane ship (LMON tick). Ship every + * READY result to its requester and free the slot. DATA-plane requests are + * shipped inline (see cluster_gcs_block_forward_serve_inline) and never reach + * this table. */ void cluster_lms_cr_ship_ready(void) @@ -949,65 +1013,206 @@ cluster_lms_cr_ship_ready(void) for (int i = 0; i < CLUSTER_LMS_CR_SLOTS; i++) { ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; - uint32 expected = CLUSTER_LMS_CR_READY; - uint32 header_len; - uint32 total; - char *buf; - GcsBlockReplyHeader *hdr; if (pg_atomic_read_u32(&slot->state) != CLUSTER_LMS_CR_READY) continue; - (void)expected; /* single shipper (LMON tick); state re-checked above */ pg_read_barrier(); - header_len = (uint32)sizeof(GcsBlockReplyHeader); - total = header_len + GCS_BLOCK_DATA_SIZE; - /* spec-6.12i: a served undo-TT fetch / undo verdict appends the - * authority trailer (tt_generation); DENIED undo replies stay - * v1-sized. */ - if (GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)slot->result_status)) - total += (uint32)sizeof(ClusterGcsUndoAuthTrailer); - buf = (char *)palloc0(total); - hdr = (GcsBlockReplyHeader *)buf; - hdr->request_id = slot->request_id; - hdr->page_lsn = 0; - hdr->epoch = cluster_epoch_get_current(); - hdr->sender_node = cluster_node_id; - hdr->requester_backend_id = slot->requester_backend; - hdr->transition_id = slot->transition_id; - hdr->status = slot->result_status; - GcsBlockReplyHeaderSetForwardingMasterNode(hdr, slot->reply_master_node); - - 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 (GcsBlockReplyStatusCarriesUndoAuthTrailer((GcsBlockReplyStatus)hdr->status)) { - ClusterGcsUndoAuthTrailer *trailer - = (ClusterGcsUndoAuthTrailer *)(buf + header_len + GCS_BLOCK_DATA_SIZE); - - /* - * spec-6.12i co-sample carriage: the authority the LMS sampled - * ATOMICALLY with the content read overrides the ship-time - * header fields — epoch carries the sampled origin epoch (the - * HC100 `hdr.epoch >= request_epoch` check then drops a - * mid-reconfig reply, which IS the D-i3 fail-closed) and - * page_lsn carries live_hwm_lsn. - */ - hdr->epoch = slot->undo_auth.origin_epoch; - hdr->page_lsn = slot->undo_auth.live_hwm_lsn; - memcpy(buf + header_len, slot->result_page, BLCKSZ); - ClusterGcsUndoAuthTrailerSetTtGeneration(trailer, slot->undo_auth.tt_generation); - ClusterGcsUndoAuthTrailerSetAuthorityScn(trailer, - (uint64)slot->undo_auth.authority_scn); + cr_build_and_send_reply(slot); + + pg_atomic_write_u32(&slot->state, CLUSTER_LMS_CR_FREE); + } +} + +/* + * spec-7.3 D6 — scratch context for one inline serve. CR construction and + * the reply build palloc transient state; the inline serve runs inside the + * worker's long-lived DATA-dispatch loop, so it must reset its own scratch + * rather than leak into that context. Lazily created per worker process. + */ +static MemoryContext CrServeScratchCtx = NULL; + +static MemoryContext +cr_serve_scratch_context(void) +{ + if (CrServeScratchCtx == NULL) + CrServeScratchCtx = AllocSetContextCreate(TopMemoryContext, "cluster cr inline serve", + ALLOCSET_DEFAULT_SIZES); + return CrServeScratchCtx; +} + +/* + * cluster_gcs_block_forward_serve_inline — spec-7.3 D6 entry. When the GCS + * block family is on the DATA plane, the worker[shard] that received a + * GCS_BLOCK_FORWARD CR / undo-fetch / undo-verdict request serves it inline + * and ships the reply on its own DATA channel — no shmem slot, no worker-0 + * poll handoff, no 100 ms idle latency. The forward handler routes to this + * only when cluster_gcs_block_family_on_data_plane(): on the CONTROL plane + * the request must go through the light-work park path instead (LMON must not + * walk undo I/O in its dispatch loop). + * + * Populates the request carrier from the forward payload (decoding the + * synthetic undo address / xid carrier as the submit path does), then serves + * it through the shared cr_serve_slot() envelope and ships exactly one reply + * (the result, or a fail-closed DENIED on any refusal / wave-GUC-off / + * malformed request — the requester keeps its unchanged 53R9G / 53R97, Rule + * 8.A). Everything runs inside a per-call scratch context that is reset on + * return so the long-lived DATA-dispatch loop never accumulates transients. + */ +void +cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, ClusterLmsCrSlotKind kind) +{ + ClusterLmsCrSlot slot; + MemoryContext old; + uint32 segment_id = 0; + uint32 block_no = 0; + bool inject_refuse; + TimestampTz serve_started_at; + + if (fwd == NULL) + return; + + /* + * PGRAC: spec-7.3 D5 (review P2-1) — per-worker shard routing guard, + * FORWARD inline-serve entry. Same invariant as the REQUEST dedup + * guard (cluster_gcs_block.c): a block-family frame's tag routes to + * exactly one LMS worker (worker[shard(tag)], D4), and this serve runs + * in the worker whose DATA channel received the envelope. A mismatch + * is a mis-route (per-tag order break, 8.A) that cannot happen without + * a code bug — fail closed (drop without serving; the requester + * retransmits within its budget and 53R90/53R9G fail-closes) rather + * than serve a tag this worker does not own. + */ + { + int recv_worker = cluster_ic_tier1_my_data_channel(); + int tag_shard = cluster_lms_shard_for_tag(&fwd->tag, cluster_lms_workers); + + Assert(tag_shard == recv_worker); + if (tag_shard != recv_worker) { + static bool misroute_logged = false; + + cluster_gcs_block_dedup_note_misroute(); + if (!misroute_logged) { + misroute_logged = true; + ereport(LOG, + (errmsg_internal("gcs block forward misrouted to LMS worker %d (tag shard " + "%d); dropping (spec-7.3 P2-1 8.A fail-closed)", + recv_worker, tag_shard))); + } + return; } - hdr->checksum = cluster_gcs_block_compute_checksum(buf + header_len); + } - (void)cluster_ic_send_envelope(PGRAC_IC_MSG_GCS_BLOCK_REPLY, slot->requester_node, buf, - total); - pfree(buf); + /* Populate the request carrier from the forward payload (was submit). */ + memset(&slot, 0, sizeof(slot)); + slot.tag = fwd->tag; + 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)kind; + + /* + * PGRAC: spec-7.3 D7 — fence ×N. A write-fenced node must not let block + * images / undo bytes / commit verdicts leave: while the cooperative + * write-fence is enforcing but this node is NOT authorized for the live + * epoch, any served payload could be stale relative to the cluster's + * authoritative state — a split-brain read (CR image) or a false-committed + * verdict (stale commit_scn) surface (Rule 8.A). The worker-0 park ship + * path keeps this gate (cluster_lms.c fence-gates cr_ship_ready); D6's move + * to inline serve dropped it. Restoring it HERE — ahead of the kind switch + * and any construct/read — covers every worker (0..7) and all three kinds + * uniformly. Refuse by shipping the pre-set DENIED result WITHOUT reading + * or constructing anything; the requester retransmits within its budget and + * 53R90 fail-closes if the fence outlasts it — never a stale ship. The + * injection forces the same branch deterministically for the TAP fence leg. + */ + CLUSTER_INJECTION_POINT("cluster-lms-cr-fence-refuse"); + /* Consume a pending injection arm unconditionally (F6-1 local-var idiom): + * evaluating it as the second || operand would let a genuine fence + * short-circuit past the consume, leaking the arm to a later call. */ + inject_refuse = cluster_injection_should_skip("cluster-lms-cr-fence-refuse"); + if ((cluster_write_fence_enforcing() && !cluster_write_fence_allowed()) || inject_refuse) { + cluster_cr_server_stat_bump(CLUSTER_CR_SERVER_STAT_FENCE_REFUSED); + old = MemoryContextSwitchTo(cr_serve_scratch_context()); + cr_build_and_send_reply(&slot); /* slot.result_status == DENIED */ + MemoryContextSwitchTo(old); + MemoryContextReset(CrServeScratchCtx); + cluster_lms_obs_note_direct_reply(); /* spec-7.3 D8 */ + return; + } - pg_atomic_write_u32(&slot->state, CLUSTER_LMS_CR_FREE); + switch (kind) { + case CLUSTER_LMS_SLOT_KIND_CR: + slot.read_scn = GcsBlockForwardPayloadGetExpectedPiWatermarkScn(fwd); + break; + + case CLUSTER_LMS_SLOT_KIND_UNDO_FETCH: + slot.read_scn = InvalidScn; + /* A malformed tag leaves segment_id 0 -> lms_undo_fetch_serve refuses. */ + (void)GcsBlockUndoFetchTagDecode(fwd->tag, &segment_id, &block_no); + slot.undo_segment_id = segment_id; + slot.undo_block_no = block_no; + slot.undo_xid = InvalidTransactionId; + break; + + case CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT: { + uint64 carrier = (uint64)GcsBlockForwardPayloadGetExpectedPiWatermarkScn(fwd); + + slot.read_scn = InvalidScn; + (void)GcsBlockUndoFetchTagDecode(fwd->tag, &segment_id, &block_no); + slot.undo_segment_id = segment_id; + slot.undo_block_no = block_no; + /* A malformed carrier (upper 32 bits set / non-normal) leaves xid + * Invalid -> lms_undo_verdict_serve refuses. */ + slot.undo_xid + = (carrier <= (uint64)PG_UINT32_MAX && TransactionIdIsNormal((TransactionId)carrier)) + ? (TransactionId)carrier + : InvalidTransactionId; + break; + } + } + + old = MemoryContextSwitchTo(cr_serve_scratch_context()); + /* PGRAC: spec-7.3 D8 — time the serve into the calling worker's duration + * histogram (the R4 slow-shard backstop: a slow CR construction stalls + * only this shard, and the per-worker hist is where that shows). */ + serve_started_at = GetCurrentTimestamp(); + cr_serve_slot(&slot); + cluster_lms_obs_note_inline_serve((uint64)(GetCurrentTimestamp() - serve_started_at)); + /* A caught construction throw left CurrentMemoryContext at TopMemoryContext; + * normalize back to the scratch context before the reply build. */ + MemoryContextSwitchTo(cr_serve_scratch_context()); + + /* + * PGRAC: spec-7.3 D7 (review P1-1) — re-check the fence at SHIP time. + * The gate above runs BEFORE construction; the serve itself walks undo + * I/O / TT scans (ms-scale), so a qvotec lease that expires DURING the + * serve would otherwise let the just-constructed image / verdict leave + * the now-fenced node — stale bytes the cluster's authoritative state + * has moved past (Rule 8.A). The park ship path never had this window + * (cluster_lms.c gates cr_ship_ready at ship time, per tick); restore + * that timing here by discarding the constructed result and shipping + * the fail-closed DENIED instead — the requester retransmits within its + * budget and 53R90/53R9G fail-closes if the fence outlasts it. The + * probe is a pure in-memory time comparison (no I/O on the hot path). + * The injection forces this branch deterministically for the TAP + * TOCTOU leg (a genuine mid-serve lease expiry is not schedulable from + * TAP); same unconditional-consume idiom as the gate above (F6-1). + */ + CLUSTER_INJECTION_POINT("cluster-lms-cr-fence-recheck"); + inject_refuse = cluster_injection_should_skip("cluster-lms-cr-fence-recheck"); + if ((cluster_write_fence_enforcing() && !cluster_write_fence_allowed()) || inject_refuse) { + slot.result_status = (uint8)GCS_BLOCK_REPLY_DENIED_MASTER_NOT_HOLDER; + cluster_cr_server_stat_bump(CLUSTER_CR_SERVER_STAT_FENCE_REFUSED); } + cr_build_and_send_reply(&slot); + MemoryContextSwitchTo(old); + MemoryContextReset(CrServeScratchCtx); + cluster_lms_obs_note_direct_reply(); /* spec-7.3 D8 */ } #endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 8783bf0a0c..1e8cd28be9 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -134,18 +134,19 @@ PG_FUNCTION_INFO_V1(cluster_dump_state); #include "cluster/cluster_xid_authority.h" /* XID authority state (spec-6.15b D7) */ #include "cluster/cluster_remote_xact.h" /* remote outcome counters (spec-4.5a D11) */ #include "cluster/cluster_ic.h" /* ClusterICOps_Active, ClusterICTier */ -#include "cluster/cluster_ic_tier1.h" /* listener metadata accessors (Hardening v1.0.1 F3) */ -#include "cluster/cluster_scn.h" /* SCN typedef (stage 1.4) */ -#include "cluster/cluster_itl_slot.h" /* CLUSTER_ITL_* constants (stage 1.5) */ -#include "cluster/cluster_buffer_desc.h" /* BufferType / PcmState enums (stage 1.6) */ -#include "cluster/cluster_pcm_lock.h" /* PCM state-machine API + grd helpers */ -#include "cluster/cluster_gcs.h" /* GCS request protocol surface (spec-2.32 D8) */ -#include "cluster/cluster_gcs_block.h" /* GCS block-ship data plane (spec-2.33 D10) */ -#include "cluster/cluster_sinval.h" /* SI Broadcaster counter accessors (spec-2.38 D10) */ -#include "cluster/cluster_tt_status.h" /* TT status overlay counter accessors (spec-3.1 D9) */ -#include "cluster/cluster_tt_status_hint.h" /* TT status hint counter accessors (spec-3.2 D8) */ -#include "cluster/cluster_tx_enqueue.h" /* TX enqueue wait counters (spec-5.2 D4/D6) */ -#include "cluster/cluster_startup_phase.h" /* phase enum + accessors (stage 1.10) */ +#include "cluster/cluster_ic_tier1.h" /* listener metadata accessors (Hardening v1.0.1 F3) */ +#include "cluster/cluster_scn.h" /* SCN typedef (stage 1.4) */ +#include "cluster/cluster_itl_slot.h" /* CLUSTER_ITL_* constants (stage 1.5) */ +#include "cluster/cluster_buffer_desc.h" /* BufferType / PcmState enums (stage 1.6) */ +#include "cluster/cluster_pcm_lock.h" /* PCM state-machine API + grd helpers */ +#include "cluster/cluster_gcs.h" /* GCS request protocol surface (spec-2.32 D8) */ +#include "cluster/cluster_gcs_block.h" /* GCS block-ship data plane (spec-2.33 D10) */ +#include "cluster/cluster_gcs_block_dedup.h" /* per-worker dedup-shard counters (spec-7.3 D5/D9) */ +#include "cluster/cluster_sinval.h" /* SI Broadcaster counter accessors (spec-2.38 D10) */ +#include "cluster/cluster_tt_status.h" /* TT status overlay counter accessors (spec-3.1 D9) */ +#include "cluster/cluster_tt_status_hint.h" /* TT status hint counter accessors (spec-3.2 D8) */ +#include "cluster/cluster_tx_enqueue.h" /* TX enqueue wait counters (spec-5.2 D4/D6) */ +#include "cluster/cluster_startup_phase.h" /* phase enum + accessors (stage 1.10) */ #include "storage/bufpage.h" /* PG_PAGE_LAYOUT_VERSION, SizeOfPageHeaderData (stage 1.4) */ #include "storage/buf_internals.h" /* BufferDesc layout (stage 1.6) */ #include "cluster/cluster_pgstat.h" @@ -1320,9 +1321,66 @@ dump_lms(ReturnSetInfo *rsinfo) * on wire; reserved opcode 11 awaits spec-2.28+ integrated receiver). */ emit_row(rsinfo, "lms", "priority_starvation_observed_count", fmt_int64((int64)cluster_lms_get_priority_starvation_observed_count())); - /* spec-7.2 D6 — DATA-plane connection resets (epoch bump in production; - * cluster-lms-conn-reset injection in the F6-1 fault test). */ - emit_row(rsinfo, "lms", "lms_conn_resets", fmt_int64((int64)cluster_lms_get_conn_resets())); + + /* PGRAC: spec-7.3 D8 — per-worker DATA-plane observability. The bare + * keys are the pool aggregates (the 7.2-era single-value semantics' + * natural generalization, spec §3.8); the _w suffixed keys are the + * per-worker detail, emitted for the live pool only (w < lms_workers). */ + emit_row(rsinfo, "lms", "lms_data_dispatch_count", + fmt_int64((int64)cluster_lms_obs_get_dispatch_count(-1))); + emit_row(rsinfo, "lms", "lms_direct_reply_count", + fmt_int64((int64)cluster_lms_obs_get_direct_reply_count(-1))); + emit_row(rsinfo, "lms", "lms_conn_reset_count", + fmt_int64((int64)cluster_lms_obs_get_conn_reset_count(-1))); + emit_row(rsinfo, "lms", "lms_inline_serve_count", + fmt_int64((int64)cluster_lms_obs_get_inline_serve_count(-1))); + { + int w, hb; + + for (w = 0; w < cluster_lms_workers && w < CLUSTER_LMS_MAX_WORKERS; w++) { + char wkey[64]; + + snprintf(wkey, sizeof(wkey), "lms_data_dispatch_count_w%d", w); + emit_row(rsinfo, "lms", wkey, fmt_int64((int64)cluster_lms_obs_get_dispatch_count(w))); + snprintf(wkey, sizeof(wkey), "lms_direct_reply_count_w%d", w); + emit_row(rsinfo, "lms", wkey, + fmt_int64((int64)cluster_lms_obs_get_direct_reply_count(w))); + snprintf(wkey, sizeof(wkey), "lms_conn_reset_count_w%d", w); + emit_row(rsinfo, "lms", wkey, + fmt_int64((int64)cluster_lms_obs_get_conn_reset_count(w))); + snprintf(wkey, sizeof(wkey), "lms_inline_serve_count_w%d", w); + emit_row(rsinfo, "lms", wkey, + fmt_int64((int64)cluster_lms_obs_get_inline_serve_count(w))); + } + + /* Inline-serve duration histogram: aggregate 16 rows + per live + * worker 16 rows (keys mirror the gcs ship-hist idiom). */ + for (hb = 0; hb < CLUSTER_LMS_SERVE_HIST_BUCKETS; hb++) { + char histkey[64]; + uint64 bound = cluster_lms_obs_serve_hist_bound_us(hb); + + if (bound == UINT64_MAX) + snprintf(histkey, sizeof(histkey), "lms_serve_hist_us_inf"); + else + snprintf(histkey, sizeof(histkey), "lms_serve_hist_us_le_" UINT64_FORMAT, bound); + emit_row(rsinfo, "lms", histkey, + fmt_int64((int64)cluster_lms_obs_get_serve_hist(-1, hb))); + } + for (w = 0; w < cluster_lms_workers && w < CLUSTER_LMS_MAX_WORKERS; w++) { + for (hb = 0; hb < CLUSTER_LMS_SERVE_HIST_BUCKETS; hb++) { + char histkey[64]; + uint64 bound = cluster_lms_obs_serve_hist_bound_us(hb); + + if (bound == UINT64_MAX) + snprintf(histkey, sizeof(histkey), "lms_serve_hist_us_inf_w%d", w); + else + snprintf(histkey, sizeof(histkey), "lms_serve_hist_us_le_" UINT64_FORMAT "_w%d", + bound, w); + emit_row(rsinfo, "lms", histkey, + fmt_int64((int64)cluster_lms_obs_get_serve_hist(w, hb))); + } + } + } } /* @@ -1947,6 +2005,10 @@ dump_gcs(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_gcs_get_block_dedup_evict_count())); emit_row(rsinfo, "gcs", "dedup_max_entries", fmt_int64((int64)cluster_gcs_get_block_dedup_max_entries())); + /* spec-7.3 D9 (rule 17): the D5 per-worker dedup-shard mis-route + * fail-closed drop counter gets its SQL surface (gcs 85 -> 86). */ + emit_row(rsinfo, "gcs", "dedup_misroute_failclosed_count", + fmt_int64((int64)cluster_gcs_block_dedup_get_misroute_failclosed_count())); emit_row(rsinfo, "gcs", "epoch_invalidate_wake_count", fmt_int64((int64)cluster_gcs_get_block_epoch_invalidate_wake_count())); emit_row(rsinfo, "gcs", "stale_reply_drop_count", @@ -2666,6 +2728,10 @@ dump_cr(ReturnSetInfo *rsinfo) 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())); + /* spec-7.3 D7 — DATA-plane inline serve refused because the node is + * write-fenced (image / undo / verdict withheld to avoid a stale ship). */ + emit_row(rsinfo, "cr", "cr_server_fence_refused_count", + fmt_int64((int64)cluster_cr_server_fence_refused_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 322ab1ff51..04ed6850f5 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -48,6 +48,7 @@ #include "cluster/cluster_gcs.h" #include "cluster/cluster_cr_server.h" /* spec-6.12b CR-server park/fetch */ #include "cluster/cluster_gcs_block.h" +#include "cluster/cluster_lms_shard.h" /* PGRAC: spec-7.3 D4 — tag->worker shard */ #include "cluster/cluster_gcs_reqid.h" /* PGRAC: spec-6.14a D1 — id domains */ #include "cluster/cluster_gcs_block_dedup.h" /* spec-2.34 D1 — counter forward */ #include "cluster/cluster_grd.h" /* spec-4.6 D4 — block_path_failclosed counter */ @@ -64,6 +65,7 @@ #include "cluster/cluster_ic_envelope.h" #include "cluster/cluster_ic_rdma.h" #include "cluster/cluster_ic_router.h" +#include "cluster/cluster_ic_tier1.h" /* PGRAC: spec-7.3 D5 — my DATA channel = worker id */ #include "cluster/cluster_lmon.h" #include "cluster/cluster_pcm_lock.h" #include "cluster/cluster_shmem.h" @@ -3573,6 +3575,7 @@ cluster_gcs_handle_block_request_envelope(const ClusterICEnvelope *env, const vo GcsBlockDedupKey key; GcsBlockDedupEntry cached_entry; GcsBlockDedupResult dr; + int dedup_worker_id; /* PGRAC: spec-7.3 D5 — this request's dedup shard */ char block_buf[GCS_BLOCK_DATA_SIZE]; GcsBlockReplyStatus status; XLogRecPtr page_lsn = InvalidXLogRecPtr; @@ -3638,6 +3641,37 @@ cluster_gcs_handle_block_request_envelope(const ClusterICEnvelope *env, const vo return; } + /* + * PGRAC: spec-7.3 D5 — per-worker dedup shard routing guard. A block + * request's tag routes to exactly one LMS worker (worker[shard(tag)], + * D4), which owns that tag's private dedup shard. This handler runs in + * the worker whose DATA channel received the envelope; verify it is the + * routed worker before touching the shard. A mismatch is a mis-route + * (序破坏, 8.A): D3 negotiates a cluster-wide n_workers and D1 shard() + * is byte-identical on both ends, so this cannot happen without a code + * bug — fail closed (drop; sender retransmits via 53R90) rather than + * serve from, or contend on, a shard this worker does not own. + */ + dedup_worker_id = cluster_ic_tier1_my_data_channel(); + { + int tag_shard = cluster_lms_shard_for_tag(&req->tag, cluster_lms_workers); + + Assert(tag_shard == dedup_worker_id); + if (tag_shard != dedup_worker_id) { + static bool misroute_logged = false; + + cluster_gcs_block_dedup_note_misroute(); + if (!misroute_logged) { + misroute_logged = true; + ereport(LOG, + (errmsg_internal("gcs block request misrouted to LMS worker %d (tag shard " + "%d); dropping (spec-7.3 D5 8.A fail-closed)", + dedup_worker_id, tag_shard))); + } + return; + } + } + /* PGRAC: spec-2.34 D5 — dedup lookup_or_register (HC90 + HC91 + HC92). */ memset(&key, 0, sizeof(key)); key.origin_node_id = (uint32)req->sender_node; @@ -3646,8 +3680,8 @@ cluster_gcs_handle_block_request_envelope(const ClusterICEnvelope *env, const vo key.cluster_epoch = req->epoch; memset(&cached_entry, 0, sizeof(cached_entry)); - dr = cluster_gcs_block_dedup_lookup_or_register(&key, req->tag, req->transition_id, - &cached_entry); + dr = cluster_gcs_block_dedup_lookup_or_register(dedup_worker_id, &key, req->tag, + req->transition_id, &cached_entry); switch (dr) { case GCS_BLOCK_DEDUP_CACHED_REPLY: gcs_block_resend_cached_reply(req->sender_node, &cached_entry); @@ -4103,8 +4137,9 @@ cluster_gcs_handle_block_request_envelope(const ClusterICEnvelope *env, const vo fwd_hdr.sender_node = x_holder; fwd_hdr.status = (uint8)GCS_BLOCK_REPLY_X_GRANTED_FROM_HOLDER; GcsBlockReplyHeaderSetForwardingMasterNode(&fwd_hdr, cluster_node_id); - cluster_gcs_block_dedup_install_reply( - &key, GCS_BLOCK_REPLY_X_GRANTED_FROM_HOLDER, &fwd_hdr, NULL); + cluster_gcs_block_dedup_install_reply(dedup_worker_id, &key, + GCS_BLOCK_REPLY_X_GRANTED_FROM_HOLDER, + &fwd_hdr, NULL); return; } cluster_pcm_lock_clear_pending_x(req->tag); @@ -4445,8 +4480,9 @@ cluster_gcs_handle_block_request_envelope(const ClusterICEnvelope *env, const vo fwd_hdr.sender_node = holder_node; fwd_hdr.status = (uint8)GCS_BLOCK_REPLY_READ_IMAGE_FROM_XHOLDER; GcsBlockReplyHeaderSetForwardingMasterNode(&fwd_hdr, cluster_node_id); - cluster_gcs_block_dedup_install_reply( - &key, GCS_BLOCK_REPLY_READ_IMAGE_FROM_XHOLDER, &fwd_hdr, NULL); + cluster_gcs_block_dedup_install_reply(dedup_worker_id, &key, + GCS_BLOCK_REPLY_READ_IMAGE_FROM_XHOLDER, + &fwd_hdr, NULL); return; } /* Forward send failed — fall through to the fail-closed flow. */ @@ -4516,8 +4552,8 @@ cluster_gcs_handle_block_request_envelope(const ClusterICEnvelope *env, const vo fwd_hdr.sender_node = holder_node; /* HC113: holder stored here */ fwd_hdr.status = (uint8)GCS_BLOCK_REPLY_GRANTED_FROM_HOLDER; GcsBlockReplyHeaderSetForwardingMasterNode(&fwd_hdr, cluster_node_id); - cluster_gcs_block_dedup_install_reply(&key, GCS_BLOCK_REPLY_GRANTED_FROM_HOLDER, - &fwd_hdr, NULL); + cluster_gcs_block_dedup_install_reply( + dedup_worker_id, &key, GCS_BLOCK_REPLY_GRANTED_FROM_HOLDER, &fwd_hdr, NULL); } return; } @@ -4639,7 +4675,7 @@ build_and_send_reply: { hdr->checksum = gcs_block_compute_checksum(buf + header_len); } - cluster_gcs_block_dedup_install_reply_ex(&key, status, hdr, + cluster_gcs_block_dedup_install_reply_ex(dedup_worker_id, &key, status, hdr, has_block_payload ? block_payload : NULL, send_sf_dep ? &sf_dep_vec : NULL, send_sf_dep); @@ -4729,6 +4765,11 @@ build_and_send_reply: { } +/* cluster_gcs_block_payload_shard (spec-7.3 D4) lives in + * cluster_gcs_block_shard.c — extracted at D9 as a pure file so the + * cluster_unit suite links the REAL staging-path router. */ + + /* ============================================================ * Receiver: sender-side (D6). * @@ -5305,49 +5346,57 @@ cluster_gcs_handle_block_forward_envelope(const ClusterICEnvelope *env, const vo * dedup TTL will sweep stale entry */ /* - * PGRAC: spec-6.12b — CR-server request. LMON only validates + parks - * the request for LMS (light-work rule: a CR construction walks undo - * I/O and must never run in this dispatch loop); the LMON tick ships - * the finished result. A refused park (data plane off / no free slot) - * replies a fail-closed DENIED immediately so the requester keeps its - * unchanged 53R9G refusal (Rule 8.A). Never falls through to the - * current-image ship below: a CR result is HISTORICAL by intent and - * the lost-write watermark verdict does not apply (the SCN carrier - * holds the requester's read_scn on this path). + * PGRAC: spec-6.12b + spec-7.3 D6 — CR-server request. When the family is + * on the DATA plane this handler runs in the receiving worker[shard], so it + * serves the request INLINE (construct under the PG_TRY -> DENIED envelope) + * and ships on its own channel — the park -> LMS-poll -> LMON-ship + * indirection is retired there (D6). On the CONTROL plane the handler runs + * in LMON, whose tight IC dispatch loop must NOT walk undo I/O (the + * light-work rule), so it parks for LMS worker 0 instead; a refused park + * (data plane off / no free slot) replies a fail-closed DENIED immediately. + * Either way the requester keeps its unchanged 53R9G on refusal (Rule 8.A). + * Never falls through to the current-image ship below: a CR result is + * HISTORICAL by intent and the lost-write watermark verdict does not apply + * (the SCN carrier holds the requester's read_scn on this path). */ if (GcsBlockForwardPayloadIsCrRequest(fwd)) { - if (!cluster_lms_cr_submit(fwd)) + if (cluster_gcs_block_family_on_data_plane()) + cluster_gcs_block_forward_serve_inline(fwd, CLUSTER_LMS_SLOT_KIND_CR); + else if (!cluster_lms_cr_submit(fwd)) gcs_block_forward_reply_immediate_deny(fwd); return; } /* - * PGRAC: spec-6.12i D-i1 — undo-TT fetch request. Same LMON shape as the - * CR branch above (validate + park only; the undo file read runs in LMS, - * the LMON tick ships). MUST branch here, before any holder / GRD logic: - * the tag is a synthetic undo address, not a block identity. A refused - * park (wave GUC off / malformed tag / no free slot) replies the - * fail-closed DENIED immediately so the requester keeps its unchanged - * 53R97 refusal (Rule 8.A). + * PGRAC: spec-6.12i D-i1 + spec-7.3 D6 — undo-TT fetch request. DATA plane + * serves inline (the undo file read runs in the worker[shard]); CONTROL + * plane parks for LMS worker 0 (light-work rule). MUST branch here, before + * any holder / GRD logic: the tag is a synthetic undo address, not a block + * identity. A refusal (wave GUC off / malformed tag / no free slot) ships + * DENIED so the requester keeps its unchanged 53R97 (Rule 8.A). */ if (GcsBlockForwardPayloadIsUndoTtFetchRequest(fwd)) { - if (!cluster_lms_undo_fetch_submit(fwd)) + if (cluster_gcs_block_family_on_data_plane()) + cluster_gcs_block_forward_serve_inline(fwd, CLUSTER_LMS_SLOT_KIND_UNDO_FETCH); + else if (!cluster_lms_undo_fetch_submit(fwd)) gcs_block_forward_reply_immediate_deny(fwd); return; } /* - * PGRAC: spec-6.12i D-i4 / spec-6.15 D4 — undo-verdict request. Same - * LMON shape as the two branches above (validate + park only; the - * complete durable-TT scan + CLOG cross-check 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 xid. A - * refused park (wave GUC off / malformed tag or carrier / no free slot) - * replies the fail-closed DENIED immediately so the requester keeps its - * unchanged 53R97 refusal (Rule 8.A). + * PGRAC: spec-6.12i D-i4 / spec-6.15 D4 + spec-7.3 D6 — undo-verdict + * request. DATA plane serves inline (the complete durable-TT scan + CLOG + * cross-check runs in the worker[shard]); CONTROL plane parks for LMS + * worker 0 (light-work rule). MUST branch here, before any holder / GRD + * logic: the tag is a synthetic undo address and the SCN carrier holds the + * widened xid. A refusal (wave GUC off / malformed tag or carrier / no + * free slot) ships DENIED so the requester keeps its unchanged 53R97 + * (Rule 8.A). */ if (GcsBlockForwardPayloadIsUndoVerdictRequest(fwd)) { - if (!cluster_lms_undo_verdict_submit(fwd)) + if (cluster_gcs_block_family_on_data_plane()) + cluster_gcs_block_forward_serve_inline(fwd, CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT); + else if (!cluster_lms_undo_verdict_submit(fwd)) gcs_block_forward_reply_immediate_deny(fwd); return; } @@ -6253,6 +6302,36 @@ cluster_gcs_handle_block_invalidate_envelope(const ClusterICEnvelope *env, const if (inv->checksum != gcs_block_compute_invalidate_checksum(inv)) return; + /* + * PGRAC: spec-7.3 D5 (review P2-1) — per-worker shard routing guard, + * INVALIDATE receive side. Same invariant as the REQUEST dedup guard + * above: INVALIDATE is staged and routed by shard(tag) (payload_shard), + * so the receiving worker must be the routed worker. A mismatch is a + * mis-route (per-tag order break, 8.A — an out-of-order invalidate + * could drop a copy a later grant relies on) that cannot happen without + * a code bug — fail closed (drop without ACK; the master's broadcast + * fail-closes on its own budget) rather than apply out of order. + */ + { + int recv_worker = cluster_ic_tier1_my_data_channel(); + int tag_shard = cluster_lms_shard_for_tag(&inv->tag, cluster_lms_workers); + + Assert(tag_shard == recv_worker); + if (tag_shard != recv_worker) { + static bool misroute_logged = false; + + cluster_gcs_block_dedup_note_misroute(); + if (!misroute_logged) { + misroute_logged = true; + ereport(LOG, + (errmsg_internal("gcs block invalidate misrouted to LMS worker %d (tag " + "shard %d); dropping (spec-7.3 P2-1 8.A fail-closed)", + recv_worker, tag_shard))); + } + return; + } + } + current_epoch = cluster_epoch_get_current(); /* @@ -6373,6 +6452,37 @@ cluster_gcs_handle_block_invalidate_ack_envelope(const ClusterICEnvelope *env, c return; } + /* + * PGRAC: spec-7.3 D5 (review P2-1) — per-worker shard routing guard, + * INVALIDATE-ACK receive side (master). The ACK is direct-sent from + * the holder worker that received the INVALIDATE (worker[shard(tag)]), + * and worker channels pair i<->i across nodes, so a well-routed ACK + * always lands on this master's worker[shard(tag)]. A mismatch is a + * mis-route (per-tag order break, 8.A — an out-of-order ACK could + * certify a drop the holder has not applied yet) that cannot happen + * without a code bug — fail closed (drop; the broadcast fail-closes on + * its own budget) rather than apply out of order. + */ + { + int recv_worker = cluster_ic_tier1_my_data_channel(); + int tag_shard = cluster_lms_shard_for_tag(&ack->tag, cluster_lms_workers); + + Assert(tag_shard == recv_worker); + if (tag_shard != recv_worker) { + static bool misroute_logged = false; + + cluster_gcs_block_dedup_note_misroute(); + if (!misroute_logged) { + misroute_logged = true; + ereport(LOG, (errmsg_internal("gcs block invalidate-ack misrouted to LMS worker %d " + "(tag shard %d); dropping (spec-7.3 P2-1 8.A " + "fail-closed)", + recv_worker, tag_shard))); + } + return; + } + } + /* * PGRAC: spec-6.12h D-h2 — unsolicited rides on the ACK wire, diverted * BEFORE the e2 slotless branch and the HC100 slot logic (both reject diff --git a/src/backend/cluster/cluster_gcs_block_dedup.c b/src/backend/cluster/cluster_gcs_block_dedup.c index 7f4fd029ad..fb0d7bd97e 100644 --- a/src/backend/cluster/cluster_gcs_block_dedup.c +++ b/src/backend/cluster/cluster_gcs_block_dedup.c @@ -5,16 +5,26 @@ * implementation (spec-2.34 D2 + D5 + D6). * * Implements: - * - shmem region "pgrac cluster gcs block dedup" with header + HTAB - * - LWLock guarding HTAB lookup / install / sweep (HC90) - * - lookup_or_register / install_reply / remove APIs - * - TTL sweep (LMON tick body) + * - shmem region "pgrac cluster gcs block dedup" with a per-worker + * shard array (spec-7.3 D5): dedup_shards[worker_id] each own their + * header + HTAB + LWLock, so the LMS worker pool never contends on + * one lock and never shares dedup state across workers + * - lookup_or_register / install_reply / remove APIs (worker_id-keyed) + * - TTL sweep + node-dead + backend-exit GC (iterate every shard) * - before_shmem_exit local backend cleanup hook - * - cleanup_on_node_dead (CSSD DEAD hook) - * - 5 atomic counter accessors (hit / miss / collision / full / in_flight) + * - counter accessors that sum across shards + a misroute fail-closed + * counter in the always-present ctl header (8.A) * * See cluster_gcs_block_dedup.h for the HC contracts and entry layout. * + * spec-7.3 D5 承重 invariant: a block tag routes to exactly one worker + * (worker[shard(tag)], D4), and every message for that tag — original + + * retransmits — carries the same request_id, so the dedup entry for a + * request lives in exactly one shard. Accessing a shard other than the + * routed worker is a mis-route (序破坏); the hot-path bounds guard fails + * it closed (FULL + misroute_failclosed_count++) rather than serving + * from the wrong shard. + * * * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -28,6 +38,7 @@ * NOTES * This is a pgrac-original file. * Spec: spec-2.34-gcs-block-reliability-hardening.md (FROZEN v0.3) + * Spec: spec-7.3-lms-worker-pool.md (D5 — per-worker shard) * Design: docs/cache-fusion-protocol-design.md * AD-005 (Cache Fusion full) * @@ -37,9 +48,10 @@ #ifdef USE_PGRAC_CLUSTER -#include "cluster/cluster_conf.h" +#include "cluster/cluster_conf.h" /* declared_node_count_early (spec-7.2a D4) */ #include "cluster/cluster_gcs_block_dedup.h" #include "cluster/cluster_guc.h" +#include "cluster/cluster_lms_shard.h" /* CLUSTER_LMS_MAX_WORKERS */ #include "cluster/cluster_shmem.h" #include "miscadmin.h" #include "port/atomics.h" @@ -52,31 +64,44 @@ /* ============================================================ - * Module-scope shmem state. + * Module-scope shmem state (spec-7.3 D5 — per-worker shards). + * + * ClusterGcsBlockDedupShard is the old ClusterGcsBlockDedupShared renamed: + * one private lock + counter block + HTAB per LMS worker. The ctl header + * holds cluster-wide state that must exist even when no worker touches its + * own shard (the misroute counter, the live shard count). * ============================================================ */ -typedef struct ClusterGcsBlockDedupShared { - LWLockPadded lock; /* guards HTAB + entry_count */ +typedef struct ClusterGcsBlockDedupShard { + LWLockPadded lock; /* guards this shard's HTAB + entry_count */ pg_atomic_uint64 hit_count; /* CACHED_REPLY paths */ pg_atomic_uint64 miss_count; /* MISS_REGISTERED paths */ pg_atomic_uint64 collision_count; /* HC91 VALIDATION_FAIL */ pg_atomic_uint64 full_count; /* HC92 FULL */ pg_atomic_uint64 evict_count; /* spec-7.2a: eager reclaim + TTL sweep removed */ pg_atomic_uint32 entry_count; /* live in-flight + completed entries */ - int max_entries_effective; /* spec-7.2a D4: cap the HTAB was sized - * with (configured + auto-size floor); - * stamped once at init, read-only after */ -} ClusterGcsBlockDedupShared; - -static ClusterGcsBlockDedupShared *cluster_gcs_block_dedup_shared = NULL; -static HTAB *cluster_gcs_block_dedup_htab = NULL; +} ClusterGcsBlockDedupShard; + +typedef struct ClusterGcsBlockDedupCtl { + pg_atomic_uint64 misroute_failclosed_count; /* spec-7.3 D5 — 8.A drops */ + int n_shards; /* live shard count fixed at init */ + int max_entries_effective; /* spec-7.2a D4: per-shard cap the + * HTABs were sized with (configured + * + auto-size floor); stamped once + * at init, read-only after */ +} ClusterGcsBlockDedupCtl; + +static ClusterGcsBlockDedupCtl *cluster_gcs_block_dedup_ctl = NULL; +static ClusterGcsBlockDedupShard *cluster_gcs_block_dedup_shards = NULL; +static HTAB *cluster_gcs_block_dedup_htabs[CLUSTER_LMS_MAX_WORKERS]; +static int cluster_gcs_block_dedup_n_shards = 0; static bool dedup_backend_exit_hook_registered = false; /* * Upper bound on entries examined per cap-full eager-reclaim probe. We do * NOT full-scan the HTAB under the exclusive lock on every cap-full MISS - * (spec-7.2a §6 "非全扫"): if no reclaim-safe entry is found within the probe - * budget, fall back to the fail-closed HC92 path. + * (spec-7.2a §6 "no full scan"): if no reclaim-safe entry is found within the + * probe budget, fall back to the fail-closed HC92 path. */ #define GCS_BLOCK_DEDUP_RECLAIM_MAX_PROBE 256 @@ -90,15 +115,13 @@ static bool dedup_backend_exit_hook_registered = false; /* Forward declarations for GC helpers used by the MISS-path eager reclaim. */ static int64 dedup_expiry_threshold_us(void); -static int dedup_reclaim_reclaimable_locked(TimestampTz now, int want); +static int dedup_reclaim_reclaimable_locked(ClusterGcsBlockDedupShard *shard, HTAB *htab, + TimestampTz now, int want); static int cluster_gcs_block_dedup_effective_entries(void) { - int configured; - int64 auto_floor; - /* * Heavy GCS block-dedup storage is only meaningful for configured * cluster nodes. initdb/bootstrap runs with cluster_node_id = -1 and @@ -108,29 +131,66 @@ cluster_gcs_block_dedup_effective_entries(void) if (!cluster_enabled || cluster_node_id < 0) return 0; - configured - = cluster_gcs_block_dedup_max_entries > 0 ? cluster_gcs_block_dedup_max_entries : 1024; + { + int configured + = cluster_gcs_block_dedup_max_entries > 0 ? cluster_gcs_block_dedup_max_entries : 1024; + int64 auto_floor; + + /* + * spec-7.2a D4 (Q4) auto-size lower bound: every connected backend on + * every declared node can hold one block request in flight against + * this master, so a configured cap below MaxConnections × node_count + * is guaranteed to saturate under distinct-read pressure. Raise such + * configs to that floor, clamped at the GUC ceiling — auto-sizing + * widens a foot-gun config, it never grows shmem past what the DBA + * could configure by hand. The node count comes from the pre-shmem + * conf sniff (cluster_conf_load() runs only after every region is + * initialised, so cluster_conf_node_count() is still 0 when + * size_fn/init_fn call here); with no readable pgrac.conf the sniff + * reports 1 and the floor degenerates to MaxConnections. The floor + * applies PER SHARD (spec-7.3 D5): tags route to exactly one worker's + * shard, so a single hot shard must alone absorb the floor's + * worst-case in-flight population. + */ + auto_floor = (int64)MaxConnections * cluster_conf_declared_node_count_early(); + if (auto_floor > CLUSTER_GCS_BLOCK_DEDUP_MAX_ENTRIES_CEILING) + auto_floor = CLUSTER_GCS_BLOCK_DEDUP_MAX_ENTRIES_CEILING; + if (configured < (int)auto_floor) + configured = (int)auto_floor; + + return configured; + } +} - /* - * spec-7.2a D4 (Q4) auto-size lower bound: every connected backend on - * every declared node can hold one block request in flight against this - * master, so a configured cap below MaxConnections × node_count is - * guaranteed to saturate under distinct-read pressure. Raise such - * configs to that floor, clamped at the GUC ceiling — auto-sizing widens - * a foot-gun config, it never grows shmem past what the DBA could - * configure by hand. The node count comes from the pre-shmem conf sniff - * (cluster_conf_load() runs only after every region is initialised, so - * cluster_conf_node_count() is still 0 when size_fn/init_fn call here); - * with no readable pgrac.conf the sniff reports 1 and the floor - * degenerates to MaxConnections. - */ - auto_floor = (int64)MaxConnections * cluster_conf_declared_node_count_early(); - if (auto_floor > CLUSTER_GCS_BLOCK_DEDUP_MAX_ENTRIES_CEILING) - auto_floor = CLUSTER_GCS_BLOCK_DEDUP_MAX_ENTRIES_CEILING; - if (configured < (int)auto_floor) - configured = (int)auto_floor; +/* + * spec-7.3 D5 — live shard count = configured LMS worker count, clamped to + * the compile-time cap. POSTMASTER-level GUC, so this is stable across the + * shmem_size / shmem_init pair and for the process lifetime. + */ +static int +cluster_gcs_block_dedup_live_shards(void) +{ + int n = cluster_lms_workers; - return configured; + if (n < 1) + n = 1; + if (n > CLUSTER_LMS_MAX_WORKERS) + n = CLUSTER_LMS_MAX_WORKERS; + return n; +} + +/* + * Bytes carved by ShmemInitStruct: ctl header + the per-worker shard array + * (the HTABs are allocated separately by ShmemInitHash). + */ +static Size +cluster_gcs_block_dedup_struct_bytes(int n_shards) +{ + Size sz; + + sz = MAXALIGN(sizeof(ClusterGcsBlockDedupCtl)); + sz = add_size(sz, mul_size(n_shards, MAXALIGN(sizeof(ClusterGcsBlockDedupShard)))); + return sz; } @@ -143,13 +203,15 @@ cluster_gcs_block_dedup_shmem_size(void) { Size sz; int cap; + int n; cap = cluster_gcs_block_dedup_effective_entries(); if (cap == 0) return 0; - sz = MAXALIGN(sizeof(ClusterGcsBlockDedupShared)); - sz = add_size(sz, hash_estimate_size(cap, sizeof(GcsBlockDedupEntry))); + n = cluster_gcs_block_dedup_live_shards(); + sz = cluster_gcs_block_dedup_struct_bytes(n); + sz = add_size(sz, mul_size(n, hash_estimate_size(cap, sizeof(GcsBlockDedupEntry)))); return sz; } @@ -159,36 +221,62 @@ cluster_gcs_block_dedup_shmem_init(void) bool found; HASHCTL info; int cap; + int n; + int i; + char *base; cap = cluster_gcs_block_dedup_effective_entries(); if (cap == 0) return; - cluster_gcs_block_dedup_shared = (ClusterGcsBlockDedupShared *)ShmemInitStruct( - "pgrac cluster gcs block dedup", MAXALIGN(sizeof(ClusterGcsBlockDedupShared)), &found); + n = cluster_gcs_block_dedup_live_shards(); + + base = (char *)ShmemInitStruct("pgrac cluster gcs block dedup", + cluster_gcs_block_dedup_struct_bytes(n), &found); + cluster_gcs_block_dedup_ctl = (ClusterGcsBlockDedupCtl *)base; + cluster_gcs_block_dedup_shards + = (ClusterGcsBlockDedupShard *)(base + MAXALIGN(sizeof(ClusterGcsBlockDedupCtl))); + cluster_gcs_block_dedup_n_shards = n; if (!found) { - memset(cluster_gcs_block_dedup_shared, 0, sizeof(*cluster_gcs_block_dedup_shared)); - LWLockInitialize(&cluster_gcs_block_dedup_shared->lock.lock, - LWTRANCHE_CLUSTER_GCS_BLOCK_DEDUP); - pg_atomic_init_u64(&cluster_gcs_block_dedup_shared->hit_count, 0); - pg_atomic_init_u64(&cluster_gcs_block_dedup_shared->miss_count, 0); - pg_atomic_init_u64(&cluster_gcs_block_dedup_shared->collision_count, 0); - pg_atomic_init_u64(&cluster_gcs_block_dedup_shared->full_count, 0); - pg_atomic_init_u64(&cluster_gcs_block_dedup_shared->evict_count, 0); - pg_atomic_init_u32(&cluster_gcs_block_dedup_shared->entry_count, 0); - /* spec-7.2a D4: stamp the cap the HTAB below is sized with, so the - * observability accessor reports the capacity actually in force - * (identical in every process, EXEC_BACKEND included). */ - cluster_gcs_block_dedup_shared->max_entries_effective = cap; + pg_atomic_init_u64(&cluster_gcs_block_dedup_ctl->misroute_failclosed_count, 0); + cluster_gcs_block_dedup_ctl->n_shards = n; + /* spec-7.2a D4: stamp the per-shard cap the HTABs below are sized + * with, so the observability accessor reports the capacity actually + * in force (identical in every process, EXEC_BACKEND included). */ + cluster_gcs_block_dedup_ctl->max_entries_effective = cap; + + for (i = 0; i < n; i++) { + ClusterGcsBlockDedupShard *shard = &cluster_gcs_block_dedup_shards[i]; + + memset(shard, 0, sizeof(*shard)); + LWLockInitialize(&shard->lock.lock, LWTRANCHE_CLUSTER_GCS_BLOCK_DEDUP); + pg_atomic_init_u64(&shard->hit_count, 0); + pg_atomic_init_u64(&shard->miss_count, 0); + pg_atomic_init_u64(&shard->collision_count, 0); + pg_atomic_init_u64(&shard->full_count, 0); + pg_atomic_init_u64(&shard->evict_count, 0); + pg_atomic_init_u32(&shard->entry_count, 0); + } } memset(&info, 0, sizeof(info)); info.keysize = sizeof(GcsBlockDedupKey); info.entrysize = sizeof(GcsBlockDedupEntry); - cluster_gcs_block_dedup_htab = ShmemInitHash("pgrac cluster gcs block dedup htab", cap, cap, - &info, HASH_ELEM | HASH_BLOBS); + for (i = 0; i < n; i++) { + char hname[96]; + + /* shard 0 keeps the spec-2.34 name so lms_workers=1 is a + * byte-identical topology (spec-7.3 §3.5). */ + if (i == 0) + snprintf(hname, sizeof(hname), "pgrac cluster gcs block dedup htab"); + else + snprintf(hname, sizeof(hname), "pgrac cluster gcs block dedup htab %d", i); + + cluster_gcs_block_dedup_htabs[i] + = ShmemInitHash(hname, cap, cap, &info, HASH_ELEM | HASH_BLOBS); + } } static const ClusterShmemRegion cluster_gcs_block_dedup_region = { @@ -207,6 +295,32 @@ cluster_gcs_block_dedup_module_init(void) } +/* ============================================================ + * spec-7.3 D5 — shard resolution. Returns the shard for a hot-path + * worker_id, or NULL (fail-closed) when the module is not initialized or + * the worker_id is out of the live range (a mis-route, counted). + * ============================================================ */ +static ClusterGcsBlockDedupShard * +cluster_gcs_block_dedup_resolve_shard(int worker_id, HTAB **htab_out) +{ + if (cluster_gcs_block_dedup_ctl == NULL || cluster_gcs_block_dedup_shards == NULL) + return NULL; /* module off — fail-closed, not a mis-route */ + + if (worker_id < 0 || worker_id >= cluster_gcs_block_dedup_n_shards + || cluster_gcs_block_dedup_htabs[worker_id] == NULL) { + /* A block tag reached the wrong worker: shard key = tag alone, and + * D3 negotiates a cluster-wide n_workers, so this is a code-path + * invariant break. Fail closed (8.A), never serve wrong shard. */ + cluster_gcs_block_dedup_note_misroute(); + return NULL; + } + + if (htab_out != NULL) + *htab_out = cluster_gcs_block_dedup_htabs[worker_id]; + return &cluster_gcs_block_dedup_shards[worker_id]; +} + + /* ============================================================ * Backend-exit hook registration (idempotent; called by the sender/backend * path once per backend before it issues block requests). @@ -239,10 +353,12 @@ cluster_gcs_block_dedup_register_backend_exit_hook(void) * ============================================================ */ GcsBlockDedupResult -cluster_gcs_block_dedup_lookup_or_register(const GcsBlockDedupKey *key, BufferTag tag, - uint8 transition_id, +cluster_gcs_block_dedup_lookup_or_register(int worker_id, const GcsBlockDedupKey *key, + BufferTag tag, uint8 transition_id, GcsBlockDedupEntry *cached_reply_out) { + ClusterGcsBlockDedupShard *shard; + HTAB *htab = NULL; GcsBlockDedupEntry *entry; bool found; GcsBlockDedupResult result; @@ -251,19 +367,20 @@ cluster_gcs_block_dedup_lookup_or_register(const GcsBlockDedupKey *key, BufferTa if (cached_reply_out != NULL) memset(cached_reply_out, 0, sizeof(*cached_reply_out)); - if (cluster_gcs_block_dedup_shared == NULL || cluster_gcs_block_dedup_htab == NULL) - return GCS_BLOCK_DEDUP_FULL; /* not initialized; fail closed */ + shard = cluster_gcs_block_dedup_resolve_shard(worker_id, &htab); + if (shard == NULL) + return GCS_BLOCK_DEDUP_FULL; /* not initialized / mis-route; fail closed */ - LWLockAcquire(&cluster_gcs_block_dedup_shared->lock.lock, LW_EXCLUSIVE); + LWLockAcquire(&shard->lock.lock, LW_EXCLUSIVE); - entry = (GcsBlockDedupEntry *)hash_search(cluster_gcs_block_dedup_htab, key, HASH_FIND, &found); + entry = (GcsBlockDedupEntry *)hash_search(htab, key, HASH_FIND, &found); if (found) { /* HC91 — entry value collision check */ if (memcmp(&entry->tag, &tag, sizeof(BufferTag)) != 0 || entry->transition_id != transition_id) { - pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_shared->collision_count, 1); - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + pg_atomic_fetch_add_u64(&shard->collision_count, 1); + LWLockRelease(&shard->lock.lock); return GCS_BLOCK_DEDUP_VALIDATION_FAIL; } @@ -280,26 +397,25 @@ cluster_gcs_block_dedup_lookup_or_register(const GcsBlockDedupKey *key, BufferTa || entry->status == (uint8)GCS_BLOCK_REPLY_X_GRANTED_FROM_HOLDER) { if (cached_reply_out != NULL) *cached_reply_out = *entry; - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); return GCS_BLOCK_DEDUP_FORWARDED_DUPLICATE; } if (entry->completed_at_ts == 0) { - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); return GCS_BLOCK_DEDUP_IN_FLIGHT_DUPLICATE; } - pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_shared->hit_count, 1); + pg_atomic_fetch_add_u64(&shard->hit_count, 1); if (cached_reply_out != NULL) *cached_reply_out = *entry; - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); return GCS_BLOCK_DEDUP_CACHED_REPLY; } /* MISS path — insert new in-flight slot. HASH_ENTER_NULL → may fail * with cap reached; convert to FULL fail-closed (HC92). */ - entry = (GcsBlockDedupEntry *)hash_search(cluster_gcs_block_dedup_htab, key, HASH_ENTER_NULL, - &found); + entry = (GcsBlockDedupEntry *)hash_search(htab, key, HASH_ENTER_NULL, &found); if (entry == NULL) { /* PGRAC: spec-7.2a D1 — before failing closed, try to reclaim one * reclaim-safe entry (aged past the 2x window, or a site-proven @@ -307,13 +423,12 @@ cluster_gcs_block_dedup_lookup_or_register(const GcsBlockDedupKey *key, BufferTa * whose retransmitted duplicate could still be served incorrectly * (§3.1); if nothing is safe to reclaim, keep the fail-closed HC92 * behavior below. */ - if (dedup_reclaim_reclaimable_locked(GetCurrentTimestamp(), 1) > 0) - entry = (GcsBlockDedupEntry *)hash_search(cluster_gcs_block_dedup_htab, key, - HASH_ENTER_NULL, &found); + if (dedup_reclaim_reclaimable_locked(shard, htab, GetCurrentTimestamp(), 1) > 0) + entry = (GcsBlockDedupEntry *)hash_search(htab, key, HASH_ENTER_NULL, &found); } if (entry == NULL) { - pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_shared->full_count, 1); - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + pg_atomic_fetch_add_u64(&shard->full_count, 1); + LWLockRelease(&shard->lock.lock); return GCS_BLOCK_DEDUP_FULL; } @@ -326,19 +441,22 @@ cluster_gcs_block_dedup_lookup_or_register(const GcsBlockDedupKey *key, BufferTa entry->completed_at_ts = 0; entry->registered_at_ts = GetCurrentTimestamp(); - pg_atomic_fetch_add_u32(&cluster_gcs_block_dedup_shared->entry_count, 1); - pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_shared->miss_count, 1); + pg_atomic_fetch_add_u32(&shard->entry_count, 1); + pg_atomic_fetch_add_u64(&shard->miss_count, 1); result = GCS_BLOCK_DEDUP_MISS_REGISTERED; - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); return result; } void -cluster_gcs_block_dedup_install_reply_ex(const GcsBlockDedupKey *key, GcsBlockReplyStatus status, +cluster_gcs_block_dedup_install_reply_ex(int worker_id, const GcsBlockDedupKey *key, + GcsBlockReplyStatus status, const GcsBlockReplyHeader *header, const char *block_data, const ClusterSfDepVec *sf_dep_vec, bool has_sf_dep) { + ClusterGcsBlockDedupShard *shard; + HTAB *htab = NULL; GcsBlockDedupEntry *entry; bool found; bool has_block_payload; @@ -346,17 +464,18 @@ cluster_gcs_block_dedup_install_reply_ex(const GcsBlockDedupKey *key, GcsBlockRe Assert(key != NULL); Assert(header != NULL); - if (cluster_gcs_block_dedup_shared == NULL || cluster_gcs_block_dedup_htab == NULL) + shard = cluster_gcs_block_dedup_resolve_shard(worker_id, &htab); + if (shard == NULL) return; - LWLockAcquire(&cluster_gcs_block_dedup_shared->lock.lock, LW_EXCLUSIVE); + LWLockAcquire(&shard->lock.lock, LW_EXCLUSIVE); - entry = (GcsBlockDedupEntry *)hash_search(cluster_gcs_block_dedup_htab, key, HASH_FIND, &found); + entry = (GcsBlockDedupEntry *)hash_search(htab, key, HASH_FIND, &found); if (!found) { /* Entry got swept between MISS_REGISTERED and install_reply * (rare: TTL sweep + reconfig race). Drop silently — the * sender will eventually time out and retry. */ - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); return; } @@ -386,35 +505,45 @@ cluster_gcs_block_dedup_install_reply_ex(const GcsBlockDedupKey *key, GcsBlockRe memset(entry->block_data, 0, GCS_BLOCK_DATA_SIZE); entry->completed_at_ts = GetCurrentTimestamp(); - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); } void -cluster_gcs_block_dedup_install_reply(const GcsBlockDedupKey *key, GcsBlockReplyStatus status, - const GcsBlockReplyHeader *header, const char *block_data) +cluster_gcs_block_dedup_install_reply(int worker_id, const GcsBlockDedupKey *key, + GcsBlockReplyStatus status, const GcsBlockReplyHeader *header, + const char *block_data) { - cluster_gcs_block_dedup_install_reply_ex(key, status, header, block_data, NULL, false); + cluster_gcs_block_dedup_install_reply_ex(worker_id, key, status, header, block_data, NULL, + false); } void -cluster_gcs_block_dedup_remove(const GcsBlockDedupKey *key) +cluster_gcs_block_dedup_remove(int worker_id, const GcsBlockDedupKey *key) { + ClusterGcsBlockDedupShard *shard; + HTAB *htab = NULL; bool found; Assert(key != NULL); - if (cluster_gcs_block_dedup_shared == NULL || cluster_gcs_block_dedup_htab == NULL) + + shard = cluster_gcs_block_dedup_resolve_shard(worker_id, &htab); + if (shard == NULL) return; - LWLockAcquire(&cluster_gcs_block_dedup_shared->lock.lock, LW_EXCLUSIVE); - (void)hash_search(cluster_gcs_block_dedup_htab, key, HASH_REMOVE, &found); + LWLockAcquire(&shard->lock.lock, LW_EXCLUSIVE); + (void)hash_search(htab, key, HASH_REMOVE, &found); if (found) - pg_atomic_fetch_sub_u32(&cluster_gcs_block_dedup_shared->entry_count, 1); - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + pg_atomic_fetch_sub_u32(&shard->entry_count, 1); + LWLockRelease(&shard->lock.lock); } /* ============================================================ * GC paths — TTL sweep, backend exit, node DEAD. + * + * spec-7.3 D5 — these run in non-worker processes (LMON tick, requester + * backend before_shmem_exit, CSSD DEAD hook) and a request's entries are + * spread across shards by tag, so every GC path iterates all live shards. * ============================================================ */ /* @@ -448,10 +577,10 @@ dedup_expiry_threshold_us(void) } /* - * dedup_reclaim_reclaimable_locked -- caller MUST hold the dedup lock - * exclusively. Under cap pressure (HASH_ENTER_NULL about to fail), scan the - * HTAB and remove up to `want` reclaim-safe entries so the MISS path can - * register instead of failing closed (spec-7.2a D1). + * dedup_reclaim_reclaimable_locked -- caller MUST hold this shard's dedup + * lock exclusively. Under cap pressure (HASH_ENTER_NULL about to fail), + * scan the shard's HTAB and remove up to `want` reclaim-safe entries so the + * MISS path can register instead of failing closed (spec-7.2a D1). * * Only entries GcsBlockDedupEntryIsReclaimSafe() approves are removed: an * entry aged past the 2x out-of-window threshold (safe for every status, @@ -460,12 +589,14 @@ dedup_expiry_threshold_us(void) * retransmitted duplicate could be re-served incorrectly — it only ever * brings the FULL path forward in time, never sacrifices correctness. * - * The scan is bounded to GCS_BLOCK_DEDUP_RECLAIM_MAX_PROBE entries so a table - * full of in-window entries does not turn every MISS into a full O(cap) scan - * under the exclusive lock (spec-7.2a §6). Returns the number reclaimed. + * The scan is bounded to GCS_BLOCK_DEDUP_RECLAIM_MAX_PROBE entries so a + * shard full of in-window entries does not turn every MISS into a full + * O(cap) scan under the exclusive lock (spec-7.2a §6). Returns the number + * reclaimed. */ static int -dedup_reclaim_reclaimable_locked(TimestampTz now, int want) +dedup_reclaim_reclaimable_locked(ClusterGcsBlockDedupShard *shard, HTAB *htab, TimestampTz now, + int want) { HASH_SEQ_STATUS seq; GcsBlockDedupEntry *entry; @@ -478,10 +609,10 @@ dedup_reclaim_reclaimable_locked(TimestampTz now, int want) out_of_window_us = dedup_expiry_threshold_us(); - hash_seq_init(&seq, cluster_gcs_block_dedup_htab); + hash_seq_init(&seq, htab); while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { if (GcsBlockDedupEntryIsReclaimSafe(entry, now, out_of_window_us)) { - (void)hash_search(cluster_gcs_block_dedup_htab, &entry->key, HASH_REMOVE, NULL); + (void)hash_search(htab, &entry->key, HASH_REMOVE, NULL); reclaimed++; } @@ -492,8 +623,8 @@ dedup_reclaim_reclaimable_locked(TimestampTz now, int want) } if (reclaimed > 0) { - pg_atomic_fetch_sub_u32(&cluster_gcs_block_dedup_shared->entry_count, (uint32)reclaimed); - pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_shared->evict_count, (uint64)reclaimed); + pg_atomic_fetch_sub_u32(&shard->entry_count, (uint32)reclaimed); + pg_atomic_fetch_add_u64(&shard->evict_count, (uint64)reclaimed); } return reclaimed; } @@ -501,183 +632,248 @@ dedup_reclaim_reclaimable_locked(TimestampTz now, int want) void cluster_gcs_block_dedup_sweep_expired(TimestampTz now) { - HASH_SEQ_STATUS seq; - GcsBlockDedupEntry *entry; int64 threshold_us; - int removed = 0; + int s; - if (cluster_gcs_block_dedup_shared == NULL || cluster_gcs_block_dedup_htab == NULL) + if (cluster_gcs_block_dedup_shards == NULL) return; threshold_us = dedup_expiry_threshold_us(); /* - * spec-7.2a D5: saturation LOG-once. When DENIED_DEDUP_FULL keeps growing - * past a threshold across sweep cycles, emit one LOG so operators see - * sustained dedup saturation without flooding the hot request path (rule - * 17). Lock-free (atomics only); the LMON sweep is the sole caller so the - * static high-water mark is process-local and race-free. + * spec-7.2a D5: saturation LOG-once. When DENIED_DEDUP_FULL keeps + * growing past a threshold across sweep cycles, emit one LOG so operators + * see sustained dedup saturation without flooding the hot request path + * (rule 17). Lock-free (atomics only); the LMON sweep is the sole caller + * so the static high-water mark is process-local and race-free. The + * counters aggregate over every shard (spec-7.3 D5). */ { static uint64 dedup_full_logged_hwm = 0; - uint64 cur_full = pg_atomic_read_u64(&cluster_gcs_block_dedup_shared->full_count); + uint64 cur_full = cluster_gcs_block_dedup_get_full_count(); if (cur_full - dedup_full_logged_hwm >= GCS_BLOCK_DEDUP_FULL_LOG_THRESHOLD) { - elog( - LOG, - "GCS block dedup saturating: %llu new DENIED_DEDUP_FULL since last report " - "(cap=%d, live entries=%u); raise cluster.gcs_block_dedup_max_entries if sustained", - (unsigned long long)(cur_full - dedup_full_logged_hwm), - cluster_gcs_block_dedup_shared->max_entries_effective, - pg_atomic_read_u32(&cluster_gcs_block_dedup_shared->entry_count)); + elog(LOG, + "GCS block dedup saturating: %llu new DENIED_DEDUP_FULL since last report " + "(per-shard cap=%d, live entries=%llu); raise " + "cluster.gcs_block_dedup_max_entries if sustained", + (unsigned long long)(cur_full - dedup_full_logged_hwm), + cluster_gcs_block_dedup_ctl != NULL + ? cluster_gcs_block_dedup_ctl->max_entries_effective + : 0, + (unsigned long long)cluster_gcs_block_dedup_get_in_flight_count()); dedup_full_logged_hwm = cur_full; } } - LWLockAcquire(&cluster_gcs_block_dedup_shared->lock.lock, LW_EXCLUSIVE); + for (s = 0; s < cluster_gcs_block_dedup_n_shards; s++) { + ClusterGcsBlockDedupShard *shard = &cluster_gcs_block_dedup_shards[s]; + HTAB *htab = cluster_gcs_block_dedup_htabs[s]; + HASH_SEQ_STATUS seq; + GcsBlockDedupEntry *entry; + int removed = 0; - hash_seq_init(&seq, cluster_gcs_block_dedup_htab); - while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { - TimestampTz anchor; - int64 age_us; - - anchor = entry->completed_at_ts != 0 ? entry->completed_at_ts : entry->registered_at_ts; - if (anchor == 0) + if (htab == NULL) continue; - age_us = (int64)(now - anchor); - if (age_us > threshold_us) { - (void)hash_search(cluster_gcs_block_dedup_htab, &entry->key, HASH_REMOVE, NULL); - removed++; + LWLockAcquire(&shard->lock.lock, LW_EXCLUSIVE); + + hash_seq_init(&seq, htab); + while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { + TimestampTz anchor; + int64 age_us; + + anchor = entry->completed_at_ts != 0 ? entry->completed_at_ts : entry->registered_at_ts; + if (anchor == 0) + continue; + + age_us = (int64)(now - anchor); + if (age_us > threshold_us) { + (void)hash_search(htab, &entry->key, HASH_REMOVE, NULL); + removed++; + } } - } - if (removed > 0) { - pg_atomic_fetch_sub_u32(&cluster_gcs_block_dedup_shared->entry_count, (uint32)removed); - /* spec-7.2a D5: evict_count aggregates eager reclaim + TTL sweep. */ - pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_shared->evict_count, (uint64)removed); - } + if (removed > 0) { + pg_atomic_fetch_sub_u32(&shard->entry_count, (uint32)removed); + /* spec-7.2a D5: evict_count aggregates eager reclaim + TTL sweep. */ + pg_atomic_fetch_add_u64(&shard->evict_count, (uint64)removed); + } - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); + LWLockRelease(&shard->lock.lock); + } } void cluster_gcs_block_dedup_cleanup_on_backend_exit(uint32 origin_node_id, int32 backend_id) { - HASH_SEQ_STATUS seq; - GcsBlockDedupEntry *entry; - int removed = 0; + int s; - if (cluster_gcs_block_dedup_shared == NULL || cluster_gcs_block_dedup_htab == NULL) + if (cluster_gcs_block_dedup_shards == NULL) return; - LWLockAcquire(&cluster_gcs_block_dedup_shared->lock.lock, LW_EXCLUSIVE); - hash_seq_init(&seq, cluster_gcs_block_dedup_htab); - while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { - if (entry->key.origin_node_id == origin_node_id - && entry->key.requester_backend_id == backend_id) { - (void)hash_search(cluster_gcs_block_dedup_htab, &entry->key, HASH_REMOVE, NULL); - removed++; + for (s = 0; s < cluster_gcs_block_dedup_n_shards; s++) { + ClusterGcsBlockDedupShard *shard = &cluster_gcs_block_dedup_shards[s]; + HTAB *htab = cluster_gcs_block_dedup_htabs[s]; + HASH_SEQ_STATUS seq; + GcsBlockDedupEntry *entry; + int removed = 0; + + if (htab == NULL) + continue; + + LWLockAcquire(&shard->lock.lock, LW_EXCLUSIVE); + hash_seq_init(&seq, htab); + while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { + if (entry->key.origin_node_id == origin_node_id + && entry->key.requester_backend_id == backend_id) { + (void)hash_search(htab, &entry->key, HASH_REMOVE, NULL); + removed++; + } } + if (removed > 0) + pg_atomic_fetch_sub_u32(&shard->entry_count, (uint32)removed); + LWLockRelease(&shard->lock.lock); } - if (removed > 0) - pg_atomic_fetch_sub_u32(&cluster_gcs_block_dedup_shared->entry_count, (uint32)removed); - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); } void cluster_gcs_block_dedup_cleanup_on_node_dead(uint32 node_id) { - HASH_SEQ_STATUS seq; - GcsBlockDedupEntry *entry; - int removed = 0; + int s; - if (cluster_gcs_block_dedup_shared == NULL || cluster_gcs_block_dedup_htab == NULL) + if (cluster_gcs_block_dedup_shards == NULL) return; - LWLockAcquire(&cluster_gcs_block_dedup_shared->lock.lock, LW_EXCLUSIVE); - hash_seq_init(&seq, cluster_gcs_block_dedup_htab); - while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { - if (entry->key.origin_node_id == node_id) { - (void)hash_search(cluster_gcs_block_dedup_htab, &entry->key, HASH_REMOVE, NULL); - removed++; + for (s = 0; s < cluster_gcs_block_dedup_n_shards; s++) { + ClusterGcsBlockDedupShard *shard = &cluster_gcs_block_dedup_shards[s]; + HTAB *htab = cluster_gcs_block_dedup_htabs[s]; + HASH_SEQ_STATUS seq; + GcsBlockDedupEntry *entry; + int removed = 0; + + if (htab == NULL) + continue; + + LWLockAcquire(&shard->lock.lock, LW_EXCLUSIVE); + hash_seq_init(&seq, htab); + while ((entry = (GcsBlockDedupEntry *)hash_seq_search(&seq)) != NULL) { + if (entry->key.origin_node_id == node_id) { + (void)hash_search(htab, &entry->key, HASH_REMOVE, NULL); + removed++; + } } + if (removed > 0) + pg_atomic_fetch_sub_u32(&shard->entry_count, (uint32)removed); + LWLockRelease(&shard->lock.lock); } - if (removed > 0) - pg_atomic_fetch_sub_u32(&cluster_gcs_block_dedup_shared->entry_count, (uint32)removed); - LWLockRelease(&cluster_gcs_block_dedup_shared->lock.lock); } /* ============================================================ - * Observability accessors. + * Observability accessors — aggregate (sum) over every live shard. * ============================================================ */ +static uint64 +cluster_gcs_block_dedup_sum_u64(size_t member_offset) +{ + uint64 total = 0; + int s; + + if (cluster_gcs_block_dedup_shards == NULL) + return 0; + + for (s = 0; s < cluster_gcs_block_dedup_n_shards; s++) { + pg_atomic_uint64 *ctr + = (pg_atomic_uint64 *)((char *)&cluster_gcs_block_dedup_shards[s] + member_offset); + + total += pg_atomic_read_u64(ctr); + } + return total; +} + uint64 cluster_gcs_block_dedup_get_hit_count(void) { - return cluster_gcs_block_dedup_shared - ? pg_atomic_read_u64(&cluster_gcs_block_dedup_shared->hit_count) - : 0; + return cluster_gcs_block_dedup_sum_u64(offsetof(ClusterGcsBlockDedupShard, hit_count)); } uint64 cluster_gcs_block_dedup_get_miss_count(void) { - return cluster_gcs_block_dedup_shared - ? pg_atomic_read_u64(&cluster_gcs_block_dedup_shared->miss_count) - : 0; + return cluster_gcs_block_dedup_sum_u64(offsetof(ClusterGcsBlockDedupShard, miss_count)); } uint64 cluster_gcs_block_dedup_get_collision_count(void) { - return cluster_gcs_block_dedup_shared - ? pg_atomic_read_u64(&cluster_gcs_block_dedup_shared->collision_count) - : 0; + return cluster_gcs_block_dedup_sum_u64(offsetof(ClusterGcsBlockDedupShard, collision_count)); } uint64 cluster_gcs_block_dedup_get_full_count(void) { - return cluster_gcs_block_dedup_shared - ? pg_atomic_read_u64(&cluster_gcs_block_dedup_shared->full_count) - : 0; + return cluster_gcs_block_dedup_sum_u64(offsetof(ClusterGcsBlockDedupShard, full_count)); } uint64 cluster_gcs_block_dedup_get_in_flight_count(void) { - return cluster_gcs_block_dedup_shared - ? (uint64)pg_atomic_read_u32(&cluster_gcs_block_dedup_shared->entry_count) - : 0; + uint64 total = 0; + int s; + + if (cluster_gcs_block_dedup_shards == NULL) + return 0; + + for (s = 0; s < cluster_gcs_block_dedup_n_shards; s++) + total += (uint64)pg_atomic_read_u32(&cluster_gcs_block_dedup_shards[s].entry_count); + return total; } uint64 cluster_gcs_block_dedup_get_evict_count(void) { - return cluster_gcs_block_dedup_shared - ? pg_atomic_read_u64(&cluster_gcs_block_dedup_shared->evict_count) - : 0; + return cluster_gcs_block_dedup_sum_u64(offsetof(ClusterGcsBlockDedupShard, evict_count)); } /* - * cluster_gcs_block_dedup_get_max_entries -- effective dedup capacity. + * cluster_gcs_block_dedup_get_max_entries -- effective PER-SHARD dedup + * capacity. * - * spec-7.2a D5: reports the capacity stamped at shmem init (the GUC value - * raised to the D4 auto-size floor — the size the HTAB was actually built - * with), or 0 when the HTAB was never allocated (initdb/bootstrap before a - * cluster node id is assigned, or vanilla mode). The occupancy ratio - * entry_count / max_entries is the saturation signal behind the DEDUP_FULL - * fail-closed path. + * spec-7.2a D5: reports the per-shard cap stamped at shmem init (the GUC + * value raised to the D4 auto-size floor — the size each shard HTAB was + * actually built with), or 0 when the HTABs were never allocated + * (initdb/bootstrap before a cluster node id is assigned, or vanilla mode). + * The occupancy ratio entry_count / max_entries is the saturation signal + * behind the DEDUP_FULL fail-closed path. */ uint64 cluster_gcs_block_dedup_get_max_entries(void) { - return cluster_gcs_block_dedup_shared - ? (uint64)cluster_gcs_block_dedup_shared->max_entries_effective + return cluster_gcs_block_dedup_ctl ? (uint64)cluster_gcs_block_dedup_ctl->max_entries_effective + : 0; +} + +uint64 +cluster_gcs_block_dedup_get_misroute_failclosed_count(void) +{ + return cluster_gcs_block_dedup_ctl + ? pg_atomic_read_u64(&cluster_gcs_block_dedup_ctl->misroute_failclosed_count) : 0; } +/* + * spec-7.3 D5 — record a mis-routed dedup access (a block tag reaching the + * wrong LMS worker). Called both by the module's own bounds guard and by + * the master-side handler when shard(tag) != its own DATA channel, so the + * two fail-closed detectors share one observability face. + */ +void +cluster_gcs_block_dedup_note_misroute(void) +{ + if (cluster_gcs_block_dedup_ctl != NULL) + pg_atomic_fetch_add_u64(&cluster_gcs_block_dedup_ctl->misroute_failclosed_count, 1); +} + #endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/cluster/cluster_gcs_block_shard.c b/src/backend/cluster/cluster_gcs_block_shard.c new file mode 100644 index 0000000000..939c2dd5fa --- /dev/null +++ b/src/backend/cluster/cluster_gcs_block_shard.c @@ -0,0 +1,99 @@ +/*------------------------------------------------------------------------- + * + * cluster_gcs_block_shard.c + * pgrac DATA-plane staging payload -> worker router — spec-7.3 D4 + * (pure layer; extracted from cluster_gcs_block.c at D9). + * + * cluster_gcs_block_payload_shard() picks the outbound ring (= DATA + * worker) for a staged block-family frame by hashing its BufferTag + * through cluster_lms_shard_for_tag(). This file has no PG-backend + * dependencies beyond the wire-struct headers, so it links into the + * standalone cluster_unit suite and the D9 routing truth table runs + * against the REAL router (not a reimplementation). + * + * 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_gcs_block_shard.c + * + * NOTES + * This is a pgrac-original file. Compiled only in --enable-cluster + * builds. Spec: spec-7.3-lms-worker-pool.md (D4/D9). + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "cluster/cluster_gcs_block.h" +#include "cluster/cluster_ic_envelope.h" +#include "cluster/cluster_lms_shard.h" +#include "storage/buf_internals.h" + +#ifdef USE_PGRAC_CLUSTER + +/* + * cluster_gcs_block_payload_shard — spec-7.3 D4 (8.A). + * + * Pick the DATA worker for a staged block-family frame by hashing its + * BufferTag. Only the three tag-carrying staging-path types reach the + * outbound ring (REQUEST / FORWARD / INVALIDATE — each with the tag at a + * fixed offset); REPLY (no tag, request_id-correlated) and INVALIDATE-ACK + * are sent DIRECTLY from the receiving worker's dispatch handler, so they + * already ride the correct worker channel and never reach this function. + * + * Returns the worker id in [0, n_workers), or -1 if the (msg_type, payload) + * pair carries no routable tag. -1 is an 8.A fail-closed signal: an + * unroutable DATA frame must be REFUSED, never defaulted to a worker (that + * would break per-tag order). The size check pins the payload ABI so a + * mismatched length can never read a tag from the wrong offset. + */ +/* spec-7.3 D4 (8.A) — the routing key is the tag at a fixed offset in each + * staging-path payload; pin the offsets so a struct change can't silently + * move the tag and misroute (payload_shard reads &p->tag, but this makes the + * assumption explicit + fails the build if a field is inserted before it). */ +StaticAssertDecl(offsetof(GcsBlockRequestPayload, tag) == 16, + "spec-7.3 D4: GcsBlockRequestPayload.tag offset moved"); +StaticAssertDecl(offsetof(GcsBlockForwardPayload, tag) == 16, + "spec-7.3 D4: GcsBlockForwardPayload.tag offset moved"); +StaticAssertDecl(offsetof(GcsBlockInvalidatePayload, tag) == 16, + "spec-7.3 D4: GcsBlockInvalidatePayload.tag offset moved"); + +int +cluster_gcs_block_payload_shard(uint8 msg_type, const void *payload, uint16 payload_len, + int n_workers) +{ + const BufferTag *tag; + + if (payload == NULL) + return -1; + + switch (msg_type) { + case PGRAC_IC_MSG_GCS_BLOCK_REQUEST: + if (payload_len != sizeof(GcsBlockRequestPayload)) + return -1; + tag = &((const GcsBlockRequestPayload *)payload)->tag; + break; + case PGRAC_IC_MSG_GCS_BLOCK_FORWARD: + if (payload_len != sizeof(GcsBlockForwardPayload)) + return -1; + tag = &((const GcsBlockForwardPayload *)payload)->tag; + break; + case PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE: + if (payload_len != sizeof(GcsBlockInvalidatePayload)) + return -1; + tag = &((const GcsBlockInvalidatePayload *)payload)->tag; + break; + default: + /* REPLY / INVALIDATE-ACK are direct-sent, not staged; any other + * DATA type would need an explicit shard key (spec-7.3 §3.6). */ + return -1; + } + + return cluster_lms_shard_for_tag(tag, n_workers); +} + +#endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/cluster/cluster_grd_outbound.c b/src/backend/cluster/cluster_grd_outbound.c index 83a453f197..032ad0bc88 100644 --- a/src/backend/cluster/cluster_grd_outbound.c +++ b/src/backend/cluster/cluster_grd_outbound.c @@ -41,6 +41,7 @@ #include "cluster/cluster_ic_rdma.h" #include "cluster/cluster_ic_router.h" #include "cluster/cluster_lms.h" /* PGRAC: spec-7.2 D4 DATA-ring routing */ +#include "cluster/cluster_guc.h" /* PGRAC: spec-7.3 D4 — cluster_lms_workers */ #include "cluster/cluster_ic_tier1.h" #include "cluster/cluster_lmon.h" #include "cluster/cluster_shmem.h" @@ -300,8 +301,22 @@ cluster_grd_outbound_enqueue_backend_msg(uint8 msg_type, uint32 dest_node_id, co { const ClusterICMsgTypeInfo *pinfo = cluster_ic_get_msg_type_info(msg_type); - if (pinfo != NULL && (ClusterICPlane)pinfo->plane == CLUSTER_IC_PLANE_DATA) - return cluster_lms_outbound_enqueue(msg_type, dest_node_id, payload, payload_len); + if (pinfo != NULL && (ClusterICPlane)pinfo->plane == CLUSTER_IC_PLANE_DATA) { + /* + * PGRAC: spec-7.3 D4 (8.A) — route this frame to the worker that + * owns its tag's shard, so every message of a tag rides one + * worker<->worker stream (per-tag FIFO). -1 = a DATA frame with + * no routable tag → refuse to stage it fail-closed rather than + * default a worker (a misroute would break message order). + */ + int worker = cluster_gcs_block_payload_shard(msg_type, payload, payload_len, + cluster_lms_workers); + + if (worker < 0) + return false; + return cluster_lms_outbound_enqueue(worker, msg_type, dest_node_id, payload, + payload_len); + } } LWLockAcquire(cluster_grd_outbound_lock, LW_EXCLUSIVE); diff --git a/src/backend/cluster/cluster_guc.c b/src/backend/cluster/cluster_guc.c index f44c17846a..643ec959d6 100644 --- a/src/backend/cluster/cluster_guc.c +++ b/src/backend/cluster/cluster_guc.c @@ -49,6 +49,7 @@ #include "cluster/cluster_cr_pool.h" /* cluster_shared_cr_pool_* (spec-5.51 D8) */ #include "cluster/cluster_resolver_cache.h" /* cluster_shared_resolver_cache_* (spec-5.55 D7) */ #include "cluster/cluster_guc.h" +#include "cluster/cluster_lms_shard.h" /* CLUSTER_LMS_MAX_WORKERS (spec-7.3 D2) */ #include "cluster/cluster_hang.h" /* CLUSTER_HANG_MAX_SAMPLES (spec-5.11 D7) */ #include "cluster/cluster_hang_resolve.h" /* HANG_RESOLVE_* + disposition GUCs (spec-5.12 D6) */ #include "cluster/storage/cluster_undo_buf.h" /* cluster_undo_buf_writeback_allowed (spec-3.18 D1) */ @@ -624,6 +625,21 @@ bool cluster_lmd_enabled = true; */ bool cluster_lms_enabled = true; +/* + * spec-7.3 D2 — cluster.lms_workers: LMS DATA-plane worker pool size. + * Default 2; PGC_POSTMASTER (restart required to resize the pool + its shmem + * ring group). 1 = spec-7.2 topology identity (worker 0 only). + */ +int cluster_lms_workers = 2; + +/* + * spec-7.3 D8 (Q8) — cluster.lms_nice: optional setpriority for the LMS + * DATA-plane workers. Default 0 = leave the inherited priority alone + * (best-effort weak alignment to Oracle's LMS scheduling priority; the + * RT scheduling class is out of scope). PGC_SIGHUP. + */ +int cluster_lms_nice = 0; + /* * spec-2.21 D2:cluster.lock_acquire_cluster_path emergency bypass GUC. * Default true; PGC_POSTMASTER context. Set false only for P0 incident @@ -3738,6 +3754,29 @@ cluster_init_guc(void) "startup-time fallback;spec-2.18 §1.4 F1 deferred wording)。"), &cluster_lms_enabled, true, PGC_POSTMASTER, 0, NULL, NULL, NULL); + /* spec-7.3 D2 — cluster.lms_workers: LMS DATA-plane worker pool size. */ + DefineCustomIntVariable( + "cluster.lms_workers", + gettext_noop("Number of LMS DATA-plane workers (including worker 0)."), + gettext_noop("The LMS DATA plane (spec-7.2) is parallelised across this many " + "workers, each serving the block-family messages of a shard of the " + "BufferTag space. Worker 0 is the LmsProcess; workers 1.. are " + "LmsWorker aux processes. 1 = the spec-7.2 single-LMS topology. " + "Must be identical on every node (HELLO negotiation rejects a " + "mismatch). PGC_POSTMASTER: restart required to resize the pool."), + &cluster_lms_workers, 2, 1, CLUSTER_LMS_MAX_WORKERS, PGC_POSTMASTER, 0, NULL, NULL, NULL); + + /* spec-7.3 D8 (Q8) — cluster.lms_nice: optional LMS scheduling priority. */ + DefineCustomIntVariable( + "cluster.lms_nice", + gettext_noop("Nice value applied to the LMS DATA-plane workers (0 = leave alone)."), + gettext_noop("When non-zero, every LMS worker applies this nice value to itself via " + "setpriority (best-effort; a failure is a WARNING, not an error). The " + "default 0 leaves the inherited priority untouched, and setting the value " + "back to 0 does not restore a previously applied one. SIGHUP: workers " + "re-apply on config reload."), + &cluster_lms_nice, 0, -20, 0, PGC_SIGHUP, 0, NULL, NULL, NULL); + /* spec-2.21 D2:emergency bypass GUC */ DefineCustomBoolVariable("cluster.lock_acquire_cluster_path", gettext_noop("Enable the cluster lock acquire gate path."), diff --git a/src/backend/cluster/cluster_ic.c b/src/backend/cluster/cluster_ic.c index e7e96e7911..8f2b5f04bb 100644 --- a/src/backend/cluster/cluster_ic.c +++ b/src/backend/cluster/cluster_ic.c @@ -635,6 +635,23 @@ cluster_ic_build_hello(uint8 out_buf[PGRAC_IC_HELLO_BYTES], uint16 hello_version ic_le_write_uint64(out_buf + PGRAC_IC_HELLO_CONN_EPOCH_OFFSET, conn_epoch); } +/* + * PGRAC: spec-7.3 D3 — write the DATA-plane worker_id / n_workers fields onto + * an already-built HELLO buffer (offsets 41/42, single bytes). build_hello + * leaves them zero; the DATA-plane send path calls this to stamp the worker + * identity so the accepting worker can enforce the shard-aligned topology and + * a cluster-uniform worker count (spec-7.3 §3.1/§3.2, 8.A). + */ +void +cluster_ic_hello_set_worker_fields(uint8 out_buf[PGRAC_IC_HELLO_BYTES], uint8 worker_id, + uint8 n_workers) +{ + Assert(out_buf != NULL); + + out_buf[PGRAC_IC_HELLO_WORKER_ID_OFFSET] = worker_id; + out_buf[PGRAC_IC_HELLO_N_WORKERS_OFFSET] = n_workers; +} + bool cluster_ic_parse_hello(const uint8 in_buf[PGRAC_IC_HELLO_BYTES], ClusterICHelloMsg *out_msg) { diff --git a/src/backend/cluster/cluster_ic_router.c b/src/backend/cluster/cluster_ic_router.c index 0c7f6900ad..5e162df98e 100644 --- a/src/backend/cluster/cluster_ic_router.c +++ b/src/backend/cluster/cluster_ic_router.c @@ -60,6 +60,7 @@ #include "cluster/cluster_ic_rdma.h" #include "cluster/cluster_ic_router.h" #include "cluster/cluster_ic_tier1.h" /* cluster_ic_tier1_get_peer_fd (spec-2.5 D2.5 fanout) */ +#include "cluster/cluster_lms.h" /* PGRAC: spec-7.3 D8 per-worker dispatch counter */ #include "cluster/cluster_xnode_profile.h" /* PGRAC: spec-5.59 D6 profiling */ @@ -314,6 +315,12 @@ cluster_ic_dispatch_envelope(const ClusterICEnvelope *env, const void *payload, if (env->msg_type == PGRAC_IC_CHUNK_MSG_TYPE) return cluster_ic_chunk_dispatch_frame(env, payload, peer_id); + /* PGRAC: spec-7.3 D8 — per-worker dispatch counter. A no-op outside an + * LMS process (the bumper resolves the caller's worker slot itself); + * placed after the chunk short-circuit so a chunked message counts once + * (the reassembled inner envelope), not once per wire frame. */ + cluster_lms_obs_note_dispatch(); + info = &dispatch_table[env->msg_type]; /* spec-2.3 §1.4 invariant 8 + §3.5b inbound: unregistered msg_type diff --git a/src/backend/cluster/cluster_ic_tier1.c b/src/backend/cluster/cluster_ic_tier1.c index fc3113d743..21c201fcdf 100644 --- a/src/backend/cluster/cluster_ic_tier1.c +++ b/src/backend/cluster/cluster_ic_tier1.c @@ -122,23 +122,54 @@ typedef struct ClusterICTier1Shmem { #define PGRAC_IC_TIER1_SHMEM_MAGIC ((uint32)0x54494331U) /* "TIC1" */ /* - * PGRAC: spec-7.2 D2 — per-plane shmem instances inside the single - * "pgrac cluster_ic_tier1" region ([0] = CONTROL, [1] = DATA). + * PGRAC: spec-7.2 D2 + spec-7.3 D3 — per-plane / per-DATA-worker shmem slots + * inside the single "pgrac cluster_ic_tier1" region (slot 0 = CONTROL, + * slot 1 + c = DATA worker channel c; see Tier1ShmemSlots below). * * Tier1Shmem stays the working alias and points at THIS process's - * plane instance: process-local tier1 state (fd arrays, buffers, - * HELLO state machines) is naturally per-process, so the plane a - * process owns is implied by the process — LMON = CONTROL (the - * default), LMS = DATA (set via cluster_ic_tier1_set_my_plane at - * LmsMain entry). Every existing tier1 function keeps reading - * Tier1Shmem unchanged and lands on its own plane's peers[] / - * listener metadata / CONNECTED gate. Cross-plane observers (SQL - * views) address Tier1ShmemByPlane[] explicitly. + * slot instance: process-local tier1 state (fd arrays, buffers, + * HELLO state machines) is naturally per-process, so the plane/channel + * a process owns is implied by the process — LMON = CONTROL (the + * default), LMS worker c = DATA channel c (set via + * cluster_ic_tier1_set_my_plane / set_my_data_channel at aux entry). + * Every existing tier1 function keeps reading Tier1Shmem unchanged and + * lands on its own slot's peers[] / listener metadata / CONNECTED gate. + * Plane-scoped observers (get_plane_misroute_reject) aggregate the DATA + * worker channels; peer_get / get_peer_fd read the caller's own slot. */ -static ClusterICTier1Shmem *Tier1ShmemByPlane[CLUSTER_IC_PLANE_N] = { NULL, NULL }; +/* + * PGRAC: spec-7.3 D3 — per-plane / per-DATA-worker shmem slots. Layout: + * slot 0 = CONTROL + * slot 1 + c (c 0..N-1) = DATA worker channel c + * so CONTROL (slot 0) and DATA worker 0 (slot 1) keep their spec-7.2 offsets + * and cluster.lms_workers = 1 is byte-identical. Each DATA worker gets its + * own instance, so peers[] (read by the send-gate) and listener metadata are + * never clobbered across worker processes. + */ +#define CLUSTER_IC_TIER1_SLOTS (1 + CLUSTER_IC_TIER1_DATA_CHANNELS) +static ClusterICTier1Shmem *Tier1ShmemSlots[CLUSTER_IC_TIER1_SLOTS]; static ClusterICPlane tier1_my_plane = CLUSTER_IC_PLANE_CONTROL; +static int tier1_my_data_channel = 0; /* worker id; valid when plane == DATA */ +static int tier1_my_n_workers = 1; /* cluster-uniform worker count (DATA HELLO) */ static ClusterICTier1Shmem *Tier1Shmem = NULL; +/* Map (plane, DATA channel) to a shmem slot index. */ +static inline int +tier1_slot_of(ClusterICPlane plane, int data_channel) +{ + if (plane == CLUSTER_IC_PLANE_DATA) + return 1 + data_channel; + return 0; /* CONTROL (channel irrelevant) */ +} + +/* Port offset this process applies to its declared data port (0 = CONTROL or + * DATA worker 0; worker c binds/dials declared_port + c — spec-7.3 D3). */ +static inline int +tier1_my_port_offset(void) +{ + return (tier1_my_plane == CLUSTER_IC_PLANE_DATA) ? tier1_my_data_channel : 0; +} + /* * Listener fd -- process-local; valid only in the LMON aux process that * called listener_bind(). Shmem stores listener_pid + incarnation so @@ -421,8 +452,9 @@ peer_addr(int32 peer_id) static Size tier1_shmem_size(void) { - /* PGRAC: spec-7.2 D2 — one instance per plane (CONTROL + DATA). */ - return CLUSTER_IC_PLANE_N * MAXALIGN(sizeof(ClusterICTier1Shmem)); + /* PGRAC: spec-7.2 D2 + spec-7.3 D3 — one instance per slot (CONTROL + + * DATA worker channels). */ + return CLUSTER_IC_TIER1_SLOTS * MAXALIGN(sizeof(ClusterICTier1Shmem)); } static void @@ -430,23 +462,23 @@ tier1_shmem_init(void) { bool found; char *base; - int plane; + int slot; base = (char *)ShmemInitStruct("pgrac cluster_ic_tier1", tier1_shmem_size(), &found); - /* PGRAC: spec-7.2 D2 — carve one instance per plane; the working - * alias follows this process's plane (CONTROL unless - * cluster_ic_tier1_set_my_plane re-aims it, i.e. in LMS). */ - for (plane = 0; plane < CLUSTER_IC_PLANE_N; plane++) - Tier1ShmemByPlane[plane] - = (ClusterICTier1Shmem *)(base + plane * MAXALIGN(sizeof(ClusterICTier1Shmem))); - Tier1Shmem = Tier1ShmemByPlane[tier1_my_plane]; + /* PGRAC: spec-7.2 D2 + spec-7.3 D3 — carve one instance per slot; the + * working alias follows this process's plane / DATA channel (CONTROL + * unless set_my_plane / set_my_data_channel re-aims it, i.e. in LMS). */ + for (slot = 0; slot < CLUSTER_IC_TIER1_SLOTS; slot++) + Tier1ShmemSlots[slot] + = (ClusterICTier1Shmem *)(base + slot * MAXALIGN(sizeof(ClusterICTier1Shmem))); + Tier1Shmem = Tier1ShmemSlots[tier1_slot_of(tier1_my_plane, tier1_my_data_channel)]; if (!found) { memset(base, 0, tier1_shmem_size()); - for (plane = 0; plane < CLUSTER_IC_PLANE_N; plane++) { - ClusterICTier1Shmem *s = Tier1ShmemByPlane[plane]; + for (slot = 0; slot < CLUSTER_IC_TIER1_SLOTS; slot++) { + ClusterICTier1Shmem *s = Tier1ShmemSlots[slot]; int i; s->magic = PGRAC_IC_TIER1_SHMEM_MAGIC; @@ -494,10 +526,50 @@ tier1_shmem_init(void) void cluster_ic_tier1_set_my_plane(ClusterICPlane plane) { + int slot; + Assert(plane >= 0 && plane < CLUSTER_IC_PLANE_N); tier1_my_plane = plane; - if (Tier1ShmemByPlane[plane] != NULL) - Tier1Shmem = Tier1ShmemByPlane[plane]; + /* set_my_plane alone keeps the DATA channel at its current value (0 = + * worker 0 by default); set_my_data_channel selects a worker channel. */ + slot = tier1_slot_of(plane, tier1_my_data_channel); + if (Tier1ShmemSlots[slot] != NULL) + Tier1Shmem = Tier1ShmemSlots[slot]; +} + +/* + * PGRAC: spec-7.3 D3 — select this DATA worker's channel. Implies the DATA + * plane and re-aims the working alias at the channel's private instance, so + * the listener/dial port (data port + channel) and the HELLO worker fields + * (channel as worker_id, n_workers) follow. Called once at DATA-worker entry + * BEFORE any tier1 use. channel in [0, CLUSTER_IC_TIER1_DATA_CHANNELS). + */ +void +cluster_ic_tier1_set_my_data_channel(int channel, int n_workers) +{ + int slot; + + Assert(channel >= 0 && channel < CLUSTER_IC_TIER1_DATA_CHANNELS); + Assert(n_workers >= 1 && n_workers <= CLUSTER_IC_TIER1_DATA_CHANNELS); + + tier1_my_plane = CLUSTER_IC_PLANE_DATA; + tier1_my_data_channel = channel; + tier1_my_n_workers = n_workers; + slot = tier1_slot_of(CLUSTER_IC_PLANE_DATA, channel); + if (Tier1ShmemSlots[slot] != NULL) + Tier1Shmem = Tier1ShmemSlots[slot]; +} + +int +cluster_ic_tier1_my_data_channel(void) +{ + return tier1_my_data_channel; +} + +int +cluster_ic_tier1_my_n_workers(void) +{ + return tier1_my_n_workers; } /* PGRAC: spec-7.2 D3 — this process's plane (router plane gates). */ @@ -519,9 +591,26 @@ cluster_ic_tier1_bump_plane_misroute_reject(void) uint64 cluster_ic_tier1_get_plane_misroute_reject(ClusterICPlane plane) { - if (plane < 0 || plane >= CLUSTER_IC_PLANE_N || Tier1ShmemByPlane[plane] == NULL) + if (plane < 0 || plane >= CLUSTER_IC_PLANE_N) + return 0; + + /* spec-7.3 D3 — the DATA plane spans per-worker channels; aggregate. */ + if (plane == CLUSTER_IC_PLANE_DATA) { + uint64 sum = 0; + int c; + + for (c = 0; c < CLUSTER_IC_TIER1_DATA_CHANNELS; c++) { + int slot = tier1_slot_of(CLUSTER_IC_PLANE_DATA, c); + + if (Tier1ShmemSlots[slot] != NULL) + sum += pg_atomic_read_u64(&Tier1ShmemSlots[slot]->plane_misroute_reject); + } + return sum; + } + + if (Tier1ShmemSlots[0] == NULL) return 0; - return pg_atomic_read_u64(&Tier1ShmemByPlane[plane]->plane_misroute_reject); + return pg_atomic_read_u64(&Tier1ShmemSlots[0]->plane_misroute_reject); } static const ClusterShmemRegion cluster_ic_tier1_region = { @@ -1119,6 +1208,11 @@ cluster_ic_tier1_listener_bind(void) (tier1_my_plane == CLUSTER_IC_PLANE_DATA) ? self->data_addr : self->interconnect_addr))); + /* PGRAC: spec-7.3 D3 — DATA worker c binds declared_port + c, so each + * worker owns a distinct listener within the node-internal range + * [data_port, data_port + n_workers). */ + self_port += tier1_my_port_offset(); + fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd < 0) ereport(FATAL, @@ -1268,6 +1362,11 @@ cluster_ic_tier1_connect_one(int32 peer_id, int *out_peer_fd) return false; } + /* PGRAC: spec-7.3 D3 — DATA worker c dials the peer's worker-c listener + * (declared_port + c), keeping the shard-aligned i<->i mesh: my channel + * only ever pairs with the peer's same channel. */ + port += tier1_my_port_offset(); + fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd < 0) { int saved = errno; @@ -1350,6 +1449,12 @@ cluster_ic_tier1_finish_connect(int32 peer_id, int peer_fd) cluster_ic_build_hello(tier1_hello_send_buf[peer_id], PGRAC_IC_HELLO_VERSION_V1, PGRAC_IC_ENVELOPE_VERSION_V1, cluster_node_id, self_cluster_name, tier1_my_plane, cluster_epoch_get_current()); + /* PGRAC: spec-7.3 D3 — stamp the DATA worker identity so the accepting + * worker can enforce the shard-aligned topology + a cluster-uniform + * worker count (8.A). CONTROL leaves the fields zero (build_hello). */ + if (tier1_my_plane == CLUSTER_IC_PLANE_DATA) + cluster_ic_hello_set_worker_fields(tier1_hello_send_buf[peer_id], + (uint8)tier1_my_data_channel, (uint8)tier1_my_n_workers); tier1_hello_send_remaining[peer_id] = PGRAC_IC_HELLO_BYTES; return cluster_ic_tier1_continue_hello_send(peer_id, peer_fd); @@ -1490,6 +1595,27 @@ cluster_ic_tier1_recv_and_verify_hello(int32 peer_id, int peer_fd) return false; } + /* + * PGRAC: spec-7.3 D3 (8.A) — on the DATA plane, reject fail-closed unless + * the peer claims MY worker channel (shard-aligned i<->i mesh) and the + * SAME cluster-uniform worker count. A worker_id skew would pair + * different shards across the two ends; an n_workers skew would make the + * two ends' shard tables disagree — either is a message-order break = + * false-visible surface, so it must be refused, never downgraded. + */ + if (tier1_my_plane == CLUSTER_IC_PLANE_DATA + && ((int)cluster_ic_hello_worker_id(&msg) != tier1_my_data_channel + || (int)cluster_ic_hello_n_workers(&msg) != tier1_my_n_workers)) { + peer_record_error(peer_id, 0, "08P01", + "HELLO DATA worker mismatch (peer worker=%d n=%d mine worker=%d n=%d)", + (int)cluster_ic_hello_worker_id(&msg), + (int)cluster_ic_hello_n_workers(&msg), tier1_my_data_channel, + tier1_my_n_workers); + cluster_ic_tier1_close_peer(peer_id, "HELLO DATA worker mismatch"); + Tier1Shmem->peers[peer_id].state = (int32)CLUSTER_IC_PEER_REJECTED; + return false; + } + peer_info = cluster_conf_lookup_node(msg.source_node_id); if (peer_info == NULL) { peer_record_error(peer_id, 0, "08P01", "HELLO unknown source_node_id %d", @@ -1524,7 +1650,15 @@ cluster_ic_tier1_recv_and_verify_hello(int32 peer_id, int peer_fd) (void)peer_addr(peer_id); /* cache addr in shmem for view */ cluster_sf_note_peer_hello_capabilities(peer_id, cluster_ic_hello_capabilities(&msg)); - ereport(LOG, (errmsg("cluster_ic tier1 peer %d HELLO verified, state CONNECTED", peer_id))); + /* spec-7.3 D3 — tag the DATA-plane CONNECTED log with this worker's + * channel so a 2-node test can prove the shard-aligned i<->i mesh formed + * per worker (CONTROL keeps its historic message verbatim). */ + if (tier1_my_plane == CLUSTER_IC_PLANE_DATA) + ereport(LOG, + (errmsg("cluster_ic tier1 peer %d HELLO verified, state CONNECTED (DATA worker %d)", + peer_id, tier1_my_data_channel))); + else + ereport(LOG, (errmsg("cluster_ic tier1 peer %d HELLO verified, state CONNECTED", peer_id))); return true; } @@ -1681,6 +1815,25 @@ cluster_ic_tier1_continue_hello_recv(int anon_slot, int peer_fd, int32 *out_lear return false; } + /* + * PGRAC: spec-7.3 D3 (8.A) — same DATA-plane worker gate as the named + * path: an anonymous inbound HELLO must claim MY worker channel and the + * SAME worker count, else it is refused fail-closed (a mismatch = shard + * misroute = message-order break). peer_id is not yet known here (this + * is the learn path), so there is no shmem REJECTED state to set. + */ + if (tier1_my_plane == CLUSTER_IC_PLANE_DATA + && ((int)cluster_ic_hello_worker_id(&msg) != tier1_my_data_channel + || (int)cluster_ic_hello_n_workers(&msg) != tier1_my_n_workers)) { + ereport( + LOG, + (errmsg("cluster_ic tier1 HELLO DATA worker mismatch (peer worker=%d n=%d mine " + "worker=%d n=%d)", + (int)cluster_ic_hello_worker_id(&msg), (int)cluster_ic_hello_n_workers(&msg), + tier1_my_data_channel, tier1_my_n_workers))); + return false; + } + peer_info = cluster_conf_lookup_node(msg.source_node_id); if (peer_info == NULL) { ereport(LOG, @@ -1705,8 +1858,16 @@ cluster_ic_tier1_continue_hello_recv(int anon_slot, int peer_fd, int32 *out_lear if (out_learned_peer_id != NULL) *out_learned_peer_id = learned; - ereport(LOG, (errmsg("cluster_ic tier1 anon slot %d HELLO verified -> peer %d state CONNECTED", - anon_slot, learned))); + /* spec-7.3 D3 — same DATA-plane channel tag on the anon (accept) path. */ + if (tier1_my_plane == CLUSTER_IC_PLANE_DATA) + ereport(LOG, + (errmsg("cluster_ic tier1 anon slot %d HELLO verified -> peer %d state CONNECTED " + "(DATA worker %d)", + anon_slot, learned, tier1_my_data_channel))); + else + ereport(LOG, + (errmsg("cluster_ic tier1 anon slot %d HELLO verified -> peer %d state CONNECTED", + anon_slot, learned))); return true; } diff --git a/src/backend/cluster/cluster_inject.c b/src/backend/cluster/cluster_inject.c index e6ba20c3e8..a2875d638f 100644 --- a/src/backend/cluster/cluster_inject.c +++ b/src/backend/cluster/cluster_inject.c @@ -361,6 +361,29 @@ static ClusterInjectPoint cluster_injection_points[] = { * that would otherwise serve FULL/PARTIAL. */ { .name = "cluster-lms-cr-construct" }, + /* + * spec-7.3 D7 — DATA-plane inline serve fence refusal injection. + * + * cluster-lms-cr-fence-refuse: + * Fires in cluster_gcs_block_forward_serve_inline ahead of the kind + * switch. SKIP forces the fence-refuse branch (ship DENIED without + * reading / constructing), the deterministic trigger for the TAP fence + * ×N legs — genuine write-fence enforcement (enforcing && !allowed) + * takes the same branch but needs a multi-node voting-disk reconfig. + */ + { .name = "cluster-lms-cr-fence-refuse" }, + /* + * spec-7.3 D7 (review P1-1) — inline-serve SHIP-time fence re-check. + * + * cluster-lms-cr-fence-recheck: + * Fires in cluster_gcs_block_forward_serve_inline AFTER cr_serve_slot + * and before the reply build. SKIP forces the ship-time re-check + * branch (discard the constructed result, ship DENIED) — the + * deterministic trigger for the TAP TOCTOU leg, modelling a qvotec + * lease that expires while the serve constructs (not schedulable + * from TAP; genuine enforcement takes the same branch). + */ + { .name = "cluster-lms-cr-fence-recheck" }, /* * spec-7.2 D6 — LMS data-plane observability injections. * diff --git a/src/backend/cluster/cluster_lms.c b/src/backend/cluster/cluster_lms.c index 590ef7eae3..cb91c67cf5 100644 --- a/src/backend/cluster/cluster_lms.c +++ b/src/backend/cluster/cluster_lms.c @@ -56,6 +56,7 @@ #include "postgres.h" #include +#include /* PGRAC: spec-7.3 D8 setpriority (cluster.lms_nice) */ #include "cluster/cluster_cr_server.h" /* spec-6.12b CR work slots */ #include "cluster/cluster_conf.h" @@ -68,6 +69,7 @@ #include "cluster/cluster_grd_outbound.h" #include "cluster/cluster_grd_work_queue.h" #include "cluster/cluster_guc.h" +#include "cluster/cluster_ic_tier1.h" /* CLUSTER_IC_TIER1_DATA_CHANNELS (spec-7.3 D3) */ #include "cluster/cluster_lms.h" #include "cluster/cluster_native_lock_probe.h" #include "cluster/cluster_shmem.h" @@ -199,8 +201,18 @@ cluster_lms_shmem_init(void) * starvation counter starts at 0. */ pg_atomic_init_u64(&cluster_lms_state->lms_restart_generation, 0); pg_atomic_init_u64(&cluster_lms_state->priority_starvation_observed_count, 0); - /* spec-7.2 D6 — DATA-plane connection resets counter. */ - pg_atomic_init_u64(&cluster_lms_state->lms_conn_resets, 0); + /* spec-7.3 D8 — per-worker observability counters + serve hist. */ + { + int w, b; + for (w = 0; w < CLUSTER_LMS_MAX_WORKERS; w++) { + pg_atomic_init_u64(&cluster_lms_state->worker_dispatch_count[w], 0); + pg_atomic_init_u64(&cluster_lms_state->worker_direct_reply_count[w], 0); + pg_atomic_init_u64(&cluster_lms_state->worker_conn_reset_count[w], 0); + pg_atomic_init_u64(&cluster_lms_state->worker_inline_serve_count[w], 0); + for (b = 0; b < CLUSTER_LMS_SERVE_HIST_BUCKETS; b++) + pg_atomic_init_u64(&cluster_lms_state->worker_serve_hist[w][b], 0); + } + } ConditionVariableInit(&cluster_lms_state->cv); } } @@ -387,15 +399,21 @@ cluster_lms_get_pid(void) } /* - * PGRAC: spec-7.2 D4 — wake the LMS data-plane loop (mirror of - * cluster_lmon_wakeup): SIGUSR1 → lms_sigusr1_handler → SetLatch. - * Safe from any context; self-kick and pre-spawn calls no-op. + * PGRAC: spec-7.2 D4 + spec-7.3 D4 — wake DATA worker worker_id's data-plane + * loop (SIGUSR1 → lms_sigusr1_handler → SetLatch). worker 0's pid is + * published to worker_pids[0] by LmsMain, workers 1..7 by LmsWorkerMain, so + * this is uniform across the pool. Safe from any context; self-kick, + * out-of-range and pre-spawn calls no-op. */ void -cluster_lms_wakeup(void) +cluster_lms_wakeup(int worker_id) { - pid_t pid = cluster_lms_get_pid(); + pid_t pid; + + if (worker_id < 0 || worker_id >= CLUSTER_LMS_MAX_WORKERS) + return; + pid = cluster_lms_get_worker_pid(worker_id); if (pid <= 0 || pid == MyProcPid) return; @@ -583,30 +601,6 @@ cluster_lms_get_priority_starvation_observed_count(void) return pg_atomic_read_u64(&cluster_lms_state->priority_starvation_observed_count); } -/* - * spec-7.2 D6 — DATA-plane connection resets counter. - * - * cluster_lms_bump_conn_resets adds the number of DATA connections a - * single reset event tore down (epoch bump in production, injection in - * the F6-1 test). Called from the LMS DATA-plane tick; a shared-memory - * atomic so pg_stat_cluster_* / the debug dump can observe reset activity. - */ -void -cluster_lms_bump_conn_resets(uint32 n) -{ - if (cluster_lms_state != NULL && n > 0) - pg_atomic_fetch_add_u64(&cluster_lms_state->lms_conn_resets, (uint64)n); -} - -uint64 -cluster_lms_get_conn_resets(void) -{ - if (cluster_lms_state == NULL) - return 0; - return pg_atomic_read_u64(&cluster_lms_state->lms_conn_resets); -} - - /* ============================================================ * HC4 single ownership atomic guard. * @@ -733,9 +727,12 @@ LmsMain(void) errhint("cluster_lms_shmem_init() must run during " "CreateSharedMemoryAndSemaphores()."))); - /* Publish STARTING + record pid / spawned_at. */ + /* Publish STARTING + record pid / spawned_at. spec-7.3 D4: worker 0's + * pid also goes to worker_pids[0] so cluster_lms_wakeup(0) is uniform with + * the LmsWorker pool (backends waking DATA shard 0 use worker_pids[0]). */ LWLockAcquire(&cluster_lms_state->lwlock, LW_EXCLUSIVE); cluster_lms_state->pid = MyProcPid; + cluster_lms_state->worker_pids[0] = MyProcPid; cluster_lms_state->spawned_at = GetCurrentTimestamp(); lms_set_state(CLUSTER_LMS_STARTING); LWLockRelease(&cluster_lms_state->lwlock); @@ -752,7 +749,10 @@ LmsMain(void) /* PGRAC: spec-7.2 D2 — bring up the LMS-owned DATA-plane listener + * mesh (false = plane off for this node; the loop below then keeps * the historic latch-only wait and park-serve keeps working). */ - (void)cluster_lms_data_plane_startup(); + (void)cluster_lms_data_plane_startup(0, cluster_lms_workers); + + /* PGRAC: spec-7.3 D8 (Q8) — optional scheduling priority (0 = no-op). */ + cluster_lms_apply_nice(); /* Transition to READY. */ LWLockAcquire(&cluster_lms_state->lwlock, LW_EXCLUSIVE); @@ -782,6 +782,8 @@ LmsMain(void) if (ConfigReloadPending) { ConfigReloadPending = false; ProcessConfigFile(PGC_SIGHUP); + /* PGRAC: spec-7.3 D8 — re-apply cluster.lms_nice on reload. */ + cluster_lms_apply_nice(); } if (ShutdownRequestPending || lms_shutdown_requested()) @@ -821,7 +823,7 @@ LmsMain(void) cluster_lms_cr_ship_ready(); cluster_gcs_block_pi_discard_drain(); } - (void)cluster_lms_outbound_drain_send(); + (void)cluster_lms_outbound_drain_send(0); /* spec-7.3 D4: worker 0's ring */ cluster_lms_data_plane_tick(LMS_IDLE_TIMEOUT_MS); } else { (void)WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, @@ -850,6 +852,331 @@ LmsMain(void) } +/* ============================================================ + * spec-7.3 D2 — LMS DATA-plane worker pool (workers 1..7). + * + * Workers are distinct AuxProcTypes (LmsWorker1..7Process) that carry the + * B_LMS_WORKER display type. worker 0 is the plain LmsProcess above; it + * keeps ALL of the LMS duties (GES grant service + park-serve CR + DATA + * shard 0). Workers 1..7 run the DATA plane ONLY. + * + * D2 scope (this file): spawn + identify (B_LMS_WORKER + worker_pids slot) + * + idle. The per-worker DATA listener + shard topology + HELLO + * worker_id/n_workers negotiation (D3), the outbound ring group + wakeup + * (D4), the per-worker private tables (D5) and the inline CR construction + * (D6) grow this loop toward the shared, worker_id-parameterised DATA loop. + * When cluster.lms_workers = 1 no worker is forked, so LmsMain runs exactly + * as in spec-7.2 (topology identity). + * ============================================================ */ + +/* The 7 worker AuxProcTypes must be exactly CLUSTER_LMS_MAX_WORKERS - 1 + * contiguous types, so ClusterLmsWorkerIdForType() maps them to 1..7. */ +StaticAssertDecl(LmsWorker7Process - LmsWorker1Process + 1 == CLUSTER_LMS_MAX_WORKERS - 1, + "spec-7.3: LmsWorker1..7Process must be CLUSTER_LMS_MAX_WORKERS-1 " + "contiguous aux process types"); + +/* The tier1 transport must carve a DATA channel per LMS worker (spec-7.3 D3). */ +StaticAssertDecl(CLUSTER_LMS_MAX_WORKERS <= CLUSTER_IC_TIER1_DATA_CHANNELS, + "spec-7.3: tier1 must provide a DATA shmem channel per LMS worker"); + +void +LmsWorkerMain(int worker_id) +{ + Assert(IsUnderPostmaster); + Assert(worker_id >= 1 && worker_id < CLUSTER_LMS_MAX_WORKERS); + + MyBackendType = B_LMS_WORKER; + init_ps_display(NULL); + + /* Standard PG aux-process signal layout (mirrors LmsMain). */ + pqsignal(SIGHUP, SignalHandlerForConfigReload); + pqsignal(SIGINT, SignalHandlerForShutdownRequest); + pqsignal(SIGTERM, SignalHandlerForShutdownRequest); + pqsignal(SIGALRM, SIG_IGN); + pqsignal(SIGPIPE, SIG_IGN); + /* SIGUSR1 = DATA-plane wakeup (latch only, async-signal-safe). In D2 it + * just re-arms the idle wait; the D4 outbound-ring producers use it to + * wake the worker that owns a tag's shard. */ + pqsignal(SIGUSR1, lms_sigusr1_handler); + pqsignal(SIGUSR2, SIG_IGN); + pqsignal(SIGCHLD, SIG_DFL); + + sigprocmask(SIG_SETMASK, &UnBlockSig, NULL); + + if (cluster_lms_state == NULL) + ereport(FATAL, + (errcode(ERRCODE_INTERNAL_ERROR), errmsg("cluster_lms shmem region not attached"), + errhint("cluster_lms_shmem_init() must run during " + "CreateSharedMemoryAndSemaphores()."))); + + /* Publish this worker's pid so the D4 wakeup path can find it. */ + LWLockAcquire(&cluster_lms_state->lwlock, LW_EXCLUSIVE); + cluster_lms_state->worker_pids[worker_id] = MyProcPid; + LWLockRelease(&cluster_lms_state->lwlock); + + ereport(LOG, (errmsg("cluster_lms: DATA-plane worker %d started (pid %d)", worker_id, + (int)MyProcPid))); + + /* + * PGRAC: spec-7.3 D3 — bring up this worker's DATA-plane channel + mesh + * (its own tier1 instance, listener on data_port + worker_id, dial peer + * worker_id only, HELLO worker/n negotiation). false = plane off for + * this node (no data_addr / cluster off / stub tier) → the loop below + * keeps the historic latch-only idle wait. + */ + (void)cluster_lms_data_plane_startup(worker_id, cluster_lms_workers); + + /* PGRAC: spec-7.3 D8 (Q8) — optional scheduling priority (0 = no-op). */ + cluster_lms_apply_nice(); + + /* + * D3 worker loop. A worker maintains its per-channel mesh via the + * data-plane tick; the block-family dispatch that arrives on its channel + * is served here (the outbound ring group + shard routing that steers + * backend requests to a worker land in D4). Worker 0's GES / park-serve + * / outbound-drain duties stay in LmsMain — a worker (1..7) is DATA only. + * SIGTERM sets ShutdownRequestPending; the head guard breaks and + * proc_exit(0) completes cleanly. + */ + for (;;) { + CHECK_FOR_INTERRUPTS(); + + if (ConfigReloadPending) { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + /* PGRAC: spec-7.3 D8 — re-apply cluster.lms_nice on reload. */ + cluster_lms_apply_nice(); + } + + if (ShutdownRequestPending) + break; + + if (cluster_lms_data_plane_enabled()) { + /* spec-7.3 D4 — drain this worker's outbound ring (backends + * staged REQUEST/FORWARD/INVALIDATE for our shard), then service + * the mesh. REPLY / INVALIDATE-ACK for blocks we received are + * sent directly from the dispatch handler in THIS process, so + * they already ride this worker's channel. */ + (void)cluster_lms_outbound_drain_send(worker_id); + cluster_lms_data_plane_tick(LMS_IDLE_TIMEOUT_MS); + } else { + (void)WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + LMS_IDLE_TIMEOUT_MS, WAIT_EVENT_PG_SLEEP); + ResetLatch(MyLatch); + } + } + + /* Close DATA-plane fds, then clear our pid slot before teardown. */ + cluster_lms_data_plane_shutdown(); + + LWLockAcquire(&cluster_lms_state->lwlock, LW_EXCLUSIVE); + cluster_lms_state->worker_pids[worker_id] = 0; + LWLockRelease(&cluster_lms_state->lwlock); + + proc_exit(0); +} + +pid_t +cluster_lms_get_worker_pid(int worker_id) +{ + pid_t pid; + + if (cluster_lms_state == NULL || worker_id < 0 || worker_id >= CLUSTER_LMS_MAX_WORKERS) + return 0; + LWLockAcquire(&cluster_lms_state->lwlock, LW_SHARED); + pid = cluster_lms_state->worker_pids[worker_id]; + LWLockRelease(&cluster_lms_state->lwlock); + return pid; +} + + +/* ============================================================ + * spec-7.3 D8 — per-worker DATA-plane observability (×N). + * + * Bumpers resolve the calling worker's slot from its DATA channel id + * and are no-ops in any non-LMS process (a backend dispatching a + * CONTROL envelope, the GC sweepers, etc.), so the call sites need no + * process-type guards of their own. Readers sum over all slots when + * worker_id = -1 (the pool aggregate the 7.2-era single-value keys + * generalize to, spec §3.8). + * ============================================================ */ + +/* Inline-serve duration histogram bounds (µs, inclusive upper bounds); + * the 16th bucket is the overflow. Finer-grained at the low end than the + * requester-side ship hist: a serve is a local construct (no wire RTT). */ +static const uint64 lms_serve_hist_bounds_us[CLUSTER_LMS_SERVE_HIST_BUCKETS - 1] = { + 50, 100, 200, 500, 1000, 2000, 5000, 10000, + 20000, 50000, 100000, 200000, 500000, 1000000, 5000000, +}; + +/* Calling worker's observability slot, or -1 when not an LMS process. */ +static int +lms_obs_my_slot(void) +{ + int w; + + if (cluster_lms_state == NULL) + return -1; + if (MyBackendType != B_LMS && MyBackendType != B_LMS_WORKER) + return -1; + w = cluster_ic_tier1_my_data_channel(); + if (w < 0 || w >= CLUSTER_LMS_MAX_WORKERS) + return -1; + return w; +} + +void +cluster_lms_obs_note_dispatch(void) +{ + int w = lms_obs_my_slot(); + + if (w >= 0) + pg_atomic_fetch_add_u64(&cluster_lms_state->worker_dispatch_count[w], 1); +} + +void +cluster_lms_obs_note_direct_reply(void) +{ + int w = lms_obs_my_slot(); + + if (w >= 0) + pg_atomic_fetch_add_u64(&cluster_lms_state->worker_direct_reply_count[w], 1); +} + +void +cluster_lms_obs_note_conn_reset(void) +{ + int w = lms_obs_my_slot(); + + if (w >= 0) + pg_atomic_fetch_add_u64(&cluster_lms_state->worker_conn_reset_count[w], 1); +} + +void +cluster_lms_obs_note_inline_serve(uint64 elapsed_us) +{ + int w = lms_obs_my_slot(); + int b = 0; + + if (w < 0) + return; + pg_atomic_fetch_add_u64(&cluster_lms_state->worker_inline_serve_count[w], 1); + while (b < CLUSTER_LMS_SERVE_HIST_BUCKETS - 1 && elapsed_us > lms_serve_hist_bounds_us[b]) + b++; + pg_atomic_fetch_add_u64(&cluster_lms_state->worker_serve_hist[w][b], 1); +} + +/* Reader helper: one slot, or the sum over all slots for worker_id = -1. */ +static uint64 +lms_obs_read(const pg_atomic_uint64 *arr, int worker_id) +{ + uint64 sum = 0; + int w; + + if (cluster_lms_state == NULL) + return 0; + if (worker_id >= 0) { + if (worker_id >= CLUSTER_LMS_MAX_WORKERS) + return 0; + return pg_atomic_read_u64(unconstify(pg_atomic_uint64 *, &arr[worker_id])); + } + for (w = 0; w < CLUSTER_LMS_MAX_WORKERS; w++) + sum += pg_atomic_read_u64(unconstify(pg_atomic_uint64 *, &arr[w])); + return sum; +} + +uint64 +cluster_lms_obs_get_dispatch_count(int worker_id) +{ + return cluster_lms_state == NULL + ? 0 + : lms_obs_read(cluster_lms_state->worker_dispatch_count, worker_id); +} + +uint64 +cluster_lms_obs_get_direct_reply_count(int worker_id) +{ + return cluster_lms_state == NULL + ? 0 + : lms_obs_read(cluster_lms_state->worker_direct_reply_count, worker_id); +} + +uint64 +cluster_lms_obs_get_conn_reset_count(int worker_id) +{ + return cluster_lms_state == NULL + ? 0 + : lms_obs_read(cluster_lms_state->worker_conn_reset_count, worker_id); +} + +uint64 +cluster_lms_obs_get_inline_serve_count(int worker_id) +{ + return cluster_lms_state == NULL + ? 0 + : lms_obs_read(cluster_lms_state->worker_inline_serve_count, worker_id); +} + +uint64 +cluster_lms_obs_get_serve_hist(int worker_id, int bucket) +{ + uint64 sum = 0; + int w; + + if (cluster_lms_state == NULL || bucket < 0 || bucket >= CLUSTER_LMS_SERVE_HIST_BUCKETS) + return 0; + if (worker_id >= 0) { + if (worker_id >= CLUSTER_LMS_MAX_WORKERS) + return 0; + return pg_atomic_read_u64(&cluster_lms_state->worker_serve_hist[worker_id][bucket]); + } + for (w = 0; w < CLUSTER_LMS_MAX_WORKERS; w++) + sum += pg_atomic_read_u64(&cluster_lms_state->worker_serve_hist[w][bucket]); + return sum; +} + +uint64 +cluster_lms_obs_serve_hist_bound_us(int bucket) +{ + if (bucket < 0 || bucket >= CLUSTER_LMS_SERVE_HIST_BUCKETS - 1) + return UINT64_MAX; /* overflow bucket (or out of range): no finite bound */ + return lms_serve_hist_bounds_us[bucket]; +} + +/* + * cluster_lms_apply_nice — spec-7.3 D8 (Q8): apply cluster.lms_nice to the + * calling LMS process via setpriority(2), best-effort. + * + * 0 (the default) means "leave the inherited priority alone" — going back + * to 0 after a non-zero value does NOT restore (raising priority back + * needs privileges anyway; documented manual behavior). Called at + * LmsMain / LmsWorkerMain entry and after each SIGHUP config reload; a + * change is applied once (LOG) and a failure warns once per value (the + * weak alignment to Oracle's LMS RT priority — the RT scheduling class + * itself is out of scope, spec §1.3). + */ +void +cluster_lms_apply_nice(void) +{ +#ifndef WIN32 + static int lms_nice_applied = 0; /* 0 = never applied (the no-op default) */ + + if (cluster_lms_nice == 0 || cluster_lms_nice == lms_nice_applied) + return; + if (setpriority(PRIO_PROCESS, 0, cluster_lms_nice) == 0) { + ereport(LOG, (errmsg("cluster_lms: applied cluster.lms_nice = %d (worker %d)", + cluster_lms_nice, cluster_ic_tier1_my_data_channel()))); + lms_nice_applied = cluster_lms_nice; + } else { + ereport(WARNING, (errmsg("cluster_lms: setpriority(%d) failed: %m", cluster_lms_nice), + errhint("cluster.lms_nice is best-effort; lowering the nice value below " + "the inherited one may require privileges."))); + lms_nice_applied = cluster_lms_nice; /* don't re-warn every reload */ + } +#endif +} + + /* ============================================================ * spec-2.25 D4 — native-lock probe collector lifecycle (Step 5). * diff --git a/src/backend/cluster/cluster_lms_data_plane.c b/src/backend/cluster/cluster_lms_data_plane.c index b8969f6137..0f43e7603b 100644 --- a/src/backend/cluster/cluster_lms_data_plane.c +++ b/src/backend/cluster/cluster_lms_data_plane.c @@ -98,12 +98,14 @@ static bool dp_enabled = false; * the READY transition. */ bool -cluster_lms_data_plane_startup(void) +cluster_lms_data_plane_startup(int worker_id, int n_workers) { int32 self_id = cluster_node_id; int32 pi; - Assert(MyBackendType == B_LMS); + /* worker 0 = LmsProcess (B_LMS); workers 1..7 = LmsWorker (B_LMS_WORKER). */ + Assert(MyBackendType == B_LMS || MyBackendType == B_LMS_WORKER); + Assert(worker_id >= 0 && worker_id < n_workers); if (!cluster_enabled) return false; @@ -112,8 +114,14 @@ cluster_lms_data_plane_startup(void) && cluster_interconnect_tier != CLUSTER_IC_TIER_3) return false; - /* Aim this process's tier1 state at the DATA plane BEFORE any use. */ - cluster_ic_tier1_set_my_plane(CLUSTER_IC_PLANE_DATA); + /* + * PGRAC: spec-7.3 D3 — aim this process's tier1 state at MY DATA worker + * channel BEFORE any use. This implies the DATA plane, gives this worker + * its private tier1 instance (peers[] / listener), sets the listener/dial + * port offset (data_port + worker_id) and the HELLO worker fields + * (worker_id, n_workers) for the shard-aligned mesh + N negotiation. + */ + cluster_ic_tier1_set_my_data_channel(worker_id, n_workers); dp_listener_fd = cluster_ic_tier1_listener_bind(); if (dp_listener_fd < 0) @@ -235,7 +243,6 @@ cluster_lms_data_plane_tick(long timeout_ms) static bool dp_epoch_seen = false; static bool dp_inject_reset_done = false; uint64 cur_epoch = cluster_epoch_get_current(); - bool epoch_bumped = dp_epoch_seen && cur_epoch != dp_last_epoch; bool inject_reset = false; /* @@ -262,6 +269,8 @@ cluster_lms_data_plane_tick(long timeout_ms) * otherwise lost. Test-only path: the injection GUC is never armed * in production, where epoch_bumped alone drives the reset. */ + bool epoch_bumped = dp_epoch_seen && cur_epoch != dp_last_epoch; + if (!epoch_bumped && cluster_injection_should_skip("cluster-lms-conn-reset") && !dp_inject_reset_done) inject_reset = true; @@ -298,13 +307,24 @@ cluster_lms_data_plane_tick(long timeout_ms) dp_inject_reset_done = true; /* - * PGRAC: spec-7.2 D6 — observability. lms_conn_resets counts - * DATA-plane connections torn down by a reset (epoch bump in - * production, injection in the F6-1 test), making the reset - * path visible in pg_stat_cluster_* / the debug dump. + * PGRAC: spec-7.3 D7 — per-worker reset observability (epoch ×N). + * Each DATA worker (0..N-1) runs this tick in its OWN process and + * observes the shared epoch bump independently, so a reconfig + * resets the whole pool without any cross-process driver — the + * sender gate (tier1_send_bytes) fail-closes any stale-epoch send + * in the meantime. Emit one LOG per worker per reset carrying the + * worker channel id so the ×N reset legs can assert that all N + * workers reset (only when a live connection was actually torn + * down, to avoid logging on an idle-mesh epoch advance). */ - if (n_closed > 0) - cluster_lms_bump_conn_resets((uint32)n_closed); + if (n_closed > 0) { + cluster_lms_obs_note_conn_reset(); /* spec-7.3 D8 per-worker counter */ + ereport(LOG, + (errmsg("cluster_lms: DATA mesh reset (worker %d) at epoch " + "%llu (%d peer%s closed)", + cluster_ic_tier1_my_data_channel(), (unsigned long long)cur_epoch, + n_closed, n_closed == 1 ? "" : "s"))); + } dp_last_epoch = cur_epoch; } diff --git a/src/backend/cluster/cluster_lms_outbound.c b/src/backend/cluster/cluster_lms_outbound.c index e7f4b9a5ec..b82b92cdc7 100644 --- a/src/backend/cluster/cluster_lms_outbound.c +++ b/src/backend/cluster/cluster_lms_outbound.c @@ -65,34 +65,51 @@ typedef struct ClusterLmsOutboundState { ClusterLmsOutboundSlot ring[PGRAC_LMS_OUTBOUND_CAPACITY]; } ClusterLmsOutboundState; -static ClusterLmsOutboundState *cluster_lms_outbound_state = NULL; -static LWLock *cluster_lms_outbound_lock = NULL; +/* + * spec-7.3 D4 — one ring per DATA worker channel. rings[0] is worker 0 (the + * spec-7.2 ring; lms_workers=1 uses only it — byte-identical), rings[c] is + * worker c. Sizing is the compile-time cap (CLUSTER_LMS_MAX_WORKERS); the + * live count follows cluster.lms_workers. Each ring keeps the Q6-B single- + * consumer single-tail FIFO semantics, and each has its own lock so worker c + * drains rings[c] without contending on the other workers. + */ +static ClusterLmsOutboundState *cluster_lms_outbound_rings = NULL; +static LWLock *cluster_lms_outbound_locks[CLUSTER_LMS_MAX_WORKERS]; + +#define OB_RING(w) (&cluster_lms_outbound_rings[(w)]) +#define OB_LOCK(w) (cluster_lms_outbound_locks[(w)]) static Size cluster_lms_outbound_shmem_size(void) { - return MAXALIGN(sizeof(ClusterLmsOutboundState)); + /* Contiguous array of CLUSTER_LMS_MAX_WORKERS rings; the C array stride is + * sizeof(ClusterLmsOutboundState), so size the whole block then MAXALIGN. */ + return MAXALIGN(mul_size(CLUSTER_LMS_MAX_WORKERS, sizeof(ClusterLmsOutboundState))); } static void cluster_lms_outbound_shmem_init(void) { bool found; + int i; - cluster_lms_outbound_state = (ClusterLmsOutboundState *)ShmemInitStruct( + cluster_lms_outbound_rings = (ClusterLmsOutboundState *)ShmemInitStruct( "pgrac cluster lms data outbound", cluster_lms_outbound_shmem_size(), &found); if (!found) - memset(cluster_lms_outbound_state, 0, sizeof(*cluster_lms_outbound_state)); + memset(cluster_lms_outbound_rings, 0, + CLUSTER_LMS_MAX_WORKERS * sizeof(ClusterLmsOutboundState)); if (!IsBootstrapProcessingMode()) - cluster_lms_outbound_lock = &(GetNamedLWLockTranche("ClusterLmsDataOutbound"))[0].lock; + for (i = 0; i < CLUSTER_LMS_MAX_WORKERS; i++) + cluster_lms_outbound_locks[i] + = &(GetNamedLWLockTranche("ClusterLmsDataOutbound"))[i].lock; } static const ClusterShmemRegion cluster_lms_outbound_region = { .name = "pgrac cluster lms data outbound", .size_fn = cluster_lms_outbound_shmem_size, .init_fn = cluster_lms_outbound_shmem_init, - .lwlock_count = 1, + .lwlock_count = CLUSTER_LMS_MAX_WORKERS, .owner_subsys = "cluster_lms_outbound", .reserved_flags = 0, }; @@ -107,7 +124,7 @@ cluster_lms_outbound_shmem_register(void) void cluster_lms_outbound_request_lwlocks(void) { - RequestNamedLWLockTranche("ClusterLmsDataOutbound", 1); + RequestNamedLWLockTranche("ClusterLmsDataOutbound", CLUSTER_LMS_MAX_WORKERS); } /* @@ -119,84 +136,98 @@ cluster_lms_outbound_request_lwlocks(void) * before the LMS wakeup fires. */ bool -cluster_lms_outbound_enqueue(uint8 msg_type, uint32 dest_node_id, const void *payload, - uint16 payload_len) +cluster_lms_outbound_enqueue(int worker_id, uint8 msg_type, uint32 dest_node_id, + const void *payload, uint16 payload_len) { + ClusterLmsOutboundState *ring; + LWLock *lock; ClusterLmsOutboundSlot *slot; - if (cluster_lms_outbound_state == NULL || cluster_lms_outbound_lock == NULL) + if (worker_id < 0 || worker_id >= CLUSTER_LMS_MAX_WORKERS) + return false; + if (cluster_lms_outbound_rings == NULL || OB_LOCK(worker_id) == NULL) return false; if (payload_len > PGRAC_LMS_OUTBOUND_PAYLOAD_MAX) return false; - LWLockAcquire(cluster_lms_outbound_lock, LW_EXCLUSIVE); - if (cluster_lms_outbound_state->count >= PGRAC_LMS_OUTBOUND_CAPACITY) { - LWLockRelease(cluster_lms_outbound_lock); + ring = OB_RING(worker_id); + lock = OB_LOCK(worker_id); + + LWLockAcquire(lock, LW_EXCLUSIVE); + if (ring->count >= PGRAC_LMS_OUTBOUND_CAPACITY) { + LWLockRelease(lock); return false; } - slot = &cluster_lms_outbound_state->ring[cluster_lms_outbound_state->head]; + slot = &ring->ring[ring->head]; slot->dest_node_id = dest_node_id; slot->msg_type = msg_type; slot->payload_len = payload_len; if (payload_len > 0) memcpy(slot->payload, payload, payload_len); - cluster_lms_outbound_state->head - = (cluster_lms_outbound_state->head + 1) % PGRAC_LMS_OUTBOUND_CAPACITY; - cluster_lms_outbound_state->count++; - LWLockRelease(cluster_lms_outbound_lock); + ring->head = (ring->head + 1) % PGRAC_LMS_OUTBOUND_CAPACITY; + ring->count++; + LWLockRelease(lock); - cluster_lms_wakeup(); + cluster_lms_wakeup(worker_id); return true; } -/* Head-requeue for WOULD_BLOCK (preserves per-peer FIFO). */ +/* Head-requeue for WOULD_BLOCK (preserves this worker's per-peer FIFO). */ static void -lms_outbound_requeue_head(const ClusterLmsOutboundSlot *slot) +lms_outbound_requeue_head(int worker_id, const ClusterLmsOutboundSlot *slot) { - LWLockAcquire(cluster_lms_outbound_lock, LW_EXCLUSIVE); - if (cluster_lms_outbound_state->count < PGRAC_LMS_OUTBOUND_CAPACITY) { - cluster_lms_outbound_state->tail - = (cluster_lms_outbound_state->tail + PGRAC_LMS_OUTBOUND_CAPACITY - 1) - % PGRAC_LMS_OUTBOUND_CAPACITY; - cluster_lms_outbound_state->ring[cluster_lms_outbound_state->tail] = *slot; - cluster_lms_outbound_state->count++; + ClusterLmsOutboundState *ring = OB_RING(worker_id); + LWLock *lock = OB_LOCK(worker_id); + + LWLockAcquire(lock, LW_EXCLUSIVE); + if (ring->count < PGRAC_LMS_OUTBOUND_CAPACITY) { + ring->tail = (ring->tail + PGRAC_LMS_OUTBOUND_CAPACITY - 1) % PGRAC_LMS_OUTBOUND_CAPACITY; + ring->ring[ring->tail] = *slot; + ring->count++; } /* full → drop; fire-and-forget layers self-heal via retransmit */ - LWLockRelease(cluster_lms_outbound_lock); + LWLockRelease(lock); } /* - * cluster_lms_outbound_drain_send — LMS data-plane loop: drain + send. + * cluster_lms_outbound_drain_send — one worker drains + sends its own ring. * * Bounded batch per call. WOULD_BLOCK requeues at the HEAD so the - * per-peer byte stream is never reordered (INV-7.2-DATA-FIFO). The - * GCS block REQUEST pre-send hook (direct-land arm) migrates here - * with the DATA consumer (D0-② coupling item ①). + * per-peer byte stream is never reordered (INV-7.2-DATA-FIFO). worker c + * only ever touches rings[c], so the single-consumer-single-tail + * guarantee holds per worker (spec-7.3 D4). The GCS block REQUEST + * pre-send hook (direct-land arm) rides along with the DATA consumer. */ int -cluster_lms_outbound_drain_send(void) +cluster_lms_outbound_drain_send(int worker_id) { + ClusterLmsOutboundState *ring; + LWLock *lock; int sent = 0; - if (cluster_lms_outbound_state == NULL || cluster_lms_outbound_lock == NULL) + if (worker_id < 0 || worker_id >= CLUSTER_LMS_MAX_WORKERS) + return 0; + if (cluster_lms_outbound_rings == NULL || OB_LOCK(worker_id) == NULL) return 0; - Assert(MyBackendType == B_LMS); + Assert(MyBackendType == B_LMS || MyBackendType == B_LMS_WORKER); + + ring = OB_RING(worker_id); + lock = OB_LOCK(worker_id); while (sent < 64) { ClusterLmsOutboundSlot slot; ClusterICSendResult rc; bool got = false; - LWLockAcquire(cluster_lms_outbound_lock, LW_EXCLUSIVE); - if (cluster_lms_outbound_state->count > 0) { - slot = cluster_lms_outbound_state->ring[cluster_lms_outbound_state->tail]; - cluster_lms_outbound_state->tail - = (cluster_lms_outbound_state->tail + 1) % PGRAC_LMS_OUTBOUND_CAPACITY; - cluster_lms_outbound_state->count--; + LWLockAcquire(lock, LW_EXCLUSIVE); + if (ring->count > 0) { + slot = ring->ring[ring->tail]; + ring->tail = (ring->tail + 1) % PGRAC_LMS_OUTBOUND_CAPACITY; + ring->count--; got = true; } - LWLockRelease(cluster_lms_outbound_lock); + LWLockRelease(lock); if (!got) break; @@ -212,7 +243,7 @@ cluster_lms_outbound_drain_send(void) continue; } if (rc == CLUSTER_IC_SEND_WOULD_BLOCK) { - lms_outbound_requeue_head(&slot); + lms_outbound_requeue_head(worker_id, &slot); break; } /* HARD_ERROR: peer down — drop; requesters retry fail-closed. */ @@ -222,15 +253,21 @@ cluster_lms_outbound_drain_send(void) } uint32 -cluster_lms_outbound_depth(void) +cluster_lms_outbound_depth(int worker_id) { + ClusterLmsOutboundState *ring; + LWLock *lock; uint32 depth; - if (cluster_lms_outbound_state == NULL || cluster_lms_outbound_lock == NULL) + if (worker_id < 0 || worker_id >= CLUSTER_LMS_MAX_WORKERS) + return 0; + if (cluster_lms_outbound_rings == NULL || OB_LOCK(worker_id) == NULL) return 0; - LWLockAcquire(cluster_lms_outbound_lock, LW_SHARED); - depth = cluster_lms_outbound_state->count; - LWLockRelease(cluster_lms_outbound_lock); + ring = OB_RING(worker_id); + lock = OB_LOCK(worker_id); + LWLockAcquire(lock, LW_SHARED); + depth = ring->count; + LWLockRelease(lock); return depth; } diff --git a/src/backend/cluster/cluster_lms_shard.c b/src/backend/cluster/cluster_lms_shard.c new file mode 100644 index 0000000000..b83bff3591 --- /dev/null +++ b/src/backend/cluster/cluster_lms_shard.c @@ -0,0 +1,80 @@ +/*------------------------------------------------------------------------- + * + * cluster_lms_shard.c + * pgrac LMS worker-pool shard map — spec-7.3 D1 (pure layer). + * + * See cluster_lms_shard.h for the contract. This file has no + * PG-backend dependencies beyond common/hashfn.h so it links into the + * standalone cluster_unit test. + * + * 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_lms_shard.c + * + * NOTES + * This is a pgrac-original file. Compiled only in --enable-cluster + * builds. Spec: spec-7.3-lms-worker-pool.md (D1). + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "common/hashfn.h" +#include "cluster/cluster_lms_shard.h" + +#ifdef USE_PGRAC_CLUSTER + +/* + * Packed shard-hash input width: the five BufferTag fields serialized in a + * fixed order (spcOid|dbOid|relNumber|forkNum|blockNum), 4 bytes each. + * Packing explicitly (rather than hashing the struct) defeats any struct + * padding and gives a byte image that is stable for the same field values — + * every cluster node runs the same build/arch, so both ends of a DATA + * connection hash to the same worker (spec-7.3 R1). + */ +#define LMS_SHARD_HASH_INPUT_LEN 20 + +/* Field widths the packing depends on (L3 encoding boundary / L6 math). */ +StaticAssertDecl(sizeof(Oid) == 4 && sizeof(RelFileNumber) == 4 && sizeof(BlockNumber) == 4 + && sizeof(ForkNumber) == 4, + "spec-7.3 D1: BufferTag field widths assumed 4B for the shard " + "hash packing; a width change requires revisiting the layout"); +StaticAssertDecl(LMS_SHARD_HASH_INPUT_LEN == 4 * 5, + "spec-7.3 D1: shard hash input is the five 4B BufferTag fields"); + +int +cluster_lms_shard_for_tag(const BufferTag *tag, int n_workers) +{ + uint8 hash_input[LMS_SHARD_HASH_INPUT_LEN]; + uint64 hash; + + Assert(tag != NULL); + Assert(n_workers >= 1 && n_workers <= CLUSTER_LMS_MAX_WORKERS); + + /* + * N == 1 is the spec-7.2 topology identity (only worker 0 runs) and also + * guards the modulo below against a zero/negative divisor: every tag maps + * to worker 0, no hash needed. Worker 0 is a live LMS, not a sentinel + * (L449) — the degenerate map is a real single-shard routing. + */ + if (n_workers <= 1) + return 0; + + /* Serialize the five fields in a fixed order (defeats struct padding). */ + memcpy(&hash_input[0], &tag->spcOid, 4); + memcpy(&hash_input[4], &tag->dbOid, 4); + memcpy(&hash_input[8], &tag->relNumber, 4); + memcpy(&hash_input[12], &tag->forkNum, 4); + memcpy(&hash_input[16], &tag->blockNum, 4); + + hash = hash_bytes_extended(hash_input, LMS_SHARD_HASH_INPUT_LEN, 0); + + return (int)(hash % (uint64)n_workers); +} + +#endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/postmaster/auxprocess.c b/src/backend/postmaster/auxprocess.c index 3be8e68c29..eae1435d62 100644 --- a/src/backend/postmaster/auxprocess.c +++ b/src/backend/postmaster/auxprocess.c @@ -137,10 +137,23 @@ AuxiliaryProcessMain(AuxProcType auxtype) case QvotecProcess: MyBackendType = B_QVOTEC; break; - /* PGRAC (spec-2.18 Sprint A Step 1): LMS aux process. */ + /* PGRAC (spec-2.18 Sprint A Step 1): LMS aux process (worker 0). */ case LmsProcess: MyBackendType = B_LMS; break; + /* PGRAC (spec-7.3 D2): LMS DATA-plane worker pool (worker 1..7). All + * carry the B_LMS_WORKER display type (Stage 0.10 reserved slot); the + * distinct AuxProcType per worker is what gives each its own + * BackendStatus / AuxiliaryProc slot. */ + case LmsWorker1Process: + case LmsWorker2Process: + case LmsWorker3Process: + case LmsWorker4Process: + case LmsWorker5Process: + case LmsWorker6Process: + case LmsWorker7Process: + MyBackendType = B_LMS_WORKER; + break; /* * PGRAC (spec-2.19 Sprint A Step 1): LMD aux process. B_LMD * BackendType already exists (spec-1.10 backend types extension); D3 @@ -224,7 +237,8 @@ AuxiliaryProcessMain(AuxProcType auxtype) #ifdef USE_PGRAC_CLUSTER if (MyAuxProcType != LmsProcess && MyAuxProcType != LmdProcess && MyAuxProcType != SinvalBcastProcess && MyAuxProcType != UndoCleanerProcess - && MyAuxProcType != MrpProcess && MyAuxProcType != RfsProcess) + && MyAuxProcType != MrpProcess && MyAuxProcType != RfsProcess + && !AmLmsWorkerProcess()) /* PGRAC: spec-7.3 D2 — workers skip like LMS */ #endif ProcSignalInit(MaxBackends + MyAuxProcType + 1); @@ -251,7 +265,8 @@ AuxiliaryProcessMain(AuxProcType auxtype) */ if (MyAuxProcType == LmsProcess || MyAuxProcType == LmdProcess || MyAuxProcType == SinvalBcastProcess || MyAuxProcType == UndoCleanerProcess - || MyAuxProcType == MrpProcess || MyAuxProcType == RfsProcess) { + || MyAuxProcType == MrpProcess || MyAuxProcType == RfsProcess + || AmLmsWorkerProcess()) { /* PGRAC: spec-7.3 D2 — workers mirror LMS */ pqsignal(SIGHUP, SignalHandlerForConfigReload); pqsignal(SIGINT, SignalHandlerForShutdownRequest); pqsignal(SIGTERM, SignalHandlerForShutdownRequest); @@ -349,6 +364,18 @@ AuxiliaryProcessMain(AuxProcType auxtype) case LmsProcess: LmsMain(); proc_exit(1); + /* PGRAC (spec-7.3 D2): LMS DATA-plane worker dispatch. worker_id is + * 1..7 derived from the AuxProcType offset; LmsWorkerMain is + * pg_attribute_noreturn(), proc_exit(1) is the defensive bailout. */ + case LmsWorker1Process: + case LmsWorker2Process: + case LmsWorker3Process: + case LmsWorker4Process: + case LmsWorker5Process: + case LmsWorker6Process: + case LmsWorker7Process: + LmsWorkerMain(ClusterLmsWorkerIdForType(MyAuxProcType)); + proc_exit(1); /* PGRAC (spec-2.19 Sprint A Step 1): LMD aux process dispatch. LmdMain * is pg_attribute_noreturn(); proc_exit(1) below is a defensive bailout * if the compiler does not honor the attribute. See cluster_lmd.h. */ diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c index 1a0f4ddccb..015c3161da 100644 --- a/src/backend/postmaster/postmaster.c +++ b/src/backend/postmaster/postmaster.c @@ -187,6 +187,7 @@ #include "cluster/cluster_catalog_bootstrap.h" /* cluster_catalog_startup_prepare (spec-6.14 D2) */ #include "cluster/cluster_fence.h" /* cluster_fence_postmaster_check (spec-2.28 D6) */ #include "cluster/cluster_guc.h" /* cluster_enabled (spec-1.11 Sprint B) */ +#include "cluster/cluster_lms_shard.h" /* CLUSTER_LMS_MAX_WORKERS (spec-7.3 D2) */ #include "cluster/cluster_lmd.h" /* cluster_lmd_mark_child_exit (spec-2.19 D12 hardening) */ #include "cluster/cluster_mrp.h" /* cluster_mrp_should_start (spec-6.4 D1) */ #include "cluster/cluster_rfs.h" /* cluster_rfs_should_start (spec-6.4 D3) */ @@ -342,6 +343,13 @@ static pid_t QvotecPID = 0; /* PGRAC (spec-2.18 Sprint A Step 1): LMS aux process pid;same pattern. */ static pid_t LmsPID = 0; +/* + * PGRAC (spec-7.3 D2): LMS DATA-plane worker pool pids. Slots [1 .. + * cluster_lms_workers-1] are the LmsWorker aux processes; slot 0 is unused + * (worker 0 = LmsPID above). Same start/reap/signal lifecycle as LmsPID, + * driven by the cluster.lms_workers count. + */ +static pid_t LmsWorkerPIDs[CLUSTER_LMS_MAX_WORKERS] = {0}; /* PGRAC (spec-2.19 Sprint A Step 1): LMD aux process pid;same pattern. */ static pid_t LmdPID = 0; /* PGRAC (spec-6.4 D1): ADG Managed Recovery Process pid;same pattern. */ @@ -671,6 +679,16 @@ static void ShmemBackendArrayRemove(Backend *bn); #define StartCssd() StartChildProcess(CssdProcess) #define StartQvotec() StartChildProcess(QvotecProcess) #define StartLms() StartChildProcess(LmsProcess) +/* PGRAC (spec-7.3 D2): spawn LMS DATA-plane worker id (1..7). */ +#define StartLmsWorker(id) StartChildProcess((AuxProcType)(LmsWorker1Process + (id) - 1)) +/* True when every LMS worker slot (1..7) has been reaped (pmState gate). */ +#define LmsWorkersAllReaped() \ + (LmsWorkerPIDs[1] == 0 && LmsWorkerPIDs[2] == 0 && LmsWorkerPIDs[3] == 0 \ + && LmsWorkerPIDs[4] == 0 && LmsWorkerPIDs[5] == 0 && LmsWorkerPIDs[6] == 0 \ + && LmsWorkerPIDs[7] == 0) +StaticAssertDecl(CLUSTER_LMS_MAX_WORKERS == 8, + "spec-7.3 D2: LmsWorkersAllReaped enumerates worker slots 1..7; " + "update it if CLUSTER_LMS_MAX_WORKERS changes"); #define StartLmd() StartChildProcess(LmdProcess) #define StartMrp() StartChildProcess(MrpProcess) #define StartRfs() StartChildProcess(RfsProcess) @@ -2030,6 +2048,22 @@ ServerLoop(void) && (pmState == PM_RUN || pmState == PM_HOT_STANDBY)) LmsPID = StartLms(); + /* + * PGRAC: spec-7.3 D2 — same ServerLoop (re)spawn for the LMS + * DATA-plane worker pool (workers 1 .. cluster_lms_workers-1). A zero + * slot in PM_RUN / PM_HOT_STANDBY is (re)spawned, mirroring the LMS + * respawn above. cluster.lms_workers = 1 forks no workers (spec-7.2 + * topology identity). + */ + if (cluster_enabled && cluster_lms_enabled + && (pmState == PM_RUN || pmState == PM_HOT_STANDBY)) { + int w; + + for (w = 1; w < cluster_lms_workers; w++) + if (LmsWorkerPIDs[w] == 0) + LmsWorkerPIDs[w] = StartLmsWorker(w); + } + /* * PGRAC: spec-2.19 Sprint A — same ServerLoop respawn for LMD * (8th cluster aux process). LMD is spawned by the phase 4 @@ -2956,6 +2990,13 @@ process_pm_reload_request(void) signal_child(QvotecPID, SIGHUP); if (LmsPID != 0) /* PGRAC spec-2.18 Sprint A Step 1 */ signal_child(LmsPID, SIGHUP); + { + int w; /* PGRAC: spec-7.3 D2 — forward SIGHUP to LMS workers */ + + for (w = 1; w < CLUSTER_LMS_MAX_WORKERS; w++) + if (LmsWorkerPIDs[w] != 0) + signal_child(LmsWorkerPIDs[w], SIGHUP); + } if (LmdPID != 0) /* PGRAC spec-2.19 Sprint A Step 1 */ signal_child(LmdPID, SIGHUP); if (MrpPID != 0) /* PGRAC spec-6.4 D1 */ @@ -3501,6 +3542,25 @@ process_pm_child_exit(void) HandleChildCrash(pid, exitstatus, _("LMS process")); continue; } + /* PGRAC (spec-7.3 D2): LMS DATA-plane worker reaper. A crashed + * worker takes down the group like any other aux process (its shard + * is unavailable until the ServerLoop respawn re-forks it). */ + { + int w; + bool matched = false; + + for (w = 1; w < CLUSTER_LMS_MAX_WORKERS; w++) { + if (pid == LmsWorkerPIDs[w]) { + LmsWorkerPIDs[w] = 0; + if (!EXIT_STATUS_0(exitstatus)) + HandleChildCrash(pid, exitstatus, _("LMS worker process")); + matched = true; + break; + } + } + if (matched) + continue; + } /* PGRAC (spec-2.19 Sprint A Step 1): LMD reaper. */ if (pid == LmdPID) { cluster_lmd_mark_child_exit(); @@ -3967,6 +4027,18 @@ HandleChildCrash(int pid, int exitstatus, const char *procname) else if (LmsPID != 0 && take_action) sigquit_child(LmsPID); + /* PGRAC (spec-7.3 D2): LMS worker crash handling — same pattern ×N. */ + { + int w; + + for (w = 1; w < CLUSTER_LMS_MAX_WORKERS; w++) { + if (pid == LmsWorkerPIDs[w]) + LmsWorkerPIDs[w] = 0; + else if (LmsWorkerPIDs[w] != 0 && take_action) + sigquit_child(LmsWorkerPIDs[w]); + } + } + /* PGRAC (spec-2.19 Sprint A Step 1): LMD crash handling. */ if (pid == LmdPID) LmdPID = 0; @@ -4159,6 +4231,15 @@ PostmasterStateMachine(void) /* spec-2.19 Q10 LIFO: LMD next. */ if (LmdPID != 0) signal_child(LmdPID, SIGTERM); + /* PGRAC: spec-7.3 D2 LIFO — LMS DATA-plane workers before worker 0. + * SIGTERM sets ShutdownRequestPending which each worker loop polls. */ + { + int w; + + for (w = 1; w < CLUSTER_LMS_MAX_WORKERS; w++) + if (LmsWorkerPIDs[w] != 0) + signal_child(LmsWorkerPIDs[w], SIGTERM); + } /* spec-2.18 Q10 LIFO: LMS next (was last-spawned in Phase 2.C * skeleton; spawn-integration site in Step 3+). SIGTERM sets * ShutdownRequestPending which the LMS main loop polls. */ @@ -4248,6 +4329,8 @@ PostmasterStateMachine(void) QvotecPID == 0 && /* PGRAC: spec-2.18 Sprint A — same wait for LMS. */ LmsPID == 0 && + /* PGRAC: spec-7.3 D2 — same wait for every LMS DATA-plane worker. */ + LmsWorkersAllReaped() && /* PGRAC: spec-2.19 Sprint A — same wait for LMD. */ LmdPID == 0 && /* PGRAC: spec-6.4 D1 — same wait for MRP. */ @@ -4620,6 +4703,13 @@ TerminateChildren(int signal) signal_child(QvotecPID, signal); if (LmsPID != 0) /* PGRAC spec-2.18 Sprint A Step 1 */ signal_child(LmsPID, signal); + { + int w; /* PGRAC: spec-7.3 D2 — broadcast to LMS DATA-plane workers */ + + for (w = 1; w < CLUSTER_LMS_MAX_WORKERS; w++) + if (LmsWorkerPIDs[w] != 0) + signal_child(LmsWorkerPIDs[w], signal); + } if (LmdPID != 0) /* PGRAC spec-2.19 Sprint A Step 1 */ signal_child(LmdPID, signal); if (MrpPID != 0) /* PGRAC spec-6.4 D1 */ diff --git a/src/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index de19c0e128..8b4ec0c203 100644 --- a/src/include/cluster/cluster_cr.h +++ b/src/include/cluster/cluster_cr.h @@ -256,6 +256,7 @@ 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_fence_refused_count(void); /* spec-7.3 D7 */ 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); diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index 7bab4dec18..e1a895ee14 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -177,7 +177,8 @@ typedef enum ClusterCrServerStat { 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 */ + CLUSTER_CR_SERVER_STAT_MULTI_VERDICT_DENIED = 8, /* spec-7.1 D3-b multi member refuse */ + CLUSTER_CR_SERVER_STAT_FENCE_REFUSED = 9 /* spec-7.3 D7 write-fenced -> refuse ship */ } ClusterCrServerStat; extern void cluster_cr_server_stat_bump(ClusterCrServerStat which); @@ -229,6 +230,21 @@ extern void cluster_lms_cr_drain(void); /* LMON tick side: ship every READY slot to its requester and free it. */ extern void cluster_lms_cr_ship_ready(void); +/* + * spec-7.3 D6 — DATA-plane inline serve. When the GCS block family is on the + * DATA plane, the worker[shard] that received a GCS_BLOCK_FORWARD CR / + * undo-fetch / undo-verdict request serves it inline (validate + construct / + * scan under the PG_TRY -> DENIED envelope) and ships the reply on its own + * DATA channel — no shmem slot, no worker-0 poll, no 100 ms latency. kind + * selects the branch; a refused / failed request ships an immediate + * fail-closed DENIED (requester keeps 53R9G / 53R97, Rule 8.A). The caller + * routes here ONLY when cluster_gcs_block_family_on_data_plane(); on the + * CONTROL plane the light-work park path (submit) is used instead. Always + * ships exactly one reply, so the caller does not itself reply on refusal. + */ +extern void cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, + ClusterLmsCrSlotKind kind); + /* Requester side (backend): fetch a CR page for (locator, fork, block) at * read_scn from origin_node. On success copies the shipped page into * dst_page and returns true; *out_partial says whether the local diff --git a/src/include/cluster/cluster_gcs_block.h b/src/include/cluster/cluster_gcs_block.h index bc683a3009..6c75c37c63 100644 --- a/src/include/cluster/cluster_gcs_block.h +++ b/src/include/cluster/cluster_gcs_block.h @@ -2114,6 +2114,15 @@ extern void cluster_gcs_block_bump_master_holder_lifecycle(void); */ extern void cluster_gcs_block_lmon_prepare_outbound_request(GcsBlockRequestPayload *req, int32 dest_node); + +/* + * spec-7.3 D4 — DATA worker for a staged block-family frame (hash of its + * BufferTag). Only REQUEST / FORWARD / INVALIDATE carry a routable tag; + * returns [0, n_workers) or -1 (8.A fail-closed: refuse to stage, never + * default a worker). See cluster_gcs_block.c for the direct-send rationale. + */ +extern int cluster_gcs_block_payload_shard(uint8 msg_type, const void *payload, uint16 payload_len, + int n_workers); extern void cluster_gcs_block_lmon_handle_direct_land_completion(int32 peer_node, uint64 wr_id, bool cqe_success, uint32 byte_len, const void *sidecar); diff --git a/src/include/cluster/cluster_gcs_block_dedup.h b/src/include/cluster/cluster_gcs_block_dedup.h index 8f47a1c15d..9fcd388429 100644 --- a/src/include/cluster/cluster_gcs_block_dedup.h +++ b/src/include/cluster/cluster_gcs_block_dedup.h @@ -283,10 +283,19 @@ GcsBlockDedupEntryIsReclaimSafe(const GcsBlockDedupEntry *entry, TimestampTz now * node-dead cleanup, and backend-exit cleanup can remove entries as soon * as this function releases the dedup lock, so CACHED_REPLY must be * replayed from the copied entry. + * + * PGRAC: spec-7.3 D5 — worker_id selects the per-worker dedup shard. It + * MUST equal cluster_lms_shard_for_tag(tag, cluster_lms_workers): every + * message for a given block tag routes to worker[shard(tag)] (D4), so the + * dedup entry for that request lives in exactly one shard. The caller + * (master-side handler) asserts shard(tag) == its own DATA channel before + * calling here. worker_id out of [0, live shard count) is a mis-route + * (序破坏, 8.A): fail-closed → FULL + misroute_failclosed_count++, never + * served from a wrong shard. */ extern GcsBlockDedupResult -cluster_gcs_block_dedup_lookup_or_register(const GcsBlockDedupKey *key, BufferTag tag, - uint8 transition_id, +cluster_gcs_block_dedup_lookup_or_register(int worker_id, const GcsBlockDedupKey *key, + BufferTag tag, uint8 transition_id, GcsBlockDedupEntry *cached_reply_out); /* @@ -304,17 +313,20 @@ extern void cluster_gcs_block_dedup_register_backend_exit_hook(void); * block_data may be NULL for non-GRANTED status; the entry's block_data * field is zero-filled in that case. */ -extern void cluster_gcs_block_dedup_install_reply(const GcsBlockDedupKey *key, +extern void cluster_gcs_block_dedup_install_reply(int worker_id, const GcsBlockDedupKey *key, GcsBlockReplyStatus status, const GcsBlockReplyHeader *header, const char *block_data); -extern void -cluster_gcs_block_dedup_install_reply_ex(const GcsBlockDedupKey *key, GcsBlockReplyStatus status, - const GcsBlockReplyHeader *header, const char *block_data, - const ClusterSfDepVec *sf_dep_vec, bool has_sf_dep); +extern void cluster_gcs_block_dedup_install_reply_ex(int worker_id, const GcsBlockDedupKey *key, + GcsBlockReplyStatus status, + const GcsBlockReplyHeader *header, + const char *block_data, + const ClusterSfDepVec *sf_dep_vec, + bool has_sf_dep); -/* Remove a specific entry by key (rare path; mostly used by tests). */ -extern void cluster_gcs_block_dedup_remove(const GcsBlockDedupKey *key); +/* Remove a specific entry by key (rare path; mostly used by tests). + * worker_id selects the shard (spec-7.3 D5). */ +extern void cluster_gcs_block_dedup_remove(int worker_id, const GcsBlockDedupKey *key); /* ============================================================ @@ -364,6 +376,21 @@ extern uint64 cluster_gcs_block_dedup_get_in_flight_count(void); extern uint64 cluster_gcs_block_dedup_get_evict_count(void); /* spec-7.2a D5 */ extern uint64 cluster_gcs_block_dedup_get_max_entries(void); /* spec-7.2a D5 */ +/* + * PGRAC: spec-7.3 D5 — count of dedup accesses rejected because worker_id + * fell outside [0, live shard count). A mis-route (a block tag reaching + * the wrong LMS worker) is a code-path invariant violation (D3 HELLO + * negotiates a cluster-wide n_workers; D1 shard() is byte-identical on + * both ends), so this stays 0 in a healthy cluster. A non-zero value is + * a fail-closed drop (8.A), never a wrong-shard serve. Summed across all + * shards but stored once in the always-present ctl header. + */ +extern uint64 cluster_gcs_block_dedup_get_misroute_failclosed_count(void); + +/* Record a mis-routed dedup access (shard(tag) != serving worker); shared + * by the module bounds guard and the master-side handler (spec-7.3 D5). */ +extern void cluster_gcs_block_dedup_note_misroute(void); + /* ============================================================ * Shmem registry — registered via cluster_shmem_register_region(). diff --git a/src/include/cluster/cluster_guc.h b/src/include/cluster/cluster_guc.h index 581a7f5cea..2e2dc478e6 100644 --- a/src/include/cluster/cluster_guc.h +++ b/src/include/cluster/cluster_guc.h @@ -784,6 +784,22 @@ extern bool cluster_lmd_enabled; */ extern bool cluster_lms_enabled; +/* + * spec-7.3 D2 — cluster.lms_workers: size of the LMS DATA-plane worker pool + * (worker 0 = LmsProcess, plus workers 1..cluster_lms_workers-1). PGC_POSTMASTER, + * default 2, range [1, CLUSTER_LMS_MAX_WORKERS]. 1 = spec-7.2 topology + * identity (no worker siblings forked). Must be cluster-uniform (HELLO + * negotiation, spec-7.3 D3). + */ +extern int cluster_lms_workers; + +/* + * spec-7.3 D8 (Q8) — cluster.lms_nice: optional nice value the LMS workers + * apply to themselves (setpriority, best-effort). PGC_SIGHUP, default 0 = + * leave the inherited priority alone; range [-20, 0]. + */ +extern int cluster_lms_nice; + /* * cluster.lock_acquire_cluster_path (spec-2.21 D2). * diff --git a/src/include/cluster/cluster_ic.h b/src/include/cluster/cluster_ic.h index c8b0b859f3..282afaf631 100644 --- a/src/include/cluster/cluster_ic.h +++ b/src/include/cluster/cluster_ic.h @@ -303,6 +303,24 @@ cluster_ic_hello_conn_epoch(const ClusterICHelloMsg *msg) return v; } +/* PGRAC: spec-7.3 D3 — DATA-plane worker_id / n_workers accessors (offsets + * 41/42). Zero for a pre-7.3 sender or a CONTROL-plane HELLO. */ +static inline uint8 +cluster_ic_hello_worker_id(const ClusterICHelloMsg *msg) +{ + if (msg == NULL) + return 0; + return msg->_pad[PGRAC_IC_HELLO_WORKER_ID_OFFSET - PGRAC_IC_HELLO_CAPABILITIES_OFFSET]; +} + +static inline uint8 +cluster_ic_hello_n_workers(const ClusterICHelloMsg *msg) +{ + if (msg == NULL) + return 0; + return msg->_pad[PGRAC_IC_HELLO_N_WORKERS_OFFSET - PGRAC_IC_HELLO_CAPABILITIES_OFFSET]; +} + /* * spec-2.2 D2 (post-codex review) -- HELLO wire encode/decode helpers. @@ -332,6 +350,15 @@ extern void cluster_ic_build_hello(uint8 out_buf[PGRAC_IC_HELLO_BYTES], uint16 h extern bool cluster_ic_parse_hello(const uint8 in_buf[PGRAC_IC_HELLO_BYTES], ClusterICHelloMsg *out_msg); +/* + * spec-7.3 D3 — write the DATA-plane worker_id / n_workers fields (offsets + * 41/42) onto an already-built HELLO buffer. Only the DATA-plane send path + * calls it; a CONTROL / pre-7.3 HELLO leaves them zero (byte-identical to + * spec-7.2). Kept next to build_hello as the wire-layout authority. + */ +extern void cluster_ic_hello_set_worker_fields(uint8 out_buf[PGRAC_IC_HELLO_BYTES], uint8 worker_id, + uint8 n_workers); + /* * spec-2.2 D1 -- per-peer state machine state. Ordering is diff --git a/src/include/cluster/cluster_ic_tier1.h b/src/include/cluster/cluster_ic_tier1.h index e6a3ce349d..caaf17e132 100644 --- a/src/include/cluster/cluster_ic_tier1.h +++ b/src/include/cluster/cluster_ic_tier1.h @@ -155,6 +155,28 @@ extern int cluster_ic_tier1_listener_bind(void); extern void cluster_ic_tier1_set_my_plane(ClusterICPlane plane); extern ClusterICPlane cluster_ic_tier1_my_plane(void); +/* + * PGRAC: spec-7.3 D3 — the DATA plane is parallelised across per-worker + * channels. CLUSTER_IC_TIER1_DATA_CHANNELS is the compile-time cap on DATA + * worker channels the transport carves shmem for (one ClusterICTier1Shmem + * instance each, so every worker has private peers[] / listener metadata and + * the send-gate is not clobbered across worker processes). It must be >= + * the LMS worker cap CLUSTER_LMS_MAX_WORKERS (asserted in cluster_lms.c). + * + * Channel 0 is worker 0 (the LmsProcess DATA plane) and keeps the spec-7.2 + * shmem offset, so cluster.lms_workers = 1 is byte-identical to spec-7.2. + * + * set_my_data_channel(channel, n_workers) implies the DATA plane and re-aims + * this process's tier1 working state at channel's private instance; the + * listener/dial port become (declared data port + channel), and the HELLO + * carries (channel as worker_id, n_workers) for the D3 negotiation. A DATA + * process that only calls set_my_plane(DATA) stays on channel 0 (worker 0). + */ +#define CLUSTER_IC_TIER1_DATA_CHANNELS 8 +extern void cluster_ic_tier1_set_my_data_channel(int channel, int n_workers); +extern int cluster_ic_tier1_my_data_channel(void); +extern int cluster_ic_tier1_my_n_workers(void); + /* PGRAC: spec-7.2 D3 — dispatch plane-gate drop counter (per plane). */ extern void cluster_ic_tier1_bump_plane_misroute_reject(void); extern uint64 cluster_ic_tier1_get_plane_misroute_reject(ClusterICPlane plane); diff --git a/src/include/cluster/cluster_lms.h b/src/include/cluster/cluster_lms.h index afe960df71..981fadefce 100644 --- a/src/include/cluster/cluster_lms.h +++ b/src/include/cluster/cluster_lms.h @@ -86,7 +86,8 @@ #include "storage/condition_variable.h" #include "storage/lock.h" /* LOCKTAG / LOCKMODE for probe slot */ #include "storage/lwlock.h" -#include "cluster/cluster_grd.h" /* ClusterGrdHolderId for probe slot */ +#include "cluster/cluster_grd.h" /* ClusterGrdHolderId for probe slot */ +#include "cluster/cluster_lms_shard.h" /* CLUSTER_LMS_MAX_WORKERS (spec-7.3) */ /* @@ -154,6 +155,14 @@ typedef enum ClusterLmsState { */ #define CLUSTER_LMS_NATIVE_LOCK_PROBE_MAX_SLOTS 64 +/* + * spec-7.3 D8 — per-worker inline-serve duration histogram bucket count. + * 15 finite upper bounds (µs, see lms_serve_hist_bounds_us in cluster_lms.c) + * + 1 overflow bucket. LMS-owned; distinct from the requester-side + * CLUSTER_GCS_SHIP_HIST_BUCKETS measurement (D0-③). + */ +#define CLUSTER_LMS_SERVE_HIST_BUCKETS 16 + typedef struct ClusterLmsNativeLockProbeSlot { pg_atomic_uint64 in_use; /* 0 = free, 2 = initializing, 1 = active */ uint64 probe_id; /* monotonic per-shard id (HC36 epoch) */ @@ -258,14 +267,45 @@ typedef struct ClusterLmsSharedState { * `LockWaitQueueInsertAtHead`改造 + integrated receiver. */ pg_atomic_uint64 priority_starvation_observed_count; - /* spec-7.2 D6 — DATA-plane connection resets observability counter. + /* + * spec-7.3 D2 — LMS DATA-plane worker pool pids. worker_pids[id] is the + * pid of worker id (0 = LmsProcess, 1..7 = LmsWorker aux processes); each + * worker publishes its own slot at main-entry and clears it on exit. 0 = + * not running. Read by backends (spec-7.3 D4) to wake the worker that + * owns a tag's shard. Guarded by lwlock like the other non-atomic fields. + */ + pid_t worker_pids[CLUSTER_LMS_MAX_WORKERS]; + + /* + * spec-7.3 D8 — per-worker DATA-plane observability (×N). Each worker + * bumps only its own slot (index = its DATA channel id), so there is no + * cross-worker contention; the dump surface exposes the per-worker + * detail plus the pool aggregate (sum). * - * Bumped by the LMS DATA-plane tick (cluster_lms_data_plane.c) once - * per connection torn down by a proactive reset: an epoch bump in - * production (INV-7.2-CONN-EPOCH ③) or the cluster-lms-conn-reset - * injection in the F6-1 fault test. Monotone; cluster-wide reset - * activity is the sum across nodes. */ - pg_atomic_uint64 lms_conn_resets; + * worker_dispatch_count: envelopes dispatched in this worker's + * DATA recv pump (shard-balance evidence). + * worker_direct_reply_count: replies shipped directly from this + * worker's dispatch context by the D6 + * inline serve (one per serve call, + * fence-refused DENIED ships included). + * worker_conn_reset_count: DATA-mesh resets this worker executed + * (epoch bump / injected; spec-7.3 D7). + * worker_inline_serve_count: inline CR / undo-fetch / undo-verdict + * serves that reached the serve envelope + * (fence-refused excluded — those never + * read or construct; spec-7.3 D6/D7). + * worker_serve_hist: inline serve duration histogram (µs; + * last bucket = overflow) — the R4 + * slow-shard backstop. LMS-owned: the + * requester-side ship_latency_hist in + * ClusterGcsBlockShared is a different + * measurement, NOT resized (D0-③). + */ + pg_atomic_uint64 worker_dispatch_count[CLUSTER_LMS_MAX_WORKERS]; + pg_atomic_uint64 worker_direct_reply_count[CLUSTER_LMS_MAX_WORKERS]; + pg_atomic_uint64 worker_conn_reset_count[CLUSTER_LMS_MAX_WORKERS]; + pg_atomic_uint64 worker_inline_serve_count[CLUSTER_LMS_MAX_WORKERS]; + pg_atomic_uint64 worker_serve_hist[CLUSTER_LMS_MAX_WORKERS][CLUSTER_LMS_SERVE_HIST_BUCKETS]; } ClusterLmsSharedState; @@ -303,6 +343,23 @@ extern void cluster_lms_request_shutdown(void); */ extern void LmsMain(void) pg_attribute_noreturn(); +/* + * spec-7.3 D2 — LMS DATA-plane worker main entry (workers 1..7). Invoked + * from auxprocess.c dispatch when MyAuxProcType is one of LmsWorker1..7Process; + * worker_id is the 1-based id. Publishes worker_pids[worker_id], installs the + * standard aux signal layout, and idles on MyLatch. The DATA-plane topology, + * outbound ring and inline construction are wired in spec-7.3 D3-D6 — a D2 + * worker only spawns, identifies, and idles. Asserts IsUnderPostmaster. + * Never returns. + */ +extern void LmsWorkerMain(int worker_id) pg_attribute_noreturn(); + +/* + * spec-7.3 D2 — read a worker's published pid (0 = not running). worker_id + * in [0, CLUSTER_LMS_MAX_WORKERS). Used by the D4 wakeup path + tests. + */ +extern pid_t cluster_lms_get_worker_pid(int worker_id); + /* * PGRAC: spec-7.2 D2 — LMS-owned DATA-plane interconnect loop * (cluster_lms_data_plane.c). startup binds the data_addr listener and @@ -311,23 +368,26 @@ extern void LmsMain(void) pg_attribute_noreturn(); * WaitEventSet round (sockets + MyLatch — replaces the historic plain * WaitLatch when the plane is live); shutdown closes owned fds. */ -extern bool cluster_lms_data_plane_startup(void); +extern bool cluster_lms_data_plane_startup(int worker_id, int n_workers); extern bool cluster_lms_data_plane_enabled(void); extern void cluster_lms_data_plane_tick(long timeout_ms); extern void cluster_lms_data_plane_shutdown(void); /* - * PGRAC: spec-7.2 D4 — LMS wakeup (mirror of cluster_lmon_wakeup) + the - * DATA-plane outbound ring (cluster_lms_outbound.c; Q6-B twin ring: - * backends stage DATA frames, LMS drains and sends on its own fds). + * PGRAC: spec-7.2 D4 + spec-7.3 D4 — wake a specific DATA worker + the + * DATA-plane outbound ring GROUP (cluster_lms_outbound.c; one Q6-B ring per + * worker). A backend picks the worker for a block by hashing its BufferTag + * (cluster_lms_shard_for_tag), stages the frame into that worker's ring, and + * wakes that worker. worker c drains and sends only rings[c]. worker_id in + * [0, CLUSTER_LMS_MAX_WORKERS). */ -extern void cluster_lms_wakeup(void); +extern void cluster_lms_wakeup(int worker_id); extern void cluster_lms_outbound_shmem_register(void); extern void cluster_lms_outbound_request_lwlocks(void); -extern bool cluster_lms_outbound_enqueue(uint8 msg_type, uint32 dest_node_id, const void *payload, - uint16 payload_len); -extern int cluster_lms_outbound_drain_send(void); -extern uint32 cluster_lms_outbound_depth(void); +extern bool cluster_lms_outbound_enqueue(int worker_id, uint8 msg_type, uint32 dest_node_id, + const void *payload, uint16 payload_len); +extern int cluster_lms_outbound_drain_send(int worker_id); +extern uint32 cluster_lms_outbound_depth(int worker_id); /* * Read-only accessors for SQL view + diagnostics. @@ -369,8 +429,32 @@ extern void cluster_lms_inc_priority_starvation_observed(void); extern uint64 cluster_lms_get_priority_starvation_observed_count(void); /* spec-7.2 D6 — DATA-plane connection resets observability counter. */ -extern void cluster_lms_bump_conn_resets(uint32 n); -extern uint64 cluster_lms_get_conn_resets(void); + +/* + * spec-7.3 D8 — per-worker DATA-plane observability (×N). + * + * The note_* bumpers are no-ops outside an LMS process (worker 0 or a + * LmsWorker) — each resolves the caller's worker slot from its DATA + * channel id. note_inline_serve also records the serve duration into + * the caller worker's histogram. The get_* readers take a worker id, + * or -1 for the pool aggregate (sum over all slots); hist_bound_us + * returns bucket b's inclusive upper bound in µs (UINT64_MAX for the + * overflow bucket, matching the gcs ship-hist idiom). + */ +extern void cluster_lms_obs_note_dispatch(void); +extern void cluster_lms_obs_note_direct_reply(void); +extern void cluster_lms_obs_note_conn_reset(void); +extern void cluster_lms_obs_note_inline_serve(uint64 elapsed_us); +extern uint64 cluster_lms_obs_get_dispatch_count(int worker_id); +extern uint64 cluster_lms_obs_get_direct_reply_count(int worker_id); +extern uint64 cluster_lms_obs_get_conn_reset_count(int worker_id); +extern uint64 cluster_lms_obs_get_inline_serve_count(int worker_id); +extern uint64 cluster_lms_obs_get_serve_hist(int worker_id, int bucket); +extern uint64 cluster_lms_obs_serve_hist_bound_us(int bucket); + +/* spec-7.3 D8 (Q8) — apply cluster.lms_nice to the calling LMS process + * (setpriority, best-effort; 0 = leave the inherited priority alone). */ +extern void cluster_lms_apply_nice(void); /* * State enum -> canonical lowercase string ("disabled", "not_started", diff --git a/src/include/cluster/cluster_lms_shard.h b/src/include/cluster/cluster_lms_shard.h new file mode 100644 index 0000000000..55068c43b1 --- /dev/null +++ b/src/include/cluster/cluster_lms_shard.h @@ -0,0 +1,69 @@ +/*------------------------------------------------------------------------- + * + * cluster_lms_shard.h + * pgrac LMS worker-pool shard map — spec-7.3 D1 (pure layer). + * + * The LMS data plane (spec-7.2) is parallelised across a pool of + * LMS workers (spec-7.3). Every DATA-plane message that concerns a + * block is routed to a worker chosen by hashing the block's + * BufferTag. cluster_lms_shard_for_tag() is the ONE shard function: + * the sender selects a stream with it, the receiver reads a stream + * bound to it, the outbound ring is chosen with it, and the per-tag + * private tables are keyed by it. Because it is the single source of + * the (tag -> worker) mapping, per-tag FIFO ordering survives the + * N-way split (spec-7.3 §3.1, 8.A load-bearing invariant). + * + * Load-bearing invariant (spec-7.3 D0-①): the shard is a function of + * the BufferTag ALONE — never request_id, backend, or direction. A + * same-tag REQUEST and its later ACK therefore ride the same + * worker<->worker stream, so the sole wire-FIFO dependency in the + * block family (cluster_gcs_block.c same-tag INVALIDATE-ACK vs + * re-REQUEST) is preserved after the split. + * + * 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_lms_shard.h + * + * NOTES + * This is a pgrac-original file. Compiled only in --enable-cluster + * builds. Spec: spec-7.3-lms-worker-pool.md (D1). + * + * Backend-only: includes storage/buf_internals.h (BufferTag). Must + * not be pulled into any frontend-reachable header (L8). + * + *------------------------------------------------------------------------- + */ +#ifndef CLUSTER_LMS_SHARD_H +#define CLUSTER_LMS_SHARD_H + +#include "storage/buf_internals.h" /* BufferTag */ + +/* + * Compile-time cap on the LMS worker pool. The runtime worker count is + * the GUC cluster.lms_workers in [1, CLUSTER_LMS_MAX_WORKERS] (spec-7.3 + * D2); shmem arrays (the DATA ring group, per-worker tracks and + * histograms) are sized to this cap, the live count follows the GUC. + * + * worker 0 = the historic LmsProcess (B_LMS); workers 1..7 are the + * new LmsWorker1..7Process (B_LMS_WORKER). N=1 is the spec-7.2 + * topology identity (only worker 0 runs). + */ +#define CLUSTER_LMS_MAX_WORKERS 8 + +/* + * cluster_lms_shard_for_tag — map a block's BufferTag to a worker id. + * + * Returns a shard in [0, n_workers). n_workers must be in + * [1, CLUSTER_LMS_MAX_WORKERS] (GUC-bounded and HELLO-negotiated to be + * cluster-uniform, spec-7.3 D3); n_workers == 1 always returns 0. The + * result depends on the BufferTag ALONE and is platform-stable for a + * given byte image, so both ends of a connection agree on the stream. + */ +extern int cluster_lms_shard_for_tag(const BufferTag *tag, int n_workers); + +#endif /* CLUSTER_LMS_SHARD_H */ diff --git a/src/include/miscadmin.h b/src/include/miscadmin.h index 0e41715ee3..b7174987e0 100644 --- a/src/include/miscadmin.h +++ b/src/include/miscadmin.h @@ -615,6 +615,28 @@ typedef enum { MrpProcess, RfsProcess, + /* + * LMS worker pool (spec-7.3 D2) — LmsWorker1..7Process are the DATA-plane + * worker siblings of LmsProcess (which is worker 0). Appended after + * RfsProcess to preserve every existing AuxProcType numeric value. Each + * is a DISTINCT AuxProcType so it gets its own BackendStatus / + * AuxiliaryProc slot (slotno = MaxBackends + MyAuxProcType) — sidestepping + * the "one process per aux type" assumption (backend_status.c) that ruled + * out a single-type-multi-instance carrier. Their MyBackendType is + * B_LMS_WORKER (the Stage 0.10 reserved BackendType). Postmaster forks + * cluster.lms_workers - 1 of them (0 when lms_workers = 1, i.e. the + * spec-7.2 topology identity). Contiguity and the worker_id derivation + * (MyAuxProcType - LmsWorker1Process + 1) are asserted in + * test_cluster_backend_types.c. + */ + LmsWorker1Process, + LmsWorker2Process, + LmsWorker3Process, + LmsWorker4Process, + LmsWorker5Process, + LmsWorker6Process, + LmsWorker7Process, + #endif NUM_AUXPROCTYPES /* Must be last! */ } AuxProcType; @@ -640,6 +662,16 @@ extern PGDLLIMPORT AuxProcType MyAuxProcType; #define AmUndoCleanerProcess() (MyAuxProcType == UndoCleanerProcess) #define AmMrpProcess() (MyAuxProcType == MrpProcess) #define AmRfsProcess() (MyAuxProcType == RfsProcess) +/* + * LMS worker pool (spec-7.3 D2). AmLmsWorkerProcess() is true in a DATA-plane + * worker sibling (worker id 1..7); worker 0 is the plain LmsProcess and is + * NOT matched here (AmLmsProcess()). ClusterLmsWorkerIdForType() maps a + * worker AuxProcType to its 1-based worker id (only valid when + * AmLmsWorkerProcess()). + */ +#define AmLmsWorkerProcess() \ + (MyAuxProcType >= LmsWorker1Process && MyAuxProcType <= LmsWorker7Process) +#define ClusterLmsWorkerIdForType(t) ((int)((t) - LmsWorker1Process) + 1) #endif diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h index 5aa1d77c7a..d6db39775c 100644 --- a/src/include/storage/proc.h +++ b/src/include/storage/proc.h @@ -514,7 +514,8 @@ extern PGDLLIMPORT PGPROC *PreparedXactProcs; * (L18 startup-time validation family). */ #ifdef USE_PGRAC_CLUSTER -#define NUM_AUXILIARY_PROCS 18 /* spec-6.4: +MrpProcess/+RfsProcess (was 16 spec-3.13) */ +#define NUM_AUXILIARY_PROCS \ + 25 /* spec-7.3: +LmsWorker1..7Process (was 18 spec-6.4: +Mrp/+Rfs; 16 spec-3.13) */ #else #define NUM_AUXILIARY_PROCS 5 #endif diff --git a/src/test/cluster_tap/t/015_inject.pl b/src/test/cluster_tap/t/015_inject.pl index fc8a3f7492..0979ef1884 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'), - '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)'); + '164', + 'pg_stat_cluster_injections returns 164 rows (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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-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)'); + '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-cr-fence-recheck,cluster-lms-cr-fence-refuse,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', + '164 injection point names match the full registry (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 b9733c5f5b..eaaf1d5170 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'}), - '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)'); + '164', + 'all 164 injection points have a .fault_type entry under inject category (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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'}), - '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)'); + '164', + 'all 164 injection points have a .hits entry under inject category (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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/018_shared_fs.pl b/src/test/cluster_tap/t/018_shared_fs.pl index 25b56e52ec..ee4dfb7794 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'), - '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)'); + '164', + 'L9 total injection registry size is 164 (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 362ed3a5a9..653707ac01 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'), - '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)'); + '164', + 'L15 total injection registry size is 164 (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 d6e940b9d0..e2ed11b217 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'), - '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)'); + '164', + 'L11 pg_stat_cluster_injections is 164 (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 496760a349..1d40fb4013 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'), - '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)'); + '164', + 'L12a pg_stat_cluster_injections is 164 (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 19710cd296..2f4d78c328 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'), - '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)'); + '164', + 'L11 pg_stat_cluster_injections is 164 (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 be06ee9005..4181f4b730 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'), - '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)'); + '164', + 'L6a pg_stat_cluster_injections has 164 entries (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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/030_acceptance.pl b/src/test/cluster_tap/t/030_acceptance.pl index 32346eb1e5..7fa47b5e54 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'), - '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)'); + '164', 'M1 164 injection points (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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 '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)'); + ) eq '328', + 'M5 inject category has 164×2 = 328 sub-keys (.fault_type + .hits; spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; spec-7.1 D3-a +1 cluster-mxid-halfspace-hard-limit; spec-7.3 D7 +1 cluster-lms-cr-fence-refuse; 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'); diff --git a/src/test/cluster_tap/t/110_gcs_loopback.pl b/src/test/cluster_tap/t/110_gcs_loopback.pl index effcf30e9c..459e8a2a7b 100644 --- a/src/test/cluster_tap/t/110_gcs_loopback.pl +++ b/src/test/cluster_tap/t/110_gcs_loopback.pl @@ -7,7 +7,7 @@ # any wire send (HC72), so wire path coverage is effectively limited # to SQL-visible surface invariants: # -# L1 fresh cluster startup: pg_cluster_state.gcs has 88 keys +# L1 fresh cluster startup: pg_cluster_state.gcs has 89 keys # L2 api_state = "active" after postmaster phase 1 init # L3 WAIT_EVENT_GCS_REPLY_WAIT registered in pg_stat_cluster_wait_events # L4 CLUSTER_WAIT_EVENTS_COUNT == 120 (cumulative through spec-7.2) @@ -65,12 +65,12 @@ sub gcs_value { $node->start; -# L1 — pg_cluster_state.gcs surface has 88 keys (spec-7.2 D6 hist). +# L1 — pg_cluster_state.gcs surface has 89 keys (spec-7.2 D6 hist). is($node->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L1 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6)'); + '89', + 'L1 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6)'); # L2 — api_state = "active" after postmaster phase 1 init. diff --git a/src/test/cluster_tap/t/111_gcs_block_ship_2node.pl b/src/test/cluster_tap/t/111_gcs_block_ship_2node.pl index fb04002d39..435ece257f 100644 --- a/src/test/cluster_tap/t/111_gcs_block_ship_2node.pl +++ b/src/test/cluster_tap/t/111_gcs_block_ship_2node.pl @@ -8,7 +8,7 @@ # # L1 ClusterPair startup — both postmasters healthy + tier1 connected # L2 fresh baseline gcs counters on both nodes (block_* counters = 0) -# L3 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip) (cumulative through +# L3 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip) (cumulative through # spec-6.13 direct-land observability) # L4 4 NEW wait events registered in pg_stat_cluster_wait_events: # ClusterGCSBlockShipWait, ClusterGCSBlockRequestDispatch, @@ -68,6 +68,7 @@ sub gcs_int_value { my $pair = PostgreSQL::Test::ClusterPair->new_pair( 'gcs_block_ship', quorum_voting_disks => 3, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off' ]); $pair->start_pair; @@ -113,19 +114,19 @@ sub gcs_int_value { # ============================================================ -# L3: pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip) +# L3: pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip) # (cumulative GCS surface through spec-6.13 direct-land observability). # ============================================================ is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node0 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip)'); + '89', + 'L3 node0 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip)'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node1 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip)'); + '89', + 'L3 node1 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip)'); # ============================================================ diff --git a/src/test/cluster_tap/t/112_gcs_block_retransmit_2node.pl b/src/test/cluster_tap/t/112_gcs_block_retransmit_2node.pl index 999927f382..ec659b3efe 100644 --- a/src/test/cluster_tap/t/112_gcs_block_retransmit_2node.pl +++ b/src/test/cluster_tap/t/112_gcs_block_retransmit_2node.pl @@ -9,7 +9,7 @@ # # L1 ClusterPair startup baseline (both postmasters healthy) # L2 fresh baseline: 9 NEW reliability counters all 0 on both nodes -# L3 pg_cluster_state.gcs has 88 keys (cumulative through spec-7.2 D6 hist) +# L3 pg_cluster_state.gcs has 89 keys (cumulative through spec-7.2 D6 hist) # L4 2 NEW wait events registered (ClusterGCSBlockRetransmitWait + # ClusterGCSBlockEpochStaleRetry) # L5 CLUSTER_WAIT_EVENTS_COUNT = 120 (spec-7.2 +2) @@ -69,9 +69,13 @@ sub gcs_int # ============================================================ # L1: ClusterPair startup. # ============================================================ +# spec-7.3: cluster.lms_workers defaults to 2, and each node's DATA plane binds +# a per-worker listener at data_port + worker_id (D3). Reserve a 2-port span per +# node so node0's worker-1 port does not collide with node1's base (mirrors t/367). my $pair = PostgreSQL::Test::ClusterPair->new_pair( 'gcs_block_retransmit', quorum_voting_disks => 3, + data_port_span => 2, extra_conf => [ 'autovacuum = off' ]); $pair->start_pair; @@ -107,18 +111,18 @@ sub gcs_int # ============================================================ -# L3: pg_cluster_state.gcs category has 88 keys (cumulative through spec-7.2 D6 hist). +# L3: pg_cluster_state.gcs category has 89 keys (cumulative through spec-7.2 D6 hist). # ============================================================ is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node0 pg_cluster_state.gcs category has 88 keys'); + '89', + 'L3 node0 pg_cluster_state.gcs category has 89 keys'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node1 pg_cluster_state.gcs category has 88 keys'); + '89', + 'L3 node1 pg_cluster_state.gcs category has 89 keys'); # ============================================================ @@ -139,7 +143,7 @@ sub gcs_int # ============================================================ -# L5: total wait event count = 85. +# L5: total wait event count = 120. # ============================================================ is($pair->node0->safe_psql( 'postgres', diff --git a/src/test/cluster_tap/t/113_gcs_block_2way_2node.pl b/src/test/cluster_tap/t/113_gcs_block_2way_2node.pl index 44da5967e6..4fc4f42892 100644 --- a/src/test/cluster_tap/t/113_gcs_block_2way_2node.pl +++ b/src/test/cluster_tap/t/113_gcs_block_2way_2node.pl @@ -9,7 +9,7 @@ # # L1 ClusterPair startup baseline (both postmasters healthy) # L2 fresh baseline: 7 NEW counters all 0 + catversion >= 202605420 -# L3 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip) (cumulative through spec-6.13) +# L3 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip) (cumulative through spec-6.13) # L4 cross-node forward path: node A read first → master_holder = A; # force same tag on node B via test-only injection → master # chooses forward path → A direct-ships to B → block_forward_sent @@ -73,6 +73,7 @@ sub gcs_int my $pair = PostgreSQL::Test::ClusterPair->new_pair( 'gcs_block_2way', quorum_voting_disks => 3, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off' ]); $pair->start_pair; @@ -108,18 +109,18 @@ sub gcs_int # ============================================================ -# L3: pg_cluster_state.gcs has 88 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a). +# L3: pg_cluster_state.gcs has 89 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a). # ============================================================ is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node0 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip)'); + '89', + 'L3 node0 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip)'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node1 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip)'); + '89', + 'L3 node1 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip)'); # ============================================================ diff --git a/src/test/cluster_tap/t/114_gcs_block_3way_2node.pl b/src/test/cluster_tap/t/114_gcs_block_3way_2node.pl index 1dcaeef5bb..411b969551 100644 --- a/src/test/cluster_tap/t/114_gcs_block_3way_2node.pl +++ b/src/test/cluster_tap/t/114_gcs_block_3way_2node.pl @@ -12,7 +12,7 @@ # # L1 ClusterPair startup baseline (both postmasters healthy) # L2 fresh baseline: 6 NEW spec-2.36 counters all 0 -# L3 pg_cluster_state.gcs has 88 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a) +# L3 pg_cluster_state.gcs has 89 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a) # L4 catversion lower-bound >= 202605430; wait event count == 120 # L5 S barrier injection — DENIED_PENDING_X surfaces under # cluster-gcs-block-starvation-force-denied inject; reader @@ -76,6 +76,7 @@ sub gcs_int # ============================================================ my $pair = PostgreSQL::Test::ClusterPair->new_pair( 'gcs_block_3way_2node', + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off' ]); $pair->start_pair; @@ -106,18 +107,18 @@ sub gcs_int # ============================================================ -# L3: pg_cluster_state.gcs has 88 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a). +# L3: pg_cluster_state.gcs has 89 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a). # ============================================================ is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node0 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip)'); + '89', + 'L3 node0 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip)'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L3 node1 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip)'); + '89', + 'L3 node1 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip)'); # ============================================================ diff --git a/src/test/cluster_tap/t/115_gcs_block_3way_3node.pl b/src/test/cluster_tap/t/115_gcs_block_3way_3node.pl index 1b12b7e661..37c1c03e06 100644 --- a/src/test/cluster_tap/t/115_gcs_block_3way_3node.pl +++ b/src/test/cluster_tap/t/115_gcs_block_3way_3node.pl @@ -130,8 +130,8 @@ sub gcs_int is($node->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - "L4 node$i pg_cluster_state.gcs has 88 keys"); + '89', + "L4 node$i pg_cluster_state.gcs has 89 keys"); } diff --git a/src/test/cluster_tap/t/116_gcs_block_lost_write_2node.pl b/src/test/cluster_tap/t/116_gcs_block_lost_write_2node.pl index 149c4711b2..57236b21b1 100644 --- a/src/test/cluster_tap/t/116_gcs_block_lost_write_2node.pl +++ b/src/test/cluster_tap/t/116_gcs_block_lost_write_2node.pl @@ -20,7 +20,7 @@ # L7 SQLSTATE 53R93 ERRCODE_CLUSTER_LOST_WRITE_DETECTED literal- # encodable in PG SQL (catalog 形式 verification) # L8 GUC switch back to 'error' SHOW returns 'error' -# L9 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a) +# L9 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a) # L10 Reply status enum value 12 (DENIED_LOST_WRITE) is新增的 # 最大 value (baseline workload must not trigger lost-write) # L11 spec-2.41 D / P1-C — behavioral lost-write inject: a @@ -71,6 +71,7 @@ sub gcs_int 'gcs_block_lost_write', quorum_voting_disks => 3, shared_data => 1, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off', 'cluster.grd_max_entries = 1024', @@ -107,8 +108,8 @@ sub gcs_int is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L2 pg_cluster_state.gcs category has 88 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a)'); + '89', + 'L2 pg_cluster_state.gcs category has 89 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a)'); # ============================================================ @@ -193,13 +194,13 @@ sub gcs_int # ============================================================ -# L9 (alias of L2): gcs key count = 88. +# L9 (alias of L2): gcs key count = 89. # ============================================================ is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '88', - 'L9 node1 pg_cluster_state.gcs has 88 keys (cross-node parity)'); + '89', + 'L9 node1 pg_cluster_state.gcs has 89 keys (cross-node parity)'); # ============================================================ 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 e4fbb8d292..0003b5874d 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 @@ -54,6 +54,7 @@ my $pair = PostgreSQL::Test::ClusterPair->new_pair( 'spec_3_9_cr', quorum_voting_disks => 3, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off', 'cluster.pcm_grd_max_entries = 0', @@ -94,8 +95,8 @@ # ---------- my $cr_rows = $node0->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}); -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)'); +is($cr_rows, '71', + 'L2 cr category has 58 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 + 1 spec-7.3 D7 fence-refused)'); # ---------- 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 0bb6ba78b5..0ac474df20 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 @@ -45,6 +45,7 @@ my $pair = PostgreSQL::Test::ClusterPair->new_pair( 'spec_3_10_cache', quorum_voting_disks => 3, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off', 'cluster.pcm_grd_max_entries = 0', @@ -78,8 +79,8 @@ is( $node0->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}), - '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)'); + '71', + 'L1d cr category has 58 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 + 1 spec-7.3 D7 fence-refused)'); } diff --git a/src/test/cluster_tap/t/252_gcs_block_coherence.pl b/src/test/cluster_tap/t/252_gcs_block_coherence.pl index f81752968f..126aac3592 100644 --- a/src/test/cluster_tap/t/252_gcs_block_coherence.pl +++ b/src/test/cluster_tap/t/252_gcs_block_coherence.pl @@ -86,6 +86,7 @@ sub gcs_int 'gcs_coherence', quorum_voting_disks => 3, shared_data => 1, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off' ]); $pair->start_pair; usleep(3_000_000); # let the tier1 mesh settle before the first GCS round-trip 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 f0ec544db5..fed912557f 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'}), - '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)'); + '71', + 'L1b cr category has 58 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 + 1 spec-7.3 D7 fence-refused)'); $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/346_cluster_6_12i_runtime_visibility.pl b/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl index a18646ead3..2924391f4a 100644 --- a/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl +++ b/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl @@ -120,6 +120,7 @@ sub poll_until 'i612_rtvis', quorum_voting_disks => 3, shared_data => 1, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ # spec-7.1a: a LIVE remote ITL ref whose overlay lookup misses is # resolved by the D4 origin pull, and that pull is (by design) @@ -249,6 +250,70 @@ sub poll_until usleep(1_000_000); } +# ============================================================ +# L4c: spec-7.3 D7 -- fence ×N on the undo serve. A write-fenced origin must +# not ship undo bytes / a commit verdict on the DATA plane (the 8.A-critical +# stale-commit_scn surface). The inline serve's fence gate sits ahead of the +# undo read / authority co-sample, so forcing it (cluster-lms-cr-fence-refuse, +# conf-armed + SIGHUP per the t/360 L5 pattern -- review P1-2: the old +# inject_skip_set guard skipped this leg forever) must keep a fresh node1 +# backend fail-closed 53R97, bump the origin's cr_server_fence_refused_count, +# and serve NOTHING (undo_served / verdict_served stay put -- the RED signal +# distinguishing the gate from a serve that failed), then recover once the +# fence clears. Shares the single gate with L7 in t/354; counter snapshots +# are taken AFTER the arm is observably effective so pre-arm serves cannot +# contaminate the static-counter assertions. +# ============================================================ +{ + # Arm on the origin; the LMS workers re-read cluster.injection_points on + # SIGHUP, so poll a fresh node1 backend (no per-backend authority memo -> + # the fetch rides the wire into the origin's fence gate) until the armed + # refusal is observable. + $node0->append_conf('postgresql.conf', + "cluster.injection_points = 'cluster-lms-cr-fence-refuse:skip'"); + $node0->reload; + my $err = 'arm poll never ran'; + for my $try (1 .. 60) + { + my ($rc, $out); + ($rc, $out, $err) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); + last if defined($err) && $err =~ /cluster TT (status unknown|slot recycled)/; + usleep(500_000); + } + like($err, qr/cluster TT (status unknown|slot recycled)/, + 'L4c fence ×N: write-fenced origin keeps the undo read fail-closed (53R97)'); + + my $fr0 = state_val($node0, 'cr', 'cr_server_fence_refused_count'); + my $us0 = state_val($node0, 'cr', 'cr_server_undo_served_count'); + my $vs0 = state_val($node0, 'cr', 'cr_server_verdict_served_count'); + + # One deterministic fenced fetch against the armed gate. + my ($rc2, $out2, $err2) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); + like($err2, qr/cluster TT (status unknown|slot recycled)/, + 'L4c armed gate keeps a fresh backend fail-closed (deterministic leg)'); + cmp_ok(state_val($node0, 'cr', 'cr_server_fence_refused_count'), '>', $fr0, + 'L4c origin fence-refused counter advanced (undo serve refused pre-read)'); + is(state_val($node0, 'cr', 'cr_server_undo_served_count'), $us0, + 'L4c no undo-TT fetch served while fenced (gate precedes the undo read)'); + is(state_val($node0, 'cr', 'cr_server_verdict_served_count'), $vs0, + 'L4c no verdict served while fenced'); + + # Disarm. A bare empty list does NOT recall a :skip arm (the assign_hook + # only reverts WARNING arms); an explicit ':none' entry re-arms the point + # to NONE in every process on SIGHUP. + $node0->append_conf('postgresql.conf', + "cluster.injection_points = 'cluster-lms-cr-fence-refuse:none'"); + $node0->reload; + my $ok = 0; + for my $try (1 .. 20) + { + my ($rc3, $out3, $err3) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); + if (defined $out3 && $out3 =~ /^12$/m) { $ok = 1; last; } + usleep(500_000); + } + ok($ok, 'L4c undo read recovers after the fence clears'); +} + # ============================================================ # L4b: aged-seed verdict leg (D-i4 / spec-6.15 D4) -- burn write xacts on the # origin until the seed xact's durable TT slot recycles; the single-block diff --git a/src/test/cluster_tap/t/354_cluster_6_12b_cr_server.pl b/src/test/cluster_tap/t/354_cluster_6_12b_cr_server.pl index fd00ae2d48..1b7f55e5d6 100644 --- a/src/test/cluster_tap/t/354_cluster_6_12b_cr_server.pl +++ b/src/test/cluster_tap/t/354_cluster_6_12b_cr_server.pl @@ -30,6 +30,13 @@ # L4 data plane OFF at the requester -> class-(3) refuse before any # fetch -> 53R9G (the boundary is byte-identical to pre-6.12b) # L5 counter surface: 6 cr-server keys present on both nodes +# L5b spec-7.3 D8: per-worker inline-serve counters + duration histogram +# L6 spec-7.3 D6: construct fail-closed injection -> requester 53R9G, +# worker survives, serve recovers on disarm (conf-armed + SIGHUP) +# L7 spec-7.3 D7: fence gate BEFORE construct -> DENIED without +# constructing (full/partial static), fence_refused advances +# L7b spec-7.3 D7 review P1-1: ship-time fence RE-CHECK (TOCTOU) -> +# construct runs, ship discarded, DENIED + fence_refused advances # # Spec: spec-6.12-crossnode-cache-fusion-perf-optimization.md (wave b) # @@ -109,6 +116,7 @@ sub cr_image_retry 'b612_crsrv', quorum_voting_disks => 3, shared_data => 1, + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off', 'cluster.crossnode_cr_data_plane = on', @@ -224,5 +232,180 @@ sub cr_image_retry is($rows, '6', 'L5 cr-server keys present (' . $n->name . ')'); } +# ============================================================ +# L5b: spec-7.3 D8 — per-worker inline-serve observability. The L2 remote CR +# above was served inline by the origin's worker[shard], so the origin's +# per-worker serve counters + duration histogram must have moved, and every +# inline serve ships exactly one direct reply (direct_reply >= inline_serve; +# fence/deny ships count as replies too). +# ============================================================ +{ + my $srv = state_val($node0, 'lms', 'lms_inline_serve_count'); + my $rep = state_val($node0, 'lms', 'lms_direct_reply_count'); + my $hist_total = $node0->safe_psql('postgres', q{ + SELECT COALESCE(sum(value::bigint), 0) FROM pg_cluster_state + WHERE category='lms' AND key LIKE 'lms\_serve\_hist\_us\_%' ESCAPE '\' + AND key NOT LIKE '%\_w%' ESCAPE '\'}); + cmp_ok($srv, '>', 0, 'L5b origin inline-serve counter moved (worker-side serve)'); + cmp_ok($rep, '>=', $srv, 'L5b every inline serve shipped a direct reply'); + cmp_ok($hist_total, '>', 0, 'L5b serve-duration histogram recorded the serves'); +} + +# Arm a SKIP injection on the origin via the conf GUC + reload (the LMS +# workers re-read cluster.injection_points on SIGHUP — the t/360 L5 pattern; +# review P1-2: the ClusterPair::inject_skip_set helper never existed, so the +# old guard skipped these legs forever), then poll the requester until the +# armed refusal is observable (the arm needs a worker SIGHUP tick to land). +# Returns the last (digest, err): on success err carries the fail-closed +# SQLSTATE; on timeout the caller's like() fails with the last evidence. +sub arm_and_poll_refuse +{ + my ($arm_node, $point, $req_node, $b, $scn) = @_; + $arm_node->append_conf('postgresql.conf', + "cluster.injection_points = '$point:skip'"); + $arm_node->reload; + my ($d, $err) = (undef, 'arm poll never ran'); + for my $i (1 .. 60) + { + ($d, $err) = cr_image($req_node, $b, $scn); + last if !defined($d) && $err =~ /(53R9G|cross-instance)/; + usleep(500_000); + } + return ($d, $err); +} + +# Disarm a conf-armed SKIP injection on $node. A bare empty list does NOT +# disarm a :skip arm (the assign_hook's not-in-list revert only recalls +# WARNING arms — colon-typed arms survive reloads by design); an explicit +# ':none' entry re-arms the point to NONE in every process on SIGHUP. +sub disarm_injection +{ + my ($node, $point) = @_; + $node->append_conf('postgresql.conf', + "cluster.injection_points = '$point:none'"); + $node->reload; +} + +# ============================================================ +# L6: spec-7.3 D6 — 8.A envelope on the inline serve. Forcing the origin's +# CR construction to fail-closed (the cluster-lms-cr-construct skip injection, +# conf-armed + SIGHUP) must (a) keep the requester at the unchanged 53R9G, +# (b) leave the origin's serving worker[shard] alive (the inline serve +# reproduces the drain's PG_TRY -> DENIED envelope, so a refused construction +# never crashes the worker), and (c) recover a normal serve once the +# injection clears. +# ============================================================ +{ + my (undef, $off_err6) + = arm_and_poll_refuse($node0, 'cluster-lms-cr-construct', $node1, 0, $scn_mid); + like($off_err6, qr/(53R9G|cross-instance)/, + 'L6 origin construct fail-closed keeps the requester 53R9G (8.A inline)'); + + # The origin's serving worker survived the fail-closed serve. + is($node0->safe_psql('postgres', 'SELECT 1'), '1', + 'L6 origin still serving after a fail-closed inline CR construct'); + + disarm_injection($node0, 'cluster-lms-cr-construct'); + my ($rec_d6, $rec_err6) = cr_image_retry($node1, 0, $scn_mid); + is($rec_d6, $auth, + 'L6 remote CR serve recovers after the injection clears ' + . '(' . (defined $rec_d6 ? 'ok' : "err=$rec_err6") . ')'); +} + +# ============================================================ +# L7: spec-7.3 D7 — fence ×N, gate-BEFORE-construct leg. A write-fenced node +# must not ship a CR image on the DATA plane. The fence gate sits ahead of +# construction in the inline serve, so forcing it (cluster-lms-cr-fence-refuse) +# must (a) keep the requester fail-closed 53R9G, (b) bump +# cr_server_fence_refused_count, (c) NOT construct anything (full/partial +# counters stay put — the RED signal distinguishing the gate from a +# construction that merely failed), (d) leave the origin serving, and +# (e) recover a normal serve once the fence clears. Counter snapshots are +# taken AFTER the arm is observably effective: the arm-landing poll itself +# may be served FULL until the worker's SIGHUP tick, and those pre-arm +# serves must not contaminate the static-counter assertions. +# ============================================================ +{ + my (undef, $off_err7) + = arm_and_poll_refuse($node0, 'cluster-lms-cr-fence-refuse', $node1, 0, $scn_mid); + like($off_err7, qr/(53R9G|cross-instance)/, + 'L7 write-fenced origin keeps the requester fail-closed 53R9G (fence ×N, 8.A)'); + + my $fr_before = state_val($node0, 'cr', 'cr_server_fence_refused_count'); + my $full_before = state_val($node0, 'cr', 'cr_server_full_count'); + my $part_before = state_val($node0, 'cr', 'cr_server_partial_count'); + + # One deterministic fenced request against the armed gate. + my (undef, $gate_err7) = cr_image($node1, 0, $scn_mid); + + # The fence gate fired (refused ship) ... + my $fr_after = state_val($node0, 'cr', 'cr_server_fence_refused_count'); + ok($fr_after > $fr_before, + "L7 cr_server_fence_refused_count advanced ($fr_before -> $fr_after)"); + + # ... and did so WITHOUT constructing anything (gate is ahead of construct). + is(state_val($node0, 'cr', 'cr_server_full_count'), $full_before, + 'L7 no FULL construction happened while fenced (gate precedes construct)'); + is(state_val($node0, 'cr', 'cr_server_partial_count'), $part_before, + 'L7 no PARTIAL construction happened while fenced'); + + # The origin's serving worker survived the refused serve. + is($node0->safe_psql('postgres', 'SELECT 1'), '1', + 'L7 origin still serving after a fence-refused inline serve'); + + disarm_injection($node0, 'cluster-lms-cr-fence-refuse'); + my ($rec_d7, $rec_err7) = cr_image_retry($node1, 0, $scn_mid); + is($rec_d7, $auth, + 'L7 remote CR serve recovers after the fence clears ' + . '(' . (defined $rec_d7 ? 'ok' : "err=$rec_err7") . ')'); +} + +# ============================================================ +# L7b: spec-7.3 D7 review P1-1 — fence ×N, ship-time RE-CHECK leg (TOCTOU). +# A lease that expires while the serve constructs must discard the +# just-constructed result at ship time (cluster-lms-cr-fence-recheck models +# the mid-serve expiry, which is not schedulable from TAP). The TOCTOU +# discard signature is the exact inverse of L7's: the construct DID happen +# (full/partial advanced — the gate was passed while the lease still held) +# yet the reply is still the fail-closed DENIED (fence_refused advanced, +# requester keeps 53R9G) — proving the discard sits between construct and +# ship, not ahead of construct. +# ============================================================ +{ + my (undef, $off_err7b) + = arm_and_poll_refuse($node0, 'cluster-lms-cr-fence-recheck', $node1, 0, $scn_mid); + like($off_err7b, qr/(53R9G|cross-instance)/, + 'L7b mid-serve fence expiry keeps the requester fail-closed 53R9G (TOCTOU, 8.A)'); + + my $fr_before = state_val($node0, 'cr', 'cr_server_fence_refused_count'); + my $constructed_before = state_val($node0, 'cr', 'cr_server_full_count') + + state_val($node0, 'cr', 'cr_server_partial_count'); + + # One deterministic request against the armed re-check. + my (undef, $recheck_err7b) = cr_image($node1, 0, $scn_mid); + + # The re-check fired (discarded ship) ... + my $fr_after = state_val($node0, 'cr', 'cr_server_fence_refused_count'); + ok($fr_after > $fr_before, + "L7b cr_server_fence_refused_count advanced ($fr_before -> $fr_after)"); + + # ... AFTER a real construction (the TOCTOU discard signature: the early + # gate was passed, the serve constructed, the ship-time re-check refused). + my $constructed_after = state_val($node0, 'cr', 'cr_server_full_count') + + state_val($node0, 'cr', 'cr_server_partial_count'); + cmp_ok($constructed_after, '>', $constructed_before, + 'L7b construction DID run before the ship-time re-check discarded it'); + + # The origin's serving worker survived the discarded ship. + is($node0->safe_psql('postgres', 'SELECT 1'), '1', + 'L7b origin still serving after a re-check-discarded inline serve'); + + disarm_injection($node0, 'cluster-lms-cr-fence-recheck'); + my ($rec_d7b, $rec_err7b) = cr_image_retry($node1, 0, $scn_mid); + is($rec_d7b, $auth, + 'L7b remote CR serve recovers after the re-check clears ' + . '(' . (defined $rec_d7b ? 'ok' : "err=$rec_err7b") . ')'); +} + $pair->stop_pair if $pair->can('stop_pair'); done_testing(); diff --git a/src/test/cluster_tap/t/360_lms_data_plane_faults.pl b/src/test/cluster_tap/t/360_lms_data_plane_faults.pl index 3c78f09b49..4bda8bb7b2 100644 --- a/src/test/cluster_tap/t/360_lms_data_plane_faults.pl +++ b/src/test/cluster_tap/t/360_lms_data_plane_faults.pl @@ -16,16 +16,17 @@ # percentiles are diag'd. # L3 hygiene: zero plane misroutes + no plane-gate errors. # L5 conn-reset injection (F6-1, un-SKIPPED): arm the one-shot -# cluster-lms-conn-reset injection, observe exactly one DATA-mesh -# reset in node0's log (L5.1), verify the mesh reconnects (L5.2) -# and that the reset is one-shot (L5.3), then prove block transfer -# CONVERGES after the reset (L5.4/L5.5, spec §4 line 193 "重连收敛"): -# the lms_conn_resets D6 counter recorded the reset, and a FRESH -# post-reset coincidence table (new, un-recycled xids that never hit -# the #119 wall) is X-swapped cross-node with wire block REQUESTs on -# both sides. The reset is now latched one-shot (cluster_lms_data_ -# plane.c) so the persistent GUC arm no longer storms or crashes the -# passive LMS. +# cluster-lms-conn-reset injection, observe one DATA-mesh reset per +# worker process (worker 0 + worker 1 under the default +# lms_workers=2) in node0's log (L5.1), verify the mesh reconnects +# (L5.2) and that the reset is one-shot per worker (L5.3), then +# prove block transfer CONVERGES after the reset (L5.4/L5.5, spec +# §4 line 193 "重连收敛"): the per-worker conn-reset counter +# recorded the reset, and a FRESH post-reset coincidence table +# (new, un-recycled xids that never hit the #119 wall) is X-swapped +# cross-node with wire block REQUESTs on both sides. The reset is +# latched one-shot per worker (cluster_lms_data_plane.c) so the +# persistent GUC arm no longer storms or crashes the passive LMS. # # KNOWN-BLOCKED / deferred legs (honest SKIP; see docs/stage7-substrate- # findings.md). The stage7 P0 substrate (spec-6.15b/4.6a/2.29a) was @@ -76,6 +77,7 @@ quorum_voting_disks => 3, shared_data => 1, storage_backend => 'block_device', + data_port_span => 2, # spec-7.3: default lms_workers=2 binds data_port+[0,1] extra_conf => [ 'autovacuum = off', 'fsync = off', @@ -231,6 +233,23 @@ sub pct_bound ok(defined($p99) && $p99 <= 20000, sprintf('L2 value gate p99 < 20ms (p99 <= %s us)', defined($p99) ? $p99 : 'inf')); +# ============================================================ +# L2b — spec-7.3 D8: per-worker dispatch counter. The L2 ping-pong rode the +# DATA plane, so the LMS pool on both nodes dispatched envelopes; the +# aggregate must equal the per-worker sum (default pool: w0 + w1). This is +# the assertable surface F6-2 wanted (the mispositioned data-dispatch +# injection point stays a spec-7.2 follow-up — the counter sits on the real +# dispatch path, in cluster_ic_dispatch_envelope). +# ============================================================ +for my $n ($node0, $node1) +{ + my $agg = lms_int($n, 'lms_data_dispatch_count'); + cmp_ok($agg, '>', 0, 'L2b ' . $n->name . ' LMS pool dispatched DATA envelopes'); + is($agg, + lms_int($n, 'lms_data_dispatch_count_w0') + lms_int($n, 'lms_data_dispatch_count_w1'), + 'L2b ' . $n->name . ' dispatch aggregate = per-worker sum'); +} + # ============================================================ # L3 — hygiene: zero plane misroutes. # ============================================================ @@ -244,6 +263,17 @@ sub pct_bound # Orthogonal to the spec-7.2 DATA plane; see docs/stage7-substrate- # findings.md. The SIGKILL is NOT issued here -- it would crash the node # into an unrecoverable state.) +# +# spec-7.3 D7 (Path A) confirms this is the intended HARD-crash semantics for +# the worker pool too: a SIGKILL/SIGSEGV of ANY LMS process (worker 0 or a +# LmsWorker) is treated like any PG aux-process crash -- the reaper escalates +# (HandleChildCrash -> PMQUIT_FOR_CRASH -> whole-node crash-restart), because a +# process killed mid-serve can leave a shmem LWLock (the per-worker outbound +# ring / dedup shard) stuck, and only a shmem reinit is safe. Full recovery +# stays blocked on the same F1 wall above. The ISOLATED path -- a GRACEFUL +# worker exit (SIGTERM) that respawns just that slot with the other shards +# undisturbed -- is the reachable D7 improvement and is proven live in +# t/367 L6 (no crash cascade; worker respawns with a fresh pid; mesh re-forms). # ============================================================ SKIP: { skip 'F1-8 KNOWN-BLOCKED: block_device crash-recovery raw_layout_lock ' @@ -328,14 +358,18 @@ sub pct_bound "L5.2 DATA mesh reconnects after the reset " . "(node0 CONNECTED $conn0_before->$conn0_after, node1 $conn1_before->$conn1_after)"); -# One-shot guarantee: the persistent GUC arm produced a single reset event -# (one close-log per peer), NOT a per-tick storm. In this 2-node pair that -# is exactly one injected close-log line. +# One-shot guarantee: the persistent GUC arm produced one reset event PER +# worker process (one close-log per peer), NOT a per-tick storm. spec-7.3: +# with the default lms_workers=2, node0 runs worker 0 (LmsProcess) + worker 1 +# (LmsWorker), each with its own DATA channel + its own one-shot latch, so the +# SIGHUP fan-out fires at most one injected close-log per worker (1..2 total), +# never the per-tick storm (>> 2) the latch prevents. my $reset_final = () = PostgreSQL::Test::Utils::slurp_file($node0->logfile) =~ /$reset_re/g; my $injected = $reset_final - $resets_before; -ok($injected == 1, - "L5.3 conn-reset injection is one-shot (fired $injected time; no per-tick storm)"); +ok($injected >= 1 && $injected <= 2, + "L5.3 conn-reset injection is one-shot per worker (fired $injected time(s); no per-tick storm)"); +# ============================================================ # ============================================================ # L5.4 / L5.5 — F6-1 CONVERGENCE (spec §4 line 193 "重连收敛"): after the reset # + reconnect, a real cross-node block transfer converges. @@ -347,11 +381,11 @@ sub pct_bound # per-recycled-slot, not table-wide -- see the L5.2 note). The nodes then swap # X on it exactly as t/358 L2/L3 do; convergence = both writes succeed AND both # requesters' block_request_count grow (real wire REQUESTs after the reset) AND -# zero plane misroutes. The lms_conn_resets D6 counter also confirms the reset +# zero plane misroutes. The per-worker conn-reset counter (aggregate) also confirms the reset # was observable. # ============================================================ -cmp_ok(lms_int($node0, 'lms_conn_resets'), '>', 0, - 'L5.4 node0 lms_conn_resets D6 counter recorded the DATA-plane reset'); +cmp_ok(lms_int($node0, 'lms_conn_reset_count'), '>', 0, + 'L5.4 node0 per-worker conn-reset counter (aggregate) recorded the DATA-plane reset'); # Fresh coincidence table on new xids (t/347 OID-align pattern; independent of # the reset -- just re-aligns the OID counters the run has since drifted). diff --git a/src/test/cluster_tap/t/364_lms_worker_pool.pl b/src/test/cluster_tap/t/364_lms_worker_pool.pl new file mode 100644 index 0000000000..ecb39cb3be --- /dev/null +++ b/src/test/cluster_tap/t/364_lms_worker_pool.pl @@ -0,0 +1,133 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 364_lms_worker_pool.pl +# spec-7.3 D2 regression smoke for the LMS DATA-plane worker pool. +# +# Verifies the worker instantiation / identity / N=1 identity contract: +# L1 default cluster.lms_workers=2 exposes 1 'lms' + 1 'lms worker' +# L2 cluster.lms_workers is a postmaster int GUC +# L3 cluster.lms_workers=4 exposes 1 'lms' + 3 'lms worker' +# L4 cluster.lms_workers=1 exposes 1 'lms' + 0 'lms worker' +# (spec-7.2 topology identity: no worker sibling forked) +# L5 a node with workers shuts down cleanly (no orphan / crash) +# +# The DATA-plane routing itself (workers actually serving shards) is +# wired in spec-7.3 D3-D6; D2 only spawns, identifies and idles the +# workers, so this file asserts visibility + count + clean lifecycle. +# +# Author: SqlRush +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../lib"; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use PgracClusterNode; + +# Count backends of a given backend_type. +sub backend_count +{ + my ($node, $type) = @_; + return $node->safe_psql('postgres', + qq{SELECT count(*) FROM pg_stat_activity WHERE backend_type = '$type'}); +} + +# ---- L1 + L2 : default pool size 2 ---------------------------------------- +my $node2 = PgracClusterNode->new('lms_workers_2'); +$node2->init; +$node2->append_conf('postgresql.conf', + "cluster.node_id = 0\ncluster.lms_workers = 2\n"); +$node2->start; + +ok( $node2->poll_query_until( + 'postgres', + q{SELECT count(*) = 1 FROM pg_stat_activity WHERE backend_type = 'lms worker'} + ), + 'L1 one lms worker visible when cluster.lms_workers=2'); +is(backend_count($node2, 'lms'), '1', 'L1a worker 0 (lms) present alongside'); + +my $guc_meta = $node2->safe_psql('postgres', q{ + SELECT setting, vartype, context + FROM pg_settings + WHERE name = 'cluster.lms_workers'}); +is($guc_meta, '2|integer|postmaster', + 'L2 cluster.lms_workers is a postmaster int GUC defaulting to 2'); + +# ---- L2b (spec-7.3 D8) : lms_nice GUC + per-worker dump rows --------------- +my $nice_meta = $node2->safe_psql('postgres', q{ + SELECT setting, vartype, context, min_val, max_val + FROM pg_settings + WHERE name = 'cluster.lms_nice'}); +is($nice_meta, '0|integer|sighup|-20|0', + 'L2b cluster.lms_nice is a sighup int GUC defaulting to 0 (range [-20,0])'); + +# Per-worker observability rows are emitted for the LIVE pool only: +# lms_workers=2 exposes _w0 + _w1 and no _w2. +my $wrows2 = $node2->safe_psql('postgres', q{ + SELECT count(*) FROM pg_cluster_state + WHERE category='lms' AND key IN + ('lms_data_dispatch_count', 'lms_direct_reply_count', + 'lms_conn_reset_count', 'lms_inline_serve_count', + 'lms_data_dispatch_count_w0', 'lms_data_dispatch_count_w1')}); +is($wrows2, '6', + 'L2b aggregate + per-worker observability keys present (lms_workers=2)'); +is( $node2->safe_psql('postgres', q{ + SELECT count(*) FROM pg_cluster_state + WHERE category='lms' AND key = 'lms_data_dispatch_count_w2'}), + '0', 'L2b no _w2 rows beyond the live pool'); +is( $node2->safe_psql('postgres', q{ + SELECT count(*) FROM pg_cluster_state + WHERE category='lms' AND key LIKE 'lms\_serve\_hist\_us\_%' ESCAPE '\' + AND key NOT LIKE '%\_w%' ESCAPE '\'}), + '16', 'L2b aggregate serve-duration histogram has 16 buckets'); + +$node2->stop; + +# ---- L3 : pool size 4 -> three worker siblings ----------------------------- +my $node4 = PgracClusterNode->new('lms_workers_4'); +$node4->init; +$node4->append_conf('postgresql.conf', + "cluster.node_id = 0\ncluster.lms_workers = 4\n"); +$node4->start; + +ok( $node4->poll_query_until( + 'postgres', + q{SELECT count(*) = 3 FROM pg_stat_activity WHERE backend_type = 'lms worker'} + ), + 'L3 three lms workers visible when cluster.lms_workers=4'); +is(backend_count($node4, 'lms'), '1', 'L3a exactly one lms (worker 0)'); + +$node4->stop; + +# ---- L4 : pool size 1 == spec-7.2 topology identity (no worker) ------------ +my $node1 = PgracClusterNode->new('lms_workers_1'); +$node1->init; +$node1->append_conf('postgresql.conf', + "cluster.node_id = 0\ncluster.lms_workers = 1\n"); +$node1->start; + +# Give ServerLoop time to run its respawn branch; it must fork no worker. +$node1->safe_psql('postgres', 'SELECT pg_sleep(1)'); +is(backend_count($node1, 'lms worker'), '0', + 'L4 no lms worker forked when cluster.lms_workers=1 (7.2 identity)'); +is(backend_count($node1, 'lms'), '1', 'L4a worker 0 (lms) still present'); + +# L4b (spec-7.3 D8): the live-pool bound follows N=1 -- _w0 only, no _w1. +is( $node1->safe_psql('postgres', q{ + SELECT count(*) FROM pg_cluster_state + WHERE category='lms' AND key IN + ('lms_data_dispatch_count_w0', 'lms_data_dispatch_count_w1')}), + '1', 'L4b per-worker rows bounded to the live pool (N=1: _w0 only)'); + +# L5 clean shutdown (fast stop; TAP asserts the exit status is clean). +$node1->stop; +ok(1, 'L5 node with worker pool shut down cleanly'); + +done_testing(); diff --git a/src/test/cluster_tap/t/366_gcs_block_dedup_capacity_2node.pl b/src/test/cluster_tap/t/366_gcs_block_dedup_capacity_2node.pl index 5af24cfbd0..800ff76cf1 100644 --- a/src/test/cluster_tap/t/366_gcs_block_dedup_capacity_2node.pl +++ b/src/test/cluster_tap/t/366_gcs_block_dedup_capacity_2node.pl @@ -236,6 +236,15 @@ sub arm_inject $node1->append_conf('postgresql.conf', $sc_common); $node1->append_conf('postgresql.conf', $cluster_conf); $node1->append_conf('postgresql.conf', "cluster.node_id = 1\n"); +# spec-7.3 merge: this hand-rolled rig reserves ONE data port per node, but +# the shipped default cluster.lms_workers=2 binds [data_port, data_port+1] -- +# consecutive get_free_port() values then cross-wire the two nodes' worker +# ports (HELLO DATA worker mismatch, fail-closed boot). Pin the pool to one +# worker: N=1 is the spec-7.2 topology identity, and the dedup capacity / +# eager-GC subject under test then runs on the single shard 0 -- byte- +# identical to the pre-pool shape this test was written against. +$node0->append_conf('postgresql.conf', "cluster.lms_workers = 1\n"); +$node1->append_conf('postgresql.conf', "cluster.lms_workers = 1\n"); my $pgrac_conf = <i mesh: each node's log shows the DATA plane +# reaching state CONNECTED on BOTH worker channels (0 and 1) -- the +# per-worker mesh formed, tagged by channel. +# L3 no worker mismatch on the matched pool + zero plane misroutes. +# L7 (D9 multi-tag fan-out) ping-pong X over a many-block shared table: +# BOTH workers' dispatch counters move on BOTH nodes (+ aggregate +# inline-serve liveness); zero plane misroutes AND zero dedup-shard +# fail-closed drops. +# L8 (D9 per-tag affinity) invalidate/re-read one single-block table: +# the moved-worker SET is identical on both ends (double-end shard +# agreement) and the serving-side per-frame shard check stays clean +# (a heap read also fetches undo/verdict tags, so >1 worker may +# legitimately move — see the leg comment). +# L5 (D7 epoch xN) one-shot conn-reset injection: every worker resets +# its OWN DATA mesh; per-worker conn-reset counters + aggregate. +# L6 (D7 graceful recycle) SIGTERM one worker: no node cascade, the +# slot respawns isolated with a fresh pid, mesh re-HELLOs. +# L4 n_workers mismatch (node0=2, node1=3) is refused fail-closed: +# the DATA HELLO verify rejects with "HELLO DATA worker mismatch" +# (8.A: a skew would make the two ends' shard tables disagree). +# +# DATA-plane per-worker peer state is not exposed via a SQL view (a +# backend runs on the CONTROL plane), so connectivity is asserted from +# the channel-tagged server log (the t/358 evidence pattern). +# +# Author: SqlRush +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../lib"; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(usleep); +use Test::More; + +my @base_conf = ( + 'autovacuum = off', + 'fsync = off', + 'shared_buffers = 64MB', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + 'cluster.online_join = on', + 'cluster.xid_striping = on', + 'cluster.crossnode_runtime_visibility = on', + 'cluster.crossnode_cr_data_plane = on', + 'cluster.block_self_contained = on'); + +sub gcs_int +{ + my ($node, $key) = @_; + my $v = $node->safe_psql('postgres', + qq{SELECT value FROM pg_cluster_state WHERE category='gcs' AND key='$key'}); + return defined($v) && $v ne '' ? int($v) : 0; +} + +# Slurp + concatenate several nodes' logs. The tier1 mesh is asymmetric: the +# active (dialing) side logs "HELLO sent ... (active)", while the passive side +# logs "HELLO verified ... (DATA worker N)" / the reject — and which node is +# passive is a connection race, so DATA-plane assertions read the merged log. +sub merged_log +{ + my @nodes = @_; + return join('', map { PostgreSQL::Test::Utils::slurp_file($_->logfile) } @nodes); +} + +# Poll the merged log of @nodes until $re appears (the DATA mesh reaches +# CONNECTED a few seconds after CONTROL comes up). 1 on match, 0 on timeout. +sub wait_for_log +{ + my ($nodes, $re, $timeout_s) = @_; + $timeout_s //= 30; + my $deadline = time + $timeout_s; + while (time < $deadline) + { + return 1 if merged_log(@$nodes) =~ $re; + usleep(500_000); + } + return 0; +} + +# ============================================================ +# Matched pool: both nodes lms_workers=2. +# ============================================================ +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'lmspool', + quorum_voting_disks => 3, + shared_data => 1, + storage_backend => 'block_device', + data_port_span => 2, + extra_conf => [ @base_conf, 'cluster.lms_workers = 2' ]); +$pair->start_pair; +usleep(2_000_000); +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'CONTROL peers up 0->1'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'CONTROL peers up 1->0'); + +my ($node0, $node1) = ($pair->node0, $pair->node1); + +# L2 — shard-aligned mesh: poll the merged log until BOTH worker channels +# reach CONNECTED (verified on the passive side). Worker 1 is the last to +# form; if it never does this fails (would surface a real port/offset bug), +# a few-second lag after CONTROL is normal (reconnect backoff). +ok(wait_for_log([ $node0, $node1 ], qr/HELLO verified.*\(DATA worker 1\)/, 30), + 'L2 DATA worker 1 channel mesh verified'); + +# Now read the settled merged log and assert the rest. +my $merged = merged_log($node0, $node1); +my $log0 = PostgreSQL::Test::Utils::slurp_file($node0->logfile); +my $log1 = PostgreSQL::Test::Utils::slurp_file($node1->logfile); + +# L1 — each node spawned worker 1 (LmsWorker) and bound its DATA listener. +like($log0, qr/DATA-plane worker 1 started/, 'L1 node0 spawned LMS worker 1'); +like($log1, qr/DATA-plane worker 1 started/, 'L1 node1 spawned LMS worker 1'); +like($log0, qr/DATA-plane listener bound/, 'L1 node0 DATA listener bound'); +like($log1, qr/DATA-plane listener bound/, 'L1 node1 DATA listener bound'); + +# L2b — worker 0's channel also reached CONNECTED (merged: passive side). +like($merged, qr/HELLO verified.*\(DATA worker 0\)/, 'L2 DATA worker 0 channel mesh verified'); + +# L3 — matched pool: no worker mismatch reject + zero plane misroutes. +unlike($merged, qr/HELLO DATA worker mismatch/, 'L3 matched pool: no worker mismatch'); +is(gcs_int($node0, 'plane_misroute_reject'), 0, 'L3 node0 zero plane misroutes'); +is(gcs_int($node1, 'plane_misroute_reject'), 0, 'L3 node1 zero plane misroutes'); + +# ============================================================ +# L7 / L8 — spec-7.3 D9: real cross-node block traffic over the pool. +# +# L7 (multi-tag fan-out): ping-pong X over a shared table spanning many +# blocks. Distinct BufferTags hash onto DISTINCT workers (D1 golden), so +# BOTH per-worker dispatch counters move on BOTH nodes -- the D4 ring +# routing, D5 dedup shard and D6 inline serve run xN on live traffic -- +# and the zero-misroute hygiene holds (incl. the D5 dedup-shard +# fail-closed drop counter, SQL-surfaced at D9). +# +# L8 (single-tag affinity): invalidate/re-read ONE single-block table. +# Every frame of that tag rides ONE worker stream, so exactly one +# worker's dispatch counter moves per node AND it is the SAME worker +# index on both ends -- the two ends' shard tables agree (R1, e2e). +# ============================================================ +sub poll_write_ok +{ + my ($node, $sql, $timeout_s, $label) = @_; + $timeout_s //= 60; + my $deadline = time + $timeout_s; + while (time < $deadline) + { + return 1 if eval { $node->safe_psql('postgres', $sql); 1 }; + usleep(500_000); + } + diag("poll_write_ok timeout ($label)"); + return 0; +} + +sub timed_update_retry +{ + my ($node, $sql, $tries) = @_; + $tries //= 30; + for my $attempt (1 .. $tries) + { + return 1 if eval { $node->safe_psql('postgres', $sql); 1 }; + usleep(500_000); + } + return 0; +} + +ok(poll_write_ok($node0, 'CREATE TABLE wg0 (x int)', 90, 'node0 gate'), + 'L7 node0 write gate open (join admitted)'); +ok(poll_write_ok($node1, 'CREATE TABLE wg1 (x int)', 90, 'node1 gate'), + 'L7 node1 write gate open (join admitted)'); + +# Shared-table relfilenode coincidence (t/347 OID-align pattern), for BOTH +# the multi-block L7 table and the single-block L8 table. +my ($p0, $p1, $q0, $q1) = ('', '', '', ''); +for my $attempt (1 .. 8) +{ + for my $n ($node0, $node1) + { + $n->safe_psql('postgres', + 'CREATE TABLE pool_t (aid int, bal int) WITH (fillfactor = 50)'); + $n->safe_psql('postgres', + 'CREATE TABLE pool_one (aid int, bal int) WITH (fillfactor = 50)'); + } + $p0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_t')}); + $p1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_t')}); + $q0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_one')}); + $q1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('pool_one')}); + last if $p0 eq $p1 && $q0 eq $q1; + my ($n0) = $p0 =~ /(\d+)$/; + my ($n1) = $p1 =~ /(\d+)$/; + my ($lag, $burn) = $n0 < $n1 ? ($node0, $n1 - $n0) : ($node1, $n0 - $n1); + $burn = 1 if $burn <= 0; + $lag->safe_psql('postgres', + "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + for my $n ($node0, $node1) + { + $n->safe_psql('postgres', 'DROP TABLE pool_t'); + $n->safe_psql('postgres', 'DROP TABLE pool_one'); + } +} +is($p0, $p1, 'L7 shared-table coincidence (pool_t)'); +is($q0, $q1, 'L8 shared-table coincidence (pool_one)'); +$node0->safe_psql('postgres', + 'INSERT INTO pool_t SELECT g, 0 FROM generate_series(1, 5000) g'); +$node0->safe_psql('postgres', 'INSERT INTO pool_one VALUES (1, 0)'); +$node0->safe_psql('postgres', 'CHECKPOINT'); +$node1->safe_psql('postgres', 'CHECKPOINT'); + +# L7 — baselines, then ping-pong the multi-block table X between the nodes. +my %l7_base; +for my $n ($node0, $node1) +{ + for my $w (0, 1) + { + $l7_base{ $n->name }{"disp$w"} = lms_int($n, "lms_data_dispatch_count_w$w"); + $l7_base{ $n->name }{"reply$w"} = lms_int($n, "lms_direct_reply_count_w$w"); + } +} +my $swaps = 0; +for my $r (1 .. 3) +{ + last unless timed_update_retry($node0, 'UPDATE pool_t SET bal = bal + 1', 20); + usleep(100_000); + last unless timed_update_retry($node1, 'UPDATE pool_t SET bal = bal + 1', 20); + usleep(100_000); + $swaps++; +} +ok($swaps > 0, "L7 completed $swaps multi-tag X ping-pong swaps over the DATA plane"); + +# Per-worker DISPATCH is the shard-balance surface: a dispatched frame is +# handled in that worker's own process, so both counters moving == distinct +# tags landed on distinct workers (fan-out) and each was served where it +# landed (the ping-pong made progress). The per-worker direct-reply counter +# deliberately counts ONLY the D6 inline CR / undo / verdict serves +# (cluster_lms.h D8 semantic) -- block-ship replies (the dominant frames +# here) ride the SGE/envelope ship path and are out of its scope, and the +# undo serves concentrate on the ACTIVE undo head block's tag (one shard at +# a time), so only the AGGREGATE reply counter is asserted for liveness. +for my $n ($node0, $node1) +{ + my $d0 = lms_int($n, 'lms_data_dispatch_count_w0') - $l7_base{ $n->name }{disp0}; + my $d1 = lms_int($n, 'lms_data_dispatch_count_w1') - $l7_base{ $n->name }{disp1}; + my $r0 = lms_int($n, 'lms_direct_reply_count_w0') - $l7_base{ $n->name }{reply0}; + my $r1 = lms_int($n, 'lms_direct_reply_count_w1') - $l7_base{ $n->name }{reply1}; + + diag('L7 ' . $n->name . " deltas: dispatch w0=$d0 w1=$d1 reply w0=$r0 w1=$r1"); + cmp_ok($d0, '>', 0, 'L7 ' . $n->name . ' worker 0 dispatched multi-tag frames'); + cmp_ok($d1, '>', 0, 'L7 ' . $n->name . ' worker 1 dispatched multi-tag frames'); + cmp_ok($r0 + $r1, '>', 0, + 'L7 ' . $n->name . ' inline CR/undo/verdict serves alive (aggregate)'); +} + +# L7 hygiene — fan-out produced ZERO misroutes: neither the tier1 plane gate +# nor the D5 per-worker dedup-shard fail-closed drop fired. +for my $n ($node0, $node1) +{ + is(gcs_int($n, 'plane_misroute_reject'), 0, + 'L7 ' . $n->name . ' zero plane misroutes after fan-out'); + is(gcs_int($n, 'dedup_misroute_failclosed_count'), 0, + 'L7 ' . $n->name . ' zero dedup-shard misroute drops after fan-out'); +} + +# L8 — quiesce, then single-BLOCK invalidate/re-read rounds on pool_one. +# +# A "single tag" SQL workload does not exist under pgrac MVCC: every +# cross-node read of the one heap block also fetches its writers' undo / +# verdict blocks (runtime visibility), whose tags shard independently +# (wire-traced at D9: the heap family rides shard(heap tag), each undo +# fetch rides shard(undo tag) — per-tag affinity holds for EVERY tag). +# So per-tag affinity is asserted e2e by the surfaces that check it +# per-frame: (a) the serving side verifies shard(tag) == its own +# channel on every REQUEST (D5) — the fail-closed drop counter must +# stay 0; and (b) the same workload must move the SAME worker SET on +# both ends (both ends run one shard table — R1 double-end agreement; +# a skew would land a tag's frames on different indexes on the two +# nodes). The exact (tag -> worker) truth table is pinned in +# test_cluster_lms_shard / test_cluster_gcs_block_shard. +my $settle_tries = 20; +my %prev; +while ($settle_tries-- > 0) +{ + my $stable = 1; + for my $n ($node0, $node1) + { + for my $w (0, 1) + { + my $v = lms_int($n, "lms_data_dispatch_count_w$w"); + $stable = 0 + if !defined $prev{ $n->name }{$w} || $prev{ $n->name }{$w} != $v; + $prev{ $n->name }{$w} = $v; + } + } + last if $stable; + usleep(500_000); +} + +my %l8_base; +for my $n ($node0, $node1) +{ + for my $w (0, 1) + { + $l8_base{ $n->name }{$w} = lms_int($n, "lms_data_dispatch_count_w$w"); + } +} +my $rounds = 0; +for my $r (1 .. 12) +{ + last + unless timed_update_retry($node0, + 'UPDATE pool_one SET bal = bal + 1 WHERE aid = 1', 20); + $node1->safe_psql('postgres', 'SELECT bal FROM pool_one WHERE aid = 1'); + $rounds++; +} +ok($rounds > 0, "L8 completed $rounds single-block invalidate/re-read rounds"); + +my %l8_moved; +for my $n ($node0, $node1) +{ + my $d0 = lms_int($n, 'lms_data_dispatch_count_w0') - $l8_base{ $n->name }{0}; + my $d1 = lms_int($n, 'lms_data_dispatch_count_w1') - $l8_base{ $n->name }{1}; + + diag('L8 ' . $n->name . " deltas: dispatch w0=$d0 w1=$d1"); + cmp_ok($d0 + $d1, '>=', 3, 'L8 ' . $n->name . ' single-block stream carried frames'); + $l8_moved{ $n->name } = ($d0 > 0 ? 1 : 0) | ($d1 > 0 ? 2 : 0); + is(gcs_int($n, 'dedup_misroute_failclosed_count'), 0, + 'L8 ' . $n->name . ' serving-side per-tag shard check clean (0 drops)'); +} +is($l8_moved{ $node0->name }, + $l8_moved{ $node1->name }, + 'L8 same moved-worker SET on BOTH ends (double-end shard agreement, R1)'); + +# ============================================================ +# L5 — spec-7.3 D7: epoch-reset ×N. Each DATA worker runs its own tick in its +# OWN process and observes the shared epoch / reconfig reset signal +# independently, so a reset reaches the WHOLE pool with no cross-process driver +# (the tier1 sender gate is the structural backstop for the window between the +# bump and each worker's next tick). Arm the one-shot conn-reset injection on +# node0 -- SIGHUP fans out to every worker pid -- and assert that BOTH worker 0 +# AND worker 1 log their own per-worker DATA-mesh reset. +# ============================================================ +my $w0_reset_re = qr/DATA mesh reset \(worker 0\)/; +my $w1_reset_re = qr/DATA mesh reset \(worker 1\)/; + +$node0->append_conf('postgresql.conf', + "cluster.injection_points = 'cluster-lms-conn-reset:skip'"); +$node0->reload; + +my $both_reset = 0; +for my $i (1 .. 60) +{ + usleep(500_000); + my $l = PostgreSQL::Test::Utils::slurp_file($node0->logfile); + $both_reset = ($l =~ $w0_reset_re && $l =~ $w1_reset_re) ? 1 : 0; + last if $both_reset; +} +ok($both_reset, + 'L5 epoch ×N: both worker 0 and worker 1 reset their DATA mesh independently'); + +# spec-7.3 D8 — the per-worker conn-reset counters carry the same fact +# (stronger than the log grep: exact worker attribution via the dump). +sub lms_int +{ + my ($node, $key) = @_; + my $v = $node->safe_psql('postgres', + qq{SELECT value FROM pg_cluster_state WHERE category='lms' AND key='$key'}); + return defined($v) && $v ne '' ? int($v) : 0; +} +cmp_ok(lms_int($node0, 'lms_conn_reset_count_w0'), '>=', 1, + 'L5 worker 0 conn-reset counter moved'); +cmp_ok(lms_int($node0, 'lms_conn_reset_count_w1'), '>=', 1, + 'L5 worker 1 conn-reset counter moved'); +is(lms_int($node0, 'lms_conn_reset_count'), + lms_int($node0, 'lms_conn_reset_count_w0') + lms_int($node0, 'lms_conn_reset_count_w1'), + 'L5 aggregate conn-reset = sum of the per-worker counters'); + +# Disarm before the recycle leg (the one-shot latch already prevents a storm; +# a later appended line wins on reload). +$node0->append_conf('postgresql.conf', "cluster.injection_points = ''"); +$node0->reload; +usleep(1_000_000); + +# ============================================================ +# L6 — spec-7.3 D7: graceful per-worker recycle isolation (L3a, Path A). +# SIGTERM ONE DATA worker (worker 1) on node0. A clean worker exit respawns +# just that slot via the ServerLoop WITHOUT a node crash cascade, so node0 +# stays up and serving throughout, worker 1 comes back with a fresh pid, and +# its DATA mesh re-forms -- worker 0 (the other shard) is never disturbed. +# (Contrast: a SIGKILL hard-crash cascades the whole node -- KNOWN-BLOCKED on +# the orthogonal F1 recovery-vs-membership wall, see t/360 L4.) +# ============================================================ +my $w1_pid = $node0->safe_psql('postgres', + q{SELECT pid FROM pg_stat_activity + WHERE backend_type = 'lms worker' ORDER BY pid LIMIT 1}); +if (defined $w1_pid && $w1_pid =~ /^\d+$/) +{ + my $verify_re = qr/HELLO verified.*\(DATA worker 1\)/; + my $verified_before = () = merged_log($node0, $node1) =~ /$verify_re/g; + + kill 'TERM', $w1_pid; + + # node0 stays UP the whole time -- a crash cascade would drop SELECT 1 + # during PM_WAIT_BACKENDS; a graceful recycle never does. + my $stayed_up = 1; + for my $i (1 .. 10) + { + $stayed_up = 0 unless eval { $node0->safe_psql('postgres', 'SELECT 1'); 1 }; + usleep(300_000); + } + ok($stayed_up, + 'L6 node0 stayed up through the graceful worker recycle (no crash cascade)'); + + # worker 1 respawned with a fresh pid (isolated restart). + my $new_pid = ''; + for my $i (1 .. 60) + { + $new_pid = $node0->safe_psql('postgres', + q{SELECT pid FROM pg_stat_activity + WHERE backend_type = 'lms worker' ORDER BY pid LIMIT 1}); + last if defined $new_pid && $new_pid =~ /^\d+$/ && $new_pid ne $w1_pid; + usleep(500_000); + } + ok(defined $new_pid && $new_pid =~ /^\d+$/ && $new_pid ne $w1_pid, + "L6 DATA worker 1 respawned isolated (pid $w1_pid -> $new_pid)"); + + # The respawned worker re-forms its DATA mesh: a FRESH HELLO verify appears + # (count strictly increases over the pre-kill baseline). + my $reverified = 0; + for my $i (1 .. 60) + { + my $now = () = merged_log($node0, $node1) =~ /$verify_re/g; + $reverified = ($now > $verified_before) ? 1 : 0; + last if $reverified; + usleep(500_000); + } + ok($reverified, + 'L6 respawned worker 1 re-formed its DATA mesh (fresh re-HELLO converged)'); +} +else +{ + SKIP: + { + skip 'no lms worker in pg_stat_activity (unexpected on a matched pool)', 3; + } +} + +$pair->stop_pair; + +# ============================================================ +# Mismatch pool: node0=2, node1=3 -> DATA HELLO refused fail-closed. +# ============================================================ +my $mix = PostgreSQL::Test::ClusterPair->new_pair( + 'lmsmix', + quorum_voting_disks => 3, + shared_data => 1, + storage_backend => 'block_device', + data_port_span => 3, + extra_conf => [@base_conf]); +$mix->node0->append_conf('postgresql.conf', "cluster.lms_workers = 2\n"); +$mix->node1->append_conf('postgresql.conf', "cluster.lms_workers = 3\n"); +$mix->start_pair; +usleep(2_000_000); +# CONTROL plane is independent of the DATA worker gate, so the cluster still +# forms; only the DATA mesh is refused. +ok($mix->wait_for_peer_state(0, 1, 'connected', 30), 'L4 CONTROL still forms under mismatch'); + +# The passive side rejects the peer's HELLO on the n_workers skew (2 vs 3); +# poll the merged log for the fail-closed reject (appears once HELLO is sent). +ok(wait_for_log([ $mix->node0, $mix->node1 ], qr/HELLO DATA worker mismatch/, 30), + 'L4 n_workers mismatch refused fail-closed (HELLO DATA worker mismatch)'); + +$mix->stop_pair; + +# ============================================================ +# L9 — spec-7.3 D9: N=1 whole-stack identity sentinel. Both nodes run +# cluster.lms_workers=1 with the PRE-7.3 single data port (span 1): the +# spec-7.2 topology identity is "same topology", not just "same behavior" +# -- no worker sibling forked, ONE DATA channel (worker 0) and no worker-1 +# mesh, live block traffic rides worker 0 alone (aggregate == _w0, no _w1 +# rows), zero misroutes. This is the regression sentinel of spec-7.3 §3 +# contract 5 under REAL cross-node traffic. +# ============================================================ +my $one = PostgreSQL::Test::ClusterPair->new_pair( + 'lmsone', + quorum_voting_disks => 3, + shared_data => 1, + storage_backend => 'block_device', + data_port_span => 1, + extra_conf => [ @base_conf, 'cluster.lms_workers = 1' ]); +$one->start_pair; +usleep(2_000_000); +ok($one->wait_for_peer_state(0, 1, 'connected', 30), 'L9 CONTROL peers up 0->1 (N=1)'); +ok($one->wait_for_peer_state(1, 0, 'connected', 30), 'L9 CONTROL peers up 1->0 (N=1)'); +my ($one0, $one1) = ($one->node0, $one->node1); + +# Topology identity: the worker-0 channel forms; worker 1 never exists. +ok(wait_for_log([ $one0, $one1 ], qr/HELLO verified.*\(DATA worker 0\)/, 30), + 'L9 DATA worker 0 channel mesh verified (N=1)'); +my $one_merged = merged_log($one0, $one1); +unlike($one_merged, qr/DATA worker 1/, 'L9 no worker-1 channel anywhere (N=1)'); +for my $n ($one0, $one1) +{ + is( $n->safe_psql('postgres', + q{SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'lms worker'}), + '0', + 'L9 ' . $n->name . ' no lms worker forked (7.2 identity)'); + is( $n->safe_psql('postgres', q{ + SELECT count(*) FROM pg_cluster_state + WHERE category='lms' AND key = 'lms_data_dispatch_count_w1'}), + '0', 'L9 ' . $n->name . ' no _w1 observability row (live pool = 1)'); +} + +# Live traffic sentinel: block ping-pong rides worker 0 alone. +ok(poll_write_ok($one0, 'CREATE TABLE og0 (x int)', 90, 'L9 node0 gate'), + 'L9 node0 write gate open'); +ok(poll_write_ok($one1, 'CREATE TABLE og1 (x int)', 90, 'L9 node1 gate'), + 'L9 node1 write gate open'); +my ($o0, $o1) = ('', ''); +for my $attempt (1 .. 8) +{ + $one0->safe_psql('postgres', 'CREATE TABLE one_t (aid int, bal int)'); + $one1->safe_psql('postgres', 'CREATE TABLE one_t (aid int, bal int)'); + $o0 = $one0->safe_psql('postgres', q{SELECT pg_relation_filepath('one_t')}); + $o1 = $one1->safe_psql('postgres', q{SELECT pg_relation_filepath('one_t')}); + last if $o0 eq $o1; + my ($m0) = $o0 =~ /(\d+)$/; + my ($m1) = $o1 =~ /(\d+)$/; + my ($lag, $burn) = $m0 < $m1 ? ($one0, $m1 - $m0) : ($one1, $m0 - $m1); + $burn = 1 if $burn <= 0; + $lag->safe_psql('postgres', + "SELECT lo_unlink(lo_create(0)) FROM generate_series(1, $burn)"); + $one0->safe_psql('postgres', 'DROP TABLE one_t'); + $one1->safe_psql('postgres', 'DROP TABLE one_t'); +} +is($o0, $o1, 'L9 shared-table coincidence (one_t)'); +$one0->safe_psql('postgres', + 'INSERT INTO one_t SELECT g, 0 FROM generate_series(1, 200) g'); +$one0->safe_psql('postgres', 'CHECKPOINT'); +$one1->safe_psql('postgres', 'CHECKPOINT'); + +my %l9_base; +for my $n ($one0, $one1) +{ + $l9_base{ $n->name } = 0 + $n->safe_psql('postgres', q{ + SELECT value FROM pg_cluster_state + WHERE category='lms' AND key='lms_data_dispatch_count_w0'}); +} +my $one_swaps = 0; +for my $r (1 .. 3) +{ + last unless timed_update_retry($one0, 'UPDATE one_t SET bal = bal + 1', 20); + usleep(100_000); + last unless timed_update_retry($one1, 'UPDATE one_t SET bal = bal + 1', 20); + usleep(100_000); + $one_swaps++; +} +ok($one_swaps > 0, "L9 completed $one_swaps X ping-pong swaps (N=1)"); +for my $n ($one0, $one1) +{ + my $w0 = 0 + $n->safe_psql('postgres', q{ + SELECT value FROM pg_cluster_state + WHERE category='lms' AND key='lms_data_dispatch_count_w0'}); + my $agg = 0 + $n->safe_psql('postgres', q{ + SELECT value FROM pg_cluster_state + WHERE category='lms' AND key='lms_data_dispatch_count'}); + + cmp_ok($w0 - $l9_base{ $n->name }, + '>', 0, 'L9 ' . $n->name . ' worker 0 carried the traffic (N=1)'); + is($agg, $w0, 'L9 ' . $n->name . ' aggregate == _w0 (single stream identity)'); + is( $n->safe_psql('postgres', + q{SELECT value FROM pg_cluster_state + WHERE category='gcs' AND key='plane_misroute_reject'}), + '0', + 'L9 ' . $n->name . ' zero plane misroutes (N=1)'); +} + +$one->stop_pair; + +done_testing(); diff --git a/src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl b/src/test/cluster_tap/t/368_cluster_multi_member_serve_refuse.pl similarity index 98% rename from src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl rename to src/test/cluster_tap/t/368_cluster_multi_member_serve_refuse.pl index 0fd766c789..64dabe4c93 100644 --- a/src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl +++ b/src/test/cluster_tap/t/368_cluster_multi_member_serve_refuse.pl @@ -1,6 +1,6 @@ #------------------------------------------------------------------------- # -# 360_cluster_multi_member_serve_refuse.pl +# 368_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 @@ -23,7 +23,7 @@ # Portions Copyright (c) 2026, pgrac contributors # # IDENTIFICATION -# src/test/cluster_tap/t/360_cluster_multi_member_serve_refuse.pl +# src/test/cluster_tap/t/368_cluster_multi_member_serve_refuse.pl #------------------------------------------------------------------------- use strict; diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index 14c56b8bbf..094792a98f 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -89,7 +89,10 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_membership \ test_cluster_node_remove \ test_cluster_hang_acceptance \ - test_cluster_xid_stripe + test_cluster_xid_stripe \ + test_cluster_lms_shard \ + test_cluster_gcs_block_dedup \ + test_cluster_gcs_block_shard # Path to the cluster_version object (no PG deps, safe to link standalone). CLUSTER_VERSION_O = $(top_builddir)/src/backend/cluster/cluster_version.o @@ -165,6 +168,41 @@ test_cluster_membership: test_cluster_membership.c unit_test.h $(CLUSTER_VERSION $(CLUSTER_VERSION_O) $(CLUSTER_MEMBERSHIP_O) \ $(top_builddir)/src/port/libpgport_srv.a -o $@ +# spec-7.3 D1: test_cluster_lms_shard links cluster_lms_shard.o (pure layer: +# BufferTag -> worker shard map). Links libpgcommon_srv.a for +# hash_bytes_extended, mirroring test_cluster_grd. +CLUSTER_LMS_SHARD_O = $(top_builddir)/src/backend/cluster/cluster_lms_shard.o +test_cluster_lms_shard: test_cluster_lms_shard.c unit_test.h $(CLUSTER_VERSION_O) \ + $(CLUSTER_LMS_SHARD_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_VERSION_O) $(CLUSTER_LMS_SHARD_O) \ + $(top_builddir)/src/common/libpgcommon_srv.a \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ + +# spec-7.3 D5: test_cluster_gcs_block_dedup links cluster_gcs_block_dedup.o +# (real per-worker sharded lookup/install/GC/counter paths) + cluster_lms_shard.o, +# with the fake-shmem stub family in the test file. libpgcommon_srv.a is pulled +# for hash_bytes_extended (cluster_lms_shard), mirroring test_cluster_lms_shard. +CLUSTER_GCS_BLOCK_DEDUP_O = $(top_builddir)/src/backend/cluster/cluster_gcs_block_dedup.o +test_cluster_gcs_block_dedup: test_cluster_gcs_block_dedup.c unit_test.h $(CLUSTER_VERSION_O) \ + $(CLUSTER_GCS_BLOCK_DEDUP_O) $(CLUSTER_LMS_SHARD_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_VERSION_O) $(CLUSTER_GCS_BLOCK_DEDUP_O) $(CLUSTER_LMS_SHARD_O) \ + $(top_builddir)/src/common/libpgcommon_srv.a \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ + +# spec-7.3 D9: test_cluster_gcs_block_shard links cluster_gcs_block_shard.o +# (the REAL staging-path payload -> worker router, extracted as a pure file) +# + cluster_lms_shard.o so the route == shard_for_tag agreement is asserted +# against both real objects. libpgcommon_srv.a for hash_bytes_extended. +CLUSTER_GCS_BLOCK_SHARD_O = $(top_builddir)/src/backend/cluster/cluster_gcs_block_shard.o +test_cluster_gcs_block_shard: test_cluster_gcs_block_shard.c unit_test.h $(CLUSTER_VERSION_O) \ + $(CLUSTER_GCS_BLOCK_SHARD_O) $(CLUSTER_LMS_SHARD_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_VERSION_O) $(CLUSTER_GCS_BLOCK_SHARD_O) $(CLUSTER_LMS_SHARD_O) \ + $(top_builddir)/src/common/libpgcommon_srv.a \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ + # spec-6.5 D0/D1: test_cluster_backup links only the dependency-light # manifest/PITR helper object. Runtime SQL/shmem code stays in # cluster_backup.o and is intentionally not pulled into this pure test. @@ -181,7 +219,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_block_dedup_reclaim test_cluster_gcs_block_dedup_htab 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)) +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_block_dedup_reclaim test_cluster_gcs_block_dedup_htab 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 test_cluster_lms_shard test_cluster_gcs_block_dedup test_cluster_gcs_block_shard,$(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_backend_types.c b/src/test/cluster_unit/test_cluster_backend_types.c index 6ca52a5bf4..5472e3d6da 100644 --- a/src/test/cluster_unit/test_cluster_backend_types.c +++ b/src/test/cluster_unit/test_cluster_backend_types.c @@ -178,17 +178,50 @@ UT_TEST(test_mrp_aux_proc_slot_is_reserved) #endif } +/* + * spec-7.3 D2 — the 7 LMS worker AuxProcTypes (LmsWorker1..7Process) are a + * contiguous block appended after RfsProcess (so no existing aux type is + * renumbered), their 1-based worker id derives from the offset, and the + * NUM_AUXILIARY_PROCS bump still covers every aux type (backend-status / + * aux-PGPROC slot per type). + */ +UT_TEST(test_lms_worker_aux_slots_reserved) +{ +#ifdef USE_PGRAC_CLUSTER + /* Contiguous block, appended after RfsProcess, before NUM_AUXPROCTYPES. */ + UT_ASSERT(LmsWorker1Process > RfsProcess); + UT_ASSERT_EQ(LmsWorker2Process, LmsWorker1Process + 1); + UT_ASSERT_EQ(LmsWorker3Process, LmsWorker1Process + 2); + UT_ASSERT_EQ(LmsWorker4Process, LmsWorker1Process + 3); + UT_ASSERT_EQ(LmsWorker5Process, LmsWorker1Process + 4); + UT_ASSERT_EQ(LmsWorker6Process, LmsWorker1Process + 5); + UT_ASSERT_EQ(LmsWorker7Process, LmsWorker1Process + 6); + UT_ASSERT(LmsWorker7Process < NUM_AUXPROCTYPES); + + /* worker_id derivation maps the 7 types to 1..7. */ + UT_ASSERT_EQ(ClusterLmsWorkerIdForType(LmsWorker1Process), 1); + UT_ASSERT_EQ(ClusterLmsWorkerIdForType(LmsWorker4Process), 4); + UT_ASSERT_EQ(ClusterLmsWorkerIdForType(LmsWorker7Process), 7); + + /* The +7 aux types must still be covered by the aux slot count. */ + UT_ASSERT(NUM_AUXILIARY_PROCS >= NUM_AUXPROCTYPES); +#else + UT_ASSERT(NUM_AUXILIARY_PROCS >= NUM_AUXPROCTYPES); +#endif +} + int main(void) { - UT_PLAN(6); + UT_PLAN(7); UT_RUN(test_backend_num_types_is_32); UT_RUN(test_pgrac_values_appended_after_wal_writer); UT_RUN(test_pg_native_values_unchanged); UT_RUN(test_pgrac_values_are_dense_and_distinct); UT_RUN(test_rfs_is_last); UT_RUN(test_mrp_aux_proc_slot_is_reserved); + UT_RUN(test_lms_worker_aux_slots_reserved); 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 e3d68981ec..d41a5a04c3 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1132,6 +1132,11 @@ cluster_gcs_get_block_dedup_max_entries(void) return 0; } uint64 +cluster_gcs_block_dedup_get_misroute_failclosed_count(void) +{ + return 0; +} +uint64 cluster_gcs_get_block_epoch_invalidate_wake_count(void) { return 0; @@ -1907,6 +1912,11 @@ cluster_cr_server_multi_verdict_denied_count(void) return 0; } uint64 +cluster_cr_server_fence_refused_count(void) +{ + return 0; +} +uint64 cluster_rtvis_underivable_failclosed_count(void) { return 0; @@ -3825,12 +3835,45 @@ cluster_lms_get_priority_starvation_observed_count(void) { return 0; } -/* spec-7.2 D6 stub — DATA-plane connection resets observability counter. */ +/* spec-7.3 D8 — per-worker observability stubs (+ the pool-size GUC the + * dump uses to bound the per-worker rows). */ +int cluster_lms_workers = 2; +uint64 +cluster_lms_obs_get_dispatch_count(int worker_id pg_attribute_unused()) +{ + return 0; +} +uint64 +cluster_lms_obs_get_direct_reply_count(int worker_id pg_attribute_unused()) +{ + return 0; +} +uint64 +cluster_lms_obs_get_conn_reset_count(int worker_id pg_attribute_unused()) +{ + return 0; +} +uint64 +cluster_lms_obs_get_inline_serve_count(int worker_id pg_attribute_unused()) +{ + return 0; +} uint64 -cluster_lms_get_conn_resets(void) +cluster_lms_obs_get_serve_hist(int worker_id pg_attribute_unused(), + int bucket pg_attribute_unused()) { return 0; } +uint64 +cluster_lms_obs_serve_hist_bound_us(int bucket) +{ + static const uint64 bounds[15] = { 50, 100, 200, 500, 1000, 2000, 5000, 10000, + 20000, 50000, 100000, 200000, 500000, 1000000, 5000000 }; + + if (bucket < 0 || bucket >= 15) + return UINT64_MAX; + return bounds[bucket]; +} const char * cluster_lms_state_to_string(int s pg_attribute_unused()) { diff --git a/src/test/cluster_unit/test_cluster_gcs_block_dedup.c b/src/test/cluster_unit/test_cluster_gcs_block_dedup.c new file mode 100644 index 0000000000..3d1348a0e4 --- /dev/null +++ b/src/test/cluster_unit/test_cluster_gcs_block_dedup.c @@ -0,0 +1,617 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_gcs_block_dedup.c + * Behavioral invariants for the spec-7.3 D5 per-worker sharded GCS + * block dedup HTAB. spec-2.34 shipped a single global dedup table + + * lock; spec-7.3 D5 shards it into per-worker private instances + * (dedup_shards[worker_id]) so the LMS worker pool (worker[shard(tag)]) + * never contends on one lock and never shares dedup state across + * workers. + * + * This binary links cluster_gcs_block_dedup.o + cluster_lms_shard.o and + * provides minimal fake-shmem stubs (an N-shard fake HTAB, one array + * per worker) so the real sharded lookup/install/remove/GC/counter + * paths run standalone. It is the direct proof of "dedup 跨 worker 零 + * 共享" (spec-7.3 §7 DoD): registering on shard i must be invisible to + * shard j. + * + * The cross-node behavioral ground truth (2-node retransmit + dedup + * CACHED_REPLY replay at the default cluster.lms_workers=2) lives in + * cluster_tap t/112_gcs_block_retransmit_2node.pl; the multi-tag -> + * multi-worker dispatch e2e + injection-forced misroute land in D9. + * + * Tests (U1-U9): + * U1 per-worker isolation: install on shard 0 is invisible to shard 1 + * U2 dedup per-shard: MISS -> IN_FLIGHT_DUPLICATE -> CACHED_REPLY + * U3 counter accessors sum across shards + * U4 cross-shard GC: cleanup_on_node_dead reaches every shard + * U5 bounds fail-closed: worker_id out of range -> FULL + misroute++ + * U6 N=1 degenerate: only shard 0 valid; worker 1 out of range + * U7 per-shard cap: fill shard 0 to cap -> FULL + full_count++ + * U8 cross-shard TTL sweep reaches every shard + * U9 backend-exit cleanup reaches every shard + * + * + * 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_gcs_block_dedup.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "cluster/cluster_gcs_block.h" +#include "cluster/cluster_gcs_block_dedup.h" +#include "cluster/cluster_lms_shard.h" +#include "cluster/cluster_shmem.h" +#include "storage/buf_internals.h" +#include "storage/ipc.h" +#include "storage/lwlock.h" +#include "utils/hsearch.h" +#include "utils/timestamp.h" + +#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(); + + +void +ExceptionalCondition(const char *conditionName pg_attribute_unused(), + const char *fileName pg_attribute_unused(), + int lineNumber pg_attribute_unused()) +{ + abort(); +} + + +/* ============================================================ + * GUC / global stubs the module reads. + * ============================================================ */ + +bool cluster_enabled = true; +int cluster_node_id = 0; +int cluster_lms_workers = 2; +int MaxConnections = 1; /* spec-7.2a D4 floor input (x declared nodes = 1) */ +int cluster_gcs_block_dedup_max_entries = 8; +int cluster_gcs_block_retransmit_initial_backoff_ms = 100; +int cluster_gcs_block_retransmit_max_retries = 4; +int MyBackendId = 1; +bool IsUnderPostmaster = true; + + +/* ============================================================ + * N-shard fake HTAB. ShmemInitHash hands out one fake shard array per + * call, in init order (shard 0, 1, ...); hash_search / hash_seq operate + * on the shard identified by the returned handle. + * ============================================================ */ + +#define FAKE_DEDUP_CAP 8 + +typedef struct FakeDedupShardHtab { + char entries[FAKE_DEDUP_CAP][sizeof(GcsBlockDedupEntry)]; + long count; +} FakeDedupShardHtab; + +static FakeDedupShardHtab fake_htab[CLUSTER_LMS_MAX_WORKERS]; +static int fake_htab_init_seq; +static Size fake_keysize; +static Size fake_entrysize; + +/* ShmemInitStruct blob for the dedup ctl header + per-shard structs. */ +static union { + uint64 force_align; + char data[16384]; +} fake_dedup_struct; +static bool fake_dedup_struct_found; + +static int fake_before_shmem_exit_registered; + + +static void +reset_fake_dedup(int n_workers, int max_entries) +{ + memset(fake_htab, 0, sizeof(fake_htab)); + fake_htab_init_seq = 0; + fake_keysize = 0; + fake_entrysize = 0; + memset(&fake_dedup_struct, 0, sizeof(fake_dedup_struct)); + fake_dedup_struct_found = false; + fake_before_shmem_exit_registered = 0; + + cluster_enabled = true; + cluster_node_id = 0; + cluster_lms_workers = n_workers; + cluster_gcs_block_dedup_max_entries = max_entries; + + /* size() then init() — same order the shmem bootstrap uses. */ + (void)cluster_gcs_block_dedup_shmem_size(); + cluster_gcs_block_dedup_shmem_init(); +} + + +/* ============================================================ + * PG-runtime stubs. + * ============================================================ */ + +void * +ShmemInitStruct(const char *name pg_attribute_unused(), Size size, bool *foundPtr) +{ + Assert(size <= sizeof(fake_dedup_struct.data)); + *foundPtr = fake_dedup_struct_found; + fake_dedup_struct_found = true; + return fake_dedup_struct.data; +} + +HTAB * +ShmemInitHash(const char *name pg_attribute_unused(), long init_size pg_attribute_unused(), + long max_size pg_attribute_unused(), HASHCTL *infoP, int hash_flags) +{ + FakeDedupShardHtab *h; + + Assert((hash_flags & HASH_ELEM) != 0); + Assert(infoP->entrysize == sizeof(GcsBlockDedupEntry)); + Assert(fake_htab_init_seq < CLUSTER_LMS_MAX_WORKERS); + fake_keysize = infoP->keysize; + fake_entrysize = infoP->entrysize; + h = &fake_htab[fake_htab_init_seq++]; + h->count = 0; + return (HTAB *)h; +} + +void * +hash_search(HTAB *hashp, const void *keyPtr, HASHACTION action, bool *foundPtr) +{ + FakeDedupShardHtab *h = (FakeDedupShardHtab *)hashp; + long i; + + Assert(h != NULL); + Assert(fake_keysize > 0); + + for (i = 0; i < h->count; i++) { + char *entry = h->entries[i]; + + if (memcmp(entry, keyPtr, fake_keysize) == 0) { + if (foundPtr != NULL) + *foundPtr = true; + if (action == HASH_REMOVE) { + if (i + 1 < h->count) + memmove(h->entries[i], h->entries[i + 1], + (size_t)(h->count - i - 1) * sizeof(GcsBlockDedupEntry)); + h->count--; + return entry; + } + return entry; + } + } + + if (foundPtr != NULL) + *foundPtr = false; + if (action == HASH_FIND || action == HASH_REMOVE) + return NULL; + if (action == HASH_ENTER_NULL && h->count >= FAKE_DEDUP_CAP) + return NULL; + if (action == HASH_ENTER || action == HASH_ENTER_NULL) { + char *entry = h->entries[h->count++]; + + memset(entry, 0, sizeof(GcsBlockDedupEntry)); + memcpy(entry, keyPtr, fake_keysize); + return entry; + } + return NULL; +} + +void +hash_seq_init(HASH_SEQ_STATUS *status, HTAB *hashp) +{ + status->hashp = hashp; + status->curBucket = 0; + status->curEntry = NULL; +} + +void * +hash_seq_search(HASH_SEQ_STATUS *status) +{ + FakeDedupShardHtab *h = (FakeDedupShardHtab *)status->hashp; + + if (status->curBucket >= (uint32)h->count) + return NULL; + return h->entries[status->curBucket++]; +} + +void +hash_seq_term(HASH_SEQ_STATUS *status pg_attribute_unused()) +{} + +Size +hash_estimate_size(long num_entries, Size entrysize) +{ + return (Size)num_entries * entrysize; +} + +void +LWLockInitialize(LWLock *lock pg_attribute_unused(), int tranche_id pg_attribute_unused()) +{} + +bool +LWLockAcquire(LWLock *lock pg_attribute_unused(), LWLockMode mode pg_attribute_unused()) +{ + return true; +} + +void +LWLockRelease(LWLock *lock pg_attribute_unused()) +{} + +TimestampTz +GetCurrentTimestamp(void) +{ + return (TimestampTz)1000; +} + +/* elog(LOG) plumbing for the spec-7.2a D5 saturation LOG-once in the TTL + * sweep (never fires in these tests: full_count stays below threshold). */ +bool +errstart(int elevel pg_attribute_unused(), const char *domain pg_attribute_unused()) +{ + return false; /* suppress: no message assembly in unit context */ +} + +bool +errstart_cold(int elevel pg_attribute_unused(), const char *domain pg_attribute_unused()) +{ + return false; +} + +void +errfinish(const char *filename pg_attribute_unused(), int lineno pg_attribute_unused(), + const char *funcname pg_attribute_unused()) +{} + +int +errmsg_internal(const char *fmt pg_attribute_unused(), ...) +{ + return 0; +} + +/* spec-7.2a D4: pre-shmem conf sniff — a single declared node keeps the + * auto-size floor at MaxConnections (=1), so the tests' tiny configured + * capacities stay in force. */ +int +cluster_conf_declared_node_count_early(void) +{ + return 1; +} + +Size +add_size(Size s1, Size s2) +{ + return s1 + s2; +} + +Size +mul_size(Size s1, Size s2) +{ + return s1 * s2; +} + +void +cluster_shmem_register_region(const ClusterShmemRegion *region pg_attribute_unused()) +{} + +void +before_shmem_exit(pg_on_exit_callback function pg_attribute_unused(), + Datum arg pg_attribute_unused()) +{ + fake_before_shmem_exit_registered++; +} + + +/* ============================================================ + * Test helpers. + * ============================================================ */ + +static GcsBlockDedupKey +make_key(uint32 origin, int32 backend, uint64 reqid, uint64 epoch) +{ + GcsBlockDedupKey k; + + memset(&k, 0, sizeof(k)); + k.origin_node_id = origin; + k.requester_backend_id = backend; + k.request_id = reqid; + k.cluster_epoch = epoch; + return k; +} + +static BufferTag +make_tag(uint32 blockno) +{ + BufferTag tag; + + memset(&tag, 0, sizeof(tag)); + tag.spcOid = 1663; + tag.dbOid = 1; + tag.relNumber = 100; + tag.forkNum = MAIN_FORKNUM; + tag.blockNum = blockno; + return tag; +} + +static void +install_granted(int worker_id, const GcsBlockDedupKey *key) +{ + GcsBlockReplyHeader hdr; + static char block[GCS_BLOCK_DATA_SIZE]; + + memset(&hdr, 0, sizeof(hdr)); + memset(block, 0x5a, sizeof(block)); + cluster_gcs_block_dedup_install_reply(worker_id, key, GCS_BLOCK_REPLY_GRANTED, &hdr, block); +} + + +/* ============================================================ + * U1 — per-worker isolation: an install on shard 0 is invisible to + * shard 1. Same key registered on both shards stays independent; + * completing shard 0's entry does NOT complete shard 1's. + * ============================================================ */ +UT_TEST(u1_per_worker_isolation) +{ + GcsBlockDedupKey key = make_key(0, 1, 42, 7); + BufferTag tag = make_tag(10); + GcsBlockDedupEntry cached; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + /* Register the same key on shard 0 and shard 1 — separate tables, so + * both see a fresh MISS. */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(1, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + + /* Complete shard 0's entry only. */ + install_granted(0, &key); + + /* Shard 0 now serves a cached reply; shard 1 is untouched (still + * in-flight) — proves zero cross-worker sharing. */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_CACHED_REPLY); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(1, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_IN_FLIGHT_DUPLICATE); +} + + +/* ============================================================ + * U2 — dedup lifecycle within one shard. + * ============================================================ */ +UT_TEST(u2_dedup_lifecycle_per_shard) +{ + GcsBlockDedupKey key = make_key(0, 1, 43, 7); + BufferTag tag = make_tag(11); + GcsBlockDedupEntry cached; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + /* retransmit before reply installed */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_IN_FLIGHT_DUPLICATE); + install_granted(0, &key); + /* retransmit after reply installed → cached replay */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_CACHED_REPLY); +} + + +/* ============================================================ + * U3 — counter accessors sum across shards. + * ============================================================ */ +UT_TEST(u3_counters_sum_across_shards) +{ + GcsBlockDedupKey ka = make_key(0, 1, 50, 7); + GcsBlockDedupKey kb = make_key(0, 2, 51, 7); + BufferTag ta = make_tag(20); + BufferTag tb = make_tag(21); + GcsBlockDedupEntry cached; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &ka, ta, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(1, &kb, tb, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + + /* miss on shard 0 + miss on shard 1 = 2 (aggregate view). */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_miss_count(), 2); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 2); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_full_count(), 0); +} + + +/* ============================================================ + * U4 — cross-shard GC: node-dead cleanup reaches every shard. + * ============================================================ */ +UT_TEST(u4_cleanup_on_node_dead_all_shards) +{ + GcsBlockDedupKey ka = make_key(3, 1, 60, 7); /* origin node 3 */ + GcsBlockDedupKey kb = make_key(3, 2, 61, 7); /* origin node 3 */ + BufferTag ta = make_tag(30); + BufferTag tb = make_tag(31); + GcsBlockDedupEntry cached; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + (void)cluster_gcs_block_dedup_lookup_or_register(0, &ka, ta, 1, &cached); + (void)cluster_gcs_block_dedup_lookup_or_register(1, &kb, tb, 1, &cached); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 2); + + cluster_gcs_block_dedup_cleanup_on_node_dead(3); + + /* both shards drained */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 0); +} + + +/* ============================================================ + * U5 — bounds fail-closed: worker_id out of range → FULL + misroute++. + * ============================================================ */ +UT_TEST(u5_out_of_range_worker_fail_closed) +{ + GcsBlockDedupKey key = make_key(0, 1, 70, 7); + BufferTag tag = make_tag(40); + GcsBlockDedupEntry cached; + uint64 before; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + before = cluster_gcs_block_dedup_get_misroute_failclosed_count(); + + /* worker_id >= live shard count → fail-closed, no crash, no store. */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(99, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_FULL); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(-1, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_FULL); + + UT_ASSERT_EQ((int)(cluster_gcs_block_dedup_get_misroute_failclosed_count() - before), 2); + /* nothing was stored */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 0); +} + + +/* ============================================================ + * U6 — N=1 degenerate: only shard 0 valid; worker 1 out of range. + * ============================================================ */ +UT_TEST(u6_n1_only_shard0) +{ + GcsBlockDedupKey key = make_key(0, 1, 80, 7); + BufferTag tag = make_tag(50); + GcsBlockDedupEntry cached; + uint64 before; + + reset_fake_dedup(1, FAKE_DEDUP_CAP); + before = cluster_gcs_block_dedup_get_misroute_failclosed_count(); + + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + /* worker 1 does not exist when lms_workers=1 → fail-closed. */ + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(1, &key, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_FULL); + UT_ASSERT_EQ((int)(cluster_gcs_block_dedup_get_misroute_failclosed_count() - before), 1); +} + + +/* ============================================================ + * U7 — per-shard cap: fill shard 0 to cap → FULL + full_count++. + * ============================================================ */ +UT_TEST(u7_per_shard_cap_full) +{ + BufferTag tag = make_tag(60); + GcsBlockDedupEntry cached; + int i; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + for (i = 0; i < FAKE_DEDUP_CAP; i++) { + GcsBlockDedupKey k = make_key(0, 1, (uint64)(100 + i), 7); + + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &k, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + } + { + GcsBlockDedupKey overflow = make_key(0, 1, 999, 7); + + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(0, &overflow, tag, 1, &cached), + (int)GCS_BLOCK_DEDUP_FULL); + } + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_full_count(), 1); + /* shard 1 is unaffected by shard 0 being full. */ + { + GcsBlockDedupKey k = make_key(0, 2, 500, 7); + BufferTag t1 = make_tag(61); + + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_lookup_or_register(1, &k, t1, 1, &cached), + (int)GCS_BLOCK_DEDUP_MISS_REGISTERED); + } +} + + +/* ============================================================ + * U8 — cross-shard TTL sweep reaches every shard. + * ============================================================ */ +UT_TEST(u8_ttl_sweep_all_shards) +{ + GcsBlockDedupKey ka = make_key(0, 1, 200, 7); + GcsBlockDedupKey kb = make_key(0, 2, 201, 7); + BufferTag ta = make_tag(70); + BufferTag tb = make_tag(71); + GcsBlockDedupEntry cached; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + (void)cluster_gcs_block_dedup_lookup_or_register(0, &ka, ta, 1, &cached); + (void)cluster_gcs_block_dedup_lookup_or_register(1, &kb, tb, 1, &cached); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 2); + + /* now far past the expiry threshold → both shards swept. */ + cluster_gcs_block_dedup_sweep_expired((TimestampTz)100000000000LL); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 0); +} + + +/* ============================================================ + * U9 — backend-exit cleanup reaches every shard. + * ============================================================ */ +UT_TEST(u9_backend_exit_cleanup_all_shards) +{ + GcsBlockDedupKey ka = make_key(0, 9, 300, 7); /* local origin, backend 9 */ + GcsBlockDedupKey kb = make_key(0, 9, 301, 7); /* local origin, backend 9 */ + BufferTag ta = make_tag(80); + BufferTag tb = make_tag(81); + GcsBlockDedupEntry cached; + + reset_fake_dedup(2, FAKE_DEDUP_CAP); + + (void)cluster_gcs_block_dedup_lookup_or_register(0, &ka, ta, 1, &cached); + (void)cluster_gcs_block_dedup_lookup_or_register(1, &kb, tb, 1, &cached); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 2); + + cluster_gcs_block_dedup_cleanup_on_backend_exit(0, 9); + UT_ASSERT_EQ((int)cluster_gcs_block_dedup_get_in_flight_count(), 0); +} + + +int +main(void) +{ + UT_PLAN(9); + UT_RUN(u1_per_worker_isolation); + UT_RUN(u2_dedup_lifecycle_per_shard); + UT_RUN(u3_counters_sum_across_shards); + UT_RUN(u4_cleanup_on_node_dead_all_shards); + UT_RUN(u5_out_of_range_worker_fail_closed); + UT_RUN(u6_n1_only_shard0); + UT_RUN(u7_per_shard_cap_full); + UT_RUN(u8_ttl_sweep_all_shards); + UT_RUN(u9_backend_exit_cleanup_all_shards); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +} diff --git a/src/test/cluster_unit/test_cluster_gcs_block_dedup_htab.c b/src/test/cluster_unit/test_cluster_gcs_block_dedup_htab.c index 038d3e56e4..c51745b724 100644 --- a/src/test/cluster_unit/test_cluster_gcs_block_dedup_htab.c +++ b/src/test/cluster_unit/test_cluster_gcs_block_dedup_htab.c @@ -179,6 +179,16 @@ add_size(Size s1, Size s2) return s1 + s2; } +Size +mul_size(Size s1, Size s2) +{ + return s1 * s2; +} + +/* spec-7.3 D5: the sharded dedup sizes/initialises per LMS worker; a single + * shard keeps these tests' capacity math identical to the pre-shard shape. */ +int cluster_lms_workers = 1; + /* Linear estimate is enough for the D4c size-equivalence assertion. */ Size hash_estimate_size(long num_entries, Size entrysize) @@ -396,7 +406,7 @@ register_key(uint64 request_id) GcsBlockDedupKey key = make_key(request_id); BufferTag tag = make_tag((uint32)request_id); - return cluster_gcs_block_dedup_lookup_or_register(&key, tag, 1, NULL); + return cluster_gcs_block_dedup_lookup_or_register(0, &key, tag, 1, NULL); } static void @@ -406,7 +416,7 @@ complete_key(uint64 request_id, GcsBlockReplyStatus status) GcsBlockReplyHeader header; memset(&header, 0, sizeof(header)); - cluster_gcs_block_dedup_install_reply(&key, status, &header, NULL); + cluster_gcs_block_dedup_install_reply(0, &key, status, &header, NULL); } /* ============================================================ @@ -486,7 +496,7 @@ UT_TEST(test_u5_reclaimed_key_re_registers_without_double_count) * again, so its registration eager-reclaims exactly one more aged * completed entry. */ UT_ASSERT_EQ(GCS_BLOCK_DEDUP_MISS_REGISTERED, - cluster_gcs_block_dedup_lookup_or_register(&key0, tag0, 1, NULL)); + cluster_gcs_block_dedup_lookup_or_register(0, &key0, tag0, 1, NULL)); UT_ASSERT_EQ(2, (int)cluster_gcs_block_dedup_get_evict_count()); UT_ASSERT_EQ(2, (int)cluster_gcs_block_dedup_get_full_count()); UT_ASSERT_EQ(4, (int)cluster_gcs_block_dedup_get_in_flight_count()); @@ -497,7 +507,7 @@ UT_TEST(test_u5_reclaimed_key_re_registers_without_double_count) * same-key retransmit hits the cached reply. */ complete_key(0, GCS_BLOCK_REPLY_GRANTED); UT_ASSERT_EQ(GCS_BLOCK_DEDUP_CACHED_REPLY, - cluster_gcs_block_dedup_lookup_or_register(&key0, tag0, 1, &cached)); + cluster_gcs_block_dedup_lookup_or_register(0, &key0, tag0, 1, &cached)); UT_ASSERT_EQ(1, (int)cluster_gcs_block_dedup_get_hit_count()); UT_ASSERT_EQ((int)GCS_BLOCK_REPLY_GRANTED, (int)cached.status); } @@ -506,12 +516,12 @@ UT_TEST(test_u6_remove_keeps_accounting_conserved) { GcsBlockDedupKey key0 = make_key(0); - cluster_gcs_block_dedup_remove(&key0); + cluster_gcs_block_dedup_remove(0, &key0); UT_ASSERT_EQ(3, (int)cluster_gcs_block_dedup_get_in_flight_count()); UT_ASSERT_EQ(3, (int)fake_live_count()); /* Removing a key that is no longer resident must not underflow. */ - cluster_gcs_block_dedup_remove(&key0); + cluster_gcs_block_dedup_remove(0, &key0); UT_ASSERT_EQ(3, (int)cluster_gcs_block_dedup_get_in_flight_count()); UT_ASSERT_EQ(3, (int)fake_live_count()); } diff --git a/src/test/cluster_unit/test_cluster_gcs_block_shard.c b/src/test/cluster_unit/test_cluster_gcs_block_shard.c new file mode 100644 index 0000000000..5c1650a11e --- /dev/null +++ b/src/test/cluster_unit/test_cluster_gcs_block_shard.c @@ -0,0 +1,363 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_gcs_block_shard.c + * Unit tests for the DATA-plane payload -> worker routing layer + * (spec-7.3 D4/D9: cluster_gcs_block_payload_shard). + * + * cluster_gcs_block_payload_shard() is the single staging-path + * decision that picks the outbound ring (= DATA worker) for a + * block-family frame. These tests pin the D9 "ring-group routing" + * truth table on the REAL function (not a reimplementation): + * + * - the three tag-carrying staging types (REQUEST / FORWARD / + * INVALIDATE) route to exactly cluster_lms_shard_for_tag(tag, N) + * -- same-tag family affinity is what keeps the INVALIDATE-ACK + * -> same-tag re-REQUEST wire FIFO intact after the N-way split + * (D0-① WATCH: the ACK direct-sends from worker[shard(tag)]'s + * dispatch process, the re-REQUEST stages back to shard(tag); + * equal shards == one worker stream == order preserved), + * - the DATA-plane registry partition is pinned: REPLY and + * INVALIDATE-ACK are the explicit direct-send whitelist (they + * carry no tag and never reach a ring; spec-7.3 §3.6), and any + * OTHER msg_type is refused (-1) -- the 8.A fail-closed contract + * that an undeclared DATA frame is never defaulted to a worker, + * - the payload-length ABI pin: a size mismatch can never read a + * tag from a stale offset (returns -1 instead), and + * - N == 1 degenerates to worker 0 (spec-7.2 topology identity). + * + * The live 2-node multi-tag distribution (per-worker counters move on + * distinct workers) is TAP t/367 L7; this file pins the pure routing + * math those counters depend on. + * + * 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_gcs_block_shard.c + * + * NOTES + * This is a pgrac-original file. + * Spec: spec-7.3-lms-worker-pool.md (D4/D9) + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "cluster/cluster_gcs_block.h" +#include "cluster/cluster_ic_envelope.h" +#include "cluster/cluster_lms_shard.h" +#include "storage/buf_internals.h" + +#undef printf +#undef fprintf +#undef snprintf + +#include "unit_test.h" + +UT_DEFINE_GLOBALS(); + +void +ExceptionalCondition(const char *conditionName, const char *fileName, int lineNumber) +{ + printf("# Assert failed: %s at %s:%d\n", conditionName, fileName, lineNumber); + abort(); +} + +/* Build a BufferTag from its five flat fields (spec-7.3 shard-key domain). */ +static BufferTag +make_tag(Oid spc, Oid db, RelFileNumber rel, ForkNumber fork, BlockNumber blk) +{ + BufferTag tag; + + tag.spcOid = spc; + tag.dbOid = db; + tag.relNumber = rel; + tag.forkNum = fork; + tag.blockNum = blk; + return tag; +} + +/* + * Helpers: minimal well-formed staging payloads. Only the tag feeds the + * shard; every other field is zero (the router must not read them). + */ +static GcsBlockRequestPayload +make_request(BufferTag tag) +{ + GcsBlockRequestPayload p; + + memset(&p, 0, sizeof(p)); + p.tag = tag; + return p; +} + +static GcsBlockForwardPayload +make_forward(BufferTag tag) +{ + GcsBlockForwardPayload p; + + memset(&p, 0, sizeof(p)); + p.tag = tag; + return p; +} + +static GcsBlockInvalidatePayload +make_invalidate(BufferTag tag) +{ + GcsBlockInvalidatePayload p; + + memset(&p, 0, sizeof(p)); + p.tag = tag; + return p; +} + +/* ====================================================================== + * U1 -- each staging type routes to exactly shard_for_tag(tag, N): the + * payload router adds no input of its own (double-end agreement, + * R1) and stays in range for every N. + * ====================================================================== */ +UT_TEST(test_route_matches_shard_for_tag) +{ + int n; + int i; + + for (i = 0; i < 128; i++) { + BufferTag tag = make_tag(1663, 5, 16384 + (i % 13), MAIN_FORKNUM, (BlockNumber)(i * 31)); + GcsBlockRequestPayload req = make_request(tag); + GcsBlockForwardPayload fwd = make_forward(tag); + GcsBlockInvalidatePayload inv = make_invalidate(tag); + + for (n = 1; n <= CLUSTER_LMS_MAX_WORKERS; n++) { + int expect = cluster_lms_shard_for_tag(&tag, n); + + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, &req, + sizeof(req), n), + expect); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_FORWARD, &fwd, + sizeof(fwd), n), + expect); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE, &inv, + sizeof(inv), n), + expect); + UT_ASSERT(expect >= 0); + UT_ASSERT(expect < n); + } + } +} + +/* ====================================================================== + * U2 -- same-tag family affinity (ACK -> same-tag re-REQUEST FIFO): an + * INVALIDATE for tag T and a later REQUEST for the same T must pick + * the SAME worker, so the direct-sent ACK (riding worker[shard]'s + * channel from its dispatch process) and the staged re-REQUEST + * share one wire stream and cannot reorder (D0-① WATCH). + * ====================================================================== */ +UT_TEST(test_route_ack_request_interleave_affinity) +{ + int i; + + for (i = 0; i < 64; i++) { + BufferTag tag = make_tag(1663 + (i % 3), 5, 20000 + i, (ForkNumber)(i % (MAX_FORKNUM + 1)), + (BlockNumber)i); + GcsBlockInvalidatePayload inv = make_invalidate(tag); + GcsBlockRequestPayload req = make_request(tag); + int s_inv = cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE, &inv, + sizeof(inv), CLUSTER_LMS_MAX_WORKERS); + int s_req = cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, &req, + sizeof(req), CLUSTER_LMS_MAX_WORKERS); + + UT_ASSERT(s_inv >= 0); + UT_ASSERT_EQ(s_req, s_inv); + } +} + +/* ====================================================================== + * U3 -- DATA-plane registry partition pin ("every DATA msg_type has a + * declared shard key or is explicitly direct-send"): of the five + * registered DATA types, exactly REQUEST / FORWARD / INVALIDATE + * are ring-routable; REPLY and INVALIDATE-ACK are the whitelist + * (no tag; direct-sent from the receiving worker's own dispatch, + * so they already ride the correct channel). A NEW DATA type that + * tries to stage without a key is refused at runtime by this same + * -1 (fail-closed, never defaulted -- 8.A); this test pins the + * declared partition so a silent re-plumb fails loudly. + * ====================================================================== */ +UT_TEST(test_route_registry_partition) +{ + BufferTag tag = make_tag(1663, 5, 16384, MAIN_FORKNUM, 7); + GcsBlockRequestPayload req = make_request(tag); + GcsBlockForwardPayload fwd = make_forward(tag); + GcsBlockInvalidatePayload inv = make_invalidate(tag); + uint8 raw[64]; + + memset(raw, 0, sizeof(raw)); + + /* Routable staging types. */ + UT_ASSERT(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, &req, sizeof(req), + CLUSTER_LMS_MAX_WORKERS) + >= 0); + UT_ASSERT(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_FORWARD, &fwd, sizeof(fwd), + CLUSTER_LMS_MAX_WORKERS) + >= 0); + UT_ASSERT(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE, &inv, sizeof(inv), + CLUSTER_LMS_MAX_WORKERS) + >= 0); + + /* Direct-send whitelist: no shard key by design (spec-7.3 §3.6). */ + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REPLY, raw, sizeof(raw), + CLUSTER_LMS_MAX_WORKERS), + -1); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE_ACK, raw, + sizeof(raw), CLUSTER_LMS_MAX_WORKERS), + -1); +} + +/* ====================================================================== + * U4 -- fail-closed refuse for anything outside the DATA block family: + * a CONTROL-plane block message (REDECLARE) and an arbitrary + * unknown msg_type must never be defaulted onto a ring, and a + * NULL payload is refused regardless of type. + * ====================================================================== */ +UT_TEST(test_route_unroutable_fail_closed) +{ + BufferTag tag = make_tag(1663, 5, 16384, MAIN_FORKNUM, 7); + GcsBlockRequestPayload req = make_request(tag); + uint8 raw[64]; + + memset(raw, 0, sizeof(raw)); + + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REDECLARE, raw, sizeof(raw), + CLUSTER_LMS_MAX_WORKERS), + -1); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(0xFF, raw, sizeof(raw), CLUSTER_LMS_MAX_WORKERS), + -1); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(0, raw, sizeof(raw), CLUSTER_LMS_MAX_WORKERS), -1); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, NULL, sizeof(req), + CLUSTER_LMS_MAX_WORKERS), + -1); +} + +/* ====================================================================== + * U5 -- payload-length ABI pin: a length that does not exactly match the + * declared wire struct is refused (-1) for every routable type -- + * a mismatched frame can never have its "tag" read from a stale + * offset and silently misroute (8.A). + * ====================================================================== */ +UT_TEST(test_route_length_mismatch_refused) +{ + BufferTag tag = make_tag(1663, 5, 16384, MAIN_FORKNUM, 7); + GcsBlockRequestPayload req = make_request(tag); + GcsBlockForwardPayload fwd = make_forward(tag); + GcsBlockInvalidatePayload inv = make_invalidate(tag); + struct { + uint8 msg_type; + const void *payload; + uint16 good_len; + } cases[3]; + int i; + + cases[0].msg_type = PGRAC_IC_MSG_GCS_BLOCK_REQUEST; + cases[0].payload = &req; + cases[0].good_len = sizeof(req); + cases[1].msg_type = PGRAC_IC_MSG_GCS_BLOCK_FORWARD; + cases[1].payload = &fwd; + cases[1].good_len = sizeof(fwd); + cases[2].msg_type = PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE; + cases[2].payload = &inv; + cases[2].good_len = sizeof(inv); + + for (i = 0; i < 3; i++) { + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(cases[i].msg_type, cases[i].payload, + cases[i].good_len - 1, + CLUSTER_LMS_MAX_WORKERS), + -1); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(cases[i].msg_type, cases[i].payload, + cases[i].good_len + 1, + CLUSTER_LMS_MAX_WORKERS), + -1); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(cases[i].msg_type, cases[i].payload, 0, + CLUSTER_LMS_MAX_WORKERS), + -1); + /* And the exact length still routes. */ + UT_ASSERT(cluster_gcs_block_payload_shard(cases[i].msg_type, cases[i].payload, + cases[i].good_len, CLUSTER_LMS_MAX_WORKERS) + >= 0); + } +} + +/* ====================================================================== + * U6 -- N == 1 degenerate: every routable frame lands on worker 0 (the + * spec-7.2 single-LMS topology identity; rings[0] byte path). + * ====================================================================== */ +UT_TEST(test_route_n1_degenerate_zero) +{ + int i; + + for (i = 0; i < 64; i++) { + BufferTag tag = make_tag(1663, 5 + (i % 5), 16384 + i, MAIN_FORKNUM, (BlockNumber)(i * 7)); + GcsBlockRequestPayload req = make_request(tag); + GcsBlockForwardPayload fwd = make_forward(tag); + GcsBlockInvalidatePayload inv = make_invalidate(tag); + + UT_ASSERT_EQ( + cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, &req, sizeof(req), 1), + 0); + UT_ASSERT_EQ( + cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_FORWARD, &fwd, sizeof(fwd), 1), + 0); + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_INVALIDATE, &inv, + sizeof(inv), 1), + 0); + } +} + +/* ====================================================================== + * U7 -- only the tag feeds the route: flipping every non-tag field of a + * staging payload (request_id / epoch / nodes / backend / flags) + * never moves the shard (the router reads &p->tag and nothing + * else -- same-tag streams cannot fork on metadata). + * ====================================================================== */ +UT_TEST(test_route_ignores_non_tag_fields) +{ + BufferTag tag = make_tag(1663, 5, 16384, MAIN_FORKNUM, 99); + GcsBlockRequestPayload req = make_request(tag); + int ref = cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, &req, sizeof(req), + CLUSTER_LMS_MAX_WORKERS); + int i; + + UT_ASSERT(ref >= 0); + for (i = 1; i <= 32; i++) { + GcsBlockRequestPayload v = make_request(tag); + + v.request_id = (uint64)i * 0x9E3779B97F4A7C15ull; + v.epoch = (uint64)i; + v.sender_node = i % 4; + v.requester_backend_id = i; + v.transition_id = (uint8)(i % 9); + memset(v.reserved_0, (int)(i & 0xFF), sizeof(v.reserved_0)); + + UT_ASSERT_EQ(cluster_gcs_block_payload_shard(PGRAC_IC_MSG_GCS_BLOCK_REQUEST, &v, sizeof(v), + CLUSTER_LMS_MAX_WORKERS), + ref); + } +} + +int +main(void) +{ + UT_PLAN(7); + UT_RUN(test_route_matches_shard_for_tag); + UT_RUN(test_route_ack_request_interleave_affinity); + UT_RUN(test_route_registry_partition); + UT_RUN(test_route_unroutable_fail_closed); + UT_RUN(test_route_length_mismatch_refused); + UT_RUN(test_route_n1_degenerate_zero); + UT_RUN(test_route_ignores_non_tag_fields); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +} diff --git a/src/test/cluster_unit/test_cluster_ic.c b/src/test/cluster_unit/test_cluster_ic.c index 76d9a357d2..ed767b08ae 100644 --- a/src/test/cluster_unit/test_cluster_ic.c +++ b/src/test/cluster_unit/test_cluster_ic.c @@ -618,6 +618,39 @@ UT_TEST(test_hello_wire_data_plane_bytes) UT_ASSERT(cluster_ic_hello_conn_epoch(&parsed) == UINT64CONST(0x1122334455667788)); } +/* + * spec-7.3 D3 — the worker_id / n_workers HELLO fields (offsets 41/42) are + * written by cluster_ic_hello_set_worker_fields on the DATA-plane send path + * and read by the accessors. build_hello alone leaves them zero, so a plain + * DATA HELLO stays byte-identical to spec-7.2 (compat pin). + */ +UT_TEST(test_hello_worker_fields_roundtrip) +{ + uint8 wire[PGRAC_IC_HELLO_BYTES]; + ClusterICHelloMsg parsed; + + /* build_hello alone => worker fields zero (spec-7.2 compat). */ + cluster_ic_build_hello(wire, PGRAC_IC_HELLO_VERSION_V1, PGRAC_IC_ENVELOPE_VERSION_V1, 3, "AB", + CLUSTER_IC_PLANE_DATA, UINT64CONST(0x99)); + UT_ASSERT_EQ(wire[PGRAC_IC_HELLO_WORKER_ID_OFFSET], 0); + UT_ASSERT_EQ(wire[PGRAC_IC_HELLO_N_WORKERS_OFFSET], 0); + UT_ASSERT(cluster_ic_parse_hello(wire, &parsed)); + UT_ASSERT_EQ(cluster_ic_hello_worker_id(&parsed), 0); + UT_ASSERT_EQ(cluster_ic_hello_n_workers(&parsed), 0); + + /* set worker fields => offsets 41/42 carry them; plane/pad/epoch intact. */ + cluster_ic_hello_set_worker_fields(wire, 3, 5); + UT_ASSERT_EQ(wire[PGRAC_IC_HELLO_WORKER_ID_OFFSET], 3); + UT_ASSERT_EQ(wire[PGRAC_IC_HELLO_N_WORKERS_OFFSET], 5); + UT_ASSERT_EQ(wire[PGRAC_IC_HELLO_PLANE_OFFSET], (uint8)CLUSTER_IC_PLANE_DATA); + UT_ASSERT_EQ(wire[43], 0); + UT_ASSERT_EQ(wire[PGRAC_IC_HELLO_CONN_EPOCH_OFFSET], 0x99); + + UT_ASSERT(cluster_ic_parse_hello(wire, &parsed)); + UT_ASSERT_EQ(cluster_ic_hello_worker_id(&parsed), 3); + UT_ASSERT_EQ(cluster_ic_hello_n_workers(&parsed), 5); +} + UT_TEST(test_hello_smart_fusion_capability_gate) { uint8 wire[PGRAC_IC_HELLO_BYTES]; @@ -691,7 +724,7 @@ UT_TEST(test_hello_build_truncates_long_name) int main(void) { - UT_PLAN(21); /* spec-2.3 D3: 6 ClusterMsgHeader/msg_send/recv tests deleted */ + UT_PLAN(22); /* spec-2.3 D3: 6 ClusterMsgHeader/msg_send/recv tests deleted */ UT_RUN(test_ic_send_bytes_linkable); UT_RUN(test_ic_recv_bytes_linkable); UT_RUN(test_ic_init_linkable); @@ -711,7 +744,8 @@ main(void) /* HELLO wire encode/decode + reference bytes (post-codex review) */ UT_RUN(test_hello_wire_roundtrip); UT_RUN(test_hello_wire_reference_bytes); - UT_RUN(test_hello_wire_data_plane_bytes); /* spec-7.2 D2 */ + UT_RUN(test_hello_wire_data_plane_bytes); /* spec-7.2 D2 */ + UT_RUN(test_hello_worker_fields_roundtrip); /* spec-7.3 D3 */ UT_RUN(test_hello_smart_fusion_capability_gate); UT_RUN(test_hello_parse_rejects_bad_magic); UT_RUN(test_hello_build_truncates_long_name); diff --git a/src/test/cluster_unit/test_cluster_ic_router.c b/src/test/cluster_unit/test_cluster_ic_router.c index 32ddbd7680..847fff1ab8 100644 --- a/src/test/cluster_unit/test_cluster_ic_router.c +++ b/src/test/cluster_unit/test_cluster_ic_router.c @@ -771,6 +771,12 @@ cluster_touched_peers_stamp(int32 node_id pg_attribute_unused(), return false; } +/* spec-7.3 D8: link-only stub — the per-worker dispatch counter bump is a + * no-op outside an LMS process, and the unit harness has no LMS. */ +void +cluster_lms_obs_note_dispatch(void) +{} + int main(void) { diff --git a/src/test/cluster_unit/test_cluster_lms_shard.c b/src/test/cluster_unit/test_cluster_lms_shard.c new file mode 100644 index 0000000000..b43bbb3854 --- /dev/null +++ b/src/test/cluster_unit/test_cluster_lms_shard.c @@ -0,0 +1,365 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_lms_shard.c + * Unit tests for the LMS worker-pool shard map pure layer (spec-7.3 D1). + * + * cluster_lms_shard_for_tag() is the single (BufferTag -> worker) + * mapping that keeps per-tag FIFO ordering intact after the DATA + * plane is split N ways (spec-7.3 §3.1, 8.A load-bearing invariant). + * These tests pin the truth table the spec's D0-① made mandatory: + * + * - the shard is in [0, n_workers) for every tag / N, + * - N == 1 always maps to worker 0 (spec-7.2 topology identity; + * worker 0 is a real LMS, not a sentinel -- L449), + * - the shard depends on the BufferTag ALONE (never request_id / + * backend / direction), so a same-tag REQUEST and its later ACK + * ride the same worker stream (D0-① WATCH), + * - every BufferTag field feeds the hash (no degenerate map), and + * - the mapping is deterministic + reasonably balanced. + * + * The real 2-node cross-worker routing (counter deltas prove tags + * land on distinct workers) is the D9 TAP; this file pins the pure + * shard math that both ends of a connection must agree on. + * + * 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_lms_shard.c + * + * NOTES + * This is a pgrac-original file. + * Spec: spec-7.3-lms-worker-pool.md (D1) + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "common/relpath.h" /* ForkNumber names */ +#include "storage/block.h" +#include "cluster/cluster_lms_shard.h" + +#undef printf +#undef fprintf +#undef snprintf + +#include "unit_test.h" + +UT_DEFINE_GLOBALS(); + +void +ExceptionalCondition(const char *conditionName, const char *fileName, int lineNumber) +{ + printf("# Assert failed: %s at %s:%d\n", conditionName, fileName, lineNumber); + abort(); +} + +/* Build a BufferTag from its five flat fields (spec-7.3 shard-key domain). */ +static BufferTag +make_tag(Oid spc, Oid db, RelFileNumber rel, ForkNumber fork, BlockNumber blk) +{ + BufferTag tag; + + tag.spcOid = spc; + tag.dbOid = db; + tag.relNumber = rel; + tag.forkNum = fork; + tag.blockNum = blk; + return tag; +} + +/* ====================================================================== + * U1 -- shard is always in [0, n_workers) for every tag and every N. + * ====================================================================== */ +UT_TEST(test_shard_in_range) +{ + int n; + BlockNumber blk; + + for (n = 1; n <= CLUSTER_LMS_MAX_WORKERS; n++) { + for (blk = 0; blk < 200; blk++) { + BufferTag tag = make_tag(1663, 5, 16384 + (blk % 7), MAIN_FORKNUM, blk); + int s = cluster_lms_shard_for_tag(&tag, n); + + UT_ASSERT(s >= 0); + UT_ASSERT(s < n); + } + } +} + +/* ====================================================================== + * U2 -- N == 1 degenerate: every tag maps to worker 0 (7.2 identity; + * worker 0 is a live LMS, not a sentinel -- L449). + * ====================================================================== */ +UT_TEST(test_shard_n1_degenerate_zero) +{ + BlockNumber blk; + + for (blk = 0; blk < 500; blk++) { + BufferTag tag = make_tag(1663 + (blk % 3), 5 + (blk % 4), 16384 + blk, + (ForkNumber)(blk % (MAX_FORKNUM + 1)), blk * 13); + + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&tag, 1), 0); + } +} + +/* ====================================================================== + * U3 -- deterministic: same (tag, N) always yields the same shard. + * ====================================================================== */ +UT_TEST(test_shard_deterministic) +{ + int n; + int i; + + for (i = 0; i < 100; i++) { + BufferTag tag = make_tag(1663, 5, 16384 + i, FSM_FORKNUM, i * 7 + 1); + + for (n = 1; n <= CLUSTER_LMS_MAX_WORKERS; n++) { + int a = cluster_lms_shard_for_tag(&tag, n); + int b = cluster_lms_shard_for_tag(&tag, n); + + UT_ASSERT_EQ(a, b); + } + } +} + +/* ====================================================================== + * U4 -- depends on the BufferTag ALONE (D0-① load-bearing invariant). + * + * The function takes no request_id / backend / direction, so its + * result must be a pure function of the tag bytes. Two + * independently constructed byte-identical tags must map alike, and + * intervening calls for other tags must not perturb the mapping (no + * hidden per-call state). + * ====================================================================== */ +UT_TEST(test_shard_depends_only_on_tag) +{ + BufferTag t1 = make_tag(1663, 5, 16384, MAIN_FORKNUM, 42); + BufferTag t2 = make_tag(1663, 5, 16384, MAIN_FORKNUM, 42); + int ref = cluster_lms_shard_for_tag(&t1, CLUSTER_LMS_MAX_WORKERS); + int i; + + /* Byte-identical tag -> identical shard. */ + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&t2, CLUSTER_LMS_MAX_WORKERS), ref); + + /* Interleave unrelated tags; ref must stay stable (no hidden state). */ + for (i = 0; i < 50; i++) { + BufferTag noise = make_tag(2000 + i, 9, 30000 + i, VISIBILITYMAP_FORKNUM, i); + + (void)cluster_lms_shard_for_tag(&noise, CLUSTER_LMS_MAX_WORKERS); + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&t1, CLUSTER_LMS_MAX_WORKERS), ref); + } +} + +/* ====================================================================== + * U5 -- same tag routes identically for every block-family message type + * (D0-① WATCH: the same-tag INVALIDATE-ACK vs re-REQUEST wire-FIFO + * dependency survives the split iff all five msg types of a tag + * share one worker). msg_type is NOT a shard input, so the shard + * is invariant across the whole family for a given tag. + * ====================================================================== */ +UT_TEST(test_shard_same_tag_all_msg_types) +{ + /* Represents REQUEST / REPLY / FORWARD / INVALIDATE / ACK -- none is + * a shard input, so all must hit the same worker for a given tag. */ + int n_msg_types = 5; + int i; + + for (i = 0; i < 64; i++) { + BufferTag tag = make_tag(1663, 5, 16384 + (i % 11), MAIN_FORKNUM, i); + int ref = cluster_lms_shard_for_tag(&tag, CLUSTER_LMS_MAX_WORKERS); + int m; + + for (m = 0; m < n_msg_types; m++) { + /* The msg type is deliberately not passed: the shard must be + * the tag's shard regardless of which message carries it. */ + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&tag, CLUSTER_LMS_MAX_WORKERS), ref); + } + } +} + +/* + * Helper: does varying one field (holding the others fixed) ever change + * the shard? Returns true if at least two distinct shards are observed. + */ +static bool +field_moves_shard(int which_field) +{ + int seen_mask = 0; + int i; + + for (i = 0; i < 256; i++) { + Oid spc = 1663; + Oid db = 5; + RelFileNumber rel = 16384; + ForkNumber fork = MAIN_FORKNUM; + BlockNumber blk = 100; + BufferTag tag; + int s; + + switch (which_field) { + case 0: + spc = 1000 + (Oid)i; + break; + case 1: + db = 1000 + (Oid)i; + break; + case 2: + rel = 16384 + (RelFileNumber)i; + break; + case 3: + fork = (ForkNumber)(i % (MAX_FORKNUM + 1)); + break; + case 4: + blk = (BlockNumber)i; + break; + default: + break; + } + + tag = make_tag(spc, db, rel, fork, blk); + s = cluster_lms_shard_for_tag(&tag, CLUSTER_LMS_MAX_WORKERS); + seen_mask |= (1 << s); + } + + /* More than one distinct shard bit set == this field feeds the hash. */ + return (seen_mask & (seen_mask - 1)) != 0; +} + +/* ====================================================================== + * U6 -- every BufferTag field feeds the hash (no degenerate map). A + * constant / partial hash would fail this (guards against a shard + * fn that ignores blockNum, fork, etc.). + * ====================================================================== */ +UT_TEST(test_shard_field_sensitivity) +{ + UT_ASSERT(field_moves_shard(0)); /* spcOid */ + UT_ASSERT(field_moves_shard(1)); /* dbOid */ + UT_ASSERT(field_moves_shard(2)); /* relNumber */ + UT_ASSERT(field_moves_shard(3)); /* forkNum */ + UT_ASSERT(field_moves_shard(4)); /* blockNum */ +} + +/* ====================================================================== + * U7 -- distribution: over many distinct tags every worker gets traffic + * and the map is roughly balanced (guards against degeneracy). + * ====================================================================== */ +UT_TEST(test_shard_distribution) +{ + int n_list[3] = { 2, 4, 8 }; + int total = 4096; + int ni; + + for (ni = 0; ni < 3; ni++) { + int n = n_list[ni]; + int buckets[CLUSTER_LMS_MAX_WORKERS]; + int min_bucket; + int b; + int i; + + for (b = 0; b < CLUSTER_LMS_MAX_WORKERS; b++) + buckets[b] = 0; + + for (i = 0; i < total; i++) { + BufferTag tag = make_tag(1663, 5, 16384 + (i / 512), MAIN_FORKNUM, + (BlockNumber)(i % 512) + (i / 512) * 1000); + int s = cluster_lms_shard_for_tag(&tag, n); + + buckets[s]++; + } + + /* Every worker must get at least one tag (catches constant maps). */ + min_bucket = total; + for (b = 0; b < n; b++) { + UT_ASSERT(buckets[b] > 0); + if (buckets[b] < min_bucket) + min_bucket = buckets[b]; + } + /* Loose balance: no bucket below mean/4 (mean = total/n). Safe for + * a decent hash over 4096 items; still catches a lopsided map. */ + UT_ASSERT(min_bucket >= (total / n) / 4); + } +} + +/* ====================================================================== + * U8 -- boundary tags: InvalidOid / all-max / block 0 / every fork all + * produce in-range shards; N==1 stays 0. + * ====================================================================== */ +UT_TEST(test_shard_boundary_tags) +{ + BufferTag tags[6]; + int n_tags = 6; + int n; + int i; + + tags[0] = make_tag(InvalidOid, InvalidOid, InvalidRelFileNumber, MAIN_FORKNUM, 0); + tags[1] = make_tag(0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, INIT_FORKNUM, 0xFFFFFFFFu); + tags[2] = make_tag(1663, 5, 16384, FSM_FORKNUM, 0); + tags[3] = make_tag(1663, 5, 16384, VISIBILITYMAP_FORKNUM, 0); + tags[4] = make_tag(1663, 5, 16384, INIT_FORKNUM, 0); + tags[5] = make_tag(1663, 5, 16384, MAIN_FORKNUM, 0xFFFFFFFFu); + + for (i = 0; i < n_tags; i++) { + for (n = 1; n <= CLUSTER_LMS_MAX_WORKERS; n++) { + int s = cluster_lms_shard_for_tag(&tags[i], n); + + UT_ASSERT(s >= 0); + UT_ASSERT(s < n); + } + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&tags[i], 1), 0); + } +} + +/* ====================================================================== + * U9 -- golden characterization: the exact (tag -> shard) mapping is + * pinned so any accidental change to the field packing or hash + * (which would silently diverge the two ends of a connection -- + * R1) fails loudly. Values captured on the reference build; the + * map is a stable cluster-wide contract, not an implementation + * detail. If BufferTag layout or hash_bytes_extended changes, this + * is expected to fail and the wire compatibility must be reasoned + * through before re-blessing. + * ====================================================================== */ +UT_TEST(test_shard_golden) +{ + struct { + BufferTag tag; + int n2; + int n4; + int n8; + } cases[6] = { + { { 1663, 5, 16384, MAIN_FORKNUM, 0 }, 1, 1, 1 }, + { { 1663, 5, 16384, MAIN_FORKNUM, 1 }, 0, 2, 2 }, + { { 1663, 5, 16384, MAIN_FORKNUM, 42 }, 0, 0, 0 }, + { { 1663, 5, 16385, MAIN_FORKNUM, 0 }, 0, 0, 0 }, + { { 1663, 12345, 16384, FSM_FORKNUM, 7 }, 1, 3, 3 }, + { { 1700, 99, 20000, VISIBILITYMAP_FORKNUM, 123 }, 1, 1, 5 }, + }; + int i; + + for (i = 0; i < 6; i++) { + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&cases[i].tag, 2), cases[i].n2); + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&cases[i].tag, 4), cases[i].n4); + UT_ASSERT_EQ(cluster_lms_shard_for_tag(&cases[i].tag, 8), cases[i].n8); + } +} + +int +main(void) +{ + UT_PLAN(9); + UT_RUN(test_shard_in_range); + UT_RUN(test_shard_n1_degenerate_zero); + UT_RUN(test_shard_deterministic); + UT_RUN(test_shard_depends_only_on_tag); + UT_RUN(test_shard_same_tag_all_msg_types); + UT_RUN(test_shard_field_sensitivity); + UT_RUN(test_shard_distribution); + UT_RUN(test_shard_boundary_tags); + UT_RUN(test_shard_golden); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +} diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm index 9d9490b6b1..a0f65a891f 100644 --- a/src/test/perl/PostgreSQL/Test/Cluster.pm +++ b/src/test/perl/PostgreSQL/Test/Cluster.pm @@ -1571,6 +1571,69 @@ sub get_free_port return $port; } +=pod + +=item get_free_port_range(n) + +PGRAC: spec-7.3 D3 -- reserve a block of C consecutive free ports and +return the base. The LMS DATA-plane worker pool binds one listener per +worker at C, so a multi-node test needs each node's +C<[data_port, data_port + workers)> range to be free and non-overlapping +across nodes. Mirrors get_free_port's availability + reservation logic over +a whole range (each port is 127.0.0.1-bindable, not held by a live node, and +reserved via the same lockfile scheme), so two independent calls never +overlap. + +=cut + +sub get_free_port_range +{ + my $n = shift; + die "get_free_port_range: n must be >= 1" if !defined $n || $n < 1; + + # Bounded retry: get_free_port() advances the shared cursor, so each + # attempt probes a fresh base. 100 attempts is ample even when the port + # space is busy; a real exhaustion dies loudly rather than looping. + for (my $attempt = 0; $attempt < 100; $attempt++) + { + my $base = get_free_port(); # reserves $base + my $ok = 1; + + for my $off (1 .. $n - 1) + { + my $p = $base + $off; + + if ($p > $port_upper_bound) + { + $ok = 0; + last; + } + foreach my $node (@all_nodes) + { + if ($node->port == $p) + { + $ok = 0; + last; + } + } + last if !$ok; + if (!can_bind("127.0.0.1", $p) || !_reserve_port($p)) + { + $ok = 0; + last; + } + } + + return $base if $ok; + + # This base's block was not fully free; the ports we did reserve stay + # reserved for this process (released at exit) — harmless, and the next + # attempt starts past them. + } + + die "get_free_port_range: could not find $n consecutive free ports"; +} + # Internal routine to check whether a host:port is available to bind sub can_bind { diff --git a/src/test/perl/PostgreSQL/Test/ClusterPair.pm b/src/test/perl/PostgreSQL/Test/ClusterPair.pm index 1f84f527f5..45724b4eca 100644 --- a/src/test/perl/PostgreSQL/Test/ClusterPair.pm +++ b/src/test/perl/PostgreSQL/Test/ClusterPair.pm @@ -83,8 +83,24 @@ sub new_pair my $pg_port_1 = PostgreSQL::Test::Cluster::get_free_port(); my $ic_port_0 = PostgreSQL::Test::Cluster::get_free_port(); my $ic_port_1 = PostgreSQL::Test::Cluster::get_free_port(); - my $data_port_0 = PostgreSQL::Test::Cluster::get_free_port(); - my $data_port_1 = PostgreSQL::Test::Cluster::get_free_port(); + # spec-7.3 D3: the LMS worker pool binds a listener per worker at + # data_port + worker_id, so a test that runs >1 worker needs each node's + # [data_port, data_port + span) range free and non-overlapping across the + # two same-host nodes. The DEFAULT span follows the shipped default + # cluster.lms_workers = 2 (spec-7.3 D9 root fix: with a span of 1 any + # pair test that leaves the pool at its default binds data_port + 1 + # on an unreserved port and can FATAL on "Address already in use" -- + # the D5/D7 point fixes whack-a-moled 8 files of ~85). Tests that + # override cluster.lms_workers above 2 pass data_port_span explicitly. + my $data_span = $opts{data_port_span} // 2; + my $data_port_0 = + $data_span > 1 + ? PostgreSQL::Test::Cluster::get_free_port_range($data_span) + : PostgreSQL::Test::Cluster::get_free_port(); + my $data_port_1 = + $data_span > 1 + ? PostgreSQL::Test::Cluster::get_free_port_range($data_span) + : PostgreSQL::Test::Cluster::get_free_port(); my $node0 = PostgreSQL::Test::Cluster->new("${cluster_name}_node0", port => $pg_port_0); diff --git a/src/test/perl/PostgreSQL/Test/ClusterTriple.pm b/src/test/perl/PostgreSQL/Test/ClusterTriple.pm index 4b625e1792..9612395755 100644 --- a/src/test/perl/PostgreSQL/Test/ClusterTriple.pm +++ b/src/test/perl/PostgreSQL/Test/ClusterTriple.pm @@ -39,8 +39,10 @@ use PostgreSQL::Test::Utils; # # Allocate three PG instances that share a pgrac cluster name. # Optional %opts: -# extra_conf : arrayref of extra GUC lines appended to ALL nodes' -# postgresql.conf +# extra_conf : arrayref of extra GUC lines appended to ALL nodes' +# postgresql.conf +# data_port_span : per-node DATA-plane port range width (default 2 = +# the shipped cluster.lms_workers default; spec-7.3) #----------------------------------------------------------------------- sub new_triple { @@ -58,12 +60,18 @@ sub new_triple PostgreSQL::Test::Cluster::get_free_port(), ); # spec-7.2 D2: explicit DATA-plane ports (allocator-provided, never - # offset-derived -- r1-F2). - my @data_ports = ( - PostgreSQL::Test::Cluster::get_free_port(), - PostgreSQL::Test::Cluster::get_free_port(), - PostgreSQL::Test::Cluster::get_free_port(), - ); + # offset-derived -- r1-F2). spec-7.3 D9: the LMS worker pool binds a + # listener per worker at data_port + worker_id, so each node needs a + # reserved [data_port, data_port + span) range; the default span + # follows the shipped default cluster.lms_workers = 2 (same root fix + # as ClusterPair -- a span of 1 left data_port + 1 unreserved and the + # triple FATALed on "Address already in use"). + my $data_span = $opts{data_port_span} // 2; + my @data_ports = map { + $data_span > 1 + ? PostgreSQL::Test::Cluster::get_free_port_range($data_span) + : PostgreSQL::Test::Cluster::get_free_port() + } (0 .. 2); my @nodes; for my $i (0 .. 2)