1use 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
20pub struct CheckpointDecisionStore {
22 store: Arc<dyn ObjectStore>,
23 update_mode: DecisionStoreUpdateMode,
24 metadata_write_lock: tokio::sync::Mutex<()>,
26 local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
29 checkpoint_id_head: parking_lot::Mutex<Option<VersionedCheckpointIdHead>>,
31 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 #[cfg(test)]
47 StoreAuthority(usize),
48 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#[derive(Debug, thiserror::Error)]
83pub enum DecisionError {
84 #[error("object store I/O: {0}")]
86 Io(String),
87 #[error("checkpoint decision conflict: {0}")]
89 Conflict(String),
90 #[error("checkpoint decision inventory changed during audit: {0}")]
93 InventoryChanged(String),
94}
95
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "snake_case")]
99pub enum CheckpointVerdict {
100 Commit,
102 Abort,
104}
105
106#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
108#[serde(deny_unknown_fields)]
109pub struct CheckpointOutcome {
110 pub version: u32,
112 pub scope: CheckpointScope,
114 pub epoch: u64,
116 pub checkpoint_id: u64,
118 pub deployment_id: String,
120 pub assignment_fence: Option<CheckpointAssignmentFence>,
122 pub leader_proof: Option<LeaderProof>,
124 pub committed_checkpoint: Option<CommittedCheckpointRef>,
126 pub verdict: CheckpointVerdict,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum RecordOutcomeResult {
133 Created(CheckpointOutcome),
135 Unchanged(CheckpointOutcome),
137 Conflict {
139 winner: CheckpointOutcome,
141 },
142}
143
144#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
146#[serde(deny_unknown_fields)]
147pub struct CheckpointRetentionCursor {
148 pub protected: CommittedCheckpointRef,
150 pub current: CommittedCheckpointRef,
152 pub next: Option<CommittedCheckpointRef>,
154 pub stop_before: Option<CommittedCheckpointRef>,
156}
157
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
160#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "phase")]
161pub enum CheckpointRetentionState {
162 Idle {
164 protected: CommittedCheckpointRef,
166 },
167 DeleteData {
169 cursor: CheckpointRetentionCursor,
171 },
172 DeleteMetadata {
174 cursor: CheckpointRetentionCursor,
176 },
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum CheckpointRetentionUpdateResult {
182 Applied(CheckpointRetentionState),
184 Unchanged(CheckpointRetentionState),
186 Conflict {
188 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 #[must_use]
320 pub fn is_commit(&self) -> bool {
321 matches!(self.verdict, CheckpointVerdict::Commit)
322 }
323}
324
325#[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
349#[serde(deny_unknown_fields)]
350pub struct CheckpointSinkOpenWitness {
351 version: u32,
352 pub deployment_id: String,
354 pub pipeline_identity: PipelineIdentity,
356 pub participant_id: u64,
358 pub attempt: CheckpointAttempt,
360 pub committable_sinks: Vec<String>,
362 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#[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
440#[serde(deny_unknown_fields)]
441pub struct CheckpointArtifactInventory {
442 pub deployment_id: String,
444 pub pipeline_identity: PipelineIdentity,
446 pub attempt: CheckpointAttempt,
448 pub assignment_fence: Option<CheckpointAssignmentFence>,
450 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum CheckpointArtifactInventoryUpdateResult {
490 Applied,
492 Unchanged,
494 Conflict {
496 current: Option<CheckpointArtifactInventory>,
498 },
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct CheckpointDecisionHead {
504 pub latest_terminal: Option<CheckpointOutcome>,
506 pub latest_commit: Option<CheckpointOutcome>,
508 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 #[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 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;