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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
15pub struct Checkpoint {
16 pub id: CheckpointId,
18 pub revision: u64,
20 pub run_id: RunId,
22 pub kind: String,
24 pub schema_version: u32,
26 pub payload: Value,
28 pub updated_at_ms: u64,
30}
31
32impl Checkpoint {
33 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 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
75pub trait CheckpointStore: Send + Sync {
77 fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError>;
84
85 fn compare_and_swap(
95 &self,
96 checkpoint: &Checkpoint,
97 expected_revision: Option<u64>,
98 ) -> Result<(), CheckpointError>;
99}
100
101#[derive(Clone, Debug, Default)]
103pub struct InMemoryCheckpointStore {
104 checkpoints: Arc<Mutex<BTreeMap<CheckpointId, Checkpoint>>>,
105}
106
107impl InMemoryCheckpointStore {
108 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#[derive(Clone, Debug, Eq, PartialEq)]
167#[non_exhaustive]
168pub enum CheckpointErrorKind {
169 NotFound,
171 Conflict,
173 InvalidPayload,
175 Storage,
177}
178
179#[derive(Clone, Debug, Error, Eq, PartialEq)]
181#[error("{kind:?}: {message}")]
182pub struct CheckpointError {
183 pub kind: CheckpointErrorKind,
185 pub message: String,
187}
188
189impl CheckpointError {
190 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}