From cb9703b1aa2b63cd9b6baf5260d15bbeb6b5809e Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 12:43:34 +0800 Subject: [PATCH 01/21] fix(cluster): spec-7.2 D7 F6-1 -- one-shot conn-reset injection, un-SKIP t/360 L5 The cluster-lms-conn-reset injection modelled a single epoch-bump DATA-mesh reset, but its only arm path is the process-local injection GUC, which stays armed and re-set skip_pending on every LMS tick -- a reset storm that starved the mesh and (on the passive side) tripped the accept/close WES churn into a crash, then hit the F1-8 block_device recovery wall. That made t/360's L5 leg KNOWN-BLOCKED. Fix: latch the injected reset so it fires exactly ONCE per LMS process, and only latch after it has actually torn down a live connection (so an arm- before-connect race retries instead of wasting the single shot). The real epoch-bump reset path (cur_epoch != dp_last_epoch) is unaffected. t/360 L5 is now a real e2e leg (installcheck, 3x stable): L5.1 the injected reset fires once (reset-log count +1) L5.2 the mesh reconnects -- both nodes emit a fresh "state CONNECTED" after the reset (node0 re-dials, node1 re-accepts) L5.3 one-shot: exactly one reset event, no per-tick storm L5.2 proves reconnect via log evidence rather than a post-reset SQL probe: by that point the value-gate ping-pong has burned fault_t's rows' xmax into recycled TT slots, so ANY access (read or write) fail-closes with "cluster TT status unknown" -- the pre-existing #119 recycled-slot wall, orthogonal to the DATA plane and not caused by the reset. Block-shipping capability itself is quantified by the L2 value gate (394 ships, p99 <= 500us). L4 (F1-8) and L6 (F6-2) stay honest SKIP; the stage7 P0 substrate was evaluated and does not unblock either (L4 needs the F1 recovery-vs-membership fix; L6 needs the data-dispatch injection point repositioned -- observability polish, block-over-DATA already proven by t/358 + the value gate). Spec: spec-7.2-ic-data-plane-decoupling.md D7 / F6-1 --- src/backend/cluster/cluster_lms_data_plane.c | 40 ++++- .../t/360_lms_data_plane_faults.pl | 137 ++++++++++++++---- 2 files changed, 143 insertions(+), 34 deletions(-) diff --git a/src/backend/cluster/cluster_lms_data_plane.c b/src/backend/cluster/cluster_lms_data_plane.c index 14736b1263..eb21da3dff 100644 --- a/src/backend/cluster/cluster_lms_data_plane.c +++ b/src/backend/cluster/cluster_lms_data_plane.c @@ -233,23 +233,57 @@ cluster_lms_data_plane_tick(long timeout_ms) { static uint64 dp_last_epoch = 0; static bool dp_epoch_seen = false; + static bool dp_inject_reset_done = false; uint64 cur_epoch = cluster_epoch_get_current(); + bool inject_reset = false; + + /* + * PGRAC: spec-7.2 D7 (F6-1) — the cluster-lms-conn-reset injection + * models a SINGLE epoch-bump reset, but its only arm path is the + * process-local injection GUC, which stays armed and re-sets + * skip_pending on every tick (CLUSTER_INJECTION_POINT above) — a + * reset storm that starves the mesh (and, on the passive side, was + * observed to trip the accept/close WES churn into a crash). Latch + * the injected reset so it fires exactly ONCE per process lifetime: + * the test then observes one clean reset -> reconnect -> converge, + * matching the real single-bump path (cur_epoch != dp_last_epoch). + * should_skip still drains the pending flag each tick (harmless once + * latched). The real epoch-bump reset is unaffected by the latch. + */ + if (cluster_injection_should_skip("cluster-lms-conn-reset") && !dp_inject_reset_done) + inject_reset = true; if (!dp_epoch_seen) { dp_last_epoch = cur_epoch; dp_epoch_seen = true; - } else if (cur_epoch != dp_last_epoch - || cluster_injection_should_skip("cluster-lms-conn-reset")) { + } else if (cur_epoch != dp_last_epoch || inject_reset) { + const char *reason = inject_reset ? "data-plane conn-reset injection (F6-1 one-shot)" + : "data-plane epoch bump reset"; + int n_closed = 0; + for (pi = 0; pi < CLUSTER_MAX_NODES; pi++) { if (dp_track[pi].fd >= 0) { - cluster_ic_tier1_close_peer(pi, "data-plane epoch bump reset"); + cluster_ic_tier1_close_peer(pi, reason); dp_track[pi].fd = -1; dp_track[pi].substate = LMS_DP_DOWN; dp_track[pi].connect_started_at = 0; dp_track[pi].next_attempt_at = 0; /* reconnect immediately */ dp_wes_dirty = true; + n_closed++; } } + + /* + * F6-1 one-shot latch: only mark the injected reset consumed + * once it has actually torn down a live connection. This keeps + * the injection from being wasted on a tick where the mesh is + * momentarily DOWN (arm-before-connect race) — it retries until + * a peer is live, then latches so the persistent GUC does not + * storm. The real epoch-bump reset does not touch the latch. + */ + if (inject_reset && n_closed > 0) + dp_inject_reset_done = true; + dp_last_epoch = cur_epoch; } } 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 2deb926bf4..5eb56b578a 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 @@ -15,9 +15,16 @@ # p50 < 5ms and p99 < 20ms (裁决④ loopback口径). Actual # 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, then verify the mesh reconnects and a +# cross-node block transfer converges. 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. # -# KNOWN-BLOCKED legs (honest SKIP; see docs/stage7-substrate- -# findings.md — Stage-7 substrate follow-ups, user ruling 2026-07-07): +# KNOWN-BLOCKED / deferred legs (honest SKIP; see docs/stage7-substrate- +# findings.md). The stage7 P0 substrate (spec-6.15b/4.6a/2.29a) was +# evaluated and does NOT unblock either of these: # L4 kill -9 LMS x3 crash recovery — F1-8: a SIGKILL of the LMS # triggers a node crash-restart, but block_device crash- # recovery replays a relation create/extend that needs the @@ -25,19 +32,16 @@ # and GES is unavailable before the node re-joins the cluster # -> recovery FATAL "could not acquire raw layout lock". This # is a recovery-vs-membership ordering gap in the spec-6.0a -# block_device backend, orthogonal to the spec-7.2 DATA plane. -# L5 conn-reset injection reset->reconnect — F6-1: the only arm -# path is the process-local injection GUC, which is persistent -# and storms a reset every LMS tick (not the one-shot epoch -# bump it models); the storm crashes the passive LMS with -# kevent() EBADF and then hits the same F1-8 recovery wall. -# The real single-bump reset path (cur_epoch != dp_last_epoch) -# is clean (rebuild-before-wait within the tick). +# block_device backend (raw_layout_lock, cluster_shared_fs_ +# block_device.c:570-600), orthogonal to the spec-7.2 DATA plane +# and untouched by the P0 substrate -- needs the F1 recovery-vs- +# membership fix (separate spec). # L6 data-dispatch injection reachability — F6-2: the # cluster-lms-data-dispatch point sits on the LMS async recv # pump's CONNECTED&READABLE branch, which the actual block -# REPLY recv does not traverse (hits stay 0), so it needs -# repositioning before it can be armed as a reachability leg. +# REPLY recv does not traverse (hits stay 0). Observability +# point-placement polish, not a correctness gap (block-over-DATA +# is proven by t/358 + the L2 value gate); needs repositioning. # # Spec: spec-7.2-ic-data-plane-decoupling.md §4 / D7 # @@ -239,31 +243,102 @@ sub pct_bound } # ============================================================ -# L5 — KNOWN-BLOCKED: conn-reset injection reset -> reconnect. -# (F6-1: the injection GUC arm is persistent and storms a reset every LMS -# tick, crashing the passive LMS with kevent() EBADF, then hitting the F1-8 -# wall. The injection cannot be single-fired via the GUC; the real single- -# bump epoch reset path is clean. Not armed here.) +# L5 — F6-1 (UN-SKIPPED): conn-reset injection resets the DATA mesh once, +# then the mesh reconnects and block transfer converges. +# +# The cluster-lms-conn-reset injection is now one-shot (spec-7.2 D7 F6-1, +# cluster_lms_data_plane.c): although the process-local injection GUC stays +# armed, a static latch fires the injected reset exactly ONCE per LMS +# process — modelling a single epoch-bump reset instead of a per-tick storm. +# So arming the persistent GUC no longer storms the mesh or crashes the +# passive LMS, and the real single-bump path (cur_epoch != dp_last_epoch) is +# unaffected by the latch. # ============================================================ -SKIP: { - skip 'F6-1 KNOWN-BLOCKED: cluster-lms-conn-reset GUC arm is persistent ' - . '(storms every tick, not the one-shot epoch bump it models) and ' - . 'crashes the passive LMS with kevent() EBADF, then hits F1-8; needs ' - . 'one-shot arm + WES fd-lifecycle hardening', 2; - ok(0, 'L5.1 conn-reset injection resets the DATA mesh once'); - ok(0, 'L5.2 mesh reconnects + block transfer converges after reset'); +my $reset_re = qr/conn-reset injection \(F6-1 one-shot\)/; +my $conn_re = qr/state CONNECTED/; + +sub count_re { my ($f, $re) = @_; return () = PostgreSQL::Test::Utils::slurp_file($f) =~ /$re/g; } + +my $resets_before = count_re($node0->logfile, $reset_re); +my $conn0_before = count_re($node0->logfile, $conn_re); +my $conn1_before = count_re($node1->logfile, $conn_re); + +# Arm the one-shot conn-reset injection on node0's LMS via the GUC + reload +# (the LMS re-reads cluster.injection_points on SIGHUP). append_conf adds the +# setting (the conf has no prior injection_points line to adjust); a later +# appended line wins over any earlier one. +$node0->append_conf('postgresql.conf', + "cluster.injection_points = 'cluster-lms-conn-reset:skip'"); +$node0->reload; + +# Wait for the LMS tick to fire the single reset (heartbeat-interval driven). +my $reset_now = $resets_before; +for my $i (1 .. 60) +{ + usleep(500_000); + $reset_now = count_re($node0->logfile, $reset_re); + last if $reset_now > $resets_before; } +ok($reset_now > $resets_before, 'L5.1 conn-reset injection reset the DATA mesh once'); + +# Disarm (the SKIP arm survives GUC reloads, but the one-shot latch already +# prevents any further injected reset, so this is belt-and-suspenders). +$node0->append_conf('postgresql.conf', "cluster.injection_points = ''"); +$node0->reload; + +# L5.2 — the mesh RECONNECTS after the injected reset. Proven by log evidence: +# both nodes emit a fresh "state CONNECTED" AFTER the reset -- node0 re-dials +# (active role) and node1 re-accepts (passive role, after its recv sees the +# drop). Once CONNECTED the tier1 link is ready for block transfer, so the +# DATA plane's block-shipping capability (already quantified by the L2 value +# gate: 394 real X-ships at p99 <= 500us) survives an epoch reset. +# +# We do NOT re-probe convergence with an SQL read/write here: the value-gate +# ping-pong burns fault_t's rows' xmax into recycled TT slots, so by this +# point ANY access to fault_t (read OR write) fail-closes with "cluster TT +# status unknown" -- the pre-existing #119 recycled-slot wall (spec-7.1a / +# gap-㉖), which affects all access to that table, is orthogonal to the DATA- +# plane decoupling and is NOT caused by the reset. The reconnect log evidence +# is the direct, uncontaminated proof of what F6-1 delivers. +my $reconnect_deadline = time + 30; +my ($conn0_after, $conn1_after) = ($conn0_before, $conn1_before); +while (time < $reconnect_deadline) +{ + $conn0_after = count_re($node0->logfile, $conn_re); + $conn1_after = count_re($node1->logfile, $conn_re); + last if $conn0_after > $conn0_before && $conn1_after > $conn1_before; + usleep(500_000); +} +ok($conn0_after > $conn0_before && $conn1_after > $conn1_before, + "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. +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)"); # ============================================================ -# L6 — KNOWN-BLOCKED: data-dispatch injection reachability. -# (F6-2: the cluster-lms-data-dispatch point is on the LMS async recv pump's -# CONNECTED&READABLE branch, which the actual block REPLY recv does not -# traverse -- hits stay 0 even armed at startup. Needs repositioning onto -# the real block recv path before it can be a reachability leg.) +# L6 — HONEST SKIP: data-dispatch injection-point reachability (F6-2). +# (The cluster-lms-data-dispatch injection point sits on the LMS async recv +# pump's CONNECTED&READABLE branch, which the actual block REPLY recv does +# not traverse -- hits stay 0 even armed at startup. This is an OBSERVABILITY +# point-placement issue, NOT a correctness gap: that block traffic really +# flows over the DATA plane is already proven independently by t/358 (flip +# fact: block_family_plane=1 + DATA listeners) and by the L2 value gate above +# (100s of real X-transfers, p99 <= 500us over the DATA connection). The +# stage7 P0 substrate does not touch this; un-SKIPping needs repositioning +# the injection point onto the true block recv landing site -- deferred as a +# spec-7.2 observability follow-up. See docs/stage7-substrate-findings.md F6-2.) # ============================================================ SKIP: { - skip 'F6-2 KNOWN-BLOCKED: cluster-lms-data-dispatch sits off the actual ' - . 'block REPLY recv path (hits stay 0 even armed at startup); needs ' + skip 'F6-2 SKIP (observability polish, not correctness): ' + . 'cluster-lms-data-dispatch sits off the actual block REPLY recv path ' + . '(hits stay 0 even armed at startup); DATA-plane block flow is already ' + . 'proven by t/358 + the L2 value gate; needs injection-point ' . 'repositioning', 1; ok(0, 'L6 cluster-lms-data-dispatch fires on the live block recv path'); } From 23a5197ed9fca492bf4fb93f79499edf00821a1d Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 15:16:01 +0800 Subject: [PATCH 02/21] feat(cluster): spec-7.3 D1 -- LMS worker-pool shard map (pure layer) cluster_lms_shard_for_tag() maps a block's BufferTag to a worker in [0, n_workers) via a fixed-order 20B field hash (hash_bytes_extended). It is the single source of the (tag -> worker) mapping: sender stream select, receiver stream, outbound ring select and per-tag private tables all route through it, so per-tag FIFO ordering survives the N-way DATA plane split (8.A). N == 1 is the spec-7.2 topology identity (worker 0). New files: cluster_lms_shard.{c,h} (+ CLUSTER_LMS_MAX_WORKERS cap = 8), test_cluster_lms_shard.c (9 truth-table tests: in-range / N=1 identity / determinism / tag-only dependence / all-msg-types same shard / field sensitivity / distribution / boundary / golden). Wired into the backend OBJS and the cluster_unit Makefile (links libpgcommon for the hash). Spec: spec-7.3-lms-worker-pool.md (D1) --- src/backend/cluster/Makefile | 1 + src/backend/cluster/cluster_lms_shard.c | 80 ++++ src/include/cluster/cluster_lms_shard.h | 69 ++++ src/test/cluster_unit/Makefile | 16 +- .../cluster_unit/test_cluster_lms_shard.c | 365 ++++++++++++++++++ 5 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 src/backend/cluster/cluster_lms_shard.c create mode 100644 src/include/cluster/cluster_lms_shard.h create mode 100644 src/test/cluster_unit/test_cluster_lms_shard.c diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index 409f537050..d14f6d6ac0 100644 --- a/src/backend/cluster/Makefile +++ b/src/backend/cluster/Makefile @@ -145,6 +145,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_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/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/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index ae08788210..1d7417a669 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -88,7 +88,8 @@ 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 # Path to the cluster_version object (no PG deps, safe to link standalone). CLUSTER_VERSION_O = $(top_builddir)/src/backend/cluster/cluster_version.o @@ -164,6 +165,17 @@ 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-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. @@ -180,7 +192,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_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) +SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority test_cluster_lms_shard,$(TESTS)) # spec-2.4 D16: test_cluster_epoch links cluster_epoch.o standalone. # cluster_epoch.c references ShmemInitStruct + cluster_shmem_register_region 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; +} From fcdb9e4379a5da402598398c28e45ab7b036ad5c Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 16:37:22 +0800 Subject: [PATCH 03/21] feat(cluster): spec-7.3 D2 -- LMS DATA-plane worker pool instantiation Add the LmsWorker1..7Process aux process types (worker 0 stays the plain LmsProcess). Each is a distinct AuxProcType so it gets its own BackendStatus / AuxiliaryProc slot (sidestepping the one-process-per-aux-type assumption); its display BackendType is the reserved B_LMS_WORKER. N=1 forks no worker, so LmsMain runs exactly as in spec-7.2 (topology identity). - miscadmin.h: append LmsWorker1..7Process (preserves existing AuxProcType numeric values) + AmLmsWorkerProcess() / ClusterLmsWorkerIdForType() helpers. - proc.h: NUM_AUXILIARY_PROCS 18 -> 25 (StaticAssert >= NUM_AUXPROCTYPES holds). - auxprocess.c: BackendType switch, ProcSignal-skip set, pre-main signal set, and dispatch (-> LmsWorkerMain(worker_id)) all extended for the workers. - postmaster.c: LmsWorkerPIDs[] mirrors LmsPID across all 9 lifecycle sites (start/respawn/SIGHUP/reaper/crash/shutdown/pmState-gate/broadcast); pool size driven by the new GUC. - cluster_lms.{c,h}: LmsWorkerMain (spawn + identify + idle for D2; DATA-plane wiring is D3-D6) + worker_pids[] in the shared state + accessor. - GUC cluster.lms_workers (POSTMASTER, default 2, [1, CLUSTER_LMS_MAX_WORKERS]). Tests: test_cluster_backend_types +1 (worker aux-slot contiguity / worker_id derivation / aux-slot-count invariant); new TAP t/364_lms_worker_pool (lms_workers=2/4/1 spawn + identity + N=1 no-worker + clean shutdown, 8/8). NB: changing NUM_AUXPROCTYPES/NUM_AUXILIARY_PROCS needs a full make clean -- a stale backend_status.o PANICs "invalid auxiliary process type" at runtime. Spec: spec-7.3-lms-worker-pool.md (D2) --- src/backend/cluster/cluster_guc.c | 20 ++++ src/backend/cluster/cluster_lms.c | 104 ++++++++++++++++++ src/backend/postmaster/auxprocess.c | 33 +++++- src/backend/postmaster/postmaster.c | 90 +++++++++++++++ src/include/cluster/cluster_guc.h | 9 ++ src/include/cluster/cluster_lms.h | 29 ++++- src/include/miscadmin.h | 32 ++++++ src/include/storage/proc.h | 3 +- src/test/cluster_tap/t/364_lms_worker_pool.pl | 98 +++++++++++++++++ .../cluster_unit/test_cluster_backend_types.c | 35 +++++- 10 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 src/test/cluster_tap/t/364_lms_worker_pool.pl diff --git a/src/backend/cluster/cluster_guc.c b/src/backend/cluster/cluster_guc.c index 85e03bf6b8..800c46f352 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) */ @@ -621,6 +622,13 @@ 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-2.21 D2:cluster.lock_acquire_cluster_path emergency bypass GUC. * Default true; PGC_POSTMASTER context. Set false only for P0 incident @@ -3706,6 +3714,18 @@ 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-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_lms.c b/src/backend/cluster/cluster_lms.c index 0015ee68d4..34e1f01dd3 100644 --- a/src/backend/cluster/cluster_lms.c +++ b/src/backend/cluster/cluster_lms.c @@ -825,6 +825,110 @@ 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"); + +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))); + + /* + * D2 idle loop. SIGTERM sets ShutdownRequestPending + MyLatch; the head + * guard breaks and proc_exit(0) completes cleanly. No cluster shmem work + * beyond the pid slot until the D3+ DATA-plane wiring lands. + */ + for (;;) { + CHECK_FOR_INTERRUPTS(); + + if (ConfigReloadPending) { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + } + + if (ShutdownRequestPending) + break; + + (void)WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + LMS_IDLE_TIMEOUT_MS, WAIT_EVENT_PG_SLEEP); + ResetLatch(MyLatch); + } + + /* Clear our pid slot before teardown. */ + 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-2.25 D4 — native-lock probe collector lifecycle (Step 5). * 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_guc.h b/src/include/cluster/cluster_guc.h index c7b6ce082f..bdaa675f33 100644 --- a/src/include/cluster/cluster_guc.h +++ b/src/include/cluster/cluster_guc.h @@ -778,6 +778,15 @@ 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; + /* * cluster.lock_acquire_cluster_path (spec-2.21 D2). * diff --git a/src/include/cluster/cluster_lms.h b/src/include/cluster/cluster_lms.h index 31b8b6a999..037f06b8db 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) */ /* @@ -257,6 +258,15 @@ typedef struct ClusterLmsSharedState { * BOOST = 11 awaits spec-2.28+ with PG core lock manager * `LockWaitQueueInsertAtHead`改造 + integrated receiver. */ pg_atomic_uint64 priority_starvation_observed_count; + + /* + * 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]; } ClusterLmsSharedState; @@ -294,6 +304,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 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/364_lms_worker_pool.pl b/src/test/cluster_tap/t/364_lms_worker_pool.pl new file mode 100644 index 0000000000..0f687157a5 --- /dev/null +++ b/src/test/cluster_tap/t/364_lms_worker_pool.pl @@ -0,0 +1,98 @@ +#!/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'); + +$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'); + +# 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_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; } From d2fc287825ec1f0a701cad5f45e386a78b9c2b96 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 17:30:28 +0800 Subject: [PATCH 04/21] feat(cluster): spec-7.3 D3 (wire) -- HELLO worker_id/n_workers fields Wire the DATA-plane worker identity into the HELLO handshake, filling the offsets reserved for spec-7.3 in the frozen 64B ABI (41 = worker_id, 42 = n_workers). cluster_ic_hello_set_worker_fields() stamps them on the send path (build_hello leaves them zero, so a CONTROL / pre-7.3 HELLO stays byte-identical to spec-7.2); cluster_ic_hello_worker_id() / cluster_ic_hello_n_workers() read them on the verify path. This is the wire substrate for the D3 shard-aligned topology + cluster-uniform n_workers negotiation; the tier1 send/verify integration (per-worker listener ports, fail-closed reject on worker_id/n_workers mismatch) lands next. test_cluster_ic +1: build-alone leaves worker fields zero (compat pin) then set + accessor roundtrip. Spec: spec-7.3-lms-worker-pool.md (D3) --- src/backend/cluster/cluster_ic.c | 17 +++++++++++ src/include/cluster/cluster_ic.h | 27 ++++++++++++++++++ src/test/cluster_unit/test_cluster_ic.c | 38 +++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 2 deletions(-) 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/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/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); From 1554db6148218c0a57c31a5ae3f85266740cbb73 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 17:49:21 +0800 Subject: [PATCH 05/21] feat(cluster): spec-7.3 D3 -- per-worker DATA tier1 shmem + mesh + HELLO gate Give each LMS DATA worker its own tier1 transport state so the per-worker mesh is correctness-clean, and enforce the shard-aligned topology + a cluster-uniform worker count on the HELLO handshake (8.A). tier1 (cluster_ic_tier1.c): - Carve one ClusterICTier1Shmem instance per slot: slot 0 = CONTROL, slot 1+c = DATA worker channel c. CONTROL and DATA worker 0 keep their spec-7.2 offsets, so lms_workers=1 is byte-identical. This de-collides peers[] (read by the send-gate) + listener metadata across worker procs. - set_my_data_channel(channel, n_workers) re-aims the Tier1Shmem alias at the worker's instance; listener/dial port become data_port + channel (the shard-aligned i<->i mesh: worker c only ever pairs with peer worker c). - HELLO send stamps (channel as worker_id, n_workers); both verify paths reject fail-closed on the DATA plane unless the peer claims MY channel and the SAME n_workers (a skew = shard misroute = order break = P0). - get_plane_misroute_reject(DATA) aggregates the worker channels. LMS wiring: - cluster_lms_data_plane_startup(worker_id, n_workers) selects the channel. - LmsWorkerMain runs its per-channel DATA mesh tick (was idle in D2); worker 0 keeps LmsMain's full loop on channel 0. Verification so far: builds + installs; single-node regression green (t/364 lms_workers=2/4/1, test_cluster_ic 22/22 incl HELLO worker fields, backend types 7/7). The 2-node e2e (worker i<->i mesh + n_workers-mismatch reject) is NOT yet run: it needs the ClusterPair harness to reserve a per-node data-port gap (spec-7.3 R3/F2) so per-worker listeners don't collide across the two same-host nodes; that harness change + the 2-node TAP land next. Spec: spec-7.3-lms-worker-pool.md (D3) --- src/backend/cluster/cluster_ic_tier1.c | 199 ++++++++++++++++--- src/backend/cluster/cluster_lms.c | 40 +++- src/backend/cluster/cluster_lms_data_plane.c | 16 +- src/include/cluster/cluster_ic_tier1.h | 22 ++ src/include/cluster/cluster_lms.h | 2 +- 5 files changed, 239 insertions(+), 40 deletions(-) diff --git a/src/backend/cluster/cluster_ic_tier1.c b/src/backend/cluster/cluster_ic_tier1.c index fc3113d743..5b9f77ba40 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; - return pg_atomic_read_u64(&Tier1ShmemByPlane[plane]->plane_misroute_reject); + + /* 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(&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", @@ -1681,6 +1807,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, diff --git a/src/backend/cluster/cluster_lms.c b/src/backend/cluster/cluster_lms.c index 34e1f01dd3..c140a4e160 100644 --- a/src/backend/cluster/cluster_lms.c +++ b/src/backend/cluster/cluster_lms.c @@ -68,6 +68,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" @@ -727,7 +728,7 @@ 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); /* Transition to READY. */ LWLockAcquire(&cluster_lms_state->lwlock, LW_EXCLUSIVE); @@ -848,6 +849,10 @@ StaticAssertDecl(LmsWorker7Process - LmsWorker1Process + 1 == CLUSTER_LMS_MAX_WO "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) { @@ -887,9 +892,22 @@ LmsWorkerMain(int worker_id) (int)MyProcPid))); /* - * D2 idle loop. SIGTERM sets ShutdownRequestPending + MyLatch; the head - * guard breaks and proc_exit(0) completes cleanly. No cluster shmem work - * beyond the pid slot until the D3+ DATA-plane wiring lands. + * 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); + + /* + * 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(); @@ -902,12 +920,18 @@ LmsWorkerMain(int worker_id) if (ShutdownRequestPending) break; - (void)WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, - LMS_IDLE_TIMEOUT_MS, WAIT_EVENT_PG_SLEEP); - ResetLatch(MyLatch); + if (cluster_lms_data_plane_enabled()) { + 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); + } } - /* Clear our pid slot before teardown. */ + /* 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); diff --git a/src/backend/cluster/cluster_lms_data_plane.c b/src/backend/cluster/cluster_lms_data_plane.c index eb21da3dff..7184f5bc9c 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) 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 037f06b8db..a2c2690481 100644 --- a/src/include/cluster/cluster_lms.h +++ b/src/include/cluster/cluster_lms.h @@ -329,7 +329,7 @@ extern pid_t cluster_lms_get_worker_pid(int worker_id); * 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); From 4a920eff2eab35624140ee14c9d3c8fb91ac807f Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 19:49:12 +0800 Subject: [PATCH 06/21] test(cluster): spec-7.3 D3 -- 2-node worker-pool mesh + HELLO gate e2e Close the D3 cross-node verification (was builds-only). - Cluster.pm: get_free_port_range(n) -- reserve a block of n consecutive free ports. The worker pool binds a listener per worker at data_port + worker_id, so a 2-node test needs each node's [data_port, data_port+workers) range free and non-overlapping (spec-7.3 R3/F2). Additive; existing callers unaffected. - ClusterPair.pm: data_port_span option (default 1 = historic single-port allocation, so every existing pair test is unchanged); span>1 uses the range. - cluster_ic_tier1.c: tag the DATA-plane "HELLO verified, state CONNECTED" logs with the worker channel so the test can prove the shard-aligned i<->i mesh formed per worker (CONTROL messages kept verbatim). - t/365_lms_worker_pool_2node.pl (13 tests, real 2-node install): * matched pool (both lms_workers=2): each node spawns worker 1 + binds its listener; the DATA mesh reaches CONNECTED on BOTH worker channels (verified on the passive side, asserted on the merged log); zero plane misroutes. * mismatch pool (node0=2, node1=3): CONTROL still forms; the DATA HELLO is refused fail-closed with "HELLO DATA worker mismatch" -- the 8.A gate that keeps the two ends' shard tables identical. D3 complete and cross-node verified (rule 8.B). Spec: spec-7.3-lms-worker-pool.md (D3) --- src/backend/cluster/cluster_ic_tier1.c | 22 ++- .../t/365_lms_worker_pool_2node.pl | 153 ++++++++++++++++++ src/test/perl/PostgreSQL/Test/Cluster.pm | 63 ++++++++ src/test/perl/PostgreSQL/Test/ClusterPair.pm | 16 +- 4 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/test/cluster_tap/t/365_lms_worker_pool_2node.pl diff --git a/src/backend/cluster/cluster_ic_tier1.c b/src/backend/cluster/cluster_ic_tier1.c index 5b9f77ba40..21c201fcdf 100644 --- a/src/backend/cluster/cluster_ic_tier1.c +++ b/src/backend/cluster/cluster_ic_tier1.c @@ -1650,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; } @@ -1850,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/test/cluster_tap/t/365_lms_worker_pool_2node.pl b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl new file mode 100644 index 0000000000..c28ec1780e --- /dev/null +++ b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl @@ -0,0 +1,153 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 365_lms_worker_pool_2node.pl +# spec-7.3 D3 -- 2-node LMS DATA-plane worker-pool topology + HELLO gate. +# +# L1 matched pool: both nodes cluster.lms_workers=2. Each node runs +# worker 0 (LmsProcess) + worker 1 (LmsWorker), each binding its own +# DATA listener (data_port + worker_id). +# L2 shard-aligned i<->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. +# 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'); + +$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; + +done_testing(); 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..0059ade9ab 100644 --- a/src/test/perl/PostgreSQL/Test/ClusterPair.pm +++ b/src/test/perl/PostgreSQL/Test/ClusterPair.pm @@ -83,8 +83,20 @@ 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. data_port_span (default 1) keeps the historic + # single-port allocation for every existing pair test. + my $data_span = $opts{data_port_span} // 1; + 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); From cde268f5a161b8a0cfa2819d7c38a73e6add44e7 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 20:19:00 +0800 Subject: [PATCH 07/21] feat(cluster): spec-7.3 D4 -- outbound ring group xN + per-tag shard routing Route each backend block request to the worker owning its tag's shard. - cluster_lms_outbound.c: single DATA outbound ring -> rings[N] (one Q6-B single-consumer FIFO + its own lock per worker; sizing = compile-time cap, live count = cluster.lms_workers). enqueue/drain/depth take worker_id; rings[0] = worker 0 (lms_workers=1 byte-identical to 7.2). - cluster_lms.c: cluster_lms_wakeup(worker_id) kills worker_pids[worker_id]; LmsMain publishes worker_pids[0] + drains rings[0]; LmsWorkerMain drains rings[worker_id]. - cluster_gcs_block.c: cluster_gcs_block_payload_shard(msg_type, payload, len, n) extracts the BufferTag from a staging-path payload (REQUEST/FORWARD/ INVALIDATE, tag pinned at offset 16 by StaticAssert) and hashes it via cluster_lms_shard_for_tag; returns -1 (8.A fail-closed) for an unroutable DATA frame. - cluster_grd_outbound.c: the DATA-plane staging entry computes the shard and enqueues to rings[shard] (refuses to stage on -1). 8.A argument (survey-verified): only REQUEST/FORWARD/INVALIDATE are staged through the ring (each tag-affine); REPLY (no tag, request_id-correlated) and INVALIDATE-ACK are sent DIRECTLY from the receiving worker's dispatch handler, which runs in the worker[shard] process (D3 mesh) so already rides the correct channel -- the INVALIDATE-ACK vs re-REQUEST per-tag FIFO (D0-# WATCH) holds because both share worker[shard(tag)]'s channel. A misrouted frame would break message order, so payload_shard fails closed rather than default. Verification: builds + installs; regression green (t/364 8/8 single-node, t/365 13/13 2-node mesh, cluster_unit no new fails). The multi-tag routing e2e (distinct tags land on distinct workers; INVALIDATE-ACK channel) lands at D9. Spec: spec-7.3-lms-worker-pool.md (D4) --- src/backend/cluster/cluster_gcs_block.c | 63 ++++++++++ src/backend/cluster/cluster_grd_outbound.c | 19 ++- src/backend/cluster/cluster_lms.c | 29 +++-- src/backend/cluster/cluster_lms_outbound.c | 137 +++++++++++++-------- src/include/cluster/cluster_gcs_block.h | 9 ++ src/include/cluster/cluster_lms.h | 19 +-- 6 files changed, 209 insertions(+), 67 deletions(-) diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index 0fc41b7d18..f41bebdae1 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -47,6 +47,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 */ @@ -4534,6 +4535,68 @@ build_and_send_reply: { } +/* + * 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); +} + + /* ============================================================ * Receiver: sender-side (D6). * 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_lms.c b/src/backend/cluster/cluster_lms.c index c140a4e160..c182454c7e 100644 --- a/src/backend/cluster/cluster_lms.c +++ b/src/backend/cluster/cluster_lms.c @@ -386,15 +386,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; @@ -709,9 +715,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); @@ -797,7 +806,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, @@ -921,6 +930,12 @@ LmsWorkerMain(int worker_id) 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, 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/include/cluster/cluster_gcs_block.h b/src/include/cluster/cluster_gcs_block.h index 2f8417a522..868fd66937 100644 --- a/src/include/cluster/cluster_gcs_block.h +++ b/src/include/cluster/cluster_gcs_block.h @@ -1956,6 +1956,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_lms.h b/src/include/cluster/cluster_lms.h index a2c2690481..5bd3c59af1 100644 --- a/src/include/cluster/cluster_lms.h +++ b/src/include/cluster/cluster_lms.h @@ -335,17 +335,20 @@ 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. From cec42628b2ca1e90700b9395ca3fa42cb0661ad3 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 22:03:32 +0800 Subject: [PATCH 08/21] test(cluster): spec-7.3 -- reserve per-worker DATA port span in old gcs multi-node TAP The LMS worker pool binds a DATA listener per worker at data_port + worker_id (D3), and cluster.lms_workers defaults to 2, so each node now needs a 2-port DATA range. The pre-worker-pool 2-node gcs TAP tests requested only a single DATA port (data_port_span default 1), so node 0's worker-1 port collided with node 1's base ("bind 127.0.0.1:

failed: Address already in use"). Reserve data_port_span => 2 on each, matching t/365. - t/111, t/112, t/113, t/114, t/116, t/252: data_port_span => 2 t/115 (3-node ClusterTriple) has the same latent collision but ClusterTriple lacks a data_port_span option; deferred to a follow-up harness addition. Spec: spec-7.3-lms-worker-pool.md (D3/D5) --- src/test/cluster_tap/t/111_gcs_block_ship_2node.pl | 1 + src/test/cluster_tap/t/112_gcs_block_retransmit_2node.pl | 4 ++++ src/test/cluster_tap/t/113_gcs_block_2way_2node.pl | 1 + src/test/cluster_tap/t/114_gcs_block_3way_2node.pl | 1 + src/test/cluster_tap/t/116_gcs_block_lost_write_2node.pl | 1 + src/test/cluster_tap/t/252_gcs_block_coherence.pl | 1 + 6 files changed, 9 insertions(+) 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 323f16e364..1fac3579aa 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 @@ -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; 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 95ad463b15..5aa236ebd2 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 @@ -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/365). 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; 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 c04dc7dcb2..422e739673 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 @@ -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; 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 9319413f6a..56075b62cc 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 @@ -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; 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 b3fbdf32e3..977aa156b9 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 @@ -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', 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 From af088bfd95e369a25a733d495f6ef9eed7e8397f Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 22:03:51 +0800 Subject: [PATCH 09/21] feat(cluster): spec-7.3 D5 -- per-worker sharded GCS block dedup HTAB Shard the master-side GCS block dedup table into per-worker private instances so the LMS worker pool never contends on one lock and never shares dedup state across workers. - cluster_gcs_block_dedup.c/.h: the single ClusterGcsBlockDedupShared (HTAB + lock + 5 counters) becomes dedup_shards[CLUSTER_LMS_MAX_WORKERS], each with its own HTAB + lock + counters, plus an always-present ctl header holding the live shard count and a misroute fail-closed counter. Hot-path APIs (lookup_or_register / install_reply[_ex] / remove) take an explicit worker_id = the serving worker's DATA channel; a worker_id outside [0, live shards) is a mis-route -> FULL + misroute_failclosed_count++ (never a wrong-shard serve). GC (TTL sweep / node-dead / backend-exit) runs in non-worker processes and iterates every shard; counter accessors sum across shards. Shard 0 keeps the spec-2.34 shmem/HTAB names so lms_workers=1 is a byte-identical topology. Sizing scales with cluster.lms_workers; per-shard capacity is unchanged so dedup_full_count does not regress. - cluster_gcs_block.c: the master REQUEST handler computes shard(req->tag) and asserts it equals its own DATA channel before touching the shard; on a mismatch it fails closed (counter + LOG-once + drop; sender retransmits via 53R90) rather than serve from a shard it does not own. All 5 dedup calls in the handler pass the worker id. Correctness: request_id is stably bound to one tag, so all of a request's messages route to worker[shard(tag)] and its dedup entry lives in exactly one shard; sharding by shard(tag) preserves dedup semantics and a mis-route fails closed in-spec (8.A). Scope: only the DATA-plane master-side dedup HTAB is sharded. The GES retransmit dedup (cluster_ges_dedup) is CONTROL plane and stays on worker 0; the requester-side outstanding-reply slot table is per-backend, not an LMS table. Test: test_cluster_gcs_block_dedup.c -- 9 behavioral tests over an N-shard fake HTAB (per-worker isolation, dedup lifecycle, cross-shard counter sum, cross- shard GC, out-of-range fail-closed, N=1), verified to fail when sharding is disabled. Regression: t/112 (dedup 2-node), t/364/t/365 (worker pool), t/020 (shmem registry) green at default cluster.lms_workers=2. Multi-tag dispatch + injection-forced misroute e2e land at D9. Spec: spec-7.3-lms-worker-pool.md (D5) --- src/backend/cluster/cluster_gcs_block.c | 53 +- src/backend/cluster/cluster_gcs_block_dedup.c | 433 +++++++++---- src/include/cluster/cluster_gcs_block_dedup.h | 45 +- src/test/cluster_unit/Makefile | 17 +- .../test_cluster_gcs_block_dedup.c | 582 ++++++++++++++++++ 5 files changed, 988 insertions(+), 142 deletions(-) create mode 100644 src/test/cluster_unit/test_cluster_gcs_block_dedup.c diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index f41bebdae1..abc9eb7fa1 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -64,6 +64,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" @@ -3397,6 +3398,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; @@ -3462,6 +3464,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; @@ -3470,8 +3503,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); @@ -3927,8 +3960,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); @@ -4269,8 +4303,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. */ @@ -4340,8 +4375,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; } @@ -4463,7 +4498,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); diff --git a/src/backend/cluster/cluster_gcs_block_dedup.c b/src/backend/cluster/cluster_gcs_block_dedup.c index ee2f2bd837..4c4e2b92e8 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) * @@ -39,6 +50,7 @@ #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" @@ -51,20 +63,32 @@ /* ============================================================ - * 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_uint32 entry_count; /* live in-flight + completed entries */ -} ClusterGcsBlockDedupShared; +} 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 */ +} ClusterGcsBlockDedupCtl; -static ClusterGcsBlockDedupShared *cluster_gcs_block_dedup_shared = NULL; -static HTAB *cluster_gcs_block_dedup_htab = NULL; +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; @@ -83,6 +107,37 @@ cluster_gcs_block_dedup_effective_entries(void) return cluster_gcs_block_dedup_max_entries > 0 ? cluster_gcs_block_dedup_max_entries : 1024; } +/* + * 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; + + 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; +} + /* ============================================================ * Shmem registry. @@ -93,13 +148,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; } @@ -109,31 +166,57 @@ 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_u32(&cluster_gcs_block_dedup_shared->entry_count, 0); + pg_atomic_init_u64(&cluster_gcs_block_dedup_ctl->misroute_failclosed_count, 0); + cluster_gcs_block_dedup_ctl->n_shards = n; + + 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_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 = { @@ -152,6 +235,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). @@ -184,10 +293,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; @@ -196,19 +307,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; } @@ -225,29 +337,28 @@ 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) { - 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; } @@ -260,19 +371,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; @@ -280,17 +394,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; } @@ -320,35 +435,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. * ============================================================ */ /* @@ -384,131 +509,195 @@ dedup_expiry_threshold_us(void) 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(); - 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) { - TimestampTz anchor; - int64 age_us; + 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; - 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); + if (removed > 0) + pg_atomic_fetch_sub_u32(&shard->entry_count, (uint32)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) + 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_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/include/cluster/cluster_gcs_block_dedup.h b/src/include/cluster/cluster_gcs_block_dedup.h index 5abb7f815e..9ff061d69d 100644 --- a/src/include/cluster/cluster_gcs_block_dedup.h +++ b/src/include/cluster/cluster_gcs_block_dedup.h @@ -202,10 +202,19 @@ typedef enum GcsBlockDedupResult { * 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); /* @@ -223,17 +232,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); /* ============================================================ @@ -281,6 +293,21 @@ extern uint64 cluster_gcs_block_dedup_get_collision_count(void); extern uint64 cluster_gcs_block_dedup_get_full_count(void); extern uint64 cluster_gcs_block_dedup_get_in_flight_count(void); +/* + * 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/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index 1d7417a669..bba8fb0856 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -89,7 +89,8 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_node_remove \ test_cluster_hang_acceptance \ test_cluster_xid_stripe \ - test_cluster_lms_shard + test_cluster_lms_shard \ + test_cluster_gcs_block_dedup # Path to the cluster_version object (no PG deps, safe to link standalone). CLUSTER_VERSION_O = $(top_builddir)/src/backend/cluster/cluster_version.o @@ -176,6 +177,18 @@ test_cluster_lms_shard: test_cluster_lms_shard.c unit_test.h $(CLUSTER_VERSION_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-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. @@ -192,7 +205,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_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority test_cluster_lms_shard,$(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_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority test_cluster_lms_shard test_cluster_gcs_block_dedup,$(TESTS)) # spec-2.4 D16: test_cluster_epoch links cluster_epoch.o standalone. # cluster_epoch.c references ShmemInitStruct + cluster_shmem_register_region 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..c4ee29f2b3 --- /dev/null +++ b/src/test/cluster_unit/test_cluster_gcs_block_dedup.c @@ -0,0 +1,582 @@ +/*------------------------------------------------------------------------- + * + * 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 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; +} + +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; +} From 7e400e811718e1d68b89b1175abd132a7362800b Mon Sep 17 00:00:00 2001 From: SqlRush Date: Thu, 9 Jul 2026 23:05:44 +0800 Subject: [PATCH 10/21] feat(cluster): spec-7.3 D6 -- inline CR/undo serve on the DATA plane (park kept for control) When the GCS block family is on the DATA plane, the worker[shard] that receives a GCS_BLOCK_FORWARD CR / undo-fetch / undo-verdict request now serves it INLINE and ships on its own channel -- retiring the spec-6.12b park -> LMS-poll -> LMON-ship indirection there (no worker-0 handoff, no 100 ms latency; CR construction parallelizes across workers by shard). The park-serve path is RETAINED for the CONTROL-plane fallback: a node with the data plane off (no data_addr) still dispatches the FORWARD in LMON, whose tight IC dispatch loop must not walk undo I/O (the light-work rule), so it parks for LMS worker 0. (The requester gates the CR fetch on the crossnode_cr_data_plane GUC, not on the data plane being up, so the control-plane path is reachable and must stay.) - cluster_cr_server.c: extract cr_serve_slot() (the PG_TRY -> DENIED serve envelope, verbatim from the drain) and cr_build_and_send_reply() (the reply build) as shared helpers; the park drain + ship_ready and the new inline serve both call them. Add cluster_gcs_block_forward_serve_inline(fwd, kind): populate a stack carrier from the payload, serve through cr_serve_slot() inside a per-call scratch MemoryContext (reset on return -- the DATA dispatch loop is long-lived), then ship exactly one reply (DENIED on any refusal / wave-GUC-off / malformed request, Rule 8.A). - cluster_gcs_block.c: the forward handler routes CR/undo/verdict to the inline serve when cluster_gcs_block_family_on_data_plane(), else to the light-work park submit. 8.A: the construction (cluster_cr.c) re-throws on any uncertainty; cr_serve_slot's PG_TRY -> FlushErrorState -> DENIED envelope (shared, unchanged) converts it to a fail-closed DENIED, never a worker exit or a wrong-order construction. Test: t/354 adds L6 -- the cluster-lms-cr-construct fail-closed injection keeps the requester at 53R9G, leaves the origin's serving worker alive, and recovers once cleared. t/354 + t/346 reserve data_port_span=2 (the D3 per-worker port contract). Regression green: t/354 (21), t/346 (undo/verdict), t/113, t/364, t/020 (161 injections + region list unchanged), cluster_unit (cr_server_policy 7/7). The control-plane park path is unchanged (shared helpers validated by the inline path); its data-plane-off e2e is a follow-up. Spec: spec-7.3-lms-worker-pool.md (D6) --- src/backend/cluster/cluster_cr_server.c | 465 +++++++++++------- src/backend/cluster/cluster_gcs_block.c | 62 ++- src/include/cluster/cluster_cr_server.h | 15 + .../t/346_cluster_6_12i_runtime_visibility.pl | 1 + .../t/354_cluster_6_12b_cr_server.pl | 31 ++ 5 files changed, 374 insertions(+), 200 deletions(-) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index eb431793dc..367ebd80f6 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -1,23 +1,29 @@ /*------------------------------------------------------------------------- * * 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 (LMON: FREE→PENDING, READY→FREE; LMS: PENDING→BUSY→READY), @@ -35,7 +41,8 @@ * 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) * *------------------------------------------------------------------------- */ @@ -71,7 +78,8 @@ * 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 */ @@ -138,12 +146,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) @@ -182,13 +189,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) @@ -236,7 +243,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 @@ -297,7 +304,7 @@ cluster_lms_undo_verdict_submit(const GcsBlockForwardPayload *fwd) } /* - * lms_undo_fetch_serve — LMS side of one KIND_UNDO_FETCH slot (spec-6.12i). + * lms_undo_fetch_serve — serve one KIND_UNDO_FETCH request (spec-6.12i). * * Samples the live authority triple FIRST, then reads the block: the * watermark is then conservative relative to the shipped content (every @@ -341,8 +348,8 @@ lms_undo_fetch_serve(ClusterLmsCrSlot *slot) } /* - * lms_undo_verdict_serve — LMS side of one KIND_UNDO_VERDICT slot - * (spec-6.12i D-i4 / spec-6.15 D4). + * lms_undo_verdict_serve — serve one KIND_UNDO_VERDICT request (spec-6.12i + * D-i4 / spec-6.15 D4). * * The complete-scan verdict: the requester's single fetched TT block came * back 0-match, which proves nothing (the xid's slot may live in another @@ -383,9 +390,9 @@ lms_undo_fetch_serve(ClusterLmsCrSlot *slot) * * true = result_page holds the ClusterGcsUndoVerdictPage and * slot->undo_auth the co-sampled triple; false = refuse (caller ships - * DENIED — requester keeps 53R97). Runs under the drain's PG_TRY: a CLOG + * DENIED — requester keeps 53R97). Runs under the serve PG_TRY: a CLOG * page truncated under an old xid, an unreadable segment, or any other - * throw becomes a refusal, never an LMS exit. + * throw becomes a refusal, never a worker/LMS exit. */ static bool lms_undo_verdict_serve(ClusterLmsCrSlot *slot) @@ -467,89 +474,70 @@ lms_undo_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) + 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) { + bool is_verdict = (slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT); + bool served = false; + + CLUSTER_INJECTION_POINT("cluster-lms-undo-fetch"); + if (!cluster_injection_should_skip("cluster-lms-undo-fetch")) { + PG_TRY(); + { + served = is_verdict ? lms_undo_verdict_serve(slot) : lms_undo_fetch_serve(slot); + } + PG_CATCH(); + { + /* Fail-closed serve; keep the worker/LMS alive. */ + served = false; + MemoryContextSwitchTo(TopMemoryContext); + FlushErrorState(); + } + PG_END_TRY(); + } + + if (served) { + slot->result_status = (uint8)(is_verdict ? GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT + : GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT); + cluster_cr_server_stat_bump(is_verdict ? CLUSTER_CR_SERVER_STAT_VERDICT_SERVED + : CLUSTER_CR_SERVER_STAT_UNDO_SERVED); + } else { + cluster_cr_server_stat_bump(is_verdict ? CLUSTER_CR_SERVER_STAT_VERDICT_DENIED + : CLUSTER_CR_SERVER_STAT_UNDO_DENIED); + } return; + } - for (int i = 0; i < CLUSTER_LMS_CR_SLOTS; i++) { - ClusterLmsCrSlot *slot = &CrServerShared->slots[i]; - uint32 expected = CLUSTER_LMS_CR_PENDING; + /* spec-6.12b CR construction. */ + { 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 both kinds: it models "the origin's - * undo serve plane is down", which refuses 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) { - bool is_verdict = (slot->req_kind == (uint8)CLUSTER_LMS_SLOT_KIND_UNDO_VERDICT); - bool served = false; - - CLUSTER_INJECTION_POINT("cluster-lms-undo-fetch"); - if (!cluster_injection_should_skip("cluster-lms-undo-fetch")) { - PG_TRY(); - { - served = is_verdict ? lms_undo_verdict_serve(slot) : lms_undo_fetch_serve(slot); - } - PG_CATCH(); - { - /* Fail-closed serve; keep LMS alive (as the CR kind). */ - served = false; - MemoryContextSwitchTo(TopMemoryContext); - FlushErrorState(); - } - PG_END_TRY(); - } - - if (served) { - slot->result_status = (uint8)(is_verdict ? GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT - : GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT); - cluster_cr_server_stat_bump(is_verdict ? CLUSTER_CR_SERVER_STAT_VERDICT_SERVED - : CLUSTER_CR_SERVER_STAT_UNDO_SERVED); - } else { - cluster_cr_server_stat_bump(is_verdict ? CLUSTER_CR_SERVER_STAT_VERDICT_DENIED - : CLUSTER_CR_SERVER_STAT_UNDO_DENIED); - } - - 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; - } - /* spec-6.12b injection — force the DENIED serve path. */ CLUSTER_INJECTION_POINT("cluster-lms-cr-construct"); if (!cluster_injection_should_skip("cluster-lms-cr-construct") @@ -564,10 +552,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); @@ -584,23 +572,105 @@ 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 (slot->result_status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT + || slot->result_status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) + 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 (hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT + || hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) { + 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); + } + 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) @@ -610,65 +680,114 @@ 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 (slot->result_status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT - || slot->result_status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) - 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 (hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_TT_FETCH_RESULT - || hdr->status == (uint8)GCS_BLOCK_REPLY_UNDO_VERDICT_RESULT) { - 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); - } - 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); + 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; + + if (fwd == NULL) + return; + + /* 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; + + 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()); + cr_serve_slot(&slot); + /* A caught construction throw left CurrentMemoryContext at TopMemoryContext; + * normalize back to the scratch context before the reply build. */ + MemoryContextSwitchTo(cr_serve_scratch_context()); + cr_build_and_send_reply(&slot); + MemoryContextSwitchTo(old); + MemoryContextReset(CrServeScratchCtx); +} + #endif /* USE_PGRAC_CLUSTER */ diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index abc9eb7fa1..2e463eea80 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -5205,49 +5205,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; } diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index 6ac15e08c7..3a078f2ac6 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -188,6 +188,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/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 29625605d5..b618548531 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 => [ 'autovacuum = off', 'cluster.ges_request_timeout_ms = 30000', 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..61b2811036 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 @@ -109,6 +109,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 +225,35 @@ sub cr_image_retry is($rows, '6', 'L5 cr-server keys present (' . $n->name . ')'); } +# ============================================================ +# 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) +# 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. +# ============================================================ +SKIP: +{ + skip "ClusterPair inject SKIP helper missing — L6 8.A envelope covered by " + . "the shared PG_TRY drain path", 3 + unless $pair->can('inject_skip_set'); + + $pair->inject_skip_set($node0, 'cluster-lms-cr-construct', 100); + my ($off_d6, $off_err6) = cr_image($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'); + + $pair->inject_skip_set($node0, 'cluster-lms-cr-construct', 0); + 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") . ')'); +} + $pair->stop_pair if $pair->can('stop_pair'); done_testing(); From 672558ec8143d68bb7710282d239923674590085 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 08:28:24 +0800 Subject: [PATCH 11/21] =?UTF-8?q?feat(cluster):=20spec-7.3=20D7=20--=20fen?= =?UTF-8?q?ce=20=C3=97N=20+=20crash/reconfig=20semantics=20(LMS=20worker?= =?UTF-8?q?=20pool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fence ×N: cluster_gcs_block_forward_serve_inline gains a write-fence gate ahead of the kind switch / any construct/read, so a write-fenced node refuses (immediate DENIED) to ship a CR image / undo bytes / commit verdict on the DATA plane -- restoring the parity the worker-0 park ship path keeps that D6's inline move dropped (8.A: split-brain read / false-committed verdict). New cr_server_fence_refused_count + dump row + cluster-lms-cr-fence-refuse injection for deterministic TAP. - epoch-reset ×N: per-worker DATA-mesh reset LOG; each worker observes the shared epoch bump in its own process (the tier1 sender gate is the backstop). - graceful per-worker recycle (Path A): SIGTERM one DATA worker respawns just that slot with no node crash cascade; a hard SIGKILL cascades the whole node (PG aux crash model), recovery blocked on the orthogonal F1 wall (t/360 L4). - tests: t/354 L7 (CR fence), t/346 L4c (undo/verdict fence), t/365 L5 (epoch ×N reset) + L6 (graceful recycle isolation). - port fix (pre-existing lms_workers=2 default): data_port_span=2 for the ClusterPair 2-node CR tests D5 missed (t/215/216/360); t/360 L5.3 -> one-shot per worker. Baselines: injection registry 161->162, cr category 57->58. Spec: spec-7.3-lms-worker-pool.md (D7) --- src/backend/cluster/cluster_cr.c | 6 ++ src/backend/cluster/cluster_cr_server.c | 30 +++++- src/backend/cluster/cluster_debug.c | 4 + src/backend/cluster/cluster_inject.c | 11 +++ src/backend/cluster/cluster_lms_data_plane.c | 18 ++++ src/include/cluster/cluster_cr.h | 1 + src/include/cluster/cluster_cr_server.h | 3 +- src/test/cluster_tap/t/015_inject.pl | 8 +- src/test/cluster_tap/t/020_shmem_registry.pl | 4 +- .../t/215_cluster_3_9_cr_construction.pl | 5 +- .../t/216_cluster_3_10_cr_cache.pl | 5 +- .../t/300_cluster_5_50_cr_profile.pl | 4 +- .../t/346_cluster_6_12i_runtime_visibility.pl | 44 +++++++++ .../t/354_cluster_6_12b_cr_server.pl | 46 +++++++++ .../t/360_lms_data_plane_faults.pl | 36 +++++-- .../t/365_lms_worker_pool_2node.pl | 97 +++++++++++++++++++ src/test/cluster_unit/test_cluster_debug.c | 5 + 17 files changed, 303 insertions(+), 24 deletions(-) diff --git a/src/backend/cluster/cluster_cr.c b/src/backend/cluster/cluster_cr.c index 1f2b8ed1af..2a1740cc0f 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-6.15 D4: a recycled remote ref whose tuple xid the stripe * derivation could not attribute (striping off / below the activation @@ -250,6 +251,7 @@ cluster_cr_shmem_init(void) pg_atomic_init_u64(&CRShared->rtvis_verdict_inadmissible_count, 0); pg_atomic_init_u64(&CRShared->cr_server_verdict_served_count, 0); pg_atomic_init_u64(&CRShared->cr_server_verdict_denied_count, 0); + pg_atomic_init_u64(&CRShared->cr_server_fence_refused_count, 0); pg_atomic_init_u64(&CRShared->rtvis_underivable_failclosed_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_resolved_count, 0); pg_atomic_init_u64(&CRShared->cr_xmax_recycled_invisible_count, 0); @@ -319,6 +321,9 @@ cluster_cr_server_stat_bump(ClusterCrServerStat which) case CLUSTER_CR_SERVER_STAT_VERDICT_DENIED: pg_atomic_fetch_add_u64(&CRShared->cr_server_verdict_denied_count, 1); break; + case CLUSTER_CR_SERVER_STAT_FENCE_REFUSED: + pg_atomic_fetch_add_u64(&CRShared->cr_server_fence_refused_count, 1); + break; } } @@ -447,6 +452,7 @@ CR_COUNTER_ACCESSOR(cluster_rtvis_verdict_below_horizon_count, rtvis_verdict_bel CR_COUNTER_ACCESSOR(cluster_rtvis_verdict_inadmissible_count, rtvis_verdict_inadmissible_count) CR_COUNTER_ACCESSOR(cluster_cr_server_verdict_served_count, cr_server_verdict_served_count) CR_COUNTER_ACCESSOR(cluster_cr_server_verdict_denied_count, cr_server_verdict_denied_count) +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) /* spec-3.22 D3: xmax recycled-slot resolve outcome buckets. */ CR_COUNTER_ACCESSOR(cluster_cr_xmax_resolved_count, cr_xmax_resolved_count) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index 367ebd80f6..ff5e29e9a2 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -42,7 +42,8 @@ * This is a pgrac-original file. Compiled only in --enable-cluster * builds. * 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) + * 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) * *------------------------------------------------------------------------- */ @@ -66,6 +67,7 @@ #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_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" @@ -749,6 +751,32 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste 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"); + if ((cluster_write_fence_enforcing() && !cluster_write_fence_allowed()) + || cluster_injection_should_skip("cluster-lms-cr-fence-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); + return; + } + switch (kind) { case CLUSTER_LMS_SLOT_KIND_CR: slot.read_scn = GcsBlockForwardPayloadGetExpectedPiWatermarkScn(fwd); diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 70a75026c5..d9cb269dc1 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2628,6 +2628,10 @@ dump_cr(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_cr_server_verdict_served_count())); emit_row(rsinfo, "cr", "cr_server_verdict_denied_count", fmt_int64((int64)cluster_cr_server_verdict_denied_count())); + /* spec-7.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-3.22 D3: xmax recycled-slot resolve outcome buckets. */ diff --git a/src/backend/cluster/cluster_inject.c b/src/backend/cluster/cluster_inject.c index e63f85de93..32c3608467 100644 --- a/src/backend/cluster/cluster_inject.c +++ b/src/backend/cluster/cluster_inject.c @@ -359,6 +359,17 @@ 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.2 D6 — LMS data-plane observability injections. * diff --git a/src/backend/cluster/cluster_lms_data_plane.c b/src/backend/cluster/cluster_lms_data_plane.c index 7184f5bc9c..30bda22d0c 100644 --- a/src/backend/cluster/cluster_lms_data_plane.c +++ b/src/backend/cluster/cluster_lms_data_plane.c @@ -292,6 +292,24 @@ cluster_lms_data_plane_tick(long timeout_ms) if (inject_reset && n_closed > 0) dp_inject_reset_done = true; + /* + * 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) + 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/include/cluster/cluster_cr.h b/src/include/cluster/cluster_cr.h index 0f4f7b9293..98a251af24 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 void cluster_rtvis_verdict_note_wire(void); extern void cluster_rtvis_verdict_note_failclosed(void); extern void cluster_rtvis_verdict_note_exact(void); diff --git a/src/include/cluster/cluster_cr_server.h b/src/include/cluster/cluster_cr_server.h index 3a078f2ac6..98b382862e 100644 --- a/src/include/cluster/cluster_cr_server.h +++ b/src/include/cluster/cluster_cr_server.h @@ -144,7 +144,8 @@ typedef enum ClusterCrServerStat { CLUSTER_CR_SERVER_STAT_UNDO_SERVED = 3, /* spec-6.12i D-i1 origin serve */ CLUSTER_CR_SERVER_STAT_UNDO_DENIED = 4, /* spec-6.12i D-i1 origin refuse */ CLUSTER_CR_SERVER_STAT_VERDICT_SERVED = 5, /* spec-6.12i D-i4 verdict serve */ - CLUSTER_CR_SERVER_STAT_VERDICT_DENIED = 6 /* spec-6.12i D-i4 verdict refuse */ + CLUSTER_CR_SERVER_STAT_VERDICT_DENIED = 6, /* spec-6.12i D-i4 verdict refuse */ + CLUSTER_CR_SERVER_STAT_FENCE_REFUSED = 7 /* spec-7.3 D7 write-fenced -> refuse ship */ } ClusterCrServerStat; extern void cluster_cr_server_stat_bump(ClusterCrServerStat which); diff --git a/src/test/cluster_tap/t/015_inject.pl b/src/test/cluster_tap/t/015_inject.pl index d749f086da..e96b12ce42 100644 --- a/src/test/cluster_tap/t/015_inject.pl +++ b/src/test/cluster_tap/t/015_inject.pl @@ -57,8 +57,8 @@ # ---------- is( $node->safe_psql('postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'pg_stat_cluster_injections returns 161 rows (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.18 +7 cluster-node-remove-*; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 cluster-clean-leave-survivor-suppress-preflight-ack; spec-5.53 +1 cluster-cr-skip-epoch-bump; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.8 +1 cluster-lmd-force-partial-round; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'pg_stat_cluster_injections returns 162 rows (spec-7.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-node-remove-cleanup-done,cluster-node-remove-escalate,cluster-node-remove-fence-armed,cluster-node-remove-precheck,cluster-node-remove-request,cluster-node-remove-shrink-committed,cluster-node-remove-shrink-committing,cluster-pcm-acquire-entry,cluster-pcm-convert-pre,cluster-pcm-downgrade-pre,cluster-pcm-release-pre,cluster-pgstat-mirror-sync,cluster-quorum-loss-broadcast,cluster-qvotec-marker-service-hold,cluster-qvotec-poll-post,cluster-qvotec-poll-pre,cluster-reconfig-broadcast-procsig-pre,cluster-reconfig-decide-coordinator,cluster-reconfig-epoch-bump-pre,cluster-reconfig-join-commit-marker-durable,cluster-reconfig-tick-entry,cluster-recovery-anchor-force-failclosed,cluster-relmap-crash-after-stage,cluster-relmap-crash-before-publish,cluster-run-shutdown-top,cluster-run-startup-top,cluster-scn-abort-post-advance,cluster-scn-abort-pre-advance,cluster-scn-advance-post,cluster-scn-advance-pre,cluster-scn-boc-sweep-post,cluster-scn-boc-sweep-pre,cluster-scn-commit-post-advance,cluster-scn-commit-pre-advance,cluster-scn-observe-bump-pre,cluster-scn-observe-entry,cluster-scn-replay-observe-pre,cluster-scn-wal-write-pre,cluster-scn-wraparound-warning,cluster-shared-fs-backend-register,cluster-shared-fs-init-top,cluster-shared-fs-local-open,cluster-shmem-region-init-post,cluster-shmem-region-init-pre,cluster-shmem-register-region,cluster-shmem-request,cluster-shmem-views-srf-entry,cluster-shutdown-top,cluster-sinval-ack-drop-send,cluster-sinval-ack-skip-validate,cluster-sinval-broadcast-drop-send,cluster-sinval-receive-skip-validate,cluster-smgr-create-top,cluster-smgr-open-top,cluster-smgr-which-decision,cluster-startup-phase-0-enter,cluster-startup-phase-0-exit,cluster-startup-phase-0-fail,cluster-startup-phase-1-enter,cluster-startup-phase-1-exit,cluster-startup-phase-1-fail,cluster-startup-phase-2-enter,cluster-startup-phase-2-exit,cluster-startup-phase-2-fail,cluster-startup-phase-3-enter,cluster-startup-phase-3-exit,cluster-startup-phase-3-fail,cluster-startup-phase-4-enter,cluster-startup-phase-4-exit,cluster-startup-phase-4-fail,cluster-stats-main-loop-iter,cluster-stats-post-spawn,cluster-stats-pre-spawn,cluster-stats-ready-publish,cluster-stats-shutdown-post,cluster-stats-shutdown-pre,cluster-thread-recovery-drive,cluster-views-srf-entry,cluster-voting-disk-write-fail,cluster-wal-page-init-thread-id,cluster-wal-state-ensure-pre,cluster-wal-state-write-fail,cluster-wal-thread-claim-create-fail,cluster-wal-thread-validate-pre,cluster-xid-herding-stall,cluster-xid-window-hard-limit,cr_construct_delay_us,cr_corruption,cr_cross_instance,cr_force_read_scn,cr_snapshot_too_old,undo-force-wal-before-data-violation,undo-skip-checkpoint-flush-one', - '161 injection point names match the full registry (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 cluster-clean-leave-survivor-suppress-preflight-ack; spec-5.53 +1 cluster-cr-skip-epoch-bump; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.8 +1 cluster-lmd-force-partial-round; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + 'cluster-catalog-services-ready-force-closed,cluster-clean-leave-barrier-complete,cluster-clean-leave-escalate-to-failstop,cluster-clean-leave-gcs-flushed,cluster-clean-leave-ges-drained,cluster-clean-leave-quiesce-pre,cluster-clean-leave-request,cluster-clean-leave-survivor-suppress-preflight-ack,cluster-clean-xfer-stale-holder,cluster-collision-detect,cluster-conf-load-success,cluster-conf-parse-fail,cluster-conf-shmem-init,cluster-cr-resolver-memo-suspect,cluster-cr-skip-epoch-bump,cluster-cssd-main-loop-pre-tick,cluster-cssd-mark-peer-dead,cluster-cssd-post-spawn,cluster-cssd-pre-spawn,cluster-cssd-ready-publish,cluster-cssd-shutdown-post,cluster-cssd-shutdown-pre,cluster-debug-dump-entry,cluster-diag-main-loop-iter,cluster-diag-post-spawn,cluster-diag-pre-spawn,cluster-diag-ready-publish,cluster-diag-shutdown-post,cluster-diag-shutdown-pre,cluster-fence-post-thaw-broadcast,cluster-fence-pre-freeze-broadcast,cluster-fence-pre-self-fence-shutdown,cluster-gcs-block-bast-nudge,cluster-gcs-block-drop-reply-before-send,cluster-gcs-block-evict-holder-before-ship,cluster-gcs-block-force-epoch-stale-reply,cluster-gcs-block-forward-master-side,cluster-gcs-block-invalidate-drop-broadcast,cluster-gcs-block-invalidate-stall-ack,cluster-gcs-block-remote-downgrade,cluster-gcs-block-stale-ship,cluster-gcs-block-starvation-force-denied,cluster-gcs-block-x-forward-master-side,cluster-grd-redeclare-skip,cluster-guc-init-pre-define,cluster-ic-mock-send-pre-enqueue,cluster-ic-tier-selected,cluster-init-post-shmem,cluster-init-pre-shmem,cluster-init-top,cluster-ko-peer-skip-ack,cluster-lck-main-loop-iter,cluster-lck-post-spawn,cluster-lck-pre-spawn,cluster-lck-ready-publish,cluster-lck-shutdown-post,cluster-lck-shutdown-pre,cluster-lmd-force-partial-round,cluster-lmon-main-loop-iter,cluster-lmon-post-spawn,cluster-lmon-pre-spawn,cluster-lmon-ready-publish,cluster-lmon-shutdown-post,cluster-lmon-shutdown-pre,cluster-lms-conn-reset,cluster-lms-cr-construct,cluster-lms-cr-fence-refuse,cluster-lms-data-dispatch,cluster-lms-undo-fetch,cluster-node-remove-cleanup-done,cluster-node-remove-escalate,cluster-node-remove-fence-armed,cluster-node-remove-precheck,cluster-node-remove-request,cluster-node-remove-shrink-committed,cluster-node-remove-shrink-committing,cluster-pcm-acquire-entry,cluster-pcm-convert-pre,cluster-pcm-downgrade-pre,cluster-pcm-release-pre,cluster-pgstat-mirror-sync,cluster-quorum-loss-broadcast,cluster-qvotec-marker-service-hold,cluster-qvotec-poll-post,cluster-qvotec-poll-pre,cluster-reconfig-broadcast-procsig-pre,cluster-reconfig-decide-coordinator,cluster-reconfig-epoch-bump-pre,cluster-reconfig-join-commit-marker-durable,cluster-reconfig-tick-entry,cluster-recovery-anchor-force-failclosed,cluster-relmap-crash-after-stage,cluster-relmap-crash-before-publish,cluster-run-shutdown-top,cluster-run-startup-top,cluster-scn-abort-post-advance,cluster-scn-abort-pre-advance,cluster-scn-advance-post,cluster-scn-advance-pre,cluster-scn-boc-sweep-post,cluster-scn-boc-sweep-pre,cluster-scn-commit-post-advance,cluster-scn-commit-pre-advance,cluster-scn-observe-bump-pre,cluster-scn-observe-entry,cluster-scn-replay-observe-pre,cluster-scn-wal-write-pre,cluster-scn-wraparound-warning,cluster-shared-fs-backend-register,cluster-shared-fs-init-top,cluster-shared-fs-local-open,cluster-shmem-region-init-post,cluster-shmem-region-init-pre,cluster-shmem-register-region,cluster-shmem-request,cluster-shmem-views-srf-entry,cluster-shutdown-top,cluster-sinval-ack-drop-send,cluster-sinval-ack-skip-validate,cluster-sinval-broadcast-drop-send,cluster-sinval-receive-skip-validate,cluster-smgr-create-top,cluster-smgr-open-top,cluster-smgr-which-decision,cluster-startup-phase-0-enter,cluster-startup-phase-0-exit,cluster-startup-phase-0-fail,cluster-startup-phase-1-enter,cluster-startup-phase-1-exit,cluster-startup-phase-1-fail,cluster-startup-phase-2-enter,cluster-startup-phase-2-exit,cluster-startup-phase-2-fail,cluster-startup-phase-3-enter,cluster-startup-phase-3-exit,cluster-startup-phase-3-fail,cluster-startup-phase-4-enter,cluster-startup-phase-4-exit,cluster-startup-phase-4-fail,cluster-stats-main-loop-iter,cluster-stats-post-spawn,cluster-stats-pre-spawn,cluster-stats-ready-publish,cluster-stats-shutdown-post,cluster-stats-shutdown-pre,cluster-thread-recovery-drive,cluster-views-srf-entry,cluster-voting-disk-write-fail,cluster-wal-page-init-thread-id,cluster-wal-state-ensure-pre,cluster-wal-state-write-fail,cluster-wal-thread-claim-create-fail,cluster-wal-thread-validate-pre,cluster-xid-herding-stall,cluster-xid-window-hard-limit,cr_construct_delay_us,cr_corruption,cr_cross_instance,cr_force_read_scn,cr_snapshot_too_old,undo-force-wal-before-data-violation,undo-skip-checkpoint-flush-one', + '162 injection point names match the full registry (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/020_shmem_registry.pl b/src/test/cluster_tap/t/020_shmem_registry.pl index 76e50ecdba..b8c2a51309 100644 --- a/src/test/cluster_tap/t/020_shmem_registry.pl +++ b/src/test/cluster_tap/t/020_shmem_registry.pl @@ -280,8 +280,8 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', - 'L15 total injection registry size is 161 (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 cluster-xid-herding-stall + cluster-xid-window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; full breakdown in t/015)'); + '162', + 'L15 total injection registry size is 162 (spec-7.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/215_cluster_3_9_cr_construction.pl b/src/test/cluster_tap/t/215_cluster_3_9_cr_construction.pl index 719c919676..8beba686bf 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, '57', - 'L2 cr category has 57 counter rows (9 + 4 cache + 4 xmax + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); +is($cr_rows, '58', + '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 d912b07ede..46cabe5f0c 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'}), - '57', - 'L1d cr category has 57 rows (9 + 4 cache + 4 xmax + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); + '58', + '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/300_cluster_5_50_cr_profile.pl b/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl index 12ff7c2ac2..59299197c3 100644 --- a/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl +++ b/src/test/cluster_tap/t/300_cluster_5_50_cr_profile.pl @@ -126,8 +126,8 @@ is( $node->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='cr'}), - '57', - 'L1b cr category has 57 counters (17 + 5 spec-5.53 mismatch + 8 spec-5.54 tuple + 5 spec-5.56 lifecycle + 6 spec-6.12b cr-server + 16 spec-6.12i/6.15 runtime-visibility & verdict)'); + '58', + '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 b618548531..164309d77e 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 @@ -243,6 +243,50 @@ 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) +# 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. +# ============================================================ +SKIP: +{ + skip "ClusterPair inject SKIP helper missing", 5 + unless $pair->can('inject_skip_set'); + + 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'); + + $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 100); + # Fresh backend -> no cached authority memo -> the fetch rides the wire into + # the origin's fence gate. + my ($rc, $out, $err) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); + like($err, qr/cluster TT (status unknown|slot recycled)/, + 'L4c fence ×N: write-fenced origin keeps the undo read fail-closed (53R97)'); + 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'); + + $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 0); + usleep(1_000_000); + my $ok = 0; + for my $try (1 .. 20) + { + my ($rc2, $out2, $err2) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); + if (defined $out2 && $out2 =~ /^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 61b2811036..1f86454e69 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 @@ -255,5 +255,51 @@ sub cr_image_retry . '(' . (defined $rec_d6 ? 'ok' : "err=$rec_err6") . ')'); } +# ============================================================ +# L7: spec-7.3 D7 — fence ×N. 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. +# ============================================================ +SKIP: +{ + skip "ClusterPair inject SKIP helper missing — fence gate covered by " + . "the shared DENIED reply path", 5 + unless $pair->can('inject_skip_set'); + + 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'); + + $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 100); + my ($off_d7, $off_err7) = cr_image($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)'); + + # 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'); + + $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 0); + 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") . ')'); +} + $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 5eb56b578a..2581e33f56 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,11 +16,12 @@ # 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, then verify the mesh reconnects and a -# cross-node block transfer converges. 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, then verify the mesh reconnects and +# a cross-node block transfer converges. 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 @@ -71,6 +72,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,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/365 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 ' @@ -313,13 +326,16 @@ 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)"); # ============================================================ # L6 — HONEST SKIP: data-dispatch injection-point reachability (F6-2). diff --git a/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl index c28ec1780e..d8c85f2a7a 100644 --- a/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl +++ b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl @@ -123,6 +123,103 @@ sub wait_for_log 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'); +# ============================================================ +# 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'); + +# 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; # ============================================================ diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index 3981b4a75e..46c8fd75e4 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1867,6 +1867,11 @@ cluster_cr_server_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; From 941f0d9fa531afa273469879c75afc364f7e1808 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 09:16:08 +0800 Subject: [PATCH 12/21] fix(cluster): spec-7.3 D7 review r1 -- consume fence-refuse injection arm unconditionally P2: the || short-circuit let a genuine fence skip cluster_injection_should_skip, leaking a pending injection arm to a later call (test-determinism only). Hoist the consume into a local ahead of the predicate (F6-1 local-var idiom). Spec: spec-7.3-lms-worker-pool.md (D7) --- src/backend/cluster/cluster_cr_server.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index ff5e29e9a2..7b846f86a3 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -735,6 +735,7 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste MemoryContext old; uint32 segment_id = 0; uint32 block_no = 0; + bool inject_refuse; if (fwd == NULL) return; @@ -767,8 +768,11 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste * injection forces the same branch deterministically for the TAP fence leg. */ CLUSTER_INJECTION_POINT("cluster-lms-cr-fence-refuse"); - if ((cluster_write_fence_enforcing() && !cluster_write_fence_allowed()) - || cluster_injection_should_skip("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 */ From fd5dd7563048da0df146333867b2538379eb3821 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 12:55:37 +0800 Subject: [PATCH 13/21] =?UTF-8?q?feat(cluster):=20spec-7.3=20D8=20--=20per?= =?UTF-8?q?-worker=20DATA-plane=20observability=20=C3=97N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4 per-worker counters in ClusterLmsSharedState (dispatch / direct_reply / conn_resets / inline_serve), bumped from the worker's own slot: dispatch in cluster_ic_dispatch_envelope (real dispatch path, after the chunk short-circuit; no-op outside an LMS process), direct_reply + inline_serve in the D6 inline serve, conn_resets in the D7 epoch-reset closure. - per-worker inline-serve duration histogram (16 buckets, LMS-owned -- the requester-side ship_latency_hist is a different measurement, not resized; the R4 slow-shard backstop). - dump surface: bare keys = pool aggregates (7.2 single-value semantics' natural generalization), _w suffixed keys = per-worker detail bounded to the live pool (w < cluster.lms_workers). - cluster.lms_nice (Q8): optional SIGHUP int [-20,0] default 0 = leave the inherited priority alone; setpriority best-effort at worker entry + on config reload (POSIX, #ifndef WIN32). - injection points: NONE added -- cluster-lms-worker-crash is untestable under the D7 Path A crash model (hard-crash cascades; graceful recycle needs plain SIGTERM), and the per-worker dispatch counter supersedes cluster-lms-worker-dispatch as the assertable surface (the mispositioned cluster-lms-data-dispatch point stays a spec-7.2 follow-up). - tests: t/364 L2b/L4b (lms_nice GUC + live-pool key bound + 16-bucket agg hist), t/354 L5b (inline serve + hist moved; direct_reply >= inline_serve), t/365 L5 (per-worker conn-reset counters, aggregate = sum), t/360 L2b (dispatch aggregate = per-worker sum on both nodes). - baseline sweep: 7 injection-count sites D7 missed (t/017 x2, t/018, t/021, t/022, t/023, t/024, t/030 M1 + M5 322->324) bumped to 162. Spec: spec-7.3-lms-worker-pool.md (D8) --- src/backend/cluster/cluster_cr_server.c | 10 + src/backend/cluster/cluster_debug.c | 60 +++++ src/backend/cluster/cluster_guc.c | 19 ++ src/backend/cluster/cluster_ic_router.c | 7 + src/backend/cluster/cluster_lms.c | 210 ++++++++++++++++++ src/backend/cluster/cluster_lms_data_plane.c | 4 +- src/include/cluster/cluster_guc.h | 7 + src/include/cluster/cluster_lms.h | 65 ++++++ src/test/cluster_tap/t/017_debug.pl | 8 +- src/test/cluster_tap/t/018_shared_fs.pl | 2 +- src/test/cluster_tap/t/021_block_format.pl | 2 +- src/test/cluster_tap/t/022_itl_slot.pl | 2 +- .../cluster_tap/t/023_buffer_descriptor.pl | 2 +- src/test/cluster_tap/t/024_pcm_lock.pl | 2 +- src/test/cluster_tap/t/030_acceptance.pl | 6 +- .../t/354_cluster_6_12b_cr_server.pl | 19 ++ .../t/360_lms_data_plane_faults.pl | 24 ++ src/test/cluster_tap/t/364_lms_worker_pool.pl | 35 +++ .../t/365_lms_worker_pool_2node.pl | 17 ++ src/test/cluster_unit/test_cluster_debug.c | 39 ++++ .../cluster_unit/test_cluster_ic_router.c | 6 + 21 files changed, 533 insertions(+), 13 deletions(-) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index 7b846f86a3..d95a4f099a 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -62,6 +62,7 @@ #include "cluster/cluster_ic_router.h" /* cluster_ic_send_envelope */ #include "cluster/cluster_inject.h" #include "cluster/cluster_lmon.h" /* PGRAC: spec-7.2 D1 READY-publish wakeup */ +#include "cluster/cluster_lms.h" /* PGRAC: spec-7.3 D8 per-worker serve counters */ #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) */ @@ -75,6 +76,7 @@ #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 @@ -736,6 +738,7 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste uint32 segment_id = 0; uint32 block_no = 0; bool inject_refuse; + TimestampTz serve_started_at; if (fwd == NULL) return; @@ -778,6 +781,7 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste 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; } @@ -813,13 +817,19 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste } 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()); 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 d9cb269dc1..bab9c8a090 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -1310,6 +1310,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())); + + /* 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))); + } + } + } } /* diff --git a/src/backend/cluster/cluster_guc.c b/src/backend/cluster/cluster_guc.c index 800c46f352..2de5af9a8b 100644 --- a/src/backend/cluster/cluster_guc.c +++ b/src/backend/cluster/cluster_guc.c @@ -629,6 +629,14 @@ bool cluster_lms_enabled = true; */ 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 @@ -3726,6 +3734,17 @@ cluster_init_guc(void) "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_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_lms.c b/src/backend/cluster/cluster_lms.c index c182454c7e..24625f1ee8 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" @@ -200,6 +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.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); } } @@ -739,6 +752,9 @@ LmsMain(void) * the historic latch-only wait and park-serve keeps working). */ (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); cluster_lms_state->ready_at = GetCurrentTimestamp(); @@ -767,6 +783,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()) @@ -909,6 +927,9 @@ LmsWorkerMain(int worker_id) */ (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 @@ -924,6 +945,8 @@ LmsWorkerMain(int worker_id) 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) @@ -968,6 +991,193 @@ cluster_lms_get_worker_pid(int worker_id) } +/* ============================================================ + * 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 30bda22d0c..7b5eedf394 100644 --- a/src/backend/cluster/cluster_lms_data_plane.c +++ b/src/backend/cluster/cluster_lms_data_plane.c @@ -303,12 +303,14 @@ cluster_lms_data_plane_tick(long timeout_ms) * workers reset (only when a live connection was actually torn * down, to avoid logging on an idle-mesh epoch advance). */ - if (n_closed > 0) + 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/include/cluster/cluster_guc.h b/src/include/cluster/cluster_guc.h index bdaa675f33..b20b9f8097 100644 --- a/src/include/cluster/cluster_guc.h +++ b/src/include/cluster/cluster_guc.h @@ -787,6 +787,13 @@ extern bool cluster_lms_enabled; */ 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_lms.h b/src/include/cluster/cluster_lms.h index 5bd3c59af1..5407e67c49 100644 --- a/src/include/cluster/cluster_lms.h +++ b/src/include/cluster/cluster_lms.h @@ -155,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) */ @@ -267,6 +275,37 @@ typedef struct ClusterLmsSharedState { * 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). + * + * 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; @@ -389,6 +428,32 @@ extern void cluster_lms_bump_restart_generation_at_main_entry(void); extern void cluster_lms_inc_priority_starvation_observed(void); extern uint64 cluster_lms_get_priority_starvation_observed_count(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", * "starting", "ready", "draining", "stopped"). Out-of-range returns diff --git a/src/test/cluster_tap/t/017_debug.pl b/src/test/cluster_tap/t/017_debug.pl index 739b6a0e49..2f960c506c 100644 --- a/src/test/cluster_tap/t/017_debug.pl +++ b/src/test/cluster_tap/t/017_debug.pl @@ -123,15 +123,15 @@ 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='inject' AND key LIKE '%.fault_type'}), - '161', - 'all 161 injection points have a .fault_type entry under inject category (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2; spec-5.13 H1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'all 162 injection points have a .fault_type entry under inject category (spec-7.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'}), - '161', - 'all 161 injection points have a .hits entry under inject category (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2; spec-5.13 H1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', + 'all 162 injection points have a .hits entry under inject category (spec-7.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 60db597af5..a7f66ecd81 100644 --- a/src/test/cluster_tap/t/018_shared_fs.pl +++ b/src/test/cluster_tap/t/018_shared_fs.pl @@ -131,7 +131,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', + '162', 'L9 total injection registry size is 161 (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); diff --git a/src/test/cluster_tap/t/021_block_format.pl b/src/test/cluster_tap/t/021_block_format.pl index 39f6a34abc..fe36b7cbc1 100644 --- a/src/test/cluster_tap/t/021_block_format.pl +++ b/src/test/cluster_tap/t/021_block_format.pl @@ -188,7 +188,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', + '162', 'L11 pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); diff --git a/src/test/cluster_tap/t/022_itl_slot.pl b/src/test/cluster_tap/t/022_itl_slot.pl index 1354ed353e..2fc74b0b6a 100644 --- a/src/test/cluster_tap/t/022_itl_slot.pl +++ b/src/test/cluster_tap/t/022_itl_slot.pl @@ -204,7 +204,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', + '162', 'L12a pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); is($node->safe_psql( diff --git a/src/test/cluster_tap/t/023_buffer_descriptor.pl b/src/test/cluster_tap/t/023_buffer_descriptor.pl index f7ddfec0fa..43e59aa593 100644 --- a/src/test/cluster_tap/t/023_buffer_descriptor.pl +++ b/src/test/cluster_tap/t/023_buffer_descriptor.pl @@ -157,7 +157,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', + '162', 'L11 pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); diff --git a/src/test/cluster_tap/t/024_pcm_lock.pl b/src/test/cluster_tap/t/024_pcm_lock.pl index a01a9639c1..3d1f2281ad 100644 --- a/src/test/cluster_tap/t/024_pcm_lock.pl +++ b/src/test/cluster_tap/t/024_pcm_lock.pl @@ -163,7 +163,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', + '162', 'L6a pg_stat_cluster_injections has 161 entries (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-5.13 +6 cluster-clean-leave-* + Hardening v1.0.3 +1 suppress-preflight-ack) (spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); is($node->safe_psql( diff --git a/src/test/cluster_tap/t/030_acceptance.pl b/src/test/cluster_tap/t/030_acceptance.pl index 55de4798c9..6f48a438d5 100644 --- a/src/test/cluster_tap/t/030_acceptance.pl +++ b/src/test/cluster_tap/t/030_acceptance.pl @@ -330,7 +330,7 @@ is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '161', 'M1 161 injection points (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.18 +7 cluster-node-remove-*; spec-5.13 +6 cluster-clean-leave-*; spec-5.13 Hardening v1.0.3 +1 clean-leave-survivor-suppress-preflight-ack; spec-2.41 +1 gcs-block-stale-ship; spec-5.7 D6 +1 ko-peer-skip-ack; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + '162', 'M1 162 injection points (spec-7.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 '322', - 'M5 inject category has 161×2 = 322 sub-keys (.fault_type + .hits; spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5 +2 cluster-relmap-crash-*; spec-5.6a +1 cluster-recovery-anchor-force-failclosed; spec-6.14 D8 +1 cluster-catalog-services-ready-force-closed; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 herding-stall + window-hard-limit; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.13 +6 cluster-clean-leave-*; spec-2.41 +1 gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); + ) eq '324', + 'M5 inject category has 162×2 = 324 sub-keys (.fault_type + .hits; spec-7.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/354_cluster_6_12b_cr_server.pl b/src/test/cluster_tap/t/354_cluster_6_12b_cr_server.pl index 1f86454e69..0a84e0a610 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 @@ -225,6 +225,25 @@ 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'); +} + # ============================================================ # 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) 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 2581e33f56..f3f948441c 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 @@ -220,6 +220,30 @@ 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). +# ============================================================ +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; +} +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. # ============================================================ diff --git a/src/test/cluster_tap/t/364_lms_worker_pool.pl b/src/test/cluster_tap/t/364_lms_worker_pool.pl index 0f687157a5..ecb39cb3be 100644 --- a/src/test/cluster_tap/t/364_lms_worker_pool.pl +++ b/src/test/cluster_tap/t/364_lms_worker_pool.pl @@ -60,6 +60,34 @@ sub backend_count 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 ----------------------------- @@ -91,6 +119,13 @@ sub backend_count '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'); diff --git a/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl index d8c85f2a7a..90b19020e4 100644 --- a/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl +++ b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl @@ -150,6 +150,23 @@ sub wait_for_log 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 = ''"); diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index 46c8fd75e4..2127f72dec 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -3708,6 +3708,45 @@ cluster_lms_get_priority_starvation_observed_count(void) { return 0; } +/* 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_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_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) { From 4c792928875e3e90079fdbc9b9cc84c282ffa8fc Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 19:12:04 +0800 Subject: [PATCH 14/21] test(cluster): spec-7.3 D9 -- t/365 -> t/367 renumber + live routing e2e legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renumber (hub arbitration): main (PR#27) owns t/365 for the spec-7.1a write-write TAP and nightly shards select by numeric prefix, so this lane's unpushed 2-node pool TAP moves to t/367 (364 = 7.3 single-node, 365 = 7.1a on main, 366 = 7.2a, 367 = this file). Banner and the t/360 / t/112 cross-references move with it. New D9 legs (wire-traced grounding; the 'single tag' SQL premise is false under pgrac MVCC -- a heap read also fetches its writers' undo / verdict blocks whose tags shard independently): L7 multi-tag fan-out: X ping-pong over a ~40-block shared table moves BOTH per-worker dispatch counters on BOTH nodes + aggregate inline serve liveness + zero plane misroutes + zero dedup-shard drops. L8 per-tag affinity: single-block invalidate/re-read keeps the serving-side per-frame shard check clean and moves the SAME worker set on both ends (double-end shard agreement, R1). L9 N=1 whole-stack identity sentinel: lms_workers=1 pool on the pre-7.3 single data port -- no worker sibling, no worker-1 channel, live traffic rides worker 0 alone (aggregate == _w0), zero misroutes (spec-7.3 §3 contract 5 under real traffic). Spec: spec-7.3-lms-worker-pool.md (D9) --- .../t/112_gcs_block_retransmit_2node.pl | 16 +- .../t/360_lms_data_plane_faults.pl | 2 +- .../t/365_lms_worker_pool_2node.pl | 267 -------- .../t/367_lms_worker_pool_2node.pl | 592 ++++++++++++++++++ 4 files changed, 601 insertions(+), 276 deletions(-) delete mode 100644 src/test/cluster_tap/t/365_lms_worker_pool_2node.pl create mode 100644 src/test/cluster_tap/t/367_lms_worker_pool_2node.pl 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 5aa236ebd2..a4d7dd04bd 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 85 keys (cumulative through spec-7.2 D6 hist) +# L3 pg_cluster_state.gcs has 86 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) @@ -71,7 +71,7 @@ sub gcs_int # ============================================================ # 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/365). +# 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, @@ -111,18 +111,18 @@ sub gcs_int # ============================================================ -# L3: pg_cluster_state.gcs category has 85 keys (cumulative through spec-7.2 D6 hist). +# L3: pg_cluster_state.gcs category has 86 keys (cumulative through spec-7.2 D6 hist). # ============================================================ is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L3 node0 pg_cluster_state.gcs category has 85 keys'); + '86', + 'L3 node0 pg_cluster_state.gcs category has 86 keys'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L3 node1 pg_cluster_state.gcs category has 85 keys'); + '86', + 'L3 node1 pg_cluster_state.gcs category has 86 keys'); # ============================================================ @@ -143,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/360_lms_data_plane_faults.pl b/src/test/cluster_tap/t/360_lms_data_plane_faults.pl index f3f948441c..0762c267d0 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 @@ -267,7 +267,7 @@ sub lms_int # 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/365 L6 (no crash cascade; worker respawns with a fresh pid; mesh re-forms). +# 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 ' diff --git a/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl b/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl deleted file mode 100644 index 90b19020e4..0000000000 --- a/src/test/cluster_tap/t/365_lms_worker_pool_2node.pl +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env perl -#------------------------------------------------------------------------- -# -# 365_lms_worker_pool_2node.pl -# spec-7.3 D3 -- 2-node LMS DATA-plane worker-pool topology + HELLO gate. -# -# L1 matched pool: both nodes cluster.lms_workers=2. Each node runs -# worker 0 (LmsProcess) + worker 1 (LmsWorker), each binding its own -# DATA listener (data_port + worker_id). -# L2 shard-aligned i<->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. -# 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'); - -# ============================================================ -# 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; - -done_testing(); diff --git a/src/test/cluster_tap/t/367_lms_worker_pool_2node.pl b/src/test/cluster_tap/t/367_lms_worker_pool_2node.pl new file mode 100644 index 0000000000..f29e0863da --- /dev/null +++ b/src/test/cluster_tap/t/367_lms_worker_pool_2node.pl @@ -0,0 +1,592 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 367_lms_worker_pool_2node.pl +# spec-7.3 D3/D7/D9 -- 2-node LMS DATA-plane worker pool: topology, +# HELLO gate, live multi-tag routing, and fault legs. +# +# L1 matched pool: both nodes cluster.lms_workers=2. Each node runs +# worker 0 (LmsProcess) + worker 1 (LmsWorker), each binding its own +# DATA listener (data_port + worker_id). +# L2 shard-aligned i<->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(); From 1265ec5ed0955f4d68441763136f999717051821 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 19:12:44 +0800 Subject: [PATCH 15/21] feat(cluster): spec-7.3 D9 -- shard-route unit layer + dedup misroute SQL surface Extract cluster_gcs_block_payload_shard into cluster_gcs_block_shard.c (a pure file, same D1/D5 pattern) so the cluster_unit suite links the REAL staging-path router: new test_cluster_gcs_block_shard pins the D9 ring-group routing truth table (route == shard_for_tag agreement for REQUEST / FORWARD / INVALIDATE, ACK -> same-tag re-REQUEST affinity, DATA registry partition REPLY / INVALIDATE-ACK direct-send whitelist, payload-length ABI pin refuses mismatched frames, N=1 degenerate, non-tag fields never move the route). Surface the D5 per-worker dedup-shard fail-closed drop counter in SQL (gcs key dedup_misroute_failclosed_count, 85 -> 86; rule 17: the D5 counter had an accessor but no observable surface) with the test_cluster_debug stub; bump the gcs key-count baselines across the eight consumers (t/110-116, t/200 unaffected) and fix the stale wait-event / key-count comments in t/112 / t/116 while there. Spec: spec-7.3-lms-worker-pool.md (D4/D9) --- src/backend/cluster/Makefile | 1 + src/backend/cluster/cluster_debug.c | 29 +- src/backend/cluster/cluster_gcs_block.c | 63 +-- src/backend/cluster/cluster_gcs_block_shard.c | 99 +++++ src/test/cluster_tap/t/110_gcs_loopback.pl | 8 +- .../cluster_tap/t/111_gcs_block_ship_2node.pl | 12 +- .../cluster_tap/t/113_gcs_block_2way_2node.pl | 12 +- .../cluster_tap/t/114_gcs_block_3way_2node.pl | 12 +- .../cluster_tap/t/115_gcs_block_3way_3node.pl | 4 +- .../t/116_gcs_block_lost_write_2node.pl | 12 +- src/test/cluster_unit/Makefile | 17 +- src/test/cluster_unit/test_cluster_debug.c | 5 + .../test_cluster_gcs_block_shard.c | 363 ++++++++++++++++++ 13 files changed, 533 insertions(+), 104 deletions(-) create mode 100644 src/backend/cluster/cluster_gcs_block_shard.c create mode 100644 src/test/cluster_unit/test_cluster_gcs_block_shard.c diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index d14f6d6ac0..a8bc6a02a0 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 \ diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index bab9c8a090..a9286e0fe7 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -133,18 +133,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" @@ -1985,6 +1986,10 @@ dump_gcs(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_gcs_get_block_dedup_collision_count())); emit_row(rsinfo, "gcs", "dedup_full_count", fmt_int64((int64)cluster_gcs_get_block_dedup_full_count())); + /* 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", diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index 2e463eea80..7324bccd5e 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -4570,66 +4570,9 @@ build_and_send_reply: { } -/* - * 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); -} +/* 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. */ /* ============================================================ 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/test/cluster_tap/t/110_gcs_loopback.pl b/src/test/cluster_tap/t/110_gcs_loopback.pl index 9f508ee72c..be28c4246d 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 85 keys +# L1 fresh cluster startup: pg_cluster_state.gcs has 86 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 85 keys (spec-7.2 D6 hist). +# L1 — pg_cluster_state.gcs surface has 86 keys (spec-7.2 D6 hist). is($node->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L1 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6)'); + '86', + 'L1 pg_cluster_state.gcs category has 86 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 1fac3579aa..b7341d4b96 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 85 keys (spec-7.2 D6+flip) (cumulative through +# L3 pg_cluster_state.gcs category has 86 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, @@ -114,19 +114,19 @@ sub gcs_int_value { # ============================================================ -# L3: pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip) +# L3: pg_cluster_state.gcs category has 86 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'}), - '85', - 'L3 node0 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip)'); + '86', + 'L3 node0 pg_cluster_state.gcs category has 86 keys (spec-7.2 D6+flip)'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L3 node1 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip)'); + '86', + 'L3 node1 pg_cluster_state.gcs category has 86 keys (spec-7.2 D6+flip)'); # ============================================================ 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 422e739673..9e0a7a5511 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 85 keys (spec-7.2 D6+flip) (cumulative through spec-6.13) +# L3 pg_cluster_state.gcs category has 86 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 @@ -109,18 +109,18 @@ sub gcs_int # ============================================================ -# L3: pg_cluster_state.gcs has 85 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a). +# L3: pg_cluster_state.gcs has 86 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'}), - '85', - 'L3 node0 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip)'); + '86', + 'L3 node0 pg_cluster_state.gcs category has 86 keys (spec-7.2 D6+flip)'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L3 node1 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip)'); + '86', + 'L3 node1 pg_cluster_state.gcs category has 86 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 56075b62cc..a03ebb1fa8 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 85 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a) +# L3 pg_cluster_state.gcs has 86 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 @@ -107,18 +107,18 @@ sub gcs_int # ============================================================ -# L3: pg_cluster_state.gcs has 85 keys (spec-7.2 D6+flip; was 67 cumulative through spec-6.14a). +# L3: pg_cluster_state.gcs has 86 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'}), - '85', - 'L3 node0 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip)'); + '86', + 'L3 node0 pg_cluster_state.gcs category has 86 keys (spec-7.2 D6+flip)'); is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L3 node1 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip)'); + '86', + 'L3 node1 pg_cluster_state.gcs category has 86 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 64f60a9614..0f42d2dbca 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'}), - '85', - "L4 node$i pg_cluster_state.gcs has 85 keys"); + '86', + "L4 node$i pg_cluster_state.gcs has 86 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 977aa156b9..f45353156f 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 85 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a) +# L9 pg_cluster_state.gcs category has 86 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 @@ -108,8 +108,8 @@ sub gcs_int is($pair->node0->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L2 pg_cluster_state.gcs category has 85 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a)'); + '86', + 'L2 pg_cluster_state.gcs category has 86 keys (spec-7.2 D6+flip) (cumulative through spec-6.14a)'); # ============================================================ @@ -194,13 +194,13 @@ sub gcs_int # ============================================================ -# L9 (alias of L2): gcs key count = 48. +# L9 (alias of L2): gcs key count = 86. # ============================================================ is($pair->node1->safe_psql( 'postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category='gcs'}), - '85', - 'L9 node1 pg_cluster_state.gcs has 67 keys (cross-node parity)'); + '86', + 'L9 node1 pg_cluster_state.gcs has 86 keys (cross-node parity)'); # ============================================================ diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index bba8fb0856..b19392b053 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -90,7 +90,8 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_hang_acceptance \ test_cluster_xid_stripe \ test_cluster_lms_shard \ - test_cluster_gcs_block_dedup + 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 @@ -189,6 +190,18 @@ test_cluster_gcs_block_dedup: test_cluster_gcs_block_dedup.c unit_test.h $(CLUST $(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. @@ -205,7 +218,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_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority test_cluster_lms_shard test_cluster_gcs_block_dedup,$(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_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority 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 diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index 2127f72dec..7d424ddd44 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -1102,6 +1102,11 @@ cluster_gcs_get_block_dedup_full_count(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; 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; +} From c289100982a2e3c087b05cbb2c10d759a51057be Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 19:34:19 +0800 Subject: [PATCH 16/21] perf(cluster): spec-7.3 D9 -- LMS pool-scaling gate harness (Q9 L5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_73_pool_scaling.pl boots the 2-node block_device pair at cluster.lms_workers = 1 and 2 and measures (a) aggregate serve throughput (lms_direct_reply_count delta/s) under the spec's motivating CR-construction workload -- node0 UPDATE churn + node1 lagged REPEATABLE READ scans -- with per-worker inline-serve fan-out evidence, and (b) single-block requester ship-latency p99 from the ship_hist delta. Medians over PS_ROUNDS; PS_EXTRA_GUC injects lever GUCs for control experiments; the reader probe is DO-wrapped so fail-closed retryable visibility errors roll back the probe, not the client. Measured verdict (2026-07-10, 本机 loopback): p99 leg PASS (1.00x); the >=1.6x aggregate throughput leg is structurally unreachable on loopback -- the dominant cross-node serve stream is the runtime visibility undo/TT probe whose synthetic tag is constant per origin-segment (block-0 slice), so it rides ONE worker by per-tag FIFO design, and the residual block-family load sits far below single-worker saturation. Numbers + root-cause analysis in the pgrac measure report; gate disposition escalated per rule 6.A. Spec: spec-7.3-lms-worker-pool.md (D9/Q9) --- scripts/perf/run_73_pool_scaling.pl | 465 ++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 scripts/perf/run_73_pool_scaling.pl 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; From d365f11049cafb69c334d9a774241a21098ac98e Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 19:47:57 +0800 Subject: [PATCH 17/21] ci(nightly): spec-7.3 D9 -- shard the LMS worker-pool TAP family (364, 367) t/364 (single-node pool smoke) and t/367 (2-node topology + routing + fault legs) were nightly orphans -- the shard ranges stop at 363. Give the family its own stage7-lms-pool shard; 365 / 366 stay with the spec-7.1a (main) / spec-7.2a lanes per the hub number ledger. Spec: spec-7.3-lms-worker-pool.md (D9) --- .github/workflows/nightly.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 905b89fab7..30fc3e1e9a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -190,6 +190,12 @@ jobs: - { 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). 365 = spec-7.1a (main), 366 = spec-7.2a -- hub + # number ledger 2026-07-10; every new t/ file lands in a shard + # the same commit (L342). + - { name: stage7-lms-pool, ranges: "364 367", unit: false, regress: false } steps: - name: Checkout uses: actions/checkout@v4 From f616041f34829258edad9615b5ee7c5b26a9073b Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 19:47:57 +0800 Subject: [PATCH 18/21] test(cluster): spec-7.3 D9 -- default DATA port span follows the pool default With cluster.lms_workers defaulting to 2 (spec-7.3 D2) every node binds DATA listeners at data_port + {0,1}, but ClusterPair only reserved a single allocator port unless the test passed data_port_span, and ClusterTriple had no span support at all -- any 2-/3-node test with an active data plane could FATAL on 'Address already in use' (t/348 did, in the D9 regression gate). The D5/D7 point fixes covered 8 files of ~85 affected. Root fix: both fixtures now default data_port_span to 2 (the shipped cluster.lms_workers default) and allocate the range via get_free_port_range; ClusterTriple gains the data_port_span option. Tests that override the pool size keep passing an explicit span. Verified: t/348/349/350/355 (84 tests, incl. the 3-node triple) + the 15-file lane family batch (426 tests, incl. the previously unspanned t/117 canary) all green. Spec: spec-7.3-lms-worker-pool.md (D9) --- src/test/perl/PostgreSQL/Test/ClusterPair.pm | 10 +++++--- .../perl/PostgreSQL/Test/ClusterTriple.pm | 24 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/test/perl/PostgreSQL/Test/ClusterPair.pm b/src/test/perl/PostgreSQL/Test/ClusterPair.pm index 0059ade9ab..45724b4eca 100644 --- a/src/test/perl/PostgreSQL/Test/ClusterPair.pm +++ b/src/test/perl/PostgreSQL/Test/ClusterPair.pm @@ -86,9 +86,13 @@ sub new_pair # 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. data_port_span (default 1) keeps the historic - # single-port allocation for every existing pair test. - my $data_span = $opts{data_port_span} // 1; + # 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) 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) From f8bc665248f735f87abe7c3fefe5e653b963aa47 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 22:12:36 +0800 Subject: [PATCH 19/21] fix(cluster): spec-7.3 review P1-1+P2-1 -- ship-time fence re-check + shard misroute guards P1-1: the inline-serve fence gate ran only BEFORE construction; a qvotec lease expiring while the serve walks undo I/O / TT scans (ms-scale) let the just-constructed CR image / undo bytes / commit verdict leave the now-fenced node (TOCTOU; the park ship path gated at ship time and never had this window). Re-check the same predicate after cr_serve_slot and before the reply build; on a hit discard the constructed result and ship the fail-closed DENIED (fence_refused bumps; requester keeps its retransmit budget / 53R90). New decision-style injection point cluster-lms-cr-fence-recheck models the mid-serve expiry for the TAP TOCTOU leg (registry 162 -> 163; baselines t/015/017/018/020-024/030, M5 324 -> 326). P2-1: per-frame shard(tag)==my_channel cross-checks covered only the REQUEST dedup path (1/5 message classes). Add the same Assert + note_misroute fail-closed guard to the FORWARD inline-serve entry, the INVALIDATE receive side, and the INVALIDATE-ACK receive side, so a future routing regression surfaces as a counted drop instead of a silent per-tag order break. Spec: spec-7.3-lms-worker-pool.md --- src/backend/cluster/cluster_cr_server.c | 61 ++++++++++++++++++- src/backend/cluster/cluster_gcs_block.c | 61 +++++++++++++++++++ src/backend/cluster/cluster_inject.c | 12 ++++ src/test/cluster_tap/t/015_inject.pl | 8 +-- src/test/cluster_tap/t/017_debug.pl | 8 +-- src/test/cluster_tap/t/018_shared_fs.pl | 2 +- src/test/cluster_tap/t/020_shmem_registry.pl | 4 +- src/test/cluster_tap/t/021_block_format.pl | 2 +- src/test/cluster_tap/t/022_itl_slot.pl | 2 +- .../cluster_tap/t/023_buffer_descriptor.pl | 2 +- src/test/cluster_tap/t/024_pcm_lock.pl | 2 +- src/test/cluster_tap/t/030_acceptance.pl | 6 +- 12 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index d95a4f099a..8c8af1454b 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -57,12 +57,15 @@ #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_lms.h" /* PGRAC: spec-7.3 D8 per-worker serve counters */ +#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_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) */ @@ -743,6 +746,37 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste 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; + } + } + /* Populate the request carrier from the forward payload (was submit). */ memset(&slot, 0, sizeof(slot)); slot.tag = fwd->tag; @@ -826,6 +860,29 @@ cluster_gcs_block_forward_serve_inline(const GcsBlockForwardPayload *fwd, Cluste /* 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); diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index 7324bccd5e..97226a7787 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -6088,6 +6088,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(); /* @@ -6208,6 +6238,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_inject.c b/src/backend/cluster/cluster_inject.c index 32c3608467..bd840f14d5 100644 --- a/src/backend/cluster/cluster_inject.c +++ b/src/backend/cluster/cluster_inject.c @@ -370,6 +370,18 @@ static ClusterInjectPoint cluster_injection_points[] = { * 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/test/cluster_tap/t/015_inject.pl b/src/test/cluster_tap/t/015_inject.pl index e96b12ce42..9964c13dad 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.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)'); + '163', + 'pg_stat_cluster_injections returns 163 rows (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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-cr-fence-refuse,cluster-lms-data-dispatch,cluster-lms-undo-fetch,cluster-node-remove-cleanup-done,cluster-node-remove-escalate,cluster-node-remove-fence-armed,cluster-node-remove-precheck,cluster-node-remove-request,cluster-node-remove-shrink-committed,cluster-node-remove-shrink-committing,cluster-pcm-acquire-entry,cluster-pcm-convert-pre,cluster-pcm-downgrade-pre,cluster-pcm-release-pre,cluster-pgstat-mirror-sync,cluster-quorum-loss-broadcast,cluster-qvotec-marker-service-hold,cluster-qvotec-poll-post,cluster-qvotec-poll-pre,cluster-reconfig-broadcast-procsig-pre,cluster-reconfig-decide-coordinator,cluster-reconfig-epoch-bump-pre,cluster-reconfig-join-commit-marker-durable,cluster-reconfig-tick-entry,cluster-recovery-anchor-force-failclosed,cluster-relmap-crash-after-stage,cluster-relmap-crash-before-publish,cluster-run-shutdown-top,cluster-run-startup-top,cluster-scn-abort-post-advance,cluster-scn-abort-pre-advance,cluster-scn-advance-post,cluster-scn-advance-pre,cluster-scn-boc-sweep-post,cluster-scn-boc-sweep-pre,cluster-scn-commit-post-advance,cluster-scn-commit-pre-advance,cluster-scn-observe-bump-pre,cluster-scn-observe-entry,cluster-scn-replay-observe-pre,cluster-scn-wal-write-pre,cluster-scn-wraparound-warning,cluster-shared-fs-backend-register,cluster-shared-fs-init-top,cluster-shared-fs-local-open,cluster-shmem-region-init-post,cluster-shmem-region-init-pre,cluster-shmem-register-region,cluster-shmem-request,cluster-shmem-views-srf-entry,cluster-shutdown-top,cluster-sinval-ack-drop-send,cluster-sinval-ack-skip-validate,cluster-sinval-broadcast-drop-send,cluster-sinval-receive-skip-validate,cluster-smgr-create-top,cluster-smgr-open-top,cluster-smgr-which-decision,cluster-startup-phase-0-enter,cluster-startup-phase-0-exit,cluster-startup-phase-0-fail,cluster-startup-phase-1-enter,cluster-startup-phase-1-exit,cluster-startup-phase-1-fail,cluster-startup-phase-2-enter,cluster-startup-phase-2-exit,cluster-startup-phase-2-fail,cluster-startup-phase-3-enter,cluster-startup-phase-3-exit,cluster-startup-phase-3-fail,cluster-startup-phase-4-enter,cluster-startup-phase-4-exit,cluster-startup-phase-4-fail,cluster-stats-main-loop-iter,cluster-stats-post-spawn,cluster-stats-pre-spawn,cluster-stats-ready-publish,cluster-stats-shutdown-post,cluster-stats-shutdown-pre,cluster-thread-recovery-drive,cluster-views-srf-entry,cluster-voting-disk-write-fail,cluster-wal-page-init-thread-id,cluster-wal-state-ensure-pre,cluster-wal-state-write-fail,cluster-wal-thread-claim-create-fail,cluster-wal-thread-validate-pre,cluster-xid-herding-stall,cluster-xid-window-hard-limit,cr_construct_delay_us,cr_corruption,cr_cross_instance,cr_force_read_scn,cr_snapshot_too_old,undo-force-wal-before-data-violation,undo-skip-checkpoint-flush-one', - '162 injection point names match the full registry (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)'); + '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-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', + '163 injection point names match the full registry (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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 2f960c506c..4e546b966b 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.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)'); + '163', + 'all 163 injection points have a .fault_type entry under inject category (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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.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)'); + '163', + 'all 163 injection points have a .hits entry under inject category (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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 a7f66ecd81..072014ac71 100644 --- a/src/test/cluster_tap/t/018_shared_fs.pl +++ b/src/test/cluster_tap/t/018_shared_fs.pl @@ -131,7 +131,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '162', + '163', 'L9 total injection registry size is 161 (spec-7.2 D6 +2 cluster-lms-data-dispatch + cluster-lms-conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12i +1 cluster-lms-undo-fetch; spec-6.12b +1 cluster-lms-cr-construct; spec-6.12a ㉕ +1 cluster-gcs-block-remote-downgrade; spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); diff --git a/src/test/cluster_tap/t/020_shmem_registry.pl b/src/test/cluster_tap/t/020_shmem_registry.pl index b8c2a51309..4b95c05772 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.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)'); + '163', + 'L15 total injection registry size is 162 (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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 fe36b7cbc1..6c6c9d6e99 100644 --- a/src/test/cluster_tap/t/021_block_format.pl +++ b/src/test/cluster_tap/t/021_block_format.pl @@ -188,7 +188,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '162', + '163', 'L11 pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); diff --git a/src/test/cluster_tap/t/022_itl_slot.pl b/src/test/cluster_tap/t/022_itl_slot.pl index 2fc74b0b6a..587e6bf0f3 100644 --- a/src/test/cluster_tap/t/022_itl_slot.pl +++ b/src/test/cluster_tap/t/022_itl_slot.pl @@ -204,7 +204,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '162', + '163', 'L12a pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); is($node->safe_psql( diff --git a/src/test/cluster_tap/t/023_buffer_descriptor.pl b/src/test/cluster_tap/t/023_buffer_descriptor.pl index 43e59aa593..54dc6d2fb4 100644 --- a/src/test/cluster_tap/t/023_buffer_descriptor.pl +++ b/src/test/cluster_tap/t/023_buffer_descriptor.pl @@ -157,7 +157,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '162', + '163', 'L11 pg_stat_cluster_injections is 161 (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-6.12e2 +1 cluster-gcs-block-bast-nudge; spec-6.15 D3 +2 xid herding/hard-limit; spec-6.12 waves a/b/i +3; full breakdown in t/015)'); diff --git a/src/test/cluster_tap/t/024_pcm_lock.pl b/src/test/cluster_tap/t/024_pcm_lock.pl index 3d1f2281ad..a1808e0407 100644 --- a/src/test/cluster_tap/t/024_pcm_lock.pl +++ b/src/test/cluster_tap/t/024_pcm_lock.pl @@ -163,7 +163,7 @@ is($node->safe_psql( 'postgres', 'SELECT count(*) FROM pg_stat_cluster_injections'), - '162', + '163', 'L6a pg_stat_cluster_injections has 161 entries (spec-7.2 D6 +2 lms data-dispatch/conn-reset; spec-6.14 D5+D8 +3; spec-5.6a +1; spec-5.13 +6 cluster-clean-leave-* + Hardening v1.0.3 +1 suppress-preflight-ack) (spec-5.2a +1 clean-xfer stale-holder; spec-4.8ab +2 undo boundary guards; spec-5.7 +1 cluster-ko-peer-skip-ack; spec-2.41 +1 cluster-gcs-block-stale-ship; spec-5.55 Hardening v1.1 +1 cluster-cr-resolver-memo-suspect; spec-5.15 Hardening v1.1 +1 cluster-reconfig-join-commit-marker-durable; spec-2.29a +1 cluster-qvotec-marker-service-hold)'); is($node->safe_psql( diff --git a/src/test/cluster_tap/t/030_acceptance.pl b/src/test/cluster_tap/t/030_acceptance.pl index 6f48a438d5..31bd7b68e6 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.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)'); + '163', 'M1 163 injection points (spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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.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)'); + ) eq '326', + 'M5 inject category has 163×2 = 326 sub-keys (.fault_type + .hits; spec-7.3 review P1-1 +1 cluster-lms-cr-fence-recheck; 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'); From 447b27a05f3eafcb5668ef2d786e5ffa42909166 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 22:12:58 +0800 Subject: [PATCH 20/21] test(cluster): spec-7.3 review P1-2 -- conf-arm the fence/construct TAP legs for real The t/354 L6/L7 and t/346 L4c legs were guarded by $pair->can('inject_skip_set'), a helper that never existed anywhere in the tree -- the guard was always false, so all three legs skipped forever and their closure counts were skip-green: the fence gate (the D7 headline deliverable) had zero executing test coverage. Rewrite them on the t/360 L5 conf-arm pattern (cluster.injection_points=':skip' + reload; the LMS workers re-read the GUC on SIGHUP), with counter snapshots taken only after the arm is observably effective so pre-arm serves cannot contaminate the static-counter assertions. Disarm uses an explicit ':none' entry: a bare empty list does not recall a colon-typed arm (the assign_hook's not-in-list revert only recalls WARNING arms). New t/354 L7b pins the P1-1 ship-time re-check via the TOCTOU discard signature: construction DID run (full/partial advanced) yet the reply stays the fail-closed DENIED (fence_refused advanced, requester keeps 53R9G), proving the discard sits between construct and ship. RED/GREEN proven: with both fence branches locally neutered the legs fail exactly on the gate assertions (t/354: 25-27, 31-32; t/346 L4c: fence_refused static + undo served while fenced); with the shipped gate they pass 35/35 + 45/45. t/358 gains the standard Author/IDENTIFICATION banner block (it now enters the CI matrix; comment-header gate was red on it). Spec: spec-7.3-lms-worker-pool.md --- .../t/346_cluster_6_12i_runtime_visibility.pl | 56 ++++--- .../t/354_cluster_6_12b_cr_server.pl | 145 ++++++++++++++---- .../cluster_tap/t/358_data_plane_flip_e2e.pl | 7 + 3 files changed, 161 insertions(+), 47 deletions(-) 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 164309d77e..d1965cecfa 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 @@ -247,27 +247,43 @@ sub poll_until # 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) -# 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. +# 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. # ============================================================ -SKIP: { - skip "ClusterPair inject SKIP helper missing", 5 - unless $pair->can('inject_skip_set'); + # 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'); - $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 100); - # Fresh backend -> no cached authority memo -> the fetch rides the wire into - # the origin's fence gate. - my ($rc, $out, $err) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); - like($err, qr/cluster TT (status unknown|slot recycled)/, - 'L4c fence ×N: write-fenced origin keeps the undo read fail-closed (53R97)'); + # 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, @@ -275,13 +291,17 @@ sub poll_until is(state_val($node0, 'cr', 'cr_server_verdict_served_count'), $vs0, 'L4c no verdict served while fenced'); - $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 0); - usleep(1_000_000); + # 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 ($rc2, $out2, $err2) = $node1->psql('postgres', 'SELECT count(*) FROM i_t'); - if (defined $out2 && $out2 =~ /^12$/m) { $ok = 1; last; } + 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'); 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 0a84e0a610..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) # @@ -244,22 +251,53 @@ sub cr_image_retry 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) -# 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. +# 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. # ============================================================ -SKIP: { - skip "ClusterPair inject SKIP helper missing — L6 8.A envelope covered by " - . "the shared PG_TRY drain path", 3 - unless $pair->can('inject_skip_set'); - - $pair->inject_skip_set($node0, 'cluster-lms-cr-construct', 100); - my ($off_d6, $off_err6) = cr_image($node1, 0, $scn_mid); + 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)'); @@ -267,7 +305,7 @@ sub cr_image_retry is($node0->safe_psql('postgres', 'SELECT 1'), '1', 'L6 origin still serving after a fail-closed inline CR construct'); - $pair->inject_skip_set($node0, 'cluster-lms-cr-construct', 0); + 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 ' @@ -275,28 +313,30 @@ sub cr_image_retry } # ============================================================ -# L7: spec-7.3 D7 — fence ×N. 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. +# 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. # ============================================================ -SKIP: { - skip "ClusterPair inject SKIP helper missing — fence gate covered by " - . "the shared DENIED reply path", 5 - unless $pair->can('inject_skip_set'); + 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'); - $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 100); - my ($off_d7, $off_err7) = cr_image($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)'); + # 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'); @@ -313,12 +353,59 @@ sub cr_image_retry is($node0->safe_psql('postgres', 'SELECT 1'), '1', 'L7 origin still serving after a fence-refused inline serve'); - $pair->inject_skip_set($node0, 'cluster-lms-cr-fence-refuse', 0); + 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/358_data_plane_flip_e2e.pl b/src/test/cluster_tap/t/358_data_plane_flip_e2e.pl index 6b4bbb8b69..944699b5c5 100644 --- a/src/test/cluster_tap/t/358_data_plane_flip_e2e.pl +++ b/src/test/cluster_tap/t/358_data_plane_flip_e2e.pl @@ -24,6 +24,13 @@ # # Spec: spec-7.2-ic-data-plane-decoupling.md (flip commit) # +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +# IDENTIFICATION +# src/test/cluster_tap/t/358_data_plane_flip_e2e.pl +# #------------------------------------------------------------------------- use strict; From 8a69f52847db9c70e7faf96330f180d687c02fd0 Mon Sep 17 00:00:00 2001 From: SqlRush Date: Fri, 10 Jul 2026 22:13:17 +0800 Subject: [PATCH 21/21] ci(nightly): spec-7.3 review P1-3 -- shard t/358-360 (data-plane flip + fault family) t/358 and t/360 were in no CI matrix: the nightly shard ranges skipped 358-360 and fast.yml's whitelist never included them, so neither file ever ran in automation. Add a stage7-data-plane shard covering 358-360 (t/359 is an unused reserved number; the range matcher iterates existing files) and drop the stale 'reserved by the parallel spec-7.2 lane' comment -- that lane landed and the reservation is resolved. Spec: spec-7.3-lms-worker-pool.md --- .github/workflows/nightly.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 30fc3e1e9a..55cbb48402 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -183,10 +183,16 @@ 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 (t/359 is an unused reserved number). Own shard: the + # t/360 value gate + reconnect deadline need the wall clock. + - { 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 }