Skip to main content

reddb_server/runtime/
serverless_snapshot_publication.rs

1//! Lease-generation-fenced publication of serverless snapshots.
2//!
3//! Payloads are uploaded under immutable keys. Only the small `HEAD.json`
4//! pointer is mutable, and every owner claims and advances it with backend CAS.
5//! A stale process may finish an expensive payload upload after its lease has
6//! expired, but it cannot make that payload current after a successor claims
7//! the head under a newer lease generation.
8
9use std::path::Path;
10use std::sync::{Arc, Mutex};
11
12use crate::replication::lease::WriterLease;
13use crate::storage::backend::{
14    AtomicRemoteBackend, BackendError, BackendObjectVersion, ConditionalPut,
15};
16
17#[derive(Debug)]
18pub enum SnapshotPublicationError {
19    Backend(BackendError),
20    InvalidState(String),
21    Fenced(String),
22}
23
24impl std::fmt::Display for SnapshotPublicationError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::Backend(err) => write!(f, "snapshot publication backend error: {err}"),
28            Self::InvalidState(message) => write!(f, "snapshot publication state error: {message}"),
29            Self::Fenced(message) => write!(f, "snapshot publication fenced: {message}"),
30        }
31    }
32}
33
34impl std::error::Error for SnapshotPublicationError {}
35
36impl From<BackendError> for SnapshotPublicationError {
37    fn from(value: BackendError) -> Self {
38        Self::Backend(value)
39    }
40}
41
42#[derive(Clone)]
43struct VersionedHead {
44    head: reddb_file::ServerlessSnapshotHead,
45    version: BackendObjectVersion,
46}
47
48enum PublicationState {
49    Unobserved,
50    Observed(Option<VersionedHead>),
51    Claimed(VersionedHead),
52    Fenced,
53}
54
55pub struct SnapshotPublicationStore {
56    backend: Arc<dyn AtomicRemoteBackend>,
57    database_key: String,
58    head_key: String,
59    state: Mutex<PublicationState>,
60}
61
62impl SnapshotPublicationStore {
63    pub fn new(backend: Arc<dyn AtomicRemoteBackend>, database_key: impl Into<String>) -> Self {
64        let database_key = database_key.into();
65        let head_key = reddb_file::serverless_snapshot_head_key(&database_key);
66        Self {
67            backend,
68            database_key,
69            head_key,
70            state: Mutex::new(PublicationState::Unobserved),
71        }
72    }
73
74    /// Observe the exact head version used to hydrate this boot. The later
75    /// claim must CAS this version; if publication advances while the runtime
76    /// is opening, the claim fails and boot retries from the newer snapshot.
77    pub fn observe_for_boot(&self) -> Result<Option<String>, SnapshotPublicationError> {
78        let mut state = self
79            .state
80            .lock()
81            .expect("poisoned snapshot publication state");
82        match &*state {
83            PublicationState::Unobserved => {}
84            PublicationState::Observed(head) => {
85                return Ok(head.as_ref().map(|head| head.head.snapshot_key.clone()));
86            }
87            PublicationState::Claimed(head) => {
88                return Ok(Some(head.head.snapshot_key.clone()));
89            }
90            PublicationState::Fenced => {
91                return Err(SnapshotPublicationError::Fenced(
92                    "publication head was already superseded".to_string(),
93                ));
94            }
95        }
96
97        let observed = self.read_versioned_head()?;
98        let snapshot_key = observed.as_ref().map(|head| head.head.snapshot_key.clone());
99        *state = PublicationState::Observed(observed);
100        Ok(snapshot_key)
101    }
102
103    /// Claim the observed head for `lease`. On the first serverless boot there
104    /// is no head yet, so the already-open local database is uploaded once as
105    /// an initial immutable snapshot and installed with `IfAbsent`.
106    pub fn claim(
107        &self,
108        lease: &WriterLease,
109        local_snapshot: &Path,
110    ) -> Result<(), SnapshotPublicationError> {
111        let mut state = self
112            .state
113            .lock()
114            .expect("poisoned snapshot publication state");
115        let observed = match &*state {
116            PublicationState::Observed(observed) => observed.clone(),
117            PublicationState::Unobserved => {
118                return Err(SnapshotPublicationError::InvalidState(
119                    "head must be observed before lease acquisition".to_string(),
120                ));
121            }
122            PublicationState::Claimed(_) => {
123                return Err(SnapshotPublicationError::InvalidState(
124                    "head is already claimed".to_string(),
125                ));
126            }
127            PublicationState::Fenced => {
128                return Err(SnapshotPublicationError::Fenced(
129                    "publication head was already superseded".to_string(),
130                ));
131            }
132        };
133
134        let (next, condition) = match observed {
135            Some(current) => {
136                if current.head.database_key != self.database_key {
137                    return Err(SnapshotPublicationError::InvalidState(format!(
138                        "head database '{}' does not match '{}'",
139                        current.head.database_key, self.database_key
140                    )));
141                }
142                let mut next = current.head;
143                // Release deletes the lease object, so generations may repeat.
144                // Reserve a sequence on every claim to avoid both payload-key
145                // reuse and identical HEAD bytes (ABA) across clean restarts.
146                next.publication_sequence =
147                    next.publication_sequence.checked_add(1).ok_or_else(|| {
148                        SnapshotPublicationError::InvalidState("snapshot sequence exhausted".into())
149                    })?;
150                next.holder_id = lease.holder_id.clone();
151                next.term = lease.term;
152                next.lease_generation = lease.generation;
153                (next, ConditionalPut::IfVersion(current.version))
154            }
155            None => {
156                let publication_sequence = 1;
157                let snapshot_key = reddb_file::serverless_snapshot_key(
158                    &self.database_key,
159                    lease.generation,
160                    publication_sequence,
161                );
162                self.backend.upload_conditional(
163                    local_snapshot,
164                    &snapshot_key,
165                    ConditionalPut::IfAbsent,
166                )?;
167                (
168                    reddb_file::ServerlessSnapshotHead {
169                        database_key: self.database_key.clone(),
170                        holder_id: lease.holder_id.clone(),
171                        term: lease.term,
172                        lease_generation: lease.generation,
173                        publication_sequence,
174                        snapshot_key,
175                    },
176                    ConditionalPut::IfAbsent,
177                )
178            }
179        };
180
181        match self.publish_head(&next, condition) {
182            Ok(version) => {
183                *state = PublicationState::Claimed(VersionedHead {
184                    head: next,
185                    version,
186                });
187                Ok(())
188            }
189            Err(SnapshotPublicationError::Backend(BackendError::PreconditionFailed(message))) => {
190                *state = PublicationState::Fenced;
191                Err(SnapshotPublicationError::Fenced(message))
192            }
193            Err(err) => Err(err),
194        }
195    }
196
197    pub fn publish(
198        &self,
199        lease: &WriterLease,
200        local_snapshot: &Path,
201    ) -> Result<(), SnapshotPublicationError> {
202        let mut state = self
203            .state
204            .lock()
205            .expect("poisoned snapshot publication state");
206        let current = match &*state {
207            PublicationState::Claimed(current) => current.clone(),
208            PublicationState::Fenced => {
209                return Err(SnapshotPublicationError::Fenced(
210                    "publication head was superseded".to_string(),
211                ));
212            }
213            _ => {
214                return Err(SnapshotPublicationError::InvalidState(
215                    "publication requires a claimed head".to_string(),
216                ));
217            }
218        };
219        if current.head.fencing_token() != lease.fencing_token()
220            || current.head.holder_id != lease.holder_id
221        {
222            *state = PublicationState::Fenced;
223            return Err(SnapshotPublicationError::Fenced(format!(
224                "lease {} generation {} does not own head generation {}",
225                lease.holder_id, lease.generation, current.head.lease_generation
226            )));
227        }
228
229        let publication_sequence = current
230            .head
231            .publication_sequence
232            .checked_add(1)
233            .ok_or_else(|| {
234                SnapshotPublicationError::InvalidState("snapshot sequence exhausted".into())
235            })?;
236        let snapshot_key = reddb_file::serverless_snapshot_key(
237            &self.database_key,
238            lease.generation,
239            publication_sequence,
240        );
241        self.backend
242            .upload_conditional(local_snapshot, &snapshot_key, ConditionalPut::IfAbsent)?;
243
244        let mut next = current.head;
245        next.publication_sequence = publication_sequence;
246        next.snapshot_key = snapshot_key;
247        match self.publish_head(&next, ConditionalPut::IfVersion(current.version)) {
248            Ok(version) => {
249                *state = PublicationState::Claimed(VersionedHead {
250                    head: next,
251                    version,
252                });
253                Ok(())
254            }
255            Err(SnapshotPublicationError::Backend(BackendError::PreconditionFailed(message))) => {
256                *state = PublicationState::Fenced;
257                Err(SnapshotPublicationError::Fenced(message))
258            }
259            Err(err) => Err(err),
260        }
261    }
262
263    fn read_versioned_head(&self) -> Result<Option<VersionedHead>, SnapshotPublicationError> {
264        let before = match self.backend.object_version(&self.head_key)? {
265            Some(version) => version,
266            None => return Ok(None),
267        };
268        let temp = reddb_file::ServerlessWriterLeaseTempFile::new("snapshot-head-read");
269        if !self.backend.download(&self.head_key, temp.path())? {
270            return Ok(None);
271        }
272        if self.backend.object_version(&self.head_key)?.as_ref() != Some(&before) {
273            return Err(SnapshotPublicationError::Backend(
274                BackendError::PreconditionFailed(
275                    "serverless snapshot head changed while being read".to_string(),
276                ),
277            ));
278        }
279        let bytes = temp
280            .read_bytes()
281            .map_err(|err| BackendError::Transport(err.to_string()))?;
282        let head = reddb_file::decode_serverless_snapshot_head_json(&bytes)
283            .map_err(|err| BackendError::Internal(err.to_string()))?;
284        Ok(Some(VersionedHead {
285            head,
286            version: before,
287        }))
288    }
289
290    fn publish_head(
291        &self,
292        head: &reddb_file::ServerlessSnapshotHead,
293        condition: ConditionalPut,
294    ) -> Result<BackendObjectVersion, SnapshotPublicationError> {
295        let bytes = reddb_file::encode_serverless_snapshot_head_json(head)
296            .map_err(|err| BackendError::Internal(err.to_string()))?;
297        let temp = reddb_file::ServerlessWriterLeaseTempFile::new("snapshot-head-write");
298        temp.write_bytes(&bytes)
299            .map_err(|err| BackendError::Transport(err.to_string()))?;
300        Ok(self
301            .backend
302            .upload_conditional(temp.path(), &self.head_key, condition)?)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::storage::backend::LocalBackend;
310
311    fn lease(holder: &str, generation: u64) -> WriterLease {
312        WriterLease {
313            database_key: "main".to_string(),
314            holder_id: holder.to_string(),
315            term: 1,
316            generation,
317            acquired_at_ms: 1,
318            expires_at_ms: u64::MAX,
319        }
320    }
321
322    fn read_current_snapshot(database_key: &str) -> Vec<u8> {
323        let head_path = reddb_file::serverless_snapshot_head_key(database_key);
324        let head_bytes = std::fs::read(head_path).expect("read publication head");
325        let head = reddb_file::decode_serverless_snapshot_head_json(&head_bytes)
326            .expect("decode publication head");
327        std::fs::read(head.snapshot_key).expect("read current immutable snapshot")
328    }
329
330    #[test]
331    fn successor_claim_fences_delayed_old_writer_publication() {
332        let root = tempfile::tempdir().expect("publication directory");
333        let database_key = root.path().join("remote").join("main.rdb");
334        let database_key = database_key.to_string_lossy().to_string();
335        let old_local = root.path().join("old.rdb");
336        let new_local = root.path().join("new.rdb");
337        std::fs::write(&old_local, b"old-snapshot").expect("write old local snapshot");
338        std::fs::write(&new_local, b"new-snapshot").expect("write new local snapshot");
339
340        let old = SnapshotPublicationStore::new(Arc::new(LocalBackend), database_key.clone());
341        assert_eq!(old.observe_for_boot().expect("observe empty head"), None);
342        old.claim(&lease("old", 1), &old_local)
343            .expect("old writer claims initial head");
344
345        let successor = SnapshotPublicationStore::new(Arc::new(LocalBackend), database_key.clone());
346        assert!(successor
347            .observe_for_boot()
348            .expect("observe old head")
349            .is_some());
350        successor
351            .claim(&lease("new", 2), &old_local)
352            .expect("successor claims head under new generation");
353
354        assert!(matches!(
355            old.publish(&lease("old", 1), &old_local),
356            Err(SnapshotPublicationError::Fenced(_))
357        ));
358        successor
359            .publish(&lease("new", 2), &new_local)
360            .expect("successor publishes current snapshot");
361        assert_eq!(read_current_snapshot(&database_key), b"new-snapshot");
362    }
363
364    #[test]
365    fn reused_lease_identity_cannot_restore_an_old_heads_cas_version() {
366        let root = tempfile::tempdir().expect("publication directory");
367        let database_key = root.path().join("main.rdb").to_string_lossy().to_string();
368        let local = root.path().join("local.rdb");
369        std::fs::write(&local, b"committed").expect("local snapshot");
370        let old = SnapshotPublicationStore::new(Arc::new(LocalBackend), database_key.clone());
371        old.observe_for_boot().expect("observe first boot");
372        old.claim(&lease("same-holder", 1), &local)
373            .expect("first claim");
374        // A clean release removes the lease key. Even with the same holder,
375        // term and generation, the next boot must change the HEAD CAS version.
376        let successor = SnapshotPublicationStore::new(Arc::new(LocalBackend), database_key.clone());
377        successor.observe_for_boot().expect("observe next boot");
378        successor
379            .claim(&lease("same-holder", 1), &local)
380            .expect("reused identity claims");
381        std::fs::write(&local, b"stale").expect("old writer resumes");
382        assert!(matches!(
383            old.publish(&lease("same-holder", 1), &local),
384            Err(SnapshotPublicationError::Fenced(_))
385        ));
386        assert_eq!(read_current_snapshot(&database_key), b"committed");
387    }
388
389    #[test]
390    fn boot_claim_fails_when_head_advanced_during_hydration() {
391        let root = tempfile::tempdir().expect("publication directory");
392        let database_key = root.path().join("remote").join("main.rdb");
393        let database_key = database_key.to_string_lossy().to_string();
394        let first = root.path().join("first.rdb");
395        let second = root.path().join("second.rdb");
396        std::fs::write(&first, b"first").expect("write first snapshot");
397        std::fs::write(&second, b"second").expect("write second snapshot");
398
399        let writer = SnapshotPublicationStore::new(Arc::new(LocalBackend), database_key.clone());
400        writer.observe_for_boot().expect("observe empty head");
401        writer
402            .claim(&lease("writer", 1), &first)
403            .expect("claim first head");
404
405        let booting = SnapshotPublicationStore::new(Arc::new(LocalBackend), database_key);
406        booting.observe_for_boot().expect("observe first head");
407        writer
408            .publish(&lease("writer", 1), &second)
409            .expect("advance head while successor hydrates");
410
411        assert!(matches!(
412            booting.claim(&lease("successor", 2), &first),
413            Err(SnapshotPublicationError::Fenced(_))
414        ));
415    }
416}