1use 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#[derive(Debug, Error)]
51pub enum CoordinatorError {
52 #[error(transparent)]
54 Shard(#[from] ShardManagerError),
55 #[error("sink error: {0}")]
57 Sink(String),
58 #[error(transparent)]
60 Shipper(#[from] ShipperError),
61 #[error("encoding error: {0}")]
63 Encoding(String),
64 #[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
75pub type CoordinatorResult<T> = std::result::Result<T, CoordinatorError>;
77
78#[derive(Debug, Clone)]
82pub struct RoutedEvent {
83 pub shard: ShardId,
85 pub node: NodeId,
87 pub local: bool,
89}
90
91#[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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct CoordinatorConfig {
137 pub coord_id: String,
140 pub local_node: NodeId,
142 pub n_shards: u32,
144 pub stream_id: Option<String>,
147 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
164pub struct DistributedStreamCoordinator {
166 config: CoordinatorConfig,
167 shard_mgr: ShardManager,
168 sink: Arc<dyn StreamSink>,
169 shipper: Arc<EventShipper>,
170 last_assignment: RwLock<ShardAssignment>,
172 proposal_offset: AtomicU64,
173 stats: Arc<CoordinatorStats>,
174}
175
176impl DistributedStreamCoordinator {
177 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 pub fn stats(&self) -> &Arc<CoordinatorStats> {
199 &self.stats
200 }
201
202 pub fn shard_manager(&self) -> &ShardManager {
204 &self.shard_mgr
205 }
206
207 pub fn current_assignment(&self) -> ShardAssignment {
209 self.last_assignment.read().clone()
210 }
211
212 pub fn local_node(&self) -> &NodeId {
214 &self.config.local_node
215 }
216
217 pub fn n_shards(&self) -> u32 {
219 self.config.n_shards
220 }
221
222 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 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 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 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 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#[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 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 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 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}