Skip to content

Commit 7da2233

Browse files
authored
Merge pull request #58 from sqlrush/fix/s3-crash-rejoin-redeclare-barrier
fix(cluster): crash-rejoin re-declare barrier (Shape A) + cold-GRD fail-closed
2 parents cc7ad19 + 32edefe commit 7da2233

14 files changed

Lines changed: 804 additions & 8 deletions

src/backend/cluster/cluster_debug.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,10 @@ dump_grd_recovery(ReturnSetInfo *rsinfo)
13711371
fmt_int64((int64)c.join_block_views_rebuilt));
13721372
emit_row(rsinfo, "grd_recovery", "join_block_recovering_failclosed",
13731373
fmt_int64((int64)c.join_block_recovering_failclosed));
1374+
/* Shape A (crash-rejoin re-declare barrier): off-path crash-rejoin fence-arm
1375+
* events (standalone counter, not part of the snapshot struct). */
1376+
emit_row(rsinfo, "grd_recovery", "offpath_crash_rejoin_fenced",
1377+
fmt_int64((int64)cluster_grd_offpath_crash_rejoin_fenced_count()));
13741378
}
13751379

13761380
/*

src/backend/cluster/cluster_gcs_block.c

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1275,9 +1275,43 @@ cluster_gcs_block_fallback_verify_refresh(BufferDesc *buf, BufferTag tag, SCN ex
12751275
GcsLostWriteVerdict verdict;
12761276
bool refreshed = false; /* S3 forensics — storage re-read happened */
12771277

1278-
if (buf == NULL || !SCN_VALID(expected_scn))
1278+
if (buf == NULL)
12791279
return;
12801280

1281+
/*
1282+
* fix 2 (crash-rejoin re-declare barrier, defense in depth): an InvalidScn
1283+
* master watermark normally SKIPs (not SCN-tracked / old-binary master /
1284+
* holder re-ack). But if THIS self-home block is under the off-path crash-
1285+
* rejoin fence, the local GRD watermark was wiped by the restart, so an
1286+
* Invalid watermark can mask a stale home block — fail-closed instead of
1287+
* SKIP, except for a genuine extension block (never cross-node written).
1288+
* This is a second line behind the phase-gate boot barrier, which already
1289+
* fences the self-home block before the acquire reaches here.
1290+
*/
1291+
{
1292+
bool self_fenced
1293+
= (!cluster_online_join && cluster_gcs_lookup_master_static(tag) == cluster_node_id
1294+
&& cluster_conf_node_count() > 1 && !cluster_grd_offpath_boot_decided());
1295+
ClusterColdGrdVerdict cv = cluster_gcs_cold_grd_watermark_verdict(
1296+
SCN_VALID(expected_scn), self_fenced,
1297+
self_fenced && cluster_bufmgr_block_is_extension_for_gcs(tag));
1298+
1299+
if (cv == CLUSTER_COLD_GRD_SKIP)
1300+
return;
1301+
if (cv == CLUSTER_COLD_GRD_FAIL_CLOSED) {
1302+
pg_atomic_fetch_add_u64(&ClusterGcsBlock->fallback_scn_failclosed_count, 1);
1303+
ereport(ERROR,
1304+
(errcode(ERRCODE_CLUSTER_GCS_BLOCK_RESOURCE_RECOVERING),
1305+
errmsg("crash-rejoin: cannot prove home block ownership after restart "
1306+
"(cold GRD watermark) for tag spc=%u db=%u rel=%u block=%u",
1307+
tag.spcOid, tag.dbOid, tag.relNumber, tag.blockNum),
1308+
errhint("The block resource is recovering after an unclean restart; retry the "
1309+
"transaction, or enable cluster.online_join for an online re-declare "
1310+
"rejoin.")));
1311+
}
1312+
/* CLUSTER_COLD_GRD_PROVE: expected_scn valid — run the normal verdict. */
1313+
}
1314+
12811315
page_scn = cluster_bufmgr_read_block_scn_for_gcs(buf);
12821316
verdict = gcs_block_lost_write_verdict(expected_scn, page_scn);
12831317
if (verdict == GCS_LOST_WRITE_PASS) {
@@ -1450,6 +1484,28 @@ cluster_gcs_block_phase_for_tag(BufferTag tag)
14501484
*/
14511485
static_master = cluster_gcs_lookup_master_static(tag);
14521486

1487+
/*
1488+
* TT lane / crash-rejoin re-declare barrier (Shape A) — off-path boot
1489+
* barrier. With cluster.online_join=off a node that boots into a running
1490+
* cluster self-admits immediately (cluster_reconfig.c:206) with an EMPTY
1491+
* GRD and NO re-declare episode: for a block whose STATIC home is self,
1492+
* the acquire path would find master==self, read the empty local GRD, and
1493+
* cold-grant from the stale/empty disk page — a silent stale READ and a
1494+
* silently-diverging WRITE (the P0). Until the off-path rejoin tick has
1495+
* classified this incarnation (crash-rejoin -> self-fence armed;
1496+
* bootstrap -> nothing), self cannot prove its home blocks' ownership, so
1497+
* fence them RECOVERING. Both reads and writes reach this gate via
1498+
* cluster_pcm_lock_acquire_buffer, so this closes the boot-to-decision
1499+
* race with ZERO cold-serve window (Rule 8.A: uncertain -> fail-closed).
1500+
* Skipped for online_join=on (its admission + join fence govern) and for
1501+
* a single declared node (no peer can hold a conflicting copy).
1502+
*/
1503+
if (!cluster_online_join && static_master == cluster_node_id && cluster_conf_node_count() > 1
1504+
&& !cluster_grd_offpath_boot_decided()) {
1505+
cluster_grd_inc_join_block_failclosed();
1506+
return GCS_BLOCK_RECOVERING;
1507+
}
1508+
14531509
/*
14541510
* spec-5.16 D3 (r1 P1-C) — online-join PCM block snap-back fence, placed
14551511
* BEFORE the non-DEAD-static-master early NORMAL below. When a joiner (a

src/backend/cluster/cluster_grd.c

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,11 +763,14 @@ 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);
769771
pg_atomic_init_u64(&cluster_grd_state->join_block_views_rebuilt_count, 0);
770772
pg_atomic_init_u64(&cluster_grd_state->join_block_recovering_failclosed_count, 0);
773+
pg_atomic_init_u64(&cluster_grd_state->offpath_crash_rejoin_fenced_count, 0);
771774
}
772775

773776
/* spec-2.15 v0.4 P1.1: entry HTAB allocation gated on GUC. GUC=0
@@ -1799,6 +1802,53 @@ cluster_grd_inc_join_block_failclosed(void)
17991802
pg_atomic_fetch_add_u64(&cluster_grd_state->join_block_recovering_failclosed_count, 1);
18001803
}
18011804

1805+
/*
1806+
* Shape A (crash-rejoin re-declare barrier) — off-path boot-barrier flag.
1807+
*
1808+
* cluster_grd_offpath_boot_decided() -- false until the off-path rejoin
1809+
* tick has classified this incarnation (bootstrap vs crash-rejoin). The
1810+
* phase gate fences self-home blocks RECOVERING while false, so a node
1811+
* that self-admits at boot with cluster.online_join=off cannot cold-serve
1812+
* its home blocks before it has proven their ownership (Rule 8.A).
1813+
* Defaults DECIDED (true) when the GRD region is absent so a cluster-off
1814+
* build never fences.
1815+
*/
1816+
bool
1817+
cluster_grd_offpath_boot_decided(void)
1818+
{
1819+
if (cluster_grd_state == NULL)
1820+
return true;
1821+
return pg_atomic_read_u32(&cluster_grd_state->offpath_boot_decided) != 0;
1822+
}
1823+
1824+
/* Mark the off-path boot decision complete (idempotent; single writer = the
1825+
* reconfig LMON tick). After this the boot barrier lifts; on a crash-rejoin
1826+
* the caller has already armed the self-fence, which keeps home blocks
1827+
* RECOVERING via the existing join fence check. */
1828+
void
1829+
cluster_grd_set_offpath_boot_decided(void)
1830+
{
1831+
if (cluster_grd_state != NULL)
1832+
pg_atomic_write_u32(&cluster_grd_state->offpath_boot_decided, 1);
1833+
}
1834+
1835+
/* Shape A observability: count an off-path crash-rejoin fence-arm (LMON single
1836+
* writer; read for dump_grd + t/404). */
1837+
void
1838+
cluster_grd_inc_offpath_crash_rejoin_fenced(void)
1839+
{
1840+
if (cluster_grd_state != NULL)
1841+
pg_atomic_fetch_add_u64(&cluster_grd_state->offpath_crash_rejoin_fenced_count, 1);
1842+
}
1843+
1844+
uint64
1845+
cluster_grd_offpath_crash_rejoin_fenced_count(void)
1846+
{
1847+
if (cluster_grd_state == NULL)
1848+
return 0;
1849+
return pg_atomic_read_u64(&cluster_grd_state->offpath_crash_rejoin_fenced_count);
1850+
}
1851+
18021852
/* spec-4.6 D5 — bulk counter snapshot for the dump path. */
18031853
void
18041854
cluster_grd_recovery_counters_snapshot(ClusterGrdRecoveryCounters *out)

src/backend/cluster/cluster_qvotec.c

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,34 @@ typedef struct ClusterQvotecShmem {
140140
pg_atomic_uint32 poll_cycle_count;
141141
pg_atomic_uint32 torn_write_detect_count;
142142
pg_atomic_uint32 _pad;
143-
uint8 _reserved[64];
143+
/*
144+
* Merge-order reservation (守门 07-15): the convert-queue lane claims
145+
* offset 64..71 for its self_incarnation (pg_atomic_uint64, commit
146+
* ee536b5bb7, StaticAssert-pinned). Queue merges first; this lane rebases
147+
* after and drops this placeholder so self_incarnation occupies 64..71 and
148+
* prior_unclean_death stays at 72. Keeping the byte layout identical now
149+
* makes that rebase a no-op on the wire/shmem image.
150+
*/
151+
uint8 _reserved_queue_self_incarnation[8]; /* offset 64..71 */
152+
/*
153+
* Crash-rejoin re-declare barrier (Shape A) — set ONCE at qvotec startup
154+
* (before the READY publish), read-only thereafter: 1 iff this node's
155+
* prior-incarnation self-slot on the voting disk still had the ALIVE flag
156+
* set (a clean shutdown clears it via qvotec_clear_self_alive_on_clean_
157+
* shutdown; a crash / immediate stop does NOT), i.e. this boot follows an
158+
* UNCLEAN death. The off-path rejoin tick fences self-home blocks +
159+
* closes the write gate on this, so a crash-rejoined node never cold-
160+
* serves stale ownership even when it restarts faster than the survivor's
161+
* dead-deadband (the epoch signal is INITIAL on both sides in that race).
162+
*/
163+
pg_atomic_uint32 prior_unclean_death; /* offset 72..75 */
164+
uint8 _reserved[52];
144165
} ClusterQvotecShmem;
145166

146167
StaticAssertDecl(sizeof(ClusterQvotecShmem) == 128,
147168
"ClusterQvotecShmem must be exactly 128 bytes (2 cache lines)");
169+
StaticAssertDecl(offsetof(ClusterQvotecShmem, prior_unclean_death) == 72,
170+
"prior_unclean_death must sit at offset 72 (queue lane owns 64..71)");
148171

149172

150173
static ClusterQvotecShmem *QvotecShmem = NULL;
@@ -267,6 +290,9 @@ cluster_qvotec_shmem_init(void)
267290
pg_atomic_init_u32(&QvotecShmem->poll_cycle_count, 0);
268291
pg_atomic_init_u32(&QvotecShmem->torn_write_detect_count, 0);
269292
pg_atomic_init_u32(&QvotecShmem->_pad, 0);
293+
memset(QvotecShmem->_reserved_queue_self_incarnation, 0,
294+
sizeof(QvotecShmem->_reserved_queue_self_incarnation));
295+
pg_atomic_init_u32(&QvotecShmem->prior_unclean_death, 0);
270296
memset(QvotecShmem->_reserved, 0, sizeof(QvotecShmem->_reserved));
271297
}
272298
}
@@ -372,6 +398,23 @@ cluster_qvotec_get_disks_total_count(void)
372398
return (int)pg_atomic_read_u32(&QvotecShmem->disks_total_count);
373399
}
374400

401+
/*
402+
* cluster_qvotec_prior_unclean_death -- crash-rejoin re-declare barrier
403+
* (Shape A). True iff this node's prior-incarnation self-slot on the voting
404+
* disk still carried the ALIVE flag at startup (an unclean death: a crash /
405+
* immediate stop that skipped the clean-shutdown ALIVE blank). Latched once
406+
* before the READY publish; stable for the incarnation. False when qvotec is
407+
* absent (no voting disks) so a diskless / single-node deployment is never
408+
* fenced by this signal.
409+
*/
410+
bool
411+
cluster_qvotec_prior_unclean_death(void)
412+
{
413+
if (QvotecShmem == NULL)
414+
return false;
415+
return pg_atomic_read_u32(&QvotecShmem->prior_unclean_death) != 0;
416+
}
417+
375418
uint64
376419
cluster_qvotec_get_current_epoch_at_boot(void)
377420
{
@@ -1749,7 +1792,7 @@ ClusterQvotecMain(void)
17491792
bool ghost_fresh = false;
17501793
int d;
17511794

1752-
for (d = 0; d < qvotec_n_disks && !ghost_fresh; d++) {
1795+
for (d = 0; d < qvotec_n_disks; d++) {
17531796
ClusterVotingSlot probe;
17541797
ClusterVotingDiskIoState rrc;
17551798

@@ -1759,14 +1802,29 @@ ClusterQvotecMain(void)
17591802
if (probe.generation == 0)
17601803
continue; /* never written */
17611804
if (!(probe.flags & CLUSTER_VOTING_SLOT_FLAG_ALIVE))
1762-
continue; /* prior shutdown cleared ALIVE — ok */
1805+
continue; /* prior shutdown cleared ALIVE — clean death, ok */
17631806
if (probe.incarnation == qvotec_self_incarnation)
17641807
continue; /* same incarnation — impossible but defensive */
1808+
1809+
/*
1810+
* Crash-rejoin re-declare barrier (Shape A) — a prior-incarnation
1811+
* self-slot that still carries ALIVE means the previous postmaster
1812+
* of THIS node died WITHOUT running the clean-shutdown blank
1813+
* (qvotec_clear_self_alive_on_clean_shutdown), i.e. an UNCLEAN
1814+
* death. Latch it REGARDLESS of freshness: a stale ALIVE ghost is
1815+
* still proof we crashed (we just crashed longer ago), and the
1816+
* fence must engage on a fast rejoin where the survivor has not yet
1817+
* advanced its epoch (the epoch signal is INITIAL on both sides).
1818+
* Single writer, before the READY publish; read-only afterwards.
1819+
*/
1820+
if (QvotecShmem != NULL)
1821+
pg_atomic_write_u32(&QvotecShmem->prior_unclean_death, 1);
1822+
17651823
if (probe.heartbeat_ts_us == 0)
17661824
continue;
17671825
if (now_us > probe.heartbeat_ts_us
17681826
&& (now_us - probe.heartbeat_ts_us) > heartbeat_timeout_us)
1769-
continue; /* already stale */
1827+
continue; /* already stale — no fast-restart Q6 sleep needed */
17701828
ghost_fresh = true;
17711829
}
17721830

0 commit comments

Comments
 (0)