Skip to main content

laminar_core/checkpoint_decision/
mod.rs

1//! Durable checkpoint identity, ID allocation, and immutable terminal outcomes.
2
3use std::path::{Path as FsPath, PathBuf};
4use std::sync::{Arc, OnceLock, Weak};
5
6use crate::checkpoint::CheckpointAttempt;
7pub use crate::checkpoint::CheckpointScope;
8use crate::checkpoint::{
9    CheckpointAssignmentFence, CommittedCheckpointIndex, CommittedCheckpointRef, LeaderProof,
10    PipelineIdentity, MAX_COMMITTED_CHECKPOINT_INDEX_BYTES,
11};
12use bytes::{Bytes, BytesMut};
13use futures::StreamExt;
14use object_store::path::Path as OsPath;
15use object_store::{
16    GetOptions, GetRange, GetResult, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload,
17    UpdateVersion,
18};
19
20/// Durable checkpoint metadata store.
21pub struct CheckpointDecisionStore {
22    store: Arc<dyn ObjectStore>,
23    update_mode: DecisionStoreUpdateMode,
24    /// Serializes deployment creation and checkpoint-ID allocation from this instance.
25    metadata_write_lock: tokio::sync::Mutex<()>,
26    /// Serializes local read/compare/overwrite transitions across every store instance that owns
27    /// this namespace. Shared stores use native object-store CAS instead.
28    local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
29    /// Last checkpoint-ID head observed by this instance. Shared-store CAS detects stale entries.
30    checkpoint_id_head: parking_lot::Mutex<Option<VersionedCheckpointIdHead>>,
31    /// IDs already covered by the current local durable reservation.
32    local_reservation: parking_lot::Mutex<LocalReservation>,
33    deployment_id: tokio::sync::OnceCell<String>,
34}
35
36#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38enum DecisionStoreUpdateMode {
39    NativeCas,
40    LocalSingleWriter,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44enum LocalMetadataNamespace {
45    /// Generic local stores share an authority by cloning the same `Arc<dyn ObjectStore>`.
46    #[cfg(test)]
47    StoreAuthority(usize),
48    /// Filesystem constructors can identify independently opened stores by canonical root.
49    Filesystem(PathBuf),
50}
51
52fn shared_local_metadata_rmw_lock(
53    namespace: LocalMetadataNamespace,
54) -> Arc<tokio::sync::Mutex<()>> {
55    static LOCKS: OnceLock<
56        parking_lot::Mutex<
57            rustc_hash::FxHashMap<LocalMetadataNamespace, Weak<tokio::sync::Mutex<()>>>,
58        >,
59    > = OnceLock::new();
60
61    let mut locks = LOCKS
62        .get_or_init(|| parking_lot::Mutex::new(rustc_hash::FxHashMap::default()))
63        .lock();
64    locks.retain(|_, lock| lock.strong_count() != 0);
65    if let Some(lock) = locks.get(&namespace).and_then(Weak::upgrade) {
66        return lock;
67    }
68    let lock = Arc::new(tokio::sync::Mutex::new(()));
69    locks.insert(namespace, Arc::downgrade(&lock));
70    lock
71}
72
73impl std::fmt::Debug for CheckpointDecisionStore {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("CheckpointDecisionStore")
76            .field("update_mode", &self.update_mode)
77            .finish_non_exhaustive()
78    }
79}
80
81/// Errors raised by [`CheckpointDecisionStore`] operations.
82#[derive(Debug, thiserror::Error)]
83pub enum DecisionError {
84    /// Underlying object-store I/O failure.
85    #[error("object store I/O: {0}")]
86    Io(String),
87    /// A persisted decision is malformed or conflicts with the requested cut.
88    #[error("checkpoint decision conflict: {0}")]
89    Conflict(String),
90    /// The immutable inventory changed while it was being enumerated, normally because GC
91    /// retired a resolved pair. Callers may retry the complete audit from a fresh LIST.
92    #[error("checkpoint decision inventory changed during audit: {0}")]
93    InventoryChanged(String),
94}
95
96/// Irrevocable terminal verdict for one concrete checkpoint attempt.
97#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "snake_case")]
99pub enum CheckpointVerdict {
100    /// Every participant prepared successfully and recovery must finish commit.
101    Commit,
102    /// Recovery must roll back this exact checkpoint attempt.
103    Abort,
104}
105
106/// Single create-once terminal outcome for one epoch.
107#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
108#[serde(deny_unknown_fields)]
109pub struct CheckpointOutcome {
110    /// Outcome payload format.
111    pub version: u32,
112    /// Recovery domain that created the outcome.
113    pub scope: CheckpointScope,
114    /// Terminal epoch, matching the object path.
115    pub epoch: u64,
116    /// Exact checkpoint attempt resolved for the epoch.
117    pub checkpoint_id: u64,
118    /// Durable deployment incarnation that owns the outcome.
119    pub deployment_id: String,
120    /// Exact assignment certificate for a cluster outcome.
121    pub assignment_fence: Option<CheckpointAssignmentFence>,
122    /// Exact leader authority that selected a cluster outcome.
123    pub leader_proof: Option<LeaderProof>,
124    /// Exact global recovery index selected by a Commit.
125    pub committed_checkpoint: Option<CommittedCheckpointRef>,
126    /// Create-once terminal verdict.
127    pub verdict: CheckpointVerdict,
128}
129
130/// Result of attempting to create a terminal checkpoint outcome.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum RecordOutcomeResult {
133    /// This call durably created the outcome.
134    Created(CheckpointOutcome),
135    /// An identical outcome was already durable.
136    Unchanged(CheckpointOutcome),
137    /// A different outcome won the create-once race for the epoch.
138    Conflict {
139        /// Durable winner that callers must obey.
140        winner: CheckpointOutcome,
141    },
142}
143
144/// Exact position of a crash-resumable local checkpoint cleanup.
145#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
146#[serde(deny_unknown_fields)]
147pub struct CheckpointRetentionCursor {
148    /// Recovery cut that must remain intact throughout this cleanup.
149    pub protected: CommittedCheckpointRef,
150    /// Expired cut currently being retired.
151    pub current: CommittedCheckpointRef,
152    /// Exact predecessor of `current`.
153    pub next: Option<CommittedCheckpointRef>,
154    /// Exclusive lower boundary of this cleanup.
155    pub stop_before: Option<CommittedCheckpointRef>,
156}
157
158/// Durable phase of local checkpoint retention.
159#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
160#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "phase")]
161pub enum CheckpointRetentionState {
162    /// Only this recovery cut is retained and no deletion is pending.
163    Idle {
164        /// Authoritative retained recovery cut.
165        protected: CommittedCheckpointRef,
166    },
167    /// Referenced node-data objects for `current` may be deleted.
168    DeleteData {
169        /// Exact bounded cleanup position.
170        cursor: CheckpointRetentionCursor,
171    },
172    /// Node data is settled; the manifest and then index may be deleted.
173    DeleteMetadata {
174        /// Exact bounded cleanup position.
175        cursor: CheckpointRetentionCursor,
176    },
177}
178
179/// Result of a conditional retention-head transition.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum CheckpointRetentionUpdateResult {
182    /// This call durably changed the retention head.
183    Applied(CheckpointRetentionState),
184    /// The requested state was already durable or an active cleanup must resume first.
185    Unchanged(CheckpointRetentionState),
186    /// Another writer changed the retention head.
187    Conflict {
188        /// Durable state observed after the failed transition.
189        current: Option<CheckpointRetentionState>,
190    },
191}
192
193const CHECKPOINT_OUTCOME_VERSION: u32 = 3;
194const CHECKPOINT_DECISION_HEAD_VERSION: u32 = 2;
195const CHECKPOINT_DECISION_HEAD_MAX_BYTES: u64 = 128 * 1_024;
196const CHECKPOINT_RETENTION_HEAD_VERSION: u32 = 1;
197const CHECKPOINT_RETENTION_HEAD_MAX_BYTES: u64 = 64 * 1_024;
198const ABORTED_COMMITTED_CHECKPOINT_SEAL_VERSION: u32 = 1;
199
200#[derive(Debug, serde::Serialize)]
201struct AbortedCommittedCheckpointSeal<'a> {
202    version: u32,
203    deployment_id: &'a str,
204    candidate: &'a CommittedCheckpointRef,
205}
206
207impl CheckpointOutcome {
208    pub(crate) fn validate_shape(&self, path_epoch: u64) -> Result<(), DecisionError> {
209        if self.version != CHECKPOINT_OUTCOME_VERSION {
210            return Err(DecisionError::Conflict(format!(
211                "outcome for epoch {path_epoch} has unsupported version {}; expected \
212                 {CHECKPOINT_OUTCOME_VERSION}",
213                self.version
214            )));
215        }
216        if self.epoch == 0 || self.epoch != path_epoch {
217            return Err(DecisionError::Conflict(format!(
218                "outcome path epoch {path_epoch} does not match non-zero payload epoch {}",
219                self.epoch
220            )));
221        }
222        if self.checkpoint_id == 0 {
223            return Err(DecisionError::Conflict(format!(
224                "outcome for epoch {path_epoch} has checkpoint ID 0"
225            )));
226        }
227        if self.epoch != self.checkpoint_id {
228            return Err(DecisionError::Conflict(format!(
229                "outcome for epoch {path_epoch} has non-canonical checkpoint ID {}; runtime \
230                 outcomes require epoch == checkpoint ID",
231                self.checkpoint_id
232            )));
233        }
234
235        let deployment = uuid::Uuid::parse_str(&self.deployment_id).map_err(|error| {
236            DecisionError::Conflict(format!(
237                "outcome for epoch {path_epoch} has invalid deployment identity: {error}"
238            ))
239        })?;
240        if deployment.is_nil() || deployment.to_string() != self.deployment_id {
241            return Err(DecisionError::Conflict(format!(
242                "outcome for epoch {path_epoch} must use a canonical non-nil deployment identity"
243            )));
244        }
245
246        match (
247            self.scope,
248            self.assignment_fence.as_ref(),
249            self.leader_proof.as_ref(),
250        ) {
251            (CheckpointScope::Local, None, None) => {}
252            (CheckpointScope::Local, _, _) => {
253                return Err(DecisionError::Conflict(format!(
254                    "local outcome for epoch {path_epoch} cannot carry an assignment fence or \
255                     leader proof"
256                )));
257            }
258            (CheckpointScope::Cluster, Some(fence), Some(proof))
259                if fence.is_canonical() && proof.is_canonical() => {}
260            (CheckpointScope::Cluster, Some(fence), Some(proof))
261                if !fence.is_canonical() || !proof.is_canonical() =>
262            {
263                return Err(DecisionError::Conflict(format!(
264                    "cluster outcome for epoch {path_epoch} has a non-canonical assignment fence \
265                     or leader proof"
266                )));
267            }
268            (CheckpointScope::Cluster, _, _) => {
269                return Err(DecisionError::Conflict(format!(
270                    "cluster outcome for epoch {path_epoch} requires an assignment fence and \
271                     leader proof"
272                )));
273            }
274        }
275        if self.is_commit()
276            && self.scope == CheckpointScope::Cluster
277            && self
278                .assignment_fence
279                .as_ref()
280                .zip(self.leader_proof.as_ref())
281                .is_some_and(|(fence, proof)| {
282                    fence.participant_incarnation(proof.owner.node_id) != Some(proof.owner.boot_id)
283                })
284        {
285            return Err(DecisionError::Conflict(format!(
286                "cluster Commit for epoch {path_epoch} requires its leader proof in the assignment fence"
287            )));
288        }
289
290        match (&self.verdict, self.committed_checkpoint.as_ref()) {
291            (CheckpointVerdict::Commit, Some(reference)) => {
292                reference.validate().map_err(|error| {
293                    DecisionError::Conflict(format!(
294                        "commit outcome for epoch {path_epoch} has an invalid committed checkpoint reference: {error}"
295                    ))
296                })?;
297                if reference.epoch != self.epoch || reference.checkpoint_id != self.checkpoint_id {
298                    return Err(DecisionError::Conflict(format!(
299                        "commit outcome for epoch {path_epoch} names a different committed checkpoint"
300                    )));
301                }
302            }
303            (CheckpointVerdict::Commit, None) => {
304                return Err(DecisionError::Conflict(format!(
305                    "commit outcome for epoch {path_epoch} requires a committed checkpoint reference"
306                )));
307            }
308            (CheckpointVerdict::Abort, Some(_)) => {
309                return Err(DecisionError::Conflict(format!(
310                    "abort outcome for epoch {path_epoch} cannot carry a committed checkpoint reference"
311                )));
312            }
313            (CheckpointVerdict::Abort, None) => {}
314        }
315        Ok(())
316    }
317
318    /// Whether this outcome irrevocably selected commit.
319    #[must_use]
320    pub fn is_commit(&self) -> bool {
321        matches!(self.verdict, CheckpointVerdict::Commit)
322    }
323}
324
325/// Durable checkpoint namespace identity and its shared-store allocation head.
326///
327/// `id` never changes. Deleting this single authority creates a new deployment identity before
328/// checkpoint IDs restart, so surviving external sinks cannot confuse the new sequence with the
329/// previous writer. `allocation_id` identifies the last CAS proposal for lost-response recovery.
330#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
331#[serde(deny_unknown_fields)]
332struct DeploymentIdentity {
333    version: u32,
334    id: String,
335    allocator_mode: DecisionStoreUpdateMode,
336    checkpoint_id: u64,
337    allocation_id: String,
338}
339
340const DEPLOYMENT_IDENTITY_VERSION: u32 = 3;
341const DEPLOYMENT_IDENTITY_MAX_BYTES: u64 = 1_024;
342
343/// Durable proof that every named checkpoint-committable sink may have opened this attempt.
344///
345/// The witness is created before any external begin call and remains live until the exact attempt
346/// reaches a terminal outcome or every named sink confirms rollback. Recovery must reconcile the
347/// attempt before opening a later one.
348#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
349#[serde(deny_unknown_fields)]
350pub struct CheckpointSinkOpenWitness {
351    version: u32,
352    /// Durable deployment incarnation that owns this witness.
353    pub deployment_id: String,
354    /// Logical pipeline and recovery-state ABI identity.
355    pub pipeline_identity: PipelineIdentity,
356    /// Runtime participant that owns the named sink handles (`0` in embedded/local mode).
357    pub participant_id: u64,
358    /// Exact canonical attempt that may be externally open.
359    pub attempt: CheckpointAttempt,
360    /// Canonically sorted, unique checkpoint-committable sink names.
361    pub committable_sinks: Vec<String>,
362    /// Unique create proposal used to reconcile an ambiguous object-store response.
363    create_token: String,
364}
365
366const CHECKPOINT_SINK_OPEN_WITNESS_VERSION: u32 = 1;
367const CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES: u64 = 64 * 1_024;
368const CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS: usize = 1_024;
369
370/// Versioned singleton that never returns to an absent state after its first open transition.
371#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
372#[serde(deny_unknown_fields)]
373struct CheckpointSinkOpenWitnessSlot {
374    version: u32,
375    state: CheckpointSinkOpenWitnessSlotState,
376}
377
378#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
379#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
380enum CheckpointSinkOpenWitnessSlotState {
381    Open {
382        witness: CheckpointSinkOpenWitness,
383    },
384    Closed {
385        witness: CheckpointSinkOpenWitness,
386        close_token: String,
387    },
388}
389
390impl CheckpointSinkOpenWitnessSlot {
391    fn open(witness: CheckpointSinkOpenWitness) -> Self {
392        Self {
393            version: CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION,
394            state: CheckpointSinkOpenWitnessSlotState::Open { witness },
395        }
396    }
397
398    fn closed(witness: CheckpointSinkOpenWitness) -> Self {
399        Self {
400            version: CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION,
401            state: CheckpointSinkOpenWitnessSlotState::Closed {
402                witness,
403                close_token: uuid::Uuid::now_v7().to_string(),
404            },
405        }
406    }
407
408    const fn witness(&self) -> &CheckpointSinkOpenWitness {
409        match &self.state {
410            CheckpointSinkOpenWitnessSlotState::Open { witness }
411            | CheckpointSinkOpenWitnessSlotState::Closed { witness, .. } => witness,
412        }
413    }
414}
415
416const CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION: u32 = 1;
417
418#[derive(Debug)]
419struct VersionedCheckpointSinkOpenWitnessSlot {
420    slot: CheckpointSinkOpenWitnessSlot,
421    update_version: UpdateVersion,
422}
423
424#[derive(Debug, Clone)]
425struct VersionedCheckpointIdHead {
426    head: DeploymentIdentity,
427    update_version: UpdateVersion,
428}
429
430#[derive(Debug, Default)]
431struct LocalReservation {
432    next_id: Option<u64>,
433    end: u64,
434}
435
436const LOCAL_RESERVATION_SIZE: u64 = 65_536;
437
438/// Durable inventory for one checkpoint attempt that may have written artifacts.
439#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
440#[serde(deny_unknown_fields)]
441pub struct CheckpointArtifactInventory {
442    /// Durable deployment incarnation that owns the artifacts.
443    pub deployment_id: String,
444    /// Logical pipeline and recovery-state ABI identity.
445    pub pipeline_identity: PipelineIdentity,
446    /// Exact canonical checkpoint attempt.
447    pub attempt: CheckpointAttempt,
448    /// Exact cluster assignment, absent for local checkpoints.
449    pub assignment_fence: Option<CheckpointAssignmentFence>,
450    /// Whether this attempt durably admits sink artifact intents before connector begin.
451    #[serde(default, skip_serializing_if = "is_false")]
452    pub sink_artifact_intent_protocol: bool,
453}
454
455const fn is_false(value: &bool) -> bool {
456    !*value
457}
458
459impl CheckpointArtifactInventory {
460    /// Validate the canonical persisted shape.
461    ///
462    /// # Errors
463    /// Returns an error for a foreign format or non-canonical identity.
464    pub fn validate(&self) -> Result<(), String> {
465        let deployment = uuid::Uuid::parse_str(&self.deployment_id)
466            .map_err(|error| format!("invalid deployment identity: {error}"))?;
467        if deployment.is_nil() || deployment.to_string() != self.deployment_id {
468            return Err("deployment identity must be a canonical non-nil UUID".into());
469        }
470        if !self.pipeline_identity.is_canonical() {
471            return Err("pipeline identity is not canonical".into());
472        }
473        if !self.attempt.is_canonical() {
474            return Err("checkpoint attempt is not canonical".into());
475        }
476        if self
477            .assignment_fence
478            .as_ref()
479            .is_some_and(|fence| !fence.is_canonical())
480        {
481            return Err("checkpoint assignment fence is not canonical".into());
482        }
483        Ok(())
484    }
485}
486
487/// Result of a conditional checkpoint artifact-inventory transition.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum CheckpointArtifactInventoryUpdateResult {
490    /// This call durably changed the decision head.
491    Applied,
492    /// The exact requested state was already durable.
493    Unchanged,
494    /// Another attempt or terminal transition changed the decision head.
495    Conflict {
496        /// Active inventory observed after the failed transition.
497        current: Option<CheckpointArtifactInventory>,
498    },
499}
500
501/// Exact local recovery cursor and unresolved artifact inventory.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct CheckpointDecisionHead {
504    /// Greatest terminal local outcome.
505    pub latest_terminal: Option<CheckpointOutcome>,
506    /// Greatest committed local outcome, including its exact committed-index reference.
507    pub latest_commit: Option<CheckpointOutcome>,
508    /// Exact unresolved attempt whose artifacts require a terminal decision or cleanup.
509    pub active_artifacts: Option<CheckpointArtifactInventory>,
510}
511
512#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
513#[serde(deny_unknown_fields)]
514struct DurableCheckpointDecisionHead {
515    version: u32,
516    deployment_id: String,
517    latest_terminal: Option<CheckpointOutcome>,
518    latest_commit: Option<CheckpointOutcome>,
519    active_artifacts: Option<CheckpointArtifactInventory>,
520}
521
522#[derive(Debug)]
523struct VersionedCheckpointDecisionHead {
524    head: DurableCheckpointDecisionHead,
525    update_version: UpdateVersion,
526}
527
528#[derive(Debug)]
529enum DecisionHeadCasResult {
530    Applied,
531    Unchanged,
532    Conflict(Option<Box<DurableCheckpointDecisionHead>>),
533}
534
535#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
536#[serde(deny_unknown_fields)]
537struct DurableCheckpointRetentionHead {
538    version: u32,
539    deployment_id: String,
540    state: CheckpointRetentionState,
541}
542
543#[derive(Debug)]
544struct VersionedCheckpointRetentionHead {
545    head: DurableCheckpointRetentionHead,
546    update_version: UpdateVersion,
547}
548
549impl CheckpointDecisionStore {
550    fn ensure_control_record_size(
551        record: &str,
552        size: u64,
553        maximum: u64,
554    ) -> Result<(), DecisionError> {
555        if size > maximum {
556            return Err(DecisionError::Conflict(format!(
557                "{record} is {size} bytes; maximum is {maximum}"
558            )));
559        }
560        Ok(())
561    }
562
563    fn encode_control_record<T: serde::Serialize>(
564        record: &str,
565        value: &T,
566        maximum: u64,
567    ) -> Result<Bytes, DecisionError> {
568        let payload = serde_json::to_vec(value)
569            .map(Bytes::from)
570            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
571        let size = u64::try_from(payload.len()).unwrap_or(u64::MAX);
572        Self::ensure_control_record_size(record, size, maximum)?;
573        Ok(payload)
574    }
575
576    async fn read_control_record_bytes(
577        result: GetResult,
578        record: &str,
579        maximum: u64,
580        expected_size: Option<u64>,
581    ) -> Result<Bytes, DecisionError> {
582        Self::ensure_control_record_size(record, result.meta.size, maximum)?;
583        if let Some(expected_size) = expected_size {
584            if result.meta.size != expected_size {
585                return Err(DecisionError::Conflict(format!(
586                    "{record} is {} bytes, expected {expected_size}",
587                    result.meta.size
588                )));
589            }
590        }
591        let metadata_size = result.meta.size;
592        if result.range.start != 0 || result.range.end != metadata_size {
593            return Err(DecisionError::Conflict(format!(
594                "{record} returned byte range {}..{}, expected 0..{metadata_size}",
595                result.range.start, result.range.end
596            )));
597        }
598        let capacity = usize::try_from(metadata_size).map_err(|_| {
599            DecisionError::Conflict(format!(
600                "{record} length {metadata_size} exceeds this process address space"
601            ))
602        })?;
603        let mut bytes = BytesMut::with_capacity(capacity);
604        let mut stream = result.into_stream();
605        while let Some(chunk) = stream.next().await {
606            let chunk = chunk.map_err(|error| DecisionError::Io(error.to_string()))?;
607            let next_len = bytes.len().checked_add(chunk.len()).ok_or_else(|| {
608                DecisionError::Conflict(format!("{record} payload length overflow"))
609            })?;
610            if next_len > capacity {
611                return Err(DecisionError::Conflict(format!(
612                    "{record} payload exceeded its advertised {metadata_size}-byte length"
613                )));
614            }
615            bytes.extend_from_slice(&chunk);
616        }
617        if bytes.len() != capacity {
618            return Err(DecisionError::Conflict(format!(
619                "{record} payload length changed while reading"
620            )));
621        }
622        Ok(bytes.freeze())
623    }
624
625    async fn get_control_record(
626        &self,
627        path: &OsPath,
628        record: &str,
629        maximum: u64,
630    ) -> Result<Option<GetResult>, DecisionError> {
631        let request_end = maximum.checked_add(1).ok_or_else(|| {
632            DecisionError::Conflict(format!("{record} maximum cannot be range-bounded"))
633        })?;
634        let result = match self
635            .store
636            .get_opts(
637                path,
638                GetOptions {
639                    range: Some(GetRange::Bounded(0..request_end)),
640                    ..GetOptions::default()
641                },
642            )
643            .await
644        {
645            Ok(result) => result,
646            Err(object_store::Error::NotFound { .. }) => return Ok(None),
647            Err(error) => return Err(DecisionError::Io(error.to_string())),
648        };
649        Self::ensure_control_record_size(record, result.meta.size, maximum)?;
650        if result.range.start != 0 || result.range.end != result.meta.size {
651            return Err(DecisionError::Conflict(format!(
652                "{record} returned byte range {}..{}, inconsistent with advertised size {}",
653                result.range.start, result.range.end, result.meta.size
654            )));
655        }
656        Ok(Some(result))
657    }
658
659    fn require_native_cas_token(
660        &self,
661        record: &str,
662        update_version: &UpdateVersion,
663    ) -> Result<(), DecisionError> {
664        if self.update_mode == DecisionStoreUpdateMode::NativeCas
665            && update_version.e_tag.is_none()
666            && update_version.version.is_none()
667        {
668            return Err(DecisionError::Conflict(format!(
669                "shared {record} has neither an ETag nor an object version for CAS"
670            )));
671        }
672        Ok(())
673    }
674
675    /// Wrap shared storage that must provide native conditional updates.
676    #[must_use]
677    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
678        Self::with_update_mode(store, DecisionStoreUpdateMode::NativeCas, None)
679    }
680
681    #[cfg(test)]
682    fn local_single_writer(store: Arc<dyn ObjectStore>) -> Self {
683        let authority = Arc::as_ptr(&store).cast::<()>() as usize;
684        let lock =
685            shared_local_metadata_rmw_lock(LocalMetadataNamespace::StoreAuthority(authority));
686        Self::with_update_mode(
687            store,
688            DecisionStoreUpdateMode::LocalSingleWriter,
689            Some(lock),
690        )
691    }
692
693    /// Open crash-durable checkpoint metadata in a caller-owned local directory.
694    /// The caller must retain its exclusive namespace lease for the store's write lifetime.
695    ///
696    /// # Errors
697    /// Returns an I/O error when the directory cannot be created, synchronized, or opened.
698    pub fn local_filesystem(root: impl AsRef<FsPath>) -> Result<Self, DecisionError> {
699        let root = root.as_ref();
700        let store = crate::checkpoint::object_store_builder::durable_local_object_store(root)
701            .map_err(|error| DecisionError::Io(error.to_string()))?;
702        let canonical_root =
703            std::fs::canonicalize(root).map_err(|error| DecisionError::Io(error.to_string()))?;
704        let lock =
705            shared_local_metadata_rmw_lock(LocalMetadataNamespace::Filesystem(canonical_root));
706        Ok(Self::with_update_mode(
707            store,
708            DecisionStoreUpdateMode::LocalSingleWriter,
709            Some(lock),
710        ))
711    }
712
713    fn with_update_mode(
714        store: Arc<dyn ObjectStore>,
715        update_mode: DecisionStoreUpdateMode,
716        local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
717    ) -> Self {
718        debug_assert_eq!(
719            update_mode == DecisionStoreUpdateMode::LocalSingleWriter,
720            local_metadata_rmw_lock.is_some()
721        );
722        Self {
723            store,
724            update_mode,
725            metadata_write_lock: tokio::sync::Mutex::new(()),
726            local_metadata_rmw_lock,
727            checkpoint_id_head: parking_lot::Mutex::new(None),
728            local_reservation: parking_lot::Mutex::new(LocalReservation::default()),
729            deployment_id: tokio::sync::OnceCell::new(),
730        }
731    }
732
733    fn decision_head_path(deployment_id: &str) -> OsPath {
734        OsPath::from(format!(
735            "checkpoint-decisions/deployment={deployment_id}/head"
736        ))
737    }
738
739    fn retention_head_path(deployment_id: &str) -> OsPath {
740        OsPath::from(format!(
741            "checkpoint-retention/deployment={deployment_id}/head"
742        ))
743    }
744
745    fn committed_checkpoint_path(reference: &CommittedCheckpointRef) -> OsPath {
746        OsPath::from(format!(
747            "committed-checkpoints/epoch={:020}/checkpoint={:020}/sha256={}",
748            reference.epoch, reference.checkpoint_id, reference.sha256
749        ))
750    }
751
752    fn deployment_identity_path() -> OsPath {
753        OsPath::from("checkpoint-deployment/identity.json")
754    }
755
756    fn sink_open_witness_path() -> OsPath {
757        OsPath::from("checkpoint-sink-open-witness/witness.json")
758    }
759}
760
761mod allocation;
762mod committed;
763mod deployment;
764mod outcome;
765mod retention;
766mod sink_witness;
767
768#[cfg(test)]
769mod tests;