Skip to main content

laminar_core/checkpoint/
checkpoint_store.rs

1//! Checkpoint persistence via the [`CheckpointStore`] trait.
2//!
3//! Provides a filesystem-backed implementation ([`FileSystemCheckpointStore`])
4//! that writes manifests as atomic JSON files with a `latest.txt` pointer
5//! for crash-safe recovery.
6//!
7
8#![allow(clippy::disallowed_types)] // cold path: checkpoint metadata operations
9//! ## Disk Layout
10//!
11//! ```text
12//! {base_dir}/checkpoints/
13//!   checkpoint_000001/
14//!     manifest.json     # CheckpointManifest as pretty-printed JSON
15//!     state.bin         # Optional: large operator state sidecar
16//!   checkpoint_000002/
17//!     manifest.json
18//!   latest.txt          # "checkpoint_000002" — pointer to latest good checkpoint
19//! ```
20
21use 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;
44/// Default upper bound for both one checkpoint's aggregate raw logical operator state and its
45/// external sidecar.
46pub const DEFAULT_MAX_CHECKPOINT_STATE_BYTES: u64 = 512 * 1024 * 1024;
47/// Maximum number of checkpoint entries one bounded inventory operation may materialize.
48pub const MAX_CHECKPOINT_INVENTORY_ENTRIES: usize = 65_536;
49const MAX_CAS_ATTEMPTS: usize = 16;
50
51/// Validate a configured per-checkpoint operator-state byte budget.
52///
53/// # Errors
54///
55/// Returns [`CheckpointStoreError::Invalid`] when the budget is zero or cannot
56/// be safely represented and overflow-probed by this process.
57pub 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    // Read through a fixed scratch buffer so neither EOF detection nor a concurrent one-byte
230    // growth can make the destination Vec reserve beyond the configured checkpoint budget.
231    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/// Errors from checkpoint store operations.
562#[derive(Debug, thiserror::Error)]
563pub enum CheckpointStoreError {
564    /// I/O error during checkpoint persistence.
565    #[error("checkpoint I/O error: {0}")]
566    Io(#[from] std::io::Error),
567
568    /// JSON serialization/deserialization error.
569    #[error("checkpoint serialization error: {0}")]
570    Serde(#[from] serde_json::Error),
571
572    /// Checkpoint not found.
573    #[error("checkpoint {0} not found")]
574    NotFound(u64),
575
576    /// Object store error.
577    #[error("object store error: {0}")]
578    ObjectStore(#[from] object_store::Error),
579
580    /// Persisted checkpoint metadata violates the store contract.
581    #[error("invalid checkpoint: {0}")]
582    Invalid(String),
583}
584
585// ---------------------------------------------------------------------------
586// Checkpoint validation types
587// ---------------------------------------------------------------------------
588
589/// Classification of a single validation finding.
590///
591/// Both variants are fatal. They remain distinct so diagnostics can separate
592/// an incompatible runtime contract from corrupt persisted bytes.
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub enum ValidationIssue {
595    /// Fatal manifest-level incompatibility (for example a key-group-count mismatch).
596    ManifestIncompatibility(String),
597    /// Fatal: manifest is missing/corrupt, or the sidecar integrity
598    /// check (checksum, presence) failed.
599    IntegrityFailure(String),
600}
601
602impl ValidationIssue {
603    /// Underlying human-readable message.
604    #[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/// Result of validating a single checkpoint.
619#[derive(Debug, Clone)]
620pub struct ValidationResult {
621    /// Checkpoint ID that was validated.
622    pub checkpoint_id: u64,
623    /// Whether the checkpoint is valid for recovery. A checkpoint is
624    /// valid iff it has no validation issues.
625    pub valid: bool,
626    /// Issues found during validation.
627    pub issues: Vec<ValidationIssue>,
628}
629
630/// Manifest and optional operator-state sidecar loaded for one exact checkpoint.
631///
632/// The sidecar is present only when the manifest's checksum shape requires it. Keeping both
633/// artifacts together lets recovery validate and restore the same bytes without a second read.
634#[derive(Debug, Clone, PartialEq)]
635pub struct CheckpointArtifacts {
636    /// Parsed checkpoint manifest.
637    pub manifest: CheckpointManifest,
638    /// Exact checksum-required `state.bin` bytes, if the manifest uses a sidecar.
639    pub state_data: Option<Vec<u8>>,
640}
641
642impl CheckpointArtifacts {
643    /// Validate these already-loaded bytes without blocking an async runtime worker.
644    ///
645    /// Ownership is returned with the result so recovery can restore the same sidecar allocation
646    /// that was checksummed instead of cloning or re-reading it.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`CheckpointStoreError::Invalid`] for an unusable state budget, or
651    /// [`CheckpointStoreError::Io`] if the blocking validation task cannot be joined.
652    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    // Retain this integrity classification in addition to the manifest compatibility finding.
878    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    // Invalid shape, namespace, or budget is already fatal; avoid hashing a large sidecar that
885    // recovery cannot use.
886    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/// Report from a crash-safe recovery walk.
928///
929/// Captures which checkpoints were tried, which were skipped (and why),
930/// and which was ultimately chosen for recovery.
931#[derive(Debug, Clone)]
932pub struct RecoveryReport {
933    /// The checkpoint that was selected for recovery (`None` if fresh start).
934    pub chosen_id: Option<u64>,
935    /// Checkpoints that were tried and skipped (id, reason).
936    pub skipped: Vec<(u64, String)>,
937    /// Total number of checkpoints examined.
938    pub examined: usize,
939    /// Elapsed time for the recovery walk.
940    pub elapsed: std::time::Duration,
941}
942
943/// Parse a checkpoint id out of an object-store path segment shaped like
944/// `"{prefix}NNNNNN{suffix}"` (e.g. `"manifest-000042.json"`). Scans all
945/// '/'-separated segments so the helper works on prefixed stores. A
946/// segment with the right affixes but a non-numeric middle is logged at
947/// warn — operators need to notice manually-renamed files rather than
948/// see silent gaps in `prune`/`list_ids`.
949fn 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
972/// Compute SHA-256 hex digest of data.
973fn sha256_hex(data: &[u8]) -> String {
974    let mut hasher = Sha256::new();
975    hasher.update(data);
976    format!("{:x}", hasher.finalize())
977}
978
979/// Compute SHA-256 hex digest across a chain of `Bytes` chunks.
980///
981/// Equivalent to hashing the concatenation of the chunks, but without
982/// materializing that concatenation in memory. Used by
983/// [`CheckpointStore::save_with_state`] to checksum the sidecar before
984/// the multi-chunk write.
985fn 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
1004/// Combined checksum for a mixed (inline + external) manifest:
1005/// `sha256(inline_hash_hex || concat(sidecar_chunks))`.
1006fn 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
1025/// SHA-256 over inline operator-state entries in sorted-name order,
1026/// used as the `state_checksum` when no sidecar exists.
1027fn 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/// Trait for checkpoint persistence backends.
1070///
1071/// Implementations must guarantee atomic manifest writes (readers never see
1072/// a partial manifest). The `latest.txt` pointer is updated only after the
1073/// manifest is fully written and synced.
1074#[async_trait]
1075pub trait CheckpointStore: Send + Sync {
1076    /// Maximum aggregate raw logical operator-state bytes admitted for one checkpoint.
1077    ///
1078    /// The same bound independently limits the physical sidecar. Implementations must enforce it
1079    /// on writes, body reads, metadata-only length reads, and artifact validation.
1080    fn max_state_data_bytes(&self) -> u64;
1081
1082    /// Runtime key-group count that manifests written by this store are
1083    /// expected to use. Consulted when validating loaded manifests. Embedded
1084    /// and single-node stores default to one key group.
1085    fn key_group_count(&self) -> KeyGroupCount {
1086        LOCAL_KEY_GROUP_COUNT
1087    }
1088
1089    /// Participant whose manifests belong in this store namespace.
1090    ///
1091    /// Embedded and standalone stores use participant `0`; cluster stores use their stable
1092    /// numeric instance id and a matching participant-specific namespace.
1093    fn participant_id(&self) -> u64 {
1094        0
1095    }
1096
1097    /// Reject a manifest that was routed to another participant's namespace.
1098    ///
1099    /// # Errors
1100    /// Returns [`CheckpointStoreError::Invalid`] when the manifest participant does not match
1101    /// this store's participant.
1102    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    /// Reject an invalid manifest before any sidecar, directory, or object is created.
1118    ///
1119    /// # Errors
1120    ///
1121    /// Returns [`CheckpointStoreError::Invalid`] when the manifest violates an invariant.
1122    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    /// Atomically persists a checkpoint manifest. Implementations must
1142    /// guarantee readers never observe a partial manifest.
1143    ///
1144    /// # Errors
1145    /// Returns [`CheckpointStoreError`] on I/O or serialization failure.
1146    async fn save(&self, manifest: &CheckpointManifest) -> Result<(), CheckpointStoreError>;
1147
1148    /// Loads the most recent checkpoint manifest, or `Ok(None)` when the
1149    /// recovery pointer does not exist in a fresh store.
1150    ///
1151    /// # Errors
1152    /// Returns [`CheckpointStoreError`] on I/O or deserialization failure, or
1153    /// when an existing recovery pointer is malformed, dangling, or references
1154    /// anything other than the matching Finalized manifest.
1155    async fn load_latest(&self) -> Result<Option<CheckpointManifest>, CheckpointStoreError>;
1156
1157    /// Loads a specific manifest, or `Ok(None)` if absent.
1158    ///
1159    /// # Errors
1160    /// Returns [`CheckpointStoreError`] on I/O or deserialization failure.
1161    async fn load_by_id(&self, id: u64)
1162        -> Result<Option<CheckpointManifest>, CheckpointStoreError>;
1163
1164    /// Load an exact checkpoint from a participant namespace.
1165    ///
1166    /// Cluster recovery uses this only when the durable decision proves that the local
1167    /// participant was not part of the committed cut. Implementations that do not expose a
1168    /// shared participant namespace reject non-local reads.
1169    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    /// Lists all available checkpoints as `(id, epoch)` pairs, sorted
1184    /// ascending by ID. May read every manifest; callers that only
1185    /// need IDs should use [`Self::list_ids`].
1186    ///
1187    /// # Errors
1188    /// Returns [`CheckpointStoreError`] on I/O, deserialization, or manifest validation failure.
1189    async fn list(&self) -> Result<Vec<(u64, u64)>, CheckpointStoreError>;
1190
1191    /// Lists all checkpoint IDs, **sorted ascending**. Unlike
1192    /// [`Self::list`] this enumerates corrupt manifests too (used by
1193    /// crash recovery). Callers rely on the ascending invariant.
1194    ///
1195    /// # Errors
1196    /// Returns [`CheckpointStoreError`] on I/O failure.
1197    async fn list_ids(&self) -> Result<Vec<u64>, CheckpointStoreError>;
1198
1199    /// Prunes manifests whose checkpoint epoch is strictly below `before_epoch`.
1200    ///
1201    /// This is the production retention primitive. The coordinator supplies the
1202    /// same externally-committed/recovery-safe horizon used for state and decision
1203    /// retention, so all three durable inventories advance together. The manifest
1204    /// referenced by the latest recovery pointer is always retained.
1205    ///
1206    /// # Errors
1207    /// Returns [`CheckpointStoreError`] on I/O or deserialization failure. A
1208    /// malformed latest pointer or manifest fails closed without deleting it.
1209    async fn prune_before(&self, before_epoch: u64) -> Result<usize, CheckpointStoreError>;
1210
1211    /// Atomically publish a prepared checkpoint as the latest recoverable cut.
1212    ///
1213    /// The transition is idempotent and preserves the checksum stamped by
1214    /// [`Self::save_with_state`].
1215    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        // Save even when the manifest was already Finalized: a crash may have landed that phase
1244        // transition but not its recovery pointer, and finalize is the repair operation.
1245        self.save(&manifest).await?;
1246        Ok(manifest)
1247    }
1248
1249    /// Writes operator state sidecar bytes for a checkpoint.
1250    ///
1251    /// Accepts a chain of `Bytes` chunks (one per operator) rather than
1252    /// a single concatenated slice. Backends that support native
1253    /// multi-chunk writes (object-store `PutPayload`) avoid copying the
1254    /// chunks into a contiguous buffer; backends without such support
1255    /// write sequentially.
1256    ///
1257    /// # Errors
1258    /// Returns [`CheckpointStoreError`] on I/O failure or when the sidecar
1259    /// exceeds [`Self::max_state_data_bytes`].
1260    async fn save_state_data(
1261        &self,
1262        id: u64,
1263        chunks: &[bytes::Bytes],
1264    ) -> Result<(), CheckpointStoreError>;
1265
1266    /// Loads operator state sidecar bytes for a checkpoint, or `Ok(None)`
1267    /// if no sidecar was written.
1268    ///
1269    /// # Errors
1270    /// Returns [`CheckpointStoreError`] on I/O failure, malformed storage
1271    /// metadata/body, or a sidecar above [`Self::max_state_data_bytes`].
1272    async fn load_state_data(&self, id: u64) -> Result<Option<Vec<u8>>, CheckpointStoreError>;
1273
1274    /// Load operator state sidecar bytes from a participant namespace.
1275    ///
1276    /// Stores without a shared participant namespace reject non-local reads.
1277    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    /// Read only the durable sidecar object's length from storage metadata.
1292    ///
1293    /// Cluster retention uses this to prove that a manifest's immutable sidecar still exists
1294    /// without downloading its body. Custom stores fail closed until they provide an equivalent
1295    /// metadata-only lookup.
1296    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    /// Load the manifest and checksum-required sidecar for a local checkpoint exactly once.
1308    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    /// Load the manifest and checksum-required sidecar from a participant namespace exactly once.
1317    ///
1318    /// Inline-only checkpoints do not read `state.bin`. External and mixed shapes perform one
1319    /// sidecar read after the manifest has selected that shape.
1320    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    /// Validate a specific checkpoint's integrity.
1344    ///
1345    /// Checks that the manifest is parseable and, if a `state_checksum` is
1346    /// present, verifies the sidecar data matches.
1347    ///
1348    /// # Errors
1349    ///
1350    /// Returns [`CheckpointStoreError`] on I/O failure.
1351    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    /// Walk backward from latest to find the first valid checkpoint.
1383    ///
1384    /// Returns a [`RecoveryReport`] describing the walk. If no valid
1385    /// checkpoint is found, `chosen_id` is `None` (fresh start).
1386    ///
1387    /// # Errors
1388    ///
1389    /// Returns [`CheckpointStoreError`] on I/O failure.
1390    async fn recover_latest_validated(&self) -> Result<RecoveryReport, CheckpointStoreError> {
1391        let start = std::time::Instant::now();
1392        let mut skipped = Vec::new();
1393
1394        // list_ids returns ascending per the trait contract; we iterate
1395        // newest-first so the first valid checkpoint wins.
1396        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    /// Atomically saves a checkpoint manifest with optional sidecar state data.
1463    ///
1464    /// When `state_data` is provided, the sidecar (`state.bin`) is written and
1465    /// fsynced **before** the manifest. This ensures that if the sidecar write
1466    /// fails, the manifest is never persisted and `latest.txt` still points to
1467    /// the previous valid checkpoint.
1468    ///
1469    /// A `state.bin` written without a manifest is not visible to checkpoint
1470    /// inventory or recovery.
1471    ///
1472    /// # Errors
1473    ///
1474    /// Returns [`CheckpointStoreError`] on I/O, serialization, validation, or operator state
1475    /// above [`Self::max_state_data_bytes`].
1476    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        // Validate the cheap manifest invariants before scheduling a potentially large hash. The
1486        // checksum is always restamped below, so a temporary value satisfies its presence rule.
1487        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        // Bytes clones retain the capture buffers without copying their contents. Hashing and
1494        // exact base64/layout validation are CPU work and must not occupy a Tokio worker.
1495        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
1540/// Stamp `state_checksum` for a save: mixed manifests hash inline+sidecar
1541/// together, purely-external hash the sidecar alone.
1542fn 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
1558/// Filesystem-backed checkpoint store.
1559///
1560/// Writes checkpoint manifests as JSON files with atomic rename semantics.
1561/// A `latest.txt` pointer (not a symlink) tracks the most recent checkpoint
1562/// for Windows compatibility.
1563pub 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    /// Creates a new filesystem checkpoint store.
1574    ///
1575    /// The `base_dir` is the parent directory; checkpoints are stored under
1576    /// `{base_dir}/checkpoints/`. The directory is created lazily on first save.
1577    ///
1578    /// Embedded and single-node stores default to one key group. Cluster hosts
1579    /// must chain [`Self::with_key_group_count`] with their durable topology.
1580    #[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    /// Override the stable key-group count used during manifest validation.
1599    #[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    /// Bind this store to one runtime participant.
1606    #[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    /// Override the aggregate raw logical-state and physical-sidecar bytes admitted per checkpoint.
1613    ///
1614    /// # Errors
1615    ///
1616    /// Returns [`CheckpointStoreError::Invalid`] when the limit cannot safely
1617    /// bound an in-memory restore allocation and its one-byte overflow probe.
1618    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    /// Returns the checkpoints directory path.
1628    fn checkpoints_dir(&self) -> PathBuf {
1629        self.base_dir.join("checkpoints")
1630    }
1631
1632    /// Returns the directory path for a specific checkpoint.
1633    fn checkpoint_dir(&self, id: u64) -> PathBuf {
1634        self.checkpoints_dir().join(format!("checkpoint_{id:06}"))
1635    }
1636
1637    /// Returns the manifest file path for a specific checkpoint.
1638    fn manifest_path(&self, id: u64) -> PathBuf {
1639        self.checkpoint_dir(id).join("manifest.json")
1640    }
1641
1642    /// Returns the state sidecar file path for a specific checkpoint.
1643    fn state_path(&self, id: u64) -> PathBuf {
1644        self.checkpoint_dir(id).join("state.bin")
1645    }
1646
1647    /// Returns the latest.txt pointer path.
1648    fn latest_path(&self) -> PathBuf {
1649        self.checkpoints_dir().join("latest.txt")
1650    }
1651
1652    /// Read the recovery pointer without loading its manifest. Retention must
1653    /// preserve this exact ID even if newer prepared attempts sort after it.
1654    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    /// Parses a checkpoint ID from a directory name like `checkpoint_000042`.
1662    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    /// Collects and sorts checkpoint directories that contain a manifest.
1668    ///
1669    /// A sidecar-only directory is an expected crash orphan: it was written
1670    /// before manifest publication and must not turn an otherwise fresh store
1671    /// into unusable checkpoint history.
1672    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        // Prepared attempts are inventory, never the published recovery cut.
1762        if manifest.durable_phase == DurableCheckpointPhase::Prepared {
1763            return Ok(());
1764        }
1765
1766        // The blocking task owns the lock and the complete compare/publish transaction. Dropping
1767        // this future cannot leave a detached stale writer that later regresses the pointer.
1768        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        // Resolve the complete candidate set before deleting anything. A corrupt
1858        // manifest therefore fails this pass closed instead of partially advancing
1859        // retention past an inventory we could not classify.
1860        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// ---------------------------------------------------------------------------
1940// ObjectStoreCheckpointStore — adapter for any ObjectStore backend
1941// ---------------------------------------------------------------------------
1942
1943/// JSON pointer stored in `manifests/latest.json`.
1944#[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
2022/// Object-store-backed checkpoint store.
2023///
2024/// Drives any `object_store::ObjectStore` backend (S3, GCS, Azure,
2025/// local FS) directly over `.await`; no dedicated runtime. The app
2026/// runtime's HTTP connection pool is reused.
2027///
2028/// ## Object Layout
2029///
2030/// ```text
2031/// {prefix}/
2032///   manifests/
2033///     manifest-000001.json    # Checkpoint manifest (JSON)
2034///     manifest-000002.json
2035///     latest.json             # {"checkpoint_id": 2}
2036///   checkpoints/
2037///     state-000001.bin        # Optional sidecar state
2038///     state-000002.bin
2039/// ```
2040///
2041/// Manifest creation, finalization, and recovery-pointer publication are all
2042/// conditional writes. The only mutable manifest transition is the exact
2043/// `Prepared` to `Finalized` phase change.
2044pub 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    /// Create a new object-store-backed checkpoint store.
2054    ///
2055    /// `prefix` is prepended to all object paths (e.g., `"nodes/abc123/"`).
2056    /// It should end with `/` or be empty.
2057    ///
2058    /// Embedded and single-node stores default to one key group. Cluster hosts
2059    /// must chain [`Self::with_key_group_count`] with their durable topology.
2060    #[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    /// Override the stable key-group count used during manifest validation.
2072    #[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    /// Bind this store to one runtime participant.
2079    #[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    /// Override the aggregate raw logical-state and physical-sidecar bytes admitted per checkpoint.
2086    ///
2087    /// # Errors
2088    ///
2089    /// Returns [`CheckpointStoreError::Invalid`] when the limit cannot safely
2090    /// bound an in-memory restore allocation and its one-byte overflow probe.
2091    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    /// Read the recovery pointer without loading its manifest. Retention must
2149    /// preserve this exact ID even if newer prepared attempts sort after it.
2150    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    // ── Helpers ──
2158
2159    /// Create an immutable object with bounded retry and report whether this call created it.
2160    ///
2161    /// An ambiguous transient failure is retried with the same conditional create. If the first
2162    /// request actually succeeded, the retry returns `AlreadyExists`; callers then compare the
2163    /// durable bytes with their intended content before treating the operation as idempotent.
2164    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    /// Load a sidecar only after a metadata preflight has proved its allocation bound.
2210    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        // An empty immutable object needs no body allocation. A conditional second HEAD still
2241        // proves that the exact preflight version remains empty without issuing an invalid 0..0
2242        // range request on backends that reject ranges against empty objects.
2243        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                    // This also reconciles a successful write whose response was lost.
2494                    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                    // Re-read to distinguish contention from a successful write with a lost ACK.
2565                    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    /// List checkpoint IDs by scanning `manifests/manifest-NNNNNN.json`.
2727    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        // A prepared manifest is deliberately invisible to the normal latest
2794        // pointer. Recovery inventory still discovers it through list_ids().
2795        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        // Resolve the complete candidate set before deleting anything. A corrupt
2872        // manifest therefore fails this pass closed instead of partially advancing
2873        // retention past an inventory we could not classify.
2874        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            // The manifest is the inventory record. Delete the sidecar first so
2892            // a failed or ambiguously acknowledged sidecar delete remains
2893            // discoverable and can be completed by a later retention pass.
2894            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        // PutPayload is a chain of Bytes — no concatenation into a
2923        // contiguous buffer. Each Arc bump is ~nothing; the underlying
2924        // bytes reach the object-store client untouched.
2925        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;