1pub mod coordinator;
24pub mod event_shipper;
25pub mod shard_manager;
26
27pub use coordinator::{
28 CoordinatorConfig, CoordinatorError, CoordinatorResult,
29 CoordinatorStats as DistributedCoordinatorStats,
30 CoordinatorStatsSnapshot as DistributedCoordinatorStatsSnapshot, DistributedStreamCoordinator,
31 RoutedEvent,
32};
33pub use event_shipper::{
34 EventShipper, InProcessShipperTransport, ShippedEvent, ShipperConfig, ShipperError,
35 ShipperResult, ShipperStats, ShipperStatsSnapshot, ShipperTransport,
36};
37pub use shard_manager::{
38 NodeId as ShardManagerNodeId, RebalancePlan, ShardAssignment, ShardId, ShardManager,
39 ShardManagerConfig, ShardManagerError, ShardManagerResult, ShardMove,
40};
41
42use std::collections::{BTreeMap, HashMap, HashSet};
43use std::sync::Arc;
44use std::time::{Duration, Instant};
45
46use parking_lot::RwLock;
47use serde::{Deserialize, Serialize};
48use thiserror::Error;
49use tokio::sync::mpsc;
50use tracing::{debug, info};
51
52#[derive(Error, Debug)]
56pub enum DistributedStreamError {
57 #[error("Node not found: {node_id}")]
58 NodeNotFound { node_id: String },
59
60 #[error("No nodes available in topology")]
61 NoNodesAvailable,
62
63 #[error("Partition assignment failed: {reason}")]
64 PartitionAssignmentFailed { reason: String },
65
66 #[error("Window aggregation error: {0}")]
67 WindowAggregation(String),
68
69 #[error("Job distribution error: {0}")]
70 JobDistribution(String),
71
72 #[error("Channel send error: {0}")]
73 ChannelSend(String),
74
75 #[error("Topology inconsistency: {0}")]
76 TopologyInconsistency(String),
77}
78
79pub type DistributedResult<T> = Result<T, DistributedStreamError>;
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub enum NodeStatus {
87 Healthy,
89 Degraded,
91 Unreachable,
93 Draining,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct StreamNode {
100 pub node_id: String,
102 pub address: String,
104 pub status: NodeStatus,
106 pub assigned_partitions: usize,
108 pub capacity: f64,
110 pub last_heartbeat: std::time::SystemTime,
112 pub weight: u32,
114}
115
116impl StreamNode {
117 pub fn new(node_id: impl Into<String>, address: impl Into<String>) -> Self {
119 Self {
120 node_id: node_id.into(),
121 address: address.into(),
122 status: NodeStatus::Healthy,
123 assigned_partitions: 0,
124 capacity: 1.0,
125 last_heartbeat: std::time::SystemTime::now(),
126 weight: 100,
127 }
128 }
129
130 pub fn is_available(&self) -> bool {
132 matches!(self.status, NodeStatus::Healthy | NodeStatus::Degraded) && self.capacity > 0.0
133 }
134}
135
136#[derive(Debug, Clone)]
140struct VirtualNode {
141 hash: u64,
143 node_id: String,
145}
146
147pub struct ConsistentHashRouter {
153 ring: Vec<VirtualNode>,
155 nodes: HashMap<String, StreamNode>,
157 virtual_nodes_per_node: usize,
159}
160
161impl ConsistentHashRouter {
162 pub fn new(virtual_nodes_per_node: usize) -> Self {
164 Self {
165 ring: Vec::new(),
166 nodes: HashMap::new(),
167 virtual_nodes_per_node,
168 }
169 }
170
171 pub fn add_node(&mut self, node: StreamNode) {
173 let node_id = node.node_id.clone();
174 for i in 0..self.virtual_nodes_per_node {
175 let key = format!("{}#{}", node_id, i);
176 let hash = self.fnv1a_hash(key.as_bytes());
177 self.ring.push(VirtualNode {
178 hash,
179 node_id: node_id.clone(),
180 });
181 }
182 self.ring.sort_by_key(|v| v.hash);
183 self.nodes.insert(node_id, node);
184 debug!(
185 "Added node to ring, total virtual nodes: {}",
186 self.ring.len()
187 );
188 }
189
190 pub fn remove_node(&mut self, node_id: &str) -> DistributedResult<()> {
192 if !self.nodes.contains_key(node_id) {
193 return Err(DistributedStreamError::NodeNotFound {
194 node_id: node_id.to_string(),
195 });
196 }
197 self.ring.retain(|v| v.node_id != node_id);
198 self.nodes.remove(node_id);
199 info!("Removed node {} from ring", node_id);
200 Ok(())
201 }
202
203 pub fn route(&self, partition_key: &str) -> DistributedResult<&StreamNode> {
205 if self.ring.is_empty() {
206 return Err(DistributedStreamError::NoNodesAvailable);
207 }
208 let hash = self.fnv1a_hash(partition_key.as_bytes());
209 let start_idx = self.find_ring_position(hash);
210 for offset in 0..self.ring.len() {
212 let candidate_id = &self.ring[(start_idx + offset) % self.ring.len()].node_id;
213 if let Some(node) = self.nodes.get(candidate_id) {
214 if node.is_available() {
215 return Ok(node);
216 }
217 }
218 }
219 let fallback_id = &self.ring[start_idx].node_id;
221 self.nodes
222 .get(fallback_id)
223 .ok_or_else(|| DistributedStreamError::NodeNotFound {
224 node_id: fallback_id.clone(),
225 })
226 }
227
228 pub fn nodes(&self) -> impl Iterator<Item = &StreamNode> {
230 self.nodes.values()
231 }
232
233 pub fn node_count(&self) -> usize {
235 self.nodes.len()
236 }
237
238 fn fnv1a_hash(&self, data: &[u8]) -> u64 {
240 const FNV_OFFSET: u64 = 14695981039346656037;
241 const FNV_PRIME: u64 = 1099511628211;
242 let mut hash = FNV_OFFSET;
243 for byte in data {
244 hash ^= u64::from(*byte);
245 hash = hash.wrapping_mul(FNV_PRIME);
246 }
247 hash
248 }
249
250 fn find_ring_position(&self, hash: u64) -> usize {
252 match self.ring.binary_search_by_key(&hash, |v| v.hash) {
253 Ok(idx) => idx,
254 Err(idx) => idx % self.ring.len(),
255 }
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct PartialWindowResult {
264 pub window_id: u64,
266 pub source_node: String,
268 pub event_count: u64,
270 pub partial_sum: f64,
272 pub partial_min: f64,
274 pub partial_max: f64,
276 pub is_complete: bool,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct AggregatedWindowResult {
283 pub window_id: u64,
285 pub total_events: u64,
287 pub global_sum: f64,
289 pub global_min: f64,
291 pub global_max: f64,
293 pub global_avg: f64,
295 pub contributing_nodes: usize,
297 pub is_complete: bool,
299}
300
301pub struct DistributedWindowAggregator {
306 expected_nodes: HashSet<String>,
308 partial_results: Arc<RwLock<HashMap<(u64, String), PartialWindowResult>>>,
310 completed: Arc<RwLock<BTreeMap<u64, AggregatedWindowResult>>>,
312 timeout: Duration,
314}
315
316impl DistributedWindowAggregator {
317 pub fn new(expected_nodes: HashSet<String>, timeout: Duration) -> Self {
319 Self {
320 expected_nodes,
321 partial_results: Arc::new(RwLock::new(HashMap::new())),
322 completed: Arc::new(RwLock::new(BTreeMap::new())),
323 timeout,
324 }
325 }
326
327 pub fn submit_partial(
332 &self,
333 partial: PartialWindowResult,
334 ) -> DistributedResult<Option<AggregatedWindowResult>> {
335 let window_id = partial.window_id;
336 {
337 let mut results = self.partial_results.write();
338 results.insert((window_id, partial.source_node.clone()), partial);
339 }
340 self.try_aggregate(window_id)
341 }
342
343 pub fn force_aggregate(&self, window_id: u64) -> DistributedResult<AggregatedWindowResult> {
345 self.merge_partials(window_id, false)
346 }
347
348 pub fn get_completed(&self, window_id: u64) -> Option<AggregatedWindowResult> {
350 self.completed.read().get(&window_id).cloned()
351 }
352
353 pub fn drain_completed(&self) -> Vec<AggregatedWindowResult> {
355 let mut completed = self.completed.write();
356 let keys: Vec<u64> = completed.keys().cloned().collect();
357 keys.into_iter()
358 .filter_map(|k| completed.remove(&k))
359 .collect()
360 }
361
362 pub fn timeout(&self) -> Duration {
364 self.timeout
365 }
366
367 fn try_aggregate(&self, window_id: u64) -> DistributedResult<Option<AggregatedWindowResult>> {
368 let contributing: HashSet<String> = {
369 let results = self.partial_results.read();
370 results
371 .keys()
372 .filter(|(wid, _)| *wid == window_id)
373 .map(|(_, nid)| nid.clone())
374 .collect()
375 };
376 if contributing == self.expected_nodes {
377 let agg = self.merge_partials(window_id, true)?;
378 Ok(Some(agg))
379 } else {
380 Ok(None)
381 }
382 }
383
384 fn merge_partials(
385 &self,
386 window_id: u64,
387 all_present: bool,
388 ) -> DistributedResult<AggregatedWindowResult> {
389 let results = self.partial_results.read();
390 let partials: Vec<&PartialWindowResult> = results
391 .iter()
392 .filter(|((wid, _), _)| *wid == window_id)
393 .map(|(_, v)| v)
394 .collect();
395
396 if partials.is_empty() {
397 return Err(DistributedStreamError::WindowAggregation(format!(
398 "No partial results for window {}",
399 window_id
400 )));
401 }
402
403 let total_events = partials.iter().map(|p| p.event_count).sum::<u64>();
404 let global_sum = partials.iter().map(|p| p.partial_sum).sum::<f64>();
405 let global_min = partials
406 .iter()
407 .map(|p| p.partial_min)
408 .fold(f64::INFINITY, f64::min);
409 let global_max = partials
410 .iter()
411 .map(|p| p.partial_max)
412 .fold(f64::NEG_INFINITY, f64::max);
413 let global_avg = if total_events > 0 {
414 global_sum / total_events as f64
415 } else {
416 0.0
417 };
418
419 let agg = AggregatedWindowResult {
420 window_id,
421 total_events,
422 global_sum,
423 global_min,
424 global_max,
425 global_avg,
426 contributing_nodes: partials.len(),
427 is_complete: all_present,
428 };
429 self.completed.write().insert(window_id, agg.clone());
430 Ok(agg)
431 }
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct TopologyConfig {
439 pub max_partitions_per_node: usize,
441 pub heartbeat_interval: Duration,
443 pub node_timeout: Duration,
445 pub virtual_nodes: usize,
447 pub replication_factor: usize,
449}
450
451impl Default for TopologyConfig {
452 fn default() -> Self {
453 Self {
454 max_partitions_per_node: 64,
455 heartbeat_interval: Duration::from_secs(5),
456 node_timeout: Duration::from_secs(30),
457 virtual_nodes: 150,
458 replication_factor: 2,
459 }
460 }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct TopologyStats {
466 pub total_nodes: usize,
468 pub healthy_nodes: usize,
470 pub total_partitions: usize,
472 pub avg_partitions_per_node: f64,
474 pub snapshot_time: std::time::SystemTime,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub enum TopologyChange {
481 NodeAdded(String),
483 NodeRemoved(String),
485 PartitionReassigned {
487 partition: u32,
488 from: String,
489 to: String,
490 },
491 NodeStatusChanged { node_id: String, status: NodeStatus },
493}
494
495pub struct DistributedStreamTopology {
500 config: TopologyConfig,
501 router: Arc<RwLock<ConsistentHashRouter>>,
502 partition_map: Arc<RwLock<HashMap<u32, String>>>,
504 total_partitions: u32,
506 change_tx: mpsc::Sender<TopologyChange>,
508 change_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<TopologyChange>>>,
510}
511
512impl DistributedStreamTopology {
513 pub fn new(config: TopologyConfig, total_partitions: u32) -> Self {
515 let (change_tx, change_rx) = mpsc::channel(1024);
516 Self {
517 config: config.clone(),
518 router: Arc::new(RwLock::new(ConsistentHashRouter::new(config.virtual_nodes))),
519 partition_map: Arc::new(RwLock::new(HashMap::new())),
520 total_partitions,
521 change_tx,
522 change_rx: Arc::new(tokio::sync::Mutex::new(change_rx)),
523 }
524 }
525
526 pub async fn add_node(&self, node: StreamNode) -> DistributedResult<()> {
528 let node_id = node.node_id.clone();
529 info!("Adding node {} to topology", node_id);
530 self.router.write().add_node(node);
531 self.rebalance_partitions()?;
532 let _ = self
533 .change_tx
534 .send(TopologyChange::NodeAdded(node_id))
535 .await;
536 Ok(())
537 }
538
539 pub async fn remove_node(&self, node_id: &str) -> DistributedResult<()> {
541 info!("Removing node {} from topology", node_id);
542 self.router.write().remove_node(node_id)?;
543 let reassigned = self.reassign_from_node(node_id)?;
544 for (partition, to) in reassigned {
545 let _ = self
546 .change_tx
547 .send(TopologyChange::PartitionReassigned {
548 partition,
549 from: node_id.to_string(),
550 to,
551 })
552 .await;
553 }
554 let _ = self
555 .change_tx
556 .send(TopologyChange::NodeRemoved(node_id.to_string()))
557 .await;
558 Ok(())
559 }
560
561 pub fn route(&self, partition_key: &str) -> DistributedResult<String> {
563 self.router
564 .read()
565 .route(partition_key)
566 .map(|n| n.node_id.clone())
567 }
568
569 pub fn stats(&self) -> TopologyStats {
571 let router = self.router.read();
572 let total_nodes = router.node_count();
573 let healthy_nodes = router
574 .nodes()
575 .filter(|n| n.status == NodeStatus::Healthy)
576 .count();
577 let partition_map = self.partition_map.read();
578 let total_partitions = partition_map.len();
579 let avg_partitions_per_node = if total_nodes > 0 {
580 total_partitions as f64 / total_nodes as f64
581 } else {
582 0.0
583 };
584 TopologyStats {
585 total_nodes,
586 healthy_nodes,
587 total_partitions,
588 avg_partitions_per_node,
589 snapshot_time: std::time::SystemTime::now(),
590 }
591 }
592
593 pub fn change_receiver(&self) -> Arc<tokio::sync::Mutex<mpsc::Receiver<TopologyChange>>> {
595 Arc::clone(&self.change_rx)
596 }
597
598 fn rebalance_partitions(&self) -> DistributedResult<()> {
599 let mut partition_map = self.partition_map.write();
600 let router = self.router.read();
601 for partition in 0..self.total_partitions {
602 let key = partition.to_string();
603 let node_id = router.route(&key)?.node_id.clone();
604 partition_map.insert(partition, node_id);
605 }
606 debug!("Rebalanced {} partitions", self.total_partitions);
607 Ok(())
608 }
609
610 fn reassign_from_node(&self, removed_node_id: &str) -> DistributedResult<Vec<(u32, String)>> {
611 let mut partition_map = self.partition_map.write();
612 let router = self.router.read();
613 let mut reassigned = Vec::new();
614
615 for (partition, node_id) in partition_map.iter_mut() {
616 if node_id.as_str() == removed_node_id {
617 let key = partition.to_string();
618 let new_node = router.route(&key)?.node_id.clone();
619 reassigned.push((*partition, new_node.clone()));
620 *node_id = new_node;
621 }
622 }
623 Ok(reassigned)
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
631pub struct StreamJob {
632 pub job_id: String,
634 pub name: String,
636 pub partitions: Vec<u32>,
638 pub node_assignments: HashMap<u32, String>,
640 pub created_at: std::time::SystemTime,
642 pub is_active: bool,
644}
645
646impl StreamJob {
647 pub fn new(job_id: impl Into<String>, name: impl Into<String>, partitions: Vec<u32>) -> Self {
649 Self {
650 job_id: job_id.into(),
651 name: name.into(),
652 partitions,
653 node_assignments: HashMap::new(),
654 created_at: std::time::SystemTime::now(),
655 is_active: true,
656 }
657 }
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct CoordinatorStats {
663 pub total_jobs: usize,
665 pub active_jobs: usize,
667 pub total_partitions_managed: usize,
669 pub cluster_size: usize,
671}
672
673pub struct ClusterStreamCoordinator {
678 topology: Arc<DistributedStreamTopology>,
679 jobs: Arc<RwLock<HashMap<String, StreamJob>>>,
680 start_time: Instant,
681}
682
683impl ClusterStreamCoordinator {
684 pub fn new(topology: Arc<DistributedStreamTopology>) -> Self {
686 Self {
687 topology,
688 jobs: Arc::new(RwLock::new(HashMap::new())),
689 start_time: Instant::now(),
690 }
691 }
692
693 pub async fn submit_job(&self, mut job: StreamJob) -> DistributedResult<String> {
697 let job_id = job.job_id.clone();
698 info!(
699 "Submitting job {} with {} partitions",
700 job_id,
701 job.partitions.len()
702 );
703 for &partition in &job.partitions {
704 let key = partition.to_string();
705 let node_id = self.topology.route(&key)?;
706 job.node_assignments.insert(partition, node_id);
707 }
708 self.jobs.write().insert(job_id.clone(), job);
709 Ok(job_id)
710 }
711
712 pub fn cancel_job(&self, job_id: &str) -> DistributedResult<()> {
714 let mut jobs = self.jobs.write();
715 match jobs.get_mut(job_id) {
716 Some(job) => {
717 job.is_active = false;
718 info!("Cancelled job {}", job_id);
719 Ok(())
720 }
721 None => Err(DistributedStreamError::JobDistribution(format!(
722 "Job {} not found",
723 job_id
724 ))),
725 }
726 }
727
728 pub fn get_job(&self, job_id: &str) -> Option<StreamJob> {
730 self.jobs.read().get(job_id).cloned()
731 }
732
733 pub fn stats(&self) -> CoordinatorStats {
735 let jobs = self.jobs.read();
736 let active_jobs = jobs.values().filter(|j| j.is_active).count();
737 let total_partitions_managed = jobs
738 .values()
739 .filter(|j| j.is_active)
740 .map(|j| j.partitions.len())
741 .sum();
742 let topology_stats = self.topology.stats();
743 CoordinatorStats {
744 total_jobs: jobs.len(),
745 active_jobs,
746 total_partitions_managed,
747 cluster_size: topology_stats.total_nodes,
748 }
749 }
750
751 pub fn uptime(&self) -> Duration {
753 self.start_time.elapsed()
754 }
755
756 pub async fn rebalance_all_jobs(&self) -> DistributedResult<usize> {
760 let job_ids: Vec<String> = self
761 .jobs
762 .read()
763 .values()
764 .filter(|j| j.is_active)
765 .map(|j| j.job_id.clone())
766 .collect();
767 let count = job_ids.len();
768 for job_id in job_ids {
769 let mut jobs = self.jobs.write();
770 if let Some(job) = jobs.get_mut(&job_id) {
771 job.node_assignments.clear();
772 for &partition in &job.partitions.clone() {
773 let key = partition.to_string();
774 let node_id = self.topology.route(&key)?;
775 job.node_assignments.insert(partition, node_id);
776 }
777 }
778 }
779 info!("Rebalanced {} active jobs", count);
780 Ok(count)
781 }
782}
783
784#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn test_consistent_hash_router_basic() {
792 let mut router = ConsistentHashRouter::new(10);
793 router.add_node(StreamNode::new("node-1", "127.0.0.1:8001"));
794 router.add_node(StreamNode::new("node-2", "127.0.0.1:8002"));
795 router.add_node(StreamNode::new("node-3", "127.0.0.1:8003"));
796
797 let node = router.route("partition-42").expect("route should succeed");
798 assert!(!node.node_id.is_empty());
799 assert_eq!(router.node_count(), 3);
800 }
801
802 #[test]
803 fn test_consistent_hash_router_no_nodes() {
804 let router = ConsistentHashRouter::new(10);
805 let result = router.route("partition-1");
806 assert!(matches!(
807 result,
808 Err(DistributedStreamError::NoNodesAvailable)
809 ));
810 }
811
812 #[test]
813 fn test_consistent_hash_router_remove_node() {
814 let mut router = ConsistentHashRouter::new(10);
815 router.add_node(StreamNode::new("node-1", "127.0.0.1:8001"));
816 router.add_node(StreamNode::new("node-2", "127.0.0.1:8002"));
817
818 router.remove_node("node-1").expect("remove should succeed");
819 assert_eq!(router.node_count(), 1);
820
821 let result = router.remove_node("ghost-node");
822 assert!(matches!(
823 result,
824 Err(DistributedStreamError::NodeNotFound { .. })
825 ));
826 }
827
828 #[test]
829 fn test_consistent_hash_distribution() {
830 let mut router = ConsistentHashRouter::new(150);
831 router.add_node(StreamNode::new("node-a", "10.0.0.1:9000"));
832 router.add_node(StreamNode::new("node-b", "10.0.0.2:9000"));
833 router.add_node(StreamNode::new("node-c", "10.0.0.3:9000"));
834
835 let mut counts: HashMap<String, usize> = HashMap::new();
836 for i in 0..300u32 {
837 let key = format!("partition-{}", i);
838 let node = router.route(&key).expect("route should succeed");
839 *counts.entry(node.node_id.clone()).or_insert(0) += 1;
840 }
841 for (node_id, count) in &counts {
843 assert!(
844 *count > 20,
845 "distribution too skewed for {}: got {}",
846 node_id,
847 count
848 );
849 }
850 }
851
852 #[test]
853 fn test_distributed_window_aggregator_full() {
854 let expected: HashSet<String> = ["node-1", "node-2", "node-3"]
855 .iter()
856 .map(|s| s.to_string())
857 .collect();
858 let aggregator = DistributedWindowAggregator::new(expected, Duration::from_secs(10));
859
860 let p1 = PartialWindowResult {
861 window_id: 1000,
862 source_node: "node-1".to_string(),
863 event_count: 100,
864 partial_sum: 50.0,
865 partial_min: 1.0,
866 partial_max: 10.0,
867 is_complete: true,
868 };
869 let result = aggregator
870 .submit_partial(p1)
871 .expect("submit should succeed");
872 assert!(result.is_none(), "should not complete with only 1/3 nodes");
873
874 let p2 = PartialWindowResult {
875 window_id: 1000,
876 source_node: "node-2".to_string(),
877 event_count: 200,
878 partial_sum: 150.0,
879 partial_min: 0.5,
880 partial_max: 15.0,
881 is_complete: true,
882 };
883 let result = aggregator
884 .submit_partial(p2)
885 .expect("submit should succeed");
886 assert!(result.is_none(), "should not complete with only 2/3 nodes");
887
888 let p3 = PartialWindowResult {
889 window_id: 1000,
890 source_node: "node-3".to_string(),
891 event_count: 300,
892 partial_sum: 300.0,
893 partial_min: 0.1,
894 partial_max: 20.0,
895 is_complete: true,
896 };
897 let result = aggregator
898 .submit_partial(p3)
899 .expect("submit should succeed");
900 assert!(result.is_some(), "should complete with all 3 nodes");
901
902 let agg = result.expect("aggregate must be Some");
903 assert_eq!(agg.window_id, 1000);
904 assert_eq!(agg.total_events, 600);
905 assert!((agg.global_sum - 500.0).abs() < 1e-9);
906 assert!((agg.global_min - 0.1).abs() < 1e-9);
907 assert!((agg.global_max - 20.0).abs() < 1e-9);
908 assert!(agg.is_complete);
909 }
910
911 #[test]
912 fn test_distributed_window_force_aggregate() {
913 let expected: HashSet<String> =
914 ["node-1", "node-2"].iter().map(|s| s.to_string()).collect();
915 let aggregator = DistributedWindowAggregator::new(expected, Duration::from_secs(10));
916
917 let p = PartialWindowResult {
918 window_id: 2000,
919 source_node: "node-1".to_string(),
920 event_count: 50,
921 partial_sum: 25.0,
922 partial_min: 2.0,
923 partial_max: 8.0,
924 is_complete: false,
925 };
926 aggregator.submit_partial(p).expect("submit should succeed");
927
928 let agg = aggregator
929 .force_aggregate(2000)
930 .expect("force aggregate should succeed");
931 assert_eq!(agg.window_id, 2000);
932 assert_eq!(agg.total_events, 50);
933 assert!(!agg.is_complete);
934 }
935
936 #[test]
937 fn test_distributed_window_drain_completed() {
938 let expected: HashSet<String> = ["node-x"].iter().map(|s| s.to_string()).collect();
939 let aggregator = DistributedWindowAggregator::new(expected, Duration::from_secs(5));
940
941 let p = PartialWindowResult {
942 window_id: 3000,
943 source_node: "node-x".to_string(),
944 event_count: 10,
945 partial_sum: 5.0,
946 partial_min: 1.0,
947 partial_max: 2.0,
948 is_complete: true,
949 };
950 aggregator.submit_partial(p).expect("submit should succeed");
951
952 let drained = aggregator.drain_completed();
953 assert_eq!(drained.len(), 1);
954 assert_eq!(drained[0].window_id, 3000);
955
956 let empty = aggregator.drain_completed();
958 assert!(empty.is_empty());
959 }
960
961 #[tokio::test]
962 async fn test_distributed_stream_topology_add_remove() {
963 let config = TopologyConfig::default();
964 let topology = DistributedStreamTopology::new(config, 16);
965
966 topology
967 .add_node(StreamNode::new("node-a", "10.0.0.1:9000"))
968 .await
969 .expect("add node should succeed");
970 topology
971 .add_node(StreamNode::new("node-b", "10.0.0.2:9000"))
972 .await
973 .expect("add node should succeed");
974
975 let stats = topology.stats();
976 assert_eq!(stats.total_nodes, 2);
977 assert_eq!(stats.total_partitions, 16);
978
979 let routed = topology.route("test-key").expect("route should succeed");
980 assert!(!routed.is_empty());
981
982 topology
983 .remove_node("node-a")
984 .await
985 .expect("remove should succeed");
986 let stats = topology.stats();
987 assert_eq!(stats.total_nodes, 1);
988 }
989
990 #[tokio::test]
991 async fn test_cluster_stream_coordinator_submit_and_stats() {
992 let config = TopologyConfig::default();
993 let topology = Arc::new(DistributedStreamTopology::new(config, 32));
994 topology
995 .add_node(StreamNode::new("worker-1", "10.0.0.1:9000"))
996 .await
997 .expect("add node should succeed");
998 topology
999 .add_node(StreamNode::new("worker-2", "10.0.0.2:9000"))
1000 .await
1001 .expect("add node should succeed");
1002
1003 let coordinator = ClusterStreamCoordinator::new(Arc::clone(&topology));
1004 let job = StreamJob::new("job-001", "sensor-stream", vec![0, 1, 2, 3, 4, 5, 6, 7]);
1005 let job_id = coordinator
1006 .submit_job(job)
1007 .await
1008 .expect("submit should succeed");
1009 assert_eq!(job_id, "job-001");
1010
1011 let retrieved = coordinator.get_job("job-001").expect("job should exist");
1012 assert_eq!(retrieved.node_assignments.len(), 8);
1013
1014 let stats = coordinator.stats();
1015 assert_eq!(stats.active_jobs, 1);
1016 assert_eq!(stats.total_partitions_managed, 8);
1017 assert_eq!(stats.cluster_size, 2);
1018 }
1019
1020 #[tokio::test]
1021 async fn test_cluster_stream_coordinator_cancel() {
1022 let config = TopologyConfig::default();
1023 let topology = Arc::new(DistributedStreamTopology::new(config, 8));
1024 topology
1025 .add_node(StreamNode::new("n1", "localhost:9001"))
1026 .await
1027 .expect("add node");
1028
1029 let coordinator = ClusterStreamCoordinator::new(Arc::clone(&topology));
1030 let job = StreamJob::new("job-cancel", "cancel-test", vec![0, 1]);
1031 coordinator
1032 .submit_job(job)
1033 .await
1034 .expect("submit should succeed");
1035
1036 coordinator
1037 .cancel_job("job-cancel")
1038 .expect("cancel should succeed");
1039 let job = coordinator
1040 .get_job("job-cancel")
1041 .expect("job should still exist after cancel");
1042 assert!(!job.is_active);
1043
1044 let result = coordinator.cancel_job("nonexistent");
1045 assert!(matches!(
1046 result,
1047 Err(DistributedStreamError::JobDistribution(_))
1048 ));
1049 }
1050
1051 #[tokio::test]
1052 async fn test_rebalance_jobs_after_node_change() {
1053 let config = TopologyConfig::default();
1054 let topology = Arc::new(DistributedStreamTopology::new(config, 16));
1055 topology
1056 .add_node(StreamNode::new("n1", "localhost:9001"))
1057 .await
1058 .expect("add node");
1059 topology
1060 .add_node(StreamNode::new("n2", "localhost:9002"))
1061 .await
1062 .expect("add node");
1063
1064 let coordinator = ClusterStreamCoordinator::new(Arc::clone(&topology));
1065 let job = StreamJob::new("job-rebalance", "rebalance-test", (0u32..8).collect());
1066 coordinator
1067 .submit_job(job)
1068 .await
1069 .expect("submit should succeed");
1070
1071 topology
1072 .add_node(StreamNode::new("n3", "localhost:9003"))
1073 .await
1074 .expect("add node");
1075 let rebalanced = coordinator
1076 .rebalance_all_jobs()
1077 .await
1078 .expect("rebalance should succeed");
1079 assert_eq!(rebalanced, 1);
1080 }
1081}