Skip to main content

oxirs_stream/distributed/
mod.rs

1//! # Distributed Stream Processing
2//!
3//! Provides infrastructure for coordinating stream processing across multiple nodes
4//! in a cluster. This module implements consistent hashing for partition routing,
5//! distributed window aggregation, and cluster-wide job distribution.
6//!
7//! ## Components
8//!
9//! - [`DistributedStreamTopology`]: Coordinates stream processing across nodes
10//! - [`ConsistentHashRouter`]: Routes stream partitions to nodes via consistent hashing
11//! - [`DistributedWindowAggregator`]: Aggregates windowed results across cluster nodes
12//! - [`ClusterStreamCoordinator`]: Manages stream job distribution across the cluster
13//!
14//! ## W3-S11 (Flink-lite distributed shards via Raft)
15//!
16//! - [`coordinator::DistributedStreamCoordinator`]: Raft-tracked shard assignment,
17//!   per-key routing, integration with the W3-S9 cluster sink for assignment durability.
18//! - [`shard_manager::ShardManager`]: deterministic balanced shard placement
19//!   and rebalance plan generation.
20//! - [`event_shipper::EventShipper`]: cross-shard event delivery with a pluggable
21//!   transport.
22
23pub 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// ─── Error Types ─────────────────────────────────────────────────────────────
53
54/// Errors that can occur in distributed stream processing
55#[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
79/// Result type for distributed stream operations
80pub type DistributedResult<T> = Result<T, DistributedStreamError>;
81
82// ─── Node Definitions ────────────────────────────────────────────────────────
83
84/// Represents the health status of a cluster node
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub enum NodeStatus {
87    /// Node is healthy and processing
88    Healthy,
89    /// Node is degraded but operational
90    Degraded,
91    /// Node is unreachable
92    Unreachable,
93    /// Node is draining (removing from cluster)
94    Draining,
95}
96
97/// Metadata for a node participating in distributed stream processing
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct StreamNode {
100    /// Unique identifier for this node
101    pub node_id: String,
102    /// Network address of the node
103    pub address: String,
104    /// Current health status
105    pub status: NodeStatus,
106    /// Number of partitions assigned to this node
107    pub assigned_partitions: usize,
108    /// Processing capacity (0.0 to 1.0)
109    pub capacity: f64,
110    /// Timestamp of last heartbeat
111    pub last_heartbeat: std::time::SystemTime,
112    /// Node weight for load balancing (higher = more load)
113    pub weight: u32,
114}
115
116impl StreamNode {
117    /// Creates a new healthy stream node
118    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    /// Returns true if this node can accept new partitions
131    pub fn is_available(&self) -> bool {
132        matches!(self.status, NodeStatus::Healthy | NodeStatus::Degraded) && self.capacity > 0.0
133    }
134}
135
136// ─── Consistent Hash Router ──────────────────────────────────────────────────
137
138/// Virtual node entry in the consistent hash ring
139#[derive(Debug, Clone)]
140struct VirtualNode {
141    /// Hash position on the ring
142    hash: u64,
143    /// Owning physical node ID
144    node_id: String,
145}
146
147/// Routes stream partitions to nodes using consistent hashing.
148///
149/// Uses a hash ring with configurable virtual nodes per physical node to
150/// achieve even load distribution and minimal partition movement on topology
151/// changes.
152pub struct ConsistentHashRouter {
153    /// Sorted ring of virtual nodes
154    ring: Vec<VirtualNode>,
155    /// Physical nodes indexed by ID
156    nodes: HashMap<String, StreamNode>,
157    /// Number of virtual nodes per physical node
158    virtual_nodes_per_node: usize,
159}
160
161impl ConsistentHashRouter {
162    /// Creates a new router with the specified number of virtual nodes per physical node.
163    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    /// Adds a node to the hash ring.
172    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    /// Removes a node from the hash ring.
191    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    /// Routes a partition key to the responsible node.
204    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        // Walk the ring to find an available node
211        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        // Fallback: return the initial position even if not available
220        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    /// Returns an iterator over all known physical nodes.
229    pub fn nodes(&self) -> impl Iterator<Item = &StreamNode> {
230        self.nodes.values()
231    }
232
233    /// Returns the number of physical nodes.
234    pub fn node_count(&self) -> usize {
235        self.nodes.len()
236    }
237
238    /// FNV-1a 64-bit hash
239    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    /// Binary search for the first ring entry >= hash; wraps to 0 if past end.
251    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// ─── Distributed Window Aggregator ───────────────────────────────────────────
260
261/// A partial window result from a single node
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct PartialWindowResult {
264    /// Window identifier (start timestamp ms)
265    pub window_id: u64,
266    /// Source node that produced this partial result
267    pub source_node: String,
268    /// Number of events processed in this partial window
269    pub event_count: u64,
270    /// Partial sum for numeric aggregations
271    pub partial_sum: f64,
272    /// Partial minimum value
273    pub partial_min: f64,
274    /// Partial maximum value
275    pub partial_max: f64,
276    /// Whether this partial result is complete for the node
277    pub is_complete: bool,
278}
279
280/// Aggregated result across all nodes for a window
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct AggregatedWindowResult {
283    /// Window identifier
284    pub window_id: u64,
285    /// Total events across all nodes
286    pub total_events: u64,
287    /// Global sum
288    pub global_sum: f64,
289    /// Global minimum
290    pub global_min: f64,
291    /// Global maximum
292    pub global_max: f64,
293    /// Global average (sum / total_events)
294    pub global_avg: f64,
295    /// Number of nodes that contributed
296    pub contributing_nodes: usize,
297    /// Whether all expected nodes contributed
298    pub is_complete: bool,
299}
300
301/// Aggregates windowed results from multiple cluster nodes.
302///
303/// Collects partial results from each node and merges them into a global
304/// aggregate once all expected contributions arrive or a force-flush is triggered.
305pub struct DistributedWindowAggregator {
306    /// Expected node IDs per window
307    expected_nodes: HashSet<String>,
308    /// Partial results keyed by (window_id, node_id)
309    partial_results: Arc<RwLock<HashMap<(u64, String), PartialWindowResult>>>,
310    /// Completed aggregations keyed by window_id
311    completed: Arc<RwLock<BTreeMap<u64, AggregatedWindowResult>>>,
312    /// Timeout before forcing finalisation of incomplete windows
313    timeout: Duration,
314}
315
316impl DistributedWindowAggregator {
317    /// Creates a new aggregator expecting results from the given nodes.
318    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    /// Submits a partial result from a node.
328    ///
329    /// Returns `Some(AggregatedWindowResult)` if all expected nodes have now
330    /// contributed for this window; otherwise returns `None`.
331    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    /// Forces aggregation for a window even if not all nodes have contributed.
344    pub fn force_aggregate(&self, window_id: u64) -> DistributedResult<AggregatedWindowResult> {
345        self.merge_partials(window_id, false)
346    }
347
348    /// Returns a completed aggregate for a specific window, if available.
349    pub fn get_completed(&self, window_id: u64) -> Option<AggregatedWindowResult> {
350        self.completed.read().get(&window_id).cloned()
351    }
352
353    /// Drains and returns all completed window results in ascending window order.
354    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    /// Returns the configured aggregation timeout.
363    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// ─── Distributed Stream Topology ─────────────────────────────────────────────
435
436/// Configuration for a distributed stream topology
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct TopologyConfig {
439    /// Maximum number of partitions per node
440    pub max_partitions_per_node: usize,
441    /// Heartbeat interval for node health checks
442    pub heartbeat_interval: Duration,
443    /// How long before a silent node is considered unreachable
444    pub node_timeout: Duration,
445    /// Number of virtual nodes per physical node for consistent hashing
446    pub virtual_nodes: usize,
447    /// Replication factor for stream partitions
448    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/// Statistics snapshot for the topology
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct TopologyStats {
466    /// Total number of registered nodes
467    pub total_nodes: usize,
468    /// Number of healthy nodes
469    pub healthy_nodes: usize,
470    /// Total partitions under management
471    pub total_partitions: usize,
472    /// Average partitions per node
473    pub avg_partitions_per_node: f64,
474    /// Timestamp of this snapshot
475    pub snapshot_time: std::time::SystemTime,
476}
477
478/// A topology change event broadcast on topology changes
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub enum TopologyChange {
481    /// A new node joined
482    NodeAdded(String),
483    /// A node was removed
484    NodeRemoved(String),
485    /// A partition was reassigned from one node to another
486    PartitionReassigned {
487        partition: u32,
488        from: String,
489        to: String,
490    },
491    /// A node changed health status
492    NodeStatusChanged { node_id: String, status: NodeStatus },
493}
494
495/// Coordinates stream processing across multiple cluster nodes.
496///
497/// Maintains cluster membership, assigns partitions to nodes using consistent
498/// hashing, and broadcasts topology change events.
499pub struct DistributedStreamTopology {
500    config: TopologyConfig,
501    router: Arc<RwLock<ConsistentHashRouter>>,
502    /// Partition assignment: partition_id to node_id
503    partition_map: Arc<RwLock<HashMap<u32, String>>>,
504    /// Total number of partitions managed by this topology
505    total_partitions: u32,
506    /// Notification channel for topology changes
507    change_tx: mpsc::Sender<TopologyChange>,
508    /// Receiver exposed to callers
509    change_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<TopologyChange>>>,
510}
511
512impl DistributedStreamTopology {
513    /// Creates a new topology with the given configuration and partition count.
514    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    /// Adds a node to the topology and rebalances all partitions.
527    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    /// Removes a node from the topology and reassigns its partitions.
540    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    /// Routes a partition key to its responsible node, returning the node ID.
562    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    /// Returns current topology statistics.
570    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    /// Returns a handle to the topology change receiver.
594    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// ─── Cluster Stream Coordinator ───────────────────────────────────────────────
628
629/// A stream processing job that is distributed across cluster nodes
630#[derive(Debug, Clone, Serialize, Deserialize)]
631pub struct StreamJob {
632    /// Unique job identifier
633    pub job_id: String,
634    /// Human-readable job name
635    pub name: String,
636    /// Partitions assigned to this job
637    pub partitions: Vec<u32>,
638    /// Node assignments for each partition
639    pub node_assignments: HashMap<u32, String>,
640    /// Job creation timestamp
641    pub created_at: std::time::SystemTime,
642    /// Whether this job is currently active
643    pub is_active: bool,
644}
645
646impl StreamJob {
647    /// Creates a new stream job
648    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/// Statistics for the cluster coordinator
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct CoordinatorStats {
663    /// Total jobs ever submitted
664    pub total_jobs: usize,
665    /// Currently active jobs
666    pub active_jobs: usize,
667    /// Total partitions under active management
668    pub total_partitions_managed: usize,
669    /// Number of nodes in the cluster
670    pub cluster_size: usize,
671}
672
673/// Manages stream job distribution across a cluster.
674///
675/// Uses the [`DistributedStreamTopology`] to place job partitions on nodes
676/// and tracks running jobs.
677pub struct ClusterStreamCoordinator {
678    topology: Arc<DistributedStreamTopology>,
679    jobs: Arc<RwLock<HashMap<String, StreamJob>>>,
680    start_time: Instant,
681}
682
683impl ClusterStreamCoordinator {
684    /// Creates a new coordinator backed by the given topology.
685    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    /// Submits a new stream job, distributing its partitions across available nodes.
694    ///
695    /// Returns the job ID on success.
696    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    /// Cancels an active job by ID.
713    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    /// Returns a snapshot of a specific job by ID.
729    pub fn get_job(&self, job_id: &str) -> Option<StreamJob> {
730        self.jobs.read().get(job_id).cloned()
731    }
732
733    /// Returns coordinator statistics.
734    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    /// Returns the uptime of this coordinator.
752    pub fn uptime(&self) -> Duration {
753        self.start_time.elapsed()
754    }
755
756    /// Rebalances all active jobs when the topology changes.
757    ///
758    /// Returns the number of jobs that were rebalanced.
759    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// ─── Tests ───────────────────────────────────────────────────────────────────
785
786#[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        // Each node should receive roughly 100 partitions (wide tolerance for small ring)
842        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        // Draining again should yield empty
957        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}