Skip to main content

uni_store/snapshot/
manager.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use crate::store_utils::{
5    DEFAULT_TIMEOUT, get_with_timeout, is_not_found, list_with_timeout, put_with_timeout,
6};
7use anyhow::Result;
8use bytes::Bytes;
9use chrono::{DateTime, Utc};
10use object_store::ObjectStore;
11use object_store::path::Path as ObjectStorePath;
12use std::collections::HashMap;
13use std::sync::Arc;
14use tracing::instrument;
15use uni_common::core::fork::ForkId;
16use uni_common::core::snapshot::SnapshotManifest;
17
18/// Reads and writes snapshot manifests + the `latest` pointer.
19///
20/// A primary manager namespaces under `catalog/`; a fork-scoped manager
21/// (`new_for_fork`) namespaces under `catalog/forks/{fork_id}/` so a fork's
22/// flush never overwrites the primary's global `catalog/latest` pointer or
23/// version/WAL high-water-marks (review C1). The fork namespace mirrors the
24/// existing per-fork `catalog/forks/{fork_id}/id_allocator.json`.
25pub struct SnapshotManager {
26    store: Arc<dyn ObjectStore>,
27    /// `Some` for a fork-scoped manager; `None` for the primary.
28    fork_id: Option<ForkId>,
29}
30
31impl SnapshotManager {
32    /// Construct the primary (global-namespace) snapshot manager.
33    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
34        Self {
35            store,
36            fork_id: None,
37        }
38    }
39
40    /// Construct a fork-scoped snapshot manager.
41    ///
42    /// All catalog paths are namespaced under `catalog/forks/{fork_id}/`, so a
43    /// fork's flush publishes its manifest and `latest` pointer in isolation and
44    /// never touches the primary's global catalog (review C1).
45    pub fn new_for_fork(store: Arc<dyn ObjectStore>, fork_id: ForkId) -> Self {
46        Self {
47            store,
48            fork_id: Some(fork_id),
49        }
50    }
51
52    /// Whether this manager is fork-scoped (publishes under
53    /// `catalog/forks/{fork_id}/` rather than the global `catalog/`).
54    pub fn is_fork_scoped(&self) -> bool {
55        self.fork_id.is_some()
56    }
57
58    /// Catalog namespace prefix: `catalog` for primary, `catalog/forks/{id}`
59    /// for a fork-scoped manager.
60    fn catalog_prefix(&self) -> String {
61        match &self.fork_id {
62            Some(id) => format!("catalog/forks/{id}"),
63            None => "catalog".to_string(),
64        }
65    }
66
67    fn manifest_path(&self, snapshot_id: &str) -> ObjectStorePath {
68        ObjectStorePath::from(format!(
69            "{}/manifests/{}.json",
70            self.catalog_prefix(),
71            snapshot_id
72        ))
73    }
74
75    fn latest_ptr_path(&self) -> ObjectStorePath {
76        ObjectStorePath::from(format!("{}/latest", self.catalog_prefix()))
77    }
78
79    fn named_snapshots_path(&self) -> ObjectStorePath {
80        ObjectStorePath::from(format!("{}/named_snapshots.json", self.catalog_prefix()))
81    }
82
83    #[instrument(skip(self, manifest), fields(snapshot_id = %manifest.snapshot_id, size_bytes), level = "info")]
84    pub async fn save_snapshot(&self, manifest: &SnapshotManifest) -> Result<()> {
85        let path = self.manifest_path(&manifest.snapshot_id);
86        let json = serde_json::to_string_pretty(manifest)?;
87        tracing::Span::current().record("size_bytes", json.len());
88        put_with_timeout(&self.store, &path, Bytes::from(json), DEFAULT_TIMEOUT).await?;
89        Ok(())
90    }
91
92    #[instrument(skip(self), level = "info")]
93    pub async fn load_snapshot(&self, snapshot_id: &str) -> Result<SnapshotManifest> {
94        // Try this manager's own namespace first.
95        match self
96            .load_snapshot_at(&self.manifest_path(snapshot_id))
97            .await
98        {
99            Ok(m) => Ok(m),
100            Err(e) if self.fork_id.is_some() => {
101                // A fork INHERITS the primary's snapshots: a pin / time-travel
102                // can reference a primary-created manifest (e.g. `create_snapshot`
103                // writes to the global namespace). Fall back to it. Fork *writes*
104                // stay fork-scoped, so this read fallback does not weaken C1.
105                let primary_path =
106                    ObjectStorePath::from(format!("catalog/manifests/{snapshot_id}.json"));
107                self.load_snapshot_at(&primary_path).await.map_err(|_| e)
108            }
109            Err(e) => Err(e),
110        }
111    }
112
113    async fn load_snapshot_at(&self, path: &ObjectStorePath) -> Result<SnapshotManifest> {
114        let result = get_with_timeout(&self.store, path, DEFAULT_TIMEOUT).await?;
115        let bytes = result.bytes().await?;
116        let content = String::from_utf8(bytes.to_vec())?;
117        let manifest: SnapshotManifest = serde_json::from_str(&content)?;
118        Ok(manifest)
119    }
120
121    pub async fn list_snapshots(&self) -> Result<Vec<String>> {
122        let prefix = ObjectStorePath::from(format!("{}/manifests", self.catalog_prefix()));
123        let metas = list_with_timeout(&self.store, Some(&prefix), DEFAULT_TIMEOUT).await?;
124        let mut ids = Vec::new();
125
126        for meta in metas {
127            if let Some(filename) = meta.location.filename()
128                && filename.ends_with(".json")
129            {
130                ids.push(filename.trim_end_matches(".json").to_string());
131            }
132        }
133        Ok(ids)
134    }
135
136    /// Check if any snapshot manifests exist (for detecting database with lost manifest pointer).
137    pub async fn has_any_manifests(&self) -> Result<bool> {
138        let ids = self.list_snapshots().await?;
139        Ok(!ids.is_empty())
140    }
141
142    pub async fn load_latest_snapshot(&self) -> Result<Option<SnapshotManifest>> {
143        let latest_path = self.latest_ptr_path();
144        match get_with_timeout(&self.store, &latest_path, DEFAULT_TIMEOUT).await {
145            Ok(result) => {
146                let bytes = result.bytes().await.map_err(anyhow::Error::from)?;
147                let snapshot_id = String::from_utf8(bytes.to_vec())?;
148                let snapshot_id = snapshot_id.trim();
149                if snapshot_id.is_empty() {
150                    return Ok(None);
151                }
152                Ok(Some(self.load_snapshot(snapshot_id).await?))
153            }
154            Err(e) if is_not_found(&e) => Ok(None),
155            Err(e) => Err(e),
156        }
157    }
158
159    #[instrument(skip(self), level = "info")]
160    pub async fn set_latest_snapshot(&self, snapshot_id: &str) -> Result<()> {
161        let path = self.latest_ptr_path();
162        put_with_timeout(
163            &self.store,
164            &path,
165            Bytes::from(snapshot_id.to_string()),
166            DEFAULT_TIMEOUT,
167        )
168        .await?;
169        Ok(())
170    }
171
172    pub async fn load_named_snapshots(&self) -> Result<HashMap<String, String>> {
173        let path = self.named_snapshots_path();
174        match get_with_timeout(&self.store, &path, DEFAULT_TIMEOUT).await {
175            Ok(result) => {
176                let bytes = result.bytes().await?;
177                let content = String::from_utf8(bytes.to_vec())?;
178                Ok(serde_json::from_str(&content)?)
179            }
180            // Only a genuine NotFound (no named-snapshots file yet) yields an
181            // empty map. A transient/IO error must propagate: `save_named_snapshot`
182            // does a read-modify-write over this map, so collapsing it to `{}`
183            // here would persist only the new entry and wipe every existing
184            // named snapshot.
185            Err(e) if is_not_found(&e) => Ok(HashMap::new()),
186            Err(e) => Err(e),
187        }
188    }
189
190    pub async fn save_named_snapshot(&self, name: &str, snapshot_id: &str) -> Result<()> {
191        let mut map = self.load_named_snapshots().await?;
192        map.insert(name.to_string(), snapshot_id.to_string());
193
194        let json = serde_json::to_string_pretty(&map)?;
195        put_with_timeout(
196            &self.store,
197            &self.named_snapshots_path(),
198            Bytes::from(json),
199            DEFAULT_TIMEOUT,
200        )
201        .await?;
202        Ok(())
203    }
204
205    pub async fn get_named_snapshot(&self, name: &str) -> Result<Option<String>> {
206        let map = self.load_named_snapshots().await?;
207        Ok(map.get(name).cloned())
208    }
209
210    /// Find the most recent snapshot created at or before the given timestamp.
211    pub async fn find_snapshot_at_time(
212        &self,
213        target: DateTime<Utc>,
214    ) -> Result<Option<SnapshotManifest>> {
215        let ids = self.list_snapshots().await?;
216        let mut best: Option<SnapshotManifest> = None;
217
218        for id in ids {
219            // Fail closed: propagate a load error for a listed snapshot rather
220            // than silently skipping it. Swallowing the error (the old `if let
221            // Ok(m)`) let a corrupt/unreadable newer manifest fall through to an
222            // older snapshot, answering a time-travel query from the wrong point
223            // in time with no signal (review #3c).
224            let m = self.load_snapshot(&id).await?;
225            if m.created_at <= target && best.as_ref().is_none_or(|b| m.created_at > b.created_at) {
226                best = Some(m);
227            }
228        }
229        Ok(best)
230    }
231}
232
233/// Make a just-published snapshot durable on local-filesystem stores by
234/// fsync'ing the manifest body and the `catalog/latest` pointer (and their
235/// parent directories) BEFORE the WAL — the only other durable copy of this
236/// flush's data — is truncated (review C4).
237///
238/// `save_snapshot` / `set_latest_snapshot` write through the object store,
239/// which does NOT fsync. Without this barrier a crash after WAL truncation but
240/// before the OS flushed those writes would lose the snapshot: recovery could
241/// not resolve `catalog/latest`.
242///
243/// A no-op when `local_root` is `None` (remote/object stores), which provide
244/// their own durability on `put`. The two artifacts are fsync'd body-first then
245/// pointer, matching the publish order, so a crash mid-barrier never leaves
246/// `latest` pointing at a non-durable manifest. Paths mirror the private
247/// `SnapshotManager::manifest_path` / `SnapshotManager::latest_ptr_path` helpers.
248pub fn fsync_snapshot_pointer(
249    local_root: Option<&std::path::Path>,
250    fork_id: Option<&ForkId>,
251    snapshot_id: &str,
252) -> std::io::Result<()> {
253    let Some(root) = local_root else {
254        return Ok(());
255    };
256    // Mirror `SnapshotManager::catalog_prefix`: forks live under
257    // `catalog/forks/{fork_id}/` so the barrier fsyncs the fork's own
258    // manifest + pointer, not the (nonexistent) global paths (review C1).
259    let prefix = match fork_id {
260        Some(id) => root.join("catalog").join("forks").join(id.to_string()),
261        None => root.join("catalog"),
262    };
263    let manifest = prefix.join("manifests").join(format!("{snapshot_id}.json"));
264    let latest = prefix.join("latest");
265    crate::runtime::wal::sync_file_and_parent(&manifest)?;
266    crate::runtime::wal::sync_file_and_parent(&latest)?;
267    Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    /// Remote/object stores have no local root — the barrier is a no-op and
275    /// must never error (durability is the backend's responsibility there).
276    #[test]
277    fn test_fsync_snapshot_pointer_noop_for_remote() {
278        assert!(fsync_snapshot_pointer(None, None, "snap-1").is_ok());
279    }
280
281    /// On a local filesystem the manifest body and `catalog/latest` pointer are
282    /// fsync'd in place (review C4).
283    #[test]
284    fn test_fsync_snapshot_pointer_syncs_local_artifacts() {
285        let dir = tempfile::TempDir::new().unwrap();
286        let root = dir.path();
287        let manifests = root.join("catalog").join("manifests");
288        std::fs::create_dir_all(&manifests).unwrap();
289        std::fs::write(manifests.join("snap-1.json"), b"{}").unwrap();
290        std::fs::write(root.join("catalog").join("latest"), b"snap-1").unwrap();
291
292        fsync_snapshot_pointer(Some(root), None, "snap-1").unwrap();
293    }
294}