Skip to main content

oxirs_stream/distributed/
coordinator.rs

1//! # Distributed stream coordinator
2//!
3//! Top-level entrypoint for distributed stream processing. Glues together:
4//!
5//! * [`super::shard_manager::ShardManager`] — owns the shard → node mapping.
6//! * [`oxirs_cluster::consensus::ConsensusManager`] — Raft-tracks the
7//!   committed assignment so every cluster node can replay it locally.
8//! * [`super::event_shipper::EventShipper`] — ships incoming events to the
9//!   node that owns the relevant shard.
10//!
11//! ## Lifecycle
12//!
13//! 1. The coordinator is constructed on every node with the same
14//!    [`CoordinatorConfig`] (in particular, the same `n_shards` value).
15//! 2. The local node calls [`DistributedStreamCoordinator::join`] to register
16//!    itself.
17//! 3. The coordinator computes a balanced rebalance plan and persists the new
18//!    assignment as a Raft proposal so all nodes converge on the same view.
19//! 4. As stream events arrive, [`DistributedStreamCoordinator::route`]
20//!    deterministically selects the destination shard / node and either
21//!    delivers locally or hands the event to the [`EventShipper`].
22//!
23//! ## Raft propagation
24//!
25//! The committed assignment is encoded into the same `RdfCommand::Insert`
26//! mechanism used by the W3-S9 cluster sink. We use synthetic triples in the
27//! `oxirs://stream-coord/{coord_id}/...` namespace so the proposal is durable
28//! and replayable but does not collide with regular RDF data.
29
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::Arc;
32
33use parking_lot::RwLock;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36use tracing::{debug, info};
37
38use oxirs_cluster::stream_integration::{StreamMessage, StreamTriple};
39use oxirs_cluster::streaming::cluster_sink::{SinkError, StreamSink};
40
41use super::event_shipper::{EventShipper, ShippedEvent, ShipperError};
42use super::shard_manager::{
43    NodeId, RebalancePlan, ShardAssignment, ShardId, ShardManager, ShardManagerConfig,
44    ShardManagerError,
45};
46
47// ─── Errors ─────────────────────────────────────────────────────────────────
48
49/// Errors raised by [`DistributedStreamCoordinator`].
50#[derive(Debug, Error)]
51pub enum CoordinatorError {
52    /// Shard manager rejected the operation.
53    #[error(transparent)]
54    Shard(#[from] ShardManagerError),
55    /// Sink rejected the assignment proposal.
56    #[error("sink error: {0}")]
57    Sink(String),
58    /// Shipper failed to deliver an event.
59    #[error(transparent)]
60    Shipper(#[from] ShipperError),
61    /// Encoding the assignment payload failed.
62    #[error("encoding error: {0}")]
63    Encoding(String),
64    /// Routing was attempted but no nodes are registered.
65    #[error("no nodes registered")]
66    NoNodes,
67}
68
69impl From<SinkError> for CoordinatorError {
70    fn from(err: SinkError) -> Self {
71        CoordinatorError::Sink(err.to_string())
72    }
73}
74
75/// Convenience alias.
76pub type CoordinatorResult<T> = std::result::Result<T, CoordinatorError>;
77
78// ─── RoutedEvent ────────────────────────────────────────────────────────────
79
80/// Output of a routing decision.
81#[derive(Debug, Clone)]
82pub struct RoutedEvent {
83    /// Destination shard.
84    pub shard: ShardId,
85    /// Destination node id.
86    pub node: NodeId,
87    /// `true` when the destination is the local node.
88    pub local: bool,
89}
90
91// ─── Stats ─────────────────────────────────────────────────────────────────
92
93/// Runtime statistics for [`DistributedStreamCoordinator`].
94#[derive(Debug, Default)]
95pub struct CoordinatorStats {
96    pub join_proposals: AtomicU64,
97    pub leave_proposals: AtomicU64,
98    pub routed: AtomicU64,
99    pub locally_delivered: AtomicU64,
100    pub remote_shipped: AtomicU64,
101    pub assignment_installs: AtomicU64,
102    pub failed_proposals: AtomicU64,
103}
104
105impl CoordinatorStats {
106    /// Plain serialisable snapshot of the counters.
107    pub fn snapshot(&self) -> CoordinatorStatsSnapshot {
108        CoordinatorStatsSnapshot {
109            join_proposals: self.join_proposals.load(Ordering::Relaxed),
110            leave_proposals: self.leave_proposals.load(Ordering::Relaxed),
111            routed: self.routed.load(Ordering::Relaxed),
112            locally_delivered: self.locally_delivered.load(Ordering::Relaxed),
113            remote_shipped: self.remote_shipped.load(Ordering::Relaxed),
114            assignment_installs: self.assignment_installs.load(Ordering::Relaxed),
115            failed_proposals: self.failed_proposals.load(Ordering::Relaxed),
116        }
117    }
118}
119
120/// Plain snapshot of [`CoordinatorStats`].
121#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
122pub struct CoordinatorStatsSnapshot {
123    pub join_proposals: u64,
124    pub leave_proposals: u64,
125    pub routed: u64,
126    pub locally_delivered: u64,
127    pub remote_shipped: u64,
128    pub assignment_installs: u64,
129    pub failed_proposals: u64,
130}
131
132// ─── Coordinator ───────────────────────────────────────────────────────────
133
134/// Configuration for [`DistributedStreamCoordinator`].
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct CoordinatorConfig {
137    /// Shared coordinator identifier; the same value must be used on every
138    /// node of a single distributed pipeline.
139    pub coord_id: String,
140    /// Identity of the local node.
141    pub local_node: NodeId,
142    /// Number of shards across the topology.
143    pub n_shards: u32,
144    /// Optional stream id used for the Raft proposals (defaults to
145    /// `"stream-coord-{coord_id}"`).
146    pub stream_id: Option<String>,
147    /// If `true`, the coordinator skips shard-manager rebalances on
148    /// duplicate joins/leaves instead of returning an error.
149    pub idempotent_membership: bool,
150}
151
152impl CoordinatorConfig {
153    fn stream_id(&self) -> String {
154        self.stream_id
155            .clone()
156            .unwrap_or_else(|| format!("stream-coord-{}", self.coord_id))
157    }
158
159    fn assignment_subject(&self) -> String {
160        format!("oxirs://stream-coord/{}/assignment", self.coord_id)
161    }
162}
163
164/// Distributed stream coordinator.
165pub struct DistributedStreamCoordinator {
166    config: CoordinatorConfig,
167    shard_mgr: ShardManager,
168    sink: Arc<dyn StreamSink>,
169    shipper: Arc<EventShipper>,
170    /// Last applied assignment (mirrors `shard_mgr.current_assignment`).
171    last_assignment: RwLock<ShardAssignment>,
172    proposal_offset: AtomicU64,
173    stats: Arc<CoordinatorStats>,
174}
175
176impl DistributedStreamCoordinator {
177    /// Build a coordinator.
178    pub fn new(
179        config: CoordinatorConfig,
180        sink: Arc<dyn StreamSink>,
181        shipper: Arc<EventShipper>,
182    ) -> CoordinatorResult<Self> {
183        let shard_mgr = ShardManager::new(ShardManagerConfig {
184            n_shards: config.n_shards,
185        })?;
186        Ok(Self {
187            config,
188            shard_mgr,
189            sink,
190            shipper,
191            last_assignment: RwLock::new(ShardAssignment::default()),
192            proposal_offset: AtomicU64::new(0),
193            stats: Arc::new(CoordinatorStats::default()),
194        })
195    }
196
197    /// Stats accessor.
198    pub fn stats(&self) -> &Arc<CoordinatorStats> {
199        &self.stats
200    }
201
202    /// Shard manager handle.
203    pub fn shard_manager(&self) -> &ShardManager {
204        &self.shard_mgr
205    }
206
207    /// Latest installed assignment.
208    pub fn current_assignment(&self) -> ShardAssignment {
209        self.last_assignment.read().clone()
210    }
211
212    /// Local node identifier.
213    pub fn local_node(&self) -> &NodeId {
214        &self.config.local_node
215    }
216
217    /// Number of shards configured for the coordinator.
218    pub fn n_shards(&self) -> u32 {
219        self.config.n_shards
220    }
221
222    /// Register a node with the coordinator (typically the local node when
223    /// the cluster boots, then any peer nodes as they appear).
224    pub async fn join(&self, node: NodeId) -> CoordinatorResult<RebalancePlan> {
225        self.stats.join_proposals.fetch_add(1, Ordering::Relaxed);
226        let plan = match self.shard_mgr.add_node(node.clone()) {
227            Ok(plan) => plan,
228            Err(ShardManagerError::NodeAlreadyExists(_)) if self.config.idempotent_membership => {
229                debug!(node = %node, "join: idempotent skip");
230                return Ok(RebalancePlan::default());
231            }
232            Err(err) => {
233                self.stats.failed_proposals.fetch_add(1, Ordering::Relaxed);
234                return Err(err.into());
235            }
236        };
237        self.persist_assignment(&plan.new_assignment).await?;
238        *self.last_assignment.write() = plan.new_assignment.clone();
239        self.stats
240            .assignment_installs
241            .fetch_add(1, Ordering::Relaxed);
242        info!(
243            node = %node,
244            moves = plan.moves.len(),
245            "coordinator: node joined"
246        );
247        Ok(plan)
248    }
249
250    /// Deregister a node.
251    pub async fn leave(&self, node: &str) -> CoordinatorResult<RebalancePlan> {
252        self.stats.leave_proposals.fetch_add(1, Ordering::Relaxed);
253        let plan = match self.shard_mgr.remove_node(node) {
254            Ok(plan) => plan,
255            Err(ShardManagerError::UnknownNode(_)) if self.config.idempotent_membership => {
256                debug!(node = %node, "leave: idempotent skip");
257                return Ok(RebalancePlan::default());
258            }
259            Err(err) => {
260                self.stats.failed_proposals.fetch_add(1, Ordering::Relaxed);
261                return Err(err.into());
262            }
263        };
264        self.persist_assignment(&plan.new_assignment).await?;
265        *self.last_assignment.write() = plan.new_assignment.clone();
266        self.stats
267            .assignment_installs
268            .fetch_add(1, Ordering::Relaxed);
269        info!(
270            node = %node,
271            moves = plan.moves.len(),
272            "coordinator: node left"
273        );
274        Ok(plan)
275    }
276
277    /// Apply an assignment that was committed elsewhere (e.g. seen on a Raft
278    /// follower). The shard manager and the coordinator's cached assignment
279    /// are updated to match. No new Raft proposal is issued.
280    pub fn install_assignment(&self, assignment: ShardAssignment) -> RebalancePlan {
281        let plan = self.shard_mgr.install_assignment(assignment.clone());
282        *self.last_assignment.write() = assignment;
283        self.stats
284            .assignment_installs
285            .fetch_add(1, Ordering::Relaxed);
286        plan
287    }
288
289    /// Decide which node owns the shard for `partition_key`, ship the event
290    /// there, and return a [`RoutedEvent`] describing the decision.
291    pub async fn route(
292        &self,
293        partition_key: &str,
294        payload: &serde_json::Value,
295    ) -> CoordinatorResult<RoutedEvent> {
296        self.stats.routed.fetch_add(1, Ordering::Relaxed);
297        let assignment = self.last_assignment.read().clone();
298        if assignment.map.is_empty() {
299            return Err(CoordinatorError::NoNodes);
300        }
301        let shard = self.shard_for_key(partition_key, &assignment);
302        let node = assignment
303            .owner_of(shard)
304            .cloned()
305            .ok_or(CoordinatorError::NoNodes)?;
306        let event = ShippedEvent::json(shard, partition_key, payload, &self.config.local_node)
307            .map_err(|e| CoordinatorError::Encoding(e.to_string()))?;
308        let local = node == self.config.local_node;
309        self.shipper.ship(&node, event).await?;
310        if local {
311            self.stats.locally_delivered.fetch_add(1, Ordering::Relaxed);
312        } else {
313            self.stats.remote_shipped.fetch_add(1, Ordering::Relaxed);
314        }
315        Ok(RoutedEvent { shard, node, local })
316    }
317
318    /// Compute the shard id for a partition key without performing any I/O.
319    pub fn shard_for_key_value(&self, partition_key: &str) -> Option<ShardId> {
320        let assignment = self.last_assignment.read();
321        if assignment.map.is_empty() {
322            None
323        } else {
324            Some(self.shard_for_key(partition_key, &assignment))
325        }
326    }
327
328    fn shard_for_key(&self, partition_key: &str, _assignment: &ShardAssignment) -> ShardId {
329        let h = fnv1a_hash(partition_key.as_bytes());
330        (h % self.config.n_shards as u64) as ShardId
331    }
332
333    async fn persist_assignment(&self, assignment: &ShardAssignment) -> CoordinatorResult<()> {
334        let payload = serde_json::to_string(assignment)
335            .map_err(|e| CoordinatorError::Encoding(e.to_string()))?;
336        let object = format!("\"{}\"", escape_quotes(&payload));
337        let triple = StreamTriple::new(
338            self.config.assignment_subject(),
339            "http://oxirs.dev/stream-coord#assignment",
340            object,
341        );
342        let off = self.proposal_offset.fetch_add(1, Ordering::Relaxed) + 1;
343        let msg = StreamMessage::insert(self.config.stream_id(), off, vec![triple]);
344        self.sink.write_batch(vec![msg]).await?;
345        Ok(())
346    }
347}
348
349fn fnv1a_hash(bytes: &[u8]) -> u64 {
350    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
351    const FNV_PRIME: u64 = 1_099_511_628_211;
352    let mut h = FNV_OFFSET;
353    for b in bytes {
354        h ^= *b as u64;
355        h = h.wrapping_mul(FNV_PRIME);
356    }
357    h
358}
359
360fn escape_quotes(s: &str) -> String {
361    s.replace('\\', "\\\\").replace('"', "\\\"")
362}
363
364// ─── Tests ──────────────────────────────────────────────────────────────────
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::distributed::event_shipper::{
370        InProcessShipperTransport, ShipperConfig, ShipperTransport,
371    };
372    use async_trait::async_trait;
373    use parking_lot::Mutex;
374    use std::sync::Arc;
375
376    #[derive(Default)]
377    struct MockSink {
378        commits: Mutex<Vec<Vec<StreamMessage>>>,
379    }
380
381    #[async_trait]
382    impl StreamSink for MockSink {
383        async fn write_batch(&self, events: Vec<StreamMessage>) -> Result<(), SinkError> {
384            self.commits.lock().push(events);
385            Ok(())
386        }
387    }
388
389    /// Build a coordinator together with the receiver half of the local sink
390    /// so the test can assert that locally-routed events arrive.
391    fn make_local_coord() -> (
392        Arc<DistributedStreamCoordinator>,
393        tokio::sync::mpsc::Receiver<ShippedEvent>,
394    ) {
395        let sink = Arc::new(MockSink::default());
396        let transport = Arc::new(InProcessShipperTransport::new(64));
397        let shipper = Arc::new(EventShipper::new(
398            ShipperConfig {
399                local_node: "local".into(),
400            },
401            transport,
402        ));
403        let (tx, rx) = tokio::sync::mpsc::channel(64);
404        shipper.install_local_sink(tx);
405        let cfg = CoordinatorConfig {
406            coord_id: "c1".into(),
407            local_node: "local".into(),
408            n_shards: 8,
409            stream_id: None,
410            idempotent_membership: true,
411        };
412        let coord = Arc::new(DistributedStreamCoordinator::new(cfg, sink, shipper).expect("ok"));
413        (coord, rx)
414    }
415
416    #[tokio::test]
417    async fn join_persists_assignment() {
418        let (coord, _rx) = make_local_coord();
419        let plan = coord.join("local".into()).await.expect("join");
420        assert_eq!(plan.new_assignment.n_shards(), 8);
421        for owner in plan.new_assignment.map.values() {
422            assert_eq!(owner, "local");
423        }
424        let stats = coord.stats().snapshot();
425        assert_eq!(stats.assignment_installs, 1);
426    }
427
428    #[tokio::test]
429    async fn route_locally_when_owner() {
430        let (coord, mut rx) = make_local_coord();
431        coord.join("local".into()).await.expect("join");
432        let routed = coord
433            .route("k1", &serde_json::json!({"x": 1}))
434            .await
435            .expect("ok");
436        assert!(routed.local);
437        assert_eq!(routed.node, "local");
438        let received = rx.recv().await.expect("local delivery");
439        assert_eq!(received.key, "k1");
440    }
441
442    #[tokio::test]
443    async fn route_to_remote_node() {
444        let sink = Arc::new(MockSink::default());
445        let transport = Arc::new(InProcessShipperTransport::new(64));
446        let mut rx_remote = transport.spawn_receiver("remote".into());
447        let shipper = Arc::new(EventShipper::new(
448            ShipperConfig {
449                local_node: "local".into(),
450            },
451            transport.clone() as Arc<dyn ShipperTransport>,
452        ));
453        let cfg = CoordinatorConfig {
454            coord_id: "c2".into(),
455            local_node: "local".into(),
456            n_shards: 4,
457            stream_id: None,
458            idempotent_membership: true,
459        };
460        let coord = DistributedStreamCoordinator::new(cfg, sink, shipper).expect("ok");
461        coord.join("local".into()).await.expect("join");
462        coord.join("remote".into()).await.expect("join");
463
464        // Find a key that hashes onto "remote".
465        let mut chosen = None;
466        for i in 0..32 {
467            let key = format!("k{i}");
468            let assignment = coord.current_assignment();
469            let shard = (fnv1a_hash(key.as_bytes()) % 4) as ShardId;
470            if assignment.owner_of(shard).map(|s| s.as_str()) == Some("remote") {
471                chosen = Some(key);
472                break;
473            }
474        }
475        let key = chosen.expect("find a remote-owned key");
476        let routed = coord
477            .route(&key, &serde_json::json!({"v": 7}))
478            .await
479            .expect("ok");
480        assert_eq!(routed.node, "remote");
481        let received = rx_remote.recv().await.expect("received");
482        assert_eq!(received.key, key);
483    }
484
485    #[tokio::test]
486    async fn idempotent_join_skips_duplicate() {
487        let (coord, _rx) = make_local_coord();
488        coord.join("local".into()).await.expect("first");
489        let plan = coord.join("local".into()).await.expect("idempotent");
490        assert_eq!(plan.moves.len(), 0);
491    }
492
493    #[tokio::test]
494    async fn leave_rebalances() {
495        let (coord, _rx) = make_local_coord();
496        coord.join("local".into()).await.expect("ok");
497        coord.join("peer".into()).await.expect("ok");
498        let plan = coord.leave("peer").await.expect("ok");
499        assert!(!plan.new_assignment.map.is_empty());
500        assert!(!plan.new_assignment.map.values().any(|n| n == "peer"));
501    }
502
503    #[tokio::test]
504    async fn route_without_join_errors() {
505        let (coord, _rx) = make_local_coord();
506        let err = coord
507            .route("key", &serde_json::json!({}))
508            .await
509            .expect_err("no nodes");
510        assert!(matches!(err, CoordinatorError::NoNodes));
511    }
512
513    #[test]
514    fn fnv1a_is_deterministic() {
515        let h1 = fnv1a_hash(b"foo");
516        let h2 = fnv1a_hash(b"foo");
517        assert_eq!(h1, h2);
518        let h3 = fnv1a_hash(b"bar");
519        assert_ne!(h1, h3);
520    }
521
522    #[tokio::test]
523    async fn install_assignment_replays_remote_decision() {
524        let (coord, _rx) = make_local_coord();
525        coord.join("local".into()).await.expect("ok");
526        // Pretend the cluster committed a new assignment that puts every shard
527        // on "remote". The local coordinator should accept it without issuing
528        // its own proposal.
529        let new = ShardAssignment::from_vec(vec!["remote".into(); 8]);
530        let plan = coord.install_assignment(new.clone());
531        assert_eq!(plan.new_assignment, new);
532        assert_eq!(coord.current_assignment(), new);
533    }
534}