Skip to main content

readcon_db/
shard.rs

1//! HPC multi-writer: one LMDB env **per shard** so writers do not serialize on a single
2//! write_txn. Route `traj_id % n_shards` (or explicit `writer_id`) to a shard directory.
3//!
4//! Millions of ranks: assign `shard = rank % n_shards` or use traj_id space partitioned by
5//! site; each rank opens **only its shard** for append. Global select fans out across shards.
6//!
7//! This is **not** multi-writer inside one LMDB env (impossible). It is **partitioned writers**,
8//! the standard embedded pattern for high write concurrency on one filesystem.
9
10use std::path::{Path, PathBuf};
11
12use readcon_core::types::ConFrame;
13
14use crate::corpus::ConCorpus;
15use crate::error::{Error, Result};
16use crate::keys::{FrameKey, TrajId};
17use crate::select::Select;
18
19/// Default shard count for HPC campaign roots (power of two aids routing).
20pub const DEFAULT_N_SHARDS: u32 = 64;
21
22/// Manifest file in the corpus root describing shard layout.
23const MANIFEST: &str = "shards.json";
24
25#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
26pub struct ShardManifest {
27    pub n_shards: u32,
28    pub version: u32,
29}
30
31/// Multi-shard campaign corpus: `root/shard_XXXX/` each holds an independent `ConCorpus`.
32pub struct ShardedConCorpus {
33    root: PathBuf,
34    n_shards: u32,
35    /// Lazily opened shards (only those touched). Avoid opening all 10^6 writers' shards in one process.
36    shards: Vec<Option<ConCorpus>>,
37}
38
39impl ShardedConCorpus {
40    /// Create or open a sharded root. If manifest missing, writes one with `n_shards`.
41    pub fn open(root: impl AsRef<Path>, n_shards: u32) -> Result<Self> {
42        let root = root.as_ref().to_path_buf();
43        std::fs::create_dir_all(&root)?;
44        let manifest_path = root.join(MANIFEST);
45        let n_shards = if manifest_path.is_file() {
46            let s = std::fs::read_to_string(&manifest_path)?;
47            let m: ShardManifest = serde_json::from_str(&s)?;
48            m.n_shards
49        } else {
50            if n_shards == 0 {
51                return Err(Error::Message("n_shards must be >= 1".into()));
52            }
53            let m = ShardManifest {
54                n_shards,
55                version: 1,
56            };
57            std::fs::write(&manifest_path, serde_json::to_string_pretty(&m)?)?;
58            n_shards
59        };
60        let mut shards = Vec::with_capacity(n_shards as usize);
61        shards.resize_with(n_shards as usize, || None);
62        Ok(Self {
63            root,
64            n_shards,
65            shards,
66        })
67    }
68
69    pub fn n_shards(&self) -> u32 {
70        self.n_shards
71    }
72
73    pub fn root(&self) -> &Path {
74        &self.root
75    }
76
77    #[inline]
78    pub fn shard_for_traj(traj_id: TrajId, n_shards: u32) -> u32 {
79        (traj_id % u64::from(n_shards)) as u32
80    }
81
82    fn shard_path(&self, shard_id: u32) -> PathBuf {
83        self.root
84            .join(format!("shard_{shard_id:04}"))
85    }
86
87    /// Open one shard env (creates dir). Safe for many processes to open **different** shards.
88    pub fn shard_mut(&mut self, shard_id: u32) -> Result<&ConCorpus> {
89        if shard_id >= self.n_shards {
90            return Err(Error::Message(format!(
91                "shard_id {shard_id} >= n_shards {}",
92                self.n_shards
93            )));
94        }
95        let i = shard_id as usize;
96        if self.shards[i].is_none() {
97            let p = self.shard_path(shard_id);
98            self.shards[i] = Some(ConCorpus::open(p)?);
99        }
100        Ok(self.shards[i].as_ref().unwrap())
101    }
102
103    /// Open only the shard for `traj_id` (HPC rank typically owns one shard).
104    pub fn open_shard_for_traj(root: impl AsRef<Path>, traj_id: TrajId) -> Result<(u32, ConCorpus)> {
105        let root = root.as_ref();
106        let manifest_path = root.join(MANIFEST);
107        let n_shards = if manifest_path.is_file() {
108            let m: ShardManifest = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
109            m.n_shards
110        } else {
111            DEFAULT_N_SHARDS
112        };
113        let sid = Self::shard_for_traj(traj_id, n_shards);
114        let corpus = ConCorpus::open(root.join(format!("shard_{sid:04}")))?;
115        Ok((sid, corpus))
116    }
117
118    /// Open a **single** shard by id (rank `r` uses `open_shard(root, r % n)`).
119    pub fn open_shard(root: impl AsRef<Path>, shard_id: u32) -> Result<ConCorpus> {
120        let root = root.as_ref();
121        let manifest_path = root.join(MANIFEST);
122        let n_shards = if manifest_path.is_file() {
123            let m: ShardManifest = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
124            m.n_shards
125        } else {
126            // Ensure manifest exists for readers.
127            let _ = Self::open(root, DEFAULT_N_SHARDS)?;
128            DEFAULT_N_SHARDS
129        };
130        if shard_id >= n_shards {
131            return Err(Error::Message(format!(
132                "shard_id {shard_id} >= n_shards {n_shards}"
133            )));
134        }
135        ConCorpus::open(root.join(format!("shard_{shard_id:04}")))
136    }
137
138    pub fn append_trajectory_path(
139        &mut self,
140        traj_id: TrajId,
141        file: impl AsRef<Path>,
142    ) -> Result<u32> {
143        let sid = Self::shard_for_traj(traj_id, self.n_shards);
144        let c = self.shard_mut(sid)?;
145        c.append_trajectory_path(traj_id, file)
146    }
147
148    pub fn append_trajectory_str(
149        &mut self,
150        traj_id: TrajId,
151        contents: &str,
152        source: impl Into<String>,
153    ) -> Result<u32> {
154        let sid = Self::shard_for_traj(traj_id, self.n_shards);
155        let c = self.shard_mut(sid)?;
156        c.append_trajectory_str(traj_id, contents, source)
157    }
158
159    pub fn append_trajectory_frames(
160        &mut self,
161        traj_id: TrajId,
162        frames: &[ConFrame],
163        source: impl Into<String>,
164    ) -> Result<u32> {
165        let sid = Self::shard_for_traj(traj_id, self.n_shards);
166        let c = self.shard_mut(sid)?;
167        c.append_trajectory_frames(traj_id, frames, source)
168    }
169
170    /// Fan-out select across all shards (read-only; opens missing shards).
171    pub fn select(&mut self, sel: &Select) -> Result<Vec<FrameKey>> {
172        let mut out = Vec::new();
173        for sid in 0..self.n_shards {
174            let c = self.shard_mut(sid)?;
175            out.extend(c.select(sel)?);
176        }
177        out.sort();
178        if let Some(lim) = sel.limit {
179            out.truncate(lim);
180        }
181        Ok(out)
182    }
183
184    pub fn get_frame_text(&mut self, key: FrameKey) -> Result<String> {
185        let sid = Self::shard_for_traj(key.traj_id, self.n_shards);
186        self.shard_mut(sid)?.get_frame_text(key)
187    }
188
189    pub fn reindex_all(&mut self) -> Result<u32> {
190        let mut n = 0u32;
191        for sid in 0..self.n_shards {
192            if self.shard_path(sid).is_dir() {
193                n += self.shard_mut(sid)?.reindex()?;
194            }
195        }
196        Ok(n)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::sync::Arc;
204    use std::thread;
205
206    fn fixture(name: &str) -> PathBuf {
207        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
208            .join("resources/test")
209            .join(name)
210    }
211
212    #[test]
213    fn parallel_writers_different_shards() {
214        let dir = tempfile::tempdir().unwrap();
215        let root = dir.path().join("hpc");
216        // 8 shards, 8 threads each write traj_id == shard so zero writer contention across envs.
217        let n_shards = 8u32;
218        ShardedConCorpus::open(&root, n_shards).unwrap();
219        let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
220        let root = Arc::new(root);
221        let mut joins = Vec::new();
222        for sid in 0..n_shards {
223            let root = Arc::clone(&root);
224            let text = text.clone();
225            joins.push(thread::spawn(move || {
226                // Each writer opens **only its shard** (HPC rank pattern).
227                let db = ShardedConCorpus::open_shard(root.as_path(), sid).unwrap();
228                let traj = u64::from(sid); // maps to this shard
229                db.append_trajectory_str(traj, &text, format!("shard{sid}"))
230                    .unwrap()
231            }));
232        }
233        let mut ns = Vec::new();
234        for j in joins {
235            ns.push(j.join().unwrap());
236        }
237        assert!(ns.iter().all(|&n| n >= 1));
238        let mut fan = ShardedConCorpus::open(root.as_path(), n_shards).unwrap();
239        let keys = fan.select(&Select::new().require_symbol("Cu")).unwrap();
240        assert_eq!(keys.len(), 8);
241    }
242
243    #[test]
244    fn traj_routing_stable() {
245        assert_eq!(ShardedConCorpus::shard_for_traj(0, 64), 0);
246        assert_eq!(ShardedConCorpus::shard_for_traj(65, 64), 1);
247    }
248
249    /// Strong-scaling HPC story: concurrent writers on distinct shards, then
250    /// fan-out select agrees with a **single-env** corpus that ingested the
251    /// same trajectory texts (ground truth membership).
252    #[test]
253    fn multi_shard_writers_select_matches_single_env_baseline() {
254        let dir = tempfile::tempdir().unwrap();
255        let root = dir.path().join("hpc_scale");
256        let baseline = dir.path().join("single_env");
257        let n_shards = 4u32;
258        ShardedConCorpus::open(&root, n_shards).unwrap();
259        let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
260        let root_a = Arc::new(root.clone());
261        let mut joins = Vec::new();
262        for sid in 0..n_shards {
263            let root = Arc::clone(&root_a);
264            let text = text.clone();
265            joins.push(thread::spawn(move || {
266                let db = ShardedConCorpus::open_shard(root.as_path(), sid).unwrap();
267                let traj = u64::from(sid);
268                db.append_trajectory_str(traj, &text, format!("s{sid}"))
269                    .unwrap()
270            }));
271        }
272        let mut frames_per_traj = Vec::new();
273        for j in joins {
274            frames_per_traj.push(j.join().unwrap());
275        }
276        assert!(frames_per_traj.iter().all(|&n| n >= 1));
277
278        // Single-env ground truth: same traj_ids and CON text.
279        let single = ConCorpus::open(&baseline).unwrap();
280        for sid in 0..n_shards {
281            let traj = u64::from(sid);
282            let n = single
283                .append_trajectory_str(traj, &text, format!("s{sid}"))
284                .unwrap();
285            assert_eq!(n, frames_per_traj[sid as usize]);
286        }
287
288        let mut fan = ShardedConCorpus::open(&root, n_shards).unwrap();
289        let sharded_keys = fan.select(&Select::new().require_symbol("Cu")).unwrap();
290        let base_keys = single.select(&Select::new().require_symbol("Cu")).unwrap();
291        assert_eq!(sharded_keys.len(), base_keys.len());
292        let mut sk: Vec<_> = sharded_keys.iter().map(|k| (k.traj_id, k.frame_idx)).collect();
293        let mut bk: Vec<_> = base_keys.iter().map(|k| (k.traj_id, k.frame_idx)).collect();
294        sk.sort_unstable();
295        bk.sort_unstable();
296        assert_eq!(sk, bk, "fan-out select must match single-env membership");
297
298        // Spot-check text blobs agree for each key.
299        for (tid, fidx) in &sk {
300            let key = crate::keys::FrameKey {
301                traj_id: *tid,
302                frame_idx: *fidx,
303            };
304            let a = fan.get_frame_text(key).unwrap();
305            let b = single.get_frame_text(key).unwrap();
306            assert_eq!(a, b);
307        }
308    }
309}
310
311/// Exportable corpus layout kinds for analysis handoff.
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub enum CorpusExportKind {
314    /// Full sharded root (`shards.json` + `shard_XXXX/`).
315    ShardedLmdb,
316    /// Single-env LMDB directory (one `ConCorpus::open` path).
317    SingleEnvLmdb,
318    /// Filtered extXYZ for external tools (non-LMDB).
319    ExtXyz,
320}
321
322impl CorpusExportKind {
323    pub fn as_str(self) -> &'static str {
324        match self {
325            Self::ShardedLmdb => "sharded-lmdb",
326            Self::SingleEnvLmdb => "single-env-lmdb",
327            Self::ExtXyz => "extxyz",
328        }
329    }
330}
331
332impl ShardedConCorpus {
333    /// **Join:** copy all frames from every shard into a **new single-env** corpus at `dst`
334    /// (traj_id preserved; collision if same traj_id appears on two shards is an error).
335    /// Secondary indexes built via normal append/prepare on each blob. Reversible with
336    /// [`Self::split_single_to_sharded`] using the same `n_shards` and traj routing.
337    pub fn join_to_single_env(&mut self, dst: impl AsRef<Path>) -> Result<u32> {
338        let dst = dst.as_ref();
339        if dst.exists() {
340            std::fs::remove_dir_all(dst).ok();
341        }
342        let out = ConCorpus::open(dst)?;
343        let mut n = 0u32;
344        let mut seen_traj = std::collections::BTreeSet::new();
345        for sid in 0..self.n_shards {
346            if !self.shard_path(sid).is_dir() {
347                continue;
348            }
349            let shard = self.shard_mut(sid)?;
350            for fk in shard.list_frame_keys()? {
351                if fk.frame_idx == 0 {
352                    if !seen_traj.insert(fk.traj_id) {
353                        return Err(Error::Message(format!(
354                            "traj_id {} appears in multiple shards; cannot join without remap",
355                            fk.traj_id
356                        )));
357                    }
358                }
359            }
360            // second pass: append full trajectories by concatenating blobs in order
361            let keys = shard.list_frame_keys()?;
362            let mut by_traj: std::collections::BTreeMap<u64, Vec<FrameKey>> =
363                std::collections::BTreeMap::new();
364            for fk in keys {
365                by_traj.entry(fk.traj_id).or_default().push(fk);
366            }
367            for (tid, mut fks) in by_traj {
368                fks.sort();
369                let mut concat = String::new();
370                for fk in &fks {
371                    concat.push_str(&shard.get_frame_text(*fk)?);
372                }
373                let nf = out.append_trajectory_str(tid, &concat, format!("join-from-shard-{sid}"))?;
374                n += nf;
375            }
376        }
377        Ok(n)
378    }
379
380    /// **Split:** read a **single-env** corpus and write a new sharded root at `dst_root`
381    /// with `n_shards` (rewrites manifest). Traj_id preserved; routing is `traj_id % n_shards`.
382    pub fn split_single_to_sharded(
383        single: &ConCorpus,
384        dst_root: impl AsRef<Path>,
385        n_shards: u32,
386    ) -> Result<u32> {
387        if n_shards == 0 {
388            return Err(Error::Message("n_shards must be >= 1".into()));
389        }
390        let dst_root = dst_root.as_ref();
391        if dst_root.exists() {
392            std::fs::remove_dir_all(dst_root).ok();
393        }
394        let mut sharded = ShardedConCorpus::open(dst_root, n_shards)?;
395        let keys = single.list_frame_keys()?;
396        let mut by_traj: std::collections::BTreeMap<u64, Vec<FrameKey>> =
397            std::collections::BTreeMap::new();
398        for fk in keys {
399            by_traj.entry(fk.traj_id).or_default().push(fk);
400        }
401        let mut n = 0u32;
402        for (tid, mut fks) in by_traj {
403            fks.sort();
404            let mut concat = String::new();
405            for fk in &fks {
406                concat.push_str(&single.get_frame_text(*fk)?);
407            }
408            let nf = sharded.append_trajectory_str(tid, &concat, "split-from-single")?;
409            n += nf;
410        }
411        Ok(n)
412    }
413}
414
415/// Join any set of **single-env** corpus directories into one destination (traj_id must be unique).
416pub fn join_corpus_dirs(sources: &[PathBuf], dst: impl AsRef<Path>) -> Result<u32> {
417    let dst = dst.as_ref();
418    if dst.exists() {
419        std::fs::remove_dir_all(dst).ok();
420    }
421    let out = ConCorpus::open(dst)?;
422    let mut n = 0u32;
423    let mut seen = std::collections::BTreeSet::new();
424    for src in sources {
425        let c = ConCorpus::open(src)?;
426        let keys = c.list_frame_keys()?;
427        let mut by_traj: std::collections::BTreeMap<u64, Vec<FrameKey>> =
428            std::collections::BTreeMap::new();
429        for fk in keys {
430            by_traj.entry(fk.traj_id).or_default().push(fk);
431        }
432        for (tid, mut fks) in by_traj {
433            if !seen.insert(tid) {
434                return Err(Error::Message(format!(
435                    "duplicate traj_id {tid} across join sources"
436                )));
437            }
438            fks.sort();
439            let mut concat = String::new();
440            for fk in &fks {
441                concat.push_str(&c.get_frame_text(*fk)?);
442            }
443            n += out.append_trajectory_str(tid, &concat, src.display().to_string())?;
444        }
445    }
446    Ok(n)
447}
448
449#[cfg(test)]
450mod compaction_tests {
451    use super::*;
452    use crate::select::Select;
453
454    fn fixture(name: &str) -> PathBuf {
455        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
456            .join("resources/test")
457            .join(name)
458    }
459
460    #[test]
461    fn join_split_reversible_membership() {
462        let dir = tempfile::tempdir().unwrap();
463        let sharded_root = dir.path().join("sharded");
464        let con_text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
465        {
466            let mut s = ShardedConCorpus::open(&sharded_root, 4).unwrap();
467            for tid in [0u64, 1, 2, 3] {
468                s.append_trajectory_str(tid, &con_text, "t").unwrap();
469            }
470        }
471        let mut s = ShardedConCorpus::open(&sharded_root, 4).unwrap();
472        let before = s.select(&Select::new()).unwrap();
473        assert_eq!(before.len(), 4);
474
475        let joined = dir.path().join("joined");
476        let n = s.join_to_single_env(&joined).unwrap();
477        assert_eq!(n, 4);
478        let joined_c = ConCorpus::open(&joined).unwrap();
479        let mid = joined_c.select(&Select::new()).unwrap();
480        assert_eq!(mid.len(), 4);
481
482        let split_root = dir.path().join("split_again");
483        let n2 = ShardedConCorpus::split_single_to_sharded(&joined_c, &split_root, 4).unwrap();
484        assert_eq!(n2, 4);
485        let mut s2 = ShardedConCorpus::open(&split_root, 4).unwrap();
486        let after = s2.select(&Select::new()).unwrap();
487        assert_eq!(after.len(), before.len());
488        // same traj set
489        let mut bt: Vec<_> = before.iter().map(|k| k.traj_id).collect();
490        let mut at: Vec<_> = after.iter().map(|k| k.traj_id).collect();
491        bt.sort();
492        at.sort();
493        assert_eq!(bt, at);
494    }
495
496    #[test]
497    fn export_kinds_documented() {
498        assert_eq!(CorpusExportKind::ShardedLmdb.as_str(), "sharded-lmdb");
499        assert_eq!(CorpusExportKind::SingleEnvLmdb.as_str(), "single-env-lmdb");
500        assert_eq!(CorpusExportKind::ExtXyz.as_str(), "extxyz");
501    }
502}