1use std::path::{Path, PathBuf};
27use std::sync::atomic::{AtomicU64, Ordering};
28
29use crate::manifest::decode_manifest;
30use crate::manifest::io::MANIFEST_FILE;
31use crate::vfs::{SyncKind, Vfs};
32use crate::wal::header::encode_header;
33use crate::wal::{LogSeq, VisibleRecord, WalWriteError};
34
35use super::{PublishedSnapshot, SnapshotLease, Store, StoreError, StoreState};
36
37const WAL_FILE: &str = "wal.ze";
38const COPY_CHUNK_BYTES: usize = 8 * 1024 * 1024;
40
41static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum SnapshotTargetReason {
46 NoFinalName,
48 ParentMissing,
50 InsideStore,
52 NotADirectory,
54 NotEmpty,
56}
57
58impl std::fmt::Display for SnapshotTargetReason {
59 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 formatter.write_str(match self {
61 Self::NoFinalName => "has no final name",
62 Self::ParentMissing => "parent directory does not exist",
63 Self::InsideStore => "is inside the store",
64 Self::NotADirectory => "exists and is not a directory",
65 Self::NotEmpty => "is not empty",
66 })
67 }
68}
69
70struct Target {
72 parent: PathBuf,
73 path: PathBuf,
74 existed: bool,
76}
77
78struct Pinned {
80 lease: SnapshotLease,
81 manifest: Option<Vec<u8>>,
83 wal_first_seq: LogSeq,
84 wal_records: Vec<VisibleRecord>,
85 #[cfg(feature = "graph-cypher")]
86 graph_objects: Vec<crate::manifest::GraphObject>,
87 #[cfg(feature = "graph-cypher")]
88 graph_pin: Option<super::native_graph::NativeSnapshotPin>,
89}
90
91impl Store {
92 pub fn write_snapshot(&self, target: impl AsRef<Path>) -> Result<u64, StoreError> {
119 let target = resolve_target(&self.directory, target.as_ref())?;
120 let pinned = self.pin_snapshot()?;
121 let vfs = self.vfs.as_ref();
122 let staging = target.staging_path();
123 vfs.create_directory(&staging)
124 .map_err(|source| io(&staging, source))?;
125 if let Err(error) =
126 stage(vfs, &staging, &pinned).and_then(|()| target.publish(vfs, &staging))
127 {
128 remove_staging(vfs, &staging);
131 return Err(error);
132 }
133 Ok(pinned.lease.generation())
134 }
135
136 fn pin_snapshot(&self) -> Result<Pinned, StoreError> {
137 let state = self
138 .state
139 .lock()
140 .map_err(|_| StoreError::Synchronization { component: "state" })?;
141 match *state {
142 StoreState::Open => {}
143 StoreState::Closing => return Err(StoreError::Closing),
144 StoreState::Closed => return Err(StoreError::Closed),
145 }
146 let mut wal = self
150 .wal_writer
151 .lock()
152 .map_err(|_| StoreError::Synchronization {
153 component: "WAL writer",
154 })?;
155 let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
156 let mut active_slot = self
157 .active
158 .lock()
159 .map_err(|_| StoreError::Synchronization {
160 component: "active segment",
161 })?;
162 let active = active_slot.as_mut().ok_or(StoreError::Closed)?;
163 let intent = self
166 .directory
167 .join(crate::ingest::purge_support::PURGE_INTENT_FILE);
168 match self.vfs.open(&intent) {
169 Ok(_) => return Err(StoreError::SnapshotPurgePending),
170 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
171 Err(source) => return Err(io(&intent, source)),
172 }
173 #[cfg(feature = "graph-cypher")]
176 if writer.durable_end() != 0
177 && let Some(through) = self.graph_checkpoint_through(self.vfs.as_ref())?
178 {
179 self.checkpoint_native_graph_locked(writer, active, through, self.vfs.as_ref())
180 .map_err(|error| match error {
181 super::native_graph::NativeGraphError::Store(error) => error,
182 _ => StoreError::SnapshotPin {
183 detail: "graph checkpoint failed before snapshot pinning",
184 },
185 })?;
186 }
187 let generation = active.generation;
188 let published = self
189 .snapshot
190 .read()
191 .map_err(|_| StoreError::Synchronization {
192 component: "published snapshot",
193 })?
194 .as_ref()
195 .cloned()
196 .ok_or(StoreError::Closed)?;
197 let manifest = self.pinned_manifest(&published)?;
198 #[cfg(feature = "graph-cypher")]
199 let graph_objects = self.pinned_graph_objects(manifest.as_deref())?;
200 #[cfg(feature = "graph-cypher")]
201 let graph_pin = if published.graph_enabled {
202 Some(self.native_graph.pin_snapshot(&graph_objects)?)
203 } else {
204 None
205 };
206 let absorbed_through = published.absorbed_through();
207 #[cfg(feature = "graph-cypher")]
208 let absorbed_through = if published.graph_enabled {
209 absorbed_through.min(published.graph_absorbed_through)
210 } else {
211 absorbed_through
212 };
213 let wal_first_seq = LogSeq::new(absorbed_through.checked_add(1).ok_or(
214 StoreError::SnapshotPin {
215 detail: "the absorbed WAL prefix has no successor sequence",
216 },
217 )?);
218 #[cfg(feature = "graph-cypher")]
219 let wal_first_seq = if published.graph_enabled {
220 let path = self.directory.join(WAL_FILE);
221 if self.vfs.open(&path).map_err(|source| io(&path, source))? == 0 {
222 wal_first_seq
223 } else {
224 let bytes = self
228 .vfs
229 .read_range(&path, 0, crate::wal::header::WAL_HEADER_LEN)
230 .map_err(|source| io(&path, source))?;
231 let header = crate::wal::header::decode_header(&bytes)
232 .map_err(|error| StoreError::Wal(crate::wal::WalReadError::Header(error)))?;
233 wal_first_seq.max(header.first_seq)
234 }
235 } else {
236 wal_first_seq
237 };
238 let wal_records = writer.unabsorbed_records(wal_first_seq.get().saturating_sub(1))?;
239 #[cfg(feature = "graph-cypher")]
242 if published.graph_enabled
243 && wal_records
244 .iter()
245 .any(|record| record.op == crate::ingest::wal_payload::PREPARED_MUTATION_V1)
246 {
247 return Err(StoreError::SnapshotPin {
248 detail: "seal namespace document writes before exporting a graph snapshot",
249 });
250 }
251 let wal_first_seq = LogSeq::new(absorbed_through.saturating_add(1));
252 #[cfg(feature = "graph-cypher")]
253 let wal_first_seq = if published.graph_enabled {
254 match wal_records.first() {
258 Some(record) => record.seq,
259 None => LogSeq::new(
260 writer
261 .durable_end()
262 .checked_add(1)
263 .ok_or(StoreError::GenerationOverflow)?,
264 ),
265 }
266 } else {
267 wal_first_seq
268 };
269 drop(active_slot);
270 drop(wal);
271 drop(state);
272 Ok(Pinned {
273 lease: SnapshotLease::new_at(published, generation),
274 manifest,
275 wal_first_seq,
276 wal_records,
277 #[cfg(feature = "graph-cypher")]
278 graph_objects,
279 #[cfg(feature = "graph-cypher")]
280 graph_pin,
281 })
282 }
283
284 #[cfg(feature = "graph-cypher")]
288 fn pinned_graph_objects(
289 &self,
290 manifest: Option<&[u8]>,
291 ) -> Result<Vec<crate::manifest::GraphObject>, StoreError> {
292 match manifest {
293 Some(bytes) => Ok(decode_manifest("snapshot manifest", bytes)
294 .map_err(StoreError::Manifest)?
295 .graph
296 .map_or_else(Vec::new, |graph| graph.objects)),
297 None => Ok(Vec::new()),
298 }
299 }
300
301 fn pinned_manifest(
303 &self,
304 published: &PublishedSnapshot,
305 ) -> Result<Option<Vec<u8>>, StoreError> {
306 let path = self.directory.join(MANIFEST_FILE);
307 let bytes = match self.vfs.read(&path) {
308 Ok(bytes) => bytes,
309 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
310 #[cfg(feature = "graph-cypher")]
311 if published.graph_enabled {
312 return Err(StoreError::SnapshotPin {
313 detail: "the graph-enabled snapshot has no committed manifest",
314 });
315 }
316 if published.all_segments().is_empty() && published.absorbed_through() == 0 {
317 return Ok(None);
318 }
319 return Err(StoreError::SnapshotPin {
320 detail: "the published snapshot has no committed manifest",
321 });
322 }
323 Err(source) => return Err(io(&path, source)),
324 };
325 let manifest =
326 decode_manifest(&path.display().to_string(), &bytes).map_err(StoreError::Manifest)?;
327 let mut committed = manifest
328 .segments
329 .iter()
330 .map(|segment| segment.id)
331 .collect::<Vec<_>>();
332 let mut pinned = published
333 .all_segments()
334 .iter()
335 .map(|segment| segment.meta().id)
336 .collect::<Vec<_>>();
337 committed.sort_unstable();
338 pinned.sort_unstable();
339 if manifest.generation != published.generation()
340 || manifest.log_seq != published.absorbed_through()
341 || committed != pinned
342 {
343 return Err(StoreError::SnapshotPin {
344 detail: "the committed manifest does not describe the published snapshot",
345 });
346 }
347 Ok(Some(bytes))
348 }
349}
350
351fn io(path: &Path, source: std::io::Error) -> StoreError {
352 StoreError::Io {
353 path: path.to_path_buf(),
354 source,
355 }
356}
357
358fn resolve_target(store_directory: &Path, target: &Path) -> Result<Target, StoreError> {
359 let reject = |reason| StoreError::SnapshotTarget {
360 path: target.to_path_buf(),
361 reason,
362 };
363 let name = target
364 .file_name()
365 .ok_or_else(|| reject(SnapshotTargetReason::NoFinalName))?;
366 let parent = match target.parent() {
367 Some(parent) if !parent.as_os_str().is_empty() => parent,
368 _ => Path::new("."),
369 };
370 let parent = match std::fs::canonicalize(parent) {
371 Ok(parent) => parent,
372 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
373 return Err(reject(SnapshotTargetReason::ParentMissing));
374 }
375 Err(source) => return Err(io(parent, source)),
376 };
377 let store =
378 std::fs::canonicalize(store_directory).map_err(|source| io(store_directory, source))?;
379 let path = parent.join(name);
380 if path.starts_with(&store) {
381 return Err(reject(SnapshotTargetReason::InsideStore));
382 }
383 let existed = match std::fs::symlink_metadata(&path) {
384 Ok(metadata) if metadata.is_dir() => {
385 let mut entries = std::fs::read_dir(&path).map_err(|source| io(&path, source))?;
386 if entries.next().is_some() {
387 return Err(reject(SnapshotTargetReason::NotEmpty));
388 }
389 true
390 }
391 Ok(_) => return Err(reject(SnapshotTargetReason::NotADirectory)),
392 Err(source) if source.kind() == std::io::ErrorKind::NotFound => false,
393 Err(source) => return Err(io(&path, source)),
394 };
395 Ok(Target {
396 parent,
397 path,
398 existed,
399 })
400}
401
402impl Target {
403 fn staging_path(&self) -> PathBuf {
406 let name = self
407 .path
408 .file_name()
409 .map(|name| name.to_string_lossy().into_owned())
410 .unwrap_or_default();
411 let nanos = std::time::SystemTime::now()
412 .duration_since(std::time::UNIX_EPOCH)
413 .map_or(0, |elapsed| elapsed.as_nanos());
414 let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed);
415 self.parent.join(format!(
416 ".{name}.snapshot-{}-{nanos}-{sequence}.tmp",
417 std::process::id()
418 ))
419 }
420
421 fn publish(&self, vfs: &dyn Vfs, staging: &Path) -> Result<(), StoreError> {
422 if self.existed {
423 vfs.remove_directory(&self.path)
424 .map_err(|source| io(&self.path, source))?;
425 }
426 vfs.rename(staging, &self.path)
427 .map_err(|source| io(&self.path, source))?;
428 vfs.sync(&self.parent, SyncKind::Full)
429 .map_err(|source| io(&self.parent, source))
430 }
431}
432
433fn stage(vfs: &dyn Vfs, staging: &Path, pinned: &Pinned) -> Result<(), StoreError> {
434 #[cfg(feature = "graph-cypher")]
437 let _graph_lock = if pinned.graph_objects.is_empty() {
438 None
439 } else {
440 Some(super::StoreLock::acquire(staging).map_err(StoreError::Lock)?)
441 };
442 let lease = &pinned.lease;
443 for segment in lease.all_segments() {
444 let bytes = segment.file_bytes();
445 if u64::try_from(bytes.len()).ok() != Some(segment.meta().file_size) {
446 return Err(StoreError::SnapshotPin {
447 detail: "a pinned segment mapping differs from its manifest length",
448 });
449 }
450 write_file(
451 vfs,
452 &staging.join(segment.meta().id.file_name()),
453 lease,
454 bytes.chunks(COPY_CHUNK_BYTES).map(Ok),
455 )?;
456 }
457 #[cfg(feature = "graph-cypher")]
458 for object in &pinned.graph_objects {
459 use crate::property_graph::storage::{
460 NativeReadonlyMapping,
461 allocation::artifact_path,
462 artifact::{self, ContainerKind, MAX_ARTIFACT_BYTES},
463 };
464 let pin = pinned.graph_pin.as_ref().ok_or(StoreError::SnapshotPin {
465 detail: "graph objects have no retention pin",
466 })?;
467 let source = artifact_path(pin.bundle().directory(), object.artifact);
468 let file = pin
469 .bundle()
470 .vfs()
471 .open_for_map(&source)
472 .map_err(|error| io(&source, error))?;
473 let mapping = NativeReadonlyMapping::open_recovery(
474 file,
475 &source,
476 pin.publication(),
477 MAX_ARTIFACT_BYTES,
478 )
479 .map_err(super::native_graph::snapshot_error)?;
480 let bytes = mapping.as_bytes();
481 let frame = artifact::decode(
482 ContainerKind::Object,
483 Some((pin.bundle().base().store, object.artifact)),
484 bytes,
485 )
486 .map_err(|error| {
487 StoreError::Manifest(crate::manifest::ManifestError::Decode(error.to_string()))
488 })?;
489 if bytes.len() as u64 != object.length || frame.file_checksum() != object.checksum {
490 return Err(StoreError::SnapshotPin {
491 detail: "graph object differs from its pinned inventory",
492 });
493 }
494 let path = artifact_path(staging, object.artifact);
495 write_file(vfs, &path, lease, bytes.chunks(COPY_CHUNK_BYTES).map(Ok))?;
496 }
497 write_wal(vfs, &staging.join(WAL_FILE), pinned)?;
498 if let Some(manifest) = &pinned.manifest {
499 write_file(
500 vfs,
501 &staging.join(MANIFEST_FILE),
502 lease,
503 std::iter::once(Ok(manifest.as_slice())),
504 )?;
505 }
506 vfs.sync(staging, SyncKind::Full)
507 .map_err(|source| io(staging, source))
508}
509
510fn write_wal(vfs: &dyn Vfs, path: &Path, pinned: &Pinned) -> Result<(), StoreError> {
513 let header = encode_header(pinned.wal_first_seq)
514 .map_err(|error| StoreError::WalWrite(WalWriteError::Header(error)))?;
515 let records = pinned.wal_records.iter().map(|record| {
516 record.encoded().map_err(|source| StoreError::WalRecord {
517 seq: record.seq,
518 source,
519 })
520 });
521 write_file(
522 vfs,
523 path,
524 &pinned.lease,
525 std::iter::once(Ok(header.as_slice())).chain(records),
526 )
527}
528
529fn write_file<'a>(
530 vfs: &dyn Vfs,
531 path: &Path,
532 lease: &SnapshotLease,
533 chunks: impl IntoIterator<Item = Result<&'a [u8], StoreError>>,
534) -> Result<(), StoreError> {
535 let mut file = vfs.open_append(path).map_err(|source| io(path, source))?;
536 for chunk in chunks {
537 lease.check_active()?;
538 file.append(chunk?).map_err(|source| io(path, source))?;
539 }
540 file.sync(SyncKind::Full).map_err(|source| io(path, source))
541}
542
543fn remove_staging(vfs: &dyn Vfs, staging: &Path) {
544 if let Ok(paths) = vfs.list(staging) {
545 for path in paths {
546 let _ = vfs.delete(&path);
547 }
548 }
549 let _ = vfs.remove_directory(staging);
550}