forked from nulang-org/nulang
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcluster.rs
More file actions
2372 lines (2114 loc) · 90.6 KB
/
Copy pathcluster.rs
File metadata and controls
2372 lines (2114 loc) · 90.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Cluster membership system for Nulang's distributed actor runtime.
//!
//! This module manages node identity, cluster membership, heartbeat-based
//! failure detection, and gossip-style state dissemination. Multiple Nulang
//! nodes form a cluster, allowing actors to communicate across machine
//! boundaries.
//!
//! # Architecture
//!
//! Each node maintains a [`ClusterState`] containing a membership table of
//! all known nodes. Nodes exchange heartbeats periodically to detect failures
//! and gossip membership updates to disseminate state changes.
//!
//! # Failure Detection
//!
//! The failure detector uses a simple multi-stage timeout:
//!
//! 1. **Healthy** → nodes are responding to heartbeats.
//! 2. **Suspicious** → a heartbeat has not been received within the timeout.
//! 3. **Failed** → the node has been suspicious for too long and is removed.
//!
//! # Gossip Protocol
//!
//! Membership changes propagate via gossip. Each tick, a node selects a random
//! subset of healthy peers and sends them a compact view of the membership
//! table. When merging incoming gossip, the higher incarnation number wins,
//! ensuring convergence even under partition.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tracing::warn;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Default interval between heartbeats (500ms).
const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(500);
/// Default timeout before marking a node suspicious (2s).
const DEFAULT_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(2);
/// Default duration a node remains suspicious before being marked failed (5s).
const DEFAULT_SUSPICION_DURATION: Duration = Duration::from_secs(5);
/// How long to keep failed nodes in the table before purging them (60s).
const FAILED_NODE_RETENTION: Duration = Duration::from_secs(60);
/// Number of random gossip targets selected each tick.
const GOSSIP_FANOUT: usize = 2;
/// Default interval between liveness probes to Failed members (5s).
const DEFAULT_PROBE_INTERVAL: Duration = Duration::from_secs(5);
/// Default size of the active view: the maximum number of members a
/// node heartbeats directly. Heartbeats are the O(N) data plane; the
/// active view bounds them so cluster-wide heartbeat traffic stays
/// O(N × active_view_size) instead of O(N²).
const DEFAULT_ACTIVE_VIEW_SIZE: usize = 4;
/// Default size of the passive view: the pool of known-but-not-
/// heartbeated members used to repair the active view when a member
/// fails.
const DEFAULT_PASSIVE_VIEW_SIZE: usize = 20;
/// How long a probationary (promoted) member has to reciprocate our
/// heartbeats before it is demoted back to the passive view. Uses the
/// heartbeat timeout: a live member's reply arrives within one
/// heartbeat interval, so anything beyond the timeout is dead weight.
const PROBATION_TIMEOUT: Duration = DEFAULT_HEARTBEAT_TIMEOUT;
/// How many passive-view members that recently heartbeated us we reply
/// to per heartbeat round. Without replies, a member whose active view
/// filled up would stop heartbeating us and our detector would
/// false-fail it; with `REPLY_SLOTS` slots rotated round-robin, every
/// pinger is answered within the failure-detection window (4 slots ×
/// 500 ms = 2 s = `DEFAULT_HEARTBEAT_TIMEOUT`) for clusters of up to
/// ~80 nodes, keeping heartbeats O(active + probationary + replies).
pub(crate) const REPLY_SLOTS: usize = 4;
// ---------------------------------------------------------------------------
// NodeId
// ---------------------------------------------------------------------------
/// Unique identifier for a node in the cluster.
///
/// When TLS is active, derived from the BLAKE3 hash of the node's
/// X.509 certificate DER encoding — a cryptographic identity that
/// cannot be spoofed by an attacker who controls a socket address.
/// When plaintext is in use, derived from a hash of the node's
/// socket address for backward compatibility.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId(pub u64);
impl NodeId {
/// Create a `NodeId` from a socket address (TCP).
///
/// The id is derived with `DefaultHasher` so repeated calls with the
/// same address yield the same id.
pub fn new(addr: &SocketAddr) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
addr.hash(&mut hasher);
NodeId(hasher.finish())
}
/// Create a `NodeId` from a certificate's DER encoding.
///
/// Uses BLAKE3 (truncated to 64 bits) for a collision-resistant,
/// cryptographically-secure identity bound to the certificate.
/// Two nodes presenting the same certificate receive the same id;
/// two nodes with different certificates are guaranteed distinct ids
/// (modulo the 64-bit truncation, whose collision probability is
/// negligible at any realistic cluster size).
pub fn from_cert_der(cert_der: &[u8]) -> Self {
let hash = ::blake3::hash(cert_der);
let bytes: [u8; 8] = hash.as_bytes()[..8].try_into().unwrap();
NodeId(u64::from_le_bytes(bytes))
}
/// Create a `NodeId` from a transport address (TCP or Unix).
pub fn from_addr(addr: &crate::runtime::network::TransportAddr) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
addr.hash(&mut hasher);
NodeId(hasher.finish())
}
/// The id reserved for the local node.
pub const LOCAL: NodeId = NodeId(0);
}
// ---------------------------------------------------------------------------
// NodeStatus
// ---------------------------------------------------------------------------
/// Health status of a node in the cluster.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeStatus {
/// Node is in the process of joining the cluster.
Joining,
/// Node is active and responding to heartbeats.
Healthy,
/// Node missed a heartbeat and is under suspicion.
Suspicious,
/// Node has been declared failed.
Failed,
/// Node is gracefully leaving the cluster.
Leaving,
}
// ---------------------------------------------------------------------------
// NodeInfo
// ---------------------------------------------------------------------------
/// Information about a node in the cluster.
#[derive(Debug, Clone)]
pub struct NodeInfo {
/// Unique identifier of the node.
pub node_id: NodeId,
/// Network address the node listens on.
pub address: SocketAddr,
/// Current health status.
pub status: NodeStatus,
/// Timestamp of the last received heartbeat.
pub last_heartbeat: Instant,
/// When the node first joined the cluster (from our perspective).
pub joined_at: Instant,
/// Optional key-value metadata (e.g. region, rack, version).
pub metadata: HashMap<String, String>,
}
impl NodeInfo {
/// Create a minimal `NodeInfo` for the given node.
fn new(node_id: NodeId, address: SocketAddr) -> Self {
let now = Instant::now();
NodeInfo {
node_id,
address,
status: NodeStatus::Joining,
last_heartbeat: now,
joined_at: now,
metadata: HashMap::new(),
}
}
}
// ---------------------------------------------------------------------------
// ClusterAction
// ---------------------------------------------------------------------------
/// Actions returned by [`ClusterState::tick`] for the runtime to execute.
///
/// The caller is responsible for serialising and transmitting heartbeats
/// and gossip messages over the network.
#[derive(Debug)]
pub enum ClusterAction {
/// Send a heartbeat to the specified node.
SendHeartbeat { to: NodeId, addr: SocketAddr },
/// Notify that a node has joined the cluster.
NodeJoined { node: NodeId, addr: SocketAddr },
/// Notify that a node has been declared failed.
NodeFailed { node: NodeId },
/// Notify that a node has left the cluster.
NodeLeft { node: NodeId },
/// Send gossip to a random subset of nodes.
SendGossip { targets: Vec<(NodeId, SocketAddr)> },
/// The split-brain resolver decided the local node should leave the
/// cluster (partition minority / below quorum).
Down { node: NodeId },
/// Minimal periodic liveness probe to a Failed member, so a healed
/// partition re-joins without an external rejoin.
Probe { to: NodeId, addr: SocketAddr },
}
// ---------------------------------------------------------------------------
// Split-brain resolver
// ---------------------------------------------------------------------------
/// What the local node should do given its current membership view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolverDecision {
/// The local node keeps participating in the cluster.
StayUp,
/// The local node leaves the cluster (partition minority / below quorum).
DownSelf,
}
/// Snapshot of the membership view handed to a [`SplitBrainResolver`].
///
/// Built from the live membership table at tick time; the resolver must
/// treat it as immutable.
#[derive(Debug, Clone)]
pub struct MembershipView {
/// The node asking for a decision.
pub local: NodeId,
/// All known members with their current statuses.
pub members: Vec<NodeInfo>,
}
/// Pluggable split-brain resolution (Akka-SBR style).
///
/// A resolver is a pure function of the local membership view: no I/O, no
/// timers, so it is unit-testable and DST-drivable. `ClusterState::tick`
/// consults it after failure detection; a `DownSelf` decision marks the
/// local node down and emits [`ClusterAction::Down`].
pub trait SplitBrainResolver: Send + Sync {
fn decide(&self, view: &MembershipView) -> ResolverDecision;
}
/// Static-quorum strategy: the node stays up iff it sees at least
/// `floor(expected_nodes / 2) + 1` reachable members (itself plus every
/// `Healthy`/`Joining` member). Needs only the operator-configured expected
/// cluster size — no live count, no consensus, no leader.
///
/// With `expected_nodes == 2` both sides of a partition down themselves
/// (`1 < 2`): fail-closed is the intended 2-node behavior, and the strategy
/// is only useful for `expected_nodes >= 3`.
pub struct StaticQuorumResolver {
pub expected_nodes: usize,
}
impl SplitBrainResolver for StaticQuorumResolver {
fn decide(&self, view: &MembershipView) -> ResolverDecision {
let reachable = view
.members
.iter()
.filter(|m| {
m.node_id == view.local
|| matches!(m.status, NodeStatus::Healthy | NodeStatus::Joining)
})
.count();
if reachable >= self.expected_nodes / 2 + 1 {
ResolverDecision::StayUp
} else {
ResolverDecision::DownSelf
}
}
}
/// Split-brain resolver configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitBrainConfig {
/// No resolver: current behavior — partitions never self-resolve.
Disabled,
/// Static-quorum with the given expected cluster size (see
/// [`StaticQuorumResolver`] for the 2-node caveat).
StaticQuorum { expected_nodes: usize },
}
/// Cluster configuration applied when distribution is enabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClusterConfig {
pub split_brain: SplitBrainConfig,
/// How often to probe `Failed` members for liveness (the self-healing
/// path: a probe that reaches a live node re-promotes it to `Healthy`).
pub probe_interval: Duration,
}
impl Default for ClusterConfig {
fn default() -> Self {
ClusterConfig {
split_brain: SplitBrainConfig::Disabled,
probe_interval: DEFAULT_PROBE_INTERVAL,
}
}
}
impl ClusterConfig {
/// True when the configuration can be applied. `StaticQuorum` with
/// `expected_nodes == 0` is a configuration error, not "disabled".
pub fn is_valid(&self) -> bool {
match self.split_brain {
SplitBrainConfig::Disabled => true,
SplitBrainConfig::StaticQuorum { expected_nodes } => expected_nodes > 0,
}
}
}
// ---------------------------------------------------------------------------
// NodeGossip
// ---------------------------------------------------------------------------
/// A lightweight gossip entry for membership dissemination.
///
/// This compact representation avoids sending full [`NodeInfo`] (including
/// metadata maps) on every gossip round.
#[derive(Debug, Clone, PartialEq)]
pub struct NodeGossip {
/// Node identifier.
pub node_id: NodeId,
/// Network address.
pub address: SocketAddr,
/// Health status.
pub status: NodeStatus,
/// Incarnation number for conflict resolution.
pub incarnation: u64,
}
// ---------------------------------------------------------------------------
// ClusterState
// ---------------------------------------------------------------------------
/// Manages the cluster membership for a Nulang node.
///
/// Uses a simple gossip-style protocol where each node maintains a
/// membership table of all known nodes. Heartbeats are exchanged
/// periodically to detect failures.
///
/// # Example
///
/// ```ignore
/// use nulang::runtime::cluster::{ClusterState, NodeId};
/// # use std::net::{SocketAddr, IpAddr, Ipv4Addr};
/// let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 9000);
/// let local = NodeId::new(&addr);
/// let mut cluster = ClusterState::new(local, addr);
/// ```
pub struct ClusterState {
/// This node's identity.
local_node: NodeId,
/// Membership table: node_id → node info.
members: HashMap<NodeId, NodeInfo>,
/// Nodes that have been declared failed (kept for a while to
/// prevent rejoining with stale state).
failed_nodes: HashMap<NodeId, Instant>,
/// Heartbeat configuration.
heartbeat_interval: Duration,
heartbeat_timeout: Duration,
suspicion_duration: Duration,
/// Timestamp of last heartbeat we sent.
last_heartbeat_sent: Instant,
/// Optional virtual clock for deterministic testing.
/// When set, all time queries use this clock instead of wall time.
clock: Option<super::timer::VirtualClock>,
/// Optional split-brain resolver; `None` = resolver disabled.
split_brain: Option<Box<dyn SplitBrainResolver>>,
/// How often to probe Failed members (the self-healing path).
probe_interval: Duration,
/// When we last probed Failed members.
last_probe_sent: Option<Instant>,
/// True once the resolver decided the local node should leave.
local_down: bool,
/// True once this node has ever received a heartbeat from another
/// node — i.e. it has been part of a live cluster at some point.
/// Guards the split-brain resolver: a node that has never contacted
/// any peer is still bootstrapping (its join handshakes haven't
/// completed), not a partition minority, and must not down itself
/// before the cluster can form.
has_seen_peer: bool,
/// Active view: members we heartbeat directly. A member joins the
/// active view by heartbeating us (symmetric by construction), so
/// the failure detector — which watches exactly this set — never
/// false-fails a node we cannot hear.
active_view: Vec<NodeId>,
/// Probationary members: Healthy passive members we promoted and now
/// heartbeat, waiting for their first reply to confirm them into the
/// active view. They are NOT watched by the failure detector, so a
/// member that never reciprocates is demoted, never falsely failed.
probationary: Vec<(NodeId, Instant)>,
/// Passive view: known members we do not heartbeat; the repair pool
/// for the active view. Their liveness comes from gossip.
passive_view: Vec<NodeId>,
/// Capacity of `active_view`.
active_view_size: usize,
/// Capacity of `passive_view`.
passive_view_size: usize,
/// Rotating index into `passive_view` for the bounded reply rule.
reply_cursor: usize,
/// When we last attempted an active-view repair (eventual-repair
/// throttle).
last_repair_attempt: Option<Instant>,
/// Callback for membership change notifications.
on_member_joined: Option<Box<dyn Fn(NodeId, SocketAddr) + Send>>,
on_member_left: Option<Box<dyn Fn(NodeId) + Send>>,
on_member_failed: Option<Box<dyn Fn(NodeId) + Send>>,
}
impl ClusterState {
/// Create a new cluster state for the local node.
///
/// The local node is automatically added to the membership table with
/// [`NodeStatus::Healthy`].
pub fn new(local_node: NodeId, local_addr: SocketAddr) -> Self {
let now = Instant::now();
let mut members = HashMap::new();
let local_info = NodeInfo {
node_id: local_node,
address: local_addr,
status: NodeStatus::Healthy,
last_heartbeat: now,
joined_at: now,
metadata: HashMap::new(),
};
members.insert(local_node, local_info);
ClusterState {
local_node,
members,
clock: None,
failed_nodes: HashMap::new(),
on_member_joined: None,
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
heartbeat_timeout: DEFAULT_HEARTBEAT_TIMEOUT,
suspicion_duration: DEFAULT_SUSPICION_DURATION,
last_heartbeat_sent: now,
split_brain: None,
probe_interval: DEFAULT_PROBE_INTERVAL,
last_probe_sent: None,
local_down: false,
has_seen_peer: false,
active_view: Vec::new(),
probationary: Vec::new(),
passive_view: Vec::new(),
active_view_size: DEFAULT_ACTIVE_VIEW_SIZE,
passive_view_size: DEFAULT_PASSIVE_VIEW_SIZE,
last_repair_attempt: None,
reply_cursor: 0,
on_member_left: None,
on_member_failed: None,
}
}
/// Current time, using the virtual clock if one is configured.
fn now(&self) -> Instant {
match &self.clock {
Some(clock) => clock.now(),
None => Instant::now(),
}
}
/// Install a virtual clock for deterministic testing.
/// When set, all time queries use this clock instead of wall time.
pub fn set_clock(&mut self, clock: super::timer::VirtualClock) {
self.clock = Some(clock);
}
/// Join an existing cluster by contacting a seed node.
///
/// Records the seed node in the membership table (as Joining, with
/// baseline `_incarnation` metadata so the join propagates via gossip).
/// The actual network request to the seed is the responsibility of
/// the caller.
pub fn join_cluster(&mut self, seed_addr: SocketAddr) {
let seed_id = NodeId::new(&seed_addr);
if seed_id == self.local_node {
// Cannot join ourselves.
return;
}
if !self.members.contains_key(&seed_id) {
let mut info = NodeInfo::new(seed_id, seed_addr);
info.status = NodeStatus::Joining;
// Baseline incarnation 1: the seed address is authoritative
// (it came from an explicit join request), so same-generation
// gossip (incarnation 1) must not overwrite it with a
// discovered address of unknown quality. Strictly-higher
// incarnations still win.
info.metadata
.insert("_incarnation".to_string(), "1".to_string());
self.members.insert(seed_id, info);
}
}
/// Join a cluster by seed node ID and address, for cases where the
/// node ID is not derived from the address (e.g., TLS cert-based IDs).
pub fn join_cluster_with_id(&mut self, seed_id: NodeId, seed_addr: SocketAddr) {
if seed_id == self.local_node {
return;
}
if !self.members.contains_key(&seed_id) {
let mut info = NodeInfo::new(seed_id, seed_addr);
info.status = NodeStatus::Joining;
info.metadata
.insert("_incarnation".to_string(), "1".to_string());
self.members.insert(seed_id, info);
}
}
/// Handle an incoming heartbeat from another node.
///
/// Updates the node's `last_heartbeat` timestamp and promotes the
/// status back to [`NodeStatus::Healthy`] if it was previously
/// Suspicious or Failed.
///
/// If the node was not previously known, it is added to the
/// membership table.
///
/// View maintenance: a node that heartbeats us is alive by
/// definition, so it is placed in the active view (symmetric link —
/// we will heartbeat it back) if there is room, else the passive
/// view. A probationary member's first heartbeat confirms it into
/// the active view.
pub fn handle_heartbeat(&mut self, from: NodeId, addr: SocketAddr) {
let now = self.now();
if from != self.local_node {
self.has_seen_peer = true;
}
match self.members.get_mut(&from) {
Some(info) => {
let was_suspicious_or_failed =
matches!(info.status, NodeStatus::Suspicious | NodeStatus::Failed);
info.last_heartbeat = now;
info.address = addr;
if was_suspicious_or_failed {
info.status = NodeStatus::Healthy;
Self::bump_entry_incarnation(info);
} else if info.status == NodeStatus::Joining {
info.status = NodeStatus::Healthy;
// Bump the entry incarnation so the promotion wins
// merges on nodes that learned the stale Joining
// status from an earlier gossip round.
Self::bump_entry_incarnation(info);
}
}
None => {
// New node discovered via heartbeat.
let mut info = NodeInfo::new(from, addr);
info.last_heartbeat = now;
info.status = NodeStatus::Healthy;
self.members.insert(from, info);
if let Some(ref cb) = self.on_member_joined {
cb(from, addr);
}
}
}
self.observe_heartbeat(from);
}
/// Record that `from` heartbeated us: confirm a probationary member
/// into the active view, or place a new member into the active view
/// (room permitting) / passive view.
fn observe_heartbeat(&mut self, from: NodeId) {
if from == self.local_node {
return;
}
if let Some(pos) = self.probationary.iter().position(|(id, _)| *id == from) {
// First reply: the promoted member reciprocates, so the
// link is symmetric — confirm it into the active view.
self.probationary.remove(pos);
self.push_active(from);
return;
}
if self.active_view.contains(&from) {
return;
}
if let Some(pos) = self.passive_view.iter().position(|id| *id == from) {
self.passive_view.remove(pos);
}
self.push_active(from);
}
/// Add `node` to the active view if it has room, else the passive
/// view (bounded).
fn push_active(&mut self, node: NodeId) {
if self.active_view.len() < self.active_view_size {
self.active_view.push(node);
} else if !self.passive_view.contains(&node)
&& self.passive_view.len() < self.passive_view_size
{
self.passive_view.push(node);
}
}
/// The members we currently heartbeat directly.
pub fn active_view(&self) -> &[NodeId] {
&self.active_view
}
/// The members in the passive repair pool.
pub fn passive_view(&self) -> &[NodeId] {
&self.passive_view
}
/// The members currently on probation (promoted, awaiting their
/// first reply).
pub fn probationary(&self) -> &[(NodeId, Instant)] {
&self.probationary
}
/// Apply operator cluster configuration.
///
/// Returns false (and leaves the previous configuration in place) when
/// the configuration is invalid, e.g. `static-quorum` with
/// `expected_nodes == 0`.
pub fn apply_config(&mut self, config: &ClusterConfig) -> bool {
if !config.is_valid() {
warn!(
"cluster config: static-quorum expected_nodes must be >= 1; \
keeping the previous configuration"
);
return false;
}
self.split_brain = match config.split_brain {
SplitBrainConfig::Disabled => None,
SplitBrainConfig::StaticQuorum { expected_nodes } => {
Some(Box::new(StaticQuorumResolver { expected_nodes }))
}
};
self.probe_interval = config.probe_interval;
true
}
/// True once the split-brain resolver downed this node.
pub fn is_down(&self) -> bool {
self.local_down
}
/// Run the periodic cluster maintenance.
///
/// Should be called regularly (e.g., every 100 ms). Performs:
///
/// 1. Checks active-view members that have missed heartbeats →
/// marks Suspicious. Only the active view is watched: passive
/// members' liveness comes from gossip, and watching a node we
/// do not heartbeat would false-fail it.
/// 2. Promotes Suspicious active-view members to Failed past the
/// suspicion window, repairs the active view (promoting a
/// Healthy passive member to probation), and demotes
/// probationary members that never reciprocated.
/// 3. Cleans up old failed nodes.
/// 4. Consults the split-brain resolver; a `DownSelf` decision marks
/// the local node down and no further actions are emitted.
/// 5. Probes Failed members (throttled) so a healed partition re-joins.
/// 6. Returns a list of actions for the runtime to execute.
pub fn tick(&mut self) -> Vec<ClusterAction> {
let now = self.now();
let mut actions = Vec::new();
// ------------------------------------------------------------------
// 1. Heartbeat timeout → Suspicious (active view only)
// ------------------------------------------------------------------
for info in self.members.values_mut() {
if info.node_id == self.local_node || !self.active_view.contains(&info.node_id) {
continue;
}
if info.status == NodeStatus::Healthy {
if now.duration_since(info.last_heartbeat) > self.heartbeat_timeout {
info.status = NodeStatus::Suspicious;
}
}
}
// ------------------------------------------------------------------
// 2. Suspicion timeout → Failed (active view only) + active-view
// repair
// ------------------------------------------------------------------
let mut newly_failed = Vec::new();
for info in self.members.values_mut() {
if info.node_id == self.local_node || !self.active_view.contains(&info.node_id) {
continue;
}
if info.status == NodeStatus::Suspicious {
// Use the heartbeat timeout as a proxy for "how long
// has it been suspicious" — the moment it transitions
// to Suspicious we can track from the last heartbeat.
if now.duration_since(info.last_heartbeat)
> self.heartbeat_timeout + self.suspicion_duration
{
info.status = NodeStatus::Failed;
// Bump the entry incarnation so the Failed status
// propagates via gossip: under partial-view
// membership most nodes never watch a given member
// directly and learn its failure only from gossip.
Self::bump_entry_incarnation(info);
newly_failed.push(info.node_id);
self.failed_nodes.insert(info.node_id, now);
if let Some(ref cb) = self.on_member_failed {
cb(info.node_id);
}
actions.push(ClusterAction::NodeFailed { node: info.node_id });
}
}
}
for node_id in &newly_failed {
self.active_view.retain(|id| id != node_id);
self.probationary.retain(|(id, _)| id != node_id);
self.repair_active_view(now);
}
// ------------------------------------------------------------------
// 2.5 Demote probationary members that never reciprocated. They
// were never watched, so this is churn, not false failure:
// a live member with no room in its own view just gets
// another chance later.
// ------------------------------------------------------------------
self.probationary
.retain(|(_, solicited_at)| now.duration_since(*solicited_at) <= PROBATION_TIMEOUT);
// Every known non-failed member lives in exactly one view:
// active, probationary, or passive. Anything else (a Joining
// seed awaiting its first heartbeat, a demoted probationary)
// goes to the passive pool so the repair path can find it.
let homeless: Vec<NodeId> = self
.members
.values()
.filter(|info| {
info.node_id != self.local_node
&& info.status != NodeStatus::Failed
&& !self.active_view.contains(&info.node_id)
&& !self.probationary.iter().any(|(id, _)| *id == info.node_id)
&& !self.passive_view.contains(&info.node_id)
})
.map(|info| info.node_id)
.collect();
for node_id in homeless {
if self.passive_view.len() < self.passive_view_size {
self.passive_view.push(node_id);
}
}
// Repair is eventual: a demoted probationary leaves the active
// view underfull, and a later promotion attempt (gated at the
// probe interval, so this is at most one retry per 5 s) may
// find a member with room to reciprocate.
let repair_due = match self.last_repair_attempt {
Some(last) => now.duration_since(last) >= DEFAULT_PROBE_INTERVAL,
None => true,
};
if self.active_view.len() < self.active_view_size && repair_due {
self.repair_active_view(now);
}
// ------------------------------------------------------------------
// 3. Clean up old failed nodes
// ------------------------------------------------------------------
let mut to_remove = Vec::new();
for (node_id, failed_at) in &self.failed_nodes {
if now.duration_since(*failed_at) > FAILED_NODE_RETENTION {
to_remove.push(*node_id);
}
}
for node_id in &to_remove {
self.members.remove(node_id);
self.failed_nodes.remove(node_id);
self.passive_view.retain(|id| id != node_id);
actions.push(ClusterAction::NodeLeft { node: *node_id });
if let Some(ref cb) = self.on_member_left {
cb(*node_id);
}
}
// ------------------------------------------------------------------
// 3.5 Split-brain resolver: decide whether the local node stays up
// ------------------------------------------------------------------
if self.local_down {
// Already down: no heartbeats, gossip, or probes.
return actions;
}
// Cold-bootstrap guard: before this node has ever received a
// heartbeat from any peer, it is still forming (join handshakes
// in flight), not a partition minority. Consulting the resolver
// now would down a fresh seed — it sees only itself, below
// quorum — and the cluster could never form. Skip the resolver
// until the first peer contact.
if self.has_seen_peer {
if let Some(resolver) = &self.split_brain {
// Passive members' liveness is gossip-derived, and their
// table status can be a frozen snapshot of the last gossip
// we received. The resolver must not count them as
// reachable once that evidence is stale: demote
// stale-status passives to Suspicious in the view only
// (the table is untouched).
let view_members: Vec<NodeInfo> = self
.members
.values()
.map(|info| {
let mut info = info.clone();
if info.node_id != self.local_node
&& !self.active_view.contains(&info.node_id)
&& now.duration_since(info.last_heartbeat) > self.heartbeat_timeout
&& matches!(info.status, NodeStatus::Healthy | NodeStatus::Joining)
{
info.status = NodeStatus::Suspicious;
}
info
})
.collect();
let view = MembershipView {
local: self.local_node,
members: view_members,
};
if matches!(resolver.decide(&view), ResolverDecision::DownSelf) {
self.local_down = true;
actions.push(ClusterAction::Down {
node: self.local_node,
});
return actions;
}
}
}
// ------------------------------------------------------------------
// 3.6 Probe Failed members (throttled) so a healed partition
// re-joins without an external rejoin.
// ------------------------------------------------------------------
let probe_due = match self.last_probe_sent {
Some(last) => now.duration_since(last) >= self.probe_interval,
None => true,
};
if probe_due {
self.last_probe_sent = Some(now);
for info in self.members.values() {
if info.status == NodeStatus::Failed {
actions.push(ClusterAction::Probe {
to: info.node_id,
addr: info.address,
});
}
}
}
// ------------------------------------------------------------------
// 4. Send heartbeats (throttled) to the active view, probationary
// members, and Joining seeds. This is the bounded data plane:
// heartbeat traffic is O(active_view + probationary + joins),
// not O(every member).
// ------------------------------------------------------------------
if now.duration_since(self.last_heartbeat_sent) >= self.heartbeat_interval {
self.last_heartbeat_sent = now;
for info in self.members.values() {
if info.node_id == self.local_node {
continue;
}
// Joining members get heartbeats as the join bootstrap:
// the first heartbeat to a seed is what initiates the
// join — the seed discovers us from it and heartbeats
// back, which promotes the seed to Healthy on our side.
let in_active = self.active_view.contains(&info.node_id);
let on_probation = self.probationary.iter().any(|(id, _)| *id == info.node_id);
if matches!(info.status, NodeStatus::Healthy | NodeStatus::Joining)
&& (in_active || on_probation || info.status == NodeStatus::Joining)
{
actions.push(ClusterAction::SendHeartbeat {
to: info.node_id,
addr: info.address,
});
}
}
}
// Bounded reply rule: answer up to REPLY_SLOTS passive-view
// members that recently heartbeated us (rotated round-robin
// for fairness). Without this, a member whose active view
// filled up would stop heartbeating us and our detector —
// which watches exactly the active view — would false-fail
// it. The rotation bounds how long a pinger waits for a
// reply: with 4 slots × 500 ms it stays inside the 2 s
// failure-detection window for clusters up to ~80 nodes.
let mut replied = 0;
let n = self.passive_view.len();
for k in 0..n {
if replied >= REPLY_SLOTS {
break;
}
let id = self.passive_view[(self.reply_cursor + k) % n];
if let Some(info) = self.members.get(&id) {
if matches!(info.status, NodeStatus::Healthy | NodeStatus::Joining)
&& now.duration_since(info.last_heartbeat) <= self.heartbeat_timeout
{
actions.push(ClusterAction::SendHeartbeat {
to: id,
addr: info.address,
});
replied += 1;
}
}
}
self.reply_cursor = (self.reply_cursor + 1) % n.max(1);
// ------------------------------------------------------------------
// 5. Gossip to a random subset of healthy nodes
// ------------------------------------------------------------------
let gossip_targets = self.pick_gossip_targets(GOSSIP_FANOUT);
if !gossip_targets.is_empty() {
actions.push(ClusterAction::SendGossip {
targets: gossip_targets,
});
}
actions
}
/// Repair the active view after a failure: promote a random Healthy
/// passive member to probation (we start heartbeating it; its first
/// reply confirms it into the active view). If nothing suitable is
/// available the view stays underfull until gossip brings new
/// candidates.
fn repair_active_view(&mut self, now: Instant) {
self.last_repair_attempt = Some(now);
if self.active_view.len() >= self.active_view_size {
return;
}
let candidates: Vec<NodeId> = self
.passive_view
.iter()
.copied()
.filter(|id| {
*id != self.local_node
&& !self.active_view.contains(id)
&& !self.probationary.iter().any(|(pid, _)| pid == id)
&& matches!(
self.members.get(id).map(|info| info.status),
Some(NodeStatus::Healthy | NodeStatus::Joining)
)
})
.collect();
if candidates.is_empty() {
return;
}
use rand_core::RngCore;
let mut buf = [0u8; 8];
rand_core::OsRng.fill_bytes(&mut buf);
let pick = candidates[(u64::from_le_bytes(buf) as usize) % candidates.len()];
self.passive_view.retain(|id| *id != pick);
self.probationary.push((pick, now));
}
/// Number of members currently reachable per the resolver's view:
/// the local node plus every `Healthy`/`Joining` member with fresh
/// liveness evidence (watched, or its gossip-refreshed timestamp
/// within the heartbeat timeout). Mirrors the staleness override
/// `tick` applies when building the resolver view. Used by the
/// DST cluster harness to assert the resolver's exact semantics.
#[cfg(test)]
pub(crate) fn reachable_count(&self) -> usize {
let now = self.now();
let mut count = 1;
for info in self.members.values() {
if info.node_id == self.local_node {
continue;
}
if !matches!(info.status, NodeStatus::Healthy | NodeStatus::Joining) {
continue;
}
let fresh = self.active_view.contains(&info.node_id)
|| now.duration_since(info.last_heartbeat) <= self.heartbeat_timeout;
if fresh {