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
1206 lines (1024 loc) · 42.3 KB
/
Copy pathcluster.rs
File metadata and controls
1206 lines (1024 loc) · 42.3 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};
// ---------------------------------------------------------------------------
// 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;
// ---------------------------------------------------------------------------
// NodeId
// ---------------------------------------------------------------------------
/// Unique identifier for a node in the cluster.
///
/// Derived from a hash of the node's socket address so that the same
/// physical node (restarting with the same address) receives a stable id.
#[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 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)> },
}
// ---------------------------------------------------------------------------
// 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,
/// Monotonically increasing incarnation number for this node.
/// Used to resolve conflicting membership updates.
incarnation: u64,
/// Timestamp of last heartbeat we sent.
last_heartbeat_sent: 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,
failed_nodes: HashMap::new(),
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
heartbeat_timeout: DEFAULT_HEARTBEAT_TIMEOUT,
suspicion_duration: DEFAULT_SUSPICION_DURATION,
incarnation: 1,
last_heartbeat_sent: now,
on_member_joined: None,
on_member_left: None,
on_member_failed: None,
}
}
/// Join an existing cluster by contacting a seed node.
///
/// Records the seed node in the membership table (as Joining) and
/// bumps the incarnation so that 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);
}
self.bump_incarnation();
}
/// 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.
pub fn handle_heartbeat(&mut self, from: NodeId, addr: SocketAddr) {
let now = Instant::now();
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);
self.bump_incarnation();
} 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);
self.bump_incarnation();
if let Some(ref cb) = self.on_member_joined {
cb(from, addr);
}
}
}
}
/// Run the periodic cluster maintenance.
///
/// Should be called regularly (e.g., every 100 ms). Performs:
///
/// 1. Checks for nodes that have missed heartbeats → marks Suspicious.
/// 2. Promotes Suspicious nodes to Failed if past the suspicion window.
/// 3. Cleans up old failed nodes.
/// 4. Returns a list of actions for the runtime to execute.
pub fn tick(&mut self) -> Vec<ClusterAction> {
let now = Instant::now();
let mut actions = Vec::new();
// ------------------------------------------------------------------
// 1. Heartbeat timeout → Suspicious
// ------------------------------------------------------------------
for info in self.members.values_mut() {
if info.node_id == self.local_node {
continue;
}
if info.status == NodeStatus::Healthy {
if now.duration_since(info.last_heartbeat) > self.heartbeat_timeout {
info.status = NodeStatus::Suspicious;
}
}
}
// ------------------------------------------------------------------
// 2. Suspicion timeout → Failed
// ------------------------------------------------------------------
let mut newly_failed = Vec::new();
for info in self.members.values_mut() {
if info.node_id == self.local_node {
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;
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 });
}
}
}
// ------------------------------------------------------------------
// 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);
actions.push(ClusterAction::NodeLeft { node: *node_id });
if let Some(ref cb) = self.on_member_left {
cb(*node_id);
}
}
// ------------------------------------------------------------------
// 4. Send heartbeats to healthy members (throttled)
// ------------------------------------------------------------------
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;
}
// Heartbeats go to Joining members as well as Healthy
// ones: 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.
if matches!(info.status, NodeStatus::Healthy | NodeStatus::Joining) {
actions.push(ClusterAction::SendHeartbeat {
to: info.node_id,
addr: info.address,
});
}
}
}
// ------------------------------------------------------------------
// 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
}
/// Get the list of all healthy members **excluding** the local node.
pub fn healthy_members(&self) -> Vec<&NodeInfo> {
self.members
.values()
.filter(|info| info.node_id != self.local_node && info.status == NodeStatus::Healthy)
.collect()
}
/// Get the list of all members including the local node.
pub fn all_members(&self) -> Vec<&NodeInfo> {
self.members.values().collect()
}
/// Check if a node is known to the cluster.
pub fn is_member(&self, node_id: NodeId) -> bool {
self.members.contains_key(&node_id)
}
/// Get info for a specific node.
pub fn get_node(&self, node_id: NodeId) -> Option<&NodeInfo> {
self.members.get(&node_id)
}
/// Get the number of healthy nodes in the cluster.
///
/// This includes the local node.
pub fn healthy_node_count(&self) -> usize {
self.members
.values()
.filter(|info| info.status == NodeStatus::Healthy)
.count()
}
/// Set a callback invoked when a new member joins the cluster.
pub fn on_member_joined<F>(&mut self, callback: F)
where
F: Fn(NodeId, SocketAddr) + Send + 'static,
{
self.on_member_joined = Some(Box::new(callback));
}
/// Set a callback invoked when a member leaves the cluster.
pub fn on_member_left<F>(&mut self, callback: F)
where
F: Fn(NodeId) + Send + 'static,
{
self.on_member_left = Some(Box::new(callback));
}
/// Set a callback invoked when a member is declared failed.
pub fn on_member_failed<F>(&mut self, callback: F)
where
F: Fn(NodeId) + Send + 'static,
{
self.on_member_failed = Some(Box::new(callback));
}
/// Get the local node's incarnation number.
pub fn incarnation(&self) -> u64 {
self.incarnation
}
/// Increment the incarnation number.
///
/// Called whenever the local node's view of membership changes so
/// that gossip recipients prefer our version of the truth.
pub fn bump_incarnation(&mut self) {
self.incarnation = self.incarnation.wrapping_add(1);
}
/// Merge a membership list received from another node (gossip).
///
/// Uses incarnation numbers for conflict resolution: the entry with
/// the higher incarnation is considered authoritative. Returns
/// `true` if any changes were made to our membership table.
pub fn merge_membership(&mut self, gossip: Vec<NodeGossip>) -> bool {
let mut changed = false;
for entry in gossip {
// Never overwrite local node info from gossip.
if entry.node_id == self.local_node {
continue;
}
match self.members.get_mut(&entry.node_id) {
Some(existing) => {
let stored_incarnation = existing
.metadata
.get("_incarnation")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// Higher incarnation wins. Only a strictly-newer entry
// refreshes `last_heartbeat`: an equal-incarnation entry
// is just a re-broadcast of state we already hold, so
// treating it as a liveness hint would let surviving
// nodes refresh a dead peer's timestamp forever and
// defeat the failure detector. (Direct heartbeats refresh
// the timestamp via `handle_heartbeat`.)
if entry.incarnation > stored_incarnation {
let old_status = existing.status;
existing.last_heartbeat = Instant::now();
existing.status = entry.status;
existing.address = entry.address;
existing
.metadata
.insert("_incarnation".to_string(), entry.incarnation.to_string());
if old_status != entry.status {
changed = true;
if entry.status == NodeStatus::Failed {
self.failed_nodes.insert(entry.node_id, Instant::now());
}
}
}
}
None => {
// New node learned from gossip.
let mut info = NodeInfo::new(entry.node_id, entry.address);
info.status = entry.status;
info.last_heartbeat = Instant::now();
info.metadata
.insert("_incarnation".to_string(), entry.incarnation.to_string());
self.members.insert(entry.node_id, info);
changed = true;
}
}
}
if changed {
self.bump_incarnation();
}
changed
}
/// Get a gossip payload to send to other nodes.
///
/// Returns up to `max_entries` entries from the membership table.
/// If the table is smaller than `max_entries`, all entries are returned.
pub fn gossip_payload(&self, max_entries: usize) -> Vec<NodeGossip> {
self.members
.values()
.take(max_entries)
.map(|info| NodeGossip {
node_id: info.node_id,
address: info.address,
status: info.status,
incarnation: info
.metadata
.get("_incarnation")
.and_then(|s| s.parse().ok())
.unwrap_or(1),
})
.collect()
}
// ------------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------------
/// Increment the per-entry incarnation stored in a member's metadata.
///
/// Entries carry their version in the `_incarnation` metadata key so
/// gossip merges can resolve conflicts (higher wins). A missing key is
/// treated as 1 by `gossip_payload`, so a locally-observed status
/// change must bump the entry past that baseline to propagate.
fn bump_entry_incarnation(info: &mut NodeInfo) {
let current = info
.metadata
.get("_incarnation")
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(1);
info.metadata
.insert("_incarnation".to_string(), (current + 1).to_string());
}
/// Pick `n` random healthy targets for gossip.
///
/// Uses a simple round-robin when the `getrandom` facility is not
/// available; in production this should use a proper RNG.
fn pick_gossip_targets(&self, n: usize) -> Vec<(NodeId, SocketAddr)> {
let healthy: Vec<&NodeInfo> = self.healthy_members();
if healthy.is_empty() {
return Vec::new();
}
// Simple deterministic selection: pick the first N.
// In a real deployment this would use `rand::seq::IteratorRandom`.
healthy
.into_iter()
.take(n)
.map(|info| (info.node_id, info.address))
.collect()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::thread;
/// Helper: create a loopback address on a given port.
fn addr(port: u16) -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port)
}
// -- 1. NodeId creation ------------------------------------------------
#[test]
fn test_node_id_creation() {
let a = addr(9000);
let id1 = NodeId::new(&a);
let id2 = NodeId::new(&a);
assert_eq!(id1, id2, "same address should yield same NodeId");
assert_ne!(id1.0, 0, "NodeId should not be zero for non-local");
}
#[test]
fn test_node_id_local() {
assert_eq!(NodeId::LOCAL.0, 0);
}
// -- 2. ClusterState creation ------------------------------------------
#[test]
fn test_cluster_new() {
let a = addr(9000);
let local = NodeId::new(&a);
let cs = ClusterState::new(local, a);
assert_eq!(cs.local_node, local);
assert_eq!(cs.healthy_node_count(), 1);
assert!(cs.is_member(local));
assert_eq!(cs.incarnation(), 1);
let info = cs.get_node(local).unwrap();
assert_eq!(info.status, NodeStatus::Healthy);
assert_eq!(info.address, a);
}
// -- 3. Heartbeat from unknown node ------------------------------------
#[test]
fn test_handle_heartbeat_new_node() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let peer_addr = addr(9001);
let peer_id = NodeId::new(&peer_addr);
cs.handle_heartbeat(peer_id, peer_addr);
assert!(cs.is_member(peer_id));
assert_eq!(cs.get_node(peer_id).unwrap().status, NodeStatus::Healthy);
assert_eq!(cs.healthy_node_count(), 2);
}
// -- 4. Heartbeat updates existing node --------------------------------
#[test]
fn test_handle_heartbeat_existing_node() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let peer_addr = addr(9001);
let peer_id = NodeId::new(&peer_addr);
cs.handle_heartbeat(peer_id, peer_addr);
let first = cs.get_node(peer_id).unwrap().last_heartbeat;
// Wait a tiny bit so Instant::now() advances.
thread::sleep(Duration::from_millis(10));
cs.handle_heartbeat(peer_id, peer_addr);
let second = cs.get_node(peer_id).unwrap().last_heartbeat;
assert!(second > first, "heartbeat should update timestamp");
}
// -- 5. Suspicion detection --------------------------------------------
#[test]
fn test_suspicion_detection() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let peer_addr = addr(9001);
let peer_id = NodeId::new(&peer_addr);
cs.handle_heartbeat(peer_id, peer_addr);
assert_eq!(cs.get_node(peer_id).unwrap().status, NodeStatus::Healthy);
// Simulate time passing by not sending heartbeats.
// We can't advance Instant, so we force the status manually
// and verify tick promotes it.
// NOTE: In real usage the peer would naturally time out.
// Here we verify the state machine transition exists.
// Mark the peer as having a very old heartbeat.
if let Some(info) = cs.members.get_mut(&peer_id) {
// Artificially set last_heartbeat far in the past.
// Since Instant doesn't support subtraction directly,
// we verify the transition path via tick.
info.status = NodeStatus::Healthy;
}
// Call tick — we force the heartbeat timer to have expired so it sends a heartbeat
cs.last_heartbeat_sent = Instant::now() - cs.heartbeat_interval - Duration::from_secs(1);
let actions = cs.tick();
// Peer is still healthy because the real timeout hasn't passed.
// The test documents the API; full timeout testing requires
// mockable clocks (left as a TODO for production).
assert!(
cs.get_node(peer_id).unwrap().status == NodeStatus::Healthy
|| cs.get_node(peer_id).unwrap().status == NodeStatus::Suspicious
);
// Verify that SendHeartbeat action is produced for the peer.
let has_heartbeat = actions
.iter()
.any(|a| matches!(a, ClusterAction::SendHeartbeat { to, .. } if *to == peer_id));
assert!(has_heartbeat, "tick should request heartbeat to peer");
}
// -- 6. Failure detection ----------------------------------------------
#[test]
fn test_failure_detection() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let peer_addr = addr(9001);
let peer_id = NodeId::new(&peer_addr);
cs.handle_heartbeat(peer_id, peer_addr);
// Manually transition through the failure-detector state machine.
if let Some(info) = cs.members.get_mut(&peer_id) {
info.status = NodeStatus::Suspicious;
}
// tick won't promote to Failed because real time hasn't passed,
// but we verify the state machine paths are wired correctly by
// checking the member stays in the table.
let _actions = cs.tick();
assert!(cs.is_member(peer_id));
}
// -- 7. Healthy members filter -----------------------------------------
#[test]
fn test_healthy_members_filter() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let p1 = addr(9001);
let id1 = NodeId::new(&p1);
let p2 = addr(9002);
let id2 = NodeId::new(&p2);
cs.handle_heartbeat(id1, p1);
cs.handle_heartbeat(id2, p2);
let healthy = cs.healthy_members();
assert_eq!(healthy.len(), 2);
assert!(healthy.iter().all(|i| i.status == NodeStatus::Healthy));
assert!(!healthy.iter().any(|i| i.node_id == local));
}
// -- 8. Merge membership (gossip) --------------------------------------
#[test]
fn test_merge_membership() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let gossip = vec![
NodeGossip {
node_id: NodeId(42),
address: addr(9042),
status: NodeStatus::Healthy,
incarnation: 5,
},
NodeGossip {
node_id: NodeId(43),
address: addr(9043),
status: NodeStatus::Healthy,
incarnation: 3,
},
];
let changed = cs.merge_membership(gossip);
assert!(changed);
assert!(cs.is_member(NodeId(42)));
assert!(cs.is_member(NodeId(43)));
assert_eq!(cs.get_node(NodeId(42)).unwrap().address, addr(9042));
}
// -- 9. Merge conflict resolution (higher incarnation wins) -------------
#[test]
fn test_merge_conflict_resolution() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
// Seed the table with a node at incarnation 3.
let gossip_low = vec![NodeGossip {
node_id: NodeId(77),
address: addr(9077),
status: NodeStatus::Healthy,
incarnation: 3,
}];
cs.merge_membership(gossip_low);
assert_eq!(cs.get_node(NodeId(77)).unwrap().status, NodeStatus::Healthy);
// Now receive gossip with a higher incarnation marking it Failed.
let gossip_high = vec![NodeGossip {
node_id: NodeId(77),
address: addr(9077),
status: NodeStatus::Failed,
incarnation: 10,
}];
let changed = cs.merge_membership(gossip_high);
assert!(changed);
assert_eq!(cs.get_node(NodeId(77)).unwrap().status, NodeStatus::Failed);
}
// -- 10. Gossip payload size -------------------------------------------
#[test]
fn test_gossip_payload_size() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
// Add several peers.
for port in 9001..=9010 {
let pa = addr(port);
let pid = NodeId::new(&pa);
cs.handle_heartbeat(pid, pa);
}
let payload = cs.gossip_payload(3);
assert_eq!(payload.len(), 3, "payload should respect max_entries");
let payload_all = cs.gossip_payload(100);
assert_eq!(payload_all.len(), 11, "payload should contain all members");
}
// -- 11. Member joined callback ----------------------------------------
#[test]
fn test_member_joined_callback() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let (tx, rx) = std::sync::mpsc::channel();
cs.on_member_joined(move |id, _addr| {
let _ = tx.send(id);
});
let pa = addr(9001);
let pid = NodeId::new(&pa);
cs.handle_heartbeat(pid, pa);
let received = rx.recv_timeout(Duration::from_secs(1));
assert!(received.is_ok(), "callback should fire on new member");
assert_eq!(received.unwrap(), pid);
}
// -- 12. Graceful leave handling ---------------------------------------
#[test]
fn test_node_left_graceful() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let pa = addr(9001);
let pid = NodeId::new(&pa);
cs.handle_heartbeat(pid, pa);
assert!(cs.is_member(pid));
// Simulate the peer leaving via gossip.
let gossip = vec![NodeGossip {
node_id: pid,
address: pa,
status: NodeStatus::Leaving,
incarnation: 99,
}];
let changed = cs.merge_membership(gossip);
assert!(changed);
assert_eq!(cs.get_node(pid).unwrap().status, NodeStatus::Leaving);
}
// -- 13. Join cluster via seed -----------------------------------------
#[test]
fn test_join_cluster() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let seed = addr(9001);
cs.join_cluster(seed);
let seed_id = NodeId::new(&seed);
assert!(cs.is_member(seed_id));
assert_eq!(cs.get_node(seed_id).unwrap().status, NodeStatus::Joining);
}
// -- 14. Self-join is a no-op ------------------------------------------
#[test]
fn test_join_self_is_noop() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
cs.join_cluster(a); // join our own address
assert_eq!(cs.healthy_node_count(), 1);
}
// -- 15. Bump incarnation ----------------------------------------------
#[test]
fn test_bump_incarnation() {
let a = addr(9000);
let local = NodeId::new(&a);
let mut cs = ClusterState::new(local, a);
let first = cs.incarnation();
cs.bump_incarnation();
assert_eq!(cs.incarnation(), first + 1);
}