Skip to main content

oximedia_distributed/
partition.rs

1//! Data partitioning for distributed encoding using consistent hashing.
2//!
3//! Provides consistent hashing for stable partition assignment,
4//! partition rebalancing logic, and virtual node management.
5
6use std::collections::BTreeMap;
7use std::collections::HashMap;
8
9/// A node in the distributed cluster
10#[allow(dead_code)]
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ClusterNode {
13    /// Unique node identifier
14    pub node_id: String,
15    /// Node address (host:port)
16    pub address: String,
17    /// Weight for load distribution (1–100)
18    pub weight: u32,
19}
20
21impl ClusterNode {
22    /// Create a new cluster node
23    #[allow(dead_code)]
24    pub fn new(node_id: impl Into<String>, address: impl Into<String>, weight: u32) -> Self {
25        Self {
26            node_id: node_id.into(),
27            address: address.into(),
28            weight: weight.clamp(1, 100),
29        }
30    }
31}
32
33/// A partition assigned to a node
34#[allow(dead_code)]
35#[derive(Debug, Clone)]
36pub struct Partition {
37    /// Partition index
38    pub index: u32,
39    /// Owning node id
40    pub owner: String,
41    /// Replica node ids
42    pub replicas: Vec<String>,
43}
44
45/// Consistent hash ring for stable partition assignment.
46///
47/// Virtual nodes (vnodes) per physical node are proportional to `weight`.
48#[allow(dead_code)]
49pub struct ConsistentHashRing {
50    /// ring: hash -> `node_id`
51    ring: BTreeMap<u64, String>,
52    /// physical nodes
53    nodes: HashMap<String, ClusterNode>,
54    /// vnodes per unit weight
55    vnodes_per_weight: u32,
56}
57
58impl ConsistentHashRing {
59    /// Create a new ring with the given vnode density.
60    #[allow(dead_code)]
61    #[must_use]
62    pub fn new(vnodes_per_weight: u32) -> Self {
63        Self {
64            ring: BTreeMap::new(),
65            nodes: HashMap::new(),
66            vnodes_per_weight,
67        }
68    }
69
70    /// Add a node to the ring.
71    #[allow(dead_code)]
72    pub fn add_node(&mut self, node: ClusterNode) {
73        let vnodes = node.weight * self.vnodes_per_weight;
74        for i in 0..vnodes {
75            let key = format!("{}-{}", node.node_id, i);
76            let hash = fnv1a_hash(key.as_bytes());
77            self.ring.insert(hash, node.node_id.clone());
78        }
79        self.nodes.insert(node.node_id.clone(), node);
80    }
81
82    /// Remove a node from the ring.
83    #[allow(dead_code)]
84    pub fn remove_node(&mut self, node_id: &str) {
85        if let Some(node) = self.nodes.remove(node_id) {
86            let vnodes = node.weight * self.vnodes_per_weight;
87            for i in 0..vnodes {
88                let key = format!("{node_id}-{i}");
89                let hash = fnv1a_hash(key.as_bytes());
90                self.ring.remove(&hash);
91            }
92        }
93    }
94
95    /// Look up the responsible node for a given key.
96    #[allow(dead_code)]
97    #[must_use]
98    pub fn get_node(&self, key: &[u8]) -> Option<&ClusterNode> {
99        if self.ring.is_empty() {
100            return None;
101        }
102        let hash = fnv1a_hash(key);
103        // clockwise lookup
104        let node_id = self
105            .ring
106            .range(hash..)
107            .next()
108            .or_else(|| self.ring.iter().next())
109            .map(|(_, v)| v.as_str())?;
110        self.nodes.get(node_id)
111    }
112
113    /// Return the number of physical nodes.
114    #[allow(dead_code)]
115    #[must_use]
116    pub fn node_count(&self) -> usize {
117        self.nodes.len()
118    }
119
120    /// Return the total number of virtual nodes in the ring.
121    #[allow(dead_code)]
122    #[must_use]
123    pub fn vnode_count(&self) -> usize {
124        self.ring.len()
125    }
126}
127
128/// FNV-1a 64-bit hash (no external deps).
129#[allow(dead_code)]
130fn fnv1a_hash(data: &[u8]) -> u64 {
131    const OFFSET_BASIS: u64 = 14695981039346656037;
132    const PRIME: u64 = 1099511628211;
133    let mut hash = OFFSET_BASIS;
134    for &byte in data {
135        hash ^= u64::from(byte);
136        hash = hash.wrapping_mul(PRIME);
137    }
138    hash
139}
140
141/// Partition assignment table
142#[allow(dead_code)]
143pub struct PartitionTable {
144    /// Total number of partitions
145    pub partition_count: u32,
146    /// Assignments: `partition_index` -> Partition
147    assignments: Vec<Partition>,
148}
149
150impl PartitionTable {
151    /// Create a new partition table and assign partitions to nodes.
152    #[allow(dead_code)]
153    #[must_use]
154    pub fn new(partition_count: u32, nodes: &[ClusterNode]) -> Self {
155        let mut ring = ConsistentHashRing::new(10);
156        for node in nodes {
157            ring.add_node(node.clone());
158        }
159
160        let mut assignments = Vec::with_capacity(partition_count as usize);
161        for i in 0..partition_count {
162            let key = i.to_le_bytes();
163            let owner = ring
164                .get_node(&key)
165                .map(|n| n.node_id.clone())
166                .unwrap_or_default();
167            assignments.push(Partition {
168                index: i,
169                owner,
170                replicas: Vec::new(),
171            });
172        }
173
174        Self {
175            partition_count,
176            assignments,
177        }
178    }
179
180    /// Look up the owner of a partition.
181    #[allow(dead_code)]
182    #[must_use]
183    pub fn owner_of(&self, partition_index: u32) -> Option<&str> {
184        self.assignments
185            .get(partition_index as usize)
186            .map(|p| p.owner.as_str())
187    }
188
189    /// Return all partitions owned by a given node.
190    #[allow(dead_code)]
191    #[must_use]
192    pub fn partitions_for_node<'a>(&'a self, node_id: &str) -> Vec<&'a Partition> {
193        self.assignments
194            .iter()
195            .filter(|p| p.owner == node_id)
196            .collect()
197    }
198
199    /// Rebalance by rebuilding the ring after node changes.
200    #[allow(dead_code)]
201    pub fn rebalance(&mut self, nodes: &[ClusterNode]) {
202        let new = PartitionTable::new(self.partition_count, nodes);
203        self.assignments = new.assignments;
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn make_nodes(n: usize) -> Vec<ClusterNode> {
212        (0..n)
213            .map(|i| ClusterNode::new(format!("node-{i}"), format!("127.0.0.1:{}", 7000 + i), 10))
214            .collect()
215    }
216
217    #[test]
218    fn test_cluster_node_weight_clamped() {
219        let node = ClusterNode::new("n0", "127.0.0.1:7000", 200);
220        assert_eq!(node.weight, 100);
221        let node2 = ClusterNode::new("n1", "127.0.0.1:7001", 0);
222        assert_eq!(node2.weight, 1);
223    }
224
225    #[test]
226    fn test_add_node_increases_vnode_count() {
227        let mut ring = ConsistentHashRing::new(10);
228        assert_eq!(ring.vnode_count(), 0);
229        ring.add_node(ClusterNode::new("n0", "127.0.0.1:7000", 1));
230        assert_eq!(ring.vnode_count(), 10);
231    }
232
233    #[test]
234    fn test_remove_node_decreases_vnode_count() {
235        let mut ring = ConsistentHashRing::new(10);
236        ring.add_node(ClusterNode::new("n0", "127.0.0.1:7000", 1));
237        ring.add_node(ClusterNode::new("n1", "127.0.0.1:7001", 1));
238        ring.remove_node("n0");
239        assert_eq!(ring.node_count(), 1);
240        assert_eq!(ring.vnode_count(), 10);
241    }
242
243    #[test]
244    fn test_get_node_empty_ring_returns_none() {
245        let ring = ConsistentHashRing::new(10);
246        assert!(ring.get_node(b"some_key").is_none());
247    }
248
249    #[test]
250    fn test_get_node_returns_some() {
251        let mut ring = ConsistentHashRing::new(10);
252        ring.add_node(ClusterNode::new("n0", "127.0.0.1:7000", 1));
253        assert!(ring.get_node(b"video/segment/0001").is_some());
254    }
255
256    #[test]
257    fn test_consistent_hashing_same_key_same_node() {
258        let mut ring = ConsistentHashRing::new(20);
259        for node in make_nodes(4) {
260            ring.add_node(node);
261        }
262        let node1 = ring.get_node(b"job-abc").map(|n| n.node_id.clone());
263        let node2 = ring.get_node(b"job-abc").map(|n| n.node_id.clone());
264        assert_eq!(node1, node2);
265    }
266
267    #[test]
268    fn test_fnv1a_hash_deterministic() {
269        let h1 = fnv1a_hash(b"hello");
270        let h2 = fnv1a_hash(b"hello");
271        assert_eq!(h1, h2);
272    }
273
274    #[test]
275    fn test_fnv1a_hash_different_inputs() {
276        assert_ne!(fnv1a_hash(b"foo"), fnv1a_hash(b"bar"));
277    }
278
279    #[test]
280    fn test_partition_table_all_partitions_assigned() {
281        let nodes = make_nodes(3);
282        let table = PartitionTable::new(64, &nodes);
283        for i in 0..64 {
284            assert!(!table.owner_of(i).unwrap_or("").is_empty());
285        }
286    }
287
288    #[test]
289    fn test_partition_table_owner_of_out_of_range() {
290        let nodes = make_nodes(2);
291        let table = PartitionTable::new(16, &nodes);
292        assert!(table.owner_of(100).is_none());
293    }
294
295    #[test]
296    fn test_partitions_for_node_coverage() {
297        let nodes = make_nodes(4);
298        let table = PartitionTable::new(64, &nodes);
299        let total: usize = nodes
300            .iter()
301            .map(|n| table.partitions_for_node(&n.node_id).len())
302            .sum();
303        assert_eq!(total, 64);
304    }
305
306    #[test]
307    fn test_rebalance_after_node_removal() {
308        let mut nodes = make_nodes(4);
309        let mut table = PartitionTable::new(32, &nodes);
310        nodes.remove(3);
311        table.rebalance(&nodes);
312        for i in 0..32 {
313            let owner = table.owner_of(i).unwrap_or("");
314            assert!(owner.starts_with("node-"));
315            assert_ne!(owner, "node-3");
316        }
317    }
318
319    #[test]
320    fn test_partition_table_empty_nodes() {
321        let table = PartitionTable::new(8, &[]);
322        // With no nodes all owners are empty string
323        for i in 0..8 {
324            assert_eq!(table.owner_of(i), Some(""));
325        }
326    }
327}