1use std::fs::{self, File, OpenOptions};
2use std::io::{BufReader, BufWriter, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use anyhow::{anyhow, Context, Result};
7use lora_snapshot::{read_snapshot, write_snapshot, SnapshotOptions};
8use lora_store::{InMemoryGraph, SnapshotMeta};
9use lora_wal::{Lsn, WalRecorder};
10
11const CURRENT_FILE: &str = "CURRENT";
12const SNAPSHOT_PREFIX: &str = "snapshot-";
13const SNAPSHOT_SUFFIX: &str = ".lsnap";
14
15#[derive(Debug, Clone)]
16pub struct SnapshotConfig {
17 pub dir: PathBuf,
18 pub checkpoint_every_commits: Option<u64>,
22 pub keep_old: usize,
24 pub codec: SnapshotOptions,
27}
28
29impl SnapshotConfig {
30 pub fn enabled(dir: impl Into<PathBuf>) -> Self {
31 Self {
32 dir: dir.into(),
33 checkpoint_every_commits: None,
34 keep_old: 1,
35 codec: SnapshotOptions::default(),
36 }
37 }
38
39 pub fn every_commits(mut self, commits: u64) -> Self {
40 self.checkpoint_every_commits = Some(commits.max(1));
41 self
42 }
43
44 pub fn keep_old(mut self, keep_old: usize) -> Self {
45 self.keep_old = keep_old;
46 self
47 }
48
49 pub fn codec(mut self, codec: SnapshotOptions) -> Self {
50 self.codec = codec;
51 self
52 }
53}
54
55pub(crate) struct ManagedSnapshotStore {
56 config: SnapshotConfig,
57 commits_since_checkpoint: AtomicU64,
58}
59
60impl ManagedSnapshotStore {
61 pub(crate) fn open(config: SnapshotConfig) -> Result<Self> {
62 fs::create_dir_all(&config.dir)
63 .with_context(|| format!("create snapshot dir {}", config.dir.display()))?;
64 Ok(Self {
65 config,
66 commits_since_checkpoint: AtomicU64::new(0),
67 })
68 }
69
70 pub(crate) fn load_latest(&self, graph: &mut InMemoryGraph) -> Result<Lsn> {
71 let Some(path) = self.latest_snapshot_path()? else {
72 return Ok(Lsn::ZERO);
73 };
74 let file =
75 File::open(&path).with_context(|| format!("open snapshot {}", path.display()))?;
76 let (payload, info) =
77 read_snapshot(BufReader::new(file), self.config.codec.encryption.as_ref())
78 .with_context(|| format!("load snapshot {}", path.display()))?;
79 graph.load_snapshot_payload(payload)?;
80 Ok(info.wal_lsn.map(Lsn::new).unwrap_or(Lsn::ZERO))
81 }
82
83 pub(crate) fn checkpoint(
84 &self,
85 graph: &InMemoryGraph,
86 recorder: &WalRecorder,
87 ) -> Result<SnapshotMeta> {
88 recorder
89 .force_fsync()
90 .map_err(|e| anyhow!("WAL fsync before managed snapshot failed: {e}"))?;
91 let snapshot_lsn = recorder.wal().durable_lsn();
92 let meta = self.write_snapshot(graph, snapshot_lsn)?;
93
94 recorder
95 .checkpoint_marker(snapshot_lsn)
96 .map_err(|e| anyhow!("WAL checkpoint marker failed: {e}"))?;
97 recorder
98 .force_fsync()
99 .map_err(|e| anyhow!("WAL fsync after checkpoint marker failed: {e}"))?;
100 if let Err(err) = recorder.truncate_up_to(snapshot_lsn) {
101 tracing::warn!(
102 lsn = snapshot_lsn.raw(),
103 error = %err,
104 "WAL truncation after managed checkpoint failed; will retry later"
105 );
106 }
107
108 self.commits_since_checkpoint.store(0, Ordering::Relaxed);
109 self.prune_old_snapshots(snapshot_lsn)?;
110 Ok(meta)
111 }
112
113 pub(crate) fn observe_commit(
114 &self,
115 graph: &InMemoryGraph,
116 recorder: &WalRecorder,
117 ) -> Result<()> {
118 let Some(every) = self.config.checkpoint_every_commits else {
119 return Ok(());
120 };
121 let commits = self
122 .commits_since_checkpoint
123 .fetch_add(1, Ordering::Relaxed)
124 + 1;
125 if commits >= every {
126 self.checkpoint(graph, recorder)?;
127 }
128 Ok(())
129 }
130
131 fn write_snapshot(&self, graph: &InMemoryGraph, snapshot_lsn: Lsn) -> Result<SnapshotMeta> {
132 let target = snapshot_path(&self.config.dir, snapshot_lsn);
133 let tmp = tmp_path(&target);
134 let file = OpenOptions::new()
135 .write(true)
136 .create(true)
137 .truncate(true)
138 .open(&tmp)
139 .with_context(|| format!("open temp snapshot {}", tmp.display()))?;
140 let mut writer = BufWriter::new(file);
141 let payload = graph.snapshot_payload();
142 let info = write_snapshot(
143 &mut writer,
144 &payload,
145 Some(snapshot_lsn.raw()),
146 &self.config.codec,
147 )
148 .map_err(|e| anyhow!("encode managed snapshot failed: {e}"))?;
149 let meta = SnapshotMeta {
150 format_version: info.format_version,
151 node_count: info.node_count,
152 relationship_count: info.relationship_count,
153 wal_lsn: info.wal_lsn,
154 };
155 writer.flush()?;
156 let file = writer.into_inner().map_err(|e| e.into_error())?;
157 file.sync_all()?;
158 drop(file);
159
160 fs::rename(&tmp, &target)
161 .with_context(|| format!("rename {} to {}", tmp.display(), target.display()))?;
162 sync_dir(&self.config.dir)
163 .with_context(|| format!("sync snapshot dir {}", self.config.dir.display()))?;
164 write_current(&self.config.dir, &target)?;
165 Ok(meta)
166 }
167
168 fn latest_snapshot_path(&self) -> Result<Option<PathBuf>> {
169 let current = self.config.dir.join(CURRENT_FILE);
170 match fs::read_to_string(¤t) {
171 Ok(name) => {
172 let name = name.trim();
173 if name.is_empty() {
174 return Ok(None);
175 }
176 let path = self.config.dir.join(name);
177 if path.exists() {
178 return Ok(Some(path));
179 }
180 }
181 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
182 Err(err) => return Err(err).with_context(|| format!("read {}", current.display())),
183 }
184
185 let latest = snapshot_files(&self.config.dir)?
186 .into_iter()
187 .max_by_key(|(lsn, _)| *lsn)
188 .map(|(_, path)| path);
189 Ok(latest)
190 }
191
192 fn prune_old_snapshots(&self, current_lsn: Lsn) -> Result<()> {
193 let mut snapshots = snapshot_files(&self.config.dir)?;
194 snapshots.retain(|(lsn, _)| *lsn != current_lsn);
195 snapshots.sort_by_key(|(lsn, _)| *lsn);
196 let retain = self.config.keep_old;
197 let remove_count = snapshots.len().saturating_sub(retain);
198 for (_, path) in snapshots.into_iter().take(remove_count) {
199 fs::remove_file(&path)
200 .with_context(|| format!("remove old snapshot {}", path.display()))?;
201 }
202 sync_dir(&self.config.dir)
203 .with_context(|| format!("sync snapshot dir {}", self.config.dir.display()))?;
204 Ok(())
205 }
206}
207
208fn snapshot_path(dir: &Path, lsn: Lsn) -> PathBuf {
209 dir.join(format!(
210 "{SNAPSHOT_PREFIX}{:020}{SNAPSHOT_SUFFIX}",
211 lsn.raw()
212 ))
213}
214
215fn snapshot_files(dir: &Path) -> Result<Vec<(Lsn, PathBuf)>> {
216 let mut out = Vec::new();
217 for entry in
218 fs::read_dir(dir).with_context(|| format!("read snapshot dir {}", dir.display()))?
219 {
220 let entry = entry?;
221 let path = entry.path();
222 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
223 continue;
224 };
225 let Some(raw) = name
226 .strip_prefix(SNAPSHOT_PREFIX)
227 .and_then(|name| name.strip_suffix(SNAPSHOT_SUFFIX))
228 else {
229 continue;
230 };
231 if let Ok(lsn) = raw.parse::<u64>() {
232 out.push((Lsn::new(lsn), path));
233 }
234 }
235 Ok(out)
236}
237
238fn write_current(dir: &Path, target: &Path) -> Result<()> {
239 let name = target
240 .file_name()
241 .and_then(|name| name.to_str())
242 .ok_or_else(|| {
243 anyhow!(
244 "snapshot path has no portable filename: {}",
245 target.display()
246 )
247 })?;
248 let current = dir.join(CURRENT_FILE);
249 let tmp = tmp_path(¤t);
250 let mut file = OpenOptions::new()
251 .write(true)
252 .create(true)
253 .truncate(true)
254 .open(&tmp)
255 .with_context(|| format!("open temp CURRENT {}", tmp.display()))?;
256 writeln!(file, "{name}")?;
257 file.sync_all()?;
258 drop(file);
259 fs::rename(&tmp, ¤t)
260 .with_context(|| format!("rename {} to {}", tmp.display(), current.display()))?;
261 sync_dir(dir).with_context(|| format!("sync snapshot dir {}", dir.display()))?;
262 Ok(())
263}
264
265fn tmp_path(path: &Path) -> PathBuf {
266 let mut tmp = path.as_os_str().to_owned();
267 tmp.push(".tmp");
268 PathBuf::from(tmp)
269}
270
271#[cfg(unix)]
272fn sync_dir(path: &Path) -> Result<()> {
273 let dir = File::open(path).with_context(|| format!("open dir {}", path.display()))?;
274 dir.sync_all()
275 .with_context(|| format!("sync dir {}", path.display()))
276}
277
278#[cfg(not(unix))]
279fn sync_dir(_path: &Path) -> Result<()> {
280 Ok(())
281}