Skip to main content

ipfrs_storage/
blockstore_sharding.rs

1//! Sharded block storage system for distributed content-addressed blocks.
2//!
3//! Distributes blocks across logical shards using CID-prefix routing (FNV-1a),
4//! tracks per-shard statistics, and supports rebalancing and LRU eviction.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// FNV-1a (64-bit) — used for shard routing
10// ---------------------------------------------------------------------------
11
12#[inline]
13fn fnv1a_64(data: &[u8]) -> u64 {
14    let mut hash: u64 = 0xcbf29ce484222325;
15    for &byte in data {
16        hash ^= byte as u64;
17        hash = hash.wrapping_mul(0x100000001b3);
18    }
19    hash
20}
21
22// ---------------------------------------------------------------------------
23// ShardKey
24// ---------------------------------------------------------------------------
25
26/// Identifies which shard a block belongs to (0..num_shards).
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub struct ShardKey(pub u8);
29
30impl ShardKey {
31    /// Returns the inner shard index.
32    #[inline]
33    pub fn index(self) -> u8 {
34        self.0
35    }
36
37    /// Returns the inner shard index as `usize`.
38    #[inline]
39    pub fn as_usize(self) -> usize {
40        self.0 as usize
41    }
42}
43
44// ---------------------------------------------------------------------------
45// BlockRecord
46// ---------------------------------------------------------------------------
47
48/// A single content-addressed block stored in a shard.
49#[derive(Clone, Debug)]
50pub struct BlockRecord {
51    /// The content identifier (CID) of this block.
52    pub cid: String,
53    /// Raw block data.
54    pub data: Vec<u8>,
55    /// The shard this block belongs to.
56    pub shard: ShardKey,
57    /// Unix timestamp (seconds) when the block was first inserted.
58    pub inserted_at: u64,
59    /// Number of times this block has been read via `get`.
60    pub access_count: u64,
61}
62
63// ---------------------------------------------------------------------------
64// ShardingConfig
65// ---------------------------------------------------------------------------
66
67/// Configuration for `BlockStoreSharding`.
68#[derive(Clone, Debug)]
69pub struct ShardingConfig {
70    /// Number of logical shards (must be ≥ 1).
71    pub num_shards: u8,
72    /// Maximum number of blocks allowed per shard.
73    pub max_blocks_per_shard: usize,
74    /// Rebalance if any shard exceeds `avg * (1 + rebalance_threshold)`.
75    pub rebalance_threshold: f64,
76}
77
78impl Default for ShardingConfig {
79    fn default() -> Self {
80        Self {
81            num_shards: 16,
82            max_blocks_per_shard: 65536,
83            rebalance_threshold: 0.25,
84        }
85    }
86}
87
88// ---------------------------------------------------------------------------
89// ShardMetrics
90// ---------------------------------------------------------------------------
91
92/// Per-shard statistics.
93#[derive(Clone, Debug, Default)]
94pub struct ShardMetrics {
95    /// Which shard these metrics describe.
96    pub shard_id: u8,
97    /// Current block count in this shard.
98    pub block_count: usize,
99    /// Total bytes stored in this shard.
100    pub total_bytes: u64,
101    /// Number of successful `get` calls that found a block.
102    pub hit_count: u64,
103    /// Number of `get` calls that found no block.
104    pub miss_count: u64,
105}
106
107// ---------------------------------------------------------------------------
108// BlockStoreGlobalStats
109// ---------------------------------------------------------------------------
110
111/// Aggregate statistics for the whole `BlockStoreSharding`.
112#[derive(Clone, Debug)]
113pub struct BlockStoreGlobalStats {
114    /// Total block count across all shards.
115    pub total_blocks: usize,
116    /// Total bytes stored across all shards.
117    pub total_bytes: u64,
118    /// Cumulative insertions (including replacements).
119    pub total_insertions: u64,
120    /// Cumulative `get` calls.
121    pub total_lookups: u64,
122    /// Number of configured shards.
123    pub num_shards: u8,
124    /// Whether any shard currently exceeds the rebalance threshold.
125    pub needs_rebalance: bool,
126}
127
128// ---------------------------------------------------------------------------
129// ShardingError
130// ---------------------------------------------------------------------------
131
132/// Errors produced by `BlockStoreSharding` operations.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum ShardingError {
135    /// The target shard has reached `max_blocks_per_shard`.
136    ShardFull {
137        /// The shard that is full.
138        shard_id: u8,
139    },
140    /// A CID was expected to exist but could not be found.
141    CidNotFound(String),
142    /// The given shard id is out of range.
143    InvalidShardId(u8),
144}
145
146impl std::fmt::Display for ShardingError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::ShardFull { shard_id } => {
150                write!(f, "shard {} is full", shard_id)
151            }
152            Self::CidNotFound(cid) => write!(f, "CID not found: {}", cid),
153            Self::InvalidShardId(id) => write!(f, "invalid shard id: {}", id),
154        }
155    }
156}
157
158impl std::error::Error for ShardingError {}
159
160// ---------------------------------------------------------------------------
161// BlockStoreSharding
162// ---------------------------------------------------------------------------
163
164/// A sharded, content-addressed block store.
165///
166/// Distributes blocks across `num_shards` logical shards using FNV-1a hashing
167/// of the CID. Provides per-shard metrics, rebalancing, and LRU eviction.
168pub struct BlockStoreSharding {
169    /// Runtime configuration.
170    pub config: ShardingConfig,
171    /// Per-shard block maps: `shard_id → (cid → BlockRecord)`.
172    shards: Vec<HashMap<String, BlockRecord>>,
173    /// Per-shard statistics.
174    shard_metrics: Vec<ShardMetrics>,
175    /// Total insertions since creation (new + replaced).
176    pub total_insertions: u64,
177    /// Total `get` calls since creation.
178    pub total_lookups: u64,
179    /// Total bytes currently stored.
180    pub total_bytes: u64,
181}
182
183impl BlockStoreSharding {
184    // -----------------------------------------------------------------------
185    // Construction
186    // -----------------------------------------------------------------------
187
188    /// Creates a new `BlockStoreSharding` with the given configuration.
189    ///
190    /// Panics if `config.num_shards == 0`.
191    pub fn new(config: ShardingConfig) -> Self {
192        assert!(config.num_shards > 0, "num_shards must be > 0");
193        let n = config.num_shards as usize;
194        let mut shard_metrics: Vec<ShardMetrics> = Vec::with_capacity(n);
195        for i in 0..n {
196            shard_metrics.push(ShardMetrics {
197                shard_id: i as u8,
198                ..ShardMetrics::default()
199            });
200        }
201        Self {
202            shards: (0..n).map(|_| HashMap::new()).collect(),
203            shard_metrics,
204            config,
205            total_insertions: 0,
206            total_lookups: 0,
207            total_bytes: 0,
208        }
209    }
210
211    // -----------------------------------------------------------------------
212    // Routing
213    // -----------------------------------------------------------------------
214
215    /// Deterministically maps a CID string to a shard using FNV-1a % num_shards.
216    pub fn shard_for_cid(&self, cid: &str) -> ShardKey {
217        let hash = fnv1a_64(cid.as_bytes());
218        let idx = (hash % self.config.num_shards as u64) as u8;
219        ShardKey(idx)
220    }
221
222    // -----------------------------------------------------------------------
223    // Write
224    // -----------------------------------------------------------------------
225
226    /// Inserts or replaces a block.
227    ///
228    /// Returns `Ok(true)` if the block was newly inserted, `Ok(false)` if an
229    /// existing block for the same CID was replaced.
230    /// Returns `Err(ShardingError::ShardFull)` if the shard is at capacity and
231    /// there is no existing entry for the CID to replace.
232    pub fn put(&mut self, cid: String, data: Vec<u8>, now: u64) -> Result<bool, ShardingError> {
233        let key = self.shard_for_cid(&cid);
234        let idx = key.as_usize();
235
236        let shard = &mut self.shards[idx];
237        let metrics = &mut self.shard_metrics[idx];
238
239        if let Some(existing) = shard.get_mut(&cid) {
240            // Replace existing block
241            self.total_bytes = self
242                .total_bytes
243                .saturating_sub(existing.data.len() as u64)
244                .saturating_add(data.len() as u64);
245            metrics.total_bytes = metrics
246                .total_bytes
247                .saturating_sub(existing.data.len() as u64)
248                .saturating_add(data.len() as u64);
249            existing.data = data;
250            existing.inserted_at = now;
251            self.total_insertions = self.total_insertions.saturating_add(1);
252            return Ok(false);
253        }
254
255        // New block — check capacity
256        if shard.len() >= self.config.max_blocks_per_shard {
257            return Err(ShardingError::ShardFull { shard_id: key.0 });
258        }
259
260        let byte_len = data.len() as u64;
261        let record = BlockRecord {
262            cid: cid.clone(),
263            data,
264            shard: key,
265            inserted_at: now,
266            access_count: 0,
267        };
268        shard.insert(cid, record);
269        metrics.block_count += 1;
270        metrics.total_bytes = metrics.total_bytes.saturating_add(byte_len);
271        self.total_bytes = self.total_bytes.saturating_add(byte_len);
272        self.total_insertions = self.total_insertions.saturating_add(1);
273        Ok(true)
274    }
275
276    // -----------------------------------------------------------------------
277    // Read
278    // -----------------------------------------------------------------------
279
280    /// Looks up a block by CID.
281    ///
282    /// Updates the shard's `hit_count` / `miss_count` and, on hit, increments
283    /// the record's `access_count`. Returns `None` if not found.
284    pub fn get(&mut self, cid: &str) -> Option<&BlockRecord> {
285        let key = self.shard_for_cid(cid);
286        let idx = key.as_usize();
287        self.total_lookups = self.total_lookups.saturating_add(1);
288
289        if self.shards[idx].contains_key(cid) {
290            self.shard_metrics[idx].hit_count = self.shard_metrics[idx].hit_count.saturating_add(1);
291            let record = self.shards[idx].get_mut(cid)?;
292            record.access_count = record.access_count.saturating_add(1);
293            Some(record)
294        } else {
295            self.shard_metrics[idx].miss_count =
296                self.shard_metrics[idx].miss_count.saturating_add(1);
297            None
298        }
299    }
300
301    /// Returns a mutable reference to a block record (no metrics update).
302    pub fn get_mut(&mut self, cid: &str) -> Option<&mut BlockRecord> {
303        let key = self.shard_for_cid(cid);
304        let idx = key.as_usize();
305        self.shards[idx].get_mut(cid)
306    }
307
308    /// Returns `true` if the CID is present in the store.
309    pub fn contains(&self, cid: &str) -> bool {
310        let key = self.shard_for_cid(cid);
311        self.shards[key.as_usize()].contains_key(cid)
312    }
313
314    // -----------------------------------------------------------------------
315    // Delete
316    // -----------------------------------------------------------------------
317
318    /// Removes a block by CID.
319    ///
320    /// Returns `true` if the block existed and was removed, `false` otherwise.
321    pub fn remove(&mut self, cid: &str) -> bool {
322        let key = self.shard_for_cid(cid);
323        let idx = key.as_usize();
324        if let Some(record) = self.shards[idx].remove(cid) {
325            let byte_len = record.data.len() as u64;
326            self.shard_metrics[idx].block_count =
327                self.shard_metrics[idx].block_count.saturating_sub(1);
328            self.shard_metrics[idx].total_bytes =
329                self.shard_metrics[idx].total_bytes.saturating_sub(byte_len);
330            self.total_bytes = self.total_bytes.saturating_sub(byte_len);
331            true
332        } else {
333            false
334        }
335    }
336
337    // -----------------------------------------------------------------------
338    // Metrics
339    // -----------------------------------------------------------------------
340
341    /// Returns metrics for a specific shard, or `None` if the shard id is
342    /// out of range.
343    pub fn shard_metrics(&self, shard: ShardKey) -> Option<&ShardMetrics> {
344        self.shard_metrics.get(shard.as_usize())
345    }
346
347    /// Returns a slice of per-shard metrics.
348    pub fn all_shard_metrics(&self) -> &[ShardMetrics] {
349        &self.shard_metrics
350    }
351
352    // -----------------------------------------------------------------------
353    // Rebalancing
354    // -----------------------------------------------------------------------
355
356    /// Returns `true` when at least one shard exceeds `avg * (1 + threshold)`.
357    ///
358    /// Returns `false` when no blocks are stored.
359    pub fn needs_rebalance(&self) -> bool {
360        let total = self.total_block_count();
361        if total == 0 {
362            return false;
363        }
364        let avg = total as f64 / self.config.num_shards as f64;
365        let ceiling = avg * (1.0 + self.config.rebalance_threshold);
366        self.shards.iter().any(|s| s.len() as f64 > ceiling)
367    }
368
369    /// Rebalances the store by moving blocks from over-loaded shards to
370    /// under-loaded shards.
371    ///
372    /// For each shard that exceeds the average by more than the threshold,
373    /// the function removes the surplus blocks (those with the lowest
374    /// `access_count`) and re-inserts them using their natural `shard_for_cid`
375    /// routing. This is a no-op if the shard they naturally belong to has not
376    /// changed (e.g. due to the same num_shards); however the inserted_at
377    /// timestamp is refreshed to `now`.
378    ///
379    /// Returns the total number of blocks moved.
380    pub fn rebalance(&mut self, now: u64) -> usize {
381        let total = self.total_block_count();
382        if total == 0 {
383            return 0;
384        }
385        let avg = total as f64 / self.config.num_shards as f64;
386        let ceiling = avg * (1.0 + self.config.rebalance_threshold);
387
388        // Collect which shards are overloaded and by how much
389        let overloaded: Vec<(usize, usize)> = self
390            .shards
391            .iter()
392            .enumerate()
393            .filter_map(|(i, s)| {
394                let len = s.len() as f64;
395                if len > ceiling {
396                    let overflow = (len - avg.ceil()) as usize;
397                    Some((i, overflow))
398                } else {
399                    None
400                }
401            })
402            .collect();
403
404        if overloaded.is_empty() {
405            return 0;
406        }
407
408        let mut moved_total = 0usize;
409
410        for (shard_idx, overflow_count) in overloaded {
411            // Sort the CIDs in this shard by access_count ascending (evict coldest first)
412            let mut cids_sorted: Vec<(String, u64)> = self.shards[shard_idx]
413                .values()
414                .map(|r| (r.cid.clone(), r.access_count))
415                .collect();
416            cids_sorted.sort_by_key(|(_, ac)| *ac);
417            cids_sorted.truncate(overflow_count);
418
419            // Extract these records
420            let mut to_move: Vec<BlockRecord> = Vec::with_capacity(cids_sorted.len());
421            for (cid, _) in &cids_sorted {
422                if let Some(mut record) = self.shards[shard_idx].remove(cid.as_str()) {
423                    let byte_len = record.data.len() as u64;
424                    self.shard_metrics[shard_idx].block_count =
425                        self.shard_metrics[shard_idx].block_count.saturating_sub(1);
426                    self.shard_metrics[shard_idx].total_bytes = self.shard_metrics[shard_idx]
427                        .total_bytes
428                        .saturating_sub(byte_len);
429                    self.total_bytes = self.total_bytes.saturating_sub(byte_len);
430                    record.inserted_at = now;
431                    to_move.push(record);
432                }
433            }
434
435            // Re-insert with natural routing
436            for mut record in to_move {
437                let natural_key = self.shard_for_cid(&record.cid);
438                let nat_idx = natural_key.as_usize();
439                record.shard = natural_key;
440                let byte_len = record.data.len() as u64;
441                self.shards[nat_idx].insert(record.cid.clone(), record);
442                self.shard_metrics[nat_idx].block_count += 1;
443                self.shard_metrics[nat_idx].total_bytes = self.shard_metrics[nat_idx]
444                    .total_bytes
445                    .saturating_add(byte_len);
446                self.total_bytes = self.total_bytes.saturating_add(byte_len);
447                moved_total += 1;
448            }
449        }
450
451        moved_total
452    }
453
454    // -----------------------------------------------------------------------
455    // Eviction
456    // -----------------------------------------------------------------------
457
458    /// Evicts up to `count` blocks from the given shard, preferring blocks
459    /// with the lowest `access_count` (LRU approximation).
460    ///
461    /// Returns the number of blocks actually evicted (may be less than `count`
462    /// if the shard has fewer blocks).
463    pub fn evict_lru(&mut self, shard: ShardKey, count: usize) -> usize {
464        let idx = shard.as_usize();
465        if idx >= self.shards.len() || count == 0 {
466            return 0;
467        }
468
469        let mut cids_sorted: Vec<(String, u64)> = self.shards[idx]
470            .values()
471            .map(|r| (r.cid.clone(), r.access_count))
472            .collect();
473        cids_sorted.sort_by_key(|(_, ac)| *ac);
474        let to_evict = count.min(cids_sorted.len());
475
476        let mut evicted = 0usize;
477        for (cid, _) in cids_sorted.into_iter().take(to_evict) {
478            if let Some(record) = self.shards[idx].remove(&cid) {
479                let byte_len = record.data.len() as u64;
480                self.shard_metrics[idx].block_count =
481                    self.shard_metrics[idx].block_count.saturating_sub(1);
482                self.shard_metrics[idx].total_bytes =
483                    self.shard_metrics[idx].total_bytes.saturating_sub(byte_len);
484                self.total_bytes = self.total_bytes.saturating_sub(byte_len);
485                evicted += 1;
486            }
487        }
488        evicted
489    }
490
491    // -----------------------------------------------------------------------
492    // Introspection
493    // -----------------------------------------------------------------------
494
495    /// Returns all CIDs stored in the given shard.
496    pub fn cids_in_shard(&self, shard: ShardKey) -> Vec<&str> {
497        let idx = shard.as_usize();
498        if idx >= self.shards.len() {
499            return Vec::new();
500        }
501        self.shards[idx].keys().map(|k| k.as_str()).collect()
502    }
503
504    /// Returns the total number of blocks across all shards.
505    pub fn total_block_count(&self) -> usize {
506        self.shards.iter().map(|s| s.len()).sum()
507    }
508
509    /// Returns the total bytes stored across all shards.
510    pub fn total_bytes(&self) -> u64 {
511        self.total_bytes
512    }
513
514    /// Returns a snapshot of global statistics.
515    pub fn global_stats(&self) -> BlockStoreGlobalStats {
516        BlockStoreGlobalStats {
517            total_blocks: self.total_block_count(),
518            total_bytes: self.total_bytes,
519            total_insertions: self.total_insertions,
520            total_lookups: self.total_lookups,
521            num_shards: self.config.num_shards,
522            needs_rebalance: self.needs_rebalance(),
523        }
524    }
525}
526
527// ===========================================================================
528// Tests
529// ===========================================================================
530
531#[cfg(test)]
532mod tests {
533    use super::{
534        fnv1a_64, BlockRecord, BlockStoreGlobalStats, BlockStoreSharding, ShardKey, ShardMetrics,
535        ShardingConfig, ShardingError,
536    };
537
538    // -----------------------------------------------------------------------
539    // Helpers
540    // -----------------------------------------------------------------------
541
542    fn default_store() -> BlockStoreSharding {
543        BlockStoreSharding::new(ShardingConfig::default())
544    }
545
546    fn small_store(num_shards: u8) -> BlockStoreSharding {
547        BlockStoreSharding::new(ShardingConfig {
548            num_shards,
549            max_blocks_per_shard: 100,
550            rebalance_threshold: 0.25,
551        })
552    }
553
554    fn make_cid(n: usize) -> String {
555        format!("bafy2bzaced{:040}", n)
556    }
557
558    #[allow(dead_code)]
559    fn insert(store: &mut BlockStoreSharding, cid: &str, data: &[u8]) -> bool {
560        store
561            .put(cid.to_string(), data.to_vec(), 1000)
562            .expect("insert failed")
563    }
564
565    // -----------------------------------------------------------------------
566    // 1. new() initialises correct number of shards
567    // -----------------------------------------------------------------------
568    #[test]
569    fn test_new_default_shards() {
570        let store = default_store();
571        assert_eq!(store.config.num_shards, 16);
572        assert_eq!(store.shards.len(), 16);
573        assert_eq!(store.shard_metrics.len(), 16);
574    }
575
576    // 2. new() with custom num_shards
577    #[test]
578    fn test_new_custom_shards() {
579        let store = small_store(4);
580        assert_eq!(store.config.num_shards, 4);
581        assert_eq!(store.shards.len(), 4);
582    }
583
584    // 3. shard_for_cid returns index in range
585    #[test]
586    fn test_shard_for_cid_in_range() {
587        let store = default_store();
588        for i in 0..100usize {
589            let cid = make_cid(i);
590            let key = store.shard_for_cid(&cid);
591            assert!(key.0 < store.config.num_shards);
592        }
593    }
594
595    // 4. shard_for_cid is deterministic
596    #[test]
597    fn test_shard_for_cid_deterministic() {
598        let store = default_store();
599        let cid = make_cid(42);
600        assert_eq!(store.shard_for_cid(&cid), store.shard_for_cid(&cid));
601    }
602
603    // 5. shard_for_cid matches fnv1a_64 formula
604    #[test]
605    fn test_shard_for_cid_formula() {
606        let store = small_store(8);
607        let cid = "QmTestCid12345";
608        let expected_idx = (fnv1a_64(cid.as_bytes()) % 8) as u8;
609        assert_eq!(store.shard_for_cid(cid).0, expected_idx);
610    }
611
612    // 6. put() returns true for new block
613    #[test]
614    fn test_put_new_returns_true() {
615        let mut store = default_store();
616        let result = store.put("cid1".to_string(), vec![1, 2, 3], 100);
617        assert_eq!(result, Ok(true));
618    }
619
620    // 7. put() returns false for replacement
621    #[test]
622    fn test_put_replace_returns_false() {
623        let mut store = default_store();
624        store
625            .put("cid1".to_string(), vec![1, 2, 3], 100)
626            .expect("first insert");
627        let result = store.put("cid1".to_string(), vec![4, 5, 6], 200);
628        assert_eq!(result, Ok(false));
629    }
630
631    // 8. put() respects max_blocks_per_shard
632    #[test]
633    fn test_put_shard_full() {
634        let mut store = BlockStoreSharding::new(ShardingConfig {
635            num_shards: 1,
636            max_blocks_per_shard: 2,
637            rebalance_threshold: 0.25,
638        });
639        store.put("a".to_string(), vec![1], 0).expect("a");
640        store.put("b".to_string(), vec![2], 0).expect("b");
641        let result = store.put("c".to_string(), vec![3], 0);
642        assert!(matches!(
643            result,
644            Err(ShardingError::ShardFull { shard_id: 0 })
645        ));
646    }
647
648    // 9. put() replacement does not consume capacity
649    #[test]
650    fn test_put_replace_no_capacity_loss() {
651        let mut store = BlockStoreSharding::new(ShardingConfig {
652            num_shards: 1,
653            max_blocks_per_shard: 1,
654            rebalance_threshold: 0.25,
655        });
656        store.put("a".to_string(), vec![1], 0).expect("first");
657        // Replacing should succeed even though capacity is 1
658        let result = store.put("a".to_string(), vec![99], 1);
659        assert_eq!(result, Ok(false));
660    }
661
662    // 10. total_insertions increments on each put
663    #[test]
664    fn test_total_insertions() {
665        let mut store = default_store();
666        store.put("c1".to_string(), vec![1], 0).expect("c1");
667        store.put("c2".to_string(), vec![2], 0).expect("c2");
668        store.put("c1".to_string(), vec![3], 1).expect("replace c1");
669        assert_eq!(store.total_insertions, 3);
670    }
671
672    // 11. get() returns correct record
673    #[test]
674    fn test_get_returns_record() {
675        let mut store = default_store();
676        store
677            .put("cid_x".to_string(), vec![10, 20], 500)
678            .expect("put");
679        let record = store.get("cid_x").expect("should exist");
680        assert_eq!(record.cid, "cid_x");
681        assert_eq!(record.data, vec![10, 20]);
682        assert_eq!(record.inserted_at, 500);
683    }
684
685    // 12. get() increments access_count
686    #[test]
687    fn test_get_increments_access_count() {
688        let mut store = default_store();
689        store.put("cid_ac".to_string(), vec![1], 0).expect("put");
690        store.get("cid_ac");
691        store.get("cid_ac");
692        let record = store.get("cid_ac").expect("record");
693        assert_eq!(record.access_count, 3);
694    }
695
696    // 13. get() updates hit_count
697    #[test]
698    fn test_get_updates_hit_count() {
699        let mut store = default_store();
700        store.put("cid_h".to_string(), vec![1], 0).expect("put");
701        let key = store.shard_for_cid("cid_h");
702        store.get("cid_h");
703        store.get("cid_h");
704        assert_eq!(store.shard_metrics(key).expect("metrics").hit_count, 2);
705    }
706
707    // 14. get() on missing CID returns None and updates miss_count
708    #[test]
709    fn test_get_miss_updates_miss_count() {
710        let mut store = default_store();
711        let key = store.shard_for_cid("nonexistent");
712        store.get("nonexistent");
713        assert_eq!(store.shard_metrics(key).expect("metrics").miss_count, 1);
714        assert!(store.get("nonexistent").is_none());
715    }
716
717    // 15. get() increments total_lookups
718    #[test]
719    fn test_get_increments_total_lookups() {
720        let mut store = default_store();
721        store.put("lk".to_string(), vec![1], 0).expect("put");
722        store.get("lk");
723        store.get("missing");
724        assert_eq!(store.total_lookups, 2);
725    }
726
727    // 16. get_mut() returns mutable reference
728    #[test]
729    fn test_get_mut_returns_mutable() {
730        let mut store = default_store();
731        store.put("m".to_string(), vec![5], 0).expect("put");
732        {
733            let record = store.get_mut("m").expect("should exist");
734            record.access_count = 99;
735        }
736        let record = store.get_mut("m").expect("still exists");
737        assert_eq!(record.access_count, 99);
738    }
739
740    // 17. contains() returns true for inserted block
741    #[test]
742    fn test_contains_true() {
743        let mut store = default_store();
744        store.put("x".to_string(), vec![1], 0).expect("put");
745        assert!(store.contains("x"));
746    }
747
748    // 18. contains() returns false for missing block
749    #[test]
750    fn test_contains_false() {
751        let store = default_store();
752        assert!(!store.contains("zzz"));
753    }
754
755    // 19. remove() returns true and decrements block_count
756    #[test]
757    fn test_remove_existing() {
758        let mut store = default_store();
759        store.put("r".to_string(), vec![1, 2, 3], 0).expect("put");
760        assert!(store.remove("r"));
761        assert!(!store.contains("r"));
762    }
763
764    // 20. remove() updates total_bytes
765    #[test]
766    fn test_remove_updates_total_bytes() {
767        let mut store = default_store();
768        store
769            .put("rb".to_string(), vec![1, 2, 3, 4], 0)
770            .expect("put");
771        let before = store.total_bytes();
772        store.remove("rb");
773        assert_eq!(store.total_bytes(), before - 4);
774    }
775
776    // 21. remove() on missing CID returns false
777    #[test]
778    fn test_remove_missing_returns_false() {
779        let mut store = default_store();
780        assert!(!store.remove("no_such"));
781    }
782
783    // 22. total_block_count() sums across shards
784    #[test]
785    fn test_total_block_count() {
786        let mut store = default_store();
787        for i in 0..20usize {
788            store.put(make_cid(i), vec![i as u8], 0).expect("put");
789        }
790        assert_eq!(store.total_block_count(), 20);
791    }
792
793    // 23. total_bytes() reflects put and remove
794    #[test]
795    fn test_total_bytes_consistency() {
796        let mut store = default_store();
797        store
798            .put("a".to_string(), vec![0u8; 100], 0)
799            .expect("put a");
800        store
801            .put("b".to_string(), vec![0u8; 200], 0)
802            .expect("put b");
803        assert_eq!(store.total_bytes(), 300);
804        store.remove("a");
805        assert_eq!(store.total_bytes(), 200);
806    }
807
808    // 24. shard_metrics() returns correct shard_id
809    #[test]
810    fn test_shard_metrics_correct_id() {
811        let store = small_store(4);
812        for i in 0..4u8 {
813            let m = store.shard_metrics(ShardKey(i)).expect("metrics");
814            assert_eq!(m.shard_id, i);
815        }
816    }
817
818    // 25. shard_metrics() returns None for out-of-range shard
819    #[test]
820    fn test_shard_metrics_out_of_range() {
821        let store = small_store(4);
822        assert!(store.shard_metrics(ShardKey(4)).is_none());
823    }
824
825    // 26. all_shard_metrics() length matches num_shards
826    #[test]
827    fn test_all_shard_metrics_length() {
828        let store = small_store(8);
829        assert_eq!(store.all_shard_metrics().len(), 8);
830    }
831
832    // 27. cids_in_shard() returns correct CIDs
833    #[test]
834    fn test_cids_in_shard() {
835        let mut store = small_store(1);
836        store.put("p".to_string(), vec![1], 0).expect("put");
837        store.put("q".to_string(), vec![2], 0).expect("put");
838        let mut cids = store.cids_in_shard(ShardKey(0));
839        cids.sort_unstable();
840        assert!(cids.contains(&"p"));
841        assert!(cids.contains(&"q"));
842    }
843
844    // 28. cids_in_shard() for out-of-range returns empty vec
845    #[test]
846    fn test_cids_in_shard_out_of_range() {
847        let store = small_store(2);
848        assert!(store.cids_in_shard(ShardKey(99)).is_empty());
849    }
850
851    // 29. needs_rebalance() false when empty
852    #[test]
853    fn test_needs_rebalance_empty() {
854        let store = default_store();
855        assert!(!store.needs_rebalance());
856    }
857
858    // 30. needs_rebalance() false when evenly distributed
859    #[test]
860    fn test_needs_rebalance_even() {
861        // With 1 shard and threshold 0.25, a single block avg==1 ceiling==1.25
862        // so 1 block should be fine
863        let mut store = BlockStoreSharding::new(ShardingConfig {
864            num_shards: 1,
865            max_blocks_per_shard: 10000,
866            rebalance_threshold: 0.25,
867        });
868        for i in 0..5usize {
869            store.put(make_cid(i), vec![1], 0).expect("put");
870        }
871        // With 1 shard only: 5 blocks, avg=5, ceiling=6.25; 5 <= 6.25 → no rebalance
872        assert!(!store.needs_rebalance());
873    }
874
875    // 31. needs_rebalance() true when highly imbalanced
876    #[test]
877    fn test_needs_rebalance_imbalanced() {
878        // 2 shards, force all into shard 0 by using shard-1 store and injecting
879        // directly. We need to craft CIDs that all hash to shard 0.
880        let mut store = BlockStoreSharding::new(ShardingConfig {
881            num_shards: 2,
882            max_blocks_per_shard: 10000,
883            rebalance_threshold: 0.10,
884        });
885        // Put many blocks – not all will go to shard 0 but we can check
886        // that after enough insertions, if one shard has >> avg, it triggers.
887        // To guarantee, directly insert into shards via normal API using CIDs
888        // that we pre-compute to land on shard 0.
889        let mut shard0_count = 0usize;
890        let mut i = 0usize;
891        while shard0_count < 20 {
892            let cid = format!("rebal_cid_{}", i);
893            let key = store.shard_for_cid(&cid);
894            if key.0 == 0 {
895                store.put(cid, vec![1], 0).expect("put");
896                shard0_count += 1;
897            }
898            i += 1;
899        }
900        // shard0 has 20 blocks, shard1 has 0 → avg=10, ceiling=11 → imbalanced
901        assert!(store.needs_rebalance());
902    }
903
904    // 32. rebalance() returns 0 when empty
905    #[test]
906    fn test_rebalance_empty() {
907        let mut store = default_store();
908        assert_eq!(store.rebalance(0), 0);
909    }
910
911    // 33. rebalance() reduces imbalance
912    #[test]
913    fn test_rebalance_reduces_imbalance() {
914        let mut store = BlockStoreSharding::new(ShardingConfig {
915            num_shards: 2,
916            max_blocks_per_shard: 10000,
917            rebalance_threshold: 0.10,
918        });
919        let mut shard0_count = 0usize;
920        let mut i = 0usize;
921        while shard0_count < 20 {
922            let cid = format!("rb_cid_{}", i);
923            let key = store.shard_for_cid(&cid);
924            if key.0 == 0 {
925                store.put(cid, vec![1], 0).expect("put");
926                shard0_count += 1;
927            }
928            i += 1;
929        }
930        let total_before = store.total_block_count();
931        let _moved = store.rebalance(1000);
932        let total_after = store.total_block_count();
933        // Total blocks preserved
934        assert_eq!(total_before, total_after);
935    }
936
937    // 34. evict_lru() removes correct count
938    #[test]
939    fn test_evict_lru_count() {
940        let mut store = small_store(1);
941        for i in 0..10usize {
942            store.put(make_cid(i), vec![i as u8], 0).expect("put");
943        }
944        let evicted = store.evict_lru(ShardKey(0), 3);
945        assert_eq!(evicted, 3);
946        assert_eq!(store.total_block_count(), 7);
947    }
948
949    // 35. evict_lru() prefers lowest access_count
950    #[test]
951    fn test_evict_lru_lowest_access() {
952        let mut store = small_store(1);
953        store.put("hot".to_string(), vec![1], 0).expect("put");
954        store.put("cold".to_string(), vec![2], 0).expect("put");
955        // Access "hot" 10 times
956        for _ in 0..10 {
957            store.get("hot");
958        }
959        // Evict 1 — should remove "cold" (access_count == 0)
960        store.evict_lru(ShardKey(0), 1);
961        assert!(store.contains("hot"));
962        assert!(!store.contains("cold"));
963    }
964
965    // 36. evict_lru() with count larger than shard size
966    #[test]
967    fn test_evict_lru_clamps_to_shard_size() {
968        let mut store = small_store(1);
969        for i in 0..5usize {
970            store.put(make_cid(i), vec![1], 0).expect("put");
971        }
972        let evicted = store.evict_lru(ShardKey(0), 100);
973        assert_eq!(evicted, 5);
974        assert_eq!(store.total_block_count(), 0);
975    }
976
977    // 37. evict_lru() on out-of-range shard returns 0
978    #[test]
979    fn test_evict_lru_out_of_range() {
980        let mut store = small_store(2);
981        assert_eq!(store.evict_lru(ShardKey(99), 5), 0);
982    }
983
984    // 38. global_stats() reflects current state
985    #[test]
986    fn test_global_stats_basic() {
987        let mut store = default_store();
988        store.put("g1".to_string(), vec![1, 2], 0).expect("put");
989        store.put("g2".to_string(), vec![3, 4, 5], 0).expect("put");
990        let stats = store.global_stats();
991        assert_eq!(stats.total_blocks, 2);
992        assert_eq!(stats.total_bytes, 5);
993        assert_eq!(stats.total_insertions, 2);
994        assert_eq!(stats.num_shards, 16);
995    }
996
997    // 39. global_stats().needs_rebalance matches needs_rebalance()
998    #[test]
999    fn test_global_stats_needs_rebalance_flag() {
1000        let store = default_store();
1001        let stats = store.global_stats();
1002        assert_eq!(stats.needs_rebalance, store.needs_rebalance());
1003    }
1004
1005    // 40. replace block updates total_bytes correctly
1006    #[test]
1007    fn test_replace_updates_bytes() {
1008        let mut store = default_store();
1009        store
1010            .put("replace_me".to_string(), vec![0u8; 10], 0)
1011            .expect("first");
1012        store
1013            .put("replace_me".to_string(), vec![0u8; 25], 1)
1014            .expect("replace");
1015        assert_eq!(store.total_bytes(), 25);
1016    }
1017
1018    // 41. ShardMetrics.block_count tracks correctly
1019    #[test]
1020    fn test_shard_metrics_block_count() {
1021        let mut store = small_store(1);
1022        store.put("a".to_string(), vec![1], 0).expect("put");
1023        store.put("b".to_string(), vec![2], 0).expect("put");
1024        let m = store.shard_metrics(ShardKey(0)).expect("metrics");
1025        assert_eq!(m.block_count, 2);
1026        store.remove("a");
1027        let m2 = store.shard_metrics(ShardKey(0)).expect("metrics");
1028        assert_eq!(m2.block_count, 1);
1029    }
1030
1031    // 42. ShardMetrics.total_bytes tracks correctly
1032    #[test]
1033    fn test_shard_metrics_total_bytes() {
1034        let mut store = small_store(1);
1035        store.put("x".to_string(), vec![0u8; 50], 0).expect("put");
1036        let m = store.shard_metrics(ShardKey(0)).expect("metrics");
1037        assert_eq!(m.total_bytes, 50);
1038    }
1039
1040    // 43. ShardKey display/access
1041    #[test]
1042    fn test_shard_key_accessors() {
1043        let k = ShardKey(7);
1044        assert_eq!(k.index(), 7);
1045        assert_eq!(k.as_usize(), 7);
1046    }
1047
1048    // 44. ShardingError::ShardFull carries correct shard_id
1049    #[test]
1050    fn test_sharding_error_shard_full_id() {
1051        let mut store = BlockStoreSharding::new(ShardingConfig {
1052            num_shards: 1,
1053            max_blocks_per_shard: 0,
1054            rebalance_threshold: 0.25,
1055        });
1056        let result = store.put("any".to_string(), vec![1], 0);
1057        assert_eq!(result, Err(ShardingError::ShardFull { shard_id: 0 }));
1058    }
1059
1060    // 45. ShardingError Display
1061    #[test]
1062    fn test_sharding_error_display() {
1063        let e1 = ShardingError::ShardFull { shard_id: 3 };
1064        let e2 = ShardingError::CidNotFound("abc".to_string());
1065        let e3 = ShardingError::InvalidShardId(99);
1066        assert!(e1.to_string().contains("3"));
1067        assert!(e2.to_string().contains("abc"));
1068        assert!(e3.to_string().contains("99"));
1069    }
1070
1071    // 46. BlockRecord fields are public and readable
1072    #[test]
1073    fn test_block_record_fields() {
1074        let r = BlockRecord {
1075            cid: "test_cid".to_string(),
1076            data: vec![1, 2, 3],
1077            shard: ShardKey(2),
1078            inserted_at: 12345,
1079            access_count: 7,
1080        };
1081        assert_eq!(r.cid, "test_cid");
1082        assert_eq!(r.shard.0, 2);
1083        assert_eq!(r.inserted_at, 12345);
1084        assert_eq!(r.access_count, 7);
1085    }
1086
1087    // 47. BlockStoreGlobalStats is clonable and fields accessible
1088    #[test]
1089    fn test_global_stats_clone() {
1090        let s = BlockStoreGlobalStats {
1091            total_blocks: 10,
1092            total_bytes: 500,
1093            total_insertions: 12,
1094            total_lookups: 8,
1095            num_shards: 4,
1096            needs_rebalance: false,
1097        };
1098        let s2 = s.clone();
1099        assert_eq!(s2.total_blocks, 10);
1100    }
1101
1102    // 48. Large number of blocks distributed across shards
1103    #[test]
1104    fn test_large_insertion_distribution() {
1105        let mut store = default_store();
1106        for i in 0..1000usize {
1107            store.put(make_cid(i), vec![1], 0).expect("put");
1108        }
1109        assert_eq!(store.total_block_count(), 1000);
1110        // Each shard should have some blocks (with 16 shards and 1000 inserts)
1111        let non_empty = store
1112            .all_shard_metrics()
1113            .iter()
1114            .filter(|m| m.block_count > 0)
1115            .count();
1116        assert!(
1117            non_empty > 1,
1118            "blocks should be distributed across multiple shards"
1119        );
1120    }
1121
1122    // 49. Shard metrics total_bytes sums equal global total_bytes
1123    #[test]
1124    fn test_shard_metrics_bytes_sum_equals_global() {
1125        let mut store = default_store();
1126        for i in 0..50usize {
1127            store.put(make_cid(i), vec![0u8; i + 1], 0).expect("put");
1128        }
1129        let metrics_sum: u64 = store
1130            .all_shard_metrics()
1131            .iter()
1132            .map(|m| m.total_bytes)
1133            .sum();
1134        assert_eq!(metrics_sum, store.total_bytes());
1135    }
1136
1137    // 50. Shard metrics block_count sums equal global block count
1138    #[test]
1139    fn test_shard_metrics_block_count_sum_equals_total() {
1140        let mut store = default_store();
1141        for i in 0..50usize {
1142            store.put(make_cid(i), vec![1], 0).expect("put");
1143        }
1144        let metrics_sum: usize = store
1145            .all_shard_metrics()
1146            .iter()
1147            .map(|m| m.block_count)
1148            .sum();
1149        assert_eq!(metrics_sum, store.total_block_count());
1150    }
1151
1152    // 51. ShardMetrics implements Clone
1153    #[test]
1154    fn test_shard_metrics_clone() {
1155        let m = ShardMetrics {
1156            shard_id: 3,
1157            block_count: 10,
1158            total_bytes: 500,
1159            hit_count: 7,
1160            miss_count: 2,
1161        };
1162        let m2 = m.clone();
1163        assert_eq!(m2.shard_id, 3);
1164        assert_eq!(m2.hit_count, 7);
1165    }
1166
1167    // 52. get_mut does not update hit/miss counts
1168    #[test]
1169    fn test_get_mut_no_metrics_update() {
1170        let mut store = small_store(1);
1171        store.put("z".to_string(), vec![1], 0).expect("put");
1172        store.get_mut("z");
1173        let m = store.shard_metrics(ShardKey(0)).expect("metrics");
1174        assert_eq!(m.hit_count, 0);
1175        assert_eq!(m.miss_count, 0);
1176    }
1177
1178    // 53. rebalance() preserves total_bytes
1179    #[test]
1180    fn test_rebalance_preserves_bytes() {
1181        let mut store = BlockStoreSharding::new(ShardingConfig {
1182            num_shards: 2,
1183            max_blocks_per_shard: 10000,
1184            rebalance_threshold: 0.10,
1185        });
1186        let mut shard0_count = 0usize;
1187        let mut i = 0usize;
1188        while shard0_count < 10 {
1189            let cid = format!("rb_bytes_{}", i);
1190            let key = store.shard_for_cid(&cid);
1191            if key.0 == 0 {
1192                store.put(cid, vec![0u8; 8], 0).expect("put");
1193                shard0_count += 1;
1194            }
1195            i += 1;
1196        }
1197        let bytes_before = store.total_bytes();
1198        store.rebalance(1000);
1199        assert_eq!(store.total_bytes(), bytes_before);
1200    }
1201}