1#![allow(clippy::disallowed_types)] use std::fs::{File, OpenOptions};
22use std::io::{Read, Write};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use async_trait::async_trait;
27use bytes::BytesMut;
28use object_store::{
29 GetOptions, GetRange, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload,
30 UpdateVersion,
31};
32use rand::RngExt;
33use sha2::{Digest, Sha256};
34use tracing::warn;
35
36use crate::checkpoint::checkpoint_manifest::{
37 CheckpointManifest, DurableCheckpointPhase, OperatorCheckpoint,
38};
39use crate::durable_fs::{durable_rename, ensure_durable_directory, DurableRenameMode};
40use crate::state::{KeyGroupCount, LOCAL_KEY_GROUP_COUNT};
41
42const MAX_LATEST_POINTER_BYTES: u64 = 1_024;
43const MAX_MANIFEST_BYTES: u64 = 16 * 1_024 * 1_024;
44pub const DEFAULT_MAX_CHECKPOINT_STATE_BYTES: u64 = 512 * 1024 * 1024;
47pub const MAX_CHECKPOINT_INVENTORY_ENTRIES: usize = 65_536;
49const MAX_CAS_ATTEMPTS: usize = 16;
50
51pub fn validate_max_checkpoint_state_bytes(limit: u64) -> Result<(), CheckpointStoreError> {
58 if limit == 0 {
59 return Err(CheckpointStoreError::Invalid(
60 "checkpoint state sidecar safety limit must be greater than zero".into(),
61 ));
62 }
63 if limit.checked_add(1).is_none() {
64 return Err(CheckpointStoreError::Invalid(
65 "checkpoint state sidecar safety limit must leave room for a one-byte overflow probe"
66 .into(),
67 ));
68 }
69 if usize::try_from(limit).is_err() || isize::try_from(limit).is_err() {
70 return Err(CheckpointStoreError::Invalid(format!(
71 "checkpoint state sidecar safety limit {limit} exceeds this process address space"
72 )));
73 }
74 Ok(())
75}
76
77async fn checkpoint_cas_backoff(attempt: usize) {
78 if attempt == 0 {
79 tokio::task::yield_now().await;
80 return;
81 }
82 let base_ms = 1_u64 << attempt.saturating_sub(1).min(5);
83 let jitter_ms = rand::rng().random_range(0..=base_ms);
84 tokio::time::sleep(std::time::Duration::from_millis(base_ms + jitter_ms)).await;
85}
86
87fn exact_phase_successor(prepared: &CheckpointManifest, finalized: &CheckpointManifest) -> bool {
88 if prepared.durable_phase != DurableCheckpointPhase::Prepared
89 || finalized.durable_phase != DurableCheckpointPhase::Finalized
90 {
91 return false;
92 }
93 let mut expected = prepared.clone();
94 expected.durable_phase = DurableCheckpointPhase::Finalized;
95 expected == *finalized
96}
97
98struct TemporaryFile(PathBuf);
99
100impl Drop for TemporaryFile {
101 fn drop(&mut self) {
102 let _ = std::fs::remove_file(&self.0);
103 }
104}
105
106fn unique_temporary_path(destination: &Path) -> Result<PathBuf, std::io::Error> {
107 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
108 let name = destination
109 .file_name()
110 .ok_or_else(|| std::io::Error::other("checkpoint destination has no file name"))?
111 .to_string_lossy();
112 Ok(parent.join(format!(".{name}#{}", uuid::Uuid::new_v4().as_u128())))
113}
114
115fn write_synced_file(path: &Path, chunks: &[bytes::Bytes]) -> Result<(), std::io::Error> {
116 let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
117 for chunk in chunks {
118 file.write_all(chunk)?;
119 }
120 file.sync_all()
121}
122
123fn oversized_metadata(kind: &str, size: u64, limit: u64) -> CheckpointStoreError {
124 CheckpointStoreError::Invalid(format!(
125 "{kind} is {size} bytes, exceeding the {limit}-byte safety limit"
126 ))
127}
128
129fn read_bounded_file(
130 path: &Path,
131 limit: u64,
132 kind: &str,
133) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
134 let metadata = match std::fs::symlink_metadata(path) {
135 Ok(metadata) if metadata.file_type().is_file() => metadata,
136 Ok(_) => {
137 return Err(CheckpointStoreError::Invalid(format!(
138 "{kind} '{}' is not a regular file",
139 path.display()
140 )));
141 }
142 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
143 Err(error) => return Err(error.into()),
144 };
145 if metadata.len() > limit {
146 return Err(oversized_metadata(kind, metadata.len(), limit));
147 }
148
149 let mut file = File::open(path)?;
150 let capacity = usize::try_from(metadata.len()).map_err(|_| {
151 CheckpointStoreError::Invalid(format!(
152 "{kind} size {} cannot be represented by this process",
153 metadata.len()
154 ))
155 })?;
156 let mut bytes = Vec::with_capacity(capacity);
157 std::io::Read::by_ref(&mut file)
158 .take(limit + 1)
159 .read_to_end(&mut bytes)?;
160 if bytes.len() as u64 > limit {
161 return Err(oversized_metadata(kind, bytes.len() as u64, limit));
162 }
163 Ok(Some(bytes))
164}
165
166fn open_bounded_regular_file(
167 path: &Path,
168 limit: u64,
169 kind: &str,
170) -> Result<Option<(File, u64)>, CheckpointStoreError> {
171 let path_metadata = match std::fs::symlink_metadata(path) {
172 Ok(metadata) if metadata.file_type().is_file() => metadata,
173 Ok(_) => {
174 return Err(CheckpointStoreError::Invalid(format!(
175 "{kind} '{}' is not a regular file",
176 path.display()
177 )));
178 }
179 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
180 Err(error) => return Err(error.into()),
181 };
182 if path_metadata.len() > limit {
183 return Err(oversized_metadata(kind, path_metadata.len(), limit));
184 }
185
186 let file = File::open(path)?;
187 let opened_metadata = file.metadata()?;
188 if !opened_metadata.file_type().is_file() {
189 return Err(CheckpointStoreError::Invalid(format!(
190 "{kind} '{}' is not a regular file",
191 path.display()
192 )));
193 }
194 if opened_metadata.len() > limit {
195 return Err(oversized_metadata(kind, opened_metadata.len(), limit));
196 }
197 if opened_metadata.len() != path_metadata.len() {
198 return Err(CheckpointStoreError::Invalid(format!(
199 "{kind} '{}' changed length while it was opened ({} to {} bytes)",
200 path.display(),
201 path_metadata.len(),
202 opened_metadata.len()
203 )));
204 }
205 Ok(Some((file, opened_metadata.len())))
206}
207
208fn read_bounded_open_file(
209 file: &mut File,
210 expected_len: u64,
211 limit: u64,
212 kind: &str,
213) -> Result<Vec<u8>, CheckpointStoreError> {
214 if expected_len > limit {
215 return Err(oversized_metadata(kind, expected_len, limit));
216 }
217 let capacity = usize::try_from(expected_len).map_err(|_| {
218 CheckpointStoreError::Invalid(format!(
219 "{kind} length {expected_len} exceeds this process address space"
220 ))
221 })?;
222 let mut bytes = Vec::new();
223 bytes.try_reserve_exact(capacity).map_err(|error| {
224 CheckpointStoreError::Invalid(format!(
225 "{kind} cannot reserve its advertised {expected_len}-byte length: {error}"
226 ))
227 })?;
228
229 let mut scratch = [0_u8; 64 * 1024];
232 while bytes.len() < capacity {
233 let remaining = capacity - bytes.len();
234 let read_len = remaining.min(scratch.len());
235 let read = file.read(&mut scratch[..read_len])?;
236 if read == 0 {
237 break;
238 }
239 bytes.extend_from_slice(&scratch[..read]);
240 }
241 let actual_len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
242 if actual_len != expected_len {
243 return Err(CheckpointStoreError::Invalid(format!(
244 "{kind} body length changed from {expected_len} to {actual_len} bytes while reading"
245 )));
246 }
247
248 let mut overflow_probe = [0_u8; 1];
249 let grew = file.read(&mut overflow_probe)? != 0;
250 let final_len = file.metadata()?.len();
251 if final_len > limit {
252 return Err(oversized_metadata(kind, final_len, limit));
253 }
254 if grew {
255 let observed_len = expected_len.saturating_add(1);
256 return Err(CheckpointStoreError::Invalid(format!(
257 "{kind} body grew beyond its advertised {expected_len}-byte length (observed at least {observed_len} bytes)"
258 )));
259 }
260 if final_len != expected_len {
261 return Err(CheckpointStoreError::Invalid(format!(
262 "{kind} length changed from {expected_len} to {final_len} bytes while reading"
263 )));
264 }
265 Ok(bytes)
266}
267
268fn read_bounded_regular_file(
269 path: &Path,
270 limit: u64,
271 kind: &str,
272) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
273 let Some((mut file, expected_len)) = open_bounded_regular_file(path, limit, kind)? else {
274 return Ok(None);
275 };
276 read_bounded_open_file(&mut file, expected_len, limit, kind).map(Some)
277}
278
279fn ensure_serialized_size(kind: &str, size: usize, limit: u64) -> Result<(), CheckpointStoreError> {
280 let size = u64::try_from(size).unwrap_or(u64::MAX);
281 if size > limit {
282 Err(oversized_metadata(kind, size, limit))
283 } else {
284 Ok(())
285 }
286}
287
288fn ensure_loaded_manifest(
289 manifest: &CheckpointManifest,
290 storage_checkpoint_id: u64,
291 storage_participant_id: u64,
292 key_group_count: KeyGroupCount,
293) -> Result<(), CheckpointStoreError> {
294 if manifest.checkpoint_id != storage_checkpoint_id {
295 return Err(CheckpointStoreError::Invalid(format!(
296 "storage checkpoint {storage_checkpoint_id} contains manifest checkpoint {}",
297 manifest.checkpoint_id
298 )));
299 }
300 if manifest.participant_id != storage_participant_id {
301 return Err(CheckpointStoreError::Invalid(format!(
302 "storage participant {storage_participant_id} contains manifest participant {}",
303 manifest.participant_id
304 )));
305 }
306 let errors = manifest.validate(key_group_count);
307 if errors.is_empty() {
308 Ok(())
309 } else {
310 Err(CheckpointStoreError::Invalid(format!(
311 "stored checkpoint {storage_checkpoint_id} manifest validation: {}",
312 errors
313 .iter()
314 .map(ToString::to_string)
315 .collect::<Vec<_>>()
316 .join("; ")
317 )))
318 }
319}
320
321fn open_and_lock(path: &Path) -> Result<File, std::io::Error> {
322 let file = OpenOptions::new()
323 .read(true)
324 .write(true)
325 .create(true)
326 .truncate(false)
327 .open(path)?;
328 file.lock()?;
329 Ok(file)
330}
331
332fn file_matches_chunks(path: &Path, chunks: &[bytes::Bytes]) -> Result<bool, std::io::Error> {
333 let expected_len = chunks.iter().try_fold(0_u64, |total, chunk| {
334 total.checked_add(chunk.len() as u64).ok_or_else(|| {
335 std::io::Error::new(
336 std::io::ErrorKind::InvalidInput,
337 "checkpoint state is too large",
338 )
339 })
340 })?;
341 let mut file = File::open(path)?;
342 if file.metadata()?.len() != expected_len {
343 return Ok(false);
344 }
345 let mut scratch = [0_u8; 64 * 1024];
346 for chunk in chunks {
347 let mut offset = 0;
348 while offset < chunk.len() {
349 let length = scratch.len().min(chunk.len() - offset);
350 file.read_exact(&mut scratch[..length])?;
351 if scratch[..length] != chunk[offset..offset + length] {
352 return Ok(false);
353 }
354 offset += length;
355 }
356 }
357 Ok(true)
358}
359
360fn publish_filesystem_manifest(
361 checkpoint_dir: &Path,
362 manifest_path: &Path,
363 manifest: &CheckpointManifest,
364 encoded: bytes::Bytes,
365) -> Result<(), CheckpointStoreError> {
366 ensure_durable_directory(checkpoint_dir)?;
367 let _lock = open_and_lock(&checkpoint_dir.join(".manifest.lock"))?;
368
369 ensure_serialized_size("checkpoint manifest", encoded.len(), MAX_MANIFEST_BYTES)?;
370 if let Some(existing) =
371 read_bounded_file(manifest_path, MAX_MANIFEST_BYTES, "checkpoint manifest")?
372 {
373 let existing: CheckpointManifest = serde_json::from_slice(&existing)?;
374 if existing == *manifest || exact_phase_successor(manifest, &existing) {
375 return Ok(());
376 }
377 if !exact_phase_successor(&existing, manifest) {
378 return Err(CheckpointStoreError::Invalid(format!(
379 "checkpoint {} manifest already exists with different immutable content",
380 manifest.checkpoint_id
381 )));
382 }
383 }
384
385 let temporary = unique_temporary_path(manifest_path)?;
386 let cleanup = TemporaryFile(temporary.clone());
387 write_synced_file(&temporary, &[encoded])?;
388 let mode = if manifest_path.exists() {
389 DurableRenameMode::Replace
390 } else {
391 DurableRenameMode::NoReplace
392 };
393 durable_rename(&temporary, manifest_path, mode)?;
394 drop(cleanup);
395 Ok(())
396}
397
398fn publish_filesystem_state(
399 checkpoint_dir: &Path,
400 state_path: &Path,
401 chunks: &[bytes::Bytes],
402) -> Result<(), CheckpointStoreError> {
403 ensure_durable_directory(checkpoint_dir)?;
404 let _lock = open_and_lock(&checkpoint_dir.join(".state.lock"))?;
405 match std::fs::symlink_metadata(state_path) {
406 Ok(metadata) if !metadata.file_type().is_file() => {
407 return Err(CheckpointStoreError::Invalid(format!(
408 "checkpoint state path '{}' is not a regular file",
409 state_path.display()
410 )));
411 }
412 Ok(_) if file_matches_chunks(state_path, chunks)? => return Ok(()),
413 Ok(_) => {
414 return Err(CheckpointStoreError::Invalid(format!(
415 "checkpoint state '{}' already exists with different immutable content",
416 state_path.display()
417 )));
418 }
419 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
420 Err(error) => return Err(error.into()),
421 }
422
423 let temporary = unique_temporary_path(state_path)?;
424 let cleanup = TemporaryFile(temporary.clone());
425 write_synced_file(&temporary, chunks)?;
426 durable_rename(&temporary, state_path, DurableRenameMode::NoReplace)?;
427 drop(cleanup);
428 Ok(())
429}
430
431fn parse_filesystem_latest(bytes: &[u8]) -> Result<u64, CheckpointStoreError> {
432 let content = std::str::from_utf8(bytes).map_err(|error| {
433 CheckpointStoreError::Invalid(format!("checkpoint recovery pointer is not UTF-8: {error}"))
434 })?;
435 let name = content.trim();
436 FileSystemCheckpointStore::parse_checkpoint_id(name).ok_or_else(|| {
437 CheckpointStoreError::Invalid(format!("invalid checkpoint recovery pointer {name:?}"))
438 })
439}
440
441fn load_filesystem_manifest(
442 path: &Path,
443 checkpoint_id: u64,
444 participant_id: u64,
445 key_group_count: KeyGroupCount,
446) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
447 let Some(bytes) = read_bounded_file(path, MAX_MANIFEST_BYTES, "checkpoint manifest")? else {
448 return Ok(None);
449 };
450 let manifest: CheckpointManifest = serde_json::from_slice(&bytes)?;
451 ensure_loaded_manifest(&manifest, checkpoint_id, participant_id, key_group_count)?;
452 Ok(Some(manifest))
453}
454
455fn ensure_filesystem_latest_target(
456 checkpoints_dir: &Path,
457 checkpoint_id: u64,
458 participant_id: u64,
459 key_group_count: KeyGroupCount,
460) -> Result<(), CheckpointStoreError> {
461 let manifest_path = checkpoints_dir
462 .join(format!("checkpoint_{checkpoint_id:06}"))
463 .join("manifest.json");
464 let manifest = load_filesystem_manifest(
465 &manifest_path,
466 checkpoint_id,
467 participant_id,
468 key_group_count,
469 )?
470 .ok_or_else(|| {
471 CheckpointStoreError::Invalid(format!(
472 "checkpoint recovery pointer references missing checkpoint {checkpoint_id}"
473 ))
474 })?;
475 if manifest.durable_phase != DurableCheckpointPhase::Finalized {
476 return Err(CheckpointStoreError::Invalid(format!(
477 "checkpoint recovery pointer references non-finalized checkpoint {checkpoint_id}"
478 )));
479 }
480 Ok(())
481}
482
483#[cfg(test)]
484#[derive(Debug, Default)]
485struct FilesystemLatestPublicationGate {
486 state: std::sync::Mutex<(bool, bool)>,
487 changed: std::sync::Condvar,
488}
489
490#[cfg(test)]
491impl FilesystemLatestPublicationGate {
492 fn block(&self) -> Result<(), std::io::Error> {
493 let mut state = self
494 .state
495 .lock()
496 .map_err(|_| std::io::Error::other("latest publication gate is poisoned"))?;
497 state.0 = true;
498 self.changed.notify_all();
499 while !state.1 {
500 state = self
501 .changed
502 .wait(state)
503 .map_err(|_| std::io::Error::other("latest publication gate is poisoned"))?;
504 }
505 Ok(())
506 }
507
508 fn wait_until_entered(&self, timeout: std::time::Duration) -> bool {
509 let Ok(state) = self.state.lock() else {
510 return false;
511 };
512 self.changed
513 .wait_timeout_while(state, timeout, |state| !state.0)
514 .is_ok_and(|(state, _)| state.0)
515 }
516
517 fn release(&self) {
518 if let Ok(mut state) = self.state.lock() {
519 state.1 = true;
520 self.changed.notify_all();
521 }
522 }
523}
524
525fn publish_filesystem_latest(
526 checkpoints_dir: &Path,
527 latest_path: &Path,
528 checkpoint_id: u64,
529 participant_id: u64,
530 key_group_count: KeyGroupCount,
531 #[cfg(test)] gate: Option<Arc<FilesystemLatestPublicationGate>>,
532) -> Result<(), CheckpointStoreError> {
533 ensure_durable_directory(checkpoints_dir)?;
534 let _lock = open_and_lock(&checkpoints_dir.join(".latest.lock"))?;
535 #[cfg(test)]
536 if let Some(gate) = gate {
537 gate.block()?;
538 }
539
540 if let Some(existing) = read_bounded_file(
541 latest_path,
542 MAX_LATEST_POINTER_BYTES,
543 "checkpoint recovery pointer",
544 )? {
545 let current = parse_filesystem_latest(&existing)?;
546 ensure_filesystem_latest_target(checkpoints_dir, current, participant_id, key_group_count)?;
547 if current >= checkpoint_id {
548 return Ok(());
549 }
550 }
551
552 let temporary = unique_temporary_path(latest_path)?;
553 let cleanup = TemporaryFile(temporary.clone());
554 let content = bytes::Bytes::from(format!("checkpoint_{checkpoint_id:06}"));
555 write_synced_file(&temporary, &[content])?;
556 durable_rename(&temporary, latest_path, DurableRenameMode::Replace)?;
557 drop(cleanup);
558 Ok(())
559}
560
561#[derive(Debug, thiserror::Error)]
563pub enum CheckpointStoreError {
564 #[error("checkpoint I/O error: {0}")]
566 Io(#[from] std::io::Error),
567
568 #[error("checkpoint serialization error: {0}")]
570 Serde(#[from] serde_json::Error),
571
572 #[error("checkpoint {0} not found")]
574 NotFound(u64),
575
576 #[error("object store error: {0}")]
578 ObjectStore(#[from] object_store::Error),
579
580 #[error("invalid checkpoint: {0}")]
582 Invalid(String),
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
594pub enum ValidationIssue {
595 ManifestIncompatibility(String),
597 IntegrityFailure(String),
600}
601
602impl ValidationIssue {
603 #[must_use]
605 pub fn message(&self) -> &str {
606 match self {
607 Self::ManifestIncompatibility(s) | Self::IntegrityFailure(s) => s,
608 }
609 }
610}
611
612impl std::fmt::Display for ValidationIssue {
613 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614 f.write_str(self.message())
615 }
616}
617
618#[derive(Debug, Clone)]
620pub struct ValidationResult {
621 pub checkpoint_id: u64,
623 pub valid: bool,
626 pub issues: Vec<ValidationIssue>,
628}
629
630#[derive(Debug, Clone, PartialEq)]
635pub struct CheckpointArtifacts {
636 pub manifest: CheckpointManifest,
638 pub state_data: Option<Vec<u8>>,
640}
641
642impl CheckpointArtifacts {
643 pub async fn validate(
653 self,
654 checkpoint_id: u64,
655 participant_id: u64,
656 key_group_count: KeyGroupCount,
657 max_state_data_bytes: u64,
658 ) -> Result<(Self, ValidationResult), CheckpointStoreError> {
659 validate_max_checkpoint_state_bytes(max_state_data_bytes)?;
660 tokio::task::spawn_blocking(move || {
661 let result = validate_checkpoint_artifacts(
662 &self,
663 checkpoint_id,
664 participant_id,
665 key_group_count,
666 max_state_data_bytes,
667 );
668 (self, result)
669 })
670 .await
671 .map_err(|error| {
672 std::io::Error::other(format!("checkpoint validation task failed: {error}"))
673 })
674 .map_err(CheckpointStoreError::Io)
675 }
676}
677
678fn exact_inline_state_len(encoded: &str) -> Result<u64, String> {
679 let mut decoder = base64::read::DecoderReader::new(
680 encoded.as_bytes(),
681 &base64::engine::general_purpose::STANDARD,
682 );
683 let mut decoded_len = 0_u64;
684 let mut scratch = [0_u8; 8 * 1024];
685 loop {
686 let read = decoder
687 .read(&mut scratch)
688 .map_err(|error| format!("invalid base64: {error}"))?;
689 if read == 0 {
690 return Ok(decoded_len);
691 }
692 decoded_len = decoded_len
693 .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
694 .ok_or_else(|| "decoded inline state length overflow".to_string())?;
695 }
696}
697
698type ExternalStateRange<'a> = (u64, u64, &'a str);
699
700fn operator_raw_state_len<'a>(
701 name: &'a str,
702 state: &OperatorCheckpoint,
703 external_ranges: &mut Vec<ExternalStateRange<'a>>,
704 issues: &mut Vec<ValidationIssue>,
705) -> Option<u64> {
706 if state.external {
707 if state.state_b64.is_some() {
708 issues.push(ValidationIssue::IntegrityFailure(format!(
709 "operator '{name}' external state also contains inline base64"
710 )));
711 }
712 if state.external_length == 0 {
713 issues.push(ValidationIssue::IntegrityFailure(format!(
714 "operator '{name}' has an empty external sidecar range"
715 )));
716 }
717 match state.external_offset.checked_add(state.external_length) {
718 Some(end) => external_ranges.push((state.external_offset, end, name)),
719 None => issues.push(ValidationIssue::IntegrityFailure(format!(
720 "operator '{name}' external sidecar range overflows"
721 ))),
722 }
723 return Some(state.external_length);
724 }
725
726 if state.external_offset != 0 || state.external_length != 0 {
727 issues.push(ValidationIssue::IntegrityFailure(format!(
728 "operator '{name}' inline state has nonzero external offset or length"
729 )));
730 }
731 let Some(encoded) = state.state_b64.as_deref() else {
732 issues.push(ValidationIssue::IntegrityFailure(format!(
733 "operator '{name}' inline state is missing base64 data"
734 )));
735 return None;
736 };
737 match exact_inline_state_len(encoded) {
738 Ok(length) => Some(length),
739 Err(error) => {
740 issues.push(ValidationIssue::IntegrityFailure(format!(
741 "operator '{name}' inline state {error}"
742 )));
743 None
744 }
745 }
746}
747
748fn validate_external_state_ranges(
749 mut external_ranges: Vec<ExternalStateRange<'_>>,
750 state_data_len: Option<u64>,
751 issues: &mut Vec<ValidationIssue>,
752) {
753 external_ranges.sort_unstable();
754 if external_ranges.is_empty() {
755 if state_data_len.is_some() {
756 issues.push(ValidationIssue::IntegrityFailure(
757 "checkpoint state sidecar has no external operator ranges".into(),
758 ));
759 }
760 return;
761 }
762
763 let mut expected_offset = 0_u64;
764 for (start, end, name) in external_ranges {
765 if start != expected_offset {
766 issues.push(ValidationIssue::IntegrityFailure(format!(
767 "operator '{name}' external sidecar range starts at {start}, expected {expected_offset}"
768 )));
769 }
770 expected_offset = end;
771 }
772 match state_data_len {
773 Some(actual) if actual != expected_offset => {
774 issues.push(ValidationIssue::IntegrityFailure(format!(
775 "checkpoint state sidecar is {actual} bytes but external operator ranges cover {expected_offset} bytes"
776 )));
777 }
778 None => issues.push(ValidationIssue::IntegrityFailure(
779 "external operator state references a missing checkpoint state sidecar".into(),
780 )),
781 Some(_) => {}
782 }
783}
784
785fn operator_state_validation_issues(
786 manifest: &CheckpointManifest,
787 state_data_len: Option<u64>,
788 max_state_data_bytes: u64,
789) -> Vec<ValidationIssue> {
790 let mut issues = Vec::new();
791 if let Some(length) = state_data_len {
792 if length > max_state_data_bytes {
793 issues.push(ValidationIssue::IntegrityFailure(format!(
794 "checkpoint state sidecar is {length} bytes, exceeding the {max_state_data_bytes}-byte safety limit"
795 )));
796 }
797 }
798
799 let mut names = manifest.operator_states.keys().collect::<Vec<_>>();
800 names.sort_unstable();
801 let mut logical_bytes = 0_u64;
802 let mut logical_overflow = false;
803 let mut external_ranges = Vec::new();
804
805 for name in names {
806 let state = &manifest.operator_states[name];
807 let raw_length =
808 operator_raw_state_len(name.as_str(), state, &mut external_ranges, &mut issues);
809
810 if let Some(raw_length) = raw_length {
811 if let Some(total) = logical_bytes.checked_add(raw_length) {
812 logical_bytes = total;
813 } else if !logical_overflow {
814 logical_overflow = true;
815 issues.push(ValidationIssue::IntegrityFailure(
816 "aggregate logical operator state length overflows".into(),
817 ));
818 }
819 }
820 }
821
822 if !logical_overflow && logical_bytes > max_state_data_bytes {
823 issues.push(ValidationIssue::IntegrityFailure(format!(
824 "aggregate logical operator state is {logical_bytes} bytes, exceeding the {max_state_data_bytes}-byte safety limit"
825 )));
826 }
827
828 validate_external_state_ranges(external_ranges, state_data_len, &mut issues);
829
830 if manifest.operator_states.is_empty() && manifest.state_checksum.is_some() {
831 issues.push(ValidationIssue::IntegrityFailure(
832 "state checksum has no operator state".into(),
833 ));
834 }
835 issues
836}
837
838fn validate_checkpoint_artifacts(
839 artifacts: &CheckpointArtifacts,
840 checkpoint_id: u64,
841 participant_id: u64,
842 key_group_count: KeyGroupCount,
843 max_state_data_bytes: u64,
844) -> ValidationResult {
845 let manifest = &artifacts.manifest;
846 let mut issues = manifest
847 .validate(key_group_count)
848 .into_iter()
849 .map(|error| {
850 ValidationIssue::ManifestIncompatibility(format!("manifest validation: {error}"))
851 })
852 .collect::<Vec<_>>();
853
854 if manifest.participant_id != participant_id {
855 issues.push(ValidationIssue::ManifestIncompatibility(format!(
856 "manifest participant {} does not match store participant {participant_id}",
857 manifest.participant_id
858 )));
859 }
860 if manifest.checkpoint_id != checkpoint_id {
861 issues.push(ValidationIssue::IntegrityFailure(format!(
862 "storage checkpoint {checkpoint_id} contains manifest checkpoint {}",
863 manifest.checkpoint_id
864 )));
865 }
866
867 let state_data_len = artifacts
868 .state_data
869 .as_ref()
870 .map(|data| u64::try_from(data.len()).unwrap_or(u64::MAX));
871 issues.extend(operator_state_validation_issues(
872 manifest,
873 state_data_len,
874 max_state_data_bytes,
875 ));
876
877 if manifest.epoch == 0 || manifest.checkpoint_id == 0 {
879 issues.push(ValidationIssue::IntegrityFailure(
880 "epoch or checkpoint_id is 0 — likely corrupted".into(),
881 ));
882 }
883
884 if issues.is_empty() {
887 if let Some(expected) = &manifest.state_checksum {
888 let (any_inline, any_external) = operator_state_shape(manifest);
889 let actual = match (any_inline, any_external, artifacts.state_data.as_deref()) {
890 (true, true, Some(data)) => {
891 sha256_hex_mixed(&manifest.operator_states, std::iter::once(data))
892 }
893 (true, false, _) => sha256_hex_inline_states(&manifest.operator_states),
894 (false, true, Some(data)) => sha256_hex(data),
895 _ => unreachable!("validated operator state shape must select checksum bytes"),
896 };
897 if actual != *expected {
898 let label = if any_inline && any_external {
899 "mixed state checksum mismatch"
900 } else if any_inline {
901 "inline state checksum mismatch"
902 } else {
903 "state.bin checksum mismatch"
904 };
905 issues.push(ValidationIssue::IntegrityFailure(format!(
906 "{label}: expected {expected}, got {actual}"
907 )));
908 }
909 }
910 }
911
912 ValidationResult {
913 checkpoint_id,
914 valid: issues.is_empty(),
915 issues,
916 }
917}
918
919fn artifact_load_failure(checkpoint_id: u64, message: String) -> ValidationResult {
920 ValidationResult {
921 checkpoint_id,
922 valid: false,
923 issues: vec![ValidationIssue::IntegrityFailure(message)],
924 }
925}
926
927#[derive(Debug, Clone)]
932pub struct RecoveryReport {
933 pub chosen_id: Option<u64>,
935 pub skipped: Vec<(u64, String)>,
937 pub examined: usize,
939 pub elapsed: std::time::Duration,
941}
942
943fn parse_checkpoint_id_from_path(path: &str, prefix: &str, suffix: &str) -> Option<u64> {
950 for segment in path.split('/') {
951 let Some(rest) = segment.strip_prefix(prefix) else {
952 continue;
953 };
954 let Some(id_str) = rest.strip_suffix(suffix) else {
955 continue;
956 };
957 if let Ok(id) = id_str.parse::<u64>() {
958 let canonical = format!("{prefix}{id:06}{suffix}");
959 if segment == canonical {
960 return Some(id);
961 }
962 }
963 warn!(
964 path,
965 prefix, suffix, "malformed checkpoint id in object path — skipped"
966 );
967 return None;
968 }
969 None
970}
971
972fn sha256_hex(data: &[u8]) -> String {
974 let mut hasher = Sha256::new();
975 hasher.update(data);
976 format!("{:x}", hasher.finalize())
977}
978
979fn sha256_hex_chunks(chunks: &[bytes::Bytes]) -> String {
986 let mut hasher = Sha256::new();
987 for chunk in chunks {
988 hasher.update(chunk);
989 }
990 format!("{:x}", hasher.finalize())
991}
992
993fn checked_state_data_len(chunks: &[bytes::Bytes]) -> Result<u64, CheckpointStoreError> {
994 chunks.iter().try_fold(0_u64, |total, chunk| {
995 let chunk_len = u64::try_from(chunk.len()).map_err(|_| {
996 CheckpointStoreError::Invalid("checkpoint state sidecar length overflow".into())
997 })?;
998 total.checked_add(chunk_len).ok_or_else(|| {
999 CheckpointStoreError::Invalid("checkpoint state sidecar length overflow".into())
1000 })
1001 })
1002}
1003
1004fn sha256_hex_mixed<'a, I>(
1007 states: &std::collections::HashMap<
1008 String,
1009 crate::checkpoint::checkpoint_manifest::OperatorCheckpoint,
1010 >,
1011 sidecar_chunks: I,
1012) -> String
1013where
1014 I: IntoIterator<Item = &'a [u8]>,
1015{
1016 let inline = sha256_hex_inline_states(states);
1017 let mut hasher = Sha256::new();
1018 hasher.update(inline.as_bytes());
1019 for chunk in sidecar_chunks {
1020 hasher.update(chunk);
1021 }
1022 format!("{:x}", hasher.finalize())
1023}
1024
1025fn sha256_hex_inline_states(
1028 states: &std::collections::HashMap<
1029 String,
1030 crate::checkpoint::checkpoint_manifest::OperatorCheckpoint,
1031 >,
1032) -> String {
1033 let mut names: Vec<&String> = states.keys().collect();
1034 names.sort_unstable();
1035 let mut hasher = Sha256::new();
1036 for n in names {
1037 if let Some(op) = states.get(n) {
1038 if op.external {
1039 continue;
1040 }
1041 hasher.update(n.as_bytes());
1042 hasher.update([0u8]);
1043 if let Some(b64) = &op.state_b64 {
1044 hasher.update(b64.as_bytes());
1045 }
1046 hasher.update([0u8]);
1047 }
1048 }
1049 format!("{:x}", hasher.finalize())
1050}
1051
1052fn operator_state_shape(manifest: &CheckpointManifest) -> (bool, bool) {
1053 manifest
1054 .operator_states
1055 .values()
1056 .fold((false, false), |(inline, external), operator| {
1057 (inline || !operator.external, external || operator.external)
1058 })
1059}
1060
1061fn requires_state_data(manifest: &CheckpointManifest) -> bool {
1062 manifest.state_checksum.is_some()
1063 && manifest
1064 .operator_states
1065 .values()
1066 .any(|state| state.external)
1067}
1068
1069#[async_trait]
1075pub trait CheckpointStore: Send + Sync {
1076 fn max_state_data_bytes(&self) -> u64;
1081
1082 fn key_group_count(&self) -> KeyGroupCount {
1086 LOCAL_KEY_GROUP_COUNT
1087 }
1088
1089 fn participant_id(&self) -> u64 {
1094 0
1095 }
1096
1097 fn ensure_manifest_participant(
1103 &self,
1104 manifest: &CheckpointManifest,
1105 ) -> Result<(), CheckpointStoreError> {
1106 if manifest.participant_id == self.participant_id() {
1107 Ok(())
1108 } else {
1109 Err(CheckpointStoreError::Invalid(format!(
1110 "manifest participant {} does not match store participant {}",
1111 manifest.participant_id,
1112 self.participant_id()
1113 )))
1114 }
1115 }
1116
1117 fn ensure_manifest_valid(
1123 &self,
1124 manifest: &CheckpointManifest,
1125 ) -> Result<(), CheckpointStoreError> {
1126 let errors = manifest.validate(self.key_group_count());
1127 if errors.is_empty() {
1128 Ok(())
1129 } else {
1130 Err(CheckpointStoreError::Invalid(format!(
1131 "manifest validation: {}",
1132 errors
1133 .iter()
1134 .map(ToString::to_string)
1135 .collect::<Vec<_>>()
1136 .join("; ")
1137 )))
1138 }
1139 }
1140
1141 async fn save(&self, manifest: &CheckpointManifest) -> Result<(), CheckpointStoreError>;
1147
1148 async fn load_latest(&self) -> Result<Option<CheckpointManifest>, CheckpointStoreError>;
1156
1157 async fn load_by_id(&self, id: u64)
1162 -> Result<Option<CheckpointManifest>, CheckpointStoreError>;
1163
1164 async fn load_manifest_for_participant(
1170 &self,
1171 participant_id: u64,
1172 id: u64,
1173 ) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
1174 if participant_id != self.participant_id() {
1175 return Err(CheckpointStoreError::Invalid(format!(
1176 "checkpoint store participant {} cannot read participant {participant_id}",
1177 self.participant_id()
1178 )));
1179 }
1180 self.load_by_id(id).await
1181 }
1182
1183 async fn list(&self) -> Result<Vec<(u64, u64)>, CheckpointStoreError>;
1190
1191 async fn list_ids(&self) -> Result<Vec<u64>, CheckpointStoreError>;
1198
1199 async fn prune_before(&self, before_epoch: u64) -> Result<usize, CheckpointStoreError>;
1210
1211 async fn finalize(
1216 &self,
1217 checkpoint_id: u64,
1218 ) -> Result<CheckpointManifest, CheckpointStoreError> {
1219 let mut manifest = self
1220 .load_by_id(checkpoint_id)
1221 .await?
1222 .ok_or(CheckpointStoreError::NotFound(checkpoint_id))?;
1223 if manifest.checkpoint_id != checkpoint_id {
1224 return Err(CheckpointStoreError::Invalid(format!(
1225 "storage id {checkpoint_id} contains manifest id {}",
1226 manifest.checkpoint_id
1227 )));
1228 }
1229 self.ensure_manifest_participant(&manifest)?;
1230 let errors = manifest.validate(self.key_group_count());
1231 if !errors.is_empty() {
1232 return Err(CheckpointStoreError::Invalid(
1233 errors
1234 .iter()
1235 .map(ToString::to_string)
1236 .collect::<Vec<_>>()
1237 .join("; "),
1238 ));
1239 }
1240 if manifest.durable_phase == DurableCheckpointPhase::Prepared {
1241 manifest.durable_phase = DurableCheckpointPhase::Finalized;
1242 }
1243 self.save(&manifest).await?;
1246 Ok(manifest)
1247 }
1248
1249 async fn save_state_data(
1261 &self,
1262 id: u64,
1263 chunks: &[bytes::Bytes],
1264 ) -> Result<(), CheckpointStoreError>;
1265
1266 async fn load_state_data(&self, id: u64) -> Result<Option<Vec<u8>>, CheckpointStoreError>;
1273
1274 async fn load_state_data_for_participant(
1278 &self,
1279 participant_id: u64,
1280 id: u64,
1281 ) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
1282 if participant_id != self.participant_id() {
1283 return Err(CheckpointStoreError::Invalid(format!(
1284 "checkpoint store participant {} cannot read participant {participant_id}",
1285 self.participant_id()
1286 )));
1287 }
1288 self.load_state_data(id).await
1289 }
1290
1291 async fn state_data_len_for_participant(
1297 &self,
1298 participant_id: u64,
1299 id: u64,
1300 ) -> Result<Option<u64>, CheckpointStoreError> {
1301 Err(CheckpointStoreError::Invalid(format!(
1302 "checkpoint store participant {} cannot provide metadata-only sidecar evidence for participant {participant_id} checkpoint {id}",
1303 self.participant_id()
1304 )))
1305 }
1306
1307 async fn load_checkpoint_artifacts(
1309 &self,
1310 id: u64,
1311 ) -> Result<Option<CheckpointArtifacts>, CheckpointStoreError> {
1312 self.load_checkpoint_artifacts_for_participant(self.participant_id(), id)
1313 .await
1314 }
1315
1316 async fn load_checkpoint_artifacts_for_participant(
1321 &self,
1322 participant_id: u64,
1323 id: u64,
1324 ) -> Result<Option<CheckpointArtifacts>, CheckpointStoreError> {
1325 let Some(manifest) = self
1326 .load_manifest_for_participant(participant_id, id)
1327 .await?
1328 else {
1329 return Ok(None);
1330 };
1331 let state_data = if requires_state_data(&manifest) {
1332 self.load_state_data_for_participant(participant_id, id)
1333 .await?
1334 } else {
1335 None
1336 };
1337 Ok(Some(CheckpointArtifacts {
1338 manifest,
1339 state_data,
1340 }))
1341 }
1342
1343 async fn validate_checkpoint(&self, id: u64) -> Result<ValidationResult, CheckpointStoreError> {
1352 let artifacts = match self.load_checkpoint_artifacts(id).await {
1353 Ok(Some(artifacts)) => artifacts,
1354 Ok(None) => {
1355 return Ok(artifact_load_failure(
1356 id,
1357 format!("manifest not found for checkpoint {id}"),
1358 ));
1359 }
1360 Err(CheckpointStoreError::Serde(error)) => {
1361 return Ok(artifact_load_failure(
1362 id,
1363 format!("corrupt manifest: {error}"),
1364 ));
1365 }
1366 Err(CheckpointStoreError::Invalid(error)) => {
1367 return Ok(artifact_load_failure(id, error));
1368 }
1369 Err(error) => return Err(error),
1370 };
1371 let (_, validation) = artifacts
1372 .validate(
1373 id,
1374 self.participant_id(),
1375 self.key_group_count(),
1376 self.max_state_data_bytes(),
1377 )
1378 .await?;
1379 Ok(validation)
1380 }
1381
1382 async fn recover_latest_validated(&self) -> Result<RecoveryReport, CheckpointStoreError> {
1391 let start = std::time::Instant::now();
1392 let mut skipped = Vec::new();
1393
1394 let mut ids = self.list_ids().await?;
1397 ids.reverse();
1398
1399 let examined = ids.len();
1400
1401 for id in &ids {
1402 let (result, durable_phase) = match self.load_checkpoint_artifacts(*id).await {
1403 Ok(Some(artifacts)) => {
1404 let durable_phase = artifacts.manifest.durable_phase;
1405 let (_, validation) = artifacts
1406 .validate(
1407 *id,
1408 self.participant_id(),
1409 self.key_group_count(),
1410 self.max_state_data_bytes(),
1411 )
1412 .await?;
1413 (validation, Some(durable_phase))
1414 }
1415 Ok(None) => (
1416 artifact_load_failure(*id, format!("manifest not found for checkpoint {id}")),
1417 None,
1418 ),
1419 Err(CheckpointStoreError::Serde(error)) => (
1420 artifact_load_failure(*id, format!("corrupt manifest: {error}")),
1421 None,
1422 ),
1423 Err(CheckpointStoreError::Invalid(error)) => {
1424 (artifact_load_failure(*id, error), None)
1425 }
1426 Err(error) => return Err(error),
1427 };
1428 if result.valid {
1429 if durable_phase == Some(DurableCheckpointPhase::Finalized) {
1430 return Ok(RecoveryReport {
1431 chosen_id: Some(*id),
1432 skipped,
1433 examined,
1434 elapsed: start.elapsed(),
1435 });
1436 }
1437 skipped.push((*id, "checkpoint is prepared but not finalized".into()));
1438 continue;
1439 }
1440 let reason = result
1441 .issues
1442 .iter()
1443 .map(ToString::to_string)
1444 .collect::<Vec<_>>()
1445 .join("; ");
1446 warn!(
1447 checkpoint_id = id,
1448 reason = %reason,
1449 "skipping invalid checkpoint"
1450 );
1451 skipped.push((*id, reason));
1452 }
1453
1454 Ok(RecoveryReport {
1455 chosen_id: None,
1456 skipped,
1457 examined,
1458 elapsed: start.elapsed(),
1459 })
1460 }
1461
1462 async fn save_with_state(
1477 &self,
1478 manifest: &CheckpointManifest,
1479 state_data: Option<&[bytes::Bytes]>,
1480 ) -> Result<CheckpointManifest, CheckpointStoreError> {
1481 self.ensure_manifest_participant(manifest)?;
1482 let max_state_data_bytes = self.max_state_data_bytes();
1483 validate_max_checkpoint_state_bytes(max_state_data_bytes)?;
1484
1485 let mut manifest = manifest.clone();
1488 if !manifest.operator_states.is_empty() && manifest.state_checksum.is_none() {
1489 manifest.state_checksum = Some("pending".into());
1490 }
1491 self.ensure_manifest_valid(&manifest)?;
1492
1493 let state_data = state_data.map(<[bytes::Bytes]>::to_vec);
1496 let (manifest, state_data) = tokio::task::spawn_blocking(move || {
1497 let state_data_len = state_data
1498 .as_deref()
1499 .map(checked_state_data_len)
1500 .transpose()?;
1501 let issues =
1502 operator_state_validation_issues(&manifest, state_data_len, max_state_data_bytes);
1503 if !issues.is_empty() {
1504 return Err(CheckpointStoreError::Invalid(format!(
1505 "operator state validation: {}",
1506 issues
1507 .iter()
1508 .map(ValidationIssue::message)
1509 .collect::<Vec<_>>()
1510 .join("; ")
1511 )));
1512 }
1513
1514 let mut manifest = manifest;
1515 manifest.state_checksum = if let Some(chunks) = state_data.as_deref() {
1516 Some(stamp_checksum(&manifest.operator_states, Some(chunks)))
1517 } else if manifest.operator_states.is_empty() {
1518 None
1519 } else {
1520 Some(sha256_hex_inline_states(&manifest.operator_states))
1521 };
1522 Ok((manifest, state_data))
1523 })
1524 .await
1525 .map_err(|error| {
1526 CheckpointStoreError::Io(std::io::Error::other(format!(
1527 "checkpoint checksum task failed: {error}"
1528 )))
1529 })??;
1530
1531 self.ensure_manifest_valid(&manifest)?;
1532 if let Some(chunks) = state_data.as_deref() {
1533 self.save_state_data(manifest.checkpoint_id, chunks).await?;
1534 }
1535 self.save(&manifest).await?;
1536 Ok(manifest)
1537 }
1538}
1539
1540fn stamp_checksum(
1543 states: &std::collections::HashMap<
1544 String,
1545 crate::checkpoint::checkpoint_manifest::OperatorCheckpoint,
1546 >,
1547 chunks: Option<&[bytes::Bytes]>,
1548) -> String {
1549 let chunks = chunks.unwrap_or_default();
1550 let any_inline = states.values().any(|o| !o.external);
1551 if any_inline {
1552 sha256_hex_mixed(states, chunks.iter().map(AsRef::as_ref))
1553 } else {
1554 sha256_hex_chunks(chunks)
1555 }
1556}
1557
1558pub struct FileSystemCheckpointStore {
1564 base_dir: PathBuf,
1565 key_group_count: KeyGroupCount,
1566 participant_id: u64,
1567 max_state_data_bytes: u64,
1568 #[cfg(test)]
1569 latest_publication_gate: Option<Arc<FilesystemLatestPublicationGate>>,
1570}
1571
1572impl FileSystemCheckpointStore {
1573 #[must_use]
1581 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
1582 Self {
1583 base_dir: base_dir.into(),
1584 key_group_count: LOCAL_KEY_GROUP_COUNT,
1585 participant_id: 0,
1586 max_state_data_bytes: DEFAULT_MAX_CHECKPOINT_STATE_BYTES,
1587 #[cfg(test)]
1588 latest_publication_gate: None,
1589 }
1590 }
1591
1592 #[cfg(test)]
1593 fn with_latest_publication_gate(mut self, gate: Arc<FilesystemLatestPublicationGate>) -> Self {
1594 self.latest_publication_gate = Some(gate);
1595 self
1596 }
1597
1598 #[must_use]
1600 pub fn with_key_group_count(mut self, key_group_count: KeyGroupCount) -> Self {
1601 self.key_group_count = key_group_count;
1602 self
1603 }
1604
1605 #[must_use]
1607 pub fn with_participant_id(mut self, participant_id: u64) -> Self {
1608 self.participant_id = participant_id;
1609 self
1610 }
1611
1612 pub fn with_max_state_data_bytes(
1619 mut self,
1620 max_state_data_bytes: u64,
1621 ) -> Result<Self, CheckpointStoreError> {
1622 validate_max_checkpoint_state_bytes(max_state_data_bytes)?;
1623 self.max_state_data_bytes = max_state_data_bytes;
1624 Ok(self)
1625 }
1626
1627 fn checkpoints_dir(&self) -> PathBuf {
1629 self.base_dir.join("checkpoints")
1630 }
1631
1632 fn checkpoint_dir(&self, id: u64) -> PathBuf {
1634 self.checkpoints_dir().join(format!("checkpoint_{id:06}"))
1635 }
1636
1637 fn manifest_path(&self, id: u64) -> PathBuf {
1639 self.checkpoint_dir(id).join("manifest.json")
1640 }
1641
1642 fn state_path(&self, id: u64) -> PathBuf {
1644 self.checkpoint_dir(id).join("state.bin")
1645 }
1646
1647 fn latest_path(&self) -> PathBuf {
1649 self.checkpoints_dir().join("latest.txt")
1650 }
1651
1652 async fn latest_checkpoint_id(&self) -> Result<Option<u64>, CheckpointStoreError> {
1655 Ok(self
1656 .load_latest()
1657 .await?
1658 .map(|manifest| manifest.checkpoint_id))
1659 }
1660
1661 fn parse_checkpoint_id(name: &str) -> Option<u64> {
1663 let id = name.strip_prefix("checkpoint_")?.parse::<u64>().ok()?;
1664 (name == format!("checkpoint_{id:06}")).then_some(id)
1665 }
1666
1667 async fn sorted_checkpoint_ids(&self) -> Result<Vec<u64>, CheckpointStoreError> {
1673 self.sorted_checkpoint_ids_with_limit(MAX_CHECKPOINT_INVENTORY_ENTRIES)
1674 .await
1675 }
1676
1677 async fn sorted_checkpoint_ids_with_limit(
1678 &self,
1679 max_entries: usize,
1680 ) -> Result<Vec<u64>, CheckpointStoreError> {
1681 let dir = self.checkpoints_dir();
1682 let mut reader = match tokio::fs::read_dir(&dir).await {
1683 Ok(r) => r,
1684 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1685 Err(e) => return Err(e.into()),
1686 };
1687
1688 let mut ids: Vec<u64> = Vec::new();
1689 let mut scanned = 0usize;
1690 while let Some(entry) = reader.next_entry().await? {
1691 if scanned >= max_entries {
1692 return Err(CheckpointStoreError::Invalid(format!(
1693 "checkpoint inventory exceeds the {max_entries}-entry safety limit"
1694 )));
1695 }
1696 scanned += 1;
1697 let ft = entry.file_type().await?;
1698 if !ft.is_dir() {
1699 continue;
1700 }
1701 let name = entry.file_name();
1702 let Some(name) = name.to_str() else {
1703 continue;
1704 };
1705 let Some(id) = Self::parse_checkpoint_id(name) else {
1706 if name.starts_with("checkpoint_") {
1707 return Err(CheckpointStoreError::Invalid(format!(
1708 "non-canonical checkpoint inventory entry '{name}'"
1709 )));
1710 }
1711 continue;
1712 };
1713 let manifest_path = entry.path().join("manifest.json");
1714 match tokio::fs::symlink_metadata(&manifest_path).await {
1715 Ok(meta) if meta.file_type().is_file() => {
1716 ids.push(id);
1717 }
1718 Ok(_) => {
1719 return Err(CheckpointStoreError::Invalid(format!(
1720 "checkpoint manifest '{}' is not a regular file",
1721 manifest_path.display()
1722 )));
1723 }
1724 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1725 Err(e) => return Err(e.into()),
1726 }
1727 }
1728
1729 ids.sort_unstable();
1730 Ok(ids)
1731 }
1732}
1733
1734#[async_trait]
1735impl CheckpointStore for FileSystemCheckpointStore {
1736 fn max_state_data_bytes(&self) -> u64 {
1737 self.max_state_data_bytes
1738 }
1739
1740 fn key_group_count(&self) -> KeyGroupCount {
1741 self.key_group_count
1742 }
1743
1744 fn participant_id(&self) -> u64 {
1745 self.participant_id
1746 }
1747
1748 async fn save(&self, manifest: &CheckpointManifest) -> Result<(), CheckpointStoreError> {
1749 self.ensure_manifest_participant(manifest)?;
1750 self.ensure_manifest_valid(manifest)?;
1751 let cp_dir = self.checkpoint_dir(manifest.checkpoint_id);
1752 let manifest_path = self.manifest_path(manifest.checkpoint_id);
1753 let encoded = bytes::Bytes::from(serde_json::to_vec_pretty(manifest)?);
1754 let owned_manifest = manifest.clone();
1755 tokio::task::spawn_blocking(move || {
1756 publish_filesystem_manifest(&cp_dir, &manifest_path, &owned_manifest, encoded)
1757 })
1758 .await
1759 .map_err(std::io::Error::other)??;
1760
1761 if manifest.durable_phase == DurableCheckpointPhase::Prepared {
1763 return Ok(());
1764 }
1765
1766 let latest = self.latest_path();
1769 let checkpoints = self.checkpoints_dir();
1770 let checkpoint_id = manifest.checkpoint_id;
1771 let participant_id = self.participant_id;
1772 let key_group_count = self.key_group_count;
1773 #[cfg(test)]
1774 let gate = self.latest_publication_gate.clone();
1775 tokio::task::spawn_blocking(move || {
1776 publish_filesystem_latest(
1777 &checkpoints,
1778 &latest,
1779 checkpoint_id,
1780 participant_id,
1781 key_group_count,
1782 #[cfg(test)]
1783 gate,
1784 )
1785 })
1786 .await
1787 .map_err(std::io::Error::other)??;
1788
1789 Ok(())
1790 }
1791
1792 async fn load_latest(&self) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
1793 let latest = self.latest_path();
1794 let content = tokio::task::spawn_blocking(move || {
1795 read_bounded_file(
1796 &latest,
1797 MAX_LATEST_POINTER_BYTES,
1798 "checkpoint recovery pointer",
1799 )
1800 })
1801 .await
1802 .map_err(std::io::Error::other)??;
1803 let Some(content) = content else {
1804 return Ok(None);
1805 };
1806 let checkpoint_id = parse_filesystem_latest(&content)?;
1807 let manifest = self.load_by_id(checkpoint_id).await?.ok_or_else(|| {
1808 CheckpointStoreError::Invalid(format!(
1809 "checkpoint recovery pointer references missing checkpoint {checkpoint_id}"
1810 ))
1811 })?;
1812 if manifest.durable_phase != DurableCheckpointPhase::Finalized {
1813 return Err(CheckpointStoreError::Invalid(format!(
1814 "checkpoint recovery pointer references non-finalized checkpoint {checkpoint_id}"
1815 )));
1816 }
1817 Ok(Some(manifest))
1818 }
1819
1820 async fn load_by_id(
1821 &self,
1822 id: u64,
1823 ) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
1824 let path = self.manifest_path(id);
1825 let participant_id = self.participant_id;
1826 let key_group_count = self.key_group_count;
1827 tokio::task::spawn_blocking(move || {
1828 load_filesystem_manifest(&path, id, participant_id, key_group_count)
1829 })
1830 .await
1831 .map_err(std::io::Error::other)?
1832 }
1833
1834 async fn list_ids(&self) -> Result<Vec<u64>, CheckpointStoreError> {
1835 self.sorted_checkpoint_ids().await
1836 }
1837
1838 async fn list(&self) -> Result<Vec<(u64, u64)>, CheckpointStoreError> {
1839 let ids = self.sorted_checkpoint_ids().await?;
1840 let mut result = Vec::with_capacity(ids.len());
1841
1842 for id in ids {
1843 let manifest = self
1844 .load_by_id(id)
1845 .await?
1846 .ok_or(CheckpointStoreError::NotFound(id))?;
1847 result.push((manifest.checkpoint_id, manifest.epoch));
1848 }
1849
1850 Ok(result)
1851 }
1852
1853 async fn prune_before(&self, before_epoch: u64) -> Result<usize, CheckpointStoreError> {
1854 let latest_id = self.latest_checkpoint_id().await?;
1855 let mut candidates = Vec::new();
1856
1857 for id in self.sorted_checkpoint_ids().await? {
1861 if Some(id) == latest_id {
1862 continue;
1863 }
1864 let manifest = self
1865 .load_by_id(id)
1866 .await?
1867 .ok_or(CheckpointStoreError::NotFound(id))?;
1868 if manifest.epoch < before_epoch {
1869 candidates.push(id);
1870 }
1871 }
1872
1873 let mut removed = 0;
1874 for id in candidates {
1875 match tokio::fs::remove_dir_all(self.checkpoint_dir(id)).await {
1876 Ok(()) => removed += 1,
1877 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1878 Err(error) => return Err(error.into()),
1879 }
1880 }
1881 Ok(removed)
1882 }
1883
1884 async fn save_state_data(
1885 &self,
1886 id: u64,
1887 chunks: &[bytes::Bytes],
1888 ) -> Result<(), CheckpointStoreError> {
1889 let state_len = checked_state_data_len(chunks)?;
1890 if state_len > self.max_state_data_bytes {
1891 return Err(oversized_metadata(
1892 "checkpoint state sidecar",
1893 state_len,
1894 self.max_state_data_bytes,
1895 ));
1896 }
1897 let cp_dir = self.checkpoint_dir(id);
1898 let path = self.state_path(id);
1899 let chunks = chunks.to_vec();
1900 tokio::task::spawn_blocking(move || publish_filesystem_state(&cp_dir, &path, &chunks))
1901 .await
1902 .map_err(std::io::Error::other)?
1903 }
1904
1905 async fn load_state_data(&self, id: u64) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
1906 let path = self.state_path(id);
1907 let limit = self.max_state_data_bytes;
1908 tokio::task::spawn_blocking(move || {
1909 read_bounded_regular_file(&path, limit, "checkpoint state sidecar")
1910 })
1911 .await
1912 .map_err(std::io::Error::other)?
1913 }
1914
1915 async fn state_data_len_for_participant(
1916 &self,
1917 participant_id: u64,
1918 id: u64,
1919 ) -> Result<Option<u64>, CheckpointStoreError> {
1920 if participant_id != self.participant_id {
1921 return Err(CheckpointStoreError::Invalid(format!(
1922 "checkpoint store participant {} cannot read participant {participant_id}",
1923 self.participant_id
1924 )));
1925 }
1926 let path = self.state_path(id);
1927 let limit = self.max_state_data_bytes;
1928 tokio::task::spawn_blocking(move || {
1929 Ok(
1930 open_bounded_regular_file(&path, limit, "checkpoint state sidecar")?
1931 .map(|(_, len)| len),
1932 )
1933 })
1934 .await
1935 .map_err(std::io::Error::other)?
1936 }
1937}
1938
1939#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1945struct LatestPointer {
1946 checkpoint_id: u64,
1947}
1948
1949struct VersionedBytes {
1950 bytes: bytes::Bytes,
1951 version: UpdateVersion,
1952}
1953
1954struct VersionedManifest {
1955 manifest: CheckpointManifest,
1956 version: UpdateVersion,
1957}
1958
1959async fn collect_bounded_state_data(
1960 result: object_store::GetResult,
1961 expected_size: u64,
1962 expected_len: usize,
1963 limit: u64,
1964) -> Result<Vec<u8>, CheckpointStoreError> {
1965 use futures::TryStreamExt;
1966
1967 if result.meta.size > limit {
1968 return Err(oversized_metadata(
1969 "checkpoint state sidecar",
1970 result.meta.size,
1971 limit,
1972 ));
1973 }
1974 if result.meta.size != expected_size {
1975 return Err(CheckpointStoreError::Invalid(format!(
1976 "checkpoint state sidecar length changed from {expected_size} to {} bytes after metadata preflight",
1977 result.meta.size
1978 )));
1979 }
1980 if result.range.start != 0 || result.range.end != expected_size {
1981 return Err(CheckpointStoreError::Invalid(format!(
1982 "checkpoint state sidecar response range {}..{} does not match its advertised {expected_size}-byte length",
1983 result.range.start, result.range.end
1984 )));
1985 }
1986
1987 let mut bytes = Vec::new();
1988 bytes.try_reserve_exact(expected_len).map_err(|error| {
1989 CheckpointStoreError::Invalid(format!(
1990 "checkpoint state sidecar cannot reserve its advertised {expected_size}-byte length: {error}"
1991 ))
1992 })?;
1993 let mut stream = result.into_stream();
1994 while let Some(chunk) = stream.try_next().await? {
1995 let next_len = bytes.len().checked_add(chunk.len()).ok_or_else(|| {
1996 CheckpointStoreError::Invalid("checkpoint state sidecar body length overflow".into())
1997 })?;
1998 let next_len_u64 = u64::try_from(next_len).unwrap_or(u64::MAX);
1999 if next_len_u64 > limit {
2000 return Err(oversized_metadata(
2001 "checkpoint state sidecar",
2002 next_len_u64,
2003 limit,
2004 ));
2005 }
2006 if next_len > expected_len {
2007 return Err(CheckpointStoreError::Invalid(format!(
2008 "checkpoint state sidecar body exceeded its advertised {expected_size}-byte length"
2009 )));
2010 }
2011 bytes.extend_from_slice(&chunk);
2012 }
2013 if bytes.len() != expected_len {
2014 return Err(CheckpointStoreError::Invalid(format!(
2015 "checkpoint state sidecar body length {} does not match its advertised {expected_size}-byte length",
2016 bytes.len()
2017 )));
2018 }
2019 Ok(bytes)
2020}
2021
2022pub struct ObjectStoreCheckpointStore {
2045 store: Arc<dyn ObjectStore>,
2046 prefix: String,
2047 key_group_count: KeyGroupCount,
2048 participant_id: u64,
2049 max_state_data_bytes: u64,
2050}
2051
2052impl ObjectStoreCheckpointStore {
2053 #[must_use]
2061 pub fn new(store: Arc<dyn ObjectStore>, prefix: String) -> Self {
2062 Self {
2063 store,
2064 prefix,
2065 key_group_count: LOCAL_KEY_GROUP_COUNT,
2066 participant_id: 0,
2067 max_state_data_bytes: DEFAULT_MAX_CHECKPOINT_STATE_BYTES,
2068 }
2069 }
2070
2071 #[must_use]
2073 pub fn with_key_group_count(mut self, key_group_count: KeyGroupCount) -> Self {
2074 self.key_group_count = key_group_count;
2075 self
2076 }
2077
2078 #[must_use]
2080 pub fn with_participant_id(mut self, participant_id: u64) -> Self {
2081 self.participant_id = participant_id;
2082 self
2083 }
2084
2085 pub fn with_max_state_data_bytes(
2092 mut self,
2093 max_state_data_bytes: u64,
2094 ) -> Result<Self, CheckpointStoreError> {
2095 validate_max_checkpoint_state_bytes(max_state_data_bytes)?;
2096 self.max_state_data_bytes = max_state_data_bytes;
2097 Ok(self)
2098 }
2099
2100 fn manifest_path(&self, id: u64) -> object_store::path::Path {
2101 object_store::path::Path::from(format!("{}manifests/manifest-{id:06}.json", self.prefix))
2102 }
2103
2104 fn latest_pointer_path(&self) -> object_store::path::Path {
2105 object_store::path::Path::from(format!("{}manifests/latest.json", self.prefix))
2106 }
2107
2108 fn state_path(&self, id: u64) -> object_store::path::Path {
2109 object_store::path::Path::from(format!("{}checkpoints/state-{id:06}.bin", self.prefix))
2110 }
2111
2112 fn prefix_for_participant(&self, participant_id: u64) -> Result<String, CheckpointStoreError> {
2113 if participant_id == self.participant_id {
2114 return Ok(self.prefix.clone());
2115 }
2116 let expected = format!("nodes/{}/", self.participant_id);
2117 if self.prefix != expected {
2118 return Err(CheckpointStoreError::Invalid(format!(
2119 "checkpoint prefix '{}' is not the shared {expected} namespace",
2120 self.prefix
2121 )));
2122 }
2123 Ok(format!("nodes/{participant_id}/"))
2124 }
2125
2126 fn manifest_path_for_participant(
2127 &self,
2128 participant_id: u64,
2129 id: u64,
2130 ) -> Result<object_store::path::Path, CheckpointStoreError> {
2131 let prefix = self.prefix_for_participant(participant_id)?;
2132 Ok(object_store::path::Path::from(format!(
2133 "{prefix}manifests/manifest-{id:06}.json"
2134 )))
2135 }
2136
2137 fn state_path_for_participant(
2138 &self,
2139 participant_id: u64,
2140 id: u64,
2141 ) -> Result<object_store::path::Path, CheckpointStoreError> {
2142 let prefix = self.prefix_for_participant(participant_id)?;
2143 Ok(object_store::path::Path::from(format!(
2144 "{prefix}checkpoints/state-{id:06}.bin"
2145 )))
2146 }
2147
2148 async fn latest_checkpoint_id(&self) -> Result<Option<u64>, CheckpointStoreError> {
2151 Ok(self
2152 .load_latest()
2153 .await?
2154 .map(|manifest| manifest.checkpoint_id))
2155 }
2156
2157 async fn create_with_retry(
2165 &self,
2166 path: &object_store::path::Path,
2167 payload: PutPayload,
2168 ) -> Result<bool, CheckpointStoreError> {
2169 const BACKOFFS_MS: &[u64] = &[100, 500, 2000];
2170 let options = PutOptions {
2171 mode: PutMode::Create,
2172 ..PutOptions::default()
2173 };
2174 let mut attempt = 0usize;
2175 loop {
2176 match self
2177 .store
2178 .put_opts(path, payload.clone(), options.clone())
2179 .await
2180 {
2181 Ok(_) => return Ok(true),
2182 Err(object_store::Error::AlreadyExists { .. }) => return Ok(false),
2183 Err(object_store::Error::Generic { .. }) if attempt < BACKOFFS_MS.len() => {
2184 let base_ms = BACKOFFS_MS[attempt];
2185 let jitter_ms = rand::rng().random_range(0..=base_ms / 2);
2186 let delay = std::time::Duration::from_millis(base_ms + jitter_ms);
2187 tracing::warn!(
2188 path = %path,
2189 attempt = attempt + 1,
2190 delay_ms = delay.as_millis(),
2191 "transient immutable create error, retrying"
2192 );
2193 tokio::time::sleep(delay).await;
2194 attempt += 1;
2195 }
2196 Err(
2197 object_store::Error::NotImplemented { .. }
2198 | object_store::Error::NotSupported { .. },
2199 ) => {
2200 return Err(CheckpointStoreError::Invalid(format!(
2201 "object store does not support conditional create required for immutable checkpoint object '{path}'"
2202 )));
2203 }
2204 Err(error) => return Err(CheckpointStoreError::ObjectStore(error)),
2205 }
2206 }
2207 }
2208
2209 async fn get_bounded_state_data(
2211 &self,
2212 path: &object_store::path::Path,
2213 ) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
2214 let limit = self.max_state_data_bytes;
2215 let preflight = match self.store.head(path).await {
2216 Ok(metadata) => metadata,
2217 Err(object_store::Error::NotFound { .. }) => return Ok(None),
2218 Err(error) => return Err(CheckpointStoreError::ObjectStore(error)),
2219 };
2220 if preflight.size > limit {
2221 return Err(oversized_metadata(
2222 "checkpoint state sidecar",
2223 preflight.size,
2224 limit,
2225 ));
2226 }
2227
2228 let expected_len = usize::try_from(preflight.size).map_err(|_| {
2229 CheckpointStoreError::Invalid(format!(
2230 "checkpoint state sidecar size {} exceeds this process address space",
2231 preflight.size
2232 ))
2233 })?;
2234 let conditional_version = preflight.version.clone();
2235 let conditional_etag = conditional_version
2236 .is_none()
2237 .then(|| preflight.e_tag.clone())
2238 .flatten();
2239
2240 if expected_len == 0 {
2244 let result = self
2245 .store
2246 .get_opts(
2247 path,
2248 GetOptions {
2249 if_match: conditional_etag,
2250 version: conditional_version,
2251 head: true,
2252 ..GetOptions::default()
2253 },
2254 )
2255 .await?;
2256 if result.meta.size != 0 {
2257 return Err(CheckpointStoreError::Invalid(format!(
2258 "checkpoint state sidecar length changed from 0 to {} bytes after metadata preflight",
2259 result.meta.size
2260 )));
2261 }
2262 return Ok(Some(Vec::new()));
2263 }
2264
2265 let request_end = preflight.size.checked_add(1).ok_or_else(|| {
2266 CheckpointStoreError::Invalid(
2267 "checkpoint state sidecar safety limit cannot be range-bounded".into(),
2268 )
2269 })?;
2270 let result = self
2271 .store
2272 .get_opts(
2273 path,
2274 GetOptions {
2275 if_match: conditional_etag,
2276 range: Some(GetRange::Bounded(0..request_end)),
2277 version: conditional_version,
2278 ..GetOptions::default()
2279 },
2280 )
2281 .await?;
2282 collect_bounded_state_data(result, preflight.size, expected_len, limit)
2283 .await
2284 .map(Some)
2285 }
2286
2287 async fn get_bounded_versioned(
2288 &self,
2289 path: &object_store::path::Path,
2290 limit: u64,
2291 kind: &str,
2292 ) -> Result<Option<VersionedBytes>, CheckpointStoreError> {
2293 let request_end = limit.checked_add(1).ok_or_else(|| {
2294 CheckpointStoreError::Invalid(format!("{kind} limit cannot be range-bounded"))
2295 })?;
2296 match self
2297 .store
2298 .get_opts(
2299 path,
2300 GetOptions {
2301 range: Some(GetRange::Bounded(0..request_end)),
2302 ..GetOptions::default()
2303 },
2304 )
2305 .await
2306 {
2307 Ok(result) => {
2308 use futures::TryStreamExt;
2309
2310 let size = result.meta.size;
2311 if size > limit {
2312 return Err(oversized_metadata(kind, size, limit));
2313 }
2314 let range_size = result
2315 .range
2316 .end
2317 .checked_sub(result.range.start)
2318 .unwrap_or(u64::MAX);
2319 if range_size > limit {
2320 return Err(oversized_metadata(kind, range_size, limit));
2321 }
2322 if result.range.start != 0 || result.range.end != size {
2323 return Err(CheckpointStoreError::Invalid(format!(
2324 "{kind} response range {}..{} does not match object metadata size {size}",
2325 result.range.start, result.range.end
2326 )));
2327 }
2328 let version = UpdateVersion {
2329 e_tag: result.meta.e_tag.clone(),
2330 version: result.meta.version.clone(),
2331 };
2332 let capacity = usize::try_from(size).map_err(|_| {
2333 CheckpointStoreError::Invalid(format!(
2334 "{kind} size {size} exceeds this process address space"
2335 ))
2336 })?;
2337 let mut bytes = BytesMut::with_capacity(capacity);
2338 let mut stream = result.into_stream();
2339 while let Some(chunk) = stream.try_next().await? {
2340 let next_len = bytes.len().checked_add(chunk.len()).ok_or_else(|| {
2341 CheckpointStoreError::Invalid(format!("{kind} body length overflow"))
2342 })?;
2343 if next_len > capacity {
2344 return Err(CheckpointStoreError::Invalid(format!(
2345 "{kind} body exceeded its advertised {size}-byte length"
2346 )));
2347 }
2348 bytes.extend_from_slice(&chunk);
2349 }
2350 if bytes.len() != capacity {
2351 return Err(CheckpointStoreError::Invalid(format!(
2352 "{kind} body length {} does not match object metadata size {size}",
2353 bytes.len()
2354 )));
2355 }
2356 Ok(Some(VersionedBytes {
2357 bytes: bytes.freeze(),
2358 version,
2359 }))
2360 }
2361 Err(object_store::Error::NotFound { .. }) => Ok(None),
2362 Err(error) => Err(CheckpointStoreError::ObjectStore(error)),
2363 }
2364 }
2365
2366 fn require_update_version(
2367 path: &object_store::path::Path,
2368 version: UpdateVersion,
2369 ) -> Result<UpdateVersion, CheckpointStoreError> {
2370 if version.e_tag.is_none() && version.version.is_none() {
2371 Err(CheckpointStoreError::Invalid(format!(
2372 "object store returned no ETag or version required for conditional update of '{path}'"
2373 )))
2374 } else {
2375 Ok(version)
2376 }
2377 }
2378
2379 async fn load_manifest_versioned_at(
2380 &self,
2381 path: &object_store::path::Path,
2382 checkpoint_id: u64,
2383 participant_id: u64,
2384 ) -> Result<Option<VersionedManifest>, CheckpointStoreError> {
2385 let Some(versioned) = self
2386 .get_bounded_versioned(path, MAX_MANIFEST_BYTES, "checkpoint manifest")
2387 .await?
2388 else {
2389 return Ok(None);
2390 };
2391 let manifest: CheckpointManifest = serde_json::from_slice(&versioned.bytes)?;
2392 ensure_loaded_manifest(
2393 &manifest,
2394 checkpoint_id,
2395 participant_id,
2396 self.key_group_count,
2397 )?;
2398 Ok(Some(VersionedManifest {
2399 manifest,
2400 version: versioned.version,
2401 }))
2402 }
2403
2404 async fn load_manifest_at(
2405 &self,
2406 path: &object_store::path::Path,
2407 checkpoint_id: u64,
2408 participant_id: u64,
2409 ) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
2410 Ok(self
2411 .load_manifest_versioned_at(path, checkpoint_id, participant_id)
2412 .await?
2413 .map(|versioned| versioned.manifest))
2414 }
2415
2416 async fn load_latest_pointer_versioned(
2417 &self,
2418 ) -> Result<Option<(LatestPointer, UpdateVersion)>, CheckpointStoreError> {
2419 let Some(versioned) = self
2420 .get_bounded_versioned(
2421 &self.latest_pointer_path(),
2422 MAX_LATEST_POINTER_BYTES,
2423 "checkpoint recovery pointer",
2424 )
2425 .await?
2426 else {
2427 return Ok(None);
2428 };
2429 let pointer: LatestPointer = serde_json::from_slice(&versioned.bytes)?;
2430 Ok(Some((pointer, versioned.version)))
2431 }
2432
2433 async fn load_finalized_manifest(
2434 &self,
2435 checkpoint_id: u64,
2436 ) -> Result<CheckpointManifest, CheckpointStoreError> {
2437 let manifest = self
2438 .load_manifest_at(
2439 &self.manifest_path(checkpoint_id),
2440 checkpoint_id,
2441 self.participant_id,
2442 )
2443 .await?
2444 .ok_or_else(|| {
2445 CheckpointStoreError::Invalid(format!(
2446 "checkpoint recovery pointer references missing checkpoint {checkpoint_id}"
2447 ))
2448 })?;
2449 if manifest.durable_phase != DurableCheckpointPhase::Finalized {
2450 return Err(CheckpointStoreError::Invalid(format!(
2451 "checkpoint recovery pointer references non-finalized checkpoint {checkpoint_id}"
2452 )));
2453 }
2454 Ok(manifest)
2455 }
2456
2457 async fn publish_latest(&self, checkpoint_id: u64) -> Result<(), CheckpointStoreError> {
2458 let path = self.latest_pointer_path();
2459 let encoded = bytes::Bytes::from(serde_json::to_vec(&LatestPointer { checkpoint_id })?);
2460 ensure_serialized_size(
2461 "checkpoint recovery pointer",
2462 encoded.len(),
2463 MAX_LATEST_POINTER_BYTES,
2464 )?;
2465
2466 let mut last_storage_error = None;
2467 for attempt in 0..MAX_CAS_ATTEMPTS {
2468 let observed = self.load_latest_pointer_versioned().await?;
2469 let mode = match observed {
2470 Some((pointer, version)) => {
2471 self.load_finalized_manifest(pointer.checkpoint_id).await?;
2472 if pointer.checkpoint_id >= checkpoint_id {
2473 return Ok(());
2474 }
2475 PutMode::Update(Self::require_update_version(&path, version)?)
2476 }
2477 None => PutMode::Create,
2478 };
2479 let options = PutOptions {
2480 mode,
2481 ..PutOptions::default()
2482 };
2483 match self
2484 .store
2485 .put_opts(&path, PutPayload::from_bytes(encoded.clone()), options)
2486 .await
2487 {
2488 Ok(_) => return Ok(()),
2489 Err(
2490 object_store::Error::AlreadyExists { .. }
2491 | object_store::Error::Precondition { .. },
2492 ) => {
2493 checkpoint_cas_backoff(attempt).await;
2495 }
2496 Err(error @ object_store::Error::Generic { .. }) => {
2497 last_storage_error = Some(error.to_string());
2498 checkpoint_cas_backoff(attempt).await;
2499 }
2500 Err(
2501 object_store::Error::NotImplemented { .. }
2502 | object_store::Error::NotSupported { .. },
2503 ) => {
2504 return Err(CheckpointStoreError::Invalid(format!(
2505 "object store does not support conditional latest-pointer update for '{path}'"
2506 )));
2507 }
2508 Err(error) => return Err(CheckpointStoreError::ObjectStore(error)),
2509 }
2510 }
2511
2512 if let Some((pointer, _)) = self.load_latest_pointer_versioned().await? {
2513 self.load_finalized_manifest(pointer.checkpoint_id).await?;
2514 if pointer.checkpoint_id >= checkpoint_id {
2515 return Ok(());
2516 }
2517 }
2518 let detail = last_storage_error
2519 .map(|error| format!("; last storage error: {error}"))
2520 .unwrap_or_default();
2521 Err(CheckpointStoreError::Invalid(format!(
2522 "latest-pointer update for checkpoint {checkpoint_id} exceeded the bounded contention budget{detail}"
2523 )))
2524 }
2525
2526 async fn replace_manifest_exactly(
2527 &self,
2528 path: &object_store::path::Path,
2529 intended: &CheckpointManifest,
2530 encoded: bytes::Bytes,
2531 ) -> Result<(), CheckpointStoreError> {
2532 let mut last_storage_error = None;
2533 for attempt in 0..MAX_CAS_ATTEMPTS {
2534 let observed = self
2535 .load_manifest_versioned_at(path, intended.checkpoint_id, intended.participant_id)
2536 .await?
2537 .ok_or_else(|| {
2538 CheckpointStoreError::Invalid(format!(
2539 "checkpoint {} manifest disappeared during finalization",
2540 intended.checkpoint_id
2541 ))
2542 })?;
2543 if observed.manifest == *intended || exact_phase_successor(intended, &observed.manifest)
2544 {
2545 return Ok(());
2546 }
2547 if !exact_phase_successor(&observed.manifest, intended) {
2548 return Err(CheckpointStoreError::Invalid(format!(
2549 "checkpoint {} manifest already exists with different immutable content",
2550 intended.checkpoint_id
2551 )));
2552 }
2553 let options = PutOptions {
2554 mode: PutMode::Update(Self::require_update_version(path, observed.version)?),
2555 ..PutOptions::default()
2556 };
2557 match self
2558 .store
2559 .put_opts(path, PutPayload::from_bytes(encoded.clone()), options)
2560 .await
2561 {
2562 Ok(_) => return Ok(()),
2563 Err(object_store::Error::Precondition { .. }) => {
2564 checkpoint_cas_backoff(attempt).await;
2566 }
2567 Err(error @ object_store::Error::Generic { .. }) => {
2568 last_storage_error = Some(error.to_string());
2569 checkpoint_cas_backoff(attempt).await;
2570 }
2571 Err(
2572 object_store::Error::NotImplemented { .. }
2573 | object_store::Error::NotSupported { .. },
2574 ) => {
2575 return Err(CheckpointStoreError::Invalid(format!(
2576 "object store does not support conditional manifest finalization for '{path}'"
2577 )));
2578 }
2579 Err(error) => return Err(CheckpointStoreError::ObjectStore(error)),
2580 }
2581 }
2582
2583 let observed = self
2584 .load_manifest_at(path, intended.checkpoint_id, intended.participant_id)
2585 .await?;
2586 if observed.as_ref().is_some_and(|manifest| {
2587 manifest == intended || exact_phase_successor(intended, manifest)
2588 }) {
2589 return Ok(());
2590 }
2591 let detail = last_storage_error
2592 .map(|error| format!("; last storage error: {error}"))
2593 .unwrap_or_default();
2594 Err(CheckpointStoreError::Invalid(format!(
2595 "checkpoint {} manifest finalization exceeded the bounded contention budget{detail}",
2596 intended.checkpoint_id
2597 )))
2598 }
2599
2600 async fn publish_manifest(
2601 &self,
2602 manifest: &CheckpointManifest,
2603 encoded: bytes::Bytes,
2604 ) -> Result<(), CheckpointStoreError> {
2605 ensure_serialized_size("checkpoint manifest", encoded.len(), MAX_MANIFEST_BYTES)?;
2606 let path = self.manifest_path(manifest.checkpoint_id);
2607 if self
2608 .create_with_retry(&path, PutPayload::from_bytes(encoded.clone()))
2609 .await?
2610 {
2611 return Ok(());
2612 }
2613
2614 let existing = self
2615 .load_manifest_at(&path, manifest.checkpoint_id, manifest.participant_id)
2616 .await?
2617 .ok_or_else(|| {
2618 CheckpointStoreError::Invalid(format!(
2619 "checkpoint {} manifest create reported AlreadyExists but the object is missing",
2620 manifest.checkpoint_id
2621 ))
2622 })?;
2623 if existing == *manifest || exact_phase_successor(manifest, &existing) {
2624 return Ok(());
2625 }
2626 if exact_phase_successor(&existing, manifest) {
2627 return self
2628 .replace_manifest_exactly(&path, manifest, encoded)
2629 .await;
2630 }
2631 Err(CheckpointStoreError::Invalid(format!(
2632 "checkpoint {} manifest already exists with different immutable content",
2633 manifest.checkpoint_id
2634 )))
2635 }
2636
2637 async fn state_matches_chunks(
2638 &self,
2639 path: &object_store::path::Path,
2640 chunks: &[bytes::Bytes],
2641 ) -> Result<Option<bool>, CheckpointStoreError> {
2642 use futures::TryStreamExt;
2643
2644 let expected_len = checked_state_data_len(chunks)?;
2645 if expected_len > self.max_state_data_bytes {
2646 return Err(oversized_metadata(
2647 "checkpoint state sidecar",
2648 expected_len,
2649 self.max_state_data_bytes,
2650 ));
2651 }
2652 let options = if expected_len == 0 {
2653 GetOptions {
2654 head: true,
2655 ..GetOptions::default()
2656 }
2657 } else {
2658 GetOptions {
2659 range: Some(GetRange::Bounded(
2660 0..expected_len.checked_add(1).ok_or_else(|| {
2661 CheckpointStoreError::Invalid(
2662 "checkpoint state sidecar length cannot be range-bounded".into(),
2663 )
2664 })?,
2665 )),
2666 ..GetOptions::default()
2667 }
2668 };
2669 let result = match self.store.get_opts(path, options).await {
2670 Ok(result) => result,
2671 Err(object_store::Error::NotFound { .. }) => return Ok(None),
2672 Err(error) => return Err(CheckpointStoreError::ObjectStore(error)),
2673 };
2674 if result.meta.size > self.max_state_data_bytes {
2675 return Err(oversized_metadata(
2676 "checkpoint state sidecar",
2677 result.meta.size,
2678 self.max_state_data_bytes,
2679 ));
2680 }
2681 if result.meta.size != expected_len {
2682 return Ok(Some(false));
2683 }
2684 if expected_len == 0 {
2685 return Ok(Some(true));
2686 }
2687 if result.range.start != 0 || result.range.end != expected_len {
2688 return Err(CheckpointStoreError::Invalid(format!(
2689 "checkpoint state sidecar response range {}..{} does not match its advertised {expected_len}-byte length",
2690 result.range.start, result.range.end
2691 )));
2692 }
2693
2694 let mut actual = Sha256::new();
2695 let mut actual_len = 0_u64;
2696 let mut stream = result.into_stream();
2697 while let Some(chunk) = stream.try_next().await? {
2698 actual_len = actual_len.checked_add(chunk.len() as u64).ok_or_else(|| {
2699 CheckpointStoreError::Invalid(
2700 "checkpoint state sidecar body length overflow".into(),
2701 )
2702 })?;
2703 if actual_len > self.max_state_data_bytes {
2704 return Err(oversized_metadata(
2705 "checkpoint state sidecar",
2706 actual_len,
2707 self.max_state_data_bytes,
2708 ));
2709 }
2710 if actual_len > expected_len {
2711 return Err(CheckpointStoreError::Invalid(format!(
2712 "checkpoint state sidecar body exceeded its advertised {expected_len}-byte length"
2713 )));
2714 }
2715 actual.update(&chunk);
2716 }
2717 if actual_len != expected_len {
2718 return Err(CheckpointStoreError::Invalid(format!(
2719 "checkpoint state sidecar body length {actual_len} does not match its advertised {expected_len}-byte length"
2720 )));
2721 }
2722 let expected = sha256_hex_chunks(chunks);
2723 Ok(Some(format!("{:x}", actual.finalize()) == expected))
2724 }
2725
2726 async fn list_checkpoint_ids(&self) -> Result<Vec<u64>, CheckpointStoreError> {
2728 self.list_checkpoint_ids_with_limit(MAX_CHECKPOINT_INVENTORY_ENTRIES)
2729 .await
2730 }
2731
2732 async fn list_checkpoint_ids_with_limit(
2733 &self,
2734 max_entries: usize,
2735 ) -> Result<Vec<u64>, CheckpointStoreError> {
2736 use futures::TryStreamExt;
2737
2738 let mut ids = std::collections::BTreeSet::new();
2739
2740 let manifest_namespace = format!("{}manifests/", self.prefix);
2741 let manifests_prefix = object_store::path::Path::from(manifest_namespace.clone());
2742 let mut entries = self.store.list(Some(&manifests_prefix));
2743 let mut scanned = 0usize;
2744 while let Some(entry) = entries.try_next().await? {
2745 if scanned >= max_entries {
2746 return Err(CheckpointStoreError::Invalid(format!(
2747 "checkpoint inventory exceeds the {max_entries}-entry safety limit"
2748 )));
2749 }
2750 scanned += 1;
2751 let path = entry.location.as_ref();
2752 if let Some(id) = parse_checkpoint_id_from_path(path, "manifest-", ".json") {
2753 if entry.location != self.manifest_path(id) {
2754 return Err(CheckpointStoreError::Invalid(format!(
2755 "non-canonical checkpoint manifest path '{path}'"
2756 )));
2757 }
2758 ids.insert(id);
2759 } else if path
2760 .strip_prefix(&manifest_namespace)
2761 .is_some_and(|name| name.starts_with("manifest-"))
2762 {
2763 return Err(CheckpointStoreError::Invalid(format!(
2764 "non-canonical checkpoint manifest path '{path}'"
2765 )));
2766 }
2767 }
2768
2769 Ok(ids.into_iter().collect())
2770 }
2771}
2772
2773#[async_trait]
2774impl CheckpointStore for ObjectStoreCheckpointStore {
2775 fn max_state_data_bytes(&self) -> u64 {
2776 self.max_state_data_bytes
2777 }
2778
2779 fn key_group_count(&self) -> KeyGroupCount {
2780 self.key_group_count
2781 }
2782
2783 fn participant_id(&self) -> u64 {
2784 self.participant_id
2785 }
2786
2787 async fn save(&self, manifest: &CheckpointManifest) -> Result<(), CheckpointStoreError> {
2788 self.ensure_manifest_participant(manifest)?;
2789 self.ensure_manifest_valid(manifest)?;
2790 let encoded = bytes::Bytes::from(serde_json::to_vec_pretty(manifest)?);
2791 self.publish_manifest(manifest, encoded).await?;
2792
2793 if manifest.durable_phase == DurableCheckpointPhase::Prepared {
2796 return Ok(());
2797 }
2798
2799 self.publish_latest(manifest.checkpoint_id).await
2800 }
2801
2802 async fn finalize(
2803 &self,
2804 checkpoint_id: u64,
2805 ) -> Result<CheckpointManifest, CheckpointStoreError> {
2806 let path = self.manifest_path(checkpoint_id);
2807 let mut manifest = self
2808 .load_manifest_at(&path, checkpoint_id, self.participant_id)
2809 .await?
2810 .ok_or(CheckpointStoreError::NotFound(checkpoint_id))?;
2811 if manifest.durable_phase == DurableCheckpointPhase::Prepared {
2812 manifest.durable_phase = DurableCheckpointPhase::Finalized;
2813 let encoded = bytes::Bytes::from(serde_json::to_vec_pretty(&manifest)?);
2814 ensure_serialized_size("checkpoint manifest", encoded.len(), MAX_MANIFEST_BYTES)?;
2815 self.replace_manifest_exactly(&path, &manifest, encoded)
2816 .await?;
2817 }
2818 self.publish_latest(manifest.checkpoint_id).await?;
2819 Ok(manifest)
2820 }
2821
2822 async fn load_latest(&self) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
2823 let Some((pointer, _)) = self.load_latest_pointer_versioned().await? else {
2824 return Ok(None);
2825 };
2826 Ok(Some(
2827 self.load_finalized_manifest(pointer.checkpoint_id).await?,
2828 ))
2829 }
2830
2831 async fn load_by_id(
2832 &self,
2833 id: u64,
2834 ) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
2835 self.load_manifest_at(&self.manifest_path(id), id, self.participant_id)
2836 .await
2837 }
2838
2839 async fn load_manifest_for_participant(
2840 &self,
2841 participant_id: u64,
2842 id: u64,
2843 ) -> Result<Option<CheckpointManifest>, CheckpointStoreError> {
2844 let path = self.manifest_path_for_participant(participant_id, id)?;
2845 self.load_manifest_at(&path, id, participant_id).await
2846 }
2847
2848 async fn list_ids(&self) -> Result<Vec<u64>, CheckpointStoreError> {
2849 self.list_checkpoint_ids().await
2850 }
2851
2852 async fn list(&self) -> Result<Vec<(u64, u64)>, CheckpointStoreError> {
2853 let ids = self.list_checkpoint_ids().await?;
2854 let mut result = Vec::with_capacity(ids.len());
2855
2856 for id in ids {
2857 let manifest = self
2858 .load_by_id(id)
2859 .await?
2860 .ok_or(CheckpointStoreError::NotFound(id))?;
2861 result.push((manifest.checkpoint_id, manifest.epoch));
2862 }
2863
2864 Ok(result)
2865 }
2866
2867 async fn prune_before(&self, before_epoch: u64) -> Result<usize, CheckpointStoreError> {
2868 let latest_id = self.latest_checkpoint_id().await?;
2869 let mut candidates = Vec::new();
2870
2871 for id in self.list_checkpoint_ids().await? {
2875 if Some(id) == latest_id {
2876 continue;
2877 }
2878 let manifest = self
2879 .load_by_id(id)
2880 .await?
2881 .ok_or(CheckpointStoreError::NotFound(id))?;
2882 if manifest.epoch < before_epoch {
2883 candidates.push(id);
2884 }
2885 }
2886
2887 let mut removed = 0;
2888 for id in candidates {
2889 let manifest = self.manifest_path(id);
2890 let state = self.state_path(id);
2891 if let Err(error) = self.store.delete(&state).await {
2895 if !matches!(error, object_store::Error::NotFound { .. }) {
2896 return Err(CheckpointStoreError::ObjectStore(error));
2897 }
2898 }
2899 match self.store.delete(&manifest).await {
2900 Ok(()) => removed += 1,
2901 Err(object_store::Error::NotFound { .. }) => {}
2902 Err(error) => return Err(CheckpointStoreError::ObjectStore(error)),
2903 }
2904 }
2905 Ok(removed)
2906 }
2907
2908 async fn save_state_data(
2909 &self,
2910 id: u64,
2911 chunks: &[bytes::Bytes],
2912 ) -> Result<(), CheckpointStoreError> {
2913 let state_len = checked_state_data_len(chunks)?;
2914 if state_len > self.max_state_data_bytes {
2915 return Err(oversized_metadata(
2916 "checkpoint state sidecar",
2917 state_len,
2918 self.max_state_data_bytes,
2919 ));
2920 }
2921 let path = self.state_path(id);
2922 let payload: PutPayload = chunks.iter().cloned().collect();
2926 if self.create_with_retry(&path, payload).await? {
2927 Ok(())
2928 } else {
2929 let matches = self
2930 .state_matches_chunks(&path, chunks)
2931 .await?
2932 .ok_or_else(|| {
2933 CheckpointStoreError::Invalid(format!(
2934 "checkpoint {id} state create reported AlreadyExists but the object is missing"
2935 ))
2936 })?;
2937 if matches {
2938 Ok(())
2939 } else {
2940 Err(CheckpointStoreError::Invalid(format!(
2941 "checkpoint {id} state already exists with different immutable content"
2942 )))
2943 }
2944 }
2945 }
2946
2947 async fn load_state_data(&self, id: u64) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
2948 self.get_bounded_state_data(&self.state_path(id)).await
2949 }
2950
2951 async fn load_state_data_for_participant(
2952 &self,
2953 participant_id: u64,
2954 id: u64,
2955 ) -> Result<Option<Vec<u8>>, CheckpointStoreError> {
2956 let path = self.state_path_for_participant(participant_id, id)?;
2957 self.get_bounded_state_data(&path).await
2958 }
2959
2960 async fn state_data_len_for_participant(
2961 &self,
2962 participant_id: u64,
2963 id: u64,
2964 ) -> Result<Option<u64>, CheckpointStoreError> {
2965 let path = self.state_path_for_participant(participant_id, id)?;
2966 match self.store.head(&path).await {
2967 Ok(metadata) if metadata.size <= self.max_state_data_bytes => Ok(Some(metadata.size)),
2968 Ok(metadata) => Err(oversized_metadata(
2969 "checkpoint state sidecar",
2970 metadata.size,
2971 self.max_state_data_bytes,
2972 )),
2973 Err(object_store::Error::NotFound { .. }) => Ok(None),
2974 Err(error) => Err(CheckpointStoreError::ObjectStore(error)),
2975 }
2976 }
2977}
2978
2979#[cfg(test)]
2980mod tests;