Skip to main content

syncweb_core/sync/
checkpoint.rs

1use std::collections::HashSet;
2
3use iroh_blobs::Hash;
4use iroh_docs::NamespaceId;
5
6use crate::{Result, SyncwebError, storage::node_db::NodeDatabase};
7
8/// Status of a sync entry within a checkpoint session.
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10#[non_exhaustive]
11pub enum EntryStatus {
12    #[default]
13    Completed,
14    Failed,
15    Skipped,
16}
17
18impl EntryStatus {
19    #[must_use]
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Completed => "completed",
23            Self::Failed => "failed",
24            Self::Skipped => "skipped",
25        }
26    }
27}
28
29impl TryFrom<&str> for EntryStatus {
30    type Error = SyncwebError;
31
32    fn try_from(value: &str) -> Result<Self> {
33        match value {
34            "completed" => Ok(Self::Completed),
35            "failed" => Ok(Self::Failed),
36            "skipped" => Ok(Self::Skipped),
37            other => Err(SyncwebError::InvalidConfig(format!("invalid entry status: {other}"))),
38        }
39    }
40}
41
42/// Manages sync session checkpointing for crash recovery.
43///
44/// Tracks which entries have been processed, failed, or skipped during a sync
45/// session so that interrupted syncs can resume without re-downloading
46/// already-transferred blobs.
47#[derive(Clone, Debug)]
48pub struct SyncCheckpoint {
49    namespace_id: NamespaceId,
50    session_id: String,
51    database: NodeDatabase,
52}
53
54/// Progress for a single entry within a sync session.
55#[derive(Clone, Debug)]
56#[non_exhaustive]
57pub struct EntryProgress {
58    pub entry_key: Vec<u8>,
59    pub hash: Hash,
60    pub size: u64,
61    pub status: String,
62    pub retries: u32,
63    pub error_message: Option<String>,
64}
65
66/// Aggregate progress for a sync session.
67#[derive(Clone, Copy, Debug)]
68#[non_exhaustive]
69pub struct CheckpointProgress {
70    pub total: usize,
71    pub completed: usize,
72    pub failed: usize,
73    pub skipped: usize,
74    pub pending: usize,
75    pub bytes_transferred: u64,
76    pub bytes_total: Option<u64>,
77    pub percentage: f64,
78}
79
80impl SyncCheckpoint {
81    /// Create a new checkpoint manager for the given namespace.
82    #[must_use]
83    pub fn new(database: &NodeDatabase, namespace_id: NamespaceId) -> Self {
84        let session_id = uuid::Uuid::new_v4().to_string();
85        Self {
86            namespace_id,
87            session_id,
88            database: database.clone(),
89        }
90    }
91
92    /// The current session ID.
93    #[must_use]
94    pub fn session_id(&self) -> &str {
95        &self.session_id
96    }
97
98    /// The namespace ID for this checkpoint.
99    #[must_use]
100    pub const fn namespace_id(&self) -> NamespaceId {
101        self.namespace_id
102    }
103
104    /// Create a new sync session in the database and return the checkpoint.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the database write fails.
109    pub fn create_session(database: &NodeDatabase, namespace_id: NamespaceId) -> Result<Self> {
110        let cp = Self::new(database, namespace_id);
111        database.create_sync_checkpoint(&cp.namespace_id.to_string(), &cp.session_id)?;
112        Ok(cp)
113    }
114
115    /// Mark an entry as completed (downloaded successfully).
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the database write fails.
120    pub fn mark_completed(&self, entry_key: &[u8], hash: Hash, size: u64) -> Result<()> {
121        self.database
122            .upsert_sync_entry(&crate::storage::node_db::SyncEntryParams {
123                namespace_id: &self.namespace_id.to_string(),
124                session_id: &self.session_id,
125                entry_key,
126                hash: &hash.to_string().into_bytes(),
127                size,
128                status: EntryStatus::Completed.as_str(),
129                retries: 0,
130                error_message: None,
131            })
132    }
133
134    /// Mark an entry as failed with error message.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the database write fails.
139    pub fn mark_failed(&self, entry_key: &[u8], error: &str) -> Result<()> {
140        self.database
141            .upsert_sync_entry(&crate::storage::node_db::SyncEntryParams {
142                namespace_id: &self.namespace_id.to_string(),
143                session_id: &self.session_id,
144                entry_key,
145                hash: &[],
146                size: 0,
147                status: EntryStatus::Failed.as_str(),
148                retries: 0,
149                error_message: Some(error),
150            })
151    }
152
153    /// Mark an entry as skipped (already present locally or deleted).
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the database write fails.
158    pub fn mark_skipped(&self, entry_key: &[u8], hash: Hash, size: u64) -> Result<()> {
159        self.database
160            .upsert_sync_entry(&crate::storage::node_db::SyncEntryParams {
161                namespace_id: &self.namespace_id.to_string(),
162                session_id: &self.session_id,
163                entry_key,
164                hash: &hash.to_string().into_bytes(),
165                size,
166                status: EntryStatus::Skipped.as_str(),
167                retries: 0,
168                error_message: None,
169            })
170    }
171
172    /// Get all completed entries for this session (for resume filtering).
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the database query fails.
177    pub fn completed_entries(&self) -> Result<Vec<EntryProgress>> {
178        self.database.list_sync_entries(
179            &self.namespace_id.to_string(),
180            &self.session_id,
181            EntryStatus::Completed.as_str(),
182        )
183    }
184
185    /// Get all failed entries for this session (for retry).
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if the database query fails.
190    pub fn failed_entries(&self) -> Result<Vec<EntryProgress>> {
191        self.database.list_sync_entries(
192            &self.namespace_id.to_string(),
193            &self.session_id,
194            EntryStatus::Failed.as_str(),
195        )
196    }
197
198    /// Get all completed entry keys as a `HashSet` for fast lookup.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if the database query fails.
203    pub fn completed_keys(&self) -> Result<HashSet<Vec<u8>>> {
204        let entries = self.completed_entries()?;
205        Ok(entries.into_iter().map(|e| e.entry_key).collect())
206    }
207
208    /// Get overall progress.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the database query fails.
213    pub fn progress(&self) -> Result<CheckpointProgress> {
214        self.database
215            .get_checkpoint_progress(&self.namespace_id.to_string(), &self.session_id)
216    }
217
218    /// Mark session as completed.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the database write fails.
223    pub fn complete(&self) -> Result<()> {
224        self.database
225            .update_checkpoint_status(&self.namespace_id.to_string(), &self.session_id, "completed")
226    }
227
228    /// Leave the session status as `running` so it is picked up on resume.
229    ///
230    /// Unlike [`complete`](Self::complete), this does not persist a terminal
231    /// status.  The checkpoint remains active for the next daemon start.
232    ///
233    /// # Errors
234    ///
235    /// This method always returns `Ok(())`.
236    pub const fn leave_unfinished(&self) -> Result<()> {
237        // Leave the status as 'running' so it's picked up on resume
238        Ok(())
239    }
240
241    /// Delete the checkpoint and its entries.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the database write fails.
246    pub fn delete(&self) -> Result<()> {
247        self.database
248            .delete_sync_checkpoint(&self.namespace_id.to_string(), &self.session_id)
249    }
250
251    /// Load the most recent unfinished checkpoint for a folder.
252    ///
253    /// Returns `None` if no unfinished checkpoint exists.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the database query fails.
258    pub fn resume(database: &NodeDatabase, namespace_id: NamespaceId) -> Result<Option<Self>> {
259        let ns = namespace_id.to_string();
260        Ok(database.find_unfinished_checkpoint(&ns)?.map(|sid| Self {
261            namespace_id,
262            session_id: sid,
263            database: database.clone(),
264        }))
265    }
266}