Skip to main content

oximedia_distributed/
shard.rs

1//! Data sharding and consistent hashing for distributed workload placement.
2//!
3//! Provides a consistent hash ring for stable shard-to-node mapping, shard
4//! metadata tracking, and rebalancing utilities.
5
6#![allow(dead_code)]
7
8use std::collections::BTreeMap;
9
10// ---------------------------------------------------------------------------
11// ShardKey
12// ---------------------------------------------------------------------------
13
14/// A key that identifies a data shard.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct ShardKey(pub String);
17
18impl ShardKey {
19    /// Create a new shard key.
20    #[must_use]
21    pub fn new(key: impl Into<String>) -> Self {
22        Self(key.into())
23    }
24
25    /// Compute a 64-bit hash of the key bytes using SipHash-like mixing.
26    ///
27    /// Uses multiple rounds of mixing to ensure good distribution across the
28    /// full u64 range.
29    #[must_use]
30    pub fn hash64(&self) -> u64 {
31        // FNV-1a base
32        const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
33        const FNV_PRIME: u64 = 1_099_511_628_211;
34        let mut h = FNV_OFFSET;
35        for &b in self.0.as_bytes() {
36            h ^= u64::from(b);
37            h = h.wrapping_mul(FNV_PRIME);
38        }
39        // Finalisation mix (based on splitmix64) for better avalanche
40        h ^= h >> 30;
41        h = h.wrapping_mul(0xbf58476d1ce4e5b9);
42        h ^= h >> 27;
43        h = h.wrapping_mul(0x94d049bb133111eb);
44        h ^= h >> 31;
45        h
46    }
47}
48
49// ---------------------------------------------------------------------------
50// VirtualNode
51// ---------------------------------------------------------------------------
52
53/// A virtual node (token) in the consistent hash ring.
54#[derive(Debug, Clone)]
55pub struct VirtualNode {
56    /// Ring position (hash of `node_id:replica_index`).
57    pub position: u64,
58    /// The physical node this virtual node belongs to.
59    pub node_id: String,
60    /// Replica index within the node (0-based).
61    pub replica: u32,
62}
63
64// ---------------------------------------------------------------------------
65// ConsistentHashRing
66// ---------------------------------------------------------------------------
67
68/// A consistent hash ring for stable shard-to-node assignment.
69///
70/// Each physical node is represented by `replicas_per_node` virtual nodes
71/// spread across the ring.  Adding or removing a node only relocates
72/// `1 / N` of the shards on average.
73#[derive(Debug, Default)]
74pub struct ConsistentHashRing {
75    ring: BTreeMap<u64, VirtualNode>,
76    replicas_per_node: u32,
77}
78
79impl ConsistentHashRing {
80    /// Create a new ring with `replicas_per_node` virtual nodes per physical node.
81    #[must_use]
82    pub fn new(replicas_per_node: u32) -> Self {
83        Self {
84            ring: BTreeMap::new(),
85            replicas_per_node: replicas_per_node.max(1),
86        }
87    }
88
89    /// Add a node to the ring.
90    pub fn add_node(&mut self, node_id: impl Into<String>) {
91        let node_id = node_id.into();
92        for replica in 0..self.replicas_per_node {
93            let key = ShardKey::new(format!("{node_id}:{replica}"));
94            let position = key.hash64();
95            self.ring.insert(
96                position,
97                VirtualNode {
98                    position,
99                    node_id: node_id.clone(),
100                    replica,
101                },
102            );
103        }
104    }
105
106    /// Remove a node from the ring.
107    pub fn remove_node(&mut self, node_id: &str) {
108        for replica in 0..self.replicas_per_node {
109            let key = ShardKey::new(format!("{node_id}:{replica}"));
110            let position = key.hash64();
111            self.ring.remove(&position);
112        }
113    }
114
115    /// Find the responsible node for a shard key.
116    ///
117    /// Returns `None` if the ring is empty.
118    #[must_use]
119    pub fn get_node(&self, key: &ShardKey) -> Option<&str> {
120        if self.ring.is_empty() {
121            return None;
122        }
123        let hash = key.hash64();
124        // Walk clockwise from hash; wrap around if needed.
125        let node = self
126            .ring
127            .range(hash..)
128            .next()
129            .or_else(|| self.ring.iter().next())
130            .map(|(_, v)| v.node_id.as_str());
131        node
132    }
133
134    /// Return the number of virtual nodes (tokens) currently in the ring.
135    #[must_use]
136    pub fn virtual_node_count(&self) -> usize {
137        self.ring.len()
138    }
139
140    /// Return the number of distinct physical nodes in the ring.
141    #[must_use]
142    pub fn physical_node_count(&self) -> usize {
143        let mut nodes: Vec<&str> = self.ring.values().map(|v| v.node_id.as_str()).collect();
144        nodes.sort_unstable();
145        nodes.dedup();
146        nodes.len()
147    }
148
149    /// List all distinct physical node IDs.
150    #[must_use]
151    pub fn nodes(&self) -> Vec<String> {
152        let mut ids: Vec<String> = self.ring.values().map(|v| v.node_id.clone()).collect();
153        ids.sort();
154        ids.dedup();
155        ids
156    }
157}
158
159// ---------------------------------------------------------------------------
160// ShardMetadata
161// ---------------------------------------------------------------------------
162
163/// Lifecycle state of a shard.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum ShardState {
166    /// Shard is available and fully up-to-date.
167    Active,
168    /// Shard is being migrated to another node.
169    Migrating,
170    /// Shard has been archived / is no longer hot.
171    Archived,
172}
173
174/// Metadata about a single shard.
175#[derive(Debug, Clone)]
176pub struct ShardMetadata {
177    /// The shard's key.
178    pub key: ShardKey,
179    /// Physical node currently responsible for this shard.
180    pub owner_node: String,
181    /// Approximate size in bytes.
182    pub size_bytes: u64,
183    /// Current lifecycle state.
184    pub state: ShardState,
185    /// Unix epoch ms when this metadata was last updated.
186    pub updated_at_ms: u64,
187}
188
189impl ShardMetadata {
190    /// Create a new active shard metadata record.
191    #[must_use]
192    pub fn new(key: ShardKey, owner_node: impl Into<String>, size_bytes: u64, now_ms: u64) -> Self {
193        Self {
194            key,
195            owner_node: owner_node.into(),
196            size_bytes,
197            state: ShardState::Active,
198            updated_at_ms: now_ms,
199        }
200    }
201
202    /// Mark the shard as migrating to `target_node`.
203    pub fn begin_migration(&mut self, target_node: impl Into<String>, now_ms: u64) {
204        self.owner_node = target_node.into();
205        self.state = ShardState::Migrating;
206        self.updated_at_ms = now_ms;
207    }
208
209    /// Complete the migration (shard is now active on the new node).
210    pub fn complete_migration(&mut self, now_ms: u64) {
211        self.state = ShardState::Active;
212        self.updated_at_ms = now_ms;
213    }
214
215    /// Returns `true` if the shard is currently active.
216    #[must_use]
217    pub fn is_active(&self) -> bool {
218        self.state == ShardState::Active
219    }
220}
221
222// ---------------------------------------------------------------------------
223// ShardCatalog
224// ---------------------------------------------------------------------------
225
226/// A catalog of all shards in the cluster.
227#[derive(Debug, Default)]
228pub struct ShardCatalog {
229    shards: Vec<ShardMetadata>,
230}
231
232impl ShardCatalog {
233    /// Create an empty catalog.
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Add or update a shard entry.
240    pub fn upsert(&mut self, shard: ShardMetadata) {
241        if let Some(existing) = self.shards.iter_mut().find(|s| s.key == shard.key) {
242            *existing = shard;
243        } else {
244            self.shards.push(shard);
245        }
246    }
247
248    /// Find a shard by key.
249    #[must_use]
250    pub fn get(&self, key: &ShardKey) -> Option<&ShardMetadata> {
251        self.shards.iter().find(|s| &s.key == key)
252    }
253
254    /// Return all shards owned by a given node.
255    #[must_use]
256    pub fn shards_for_node(&self, node_id: &str) -> Vec<&ShardMetadata> {
257        self.shards
258            .iter()
259            .filter(|s| s.owner_node == node_id)
260            .collect()
261    }
262
263    /// Total data size across all shards in bytes.
264    #[must_use]
265    pub fn total_size_bytes(&self) -> u64 {
266        self.shards.iter().map(|s| s.size_bytes).sum()
267    }
268
269    /// Number of shards in the catalog.
270    #[must_use]
271    pub fn len(&self) -> usize {
272        self.shards.len()
273    }
274
275    /// Returns `true` if the catalog is empty.
276    #[must_use]
277    pub fn is_empty(&self) -> bool {
278        self.shards.is_empty()
279    }
280}
281
282// ---------------------------------------------------------------------------
283// Tests
284// ---------------------------------------------------------------------------
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    // ── ShardKey ─────────────────────────────────────────────────────────
291
292    #[test]
293    fn test_shard_key_hash64_deterministic() {
294        let k = ShardKey::new("video-chunk-0042");
295        assert_eq!(k.hash64(), k.hash64());
296    }
297
298    #[test]
299    fn test_shard_key_different_keys_different_hashes() {
300        let k1 = ShardKey::new("a");
301        let k2 = ShardKey::new("b");
302        assert_ne!(k1.hash64(), k2.hash64());
303    }
304
305    // ── ConsistentHashRing ───────────────────────────────────────────────
306
307    #[test]
308    fn test_ring_empty_returns_none() {
309        let ring = ConsistentHashRing::new(3);
310        assert!(ring.get_node(&ShardKey::new("key")).is_none());
311    }
312
313    #[test]
314    fn test_ring_single_node_always_assigned() {
315        let mut ring = ConsistentHashRing::new(10);
316        ring.add_node("node-0");
317        for i in 0..20_u32 {
318            let key = ShardKey::new(format!("key-{i}"));
319            assert_eq!(ring.get_node(&key), Some("node-0"));
320        }
321    }
322
323    #[test]
324    fn test_ring_virtual_node_count() {
325        let mut ring = ConsistentHashRing::new(5);
326        ring.add_node("n0");
327        ring.add_node("n1");
328        assert_eq!(ring.virtual_node_count(), 10);
329    }
330
331    #[test]
332    fn test_ring_physical_node_count() {
333        let mut ring = ConsistentHashRing::new(5);
334        ring.add_node("n0");
335        ring.add_node("n1");
336        ring.add_node("n2");
337        assert_eq!(ring.physical_node_count(), 3);
338    }
339
340    #[test]
341    fn test_ring_remove_node() {
342        let mut ring = ConsistentHashRing::new(5);
343        ring.add_node("n0");
344        ring.add_node("n1");
345        ring.remove_node("n0");
346        assert_eq!(ring.physical_node_count(), 1);
347    }
348
349    #[test]
350    fn test_ring_nodes_list() {
351        let mut ring = ConsistentHashRing::new(3);
352        ring.add_node("alpha");
353        ring.add_node("beta");
354        let nodes = ring.nodes();
355        assert!(nodes.contains(&"alpha".to_string()));
356        assert!(nodes.contains(&"beta".to_string()));
357        assert_eq!(nodes.len(), 2);
358    }
359
360    #[test]
361    fn test_ring_distribution_two_nodes() {
362        let mut ring = ConsistentHashRing::new(50);
363        ring.add_node("node-A");
364        ring.add_node("node-B");
365        let mut a_count = 0_u32;
366        let mut b_count = 0_u32;
367        for i in 0..100_u32 {
368            let k = ShardKey::new(format!("shard-{i}"));
369            match ring.get_node(&k) {
370                Some("node-A") => a_count += 1,
371                Some("node-B") => b_count += 1,
372                _ => {}
373            }
374        }
375        // With 50 replicas each, distribution should be roughly equal
376        assert!(a_count > 20 && b_count > 20, "a={a_count}, b={b_count}");
377    }
378
379    // ── ShardMetadata ────────────────────────────────────────────────────
380
381    #[test]
382    fn test_shard_metadata_initial_state() {
383        let meta = ShardMetadata::new(ShardKey::new("k"), "node-0", 1024, 1000);
384        assert!(meta.is_active());
385        assert_eq!(meta.state, ShardState::Active);
386    }
387
388    #[test]
389    fn test_shard_metadata_begin_migration() {
390        let mut meta = ShardMetadata::new(ShardKey::new("k"), "node-0", 1024, 1000);
391        meta.begin_migration("node-1", 2000);
392        assert_eq!(meta.state, ShardState::Migrating);
393        assert_eq!(meta.owner_node, "node-1");
394    }
395
396    #[test]
397    fn test_shard_metadata_complete_migration() {
398        let mut meta = ShardMetadata::new(ShardKey::new("k"), "node-0", 1024, 1000);
399        meta.begin_migration("node-1", 2000);
400        meta.complete_migration(3000);
401        assert!(meta.is_active());
402    }
403
404    // ── ShardCatalog ─────────────────────────────────────────────────────
405
406    #[test]
407    fn test_catalog_empty() {
408        let catalog = ShardCatalog::new();
409        assert!(catalog.is_empty());
410        assert_eq!(catalog.len(), 0);
411    }
412
413    #[test]
414    fn test_catalog_upsert_and_get() {
415        let mut catalog = ShardCatalog::new();
416        let key = ShardKey::new("shard-0");
417        catalog.upsert(ShardMetadata::new(key.clone(), "n0", 512, 100));
418        let meta = catalog.get(&key).expect("get should return a value");
419        assert_eq!(meta.size_bytes, 512);
420    }
421
422    #[test]
423    fn test_catalog_upsert_updates_existing() {
424        let mut catalog = ShardCatalog::new();
425        let key = ShardKey::new("shard-0");
426        catalog.upsert(ShardMetadata::new(key.clone(), "n0", 512, 100));
427        catalog.upsert(ShardMetadata::new(key.clone(), "n1", 1024, 200));
428        assert_eq!(catalog.len(), 1);
429        assert_eq!(
430            catalog
431                .get(&key)
432                .expect("get should return a value")
433                .size_bytes,
434            1024
435        );
436    }
437
438    #[test]
439    fn test_catalog_shards_for_node() {
440        let mut catalog = ShardCatalog::new();
441        catalog.upsert(ShardMetadata::new(ShardKey::new("s0"), "n0", 100, 1));
442        catalog.upsert(ShardMetadata::new(ShardKey::new("s1"), "n0", 200, 1));
443        catalog.upsert(ShardMetadata::new(ShardKey::new("s2"), "n1", 300, 1));
444        let shards = catalog.shards_for_node("n0");
445        assert_eq!(shards.len(), 2);
446    }
447
448    #[test]
449    fn test_catalog_total_size_bytes() {
450        let mut catalog = ShardCatalog::new();
451        catalog.upsert(ShardMetadata::new(ShardKey::new("s0"), "n0", 100, 1));
452        catalog.upsert(ShardMetadata::new(ShardKey::new("s1"), "n0", 200, 1));
453        assert_eq!(catalog.total_size_bytes(), 300);
454    }
455}