Skip to main content

runifold_core/
checkpoint.rs

1use std::{
2    collections::BTreeMap,
3    sync::{Arc, Mutex, MutexGuard},
4    time::{SystemTime, UNIX_EPOCH},
5};
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use thiserror::Error;
10
11use crate::{CheckpointId, RunId};
12
13/// Versioned, domain-neutral persisted execution state.
14#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
15pub struct Checkpoint {
16    /// Stable checkpoint identity across revisions.
17    pub id: CheckpointId,
18    /// Monotonic compare-and-swap revision.
19    pub revision: u64,
20    /// Run that owns the checkpoint.
21    pub run_id: RunId,
22    /// Namespaced payload kind.
23    pub kind: String,
24    /// Schema version owned by `kind`.
25    pub schema_version: u32,
26    /// Domain-owned serializable state.
27    pub payload: Value,
28    /// Milliseconds since the Unix epoch.
29    pub updated_at_ms: u64,
30}
31
32impl Checkpoint {
33    /// Creates revision zero of a checkpoint.
34    pub fn initial(
35        id: CheckpointId,
36        run_id: RunId,
37        kind: impl Into<String>,
38        schema_version: u32,
39        payload: Value,
40    ) -> Self {
41        Self {
42            id,
43            revision: 0,
44            run_id,
45            kind: kind.into(),
46            schema_version,
47            payload,
48            updated_at_ms: now_ms(),
49        }
50    }
51
52    /// Creates the next revision with a replacement payload.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`CheckpointError`] if the revision counter overflows.
57    pub fn next(&self, payload: Value) -> Result<Self, CheckpointError> {
58        Ok(Self {
59            id: self.id,
60            revision: self.revision.checked_add(1).ok_or_else(|| {
61                CheckpointError::new(
62                    CheckpointErrorKind::Conflict,
63                    "checkpoint revision overflow",
64                )
65            })?,
66            run_id: self.run_id,
67            kind: self.kind.clone(),
68            schema_version: self.schema_version,
69            payload,
70            updated_at_ms: now_ms(),
71        })
72    }
73}
74
75/// Atomic persistence boundary for checkpoints.
76pub trait CheckpointStore: Send + Sync {
77    /// Loads the latest checkpoint revision.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`CheckpointError`] when storage fails or the checkpoint does
82    /// not exist.
83    fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError>;
84
85    /// Creates or atomically replaces a checkpoint.
86    ///
87    /// `expected_revision = None` means create-only. An existing checkpoint
88    /// makes that operation conflict. Updates must provide the exact current
89    /// revision and a checkpoint whose revision is one greater.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`CheckpointError`] on storage failure or revision conflict.
94    fn compare_and_swap(
95        &self,
96        checkpoint: &Checkpoint,
97        expected_revision: Option<u64>,
98    ) -> Result<(), CheckpointError>;
99}
100
101/// Cloneable in-memory checkpoint store for tests and ephemeral processes.
102#[derive(Clone, Debug, Default)]
103pub struct InMemoryCheckpointStore {
104    checkpoints: Arc<Mutex<BTreeMap<CheckpointId, Checkpoint>>>,
105}
106
107impl InMemoryCheckpointStore {
108    /// Creates an empty store.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    fn checkpoints(&self) -> MutexGuard<'_, BTreeMap<CheckpointId, Checkpoint>> {
114        self.checkpoints
115            .lock()
116            .unwrap_or_else(std::sync::PoisonError::into_inner)
117    }
118}
119
120impl CheckpointStore for InMemoryCheckpointStore {
121    fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError> {
122        self.checkpoints().get(&id).cloned().ok_or_else(|| {
123            CheckpointError::new(
124                CheckpointErrorKind::NotFound,
125                format!("checkpoint `{id}` does not exist"),
126            )
127        })
128    }
129
130    fn compare_and_swap(
131        &self,
132        checkpoint: &Checkpoint,
133        expected_revision: Option<u64>,
134    ) -> Result<(), CheckpointError> {
135        let mut checkpoints = self.checkpoints();
136        let current = checkpoints.get(&checkpoint.id);
137        match (current, expected_revision) {
138            (None, None) if checkpoint.revision == 0 => {}
139            (Some(current), Some(expected))
140                if current.revision == expected
141                    && expected
142                        .checked_add(1)
143                        .is_some_and(|next| checkpoint.revision == next) => {}
144            (None, Some(_)) => {
145                return Err(CheckpointError::new(
146                    CheckpointErrorKind::NotFound,
147                    format!("checkpoint `{}` does not exist", checkpoint.id),
148                ));
149            }
150            _ => {
151                return Err(CheckpointError::new(
152                    CheckpointErrorKind::Conflict,
153                    format!(
154                        "checkpoint `{}` revision precondition failed",
155                        checkpoint.id
156                    ),
157                ));
158            }
159        }
160        checkpoints.insert(checkpoint.id, checkpoint.clone());
161        Ok(())
162    }
163}
164
165/// Normalized checkpoint storage failure category.
166#[derive(Clone, Debug, Eq, PartialEq)]
167#[non_exhaustive]
168pub enum CheckpointErrorKind {
169    /// The requested checkpoint does not exist.
170    NotFound,
171    /// A create or revision precondition failed.
172    Conflict,
173    /// Serialized state violated its domain schema.
174    InvalidPayload,
175    /// The backing store failed.
176    Storage,
177}
178
179/// Structured checkpoint failure.
180#[derive(Clone, Debug, Error, Eq, PartialEq)]
181#[error("{kind:?}: {message}")]
182pub struct CheckpointError {
183    /// Normalized category.
184    pub kind: CheckpointErrorKind,
185    /// Safe failure explanation.
186    pub message: String,
187}
188
189impl CheckpointError {
190    /// Creates a checkpoint error.
191    pub fn new(kind: CheckpointErrorKind, message: impl Into<String>) -> Self {
192        Self {
193            kind,
194            message: message.into(),
195        }
196    }
197}
198
199fn now_ms() -> u64 {
200    SystemTime::now()
201        .duration_since(UNIX_EPOCH)
202        .map_or(0, |duration| {
203            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
204        })
205}
206
207#[cfg(test)]
208mod tests {
209    use serde_json::json;
210
211    use super::{Checkpoint, CheckpointErrorKind, CheckpointStore, InMemoryCheckpointStore};
212    use crate::{CheckpointId, RunId};
213
214    #[test]
215    fn compare_and_swap_rejects_stale_writers() {
216        let store = InMemoryCheckpointStore::new();
217        let first = Checkpoint::initial(
218            CheckpointId::new(),
219            RunId::new(),
220            "test",
221            1,
222            json!({"value": 1}),
223        );
224        store.compare_and_swap(&first, None).unwrap();
225        let second = first.next(json!({"value": 2})).unwrap();
226        store.compare_and_swap(&second, Some(0)).unwrap();
227        let stale = first.next(json!({"value": 3})).unwrap();
228
229        let error = store.compare_and_swap(&stale, Some(0)).unwrap_err();
230
231        assert_eq!(error.kind, CheckpointErrorKind::Conflict);
232        assert_eq!(store.load(first.id).unwrap(), second);
233    }
234}