1use 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
19pub const DEFAULT_N_SHARDS: u32 = 64;
21
22const 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
31pub struct ShardedConCorpus {
33 root: PathBuf,
34 n_shards: u32,
35 shards: Vec<Option<ConCorpus>>,
37}
38
39impl ShardedConCorpus {
40 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 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 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 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 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 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 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 let db = ShardedConCorpus::open_shard(root.as_path(), sid).unwrap();
228 let traj = u64::from(sid); 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 #[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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub enum CorpusExportKind {
314 ShardedLmdb,
316 SingleEnvLmdb,
318 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 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 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 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
415pub 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 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}