1use 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 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 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 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 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}