Skip to content

Commit 25efd15

Browse files
AIQnetLabclaude
andcommitted
scale: fix super eligibility epoch-boundary flicker + light-ping/FSM/apply-stuck hardening
- super eligibility: recency prev = cur-1 spans the epoch boundary; the per-epoch reset ejected the whole non-genesis eligible set for the first subwindow of every epoch (a deterministic flicker at >120 supers). Pure fn of scan_end; boundary test updated. - light-ping selection: per-window slot-index cache, rebuilt only on window/size change; O(bucket)/tick instead of an O(N log N) clone+sort of the whole registry every 60s tick. - light liveness FSM: persist is_active in a separate metadata key (off the registry_root row, zero consensus impact); a genesis restart no longer resurrects dropped nodes. - pipeline watchdog: 0-sentinel progress clock so the genesis-boot wait cannot emit a spurious apply_stuck CRIT (no fleet-wide false-CRIT storm on coordinated restart). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dd31db1 commit 25efd15

4 files changed

Lines changed: 113 additions & 88 deletions

File tree

development/qnet-integration/src/block_pipeline.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,8 +1253,10 @@ impl BlockPipeline {
12531253
const STUCK_THRESHOLD_MS: u64 = 30_000;
12541254
let mut last_verified: u64 = 0;
12551255
let mut last_applied: u64 = 0;
1256-
let mut last_verified_progress_ms: u64 = now_ms();
1257-
let mut last_applied_progress_ms: u64 = now_ms();
1256+
// 0 sentinel = "no verify/apply seen yet"; the dump guards require != 0, so the boot wait
1257+
// (nothing to apply) can't trip a spurious CRIT — stall is measured from first real progress.
1258+
let mut last_verified_progress_ms: u64 = 0;
1259+
let mut last_applied_progress_ms: u64 = 0;
12581260
let mut last_verify_dump_ms: u64 = 0;
12591261
let mut last_apply_dump_ms: u64 = 0;
12601262
let mut interval = tokio::time::interval(WATCHDOG_TICK);
@@ -1287,6 +1289,7 @@ impl BlockPipeline {
12871289

12881290
// VERIFY STALL DUMP: counter unchanged for ≥30 s and op != idle.
12891291
if verify_stall_ms >= STUCK_THRESHOLD_MS
1292+
&& last_verified_progress_ms != 0
12901293
&& verify_op != PIPELINE_OP_IDLE
12911294
&& now.saturating_sub(last_verify_dump_ms) >= STUCK_THRESHOLD_MS
12921295
{
@@ -1309,6 +1312,7 @@ impl BlockPipeline {
13091312

13101313
// APPLY STALL DUMP: counter unchanged for ≥30 s and op != idle.
13111314
if apply_stall_ms >= STUCK_THRESHOLD_MS
1315+
&& last_applied_progress_ms != 0
13121316
&& apply_op != PIPELINE_OP_IDLE
13131317
&& now.saturating_sub(last_apply_dump_ms) >= STUCK_THRESHOLD_MS
13141318
{

development/qnet-integration/src/node.rs

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -912,15 +912,13 @@ pub fn window_content_from_accum(mb_idx: u64) -> Option<(Vec<[u8; 32]>, Vec<[u8;
912912
/// recency) and that was included in a block at-or-below scan_end. A pure function of the canonical chain
913913
/// ≤ scan_end ⇒ identical on every committee member, with NO live-tip dependence. Scans ≤2 subwindows
914914
/// (~2880 blocks), off the production path; bodies are retained 6 epochs, far beyond this window.
915-
/// (current, previous) global subwindow indices (anchor/1440 = epoch*10 + subwindow) for `scan_end`. The
916-
/// previous counts ONLY within the SAME epoch — at a subwindow-0/epoch boundary it returns current==prev
917-
/// (no previous), mirroring the prior `heartbeat_epoch==hb_epoch` + `if cur_sub>0` bitmask recency. Pure
918-
/// ⇒ unit-tested for the off-by-one + cross-epoch boundary.
915+
/// (current, previous) GLOBAL subwindow indices (anchor/1440) for `scan_end`; prev = cur-1 SPANS the
916+
/// epoch boundary — a Heartbeat from the prior epoch's last subwindow is still recent liveness. The old
917+
/// per-epoch reset (prev=cur at subwindow 0) ejected the whole non-genesis eligible set for the first
918+
/// subwindow of every epoch (the eligibility flicker at scale). Pure fn of scan_end ⇒ deterministic.
919919
fn recency_subwindow_indices(scan_end: u64) -> (u64, u64) {
920920
let cur_idx = scan_end / 1440;
921-
let cur_sub = (scan_end % 14400) / 1440; // 0..9 within the epoch
922-
let prev_idx = if cur_sub > 0 { cur_idx.saturating_sub(1) } else { cur_idx };
923-
(cur_idx, prev_idx)
921+
(cur_idx, cur_idx.saturating_sub(1))
924922
}
925923

926924
fn recent_heartbeat_senders(storage: &crate::storage::Storage, scan_end: u64) -> std::collections::HashSet<String> {
@@ -27610,22 +27608,23 @@ mod tests {
2761027608
assert!(!checkpoint_participation_allowed(false, 0, mb_end)); // syncing, no window → defer
2761127609
}
2761227610

27613-
// Phase-2A recency window (deterministic heartbeat eligibility): current subwindow + previous ONLY
27614-
// within the same epoch. The epoch boundary (subwindow 0) must NOT bridge to the prior epoch's
27615-
// subwindow 9 — that is the off-by-one that would change WHO is eligible vs the old bitmask gate.
27611+
// Phase-2A recency window: current subwindow + the immediately previous GLOBAL subwindow. prev = cur-1
27612+
// SPANS the epoch boundary (prior epoch's subwindow 9 is still recent liveness); the old per-epoch reset
27613+
// (prev=cur at subwindow 0) ejected the whole non-genesis eligible set each epoch start (the flicker).
2761627614
#[test]
2761727615
fn recency_subwindow_indices_boundary() {
27618-
// Mid-epoch: previous = current-1 (same epoch).
27616+
// Mid-epoch: previous = current-1.
2761927617
assert_eq!(recency_subwindow_indices(5 * 1440), (5, 4));
2762027618
assert_eq!(recency_subwindow_indices(5 * 1440 + 100), (5, 4));
27621-
// Epoch start (subwindow 0): no previous within the epoch ⇒ prev == cur (degenerates to {cur}).
27619+
// h0: no previous ⇒ saturating to (0, 0).
2762227620
assert_eq!(recency_subwindow_indices(0), (0, 0));
27623-
assert_eq!(recency_subwindow_indices(14400), (10, 10)); // epoch1 sub0 ⇒ (10,10), NOT (10,9)
27624-
assert_eq!(recency_subwindow_indices(14400 + 50), (10, 10));
27625-
// Epoch1 subwindow 1: previous is sub0 of the SAME epoch (10), never the prior epoch's sub9 (9).
27621+
// Epoch boundary (subwindow 0): prev = cur-1 BRIDGES to the prior epoch's subwindow 9 (no flicker).
27622+
assert_eq!(recency_subwindow_indices(14400), (10, 9));
27623+
assert_eq!(recency_subwindow_indices(14400 + 50), (10, 9));
27624+
// Epoch1 subwindow 1: previous is subwindow 0 of the same epoch.
2762627625
assert_eq!(recency_subwindow_indices(14400 + 1440), (11, 10));
27627-
// Epoch2 subwindow 0: again no bridge.
27628-
assert_eq!(recency_subwindow_indices(2 * 14400), (20, 20));
27626+
// Epoch2 boundary: bridges to epoch1 subwindow 9.
27627+
assert_eq!(recency_subwindow_indices(2 * 14400), (20, 19));
2762927628
}
2763027629

2763127630
// committee_for_height determinism: genesis era ⇒ None (caller uses the genesis committee), and

development/qnet-integration/src/storage.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6645,6 +6645,25 @@ impl Storage {
66456645
self.save_node_registration_inner(node_id, node_type, wallet, reputation, Some(reg_height), Some(burn_tx), vrf_pk)
66466646
}
66476647

6648+
/// Light-node liveness FSM persistence (durable across genesis restart). A separate metadata-CF key,
6649+
/// NOT the registry_root-committed node_ row ⇒ zero consensus impact. Written only on the is_active
6650+
/// FLIP (drop-after-5-misses / reactivate), so a restart does not silently resurrect a dropped node.
6651+
pub fn mark_light_inactive(&self, node_id: &str) {
6652+
if let Some(cf) = self.persistent.db.cf_handle("metadata") {
6653+
let _ = self.persistent.db.put_cf(&cf, format!("lninact_{}", node_id).as_bytes(), b"1");
6654+
}
6655+
}
6656+
pub fn clear_light_inactive(&self, node_id: &str) {
6657+
if let Some(cf) = self.persistent.db.cf_handle("metadata") {
6658+
let _ = self.persistent.db.delete_cf(&cf, format!("lninact_{}", node_id).as_bytes());
6659+
}
6660+
}
6661+
pub fn is_light_inactive(&self, node_id: &str) -> bool {
6662+
self.persistent.db.cf_handle("metadata")
6663+
.and_then(|cf| self.persistent.db.get_cf(&cf, format!("lninact_{}", node_id).as_bytes()).ok().flatten())
6664+
.is_some()
6665+
}
6666+
66486667
fn save_node_registration_inner(&self, node_id: &str, node_type: &str, wallet: &str, reputation: f64, reg_height: Option<u64>, burn_tx: Option<&str>, vrf_pk: Option<&[u8]>) -> IntegrationResult<()> {
66496668
let registry_cf = self.persistent.db.cf_handle("node_registry")
66506669
.ok_or_else(|| IntegrationError::StorageError("node_registry column family not found".to_string()))?;

development/qnet-integration/src/unified_p2p.rs

Lines changed: 72 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2155,8 +2155,12 @@ pub struct SimplifiedP2P {
21552155
/// PRODUCTION: Light Node registry synchronized via gossip
21562156
/// All Super nodes maintain identical registry for deterministic ping assignment
21572157
light_node_registry: Arc<RwLock<HashMap<String, LightNodeRegistrationData>>>,
2158-
2159-
2158+
/// Per-window ping-slot buckets for THIS genesis's shard: (window, registry_len, [slot 0..239]→node_ids).
2159+
/// Rebuilt only on window/size change ⇒ ping selection is O(bucket)/tick, not O(N log N) clone+sort of
2160+
/// the whole registry every tick — scales the 5-genesis pinger to millions of light nodes.
2161+
light_ping_slot_cache: Arc<RwLock<(u64, usize, Vec<Vec<String>>)>>,
2162+
2163+
21602164
/// PRODUCTION: Storage reference for persistent heartbeat storage
21612165
/// SCALABILITY: Each node stores ONLY its own heartbeats in RocksDB (10 records per 4h)
21622166
/// Supports millions of nodes without RAM limitations
@@ -3071,7 +3075,8 @@ impl SimplifiedP2P {
30713075

30723076
// PRODUCTION: Light Node registry for gossip sync
30733077
light_node_registry: Arc::new(RwLock::new(HashMap::new())),
3074-
3078+
light_ping_slot_cache: Arc::new(RwLock::new((u64::MAX, 0, Vec::new()))),
3079+
30753080
// PRODUCTION: Heartbeat history for reward eligibility
30763081
storage: storage, // v2.76: Storage for persistent heartbeat storage
30773082

@@ -16964,6 +16969,9 @@ impl SimplifiedP2P {
1696416969

1696516970
for (node_id, wallet_address, _node_type, registered_at) in nodes {
1696616971
if !registry.contains_key(&node_id) {
16972+
// Restore the persisted liveness FSM instead of resurrecting to active — a dropped node
16973+
// stays dropped across a genesis restart until it re-attests (honors manual reactivation).
16974+
let inactive = self.storage.as_ref().map(|s| s.is_light_inactive(&node_id)).unwrap_or(false);
1696716975
registry.insert(node_id.clone(), LightNodeRegistrationData {
1696816976
node_id,
1696916977
wallet_address,
@@ -16974,8 +16982,8 @@ impl SimplifiedP2P {
1697416982
push_type: PushType::Polling,
1697516983
unified_push_endpoint: None,
1697616984
last_seen: registered_at,
16977-
consecutive_failures: 0,
16978-
is_active: true,
16985+
consecutive_failures: if inactive { 5 } else { 0 },
16986+
is_active: !inactive,
1697916987
ed25519_signature: String::new(),
1698016988
ed25519_public_key: String::new(),
1698116989
ping_pubkey: String::new(), // Populated on re-registration
@@ -17251,95 +17259,89 @@ impl SimplifiedP2P {
1725117259
/// SCALABILITY: 2M pings per Genesis per epoch = 139 pings/sec = easily handled
1725217260
pub fn get_light_nodes_to_ping(&self) -> Vec<(LightNodeRegistrationData, PingerRole)> {
1725317261
let current_slot = Self::get_current_slot();
17262+
let current_window = Self::get_current_window_number();
1725417263
let our_node_id = &self.node_id;
1725517264
let mut result = Vec::new();
17256-
17257-
// v2.89: ONLY Genesis nodes can ping Light nodes
17258-
// This ensures 100% reliability - Genesis never goes offline
17265+
17266+
// v2.89: ONLY Genesis nodes ping Light nodes (5 fixed shard owners, always online).
1725917267
let is_genesis_node = std::env::var("QNET_BOOTSTRAP_ID")
1726017268
.map(|id| ["001", "002", "003", "004", "005"].contains(&id.as_str()))
1726117269
.unwrap_or(false);
17262-
17263-
if !is_genesis_node {
17264-
// Non-Genesis nodes don't ping Light nodes anymore
17265-
// This prevents data loss when regular nodes go offline
17266-
return result;
17267-
}
17268-
17269-
// Get our Genesis index (0-4) for shard assignment
17270+
if !is_genesis_node { return result; }
1727017271
let our_genesis_idx = std::env::var("QNET_BOOTSTRAP_ID")
17271-
.ok()
17272-
.and_then(|id| id.parse::<usize>().ok())
17273-
.map(|id| id.saturating_sub(1)) // Convert 001-005 to 0-4
17274-
.unwrap_or(0);
17275-
17272+
.ok().and_then(|id| id.parse::<usize>().ok())
17273+
.map(|id| id.saturating_sub(1)).unwrap_or(0);
1727617274
const GENESIS_COUNT: usize = 5;
17277-
17275+
1727817276
if crate::node::is_info() {
1727917277
println!("[INFO][GENESIS-PING] Genesis node {} (idx={}) checking Light nodes to ping slot={}",
1728017278
our_node_id, our_genesis_idx, current_slot);
1728117279
}
17282-
17283-
// Get all Light nodes from registry SORTED for consistent linear sharding
17284-
// v2.89 CRITICAL: Must use same ordering as bitmap creation!
17280+
1728517281
let registry = self.light_node_registry.read();
17286-
let mut all_nodes: Vec<_> = registry.values().cloned().collect();
17287-
all_nodes.sort_by(|a, b| a.node_id.cmp(&b.node_id)); // Sort by node_id
17288-
let total_light_nodes = all_nodes.len();
17289-
17290-
// v2.89: LINEAR SHARDING - each Genesis gets sequential range of indices
17291-
// This matches bitmap creation logic exactly!
17292-
let nodes_per_genesis = (total_light_nodes + GENESIS_COUNT - 1) / GENESIS_COUNT; // Ceiling division
17293-
let my_start = our_genesis_idx * nodes_per_genesis;
17294-
let my_end = std::cmp::min(my_start + nodes_per_genesis, total_light_nodes);
17295-
17296-
for idx in my_start..my_end {
17297-
let node = &all_nodes[idx];
17298-
17299-
// ACTIVITY FILTER: Skip inactive nodes (>5 consecutive failures)
17300-
if !node.is_active || node.consecutive_failures >= 5 {
17301-
continue;
17302-
}
17303-
17304-
// Check if this is the node's ping slot (randomized per window)
17305-
if !Self::is_light_node_ping_slot(&node.node_id) {
17306-
continue;
17282+
let reg_len = registry.len();
17283+
17284+
// Rebuild this genesis's per-slot buckets only when the window rolls or the registry size changes.
17285+
// Linear sharding over the SORTED registry (must match bitmap creation). O(N log N) once per window,
17286+
// not every 60s tick — the O(N log N) clone+sort per tick did not scale to millions of light nodes.
17287+
let need_rebuild = { let c = self.light_ping_slot_cache.read(); c.0 != current_window || c.1 != reg_len };
17288+
if need_rebuild {
17289+
let mut ids: Vec<&String> = registry.keys().collect();
17290+
ids.sort();
17291+
let nodes_per_genesis = (reg_len + GENESIS_COUNT - 1) / GENESIS_COUNT;
17292+
let my_start = our_genesis_idx * nodes_per_genesis;
17293+
let my_end = std::cmp::min(my_start + nodes_per_genesis, reg_len);
17294+
let mut buckets: Vec<Vec<String>> = vec![Vec::new(); 240];
17295+
for i in my_start..my_end {
17296+
let id = ids[i];
17297+
let slot = Self::calculate_randomized_slot(id, current_window) as usize;
17298+
buckets[slot].push(id.clone());
17299+
}
17300+
*self.light_ping_slot_cache.write() = (current_window, reg_len, buckets);
17301+
}
17302+
17303+
// Read the 3 grace slots {cur, cur-1, cur-2} (mod 240) — same window as is_light_node_ping_slot
17304+
// (slot_diff<=2). is_active/consecutive_failures/attested are mutable ⇒ filtered per tick.
17305+
let cache = self.light_ping_slot_cache.read();
17306+
for g in 0..=2u64 {
17307+
let s = ((current_slot + 240 - g) % 240) as usize;
17308+
for node_id in cache.2.get(s).into_iter().flatten() {
17309+
let node = match registry.get(node_id) { Some(n) => n, None => continue };
17310+
if !node.is_active || node.consecutive_failures >= 5 { continue; }
17311+
if self.has_attestation_in_window(node_id) { continue; }
17312+
result.push((node.clone(), PingerRole::Primary));
1730717313
}
17308-
17309-
// Skip if already attested in ANY slot of the current 4h window.
17310-
// The 3-slot retry window is only for failures — if the node already proved
17311-
// liveness this window, no further FCM notifications are needed.
17312-
if self.has_attestation_in_window(&node.node_id) {
17313-
continue;
17314-
}
17315-
17316-
// Genesis is always Primary pinger (no backup needed - Genesis is reliable)
17317-
result.push((node.clone(), PingerRole::Primary));
1731817314
}
17319-
17315+
1732017316
if crate::node::is_debug() && !result.is_empty() {
17321-
println!("[DBG][GENESIS-PING] Genesis {} has {} Light nodes to ping this slot (total registry: {})",
17322-
our_genesis_idx + 1, result.len(), total_light_nodes);
17317+
println!("[DBG][GENESIS-PING] Genesis {} has {} Light nodes to ping this slot (registry: {})",
17318+
our_genesis_idx + 1, result.len(), reg_len);
1732317319
}
17324-
1732517320
result
1732617321
}
1732717322

1732817323
/// Mark Light node as failed (no response to ping)
1732917324
/// After 5 consecutive failures, node is marked inactive
1733017325
pub fn mark_light_node_ping_failed(&self, node_id: &str) {
17331-
let mut registry = self.light_node_registry.write();
17332-
if let Some(node) = registry.get_mut(node_id) {
17333-
node.consecutive_failures = node.consecutive_failures.saturating_add(1);
17334-
17335-
if node.consecutive_failures >= 5 {
17336-
node.is_active = false;
17337-
if crate::node::is_info() {
17338-
println!("[WARN][P2P] Node {} marked inactive after {} consecutive failures",
17339-
node_id, node.consecutive_failures);
17326+
let mut flipped_inactive = false;
17327+
{
17328+
let mut registry = self.light_node_registry.write();
17329+
if let Some(node) = registry.get_mut(node_id) {
17330+
node.consecutive_failures = node.consecutive_failures.saturating_add(1);
17331+
if node.consecutive_failures >= 5 && node.is_active {
17332+
node.is_active = false;
17333+
flipped_inactive = true;
17334+
if crate::node::is_info() {
17335+
println!("[WARN][P2P] Node {} marked inactive after {} consecutive failures",
17336+
node_id, node.consecutive_failures);
17337+
}
1734017338
}
1734117339
}
1734217340
}
17341+
// Persist the drop so a genesis restart does not silently resurrect it (honors manual reactivation).
17342+
if flipped_inactive {
17343+
if let Some(s) = &self.storage { s.mark_light_inactive(node_id); }
17344+
}
1734317345
}
1734417346

1734517347
/// Update push_type + last_seen for a light node (called on token-refresh).
@@ -17372,6 +17374,7 @@ impl SimplifiedP2P {
1737217374
node.is_active = true;
1737317375

1737417376
if was_inactive {
17377+
if let Some(s) = &self.storage { s.clear_light_inactive(node_id); }
1737517378
if crate::node::is_info() {
1737617379
println!("[INFO][P2P] Node {} reactivated after successful ping", node_id);
1737717380
}

0 commit comments

Comments
 (0)