Skip to main content

zeppelin_embed/lifecycle/
snapshot_copy.rs

1//! ZE-220: a consistent point-in-time copy of an open store.
2//!
3//! A snapshot pins one generation for a short time, then copies what that
4//! generation references without holding any store lock:
5//!
6//! - Pin. Under the WAL writer mutex, which every manifest commit and
7//!   snapshot publication also holds, it takes a reader lease on the
8//!   published segment set, reads the committed manifest bytes, and clones
9//!   the retained WAL records after the manifest's absorbed prefix. The cost
10//!   is O(manifest + unsealed records); the records share the writer's
11//!   encoded buffers.
12//! - Copy. Segments are copied from the lease's mappings, so a segment that
13//!   seal, maintenance or a sealed-row rewrite unlinks during the copy is
14//!   still read in full. The WAL is rebuilt as a header whose first sequence
15//!   follows the absorbed prefix, then the pinned records. Close cancels the
16//!   lease, and the copy stops at its next chunk.
17//! - Publish. Everything is written into a hidden staging directory beside
18//!   the target, each file and the directory are fully synced, the staging
19//!   directory is renamed to the target, and the parent is synced. A crash
20//!   before the rename leaves no target; the staging directory is never a
21//!   snapshot.
22//!
23//! The snapshot is an ordinary store directory. Opening it read-only or
24//! read-write restores exactly the pinned state.
25
26use 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";
38/// Copy granularity. Close cancellation is checked between chunks.
39const COPY_CHUNK_BYTES: usize = 8 * 1024 * 1024;
40
41static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
42
43/// Why a snapshot target was rejected.
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum SnapshotTargetReason {
46    /// The path has no final name component, such as `/` or `a/..`.
47    NoFinalName,
48    /// The target's parent directory does not exist.
49    ParentMissing,
50    /// The target is the store directory or lies inside it.
51    InsideStore,
52    /// The target exists and is not a directory, such as a file or a link.
53    NotADirectory,
54    /// The target is a directory that already has entries.
55    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
70/// A validated target: its canonical parent and final path.
71struct Target {
72    parent: PathBuf,
73    path: PathBuf,
74    /// An existing empty directory is replaced at publication.
75    existed: bool,
76}
77
78/// Everything one snapshot copies, captured at one generation.
79struct Pinned {
80    lease: SnapshotLease,
81    /// Committed manifest bytes; `None` for a store that never committed one.
82    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    /// Writes a consistent snapshot of this store into `target` and returns
93    /// the generation it captured.
94    ///
95    /// `target` must not exist, or must be an empty directory; its parent
96    /// must exist; and it must not be the store directory or lie inside it.
97    /// A rejected target is [`StoreError::SnapshotTarget`] and nothing is
98    /// written. The handle must be writable ([`StoreError::ReadOnly`]
99    /// otherwise), because only the writer owns the WAL tail. While a
100    /// physical purge is pending the call is refused with
101    /// [`StoreError::SnapshotPurgePending`].
102    ///
103    /// Writers are blocked while the generation is pinned and any graph tail
104    /// is checkpointed. Graph-free pinning is O(manifest + unsealed records)
105    /// and copies no document bytes. Writes,
106    /// seal and maintenance run during the copy and are absent from the
107    /// snapshot. Close cancels an in-flight snapshot
108    /// ([`StoreError::ReadCancelled`]); a failed snapshot removes its
109    /// staging directory. A parent-directory sync failure may return an error
110    /// after publishing a complete `target`.
111    ///
112    /// A graph snapshot first checkpoints the graph tail and may advance the
113    /// manifest generation. Unabsorbed namespace document writes must be sealed
114    /// before export, since their transaction authority belongs to the source.
115    ///
116    /// The snapshot directory is an ordinary store: opening it read-only or
117    /// read-write restores exactly the state at the returned generation.
118    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            // The original error is the one reported. After a completed
129            // rename the staging path is gone and this finds nothing.
130            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        // Every manifest commit and snapshot publication holds this mutex,
147        // so the manifest file, the published snapshot, the active
148        // generation and the WAL tail agree while it is held.
149        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        // `purge` returns with its intent on disk and the bytes it will
164        // remove still in the store; copying them would defeat the purge.
165        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        // Fold the graph tail under the locks already excluding every publisher.
174        // Its manifest inventory then contains all objects needed by the copy.
175        #[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                // Physical purge retires the old prefix without sealing the
225                // survivors. Its replacement header is the tail's boundary;
226                // retain it even when the replacement has no records.
227                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        // Prepared namespace records bind to their original participant directory.
240        // A standalone copy cannot carry the parent transaction authority.
241        #[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            // An older graph watermark can precede a retired document-only
255            // prefix. Preserve the retained WAL boundary, including an empty
256            // WAL's durable end, instead of inventing absent records.
257            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    // ZE-346 checkpoints the entire graph tail before this census. Its
285    // inventory includes prepared objects and reclaim-proof participants, so
286    // no WAL parser or directory enumeration is needed here.
287    #[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    /// Reads the committed manifest and proves it describes `published`.
302    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    /// A hidden sibling that no other snapshot, process or crash leftover
404    /// shares; it is created exclusively.
405    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    // Graph read-only admission uses the persistent store lock. Publish that
435    // empty lock file with the snapshot so admission remains purely read-only.
436    #[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
510/// The snapshot's WAL: a header naming the first unabsorbed sequence, then
511/// every pinned record exactly as the writer encoded it.
512fn 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}