1use crate::compact;
29use crate::error::{Error, Result};
30use crate::format::{base, wal};
31use crate::mmap::MappedFile;
32use crate::overlay::Arena;
33use crate::snapshot::Snapshot;
34use crate::ttl::TtlConfig;
35use crate::writer::{WriteState, Writer};
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
39use std::sync::{Mutex, RwLock};
40
41const GEN_SLOTS: usize = 4;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Durability {
48 Always,
50 Batch(u32),
52 Never,
54}
55
56#[derive(Clone, Copy, Debug)]
58pub struct Config {
59 pub durability: Durability,
61 pub compact_after_ops: usize,
63 pub compact_after_wal_bytes: u64,
65 pub ttl: TtlConfig,
67}
68
69impl Default for Config {
70 fn default() -> Self {
71 Self {
72 durability: Durability::Always,
73 compact_after_ops: 4096,
74 compact_after_wal_bytes: 4 << 20,
75 ttl: TtlConfig::default(),
76 }
77 }
78}
79
80#[derive(Debug)]
82pub(crate) struct GenInner {
83 map: MappedFile,
84 pub(crate) dir: base::BaseDir,
85 pub(crate) arena: Arena,
86}
87
88impl GenInner {
89 pub(crate) fn bytes(&self) -> &[u8] {
90 self.map.bytes()
91 }
92
93 pub(crate) fn from_parts(map: MappedFile, dir: base::BaseDir) -> Self {
94 Self { map, dir, arena: Arena::new() }
95 }
96}
97
98#[derive(Debug)]
99pub(crate) struct GenSlot {
100 pub(crate) seq: AtomicU64,
102 pub(crate) inner: RwLock<Option<GenInner>>,
103}
104
105#[derive(Debug)]
107pub struct Store {
108 pub(crate) dir: PathBuf,
109 pub(crate) cfg: Config,
110 read_only: bool,
111 _lock: Option<LockFile>,
112 pub(crate) current: AtomicUsize,
113 pub(crate) gens: [GenSlot; GEN_SLOTS],
114 pub(crate) write: Mutex<WriteState>,
115 pub(crate) compact_inline: AtomicBool,
117 pub(crate) due: AtomicBool,
119 reload: Mutex<Reload>,
124}
125
126#[derive(Clone, Copy, Debug, Default)]
127struct Reload {
128 stamp: Option<Stamp>,
129 wal_pos: u64,
131}
132
133#[derive(Debug)]
135struct LockFile {
136 _file: fs::File,
137}
138
139impl LockFile {
140 fn acquire(path: &Path) -> Result<Self> {
141 match crate::fsio::try_lock(path)? {
142 Some(file) => Ok(Self { _file: file }),
143 None => Err(Error::Locked),
144 }
145 }
146}
147
148const RACE_RETRIES: usize = 3;
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157struct Stamp {
158 base: FileId,
159 wal_len: u64,
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163struct FileId {
164 len: u64,
165 modified: Option<std::time::SystemTime>,
166 #[cfg(unix)]
167 ino: (u64, u64),
168 #[cfg(not(unix))]
169 created: Option<std::time::SystemTime>,
170}
171
172impl FileId {
173 fn of(md: &fs::Metadata) -> Self {
174 #[cfg(unix)]
175 use std::os::unix::fs::MetadataExt as _;
176 Self {
177 len: md.len(),
178 modified: md.modified().ok(),
179 #[cfg(unix)]
180 ino: (md.dev(), md.ino()),
181 #[cfg(not(unix))]
182 created: md.created().ok(),
183 }
184 }
185}
186
187impl Stamp {
188 fn read(dir: &Path) -> Option<Self> {
191 let base = FileId::of(&fs::metadata(base_path(dir)).ok()?);
192 let wal_len = match fs::metadata(wal_path(dir)) {
193 Ok(md) => md.len(),
194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
195 Err(_) => return None,
196 };
197 Some(Self { base, wal_len })
198 }
199}
200
201pub const BASE_FILE: &str = "graph.base";
203pub const WAL_FILE: &str = "graph.wal";
205pub const LOCK_FILE: &str = "graph.lock";
207
208pub(crate) fn base_path(dir: &Path) -> PathBuf {
210 dir.join(BASE_FILE)
211}
212pub(crate) fn wal_path(dir: &Path) -> PathBuf {
213 dir.join(WAL_FILE)
214}
215fn lock_path(dir: &Path) -> PathBuf {
216 dir.join(LOCK_FILE)
217}
218
219use crate::fsio::now_ms;
221
222pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
223 crate::fsio::write_atomic(path, &[bytes]).map_err(Into::into)
224}
225
226impl Store {
227 pub fn create(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
230 let dir = dir.as_ref();
231 fs::create_dir_all(dir)?;
232 if base_path(dir).exists() {
233 return Err(Error::format("store already exists; use open"));
234 }
235 let empty = compact::render_empty_base(1);
236 atomic_write(&base_path(dir), &empty)?;
237 atomic_write(&wal_path(dir), &wal::encode_header(1, now_ms()))?;
238 Self::open_with(dir, cfg, false)
239 }
240
241 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
245 Self::open_with(dir.as_ref(), Config::default(), false)
246 }
247
248 pub fn open_cfg(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
250 Self::open_with(dir.as_ref(), cfg, false)
251 }
252
253 pub fn open_read_only(dir: impl AsRef<Path>) -> Result<Self> {
257 Self::open_with(dir.as_ref(), Config::default(), true)
258 }
259
260 fn open_with(dir: &Path, cfg: Config, read_only: bool) -> Result<Self> {
261 let lock = if read_only { None } else { Some(LockFile::acquire(&lock_path(dir))?) };
262 let stamp = Stamp::read(dir);
263 let built = load_generation(dir, read_only)?;
264 Ok(Self::assemble(dir.to_path_buf(), cfg, read_only, lock, stamp, built))
265 }
266
267 pub fn open_image(base: bytes::Bytes, wal: &[u8]) -> Result<Self> {
274 let built = build_generation(MappedFile::from_bytes(base), wal)?;
275 if built.log == LogState::Newer {
276 return Err(newer_image());
277 }
278 Ok(Self::assemble(PathBuf::new(), Config::default(), true, None, None, built))
279 }
280
281 pub fn reload_image(&self, base: Option<bytes::Bytes>, wal: &[u8]) -> Result<bool> {
286 if !self.read_only {
287 return Ok(false);
288 }
289 let _one_at_a_time = self.reload.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
290 match base {
291 Some(b) => {
292 self.install_or_apply(MappedFile::from_bytes(b), wal)?.ok_or_else(newer_image)
293 }
294 None => match self.apply_wal_bytes(wal)? {
295 Some(changed) => Ok(changed),
296 None => {
297 Err(Error::format("log image belongs to another generation; pass its base"))
298 }
299 },
300 }
301 }
302
303 #[must_use]
305 pub fn is_image(&self) -> bool {
306 self.dir.as_os_str().is_empty()
307 }
308
309 fn assemble(
310 dir: PathBuf,
311 cfg: Config,
312 read_only: bool,
313 lock: Option<LockFile>,
314 stamp: Option<Stamp>,
315 built: Built,
316 ) -> Self {
317 let generation = built.inner.dir.header.generation;
318 let reload = Reload { stamp, wal_pos: built.good_len as u64 };
319 let store = Self {
320 dir,
321 cfg,
322 read_only,
323 _lock: lock,
324 current: AtomicUsize::new(0),
325 gens: std::array::from_fn(|_| GenSlot {
326 seq: AtomicU64::new(0),
327 inner: RwLock::new(None),
328 }),
329 write: Mutex::new(WriteState::new(
330 built.next_seq,
331 built.replayed,
332 built.good_len as u64,
333 generation,
334 )),
335 compact_inline: AtomicBool::new(true),
336 due: AtomicBool::new(false),
337 reload: Mutex::new(reload),
338 };
339 *store.gens[0].inner.write().expect("fresh lock") = Some(built.inner);
340 store.gens[0].seq.store(generation, Ordering::Release);
341 store
342 }
343
344 #[must_use]
347 pub fn len(&self) -> usize {
348 self.snapshot().len()
349 }
350
351 #[must_use]
353 pub fn is_empty(&self) -> bool {
354 self.len() == 0
355 }
356
357 #[must_use]
359 pub fn dir(&self) -> &Path {
360 &self.dir
361 }
362
363 #[must_use]
366 pub fn generation(&self) -> u64 {
367 self.gens[self.current.load(Ordering::Acquire)].seq.load(Ordering::Acquire)
368 }
369
370 pub fn set_compact_inline(&self, inline: bool) {
376 self.compact_inline.store(inline, Ordering::Relaxed);
377 }
378
379 #[must_use]
382 pub fn compaction_due(&self) -> bool {
383 self.due.load(Ordering::Acquire)
384 }
385
386 #[must_use]
389 pub fn snapshot(&self) -> Snapshot<'_> {
390 loop {
391 let cur = self.current.load(Ordering::Acquire);
392 if let Ok(guard) = self.gens[cur].inner.try_read() {
393 if guard.is_some() && self.current.load(Ordering::Acquire) == cur {
394 return Snapshot::new(self, guard);
395 }
396 }
397 std::hint::spin_loop();
398 }
399 }
400
401 pub fn writer(&self) -> Result<Writer<'_>> {
408 if self.read_only {
409 return Err(Error::ReadOnly);
410 }
411 let state = self.write.lock().map_err(|_| Error::corrupt("writer mutex poisoned"))?;
412 Ok(Writer::new(self, state))
413 }
414
415 pub fn compact(&self) -> Result<compact::CompactStats> {
418 if self.read_only {
419 return Err(Error::ReadOnly);
420 }
421 let mut state = self.write.lock().map_err(|_| Error::corrupt("writer mutex poisoned"))?;
422 compact::compact_locked(self, &mut state)
423 }
424
425 pub fn reload_if_stale(&self) -> Result<bool> {
434 if self.is_image() || !self.read_only {
435 return Ok(false); }
437 let now = Stamp::read(&self.dir);
438 let mut st = self.reload.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
439 if let (Some(n), Some(l)) = (now, st.stamp) {
440 if n == l {
441 return Ok(false);
442 }
443 if n.base == l.base && n.wal_len >= st.wal_pos && st.wal_pos >= wal::HEADER_LEN as u64 {
445 let (changed, pos) = self.reload_wal_tail(st.wal_pos)?;
446 *st = Reload { stamp: now, wal_pos: pos };
447 return Ok(changed);
448 }
449 }
450 let (changed, pos) = self.reload_full()?;
451 *st = Reload { stamp: now, wal_pos: pos };
452 Ok(changed)
453 }
454
455 fn reload_wal_tail(&self, pos: u64) -> Result<(bool, u64)> {
459 use std::io::{Read as _, Seek as _, SeekFrom};
460 let mut tail = Vec::new();
461 match fs::File::open(wal_path(&self.dir)) {
462 Ok(mut f) => {
463 f.seek(SeekFrom::Start(pos))?;
464 f.read_to_end(&mut tail)?;
465 }
466 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((false, pos)),
467 Err(e) => return Err(e.into()),
468 }
469 let (ops, used) = wal::replay_frames(&tail)?;
470 Ok((self.publish_new(ops)?, pos + used as u64))
471 }
472
473 fn publish_new(&self, ops: Vec<(u64, wal::Op)>) -> Result<bool> {
476 let cur = self.current.load(Ordering::Acquire);
477 let guard = self.gens[cur].inner.try_read().map_err(|_| Error::busy("slot busy"))?;
478 let Some(g) = guard.as_ref() else { return Ok(false) };
479 let seen = g.dir.header.wal_applied_seq.max(g.arena.last_seq());
480 let mut changed = false;
481 for (seq, op) in ops.into_iter().filter(|&(s, _)| s > seen) {
482 if !g.arena.publish(seq, op) {
483 return Err(Error::busy("overlay arena full"));
484 }
485 changed = true;
486 }
487 Ok(changed)
488 }
489
490 fn apply_wal_bytes(&self, raw: &[u8]) -> Result<Option<bool>> {
493 if raw.is_empty() {
494 return Ok(Some(false));
495 }
496 let replayed = wal::replay(raw)?;
497 if replayed.base_generation != self.generation() {
498 return Ok(None);
499 }
500 self.publish_new(replayed.ops).map(Some)
501 }
502
503 fn reload_full(&self) -> Result<(bool, u64)> {
507 for _ in 0..RACE_RETRIES {
508 let map = MappedFile::open(&base_path(&self.dir))?;
509 let raw = match fs::read(wal_path(&self.dir)) {
510 Ok(raw) => raw,
511 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
512 Err(e) => return Err(e.into()),
513 };
514 if let Some(changed) = self.install_or_apply(map, &raw)? {
515 return Ok((changed, wal::committed_len(&raw) as u64));
516 }
517 }
518 Err(Error::busy("compaction in progress; retry"))
519 }
520
521 fn install_or_apply(&self, map: MappedFile, raw: &[u8]) -> Result<Option<bool>> {
526 let cur_gen = self.generation();
527 let new_gen = base::Header::parse(map.bytes())?.generation;
528 if new_gen == cur_gen {
529 return match self.apply_wal_bytes(raw)? {
532 Some(changed) => Ok(Some(changed)),
533 None if wal::replay(raw)?.base_generation > new_gen => Ok(None),
536 None => Ok(Some(false)),
537 };
538 }
539 let built = build_generation(map, raw)?;
540 if built.log == LogState::Newer {
541 return Ok(None);
542 }
543 let cur = self.current.load(Ordering::Acquire);
544 for (i, slot) in self.gens.iter().enumerate() {
545 if i == cur {
546 continue;
547 }
548 if let Ok(mut w) = slot.inner.try_write() {
549 *w = Some(built.inner);
550 drop(w);
551 slot.seq.store(new_gen, Ordering::Release);
552 self.current.store(i, Ordering::Release);
553 return Ok(Some(true));
554 }
555 }
556 Err(Error::busy("all generation slots pinned by snapshots"))
557 }
558}
559
560fn newer_image() -> Error {
561 Error::busy("log image is newer than its base; fetch the base again")
562}
563
564struct Built {
567 inner: GenInner,
568 next_seq: u64,
569 replayed: usize,
570 good_len: usize,
571 log: LogState,
572}
573
574#[derive(Clone, Copy, PartialEq, Eq)]
576enum LogState {
577 Empty,
579 Stale,
581 Current { torn: bool },
583 Newer,
586}
587
588fn build_generation(map: MappedFile, raw: &[u8]) -> Result<Built> {
591 let bdir = base::BaseDir::parse(map.bytes())?;
592 let header = bdir.header;
593 let inner = GenInner { map, dir: bdir, arena: Arena::new() };
594 if raw.is_empty() {
595 return Ok(Built {
596 inner,
597 next_seq: header.wal_applied_seq + 1,
598 replayed: 0,
599 good_len: 0,
600 log: LogState::Empty,
601 });
602 }
603 let replayed = wal::replay(raw)?;
604 if replayed.base_generation > header.generation {
605 return Ok(Built {
606 inner,
607 next_seq: header.wal_applied_seq + 1,
608 replayed: 0,
609 good_len: 0,
610 log: LogState::Newer,
611 });
612 }
613 if replayed.base_generation < header.generation {
614 return Ok(Built {
615 inner,
616 next_seq: header.wal_applied_seq + 1,
617 replayed: 0,
618 good_len: wal::HEADER_LEN,
619 log: LogState::Stale,
620 });
621 }
622 let torn = replayed.good_len < raw.len();
623 let next_seq = replayed
624 .ops
625 .last()
626 .map_or(header.wal_applied_seq + 1, |&(s, _)| s.max(header.wal_applied_seq) + 1);
627 let mut count = 0;
628 for (seq, op) in replayed.ops.into_iter().filter(|&(s, _)| s > header.wal_applied_seq) {
629 if !inner.arena.publish(seq, op) {
630 return Err(Error::busy("overlay arena full during replay"));
631 }
632 count += 1;
633 }
634 Ok(Built {
635 inner,
636 next_seq,
637 replayed: count,
638 good_len: replayed.good_len,
639 log: LogState::Current { torn },
640 })
641}
642
643fn load_generation(dir: &Path, read_only: bool) -> Result<Built> {
646 let wal_file = wal_path(dir);
647 let mut attempt = 0;
648 let mut built = loop {
649 let map = MappedFile::open(&base_path(dir))?;
650 let raw = match fs::read(&wal_file) {
651 Ok(raw) => raw,
652 Err(e) if e.kind() == std::io::ErrorKind::NotFound && read_only => Vec::new(),
653 Err(e) => return Err(e.into()),
654 };
655 let built = build_generation(map, &raw)?;
656 if built.log != LogState::Newer {
657 break built;
658 }
659 attempt += 1;
662 if !read_only {
663 return Err(Error::corrupt("wal is newer than base"));
664 }
665 if attempt == RACE_RETRIES {
666 return Err(Error::busy("compaction in progress; retry"));
667 }
668 };
669 if !read_only {
670 match built.log {
671 LogState::Stale | LogState::Empty => {
674 let generation = built.inner.dir.header.generation;
675 atomic_write(&wal_file, &wal::encode_header(generation, now_ms()))?;
676 built.good_len = wal::HEADER_LEN;
677 }
678 LogState::Current { torn: true } => {
679 let f = fs::OpenOptions::new().write(true).open(&wal_file)?;
680 f.set_len(built.good_len as u64)?;
681 f.sync_all()?;
682 }
683 LogState::Current { torn: false } | LogState::Newer => {}
684 }
685 }
686 Ok(built)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::writer::DocRecord;
693 use crate::UrlKey;
694
695 fn doc(url: &str, hash: u64) -> DocRecord<'_> {
696 DocRecord {
697 url,
698 url_key: UrlKey::of(url),
699 content_hash: hash,
700 fetched_at_ms: 1,
701 title: None,
702 snippet: None,
703 etag: None,
704 pinned: false,
705 }
706 }
707
708 fn write(store: &Store, url: &str, hash: u64) {
709 let mut w = store.writer().unwrap();
710 w.upsert_node(&doc(url, hash)).unwrap();
711 w.commit().unwrap();
712 }
713
714 #[test]
717 fn a_snapshot_does_not_observe_later_commits() {
718 let dir = tempfile::tempdir().unwrap();
719 let store = Store::create(dir.path(), Config::default()).unwrap();
720 write(&store, "https://x.dev/a", 1);
721
722 let before = store.snapshot();
723 assert_eq!(before.len(), 1);
724
725 write(&store, "https://x.dev/b", 2);
726 assert_eq!(before.len(), 1, "an existing snapshot must not grow");
727 assert_eq!(store.snapshot().len(), 2, "a fresh snapshot sees the commit");
728 }
729
730 #[test]
734 fn compaction_leaves_an_existing_snapshot_on_its_own_generation() {
735 let dir = tempfile::tempdir().unwrap();
736 let store = Store::create(dir.path(), Config::default()).unwrap();
737 write(&store, "https://x.dev/a", 1);
738 store.compact().unwrap();
739
740 let pinned = store.snapshot();
741 let pinned_gen = pinned.generation();
742
743 write(&store, "https://x.dev/b", 2);
744 store.compact().unwrap();
745
746 assert_eq!(pinned.generation(), pinned_gen, "a pinned generation must not move");
747 assert_eq!(pinned.len(), 1, "the old generation still reads its own state");
748 let fresh = store.snapshot();
749 assert!(fresh.generation() > pinned_gen, "the store advanced");
750 assert_eq!(fresh.len(), 2);
751 }
752
753 #[test]
756 fn each_compaction_advances_the_generation_exactly_once() {
757 let dir = tempfile::tempdir().unwrap();
758 let store = Store::create(dir.path(), Config::default()).unwrap();
759 write(&store, "https://x.dev/a", 1);
760
761 let mut prev = store.snapshot().generation();
762 for _ in 0..6 {
763 store.compact().unwrap();
764 let now = store.snapshot().generation();
765 assert_eq!(now, prev + 1, "a compaction must advance the generation by one");
766 prev = now;
767 }
768 }
769
770 #[test]
775 fn exhausting_the_generation_ring_is_a_retryable_refusal() {
776 let dir = tempfile::tempdir().unwrap();
777 let store = Store::create(dir.path(), Config::default()).unwrap();
778 write(&store, "https://x.dev/a", 1);
779
780 let mut pins = Vec::new();
784 for round in 0..GEN_SLOTS - 1 {
785 pins.push(store.snapshot());
786 store.compact().unwrap_or_else(|e| panic!("round {round} should still fit: {e}"));
787 }
788
789 pins.push(store.snapshot());
791 assert!(
792 matches!(store.compact(), Err(Error::Busy { .. })),
793 "a full ring must refuse with Busy, not stall or corrupt the swap"
794 );
795
796 drop(pins);
798 store.compact().expect("compaction must succeed once a slot frees up");
799 assert_eq!(store.snapshot().len(), 1);
800 }
801
802 #[test]
805 fn a_read_only_handle_reloads_a_newer_generation() {
806 let dir = tempfile::tempdir().unwrap();
807 let store = Store::create(dir.path(), Config::default()).unwrap();
808 write(&store, "https://x.dev/a", 1);
809 store.compact().unwrap();
810
811 let reader = Store::open_read_only(dir.path()).unwrap();
812 assert_eq!(reader.snapshot().len(), 1);
813 assert!(!reader.reload_if_stale().unwrap(), "nothing has changed yet");
814
815 write(&store, "https://x.dev/b", 2);
816 store.compact().unwrap();
817
818 assert!(reader.reload_if_stale().unwrap(), "a new generation is visible");
819 assert_eq!(reader.snapshot().len(), 2);
820 }
821}