Skip to main content

ipfrs_semantic/
shard_balancer.rs

1//! HNSW-on-DHT Shard Balancing
2//!
3//! This module implements consistent-hashing based shard balancing for distributed
4//! HNSW indices stored across DHT peers.  The design goals are:
5//!
6//! * **Predictable placement** – Knuth multiplicative hashing maps each `vector_id`
7//!   to a shard deterministically.
8//! * **Load awareness** – Atomic per-shard counters track live vector counts so that
9//!   hot-spot detection and rebalancing decisions can be made without locks.
10//! * **Peer coordination** – `DhtShardRouter` maintains the mapping from peer IDs to
11//!   the shards they host, enabling routing of search and insert requests.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::atomic::{AtomicUsize, Ordering};
15use std::sync::{Arc, RwLock};
16
17// ---------------------------------------------------------------------------
18// ShardConfig
19// ---------------------------------------------------------------------------
20
21/// Configuration parameters for the shard balancer.
22#[derive(Debug, Clone)]
23pub struct ShardConfig {
24    /// Total number of logical shards in the cluster.
25    pub num_shards: usize,
26    /// How many peers each shard should be replicated to.
27    pub replication_factor: usize,
28    /// Soft upper limit on vectors per shard before rebalancing is flagged.
29    pub max_vectors_per_shard: usize,
30}
31
32impl Default for ShardConfig {
33    fn default() -> Self {
34        Self {
35            num_shards: 8,
36            replication_factor: 3,
37            max_vectors_per_shard: 200_000,
38        }
39    }
40}
41
42// ---------------------------------------------------------------------------
43// ShardAssignment
44// ---------------------------------------------------------------------------
45
46/// Describes which shard a vector belongs to and which peers host that shard.
47#[derive(Debug, Clone)]
48pub struct ShardAssignment {
49    /// Logical shard index (0..num_shards).
50    pub shard_id: usize,
51    /// The primary peer responsible for writes.
52    pub primary_peer: String,
53    /// Additional peers that hold replicas.
54    pub replica_peers: Vec<String>,
55}
56
57// ---------------------------------------------------------------------------
58// ShardBalancer
59// ---------------------------------------------------------------------------
60
61/// Tracks per-shard load and makes placement / rebalancing decisions.
62///
63/// All load counters use `AtomicUsize` so concurrent updates from multiple
64/// async tasks require no mutex.
65pub struct ShardBalancer {
66    config: ShardConfig,
67    shard_loads: Arc<Vec<AtomicUsize>>,
68}
69
70impl ShardBalancer {
71    /// Create a new `ShardBalancer` with the given configuration.
72    ///
73    /// # Panics
74    ///
75    /// Panics if `config.num_shards == 0`.
76    pub fn new(config: ShardConfig) -> Self {
77        assert!(config.num_shards > 0, "num_shards must be > 0");
78        let mut loads = Vec::with_capacity(config.num_shards);
79        for _ in 0..config.num_shards {
80            loads.push(AtomicUsize::new(0));
81        }
82        Self {
83            config,
84            shard_loads: Arc::new(loads),
85        }
86    }
87
88    /// Return the shard that should store the vector with the given id.
89    ///
90    /// Uses Knuth multiplicative hashing for a uniform distribution.
91    pub fn assign_vector(&self, vector_id: u64) -> usize {
92        // Knuth multiplicative hash (32-bit Fibonacci constant widened to 64-bit)
93        let hash = vector_id.wrapping_mul(2_654_435_761_u64);
94        (hash as usize) % self.config.num_shards
95    }
96
97    /// Return the shard index with the lowest current load.
98    ///
99    /// In the case of a tie the shard with the smaller index wins, giving
100    /// stable, deterministic behaviour in tests.
101    pub fn least_loaded_shard(&self) -> usize {
102        let mut min_load = usize::MAX;
103        let mut min_shard = 0usize;
104        for (idx, counter) in self.shard_loads.iter().enumerate() {
105            let load = counter.load(Ordering::Relaxed);
106            if load < min_load {
107                min_load = load;
108                min_shard = idx;
109            }
110        }
111        min_shard
112    }
113
114    /// Atomically increment the vector count for the given shard.
115    pub fn increment_shard_load(&self, shard_id: usize) {
116        if let Some(counter) = self.shard_loads.get(shard_id) {
117            counter.fetch_add(1, Ordering::Relaxed);
118        }
119    }
120
121    /// Atomically decrement the vector count for the given shard.
122    ///
123    /// Saturates at zero to avoid underflow.
124    pub fn decrement_shard_load(&self, shard_id: usize) {
125        if let Some(counter) = self.shard_loads.get(shard_id) {
126            // Saturating decrement via compare-exchange loop
127            let mut current = counter.load(Ordering::Relaxed);
128            loop {
129                if current == 0 {
130                    break;
131                }
132                match counter.compare_exchange_weak(
133                    current,
134                    current - 1,
135                    Ordering::Relaxed,
136                    Ordering::Relaxed,
137                ) {
138                    Ok(_) => break,
139                    Err(actual) => current = actual,
140                }
141            }
142        }
143    }
144
145    /// Return a point-in-time snapshot of all shard load counts.
146    pub fn shard_loads_snapshot(&self) -> Vec<usize> {
147        self.shard_loads
148            .iter()
149            .map(|c| c.load(Ordering::Relaxed))
150            .collect()
151    }
152
153    /// Return `true` when the ratio of the most-loaded shard to the
154    /// least-loaded shard exceeds 2.0 (ignoring empty shards with zero load).
155    pub fn rebalance_needed(&self) -> bool {
156        let snapshot = self.shard_loads_snapshot();
157        // Only consider shards that have at least one vector.
158        let non_zero: Vec<usize> = snapshot.into_iter().filter(|&v| v > 0).collect();
159        if non_zero.len() < 2 {
160            return false;
161        }
162        let max = non_zero.iter().copied().max().unwrap_or(0);
163        let min = non_zero.iter().copied().min().unwrap_or(0);
164        if min == 0 {
165            return false;
166        }
167        (max as f64 / min as f64) > 2.0
168    }
169
170    /// Return the indices of shards whose load exceeds the average load.
171    pub fn hotspot_shards(&self) -> Vec<usize> {
172        let snapshot = self.shard_loads_snapshot();
173        if snapshot.is_empty() {
174            return vec![];
175        }
176        let total: usize = snapshot.iter().sum();
177        let n = snapshot.len();
178        // Use integer arithmetic: a shard is a hot-spot when its load * n > total.
179        snapshot
180            .iter()
181            .enumerate()
182            .filter(|&(_, &load)| load * n > total)
183            .map(|(idx, _)| idx)
184            .collect()
185    }
186
187    /// Expose configuration for inspection.
188    pub fn config(&self) -> &ShardConfig {
189        &self.config
190    }
191}
192
193// ---------------------------------------------------------------------------
194// DhtShardRouter
195// ---------------------------------------------------------------------------
196
197/// Routes insert / search operations to the appropriate peers based on
198/// which shards they host.
199pub struct DhtShardRouter {
200    /// Shared shard balancer.
201    pub balancer: Arc<ShardBalancer>,
202    /// Maps peer_id → set of shard indices the peer hosts.
203    peer_shard_map: Arc<RwLock<HashMap<String, HashSet<usize>>>>,
204}
205
206impl DhtShardRouter {
207    /// Create a new `DhtShardRouter` backed by the given `ShardBalancer`.
208    pub fn new(balancer: Arc<ShardBalancer>) -> Self {
209        Self {
210            balancer,
211            peer_shard_map: Arc::new(RwLock::new(HashMap::new())),
212        }
213    }
214
215    /// Register (or replace) the set of shards hosted by a peer.
216    pub fn register_peer_shards(&self, peer_id: &str, shards: Vec<usize>) {
217        let mut map = self
218            .peer_shard_map
219            .write()
220            .expect("peer_shard_map write lock poisoned");
221        map.insert(peer_id.to_string(), shards.into_iter().collect());
222    }
223
224    /// Remove a peer and all of its shard registrations.
225    pub fn unregister_peer(&self, peer_id: &str) {
226        let mut map = self
227            .peer_shard_map
228            .write()
229            .expect("peer_shard_map write lock poisoned");
230        map.remove(peer_id);
231    }
232
233    /// Return the list of peers that host the given shard.
234    pub fn peers_for_shard(&self, shard_id: usize) -> Vec<String> {
235        let map = self
236            .peer_shard_map
237            .read()
238            .expect("peer_shard_map read lock poisoned");
239        map.iter()
240            .filter(|(_, shards)| shards.contains(&shard_id))
241            .map(|(peer_id, _)| peer_id.clone())
242            .collect()
243    }
244
245    /// Return a snapshot of all peer → shard assignments.
246    pub fn all_peer_assignments(&self) -> HashMap<String, Vec<usize>> {
247        let map = self
248            .peer_shard_map
249            .read()
250            .expect("peer_shard_map read lock poisoned");
251        map.iter()
252            .map(|(peer_id, shards)| {
253                let mut shard_vec: Vec<usize> = shards.iter().copied().collect();
254                shard_vec.sort_unstable();
255                (peer_id.clone(), shard_vec)
256            })
257            .collect()
258    }
259
260    /// Return a map of shard_id → number of peers that host that shard.
261    pub fn shard_coverage(&self) -> HashMap<usize, usize> {
262        let map = self
263            .peer_shard_map
264            .read()
265            .expect("peer_shard_map read lock poisoned");
266        let num_shards = self.balancer.config().num_shards;
267        let mut coverage: HashMap<usize, usize> = (0..num_shards).map(|s| (s, 0)).collect();
268        for shards in map.values() {
269            for &shard_id in shards {
270                *coverage.entry(shard_id).or_insert(0) += 1;
271            }
272        }
273        coverage
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Tests
279// ---------------------------------------------------------------------------
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn make_balancer(num_shards: usize) -> Arc<ShardBalancer> {
286        Arc::new(ShardBalancer::new(ShardConfig {
287            num_shards,
288            replication_factor: 3,
289            max_vectors_per_shard: 200_000,
290        }))
291    }
292
293    fn make_router(num_shards: usize) -> DhtShardRouter {
294        DhtShardRouter::new(make_balancer(num_shards))
295    }
296
297    // ------------------------------------------------------------------
298    // 1. Consistency
299    // ------------------------------------------------------------------
300    #[test]
301    fn test_shard_assignment_consistency() {
302        let balancer = make_balancer(8);
303        for vector_id in [0u64, 1, 42, 999, u64::MAX / 2, u64::MAX] {
304            let first = balancer.assign_vector(vector_id);
305            // Call many times – must be identical every time.
306            for _ in 0..100 {
307                assert_eq!(
308                    balancer.assign_vector(vector_id),
309                    first,
310                    "assign_vector({vector_id}) is not consistent"
311                );
312            }
313        }
314    }
315
316    // ------------------------------------------------------------------
317    // 2. Load tracking
318    // ------------------------------------------------------------------
319    #[test]
320    fn test_shard_load_tracking() {
321        let balancer = make_balancer(4);
322
323        // All zero at start.
324        assert_eq!(balancer.shard_loads_snapshot(), vec![0, 0, 0, 0]);
325
326        balancer.increment_shard_load(0);
327        balancer.increment_shard_load(0);
328        balancer.increment_shard_load(1);
329
330        let snap = balancer.shard_loads_snapshot();
331        assert_eq!(snap[0], 2);
332        assert_eq!(snap[1], 1);
333        assert_eq!(snap[2], 0);
334        assert_eq!(snap[3], 0);
335
336        balancer.decrement_shard_load(0);
337        assert_eq!(balancer.shard_loads_snapshot()[0], 1);
338
339        // Saturates at zero.
340        balancer.decrement_shard_load(2);
341        assert_eq!(balancer.shard_loads_snapshot()[2], 0);
342    }
343
344    // ------------------------------------------------------------------
345    // 3. Rebalance detection
346    // ------------------------------------------------------------------
347    #[test]
348    fn test_rebalance_detection() {
349        let balancer = make_balancer(4);
350
351        // Balanced – no rebalance needed.
352        for shard in 0..4 {
353            for _ in 0..10 {
354                balancer.increment_shard_load(shard);
355            }
356        }
357        assert!(
358            !balancer.rebalance_needed(),
359            "balanced shards should not need rebalance"
360        );
361
362        // Overload shard 0 to cause a ratio > 2.
363        for _ in 0..30 {
364            balancer.increment_shard_load(0);
365        }
366        assert!(
367            balancer.rebalance_needed(),
368            "highly skewed shards should need rebalance"
369        );
370    }
371
372    // ------------------------------------------------------------------
373    // 4. Hotspot detection
374    // ------------------------------------------------------------------
375    #[test]
376    fn test_hotspot_shards() {
377        let balancer = make_balancer(4);
378
379        // shard 0 = 100, shards 1-3 = 10 each → avg = 32.5 → shard 0 is hotspot.
380        for _ in 0..100 {
381            balancer.increment_shard_load(0);
382        }
383        for shard in 1..4 {
384            for _ in 0..10 {
385                balancer.increment_shard_load(shard);
386            }
387        }
388
389        let hotspots = balancer.hotspot_shards();
390        assert!(hotspots.contains(&0), "shard 0 should be a hotspot");
391        assert!(!hotspots.contains(&1), "shard 1 should not be a hotspot");
392    }
393
394    // ------------------------------------------------------------------
395    // 5. Router – peer registration
396    // ------------------------------------------------------------------
397    #[test]
398    fn test_dht_shard_router_registration() {
399        let router = make_router(8);
400
401        router.register_peer_shards("peer-A", vec![0, 1, 2]);
402        router.register_peer_shards("peer-B", vec![3, 4, 5]);
403
404        let assignments = router.all_peer_assignments();
405        assert_eq!(assignments["peer-A"], vec![0, 1, 2]);
406        assert_eq!(assignments["peer-B"], vec![3, 4, 5]);
407
408        // Re-register overwrites.
409        router.register_peer_shards("peer-A", vec![0, 7]);
410        let assignments2 = router.all_peer_assignments();
411        assert_eq!(assignments2["peer-A"], vec![0, 7]);
412    }
413
414    // ------------------------------------------------------------------
415    // 6. Peers for shard
416    // ------------------------------------------------------------------
417    #[test]
418    fn test_peers_for_shard() {
419        let router = make_router(8);
420
421        router.register_peer_shards("peer-X", vec![0, 1, 2]);
422        router.register_peer_shards("peer-Y", vec![1, 2, 3]);
423        router.register_peer_shards("peer-Z", vec![4, 5, 6]);
424
425        let mut peers_for_1 = router.peers_for_shard(1);
426        peers_for_1.sort();
427        assert_eq!(peers_for_1, vec!["peer-X", "peer-Y"]);
428
429        let peers_for_4 = router.peers_for_shard(4);
430        assert_eq!(peers_for_4, vec!["peer-Z"]);
431
432        let peers_for_7 = router.peers_for_shard(7);
433        assert!(peers_for_7.is_empty(), "no peer hosts shard 7");
434    }
435
436    // ------------------------------------------------------------------
437    // 7. Shard coverage
438    // ------------------------------------------------------------------
439    #[test]
440    fn test_shard_coverage() {
441        let router = make_router(4);
442
443        router.register_peer_shards("peer-1", vec![0, 1, 2, 3]);
444        router.register_peer_shards("peer-2", vec![0, 1, 2, 3]);
445        router.register_peer_shards("peer-3", vec![0, 2]);
446
447        let coverage = router.shard_coverage();
448        assert_eq!(coverage[&0], 3);
449        assert_eq!(coverage[&1], 2);
450        assert_eq!(coverage[&2], 3);
451        assert_eq!(coverage[&3], 2);
452    }
453
454    // ------------------------------------------------------------------
455    // 8. Least loaded shard
456    // ------------------------------------------------------------------
457    #[test]
458    fn test_least_loaded_shard() {
459        let balancer = make_balancer(4);
460
461        // All zeros → shard 0 wins (lowest index tie-break).
462        assert_eq!(balancer.least_loaded_shard(), 0);
463
464        balancer.increment_shard_load(0);
465        balancer.increment_shard_load(0);
466        balancer.increment_shard_load(1);
467
468        // shard 2 and 3 are empty → shard 2 wins tie-break.
469        assert_eq!(balancer.least_loaded_shard(), 2);
470
471        balancer.increment_shard_load(2);
472        balancer.increment_shard_load(2);
473        balancer.increment_shard_load(2);
474
475        // shard 3 is now the only zero.
476        assert_eq!(balancer.least_loaded_shard(), 3);
477    }
478
479    // ------------------------------------------------------------------
480    // 9. Consistent-hash distribution
481    // ------------------------------------------------------------------
482    #[test]
483    fn test_consistent_hash_distribution() {
484        let balancer = make_balancer(8);
485        let mut counts = [0usize; 8];
486
487        for id in 0u64..1000 {
488            let shard = balancer.assign_vector(id);
489            counts[shard] += 1;
490        }
491
492        let max = *counts.iter().max().expect("non-empty");
493        let min = *counts.iter().min().expect("non-empty");
494        // Require no shard is completely empty.
495        assert!(
496            min > 0,
497            "every shard should receive at least one vector from 1000 IDs"
498        );
499        // Require the max/min ratio is less than 3.0 for good hashing.
500        assert!(
501            (max as f64 / min as f64) < 3.0,
502            "hash distribution too skewed: max={max}, min={min}"
503        );
504    }
505
506    // ------------------------------------------------------------------
507    // 10. Unregister removes peer
508    // ------------------------------------------------------------------
509    #[test]
510    fn test_unregister_removes_peer() {
511        let router = make_router(8);
512
513        router.register_peer_shards("peer-alpha", vec![0, 1, 2, 3]);
514        router.register_peer_shards("peer-beta", vec![0, 1]);
515
516        // Verify peer-alpha is present.
517        assert!(router
518            .peers_for_shard(0)
519            .contains(&"peer-alpha".to_string()));
520
521        // Unregister.
522        router.unregister_peer("peer-alpha");
523
524        // Should no longer appear for any shard.
525        for shard in 0..8 {
526            let peers = router.peers_for_shard(shard);
527            assert!(
528                !peers.contains(&"peer-alpha".to_string()),
529                "peer-alpha still appears for shard {shard} after unregistration"
530            );
531        }
532
533        // peer-beta should be unaffected.
534        assert!(router.peers_for_shard(0).contains(&"peer-beta".to_string()));
535    }
536}