Skip to main content

scirs2_io/distributed/
sharding.rs

1//! Sharded array storage: partition large arrays across multiple files or chunks.
2//!
3//! Provides:
4//! - `ShardConfig` – shard count, shard size, distribution strategy
5//! - `ShardedArray` – partition a `Vec<f64>` across N shards on disk
6//! - `RoundRobinSharding`, `HashSharding`, `RangeSharding` strategies
7//! - `ShardReader` – parallel shard reading with merge
8//! - `ShardWriter` – parallel shard writing with coordination
9//! - `VirtualConcatenation` – lazy concatenation of multiple file paths
10
11#![allow(dead_code)]
12#![allow(missing_docs)]
13
14use crate::error::{IoError, Result};
15use serde::{Deserialize, Serialize};
16use std::collections::hash_map::DefaultHasher;
17use std::hash::{Hash, Hasher};
18use std::io::{Read, Write};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::thread;
22
23// ---------------------------------------------------------------------------
24// Distribution strategy
25// ---------------------------------------------------------------------------
26
27/// Strategy used to decide which shard receives each element.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub enum ShardingStrategy {
30    /// Round-robin: element `i` → shard `i % shard_count`
31    RoundRobin,
32    /// Hash-based: hash of element index → shard
33    Hash,
34    /// Range-based: contiguous blocks of equal size per shard
35    Range,
36}
37
38/// Configuration for a sharded array.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ShardConfig {
41    /// Total number of shards
42    pub shard_count: usize,
43    /// Maximum elements per shard (0 = unlimited / auto)
44    pub shard_size: usize,
45    /// Distribution strategy
46    pub strategy: ShardingStrategy,
47    /// Base directory where shard files are written
48    pub base_dir: PathBuf,
49    /// File name prefix (e.g. `"data"` → `"data_shard_000.bin"`)
50    pub prefix: String,
51}
52
53impl ShardConfig {
54    /// Create a new `ShardConfig` with sensible defaults.
55    pub fn new<P: AsRef<Path>>(base_dir: P, shard_count: usize) -> Self {
56        Self {
57            shard_count,
58            shard_size: 0,
59            strategy: ShardingStrategy::Range,
60            base_dir: base_dir.as_ref().to_path_buf(),
61            prefix: "shard".to_string(),
62        }
63    }
64
65    /// Set the sharding strategy.
66    pub fn with_strategy(mut self, strategy: ShardingStrategy) -> Self {
67        self.strategy = strategy;
68        self
69    }
70
71    /// Set max elements per shard.
72    pub fn with_shard_size(mut self, size: usize) -> Self {
73        self.shard_size = size;
74        self
75    }
76
77    /// Set the file prefix.
78    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
79        self.prefix = prefix.into();
80        self
81    }
82
83    /// Return the file path for shard `index`.
84    pub fn shard_path(&self, index: usize) -> PathBuf {
85        self.base_dir
86            .join(format!("{}_{:04}.bin", self.prefix, index))
87    }
88
89    /// Return the metadata file path.
90    pub fn meta_path(&self) -> PathBuf {
91        self.base_dir.join(format!("{}_meta.json", self.prefix))
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Strategy helpers
97// ---------------------------------------------------------------------------
98
99/// Round-robin sharding: assign element at index `i` to shard `i % n`.
100pub struct RoundRobinSharding;
101
102impl RoundRobinSharding {
103    /// Compute the shard index for element at position `element_index`.
104    pub fn shard_for(element_index: usize, shard_count: usize) -> usize {
105        if shard_count == 0 {
106            return 0;
107        }
108        element_index % shard_count
109    }
110}
111
112/// Hash-based sharding: hash the element index to pick a shard.
113pub struct HashSharding;
114
115impl HashSharding {
116    /// Compute the shard index for element at position `element_index`.
117    pub fn shard_for(element_index: usize, shard_count: usize) -> usize {
118        if shard_count == 0 {
119            return 0;
120        }
121        let mut h = DefaultHasher::new();
122        element_index.hash(&mut h);
123        (h.finish() as usize) % shard_count
124    }
125}
126
127/// Range-based sharding: assign contiguous blocks of elements to shards.
128pub struct RangeSharding;
129
130impl RangeSharding {
131    /// Compute the shard index for element at position `element_index` when
132    /// the total array length is `total`.
133    pub fn shard_for(element_index: usize, shard_count: usize, total: usize) -> usize {
134        if shard_count == 0 || total == 0 {
135            return 0;
136        }
137        let block = (total + shard_count - 1) / shard_count;
138        (element_index / block).min(shard_count - 1)
139    }
140}
141
142// ---------------------------------------------------------------------------
143// ShardedArray metadata
144// ---------------------------------------------------------------------------
145
146/// Persistent metadata describing a sharded array on disk.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ShardedArrayMeta {
149    /// Total number of elements
150    pub total_elements: usize,
151    /// Number of shards
152    pub shard_count: usize,
153    /// Elements in each shard
154    pub shard_sizes: Vec<usize>,
155    /// Strategy used
156    pub strategy: ShardingStrategy,
157    /// Element bit-width (e.g. 64 for f64)
158    pub element_bits: u8,
159    /// File prefix
160    pub prefix: String,
161}
162
163// ---------------------------------------------------------------------------
164// ShardedArray
165// ---------------------------------------------------------------------------
166
167/// A large array of `f64` values partitioned across multiple binary shard files.
168///
169/// Each shard file stores raw little-endian `f64` values (no header).
170/// A companion `<prefix>_meta.json` records the layout.
171pub struct ShardedArray {
172    config: ShardConfig,
173    meta: Option<ShardedArrayMeta>,
174}
175
176impl ShardedArray {
177    /// Create a `ShardedArray` bound to the given config (does not touch disk yet).
178    pub fn new(config: ShardConfig) -> Self {
179        Self { config, meta: None }
180    }
181
182    /// Write `data` to disk as shards.
183    ///
184    /// Returns the resulting metadata.
185    pub fn write(&mut self, data: &[f64]) -> Result<ShardedArrayMeta> {
186        std::fs::create_dir_all(&self.config.base_dir)
187            .map_err(|e| IoError::FileError(format!("Cannot create shard dir: {e}")))?;
188
189        let shard_count = self.config.shard_count;
190        let total = data.len();
191
192        // Assign each element to a shard
193        let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); shard_count];
194        for (i, &v) in data.iter().enumerate() {
195            let shard_idx = match self.config.strategy {
196                ShardingStrategy::RoundRobin => RoundRobinSharding::shard_for(i, shard_count),
197                ShardingStrategy::Hash => HashSharding::shard_for(i, shard_count),
198                ShardingStrategy::Range => RangeSharding::shard_for(i, shard_count, total),
199            };
200            buckets[shard_idx].push(v);
201        }
202
203        let mut shard_sizes = Vec::with_capacity(shard_count);
204        for (idx, bucket) in buckets.iter().enumerate() {
205            let path = self.config.shard_path(idx);
206            let mut f = std::fs::File::create(&path)
207                .map_err(|e| IoError::FileError(format!("Cannot create shard {idx}: {e}")))?;
208            for &v in bucket {
209                f.write_all(&v.to_le_bytes())
210                    .map_err(|e| IoError::FileError(format!("Shard write error: {e}")))?;
211            }
212            shard_sizes.push(bucket.len());
213        }
214
215        let meta = ShardedArrayMeta {
216            total_elements: total,
217            shard_count,
218            shard_sizes,
219            strategy: self.config.strategy.clone(),
220            element_bits: 64,
221            prefix: self.config.prefix.clone(),
222        };
223
224        // Persist metadata
225        let meta_json = serde_json::to_string_pretty(&meta)
226            .map_err(|e| IoError::SerializationError(format!("{e}")))?;
227        let mut mf = std::fs::File::create(self.config.meta_path())
228            .map_err(|e| IoError::FileError(format!("Cannot write meta: {e}")))?;
229        mf.write_all(meta_json.as_bytes())
230            .map_err(|e| IoError::FileError(format!("Meta write error: {e}")))?;
231
232        self.meta = Some(meta.clone());
233        Ok(meta)
234    }
235
236    /// Read back all shards and return the reconstructed `Vec<f64>`.
237    ///
238    /// For `Range` strategy the order is preserved (contiguous blocks).
239    /// For `RoundRobin`/`Hash` strategies, a reconstruction pass restores order.
240    pub fn read(&self) -> Result<Vec<f64>> {
241        let meta = self.load_meta()?;
242        let shard_count = meta.shard_count;
243        let mut shards: Vec<Vec<f64>> = Vec::with_capacity(shard_count);
244        for idx in 0..shard_count {
245            let path = self.config.shard_path(idx);
246            let mut f = std::fs::File::open(&path)
247                .map_err(|_| IoError::NotFound(format!("Shard {idx} not found")))?;
248            let mut buf = Vec::new();
249            f.read_to_end(&mut buf)
250                .map_err(|e| IoError::FileError(format!("Shard read error: {e}")))?;
251            let values: Vec<f64> = buf
252                .chunks_exact(8)
253                .map(|b| {
254                    let arr: [u8; 8] = b.try_into().unwrap_or([0u8; 8]);
255                    f64::from_le_bytes(arr)
256                })
257                .collect();
258            shards.push(values);
259        }
260
261        let total = meta.total_elements;
262        match meta.strategy {
263            ShardingStrategy::Range => {
264                // Simply concatenate shards in order
265                let result: Vec<f64> = shards.into_iter().flatten().collect();
266                Ok(result)
267            }
268            ShardingStrategy::RoundRobin => {
269                let mut result = vec![0.0f64; total];
270                let mut shard_cursors = vec![0usize; shard_count];
271                for i in 0..total {
272                    let s = RoundRobinSharding::shard_for(i, shard_count);
273                    let cursor = shard_cursors[s];
274                    if cursor < shards[s].len() {
275                        result[i] = shards[s][cursor];
276                        shard_cursors[s] += 1;
277                    }
278                }
279                Ok(result)
280            }
281            ShardingStrategy::Hash => {
282                let mut result = vec![0.0f64; total];
283                let mut shard_cursors = vec![0usize; shard_count];
284                for i in 0..total {
285                    let s = HashSharding::shard_for(i, shard_count);
286                    let cursor = shard_cursors[s];
287                    if cursor < shards[s].len() {
288                        result[i] = shards[s][cursor];
289                        shard_cursors[s] += 1;
290                    }
291                }
292                Ok(result)
293            }
294        }
295    }
296
297    fn load_meta(&self) -> Result<ShardedArrayMeta> {
298        if let Some(ref m) = self.meta {
299            return Ok(m.clone());
300        }
301        let path = self.config.meta_path();
302        let mut f = std::fs::File::open(&path)
303            .map_err(|_| IoError::NotFound("Shard metadata not found".to_string()))?;
304        let mut buf = String::new();
305        f.read_to_string(&mut buf)
306            .map_err(|e| IoError::FileError(format!("Cannot read meta: {e}")))?;
307        serde_json::from_str(&buf).map_err(|e| IoError::ParseError(format!("Bad meta: {e}")))
308    }
309}
310
311// ---------------------------------------------------------------------------
312// ShardReader / ShardWriter
313// ---------------------------------------------------------------------------
314
315/// Parallel shard reader that reads multiple shards concurrently and merges them.
316pub struct ShardReader {
317    config: ShardConfig,
318    num_threads: usize,
319}
320
321impl ShardReader {
322    /// Create a `ShardReader` for the given config.
323    pub fn new(config: ShardConfig) -> Self {
324        let cores = num_cpus::get().max(1);
325        Self {
326            config,
327            num_threads: cores,
328        }
329    }
330
331    /// Set the number of reader threads.
332    pub fn with_threads(mut self, n: usize) -> Self {
333        self.num_threads = n.max(1);
334        self
335    }
336
337    /// Read all shards in parallel and merge into a single `Vec<f64>`.
338    pub fn read_all(&self) -> Result<Vec<f64>> {
339        let shard_count = self.config.shard_count;
340        let results: Arc<Mutex<Vec<(usize, Result<Vec<f64>>)>>> =
341            Arc::new(Mutex::new(Vec::with_capacity(shard_count)));
342
343        let handles: Vec<_> = (0..shard_count)
344            .map(|idx| {
345                let path = self.config.shard_path(idx);
346                let results = results.clone();
347                thread::spawn(move || {
348                    let data = read_shard_raw(&path);
349                    let mut guard = results.lock().expect("lock poisoned");
350                    guard.push((idx, data));
351                })
352            })
353            .collect();
354
355        for h in handles {
356            h.join()
357                .map_err(|_| IoError::FileError("Shard reader thread panicked".to_string()))?;
358        }
359
360        let mut guard = results.lock().expect("lock poisoned");
361        guard.sort_by_key(|(idx, _)| *idx);
362        let mut merged = Vec::new();
363        for (_, result) in guard.drain(..) {
364            let data = result?;
365            merged.extend(data);
366        }
367        Ok(merged)
368    }
369
370    /// Read a single shard by index.
371    pub fn read_shard(&self, index: usize) -> Result<Vec<f64>> {
372        read_shard_raw(&self.config.shard_path(index))
373    }
374}
375
376fn read_shard_raw(path: &Path) -> Result<Vec<f64>> {
377    let mut f = std::fs::File::open(path)
378        .map_err(|_| IoError::NotFound(format!("Shard not found: {}", path.display())))?;
379    let mut buf = Vec::new();
380    f.read_to_end(&mut buf)
381        .map_err(|e| IoError::FileError(format!("Shard read error: {e}")))?;
382    Ok(buf
383        .chunks_exact(8)
384        .map(|b| {
385            let arr: [u8; 8] = b.try_into().unwrap_or([0u8; 8]);
386            f64::from_le_bytes(arr)
387        })
388        .collect())
389}
390
391/// Parallel shard writer that distributes data across shards concurrently.
392pub struct ShardWriter {
393    config: ShardConfig,
394}
395
396impl ShardWriter {
397    /// Create a `ShardWriter` for the given config.
398    pub fn new(config: ShardConfig) -> Self {
399        Self { config }
400    }
401
402    /// Write `data` to shards in parallel (one thread per shard).
403    ///
404    /// Returns a list of paths to the written shard files.
405    pub fn write(&self, data: &[f64]) -> Result<Vec<PathBuf>> {
406        std::fs::create_dir_all(&self.config.base_dir)
407            .map_err(|e| IoError::FileError(format!("Cannot create dir: {e}")))?;
408
409        let shard_count = self.config.shard_count;
410        let total = data.len();
411
412        // Build per-shard buckets
413        let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); shard_count];
414        for (i, &v) in data.iter().enumerate() {
415            let s = match self.config.strategy {
416                ShardingStrategy::RoundRobin => RoundRobinSharding::shard_for(i, shard_count),
417                ShardingStrategy::Hash => HashSharding::shard_for(i, shard_count),
418                ShardingStrategy::Range => RangeSharding::shard_for(i, shard_count, total),
419            };
420            buckets[s].push(v);
421        }
422
423        let written: Arc<Mutex<Vec<(usize, Result<PathBuf>)>>> =
424            Arc::new(Mutex::new(Vec::with_capacity(shard_count)));
425
426        let handles: Vec<_> = buckets
427            .into_iter()
428            .enumerate()
429            .map(|(idx, bucket)| {
430                let path = self.config.shard_path(idx);
431                let written = written.clone();
432                thread::spawn(move || {
433                    let result = write_shard_raw(&path, &bucket).map(|()| path.clone());
434                    let mut guard = written.lock().expect("lock poisoned");
435                    guard.push((idx, result));
436                })
437            })
438            .collect();
439
440        for h in handles {
441            h.join()
442                .map_err(|_| IoError::FileError("Shard writer thread panicked".to_string()))?;
443        }
444
445        let mut guard = written.lock().expect("lock poisoned");
446        guard.sort_by_key(|(idx, _)| *idx);
447        let mut paths = Vec::with_capacity(shard_count);
448        for (_, result) in guard.drain(..) {
449            paths.push(result?);
450        }
451        Ok(paths)
452    }
453}
454
455fn write_shard_raw(path: &Path, data: &[f64]) -> Result<()> {
456    let mut f = std::fs::File::create(path)
457        .map_err(|e| IoError::FileError(format!("Cannot create shard: {e}")))?;
458    for &v in data {
459        f.write_all(&v.to_le_bytes())
460            .map_err(|e| IoError::FileError(format!("Shard write error: {e}")))?;
461    }
462    Ok(())
463}
464
465// ---------------------------------------------------------------------------
466// VirtualConcatenation
467// ---------------------------------------------------------------------------
468
469/// Lazy concatenation of multiple shard / data files.
470///
471/// Files are not loaded until `iter()` or `collect()` is called.
472pub struct VirtualConcatenation {
473    paths: Vec<PathBuf>,
474    element_type: ElementType,
475}
476
477/// Supported element types for virtual concatenation.
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum ElementType {
480    /// 64-bit float (little-endian)
481    F64,
482    /// 32-bit float (little-endian)
483    F32,
484    /// Signed 64-bit integer (little-endian)
485    I64,
486}
487
488impl VirtualConcatenation {
489    /// Create a new virtual concatenation over `paths`.
490    pub fn new(paths: Vec<PathBuf>, element_type: ElementType) -> Self {
491        Self {
492            paths,
493            element_type,
494        }
495    }
496
497    /// Materialise the concatenation into a `Vec<f64>`.
498    ///
499    /// `F32` and `I64` elements are widened to `f64`.
500    pub fn collect_f64(&self) -> Result<Vec<f64>> {
501        let mut out = Vec::new();
502        for path in &self.paths {
503            let mut f = std::fs::File::open(path)
504                .map_err(|_| IoError::NotFound(format!("File not found: {}", path.display())))?;
505            let mut buf = Vec::new();
506            f.read_to_end(&mut buf)
507                .map_err(|e| IoError::FileError(format!("Read error: {e}")))?;
508            match self.element_type {
509                ElementType::F64 => {
510                    for b in buf.chunks_exact(8) {
511                        let arr: [u8; 8] = b.try_into().unwrap_or([0u8; 8]);
512                        out.push(f64::from_le_bytes(arr));
513                    }
514                }
515                ElementType::F32 => {
516                    for b in buf.chunks_exact(4) {
517                        let arr: [u8; 4] = b.try_into().unwrap_or([0u8; 4]);
518                        out.push(f32::from_le_bytes(arr) as f64);
519                    }
520                }
521                ElementType::I64 => {
522                    for b in buf.chunks_exact(8) {
523                        let arr: [u8; 8] = b.try_into().unwrap_or([0u8; 8]);
524                        out.push(i64::from_le_bytes(arr) as f64);
525                    }
526                }
527            }
528        }
529        Ok(out)
530    }
531
532    /// Return total number of elements (requires reading metadata / file sizes).
533    pub fn estimated_len(&self) -> usize {
534        let bytes_per = match self.element_type {
535            ElementType::F64 | ElementType::I64 => 8,
536            ElementType::F32 => 4,
537        };
538        self.paths
539            .iter()
540            .filter_map(|p| std::fs::metadata(p).ok())
541            .map(|m| m.len() as usize / bytes_per)
542            .sum()
543    }
544
545    /// Number of source files.
546    pub fn shard_count(&self) -> usize {
547        self.paths.len()
548    }
549}
550
551// ---------------------------------------------------------------------------
552// Tests
553// ---------------------------------------------------------------------------
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use std::env::temp_dir;
559    use uuid::Uuid;
560
561    fn temp_config(strategy: ShardingStrategy) -> ShardConfig {
562        let dir = temp_dir().join(format!("scirs2_shard_{}", Uuid::new_v4()));
563        ShardConfig::new(&dir, 4).with_strategy(strategy)
564    }
565
566    #[test]
567    fn test_range_sharding() {
568        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
569        let config = temp_config(ShardingStrategy::Range);
570        let mut sa = ShardedArray::new(config.clone());
571        let meta = sa.write(&data).unwrap();
572        assert_eq!(meta.total_elements, 100);
573        assert_eq!(meta.shard_sizes.iter().sum::<usize>(), 100);
574        let loaded = sa.read().expect("range sharded read should succeed");
575        assert_eq!(loaded, data);
576        let _ = std::fs::remove_dir_all(&config.base_dir);
577    }
578
579    #[test]
580    fn test_round_robin_sharding() {
581        let data: Vec<f64> = (0..20).map(|i| i as f64).collect();
582        let config = temp_config(ShardingStrategy::RoundRobin);
583        let mut sa = ShardedArray::new(config.clone());
584        sa.write(&data).unwrap();
585        let loaded = sa.read().expect("round-robin sharded read should succeed");
586        assert_eq!(loaded, data);
587        let _ = std::fs::remove_dir_all(&config.base_dir);
588    }
589
590    #[test]
591    fn test_hash_sharding() {
592        let data: Vec<f64> = (0..20).map(|i| i as f64 * 2.0).collect();
593        let config = temp_config(ShardingStrategy::Hash);
594        let mut sa = ShardedArray::new(config.clone());
595        sa.write(&data).unwrap();
596        let loaded = sa.read().expect("hash sharded read should succeed");
597        assert_eq!(loaded, data);
598        let _ = std::fs::remove_dir_all(&config.base_dir);
599    }
600
601    #[test]
602    fn test_shard_writer_and_reader() {
603        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
604        let config = temp_config(ShardingStrategy::Range);
605        let writer = ShardWriter::new(config.clone());
606        let paths = writer.write(&data).unwrap();
607        assert_eq!(paths.len(), 4);
608        let reader = ShardReader::new(config.clone());
609        let merged = reader.read_all().unwrap();
610        assert_eq!(merged, data);
611        let _ = std::fs::remove_dir_all(&config.base_dir);
612    }
613
614    #[test]
615    fn test_virtual_concatenation() {
616        let dir = temp_dir().join(format!("scirs2_vc_{}", Uuid::new_v4()));
617        std::fs::create_dir_all(&dir).unwrap();
618
619        let mut paths = Vec::new();
620        for i in 0..3usize {
621            let p = dir.join(format!("part_{i}.bin"));
622            let mut f = std::fs::File::create(&p).unwrap();
623            for v in (i * 10..(i + 1) * 10).map(|x| (x as f64).to_le_bytes()) {
624                f.write_all(&v).unwrap();
625            }
626            paths.push(p);
627        }
628
629        let vc = VirtualConcatenation::new(paths, ElementType::F64);
630        let data = vc.collect_f64().unwrap();
631        assert_eq!(data.len(), 30);
632        assert_eq!(data[0], 0.0);
633        assert_eq!(data[29], 29.0);
634        assert_eq!(vc.estimated_len(), 30);
635        let _ = std::fs::remove_dir_all(&dir);
636    }
637}