@@ -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