Skip to content

Commit 07c709a

Browse files
author
SqlRush
committed
feat(cluster): crash-rejoin boot barrier + off-path rejoin tick (Shape A checkpoint)
Closes the fail-OPEN crash-rejoin hole for the SLOW-rejoin case and the boot-to-decision race; the FAST-rejoin-within-deadband residual needs a formation-level signal (escalated, NOT claimed done). Mechanism (fail-closed, off path only; online_join=on untouched): - New per-incarnation shmem flag ClusterGrdShared.offpath_boot_decided (init 0). The phase gate (cluster_gcs_block.c) fences a self-home block RECOVERING while the flag is 0, so from process start every backend fail-closes self-home reads AND writes (both reach the gate via cluster_pcm_lock_acquire_buffer) — zero cold-serve window. - cluster_reconfig_offpath_rejoin_tick(): the online_join=off counterpart of the joiner self-tick. already_running -> arm self-fence({self}) then demote self_join_admitted to 0 (8.A: never raised to 1 before the fence is armed); bootstrap-at-initial or single node -> lift the barrier. Measured (scratchpad bounce-read-p0/rejoin-state-measured.log): - SLOW rejoin (survivor advanced epoch first): tick detects already_running, arms the fence, self demotes to JOINING. WORKS. - FAST rejoin (node restarts within the survivor dead-deadband, survivor still at epoch INITIAL): already_running is false and bootstrap_quorum_at_initial cannot distinguish a co-boot from a long-running-but-not-yet-reconfigured peer -> mis-lifts -> t/404 L4/L5 still RED. This gap is pre-existing and affects online_join=on too (existing tests only do slow rejoins). The durable prior-self-epoch signal is erased by qvotec_poll_once (writes self slot before reading, cluster_qvotec.c:578) — robust closure is a formation-level change. Spec: spec-5.16 (extended to online_join=off crash-rejoin).
1 parent 21f3f6a commit 07c709a

5 files changed

Lines changed: 197 additions & 1 deletion

File tree

src/backend/cluster/cluster_gcs_block.c

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,28 @@ cluster_gcs_block_phase_for_tag(BufferTag tag)
14501450
*/
14511451
static_master = cluster_gcs_lookup_master_static(tag);
14521452

1453+
/*
1454+
* TT lane / crash-rejoin re-declare barrier (Shape A) — off-path boot
1455+
* barrier. With cluster.online_join=off a node that boots into a running
1456+
* cluster self-admits immediately (cluster_reconfig.c:206) with an EMPTY
1457+
* GRD and NO re-declare episode: for a block whose STATIC home is self,
1458+
* the acquire path would find master==self, read the empty local GRD, and
1459+
* cold-grant from the stale/empty disk page — a silent stale READ and a
1460+
* silently-diverging WRITE (the P0). Until the off-path rejoin tick has
1461+
* classified this incarnation (crash-rejoin -> self-fence armed;
1462+
* bootstrap -> nothing), self cannot prove its home blocks' ownership, so
1463+
* fence them RECOVERING. Both reads and writes reach this gate via
1464+
* cluster_pcm_lock_acquire_buffer, so this closes the boot-to-decision
1465+
* race with ZERO cold-serve window (Rule 8.A: uncertain -> fail-closed).
1466+
* Skipped for online_join=on (its admission + join fence govern) and for
1467+
* a single declared node (no peer can hold a conflicting copy).
1468+
*/
1469+
if (!cluster_online_join && static_master == cluster_node_id
1470+
&& cluster_conf_node_count() > 1 && !cluster_grd_offpath_boot_decided()) {
1471+
cluster_grd_inc_join_block_failclosed();
1472+
return GCS_BLOCK_RECOVERING;
1473+
}
1474+
14531475
/*
14541476
* spec-5.16 D3 (r1 P1-C) — online-join PCM block snap-back fence, placed
14551477
* BEFORE the non-DEAD-static-master early NORMAL below. When a joiner (a

src/backend/cluster/cluster_grd.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,8 @@ cluster_grd_shmem_init(void)
763763
for (i = 0; i < CLUSTER_MAX_NODES; i++)
764764
pg_atomic_init_u64(&cluster_grd_state->join_pcm_fence_member_epoch[i], 0);
765765
pg_atomic_init_u32(&cluster_grd_state->recovery_direction, (uint32)GRD_REMASTER_DIR_NONE);
766+
/* Shape A: off-path boot barrier starts UNDECIDED (fail-closed). */
767+
pg_atomic_init_u32(&cluster_grd_state->offpath_boot_decided, 0);
766768
pg_atomic_init_u64(&cluster_grd_state->join_remaster_started_count, 0);
767769
pg_atomic_init_u64(&cluster_grd_state->join_remaster_done_count, 0);
768770
pg_atomic_init_u64(&cluster_grd_state->join_shards_remastered_count, 0);
@@ -1799,6 +1801,36 @@ cluster_grd_inc_join_block_failclosed(void)
17991801
pg_atomic_fetch_add_u64(&cluster_grd_state->join_block_recovering_failclosed_count, 1);
18001802
}
18011803

1804+
/*
1805+
* Shape A (crash-rejoin re-declare barrier) — off-path boot-barrier flag.
1806+
*
1807+
* cluster_grd_offpath_boot_decided() -- false until the off-path rejoin
1808+
* tick has classified this incarnation (bootstrap vs crash-rejoin). The
1809+
* phase gate fences self-home blocks RECOVERING while false, so a node
1810+
* that self-admits at boot with cluster.online_join=off cannot cold-serve
1811+
* its home blocks before it has proven their ownership (Rule 8.A).
1812+
* Defaults DECIDED (true) when the GRD region is absent so a cluster-off
1813+
* build never fences.
1814+
*/
1815+
bool
1816+
cluster_grd_offpath_boot_decided(void)
1817+
{
1818+
if (cluster_grd_state == NULL)
1819+
return true;
1820+
return pg_atomic_read_u32(&cluster_grd_state->offpath_boot_decided) != 0;
1821+
}
1822+
1823+
/* Mark the off-path boot decision complete (idempotent; single writer = the
1824+
* reconfig LMON tick). After this the boot barrier lifts; on a crash-rejoin
1825+
* the caller has already armed the self-fence, which keeps home blocks
1826+
* RECOVERING via the existing join fence check. */
1827+
void
1828+
cluster_grd_set_offpath_boot_decided(void)
1829+
{
1830+
if (cluster_grd_state != NULL)
1831+
pg_atomic_write_u32(&cluster_grd_state->offpath_boot_decided, 1);
1832+
}
1833+
18021834
/* spec-4.6 D5 — bulk counter snapshot for the dump path. */
18031835
void
18041836
cluster_grd_recovery_counters_snapshot(ClusterGrdRecoveryCounters *out)

src/backend/cluster/cluster_reconfig.c

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,96 @@ cluster_reconfig_joiner_self_tick(void)
21362136
}
21372137

21382138

2139+
/*
2140+
* cluster_reconfig_offpath_rejoin_tick -- crash-rejoin re-declare barrier
2141+
* (Shape A), the cluster.online_join=off counterpart of the joiner self-tick.
2142+
*
2143+
* With online_join=off the joiner self-tick above early-returns, so a node
2144+
* that crash-restarts into a running cluster keeps its shmem-init
2145+
* self_join_admitted = 1 (cluster_reconfig.c:206) and boots straight to a
2146+
* writable MEMBER with an EMPTY GRD and no re-declare episode. For a block
2147+
* whose static home is self, the acquire path then cold-grants from the
2148+
* stale/empty disk page (silent stale read / silently-diverging write — the
2149+
* P0). The phase-gate boot barrier (cluster_gcs_block.c) fences self-home
2150+
* blocks RECOVERING from process start (the flag defaults 0) until THIS tick
2151+
* classifies the incarnation, so there is zero cold-serve window.
2152+
*
2153+
* Decision (LMON single-writer; the barrier flag and self_join_admitted are
2154+
* both flipped here):
2155+
* - single declared node -> decided, no fence (no peer can conflict)
2156+
* - crash-rejoin (already run) -> arm the self-fence, THEN demote
2157+
* self_join_admitted to 0 (Rule 8.A: never
2158+
* raised to 1 before the fence is armed);
2159+
* home blocks stay RECOVERING via the join
2160+
* fence, writes fail-closed 53R60. The
2161+
* survivor re-declare self-heal (fence
2162+
* lift) is a separate spec (Shape B).
2163+
* - cold bootstrap at INITIAL -> decided, no fence (fresh cluster, no
2164+
* stale home blocks)
2165+
* - undecided -> leave the barrier up (fail-closed),
2166+
* retry next tick
2167+
*
2168+
* online_join=on takes its own joiner_self_tick / note_self_admitted path
2169+
* and never enters here.
2170+
*/
2171+
static void
2172+
cluster_reconfig_offpath_rejoin_tick(void)
2173+
{
2174+
static bool offpath_decided_local = false;
2175+
2176+
if (ReconfigShmem == NULL || cluster_online_join)
2177+
return; /* off path only */
2178+
if (cluster_node_id < 0 || cluster_node_id >= CLUSTER_MAX_NODES)
2179+
return;
2180+
if (offpath_decided_local)
2181+
return; /* once per incarnation (LMON-local) */
2182+
if (cluster_reconfig_is_removed_unlocked(cluster_node_id))
2183+
return; /* a removed node keeps its 53R64 self-demote gate */
2184+
2185+
if (cluster_conf_node_count() <= 1) {
2186+
/* Lone declared node: no peer can hold a conflicting copy, so there is
2187+
* nothing to re-declare — decide at once so the boot barrier never
2188+
* fences a single-node deployment. */
2189+
cluster_grd_set_offpath_boot_decided();
2190+
offpath_decided_local = true;
2191+
return;
2192+
}
2193+
2194+
if (cluster_reconfig_cluster_already_running()) {
2195+
uint8 self_set[CLUSTER_RECONFIG_DEAD_BITMAP_BYTES] = { 0 };
2196+
2197+
self_set[cluster_node_id >> 3] = (uint8)(1u << (cluster_node_id & 7));
2198+
cluster_grd_arm_join_pcm_fence(self_set); /* fence FIRST (8.A) */
2199+
2200+
LWLockAcquire(&ReconfigShmem->lock, LW_EXCLUSIVE);
2201+
ReconfigShmem->self_join_admitted = 0; /* then close the write gate */
2202+
LWLockRelease(&ReconfigShmem->lock);
2203+
2204+
cluster_grd_set_offpath_boot_decided();
2205+
offpath_decided_local = true;
2206+
2207+
ereport(LOG,
2208+
(errmsg("cluster membership: node %d crash-rejoined a running cluster with "
2209+
"cluster.online_join=off — home blocks fenced and writes closed "
2210+
"(53R60) to avoid serving stale ownership",
2211+
cluster_node_id),
2212+
errhint("Enable cluster.online_join for an online re-declare rejoin, or "
2213+
"cold-restart the cluster. Reads of peer-mastered blocks and "
2214+
"non-home work are unaffected.")));
2215+
return;
2216+
}
2217+
2218+
if (cluster_reconfig_bootstrap_quorum_at_initial()) {
2219+
/* Cold bootstrap: fresh cluster at INITIAL, no stale home blocks. */
2220+
cluster_grd_set_offpath_boot_decided();
2221+
offpath_decided_local = true;
2222+
return;
2223+
}
2224+
2225+
/* UNDECIDED: keep the boot barrier up (fail-closed) and retry next tick. */
2226+
}
2227+
2228+
21392229
/* ============================================================
21402230
* Step 2 D2 — cluster_reconfig_lmon_tick body.
21412231
*
@@ -2208,6 +2298,14 @@ cluster_reconfig_lmon_tick(void)
22082298
*/
22092299
cluster_reconfig_joiner_self_tick();
22102300

2301+
/*
2302+
* Shape A (crash-rejoin re-declare barrier) — the online_join=off
2303+
* counterpart: arm the self-fence + close the write gate if THIS node
2304+
* crash-rejoined a running cluster, and lift the boot barrier once the
2305+
* bootstrap-vs-rejoin classification is proven. No-op on online_join=on.
2306+
*/
2307+
cluster_reconfig_offpath_rejoin_tick();
2308+
22112309
/*
22122310
* spec-5.15 D1 (INV-J8): the membership-state table — NOT raw CSSD — is the
22132311
* decision SSOT for the survivor / coordinator set. Maintain it and build

src/include/cluster/cluster_grd.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,19 @@ typedef struct ClusterGrdShared {
398398
pg_atomic_uint64 join_pcm_fence_member_epoch[CLUSTER_MAX_NODES];
399399
pg_atomic_uint32 recovery_direction;
400400

401+
/*
402+
* TT lane / crash-rejoin re-declare barrier (Shape A) — off-path boot
403+
* barrier. 0 = the online_join=off rejoin/bootstrap decision has NOT
404+
* run this incarnation, so this node cannot prove ownership of its
405+
* home blocks; the phase gate fences self-home blocks RECOVERING until
406+
* it flips (fail-closed boot-race elimination). Set to 1 by the
407+
* off-path rejoin tick once it has DECIDED (crash-rejoin -> also armed
408+
* the self-fence; cold-bootstrap -> no fence). Per-incarnation:
409+
* re-zeroed by the !found shmem init. online_join=on never consults
410+
* it (that path has its own admission + join fence).
411+
*/
412+
pg_atomic_uint32 offpath_boot_decided;
413+
401414
/* spec-5.16 D5 — join-direction remaster counters (dump_grd grd_recovery
402415
* segment; kept distinct from the failure-driven remaster_* counters so
403416
* ops can tell the two remaster kinds apart — §8 Q6-A). */
@@ -669,6 +682,9 @@ extern void cluster_grd_inc_stale_request_drop(void);
669682
extern void cluster_grd_inc_block_path_failclosed(void);
670683
/* spec-5.16 D5 — join-direction 53R9L fail-closed bump (requester + master gate). */
671684
extern void cluster_grd_inc_join_block_failclosed(void);
685+
/* Shape A (crash-rejoin re-declare barrier) — off-path boot barrier flag. */
686+
extern bool cluster_grd_offpath_boot_decided(void);
687+
extern void cluster_grd_set_offpath_boot_decided(void);
672688

673689
/* spec-4.6 D5 — bulk snapshot of the 13 grd_recovery counters for the
674690
* pg_cluster_state dump (category 'grd_recovery'; one t/249 leg each). */

src/test/cluster_tap/t/404_crash_rejoin_stale_read_write_2node.pl

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ sub membership_state
162162
diag("selected vuln=" . ($vuln // 'NONE') . " safe=" . ($safe // 'none'));
163163

164164
SKIP: {
165-
skip 'no node1-home candidate surfaced this run', 3 unless defined $vuln;
165+
skip 'no node1-home candidate surfaced this run', 4 unless defined $vuln;
166166

167167
# ========================================================
168168
# L4 HARD: node1's read of its home table is COHERENT or FAIL-CLOSED,
@@ -187,6 +187,34 @@ sub membership_state
187187
ok($wr != 0 || ($n1 eq $n0),
188188
'L5 HARD: home-block write is fail-closed or coherent, never a silent split');
189189

190+
# ========================================================
191+
# L5b HARD (boot-race, Shape A命门): reads issued CONCURRENTLY, the
192+
# instant node1 accepts connections after a fresh bounce, must ALL be
193+
# fail-closed for a node1-home table — not one silent 0 may slip
194+
# through the boot-to-decision window. The phase-gate boot barrier
195+
# (flag defaults 0 at shmem init) fences self-home blocks RECOVERING
196+
# from process start, so every early read errors 53R9L; RED on the
197+
# pre-fix binary (a burst of silent 0s).
198+
# ========================================================
199+
$pair->node1->stop('fast');
200+
$pair->node1->start; # do NOT wait for settle — probe the boot window
201+
my ($silent0, $failclosed, $coherent) = (0, 0, 0);
202+
for my $i (1 .. 40)
203+
{
204+
my ($rc, $out, $err) = psql_row($pair->node1, "SELECT count(*) FROM $vuln");
205+
if ($rc != 0) { $failclosed++; }
206+
elsif ($out eq '0') { $silent0++; }
207+
else { $coherent++; } # 64 = decided+served
208+
last if $coherent >= 3; # barrier lifted + coherent: window over
209+
usleep(150_000);
210+
}
211+
diag("L5b boot-window reads: fail-closed=$failclosed silent0=$silent0 coherent=$coherent");
212+
is($silent0, 0,
213+
'L5b HARD: zero silent 0-row reads in the boot-to-decision window');
214+
# resettle for L6
215+
$pair->wait_for_peer_state(0, 1, 'connected', 60);
216+
usleep(1_000_000);
217+
190218
# ========================================================
191219
# L6 HARD (blast radius): node1-first / node0-last flush, then a full
192220
# restart — node0's 64 committed rows must SURVIVE on disk. RED

0 commit comments

Comments
 (0)