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("../readcon-core/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
250/// Exportable corpus layout kinds for analysis handoff.
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub enum CorpusExportKind {
253    /// Full sharded root (`shards.json` + `shard_XXXX/`).
254    ShardedLmdb,
255    /// Single-env LMDB directory (one `ConCorpus::open` path).
256    SingleEnvLmdb,
257    /// Filtered extXYZ for external tools (non-LMDB).
258    ExtXyz,
259}
260
261impl CorpusExportKind {
262    pub fn as_str(self) -> &'static str {
263        match self {
264            Self::ShardedLmdb => "sharded-lmdb",
265            Self::SingleEnvLmdb => "single-env-lmdb",
266            Self::ExtXyz => "extxyz",
267        }
268    }
269}
270
271impl ShardedConCorpus {
272    /// **Join:** copy all frames from every shard into a **new single-env** corpus at `dst`
273    /// (traj_id preserved; collision if same traj_id appears on two shards is an error).
274    /// Secondary indexes built via normal append/prepare on each blob. Reversible with
275    /// [`Self::split_single_to_sharded`] using the same `n_shards` and traj routing.
276    pub fn join_to_single_env(&mut self, dst: impl AsRef<Path>) -> Result<u32> {
277        let dst = dst.as_ref();
278        if dst.exists() {
279            std::fs::remove_dir_all(dst).ok();
280        }
281        let out = ConCorpus::open(dst)?;
282        let mut n = 0u32;
283        let mut seen_traj = std::collections::BTreeSet::new();
284        for sid in 0..self.n_shards {
285            if !self.shard_path(sid).is_dir() {
286                continue;
287            }
288            let shard = self.shard_mut(sid)?;
289            for fk in shard.list_frame_keys()? {
290                if fk.frame_idx == 0 {
291                    if !seen_traj.insert(fk.traj_id) {
292                        return Err(Error::Message(format!(
293                            "traj_id {} appears in multiple shards; cannot join without remap",
294                            fk.traj_id
295                        )));
296                    }
297                }
298            }
299            // second pass: append full trajectories by concatenating blobs in order
300            let keys = shard.list_frame_keys()?;
301            let mut by_traj: std::collections::BTreeMap<u64, Vec<FrameKey>> =
302                std::collections::BTreeMap::new();
303            for fk in keys {
304                by_traj.entry(fk.traj_id).or_default().push(fk);
305            }
306            for (tid, mut fks) in by_traj {
307                fks.sort();
308                let mut concat = String::new();
309                for fk in &fks {
310                    concat.push_str(&shard.get_frame_text(*fk)?);
311                }
312                let nf = out.append_trajectory_str(tid, &concat, format!("join-from-shard-{sid}"))?;
313                n += nf;
314            }
315        }
316        Ok(n)
317    }
318
319    /// **Split:** read a **single-env** corpus and write a new sharded root at `dst_root`
320    /// with `n_shards` (rewrites manifest). Traj_id preserved; routing is `traj_id % n_shards`.
321    pub fn split_single_to_sharded(
322        single: &ConCorpus,
323        dst_root: impl AsRef<Path>,
324        n_shards: u32,
325    ) -> Result<u32> {
326        if n_shards == 0 {
327            return Err(Error::Message("n_shards must be >= 1".into()));
328        }
329        let dst_root = dst_root.as_ref();
330        if dst_root.exists() {
331            std::fs::remove_dir_all(dst_root).ok();
332        }
333        let mut sharded = ShardedConCorpus::open(dst_root, n_shards)?;
334        let keys = single.list_frame_keys()?;
335        let mut by_traj: std::collections::BTreeMap<u64, Vec<FrameKey>> =
336            std::collections::BTreeMap::new();
337        for fk in keys {
338            by_traj.entry(fk.traj_id).or_default().push(fk);
339        }
340        let mut n = 0u32;
341        for (tid, mut fks) in by_traj {
342            fks.sort();
343            let mut concat = String::new();
344            for fk in &fks {
345                concat.push_str(&single.get_frame_text(*fk)?);
346            }
347            let nf = sharded.append_trajectory_str(tid, &concat, "split-from-single")?;
348            n += nf;
349        }
350        Ok(n)
351    }
352}
353
354/// Join any set of **single-env** corpus directories into one destination (traj_id must be unique).
355pub fn join_corpus_dirs(sources: &[PathBuf], dst: impl AsRef<Path>) -> Result<u32> {
356    let dst = dst.as_ref();
357    if dst.exists() {
358        std::fs::remove_dir_all(dst).ok();
359    }
360    let out = ConCorpus::open(dst)?;
361    let mut n = 0u32;
362    let mut seen = std::collections::BTreeSet::new();
363    for src in sources {
364        let c = ConCorpus::open(src)?;
365        let keys = c.list_frame_keys()?;
366        let mut by_traj: std::collections::BTreeMap<u64, Vec<FrameKey>> =
367            std::collections::BTreeMap::new();
368        for fk in keys {
369            by_traj.entry(fk.traj_id).or_default().push(fk);
370        }
371        for (tid, mut fks) in by_traj {
372            if !seen.insert(tid) {
373                return Err(Error::Message(format!(
374                    "duplicate traj_id {tid} across join sources"
375                )));
376            }
377            fks.sort();
378            let mut concat = String::new();
379            for fk in &fks {
380                concat.push_str(&c.get_frame_text(*fk)?);
381            }
382            n += out.append_trajectory_str(tid, &concat, src.display().to_string())?;
383        }
384    }
385    Ok(n)
386}
387
388#[cfg(test)]
389mod compaction_tests {
390    use super::*;
391    use crate::select::Select;
392
393    fn fixture(name: &str) -> PathBuf {
394        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
395            .join("../readcon-core/resources/test")
396            .join(name)
397    }
398
399    #[test]
400    fn join_split_reversible_membership() {
401        let dir = tempfile::tempdir().unwrap();
402        let sharded_root = dir.path().join("sharded");
403        let con_text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
404        {
405            let mut s = ShardedConCorpus::open(&sharded_root, 4).unwrap();
406            for tid in [0u64, 1, 2, 3] {
407                s.append_trajectory_str(tid, &con_text, "t").unwrap();
408            }
409        }
410        let mut s = ShardedConCorpus::open(&sharded_root, 4).unwrap();
411        let before = s.select(&Select::new()).unwrap();
412        assert_eq!(before.len(), 4);
413
414        let joined = dir.path().join("joined");
415        let n = s.join_to_single_env(&joined).unwrap();
416        assert_eq!(n, 4);
417        let joined_c = ConCorpus::open(&joined).unwrap();
418        let mid = joined_c.select(&Select::new()).unwrap();
419        assert_eq!(mid.len(), 4);
420
421        let split_root = dir.path().join("split_again");
422        let n2 = ShardedConCorpus::split_single_to_sharded(&joined_c, &split_root, 4).unwrap();
423        assert_eq!(n2, 4);
424        let mut s2 = ShardedConCorpus::open(&split_root, 4).unwrap();
425        let after = s2.select(&Select::new()).unwrap();
426        assert_eq!(after.len(), before.len());
427        // same traj set
428        let mut bt: Vec<_> = before.iter().map(|k| k.traj_id).collect();
429        let mut at: Vec<_> = after.iter().map(|k| k.traj_id).collect();
430        bt.sort();
431        at.sort();
432        assert_eq!(bt, at);
433    }
434
435    #[test]
436    fn export_kinds_documented() {
437        assert_eq!(CorpusExportKind::ShardedLmdb.as_str(), "sharded-lmdb");
438        assert_eq!(CorpusExportKind::SingleEnvLmdb.as_str(), "single-env-lmdb");
439        assert_eq!(CorpusExportKind::ExtXyz.as_str(), "extxyz");
440    }
441}