1#[cfg(feature = "cluster")]
4use std::collections::BinaryHeap;
5use std::path::{Path as FsPath, PathBuf};
6use std::sync::{Arc, OnceLock, Weak};
7
8use bytes::{Bytes, BytesMut};
9use futures::StreamExt;
10use object_store::path::Path as OsPath;
11use object_store::{
12 GetOptions, GetRange, GetResult, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload,
13 UpdateVersion,
14};
15#[cfg(feature = "cluster")]
16use sha2::{Digest, Sha256};
17
18use crate::checkpoint::{
19 CheckpointAssignmentFence, ClusterRecoveryCapsule, LeaderProof, PipelineIdentity,
20 RecoveryCapsuleRef,
21};
22use crate::state::CheckpointAttempt;
23
24pub struct CheckpointDecisionStore {
26 store: Arc<dyn ObjectStore>,
27 update_mode: DecisionStoreUpdateMode,
28 metadata_write_lock: tokio::sync::Mutex<()>,
30 local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
33 checkpoint_id_head: parking_lot::Mutex<Option<VersionedCheckpointIdHead>>,
35 local_reservation: parking_lot::Mutex<LocalReservationState>,
37 deployment_id: tokio::sync::OnceCell<String>,
38}
39
40#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42enum DecisionStoreUpdateMode {
43 NativeCas,
44 LocalSingleWriter,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48enum LocalMetadataNamespace {
49 #[cfg(test)]
51 StoreAuthority(usize),
52 Filesystem(PathBuf),
54}
55
56fn shared_local_metadata_rmw_lock(
57 namespace: LocalMetadataNamespace,
58) -> Arc<tokio::sync::Mutex<()>> {
59 static LOCKS: OnceLock<
60 parking_lot::Mutex<
61 rustc_hash::FxHashMap<LocalMetadataNamespace, Weak<tokio::sync::Mutex<()>>>,
62 >,
63 > = OnceLock::new();
64
65 let mut locks = LOCKS
66 .get_or_init(|| parking_lot::Mutex::new(rustc_hash::FxHashMap::default()))
67 .lock();
68 locks.retain(|_, lock| lock.strong_count() != 0);
69 if let Some(lock) = locks.get(&namespace).and_then(Weak::upgrade) {
70 return lock;
71 }
72 let lock = Arc::new(tokio::sync::Mutex::new(()));
73 locks.insert(namespace, Arc::downgrade(&lock));
74 lock
75}
76
77impl std::fmt::Debug for CheckpointDecisionStore {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("CheckpointDecisionStore")
80 .field("update_mode", &self.update_mode)
81 .finish_non_exhaustive()
82 }
83}
84
85#[derive(Debug, thiserror::Error)]
87pub enum DecisionError {
88 #[error("object store I/O: {0}")]
90 Io(String),
91 #[error("checkpoint decision conflict: {0}")]
93 Conflict(String),
94 #[error("checkpoint decision inventory changed during audit: {0}")]
97 InventoryChanged(String),
98}
99
100#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "snake_case")]
103pub enum CheckpointScope {
104 Local,
106 Cluster,
108}
109
110#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "snake_case")]
113pub enum CheckpointVerdict {
114 Commit,
116 Abort,
118}
119
120#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
122pub struct CheckpointOutcome {
123 pub version: u32,
125 pub scope: CheckpointScope,
127 pub epoch: u64,
129 pub checkpoint_id: u64,
131 pub deployment_id: String,
133 pub assignment_fence: Option<CheckpointAssignmentFence>,
135 pub leader_proof: Option<LeaderProof>,
137 pub recovery_capsule: Option<RecoveryCapsuleRef>,
139 pub verdict: CheckpointVerdict,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum RecordOutcomeResult {
146 Created(CheckpointOutcome),
148 Unchanged(CheckpointOutcome),
150 Conflict {
152 winner: CheckpointOutcome,
154 },
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct OutcomeRetentionBoundary {
163 pub before_epoch: u64,
165 pub committed_checkpoint_id: Option<u64>,
167 pub highest_closed_epoch: Option<u64>,
169}
170
171const CHECKPOINT_OUTCOME_VERSION: u32 = 2;
172const CHECKPOINT_OUTCOME_MAX_BYTES: u64 = 64 * 1_024;
173const OUTCOME_GC_FLOOR_MAX_BYTES: u64 = 256 * 1_024;
174#[cfg(feature = "cluster")]
175const RECOVERY_CAPSULE_GC_BATCH_SIZE: usize = 64;
176#[cfg(feature = "cluster")]
177const RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES: u64 = 1_024;
178
179impl CheckpointOutcome {
180 pub(crate) fn validate_shape(&self, path_epoch: u64) -> Result<(), DecisionError> {
181 if self.version != CHECKPOINT_OUTCOME_VERSION {
182 return Err(DecisionError::Conflict(format!(
183 "outcome for epoch {path_epoch} has unsupported version {}; expected \
184 {CHECKPOINT_OUTCOME_VERSION}",
185 self.version
186 )));
187 }
188 if self.epoch == 0 || self.epoch != path_epoch {
189 return Err(DecisionError::Conflict(format!(
190 "outcome path epoch {path_epoch} does not match non-zero payload epoch {}",
191 self.epoch
192 )));
193 }
194 if self.checkpoint_id == 0 {
195 return Err(DecisionError::Conflict(format!(
196 "outcome for epoch {path_epoch} has checkpoint ID 0"
197 )));
198 }
199 if self.epoch != self.checkpoint_id {
200 return Err(DecisionError::Conflict(format!(
201 "outcome for epoch {path_epoch} has non-canonical checkpoint ID {}; runtime \
202 outcomes require epoch == checkpoint ID",
203 self.checkpoint_id
204 )));
205 }
206
207 let deployment = uuid::Uuid::parse_str(&self.deployment_id).map_err(|error| {
208 DecisionError::Conflict(format!(
209 "outcome for epoch {path_epoch} has invalid deployment identity: {error}"
210 ))
211 })?;
212 if deployment.is_nil() || deployment.to_string() != self.deployment_id {
213 return Err(DecisionError::Conflict(format!(
214 "outcome for epoch {path_epoch} must use a canonical non-nil deployment identity"
215 )));
216 }
217
218 match (
219 self.scope,
220 self.assignment_fence.as_ref(),
221 self.leader_proof.as_ref(),
222 ) {
223 (CheckpointScope::Local, None, None) => {}
224 (CheckpointScope::Local, _, _) => {
225 return Err(DecisionError::Conflict(format!(
226 "local outcome for epoch {path_epoch} cannot carry an assignment fence or \
227 leader proof"
228 )));
229 }
230 (CheckpointScope::Cluster, Some(fence), Some(proof))
231 if fence.is_canonical()
232 && proof.is_canonical()
233 && fence.participant_incarnation(proof.owner.node_id)
234 == Some(proof.owner.boot_id) => {}
235 (CheckpointScope::Cluster, Some(fence), Some(proof))
236 if !fence.is_canonical() || !proof.is_canonical() =>
237 {
238 return Err(DecisionError::Conflict(format!(
239 "cluster outcome for epoch {path_epoch} has a non-canonical assignment fence \
240 or leader proof"
241 )));
242 }
243 (CheckpointScope::Cluster, Some(_), Some(proof)) => {
244 return Err(DecisionError::Conflict(format!(
245 "cluster outcome for epoch {path_epoch} leader node {} boot {} is absent from \
246 the assignment fence",
247 proof.owner.node_id, proof.owner.boot_id
248 )));
249 }
250 (CheckpointScope::Cluster, _, _) => {
251 return Err(DecisionError::Conflict(format!(
252 "cluster outcome for epoch {path_epoch} requires an assignment fence and \
253 leader proof"
254 )));
255 }
256 }
257
258 match (self.scope, &self.verdict, self.recovery_capsule.as_ref()) {
259 (CheckpointScope::Local, _, None)
260 | (CheckpointScope::Cluster, CheckpointVerdict::Abort, None) => {}
261 (CheckpointScope::Cluster, CheckpointVerdict::Commit, Some(reference)) => {
262 reference.validate().map_err(|error| {
263 DecisionError::Conflict(format!(
264 "cluster commit outcome for epoch {path_epoch} has an invalid recovery capsule reference: {error}"
265 ))
266 })?;
267 }
268 (CheckpointScope::Local, _, Some(_)) => {
269 return Err(DecisionError::Conflict(format!(
270 "local outcome for epoch {path_epoch} cannot carry a recovery capsule"
271 )));
272 }
273 (CheckpointScope::Cluster, CheckpointVerdict::Commit, None) => {
274 return Err(DecisionError::Conflict(format!(
275 "cluster commit outcome for epoch {path_epoch} requires a recovery capsule"
276 )));
277 }
278 (CheckpointScope::Cluster, CheckpointVerdict::Abort, Some(_)) => {
279 return Err(DecisionError::Conflict(format!(
280 "cluster abort outcome for epoch {path_epoch} cannot carry a recovery capsule"
281 )));
282 }
283 }
284 Ok(())
285 }
286
287 #[must_use]
289 pub fn is_commit(&self) -> bool {
290 matches!(self.verdict, CheckpointVerdict::Commit)
291 }
292}
293
294#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
300#[serde(deny_unknown_fields)]
301struct DeploymentIdentity {
302 version: u32,
303 id: String,
304 allocator_mode: DecisionStoreUpdateMode,
305 checkpoint_id: u64,
306 allocation_id: String,
307}
308
309const DEPLOYMENT_IDENTITY_VERSION: u32 = 2;
310const DEPLOYMENT_IDENTITY_MAX_BYTES: u64 = 1_024;
311
312#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
318#[serde(deny_unknown_fields)]
319pub struct CheckpointSinkOpenWitness {
320 version: u32,
321 pub deployment_id: String,
323 pub pipeline_identity: PipelineIdentity,
325 pub participant_id: u64,
327 pub attempt: CheckpointAttempt,
329 pub committable_sinks: Vec<String>,
331 create_token: String,
333}
334
335const CHECKPOINT_SINK_OPEN_WITNESS_VERSION: u32 = 1;
336const CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES: u64 = 64 * 1_024;
337const CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS: usize = 1_024;
338
339#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
341#[serde(deny_unknown_fields)]
342struct CheckpointSinkOpenWitnessSlot {
343 version: u32,
344 state: CheckpointSinkOpenWitnessSlotState,
345}
346
347#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
348#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
349enum CheckpointSinkOpenWitnessSlotState {
350 Open {
351 witness: CheckpointSinkOpenWitness,
352 },
353 Closed {
354 witness: CheckpointSinkOpenWitness,
355 close_token: String,
356 },
357}
358
359impl CheckpointSinkOpenWitnessSlot {
360 fn open(witness: CheckpointSinkOpenWitness) -> Self {
361 Self {
362 version: CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION,
363 state: CheckpointSinkOpenWitnessSlotState::Open { witness },
364 }
365 }
366
367 fn closed(witness: CheckpointSinkOpenWitness) -> Self {
368 Self {
369 version: CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION,
370 state: CheckpointSinkOpenWitnessSlotState::Closed {
371 witness,
372 close_token: uuid::Uuid::now_v7().to_string(),
373 },
374 }
375 }
376
377 const fn witness(&self) -> &CheckpointSinkOpenWitness {
378 match &self.state {
379 CheckpointSinkOpenWitnessSlotState::Open { witness }
380 | CheckpointSinkOpenWitnessSlotState::Closed { witness, .. } => witness,
381 }
382 }
383}
384
385const CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION: u32 = 1;
386
387#[derive(Debug)]
388struct VersionedCheckpointSinkOpenWitnessSlot {
389 slot: CheckpointSinkOpenWitnessSlot,
390 update_version: UpdateVersion,
391}
392
393#[derive(Debug, Clone)]
394struct VersionedCheckpointIdHead {
395 head: DeploymentIdentity,
396 update_version: UpdateVersion,
397}
398
399#[derive(Debug, Default)]
402struct LocalReservationState {
403 initialized: bool,
404 highest_block: Option<u64>,
405 next_id: Option<u64>,
406 block_end: u64,
407}
408
409const LOCAL_RESERVATION_BLOCK_SIZE: u64 = 65_536;
410
411#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
418struct OutcomeGcFloor {
419 version: u32,
420 deployment_id: String,
421 before_epoch: u64,
422 terminal_anchor: Option<CheckpointOutcome>,
423 committed_anchor: Option<CheckpointOutcome>,
424}
425
426const OUTCOME_GC_FLOOR_VERSION: u32 = 4;
427
428#[derive(Debug)]
429struct VersionedOutcomeGcFloor {
430 floor: OutcomeGcFloor,
431 update_version: UpdateVersion,
432}
433
434#[cfg(feature = "cluster")]
435#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
436#[serde(deny_unknown_fields)]
437struct RecoveryCapsuleGcCursor {
438 version: u32,
439 deployment_id: String,
440 offset: Option<String>,
442}
443
444#[cfg(feature = "cluster")]
445const RECOVERY_CAPSULE_GC_CURSOR_VERSION: u32 = 1;
446
447#[cfg(feature = "cluster")]
448#[derive(Debug)]
449struct RecoveryCapsuleListCandidate {
450 location: String,
451 meta: object_store::ObjectMeta,
452}
453
454#[cfg(feature = "cluster")]
455enum RecoveryCapsuleObjectWork {
456 Retained,
457 Deleted,
458 Quarantined,
459 Failed,
460}
461
462#[cfg(feature = "cluster")]
463impl PartialEq for RecoveryCapsuleListCandidate {
464 fn eq(&self, other: &Self) -> bool {
465 self.location == other.location
466 }
467}
468
469#[cfg(feature = "cluster")]
470impl Eq for RecoveryCapsuleListCandidate {}
471
472#[cfg(feature = "cluster")]
473impl PartialOrd for RecoveryCapsuleListCandidate {
474 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
475 Some(self.cmp(other))
476 }
477}
478
479#[cfg(feature = "cluster")]
480impl Ord for RecoveryCapsuleListCandidate {
481 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
482 self.location.cmp(&other.location)
483 }
484}
485
486#[cfg(feature = "cluster")]
487fn retain_lexically_oldest_recovery_capsule(
488 oldest: &mut BinaryHeap<RecoveryCapsuleListCandidate>,
489 meta: object_store::ObjectMeta,
490) {
491 let candidate = RecoveryCapsuleListCandidate {
492 location: meta.location.to_string(),
493 meta,
494 };
495 if oldest.len() < RECOVERY_CAPSULE_GC_BATCH_SIZE {
496 oldest.push(candidate);
497 } else if oldest.peek().is_some_and(|largest| &candidate < largest) {
498 oldest.pop();
499 oldest.push(candidate);
500 }
501}
502
503#[cfg(feature = "cluster")]
504#[derive(Debug)]
505struct VersionedRecoveryCapsuleGcCursor {
506 cursor: RecoveryCapsuleGcCursor,
507 update_version: UpdateVersion,
508}
509
510#[cfg(feature = "cluster")]
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513pub struct RecoveryCapsuleGcStep {
514 pub examined: usize,
516 pub deleted: usize,
518 pub quarantined: usize,
520 pub pending: bool,
522}
523
524impl CheckpointDecisionStore {
525 fn ensure_control_record_size(
526 record: &str,
527 size: u64,
528 maximum: u64,
529 ) -> Result<(), DecisionError> {
530 if size > maximum {
531 return Err(DecisionError::Conflict(format!(
532 "{record} is {size} bytes; maximum is {maximum}"
533 )));
534 }
535 Ok(())
536 }
537
538 fn encode_control_record<T: serde::Serialize>(
539 record: &str,
540 value: &T,
541 maximum: u64,
542 ) -> Result<Bytes, DecisionError> {
543 let payload = serde_json::to_vec(value)
544 .map(Bytes::from)
545 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
546 let size = u64::try_from(payload.len()).unwrap_or(u64::MAX);
547 Self::ensure_control_record_size(record, size, maximum)?;
548 Ok(payload)
549 }
550
551 async fn read_control_record_bytes(
552 result: GetResult,
553 record: &str,
554 maximum: u64,
555 expected_size: Option<u64>,
556 ) -> Result<Bytes, DecisionError> {
557 Self::ensure_control_record_size(record, result.meta.size, maximum)?;
558 if let Some(expected_size) = expected_size {
559 if result.meta.size != expected_size {
560 return Err(DecisionError::Conflict(format!(
561 "{record} is {} bytes, expected {expected_size}",
562 result.meta.size
563 )));
564 }
565 }
566 let metadata_size = result.meta.size;
567 if result.range.start != 0 || result.range.end != metadata_size {
568 return Err(DecisionError::Conflict(format!(
569 "{record} returned byte range {}..{}, expected 0..{metadata_size}",
570 result.range.start, result.range.end
571 )));
572 }
573 let capacity = usize::try_from(metadata_size).map_err(|_| {
574 DecisionError::Conflict(format!(
575 "{record} length {metadata_size} exceeds this process address space"
576 ))
577 })?;
578 let mut bytes = BytesMut::with_capacity(capacity);
579 let mut stream = result.into_stream();
580 while let Some(chunk) = stream.next().await {
581 let chunk = chunk.map_err(|error| DecisionError::Io(error.to_string()))?;
582 let next_len = bytes.len().checked_add(chunk.len()).ok_or_else(|| {
583 DecisionError::Conflict(format!("{record} payload length overflow"))
584 })?;
585 if next_len > capacity {
586 return Err(DecisionError::Conflict(format!(
587 "{record} payload exceeded its advertised {metadata_size}-byte length"
588 )));
589 }
590 bytes.extend_from_slice(&chunk);
591 }
592 if bytes.len() != capacity {
593 return Err(DecisionError::Conflict(format!(
594 "{record} payload length changed while reading"
595 )));
596 }
597 Ok(bytes.freeze())
598 }
599
600 async fn get_control_record(
601 &self,
602 path: &OsPath,
603 record: &str,
604 maximum: u64,
605 ) -> Result<Option<GetResult>, DecisionError> {
606 let request_end = maximum.checked_add(1).ok_or_else(|| {
607 DecisionError::Conflict(format!("{record} maximum cannot be range-bounded"))
608 })?;
609 let result = match self
610 .store
611 .get_opts(
612 path,
613 GetOptions {
614 range: Some(GetRange::Bounded(0..request_end)),
615 ..GetOptions::default()
616 },
617 )
618 .await
619 {
620 Ok(result) => result,
621 Err(object_store::Error::NotFound { .. }) => return Ok(None),
622 Err(error) => return Err(DecisionError::Io(error.to_string())),
623 };
624 Self::ensure_control_record_size(record, result.meta.size, maximum)?;
625 if result.range.start != 0 || result.range.end != result.meta.size {
626 return Err(DecisionError::Conflict(format!(
627 "{record} returned byte range {}..{}, inconsistent with advertised size {}",
628 result.range.start, result.range.end, result.meta.size
629 )));
630 }
631 Ok(Some(result))
632 }
633
634 fn require_native_cas_token(
635 &self,
636 record: &str,
637 update_version: &UpdateVersion,
638 ) -> Result<(), DecisionError> {
639 if self.update_mode == DecisionStoreUpdateMode::NativeCas
640 && update_version.e_tag.is_none()
641 && update_version.version.is_none()
642 {
643 return Err(DecisionError::Conflict(format!(
644 "shared {record} has neither an ETag nor an object version for CAS"
645 )));
646 }
647 Ok(())
648 }
649
650 #[must_use]
652 pub fn new(store: Arc<dyn ObjectStore>) -> Self {
653 Self::with_update_mode(store, DecisionStoreUpdateMode::NativeCas, None)
654 }
655
656 #[cfg(test)]
657 fn local_single_writer(store: Arc<dyn ObjectStore>) -> Self {
658 let authority = Arc::as_ptr(&store).cast::<()>() as usize;
659 let lock =
660 shared_local_metadata_rmw_lock(LocalMetadataNamespace::StoreAuthority(authority));
661 Self::with_update_mode(
662 store,
663 DecisionStoreUpdateMode::LocalSingleWriter,
664 Some(lock),
665 )
666 }
667
668 pub fn local_filesystem(root: impl AsRef<FsPath>) -> Result<Self, DecisionError> {
674 let root = root.as_ref();
675 let store: Arc<dyn ObjectStore> = Arc::new(
676 crate::durable_local_store::DurableLocalObjectStore::new(root)
677 .map_err(|error| DecisionError::Io(error.to_string()))?,
678 );
679 let canonical_root =
680 std::fs::canonicalize(root).map_err(|error| DecisionError::Io(error.to_string()))?;
681 let lock =
682 shared_local_metadata_rmw_lock(LocalMetadataNamespace::Filesystem(canonical_root));
683 Ok(Self::with_update_mode(
684 store,
685 DecisionStoreUpdateMode::LocalSingleWriter,
686 Some(lock),
687 ))
688 }
689
690 fn with_update_mode(
691 store: Arc<dyn ObjectStore>,
692 update_mode: DecisionStoreUpdateMode,
693 local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
694 ) -> Self {
695 debug_assert_eq!(
696 update_mode == DecisionStoreUpdateMode::LocalSingleWriter,
697 local_metadata_rmw_lock.is_some()
698 );
699 Self {
700 store,
701 update_mode,
702 metadata_write_lock: tokio::sync::Mutex::new(()),
703 local_metadata_rmw_lock,
704 checkpoint_id_head: parking_lot::Mutex::new(None),
705 local_reservation: parking_lot::Mutex::new(LocalReservationState::default()),
706 deployment_id: tokio::sync::OnceCell::new(),
707 }
708 }
709
710 fn outcome_root() -> OsPath {
711 OsPath::from("checkpoint-outcomes/")
712 }
713
714 fn outcome_path(epoch: u64) -> OsPath {
715 OsPath::from(format!("checkpoint-outcomes/epoch={epoch}/outcome"))
716 }
717
718 fn recovery_capsule_path(reference: &RecoveryCapsuleRef) -> OsPath {
719 OsPath::from(format!(
720 "checkpoint-recovery-capsules/epoch={:020}/checkpoint={:020}/sha256={}",
721 reference.epoch, reference.checkpoint_id, reference.sha256
722 ))
723 }
724
725 #[cfg(feature = "cluster")]
726 fn recovery_capsule_root() -> OsPath {
727 OsPath::from("checkpoint-recovery-capsules/")
728 }
729
730 #[cfg(feature = "cluster")]
731 fn recovery_capsule_coordinates_from_path(location: &str) -> Option<(u64, u64, &str)> {
732 let suffix = location.strip_prefix("checkpoint-recovery-capsules/epoch=")?;
733 let (epoch, suffix) = suffix.split_once("/checkpoint=")?;
734 let (checkpoint_id, digest) = suffix.split_once("/sha256=")?;
735 if epoch.len() != 20
736 || checkpoint_id.len() != 20
737 || digest.len() != 64
738 || digest.contains('/')
739 {
740 return None;
741 }
742 Some((epoch.parse().ok()?, checkpoint_id.parse().ok()?, digest))
743 }
744
745 #[cfg(feature = "cluster")]
746 fn recovery_capsule_gc_cursor_path(deployment_id: &str) -> OsPath {
747 OsPath::from(format!(
748 "checkpoint-recovery-capsule-gc/deployment={deployment_id}/cursor"
749 ))
750 }
751
752 #[cfg(feature = "cluster")]
753 fn recovery_capsule_quarantine_path(location: &str) -> OsPath {
754 let digest = format!("{:x}", Sha256::digest(location.as_bytes()));
755 OsPath::from(format!(
756 "checkpoint-recovery-capsule-quarantine/path-sha256={digest}"
757 ))
758 }
759
760 fn outcome_gc_floor_path(deployment_id: &str) -> OsPath {
761 OsPath::from(format!(
762 "checkpoint-outcome-gc/deployment={deployment_id}/floor"
763 ))
764 }
765
766 fn local_reservation_root() -> OsPath {
767 OsPath::from("checkpoint-id-blocks/")
768 }
769
770 fn local_reservation_path(block: u64) -> OsPath {
771 OsPath::from(format!("checkpoint-id-blocks/block={block:020}"))
772 }
773
774 fn deployment_identity_path() -> OsPath {
775 OsPath::from("checkpoint-deployment/identity.json")
776 }
777
778 fn sink_open_witness_path() -> OsPath {
779 OsPath::from("checkpoint-sink-open-witness/witness.json")
780 }
781
782 #[cfg(feature = "cluster")]
783 async fn read_recovery_capsule_gc_cursor(
784 &self,
785 deployment_id: &str,
786 ) -> Result<Option<VersionedRecoveryCapsuleGcCursor>, DecisionError> {
787 let path = Self::recovery_capsule_gc_cursor_path(deployment_id);
788 let Some(result) = self
789 .get_control_record(
790 &path,
791 "recovery capsule GC cursor",
792 RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES,
793 )
794 .await?
795 else {
796 return Ok(None);
797 };
798 let update_version = UpdateVersion {
799 e_tag: result.meta.e_tag.clone(),
800 version: result.meta.version.clone(),
801 };
802 self.require_native_cas_token("recovery capsule GC cursor", &update_version)?;
803 let bytes = Self::read_control_record_bytes(
804 result,
805 "recovery capsule GC cursor",
806 RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES,
807 None,
808 )
809 .await?;
810 let cursor: RecoveryCapsuleGcCursor = serde_json::from_slice(&bytes).map_err(|error| {
811 DecisionError::Conflict(format!("recovery capsule GC cursor: {error}"))
812 })?;
813 if cursor.version != RECOVERY_CAPSULE_GC_CURSOR_VERSION
814 || cursor.deployment_id != deployment_id
815 || cursor.offset.as_ref().is_some_and(|offset| {
816 !offset.starts_with("checkpoint-recovery-capsules/")
817 || OsPath::parse(offset).is_err()
818 })
819 {
820 return Err(DecisionError::Conflict(
821 "recovery capsule GC cursor does not match its path, deployment, or progress"
822 .into(),
823 ));
824 }
825 let canonical = serde_json::to_vec(&cursor)
826 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
827 if canonical.as_slice() != bytes.as_ref() {
828 return Err(DecisionError::Conflict(
829 "recovery capsule GC cursor does not use its canonical body".into(),
830 ));
831 }
832 Ok(Some(VersionedRecoveryCapsuleGcCursor {
833 cursor,
834 update_version,
835 }))
836 }
837
838 #[cfg(feature = "cluster")]
839 async fn compare_and_swap_recovery_capsule_gc_cursor(
840 &self,
841 cursor: &RecoveryCapsuleGcCursor,
842 expected: Option<UpdateVersion>,
843 ) -> Result<bool, DecisionError> {
844 let path = Self::recovery_capsule_gc_cursor_path(&cursor.deployment_id);
845 let payload = Self::encode_control_record(
846 "recovery capsule GC cursor",
847 cursor,
848 RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES,
849 )?;
850 let options = PutOptions {
851 mode: expected.map_or(PutMode::Create, PutMode::Update),
852 ..PutOptions::default()
853 };
854 match self
855 .store
856 .put_opts(&path, PutPayload::from(payload), options)
857 .await
858 {
859 Ok(_) => Ok(true),
860 Err(
861 object_store::Error::Precondition { .. }
862 | object_store::Error::AlreadyExists { .. }
863 | object_store::Error::NotFound { .. },
864 ) => Ok(false),
865 Err(error) => match self
866 .read_recovery_capsule_gc_cursor(&cursor.deployment_id)
867 .await?
868 {
869 Some(current) if current.cursor == *cursor => Ok(true),
870 _ => Err(DecisionError::Io(error.to_string())),
871 },
872 }
873 }
874
875 pub async fn create_recovery_capsule(
883 &self,
884 capsule: &ClusterRecoveryCapsule,
885 ) -> Result<RecoveryCapsuleRef, DecisionError> {
886 let (encoded, reference) = capsule
887 .encode_and_reference()
888 .map_err(DecisionError::Conflict)?;
889 let deployment_id = self.load_or_create_deployment_id().await?;
890 if capsule.deployment_id != deployment_id {
891 return Err(DecisionError::Conflict(format!(
892 "recovery capsule belongs to deployment {}, current deployment is {deployment_id}",
893 capsule.deployment_id
894 )));
895 }
896
897 let path = Self::recovery_capsule_path(&reference);
898 let options = PutOptions {
899 mode: PutMode::Create,
900 ..PutOptions::default()
901 };
902 match self
903 .store
904 .put_opts(&path, PutPayload::from(Bytes::from(encoded)), options)
905 .await
906 {
907 Ok(_) => Ok(reference),
908 Err(put_error) => match self.load_recovery_capsule(&reference).await {
909 Ok(stored) if stored == *capsule => Ok(reference),
910 Ok(_) => Err(DecisionError::Conflict(format!(
911 "recovery capsule '{}' differs from the proposed content",
912 reference.sha256
913 ))),
914 Err(reconcile_error) => Err(DecisionError::Io(format!(
915 "recovery capsule write failed ({put_error}); reconciliation failed ({reconcile_error})"
916 ))),
917 },
918 }
919 }
920
921 pub async fn load_recovery_capsule(
929 &self,
930 reference: &RecoveryCapsuleRef,
931 ) -> Result<ClusterRecoveryCapsule, DecisionError> {
932 reference.validate().map_err(DecisionError::Conflict)?;
933 let record = format!("recovery capsule '{}'", reference.sha256);
934 let result = self
935 .get_control_record(
936 &Self::recovery_capsule_path(reference),
937 &record,
938 u64::try_from(crate::checkpoint::MAX_RECOVERY_CAPSULE_BYTES).unwrap_or(u64::MAX),
939 )
940 .await?
941 .ok_or_else(|| {
942 DecisionError::Conflict(format!(
943 "recovery capsule '{}' is missing",
944 reference.sha256
945 ))
946 })?;
947 let bytes = Self::read_control_record_bytes(
948 result,
949 &record,
950 u64::try_from(crate::checkpoint::MAX_RECOVERY_CAPSULE_BYTES).unwrap_or(u64::MAX),
951 Some(reference.len),
952 )
953 .await?;
954 let capsule: ClusterRecoveryCapsule = serde_json::from_slice(&bytes).map_err(|error| {
955 DecisionError::Conflict(format!("recovery capsule '{}': {error}", reference.sha256))
956 })?;
957 let (canonical, actual_reference) = capsule
958 .encode_and_reference()
959 .map_err(DecisionError::Conflict)?;
960 if actual_reference != *reference || canonical.as_slice() != bytes.as_ref() {
961 return Err(DecisionError::Conflict(format!(
962 "recovery capsule '{}' does not match its content-addressed reference",
963 reference.sha256
964 )));
965 }
966 let deployment_id = self.load_or_create_deployment_id().await?;
967 if capsule.deployment_id != deployment_id {
968 return Err(DecisionError::Conflict(format!(
969 "recovery capsule belongs to deployment {}, current deployment is {deployment_id}",
970 capsule.deployment_id
971 )));
972 }
973 Ok(capsule)
974 }
975
976 pub(crate) async fn validate_recovery_capsule_for_outcome(
977 &self,
978 outcome: &CheckpointOutcome,
979 ) -> Result<(), DecisionError> {
980 outcome.validate_shape(outcome.epoch)?;
981 let reference = outcome.recovery_capsule.as_ref().ok_or_else(|| {
982 DecisionError::Conflict(format!(
983 "cluster commit outcome for epoch {} requires a recovery capsule",
984 outcome.epoch
985 ))
986 })?;
987 let capsule = self.load_recovery_capsule(reference).await?;
988 if capsule.attempt.epoch != outcome.epoch
989 || capsule.attempt.checkpoint_id != outcome.checkpoint_id
990 || Some(&capsule.assignment_fence) != outcome.assignment_fence.as_ref()
991 || capsule.deployment_id != outcome.deployment_id
992 {
993 return Err(DecisionError::Conflict(format!(
994 "cluster commit outcome for epoch {} does not match recovery capsule '{}'",
995 outcome.epoch, reference.sha256
996 )));
997 }
998 Ok(())
999 }
1000
1001 fn validate_deployment_identity(
1002 identity: &DeploymentIdentity,
1003 ) -> Result<String, DecisionError> {
1004 if identity.version != DEPLOYMENT_IDENTITY_VERSION {
1005 return Err(DecisionError::Conflict(format!(
1006 "deployment identity version {} is unsupported (expected \
1007 {DEPLOYMENT_IDENTITY_VERSION})",
1008 identity.version
1009 )));
1010 }
1011 let parsed = uuid::Uuid::parse_str(&identity.id).map_err(|error| {
1012 DecisionError::Conflict(format!("deployment identity is not a UUID: {error}"))
1013 })?;
1014 let canonical = parsed.to_string();
1015 if canonical != identity.id || parsed.is_nil() {
1016 return Err(DecisionError::Conflict(
1017 "deployment identity must be a canonical non-nil UUID".into(),
1018 ));
1019 }
1020 let allocation_id = uuid::Uuid::parse_str(&identity.allocation_id).map_err(|error| {
1021 DecisionError::Conflict(format!(
1022 "deployment identity has invalid allocation identity: {error}"
1023 ))
1024 })?;
1025 if allocation_id.is_nil() || allocation_id.to_string() != identity.allocation_id {
1026 return Err(DecisionError::Conflict(
1027 "deployment identity must use a canonical non-nil allocation identity".into(),
1028 ));
1029 }
1030 Ok(canonical)
1031 }
1032
1033 async fn read_deployment_identity(
1034 &self,
1035 ) -> Result<Option<VersionedCheckpointIdHead>, DecisionError> {
1036 let Some(result) = self
1037 .get_control_record(
1038 &Self::deployment_identity_path(),
1039 "deployment identity",
1040 DEPLOYMENT_IDENTITY_MAX_BYTES,
1041 )
1042 .await?
1043 else {
1044 return Ok(None);
1045 };
1046 let update_version = UpdateVersion {
1047 e_tag: result.meta.e_tag.clone(),
1048 version: result.meta.version.clone(),
1049 };
1050 self.require_native_cas_token("deployment identity", &update_version)?;
1051 let bytes = Self::read_control_record_bytes(
1052 result,
1053 "deployment identity",
1054 DEPLOYMENT_IDENTITY_MAX_BYTES,
1055 None,
1056 )
1057 .await?;
1058 let identity: DeploymentIdentity = serde_json::from_slice(&bytes)
1059 .map_err(|error| DecisionError::Conflict(format!("deployment identity: {error}")))?;
1060 Self::validate_deployment_identity(&identity)?;
1061 if identity.allocator_mode != self.update_mode {
1062 return Err(DecisionError::Conflict(format!(
1063 "deployment identity allocator mode {:?} cannot be opened as {:?}",
1064 identity.allocator_mode, self.update_mode
1065 )));
1066 }
1067 let canonical = serde_json::to_vec(&identity)
1068 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
1069 if canonical.as_slice() != bytes.as_ref() {
1070 return Err(DecisionError::Conflict(
1071 "deployment identity does not use its canonical body".into(),
1072 ));
1073 }
1074 Ok(Some(VersionedCheckpointIdHead {
1075 head: identity,
1076 update_version,
1077 }))
1078 }
1079
1080 pub async fn load_or_create_deployment_id(&self) -> Result<String, DecisionError> {
1086 if let Some(identity) = self.deployment_id.get() {
1087 return Ok(identity.clone());
1088 }
1089 let _guard = self.metadata_write_lock.lock().await;
1090 if let Some(identity) = self.deployment_id.get() {
1091 return Ok(identity.clone());
1092 }
1093 if let Some(stored) = self.read_deployment_identity().await? {
1094 let identity = stored.head.id.clone();
1095 self.cache_checkpoint_id_head(Some(stored));
1096 let _ = self.deployment_id.set(identity.clone());
1097 return Ok(identity);
1098 }
1099
1100 let identity = DeploymentIdentity {
1101 version: DEPLOYMENT_IDENTITY_VERSION,
1102 id: uuid::Uuid::now_v7().to_string(),
1103 allocator_mode: self.update_mode,
1104 checkpoint_id: 0,
1105 allocation_id: uuid::Uuid::now_v7().to_string(),
1106 };
1107 let payload = Self::encode_control_record(
1108 "deployment identity",
1109 &identity,
1110 DEPLOYMENT_IDENTITY_MAX_BYTES,
1111 )?;
1112 let options = PutOptions {
1113 mode: PutMode::Create,
1114 ..PutOptions::default()
1115 };
1116 match self
1117 .store
1118 .put_opts(
1119 &Self::deployment_identity_path(),
1120 PutPayload::from(payload),
1121 options,
1122 )
1123 .await
1124 {
1125 Ok(put_result) => {
1126 let update_version: UpdateVersion = put_result.into();
1127 if self.update_mode == DecisionStoreUpdateMode::NativeCas
1128 && update_version.e_tag.is_none()
1129 && update_version.version.is_none()
1130 {
1131 self.cache_checkpoint_id_head(None);
1132 } else {
1133 self.cache_checkpoint_id_head(Some(VersionedCheckpointIdHead {
1134 head: identity.clone(),
1135 update_version,
1136 }));
1137 }
1138 let _ = self.deployment_id.set(identity.id.clone());
1139 Ok(identity.id)
1140 }
1141 Err(
1142 object_store::Error::Precondition { .. }
1143 | object_store::Error::AlreadyExists { .. },
1144 ) => {
1145 let stored = self.read_deployment_identity().await?.ok_or_else(|| {
1146 DecisionError::Conflict(
1147 "deployment identity disappeared after create conflict".into(),
1148 )
1149 })?;
1150 let identity = stored.head.id.clone();
1151 self.cache_checkpoint_id_head(Some(stored));
1152 let _ = self.deployment_id.set(identity.clone());
1153 Ok(identity)
1154 }
1155 Err(error) => match self.read_deployment_identity().await? {
1156 Some(stored) => {
1157 let identity = stored.head.id.clone();
1158 self.cache_checkpoint_id_head(Some(stored));
1159 let _ = self.deployment_id.set(identity.clone());
1160 Ok(identity)
1161 }
1162 None => Err(DecisionError::Io(error.to_string())),
1163 },
1164 }
1165 }
1166
1167 fn local_reservation_block(location: &str) -> Result<u64, DecisionError> {
1168 let value = location
1169 .strip_prefix("checkpoint-id-blocks/block=")
1170 .ok_or_else(|| {
1171 DecisionError::Conflict(format!("malformed checkpoint ID block path: {location}"))
1172 })?;
1173 if value.len() != 20 || value.contains('/') {
1174 return Err(DecisionError::Conflict(format!(
1175 "malformed checkpoint ID block path: {location}"
1176 )));
1177 }
1178 let block = value.parse::<u64>().map_err(|_| {
1179 DecisionError::Conflict(format!("malformed checkpoint ID block path: {location}"))
1180 })?;
1181 if Self::local_reservation_path(block).as_ref() != location {
1182 return Err(DecisionError::Conflict(format!(
1183 "checkpoint ID block path is not canonical: {location}"
1184 )));
1185 }
1186 Self::local_reservation_block_bounds(block)?;
1187 Ok(block)
1188 }
1189
1190 const fn local_reservation_block_for(checkpoint_id: u64) -> u64 {
1191 (checkpoint_id - 1) / LOCAL_RESERVATION_BLOCK_SIZE
1192 }
1193
1194 fn local_reservation_block_bounds(block: u64) -> Result<(u64, u64), DecisionError> {
1195 let start = block
1196 .checked_mul(LOCAL_RESERVATION_BLOCK_SIZE)
1197 .and_then(|value| value.checked_add(1))
1198 .ok_or_else(|| {
1199 DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1200 })?;
1201 let end = start.saturating_add(LOCAL_RESERVATION_BLOCK_SIZE - 1);
1202 Ok((start, end))
1203 }
1204
1205 async fn initialize_local_reservation(&self) -> Result<(), DecisionError> {
1206 if self.local_reservation.lock().initialized {
1207 return Ok(());
1208 }
1209
1210 let mut entries = self.store.list(Some(&Self::local_reservation_root()));
1211 let mut highest = None;
1212 while let Some(entry) = entries.next().await {
1213 let entry = entry.map_err(|error| DecisionError::Io(error.to_string()))?;
1214 let block = Self::local_reservation_block(entry.location.as_ref())?;
1215 highest = Some(highest.map_or(block, |current: u64| current.max(block)));
1216 }
1217 let mut state = self.local_reservation.lock();
1218 state.initialized = true;
1219 state.highest_block = highest;
1220 state.next_id = None;
1221 state.block_end = 0;
1222 Ok(())
1223 }
1224
1225 fn consume_local_reservation(&self, minimum: u64) -> Option<u64> {
1226 let mut state = self.local_reservation.lock();
1227 let next_id = state.next_id?;
1228 let checkpoint_id = next_id.max(minimum);
1229 if checkpoint_id > state.block_end {
1230 state.next_id = None;
1231 return None;
1232 }
1233 state.next_id = checkpoint_id
1234 .checked_add(1)
1235 .filter(|next| *next <= state.block_end);
1236 Some(checkpoint_id)
1237 }
1238
1239 async fn allocate_local_checkpoint_id_at_least(
1240 &self,
1241 minimum: u64,
1242 ) -> Result<u64, DecisionError> {
1243 self.initialize_local_reservation().await?;
1244 if let Some(checkpoint_id) = self.consume_local_reservation(minimum) {
1245 return Ok(checkpoint_id);
1246 }
1247
1248 let minimum_block = Self::local_reservation_block_for(minimum);
1249 let highest_block = self.local_reservation.lock().highest_block;
1250 let mut candidate = match highest_block {
1251 Some(block) => block
1252 .checked_add(1)
1253 .map(|next| next.max(minimum_block))
1254 .ok_or_else(|| {
1255 DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1256 })?,
1257 None => minimum_block,
1258 };
1259
1260 loop {
1261 let (start, end) = Self::local_reservation_block_bounds(candidate)?;
1262 let result = self
1263 .store
1264 .put_opts(
1265 &Self::local_reservation_path(candidate),
1266 PutPayload::from(Bytes::new()),
1267 PutOptions {
1268 mode: PutMode::Create,
1269 ..PutOptions::default()
1270 },
1271 )
1272 .await;
1273 match result {
1274 Ok(_) => {
1275 let checkpoint_id = start.max(minimum);
1276 let mut state = self.local_reservation.lock();
1277 state.highest_block = Some(
1278 state
1279 .highest_block
1280 .map_or(candidate, |current| current.max(candidate)),
1281 );
1282 state.block_end = end;
1283 state.next_id = checkpoint_id
1284 .checked_add(1)
1285 .filter(|next| *next <= state.block_end);
1286 return Ok(checkpoint_id);
1287 }
1288 Err(
1289 object_store::Error::Precondition { .. }
1290 | object_store::Error::AlreadyExists { .. },
1291 ) => {
1292 candidate = candidate.checked_add(1).ok_or_else(|| {
1293 DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1294 })?;
1295 self.local_reservation.lock().highest_block = Some(candidate - 1);
1296 tokio::task::yield_now().await;
1297 }
1298 Err(error) => return Err(DecisionError::Io(error.to_string())),
1299 }
1300 }
1301 }
1302
1303 async fn read_checkpoint_id_head(
1304 &self,
1305 deployment_id: &str,
1306 ) -> Result<Option<VersionedCheckpointIdHead>, DecisionError> {
1307 let observed = self.read_deployment_identity().await?;
1308 if let Some(observed) = observed.as_ref() {
1309 if observed.head.id != deployment_id {
1310 return Err(DecisionError::Conflict(format!(
1311 "checkpoint ID head belongs to deployment {}, current deployment is {deployment_id}",
1312 observed.head.id
1313 )));
1314 }
1315 }
1316 Ok(observed)
1317 }
1318
1319 fn cache_checkpoint_id_head(&self, head: Option<VersionedCheckpointIdHead>) {
1320 *self.checkpoint_id_head.lock() = head;
1321 }
1322
1323 fn validate_checkpoint_id_head_progress(
1324 prior: Option<&VersionedCheckpointIdHead>,
1325 observed: Option<&VersionedCheckpointIdHead>,
1326 ) -> Result<(), DecisionError> {
1327 match (prior, observed) {
1328 (Some(prior), None) => Err(DecisionError::Conflict(format!(
1329 "checkpoint ID head disappeared after durable ID {}",
1330 prior.head.checkpoint_id
1331 ))),
1332 (Some(prior), Some(observed))
1333 if observed.head.checkpoint_id < prior.head.checkpoint_id =>
1334 {
1335 Err(DecisionError::Conflict(format!(
1336 "checkpoint ID head regressed from {} to {}",
1337 prior.head.checkpoint_id, observed.head.checkpoint_id
1338 )))
1339 }
1340 (Some(prior), Some(observed))
1341 if observed.head.checkpoint_id == prior.head.checkpoint_id
1342 && observed.head != prior.head =>
1343 {
1344 Err(DecisionError::Conflict(format!(
1345 "checkpoint ID {} changed allocation identity without advancing",
1346 prior.head.checkpoint_id
1347 )))
1348 }
1349 _ => Ok(()),
1350 }
1351 }
1352
1353 async fn allocate_shared_checkpoint_id_at_least(
1354 &self,
1355 deployment_id: &str,
1356 minimum: u64,
1357 ) -> Result<u64, DecisionError> {
1358 let allocation_id = uuid::Uuid::now_v7().to_string();
1359 let mut current = self.checkpoint_id_head.lock().clone();
1360 if current.is_none() {
1361 current = self.read_checkpoint_id_head(deployment_id).await?;
1362 if current.is_none() {
1363 return Err(DecisionError::Conflict(format!(
1364 "checkpoint ID authority for deployment {deployment_id} disappeared"
1365 )));
1366 }
1367 self.cache_checkpoint_id_head(current.clone());
1368 }
1369
1370 loop {
1371 let versioned = current.as_ref().ok_or_else(|| {
1372 DecisionError::Conflict(format!(
1373 "checkpoint ID authority for deployment {deployment_id} disappeared"
1374 ))
1375 })?;
1376 let checkpoint_id = versioned
1377 .head
1378 .checkpoint_id
1379 .checked_add(1)
1380 .map(|next| next.max(minimum))
1381 .ok_or_else(|| {
1382 DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1383 })?;
1384 let head = DeploymentIdentity {
1385 version: DEPLOYMENT_IDENTITY_VERSION,
1386 id: deployment_id.to_owned(),
1387 allocator_mode: self.update_mode,
1388 checkpoint_id,
1389 allocation_id: allocation_id.clone(),
1390 };
1391 let payload = serde_json::to_vec(&head)
1392 .map(Bytes::from)
1393 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
1394 let mode = PutMode::Update(versioned.update_version.clone());
1395 let result = self
1396 .store
1397 .put_opts(
1398 &Self::deployment_identity_path(),
1399 PutPayload::from(payload),
1400 PutOptions {
1401 mode,
1402 ..PutOptions::default()
1403 },
1404 )
1405 .await;
1406 match result {
1407 Ok(put_result) => {
1408 let update_version: UpdateVersion = put_result.into();
1409 if update_version.e_tag.is_none() && update_version.version.is_none() {
1410 self.cache_checkpoint_id_head(None);
1413 } else {
1414 self.cache_checkpoint_id_head(Some(VersionedCheckpointIdHead {
1415 head,
1416 update_version,
1417 }));
1418 }
1419 return Ok(checkpoint_id);
1420 }
1421 Err(
1422 object_store::Error::Precondition { .. }
1423 | object_store::Error::AlreadyExists { .. }
1424 | object_store::Error::NotFound { .. },
1425 ) => {
1426 let observed = self.read_checkpoint_id_head(deployment_id).await?;
1427 Self::validate_checkpoint_id_head_progress(
1428 current.as_ref(),
1429 observed.as_ref(),
1430 )?;
1431 current = observed;
1432 self.cache_checkpoint_id_head(current.clone());
1433 tokio::task::yield_now().await;
1434 }
1435 Err(error) => {
1436 let observed = self.read_checkpoint_id_head(deployment_id).await?;
1437 Self::validate_checkpoint_id_head_progress(
1438 current.as_ref(),
1439 observed.as_ref(),
1440 )?;
1441 if observed.as_ref().is_some_and(|value| value.head == head) {
1442 self.cache_checkpoint_id_head(observed);
1443 return Ok(checkpoint_id);
1444 }
1445 if observed
1446 .as_ref()
1447 .is_some_and(|value| value.head.checkpoint_id >= checkpoint_id)
1448 {
1449 current = observed;
1450 self.cache_checkpoint_id_head(current.clone());
1451 tokio::task::yield_now().await;
1452 continue;
1453 }
1454 self.cache_checkpoint_id_head(observed);
1455 return Err(DecisionError::Io(error.to_string()));
1456 }
1457 }
1458 }
1459 }
1460
1461 pub async fn allocate_checkpoint_id_at_least(
1474 &self,
1475 minimum: u64,
1476 ) -> Result<u64, DecisionError> {
1477 if minimum == 0 {
1478 return Err(DecisionError::Conflict(
1479 "minimum checkpoint ID must be nonzero".to_owned(),
1480 ));
1481 }
1482
1483 let deployment_id = self.load_or_create_deployment_id().await?;
1484 let _guard = self.metadata_write_lock.lock().await;
1485 match self.update_mode {
1486 DecisionStoreUpdateMode::NativeCas => {
1487 self.allocate_shared_checkpoint_id_at_least(&deployment_id, minimum)
1488 .await
1489 }
1490 DecisionStoreUpdateMode::LocalSingleWriter => {
1491 self.allocate_local_checkpoint_id_at_least(minimum).await
1492 }
1493 }
1494 }
1495
1496 #[cfg(test)]
1497 pub async fn allocate_checkpoint_id(&self) -> Result<u64, DecisionError> {
1503 self.allocate_checkpoint_id_at_least(1).await
1504 }
1505
1506 fn validate_sink_open_witness_shape(
1507 witness: &CheckpointSinkOpenWitness,
1508 ) -> Result<(), DecisionError> {
1509 if witness.version != CHECKPOINT_SINK_OPEN_WITNESS_VERSION {
1510 return Err(DecisionError::Conflict(format!(
1511 "sink-open witness version {} is unsupported (expected \
1512 {CHECKPOINT_SINK_OPEN_WITNESS_VERSION})",
1513 witness.version
1514 )));
1515 }
1516 let deployment = uuid::Uuid::parse_str(&witness.deployment_id).map_err(|error| {
1517 DecisionError::Conflict(format!(
1518 "sink-open witness has invalid deployment identity: {error}"
1519 ))
1520 })?;
1521 if deployment.is_nil() || deployment.to_string() != witness.deployment_id {
1522 return Err(DecisionError::Conflict(
1523 "sink-open witness must use a canonical non-nil deployment identity".into(),
1524 ));
1525 }
1526 if let Some(error) = witness.pipeline_identity.validation_error() {
1527 return Err(DecisionError::Conflict(format!(
1528 "sink-open witness has an invalid pipeline identity: {error}"
1529 )));
1530 }
1531 if !witness.attempt.is_canonical() {
1532 return Err(DecisionError::Conflict(
1533 "sink-open witness must use one nonzero canonical checkpoint ID".into(),
1534 ));
1535 }
1536 if witness.committable_sinks.is_empty()
1537 || witness.committable_sinks.len() > CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS
1538 {
1539 return Err(DecisionError::Conflict(format!(
1540 "sink-open witness must name between 1 and \
1541 {CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS} committable sinks"
1542 )));
1543 }
1544 if witness
1545 .committable_sinks
1546 .iter()
1547 .any(|name| name.is_empty() || name.trim() != name)
1548 {
1549 return Err(DecisionError::Conflict(
1550 "sink-open witness contains a non-canonical sink name".into(),
1551 ));
1552 }
1553 if witness
1554 .committable_sinks
1555 .windows(2)
1556 .any(|pair| pair[0] >= pair[1])
1557 {
1558 return Err(DecisionError::Conflict(
1559 "sink-open witness sink names must be strictly sorted and unique".into(),
1560 ));
1561 }
1562 let create_token = uuid::Uuid::parse_str(&witness.create_token).map_err(|error| {
1563 DecisionError::Conflict(format!(
1564 "sink-open witness has invalid create token: {error}"
1565 ))
1566 })?;
1567 if create_token.is_nil() || create_token.to_string() != witness.create_token {
1568 return Err(DecisionError::Conflict(
1569 "sink-open witness must use a canonical non-nil create token".into(),
1570 ));
1571 }
1572 Ok(())
1573 }
1574
1575 fn validate_sink_open_witness_slot_shape(
1576 slot: &CheckpointSinkOpenWitnessSlot,
1577 ) -> Result<(), DecisionError> {
1578 if slot.version != CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION {
1579 return Err(DecisionError::Conflict(format!(
1580 "sink-open witness slot version {} is unsupported (expected \
1581 {CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION})",
1582 slot.version
1583 )));
1584 }
1585 Self::validate_sink_open_witness_shape(slot.witness())?;
1586 if let CheckpointSinkOpenWitnessSlotState::Closed { close_token, .. } = &slot.state {
1587 let token = uuid::Uuid::parse_str(close_token).map_err(|error| {
1588 DecisionError::Conflict(format!(
1589 "sink-open witness slot has invalid close token: {error}"
1590 ))
1591 })?;
1592 if token.is_nil() || token.to_string() != *close_token {
1593 return Err(DecisionError::Conflict(
1594 "sink-open witness slot must use a canonical non-nil close token".into(),
1595 ));
1596 }
1597 }
1598 Ok(())
1599 }
1600
1601 fn encode_sink_open_witness_slot(
1602 slot: &CheckpointSinkOpenWitnessSlot,
1603 ) -> Result<Bytes, DecisionError> {
1604 Self::validate_sink_open_witness_slot_shape(slot)?;
1605 Self::encode_control_record(
1606 "sink-open witness slot",
1607 slot,
1608 CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES,
1609 )
1610 }
1611
1612 async fn read_sink_open_witness_record(
1613 &self,
1614 ) -> Result<Option<VersionedCheckpointSinkOpenWitnessSlot>, DecisionError> {
1615 let Some(result) = self
1616 .get_control_record(
1617 &Self::sink_open_witness_path(),
1618 "sink-open witness",
1619 CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES,
1620 )
1621 .await?
1622 else {
1623 return Ok(None);
1624 };
1625 let update_version = UpdateVersion {
1626 e_tag: result.meta.e_tag.clone(),
1627 version: result.meta.version.clone(),
1628 };
1629 self.require_native_cas_token("sink-open witness slot", &update_version)?;
1630 let bytes = Self::read_control_record_bytes(
1631 result,
1632 "sink-open witness",
1633 CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES,
1634 None,
1635 )
1636 .await?;
1637 let slot: CheckpointSinkOpenWitnessSlot = serde_json::from_slice(&bytes)
1638 .map_err(|error| DecisionError::Conflict(format!("sink-open witness slot: {error}")))?;
1639 Self::validate_sink_open_witness_slot_shape(&slot)?;
1640 let canonical = serde_json::to_vec(&slot)
1641 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
1642 if canonical.as_slice() != bytes.as_ref() {
1643 return Err(DecisionError::Conflict(format!(
1644 "sink-open witness slot for checkpoint {} does not use its canonical body",
1645 slot.witness().attempt.checkpoint_id
1646 )));
1647 }
1648 Ok(Some(VersionedCheckpointSinkOpenWitnessSlot {
1649 slot,
1650 update_version,
1651 }))
1652 }
1653
1654 fn validate_sink_open_witness_slot_deployment(
1655 slot: &CheckpointSinkOpenWitnessSlot,
1656 deployment_id: &str,
1657 ) -> Result<(), DecisionError> {
1658 if slot.witness().deployment_id == deployment_id {
1659 return Ok(());
1660 }
1661 Err(DecisionError::Conflict(format!(
1662 "sink-open witness belongs to deployment {}, current deployment is {deployment_id}",
1663 slot.witness().deployment_id
1664 )))
1665 }
1666
1667 fn sink_open_witness_put_mode(&self, expected: Option<UpdateVersion>) -> PutMode {
1668 match (self.update_mode, expected) {
1669 (_, None) => PutMode::Create,
1670 (DecisionStoreUpdateMode::NativeCas, Some(version)) => PutMode::Update(version),
1671 (DecisionStoreUpdateMode::LocalSingleWriter, Some(_)) => PutMode::Overwrite,
1672 }
1673 }
1674
1675 async fn put_sink_open_witness_slot(
1676 &self,
1677 slot: &CheckpointSinkOpenWitnessSlot,
1678 expected: Option<UpdateVersion>,
1679 ) -> Result<(), object_store::Error> {
1680 let payload = Self::encode_sink_open_witness_slot(slot).map_err(|error| {
1681 object_store::Error::Generic {
1682 store: "CheckpointDecisionStore",
1683 source: Box::new(error),
1684 }
1685 })?;
1686 self.store
1687 .put_opts(
1688 &Self::sink_open_witness_path(),
1689 PutPayload::from(payload),
1690 PutOptions {
1691 mode: self.sink_open_witness_put_mode(expected),
1692 ..PutOptions::default()
1693 },
1694 )
1695 .await
1696 .map(|_| ())
1697 }
1698
1699 pub async fn sink_open_witness(
1704 &self,
1705 ) -> Result<Option<CheckpointSinkOpenWitness>, DecisionError> {
1706 let deployment_id = self.load_or_create_deployment_id().await?;
1707 let Some(versioned) = self.read_sink_open_witness_record().await? else {
1708 return Ok(None);
1709 };
1710 Self::validate_sink_open_witness_slot_deployment(&versioned.slot, &deployment_id)?;
1711 match versioned.slot.state {
1712 CheckpointSinkOpenWitnessSlotState::Open { witness } => Ok(Some(witness)),
1713 CheckpointSinkOpenWitnessSlotState::Closed { .. } => Ok(None),
1714 }
1715 }
1716
1717 pub async fn create_sink_open_witness(
1725 &self,
1726 pipeline_identity: PipelineIdentity,
1727 participant_id: u64,
1728 attempt: CheckpointAttempt,
1729 committable_sinks: Vec<String>,
1730 ) -> Result<CheckpointSinkOpenWitness, DecisionError> {
1731 let candidate = CheckpointSinkOpenWitness {
1732 version: CHECKPOINT_SINK_OPEN_WITNESS_VERSION,
1733 deployment_id: self.load_or_create_deployment_id().await?,
1734 pipeline_identity,
1735 participant_id,
1736 attempt,
1737 committable_sinks,
1738 create_token: uuid::Uuid::now_v7().to_string(),
1739 };
1740 Self::validate_sink_open_witness_shape(&candidate)?;
1741 Self::encode_sink_open_witness_slot(&CheckpointSinkOpenWitnessSlot::open(
1742 candidate.clone(),
1743 ))?;
1744 Self::encode_sink_open_witness_slot(&CheckpointSinkOpenWitnessSlot::closed(
1746 candidate.clone(),
1747 ))?;
1748
1749 match self.update_mode {
1750 DecisionStoreUpdateMode::NativeCas => {
1751 self.create_sink_open_witness_inner(candidate).await
1752 }
1753 DecisionStoreUpdateMode::LocalSingleWriter => {
1754 let local_lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
1755 DecisionError::Conflict(
1756 "local decision store is missing its namespace write lock".to_owned(),
1757 )
1758 })?;
1759 let _guard = local_lock.lock().await;
1760 self.create_sink_open_witness_inner(candidate).await
1761 }
1762 }
1763 }
1764
1765 async fn create_sink_open_witness_inner(
1766 &self,
1767 candidate: CheckpointSinkOpenWitness,
1768 ) -> Result<CheckpointSinkOpenWitness, DecisionError> {
1769 let current = self.read_sink_open_witness_record().await?;
1770 let (expected, prior_slot) = match current {
1771 None => (None, None),
1772 Some(versioned) => {
1773 Self::validate_sink_open_witness_slot_deployment(
1774 &versioned.slot,
1775 &candidate.deployment_id,
1776 )?;
1777 match &versioned.slot.state {
1778 CheckpointSinkOpenWitnessSlotState::Open { witness } => {
1779 return Err(DecisionError::Conflict(format!(
1780 "sink-open witness create for checkpoint {} observed conflicting \
1781 checkpoint {}",
1782 candidate.attempt.checkpoint_id, witness.attempt.checkpoint_id
1783 )));
1784 }
1785 CheckpointSinkOpenWitnessSlotState::Closed { witness, .. }
1786 if candidate.attempt.checkpoint_id <= witness.attempt.checkpoint_id =>
1787 {
1788 return Err(DecisionError::Conflict(format!(
1789 "sink-open witness checkpoint {} does not advance closed checkpoint {}",
1790 candidate.attempt.checkpoint_id, witness.attempt.checkpoint_id
1791 )));
1792 }
1793 CheckpointSinkOpenWitnessSlotState::Closed { .. } => {}
1794 }
1795 (Some(versioned.update_version), Some(versioned.slot))
1796 }
1797 };
1798 let candidate_slot = CheckpointSinkOpenWitnessSlot::open(candidate.clone());
1799 let create_error = match self
1800 .put_sink_open_witness_slot(&candidate_slot, expected)
1801 .await
1802 {
1803 Ok(()) => return Ok(candidate),
1804 Err(error) => error,
1805 };
1806
1807 if let Some(observed) = self.read_sink_open_witness_record().await? {
1809 Self::validate_sink_open_witness_slot_deployment(
1810 &observed.slot,
1811 &candidate.deployment_id,
1812 )?;
1813 if observed.slot == candidate_slot {
1814 return Ok(candidate);
1815 }
1816 let conditional_conflict = matches!(
1817 &create_error,
1818 object_store::Error::Precondition { .. }
1819 | object_store::Error::AlreadyExists { .. }
1820 | object_store::Error::NotFound { .. }
1821 );
1822 if !conditional_conflict
1823 && prior_slot
1824 .as_ref()
1825 .is_some_and(|prior| prior == &observed.slot)
1826 {
1827 return Err(DecisionError::Io(create_error.to_string()));
1828 }
1829 return Err(DecisionError::Conflict(format!(
1830 "sink-open witness create for checkpoint {} observed conflicting checkpoint {}",
1831 candidate.attempt.checkpoint_id,
1832 observed.slot.witness().attempt.checkpoint_id
1833 )));
1834 }
1835
1836 match create_error {
1837 object_store::Error::Precondition { .. }
1838 | object_store::Error::AlreadyExists { .. }
1839 | object_store::Error::NotFound { .. } => Err(DecisionError::Conflict(format!(
1840 "sink-open witness for checkpoint {} disappeared after create conflict",
1841 candidate.attempt.checkpoint_id
1842 ))),
1843 error => Err(DecisionError::Io(error.to_string())),
1844 }
1845 }
1846
1847 pub async fn clear_sink_open_witness(
1855 &self,
1856 expected: &CheckpointSinkOpenWitness,
1857 ) -> Result<(), DecisionError> {
1858 Self::validate_sink_open_witness_shape(expected)?;
1859 let deployment_id = self.load_or_create_deployment_id().await?;
1860 if expected.deployment_id != deployment_id {
1861 return Err(DecisionError::Conflict(format!(
1862 "cannot clear sink-open witness from deployment {}; current deployment is \
1863 {deployment_id}",
1864 expected.deployment_id
1865 )));
1866 }
1867 match self.update_mode {
1868 DecisionStoreUpdateMode::NativeCas => {
1869 self.clear_sink_open_witness_inner(expected).await
1870 }
1871 DecisionStoreUpdateMode::LocalSingleWriter => {
1872 let local_lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
1873 DecisionError::Conflict(
1874 "local decision store is missing its namespace write lock".to_owned(),
1875 )
1876 })?;
1877 let _guard = local_lock.lock().await;
1878 self.clear_sink_open_witness_inner(expected).await
1879 }
1880 }
1881 }
1882
1883 async fn clear_sink_open_witness_inner(
1884 &self,
1885 expected: &CheckpointSinkOpenWitness,
1886 ) -> Result<(), DecisionError> {
1887 let Some(current) = self.read_sink_open_witness_record().await? else {
1888 return Err(DecisionError::Conflict(format!(
1889 "sink-open witness slot for checkpoint {} is missing",
1890 expected.attempt.checkpoint_id
1891 )));
1892 };
1893 Self::validate_sink_open_witness_slot_deployment(¤t.slot, &expected.deployment_id)?;
1894 match ¤t.slot.state {
1895 CheckpointSinkOpenWitnessSlotState::Closed { witness, .. } if witness == expected => {
1896 return Ok(());
1897 }
1898 CheckpointSinkOpenWitnessSlotState::Open { witness } if witness == expected => {}
1899 _ => {
1900 return Err(DecisionError::Conflict(format!(
1901 "cannot clear sink-open witness for checkpoint {}; current slot names \
1902 checkpoint {} with a different create identity",
1903 expected.attempt.checkpoint_id,
1904 current.slot.witness().attempt.checkpoint_id
1905 )));
1906 }
1907 }
1908
1909 let closed = CheckpointSinkOpenWitnessSlot::closed(expected.clone());
1910 let close_error = match self
1911 .put_sink_open_witness_slot(&closed, Some(current.update_version))
1912 .await
1913 {
1914 Ok(()) => return Ok(()),
1915 Err(error) => error,
1916 };
1917 match self.read_sink_open_witness_record().await? {
1918 Some(observed) if observed.slot == closed => Ok(()),
1919 Some(observed) => {
1920 Self::validate_sink_open_witness_slot_deployment(
1921 &observed.slot,
1922 &expected.deployment_id,
1923 )?;
1924 match &observed.slot.state {
1925 CheckpointSinkOpenWitnessSlotState::Closed { witness, .. }
1926 if witness == expected =>
1927 {
1928 Ok(())
1930 }
1931 CheckpointSinkOpenWitnessSlotState::Open { witness } if witness == expected => {
1932 Err(DecisionError::Io(close_error.to_string()))
1933 }
1934 _ => {
1935 Ok(())
1938 }
1939 }
1940 }
1941 None => Err(DecisionError::Conflict(format!(
1942 "sink-open witness slot disappeared while closing checkpoint {}: {close_error}",
1943 expected.attempt.checkpoint_id
1944 ))),
1945 }
1946 }
1947
1948 fn outcome_epoch_segment(loc: &str) -> Option<&str> {
1950 let segment = loc
1951 .strip_prefix("checkpoint-outcomes/")?
1952 .strip_suffix("/outcome")?
1953 .strip_prefix("epoch=")?;
1954 let epoch = segment.parse::<u64>().ok()?;
1955 (epoch != 0 && Self::outcome_path(epoch).as_ref() == loc).then_some(segment)
1956 }
1957
1958 pub(crate) async fn canonical_outcome(
1959 &self,
1960 epoch: u64,
1961 checkpoint_id: u64,
1962 scope: CheckpointScope,
1963 assignment_fence: Option<CheckpointAssignmentFence>,
1964 leader_proof: Option<LeaderProof>,
1965 verdict: CheckpointVerdict,
1966 recovery_capsule: Option<RecoveryCapsuleRef>,
1967 ) -> Result<CheckpointOutcome, DecisionError> {
1968 let outcome = CheckpointOutcome {
1969 version: CHECKPOINT_OUTCOME_VERSION,
1970 scope,
1971 epoch,
1972 checkpoint_id,
1973 deployment_id: self.load_or_create_deployment_id().await?,
1974 assignment_fence,
1975 leader_proof,
1976 recovery_capsule,
1977 verdict,
1978 };
1979 if outcome.recovery_capsule.is_some() {
1980 self.validate_recovery_capsule_for_outcome(&outcome).await?;
1981 } else {
1982 outcome.validate_shape(epoch)?;
1983 }
1984 Ok(outcome)
1985 }
1986
1987 async fn read_outcome_record(
1988 &self,
1989 path: &OsPath,
1990 epoch: u64,
1991 ) -> Result<Option<CheckpointOutcome>, DecisionError> {
1992 let record = format!("checkpoint outcome for epoch {epoch}");
1993 let Some(result) = self
1994 .get_control_record(path, &record, CHECKPOINT_OUTCOME_MAX_BYTES)
1995 .await?
1996 else {
1997 return Ok(None);
1998 };
1999 let bytes =
2000 Self::read_control_record_bytes(result, &record, CHECKPOINT_OUTCOME_MAX_BYTES, None)
2001 .await?;
2002 let outcome: CheckpointOutcome = serde_json::from_slice(&bytes)
2003 .map_err(|error| DecisionError::Conflict(format!("outcome epoch {epoch}: {error}")))?;
2004 outcome.validate_shape(epoch)?;
2005 if outcome.scope == CheckpointScope::Cluster {
2006 return Err(DecisionError::Conflict(format!(
2007 "cluster outcome epoch {epoch} is stored outside the shared leader authority"
2008 )));
2009 }
2010 let canonical = serde_json::to_vec(&outcome)
2011 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
2012 if canonical.as_slice() != bytes.as_ref() {
2013 return Err(DecisionError::Conflict(format!(
2014 "outcome for epoch {epoch} does not use the canonical body"
2015 )));
2016 }
2017 let expected_deployment = self.load_or_create_deployment_id().await?;
2018 if outcome.deployment_id != expected_deployment {
2019 return Err(DecisionError::Conflict(format!(
2020 "outcome for epoch {epoch} belongs to deployment {}, current deployment is \
2021 {expected_deployment}",
2022 outcome.deployment_id
2023 )));
2024 }
2025 Ok(Some(outcome))
2026 }
2027
2028 async fn create_outcome(
2029 &self,
2030 candidate: CheckpointOutcome,
2031 ) -> Result<RecordOutcomeResult, DecisionError> {
2032 let path = Self::outcome_path(candidate.epoch);
2033 let payload = Self::encode_control_record(
2034 "checkpoint outcome",
2035 &candidate,
2036 CHECKPOINT_OUTCOME_MAX_BYTES,
2037 )?;
2038 let options = PutOptions {
2039 mode: PutMode::Create,
2040 ..PutOptions::default()
2041 };
2042 let Err(create_error) = self
2043 .store
2044 .put_opts(&path, PutPayload::from(payload), options)
2045 .await
2046 else {
2047 return Ok(RecordOutcomeResult::Created(candidate));
2048 };
2049
2050 if let Some(winner) = self.read_outcome_record(&path, candidate.epoch).await? {
2054 return if winner == candidate {
2055 Ok(RecordOutcomeResult::Unchanged(winner))
2056 } else {
2057 Ok(RecordOutcomeResult::Conflict { winner })
2058 };
2059 }
2060
2061 match create_error {
2062 object_store::Error::Precondition { .. }
2063 | object_store::Error::AlreadyExists { .. } => Err(DecisionError::Conflict(format!(
2064 "outcome for epoch {} disappeared after create conflict",
2065 candidate.epoch
2066 ))),
2067 error => Err(DecisionError::Io(error.to_string())),
2068 }
2069 }
2070
2071 async fn read_outcome_gc_floor(
2072 &self,
2073 deployment_id: &str,
2074 ) -> Result<Option<VersionedOutcomeGcFloor>, DecisionError> {
2075 let path = Self::outcome_gc_floor_path(deployment_id);
2076 let Some(result) = self
2077 .get_control_record(&path, "outcome GC floor", OUTCOME_GC_FLOOR_MAX_BYTES)
2078 .await?
2079 else {
2080 return Ok(None);
2081 };
2082 let update_version = UpdateVersion {
2083 e_tag: result.meta.e_tag.clone(),
2084 version: result.meta.version.clone(),
2085 };
2086 self.require_native_cas_token("outcome GC floor", &update_version)?;
2087 let bytes = Self::read_control_record_bytes(
2088 result,
2089 "outcome GC floor",
2090 OUTCOME_GC_FLOOR_MAX_BYTES,
2091 None,
2092 )
2093 .await?;
2094 let floor: OutcomeGcFloor = serde_json::from_slice(&bytes)
2095 .map_err(|error| DecisionError::Conflict(format!("outcome GC floor: {error}")))?;
2096 let before_epoch = floor.before_epoch;
2097 if floor.version != OUTCOME_GC_FLOOR_VERSION
2098 || floor.before_epoch == 0
2099 || floor.deployment_id != deployment_id
2100 {
2101 return Err(DecisionError::Conflict(
2102 "outcome GC floor does not match its canonical path, deployment, and version"
2103 .to_owned(),
2104 ));
2105 }
2106 if let Some(anchor) = floor.terminal_anchor.as_ref() {
2107 anchor.validate_shape(anchor.epoch)?;
2108 if anchor.epoch >= floor.before_epoch || anchor.deployment_id != floor.deployment_id {
2109 return Err(DecisionError::Conflict(format!(
2110 "outcome GC floor {before_epoch} has invalid terminal anchor epoch {} checkpoint {}",
2111 anchor.epoch, anchor.checkpoint_id
2112 )));
2113 }
2114 }
2115 if let Some(anchor) = floor.committed_anchor.as_ref() {
2116 anchor.validate_shape(anchor.epoch)?;
2117 if !anchor.is_commit()
2118 || anchor.epoch >= floor.before_epoch
2119 || anchor.deployment_id != floor.deployment_id
2120 {
2121 return Err(DecisionError::Conflict(format!(
2122 "outcome GC floor {before_epoch} has invalid committed anchor epoch {} checkpoint {}",
2123 anchor.epoch, anchor.checkpoint_id
2124 )));
2125 }
2126 let terminal = floor.terminal_anchor.as_ref().ok_or_else(|| {
2127 DecisionError::Conflict(format!(
2128 "outcome GC floor {before_epoch} has a committed anchor without a terminal anchor"
2129 ))
2130 })?;
2131 let ordered = if anchor.epoch == terminal.epoch {
2132 anchor == terminal
2133 } else {
2134 anchor.epoch < terminal.epoch && anchor.checkpoint_id < terminal.checkpoint_id
2135 };
2136 if !ordered {
2137 return Err(DecisionError::Conflict(format!(
2138 "outcome GC floor {before_epoch} committed anchor epoch {} checkpoint {} is not ordered before terminal anchor epoch {} checkpoint {}",
2139 anchor.epoch,
2140 anchor.checkpoint_id,
2141 terminal.epoch,
2142 terminal.checkpoint_id
2143 )));
2144 }
2145 }
2146 if floor
2147 .terminal_anchor
2148 .as_ref()
2149 .is_some_and(CheckpointOutcome::is_commit)
2150 && floor.committed_anchor != floor.terminal_anchor
2151 {
2152 return Err(DecisionError::Conflict(format!(
2153 "outcome GC floor {before_epoch} does not retain its terminal commit as the committed anchor"
2154 )));
2155 }
2156 let canonical = serde_json::to_vec(&floor)
2157 .map_err(|error| DecisionError::Conflict(error.to_string()))?;
2158 if canonical.as_slice() != bytes.as_ref() {
2159 return Err(DecisionError::Conflict(format!(
2160 "outcome GC floor {before_epoch} does not use its canonical body"
2161 )));
2162 }
2163 Ok(Some(VersionedOutcomeGcFloor {
2164 floor,
2165 update_version,
2166 }))
2167 }
2168
2169 async fn current_outcome_gc_floor(&self) -> Result<Option<OutcomeGcFloor>, DecisionError> {
2170 let deployment_id = self.load_or_create_deployment_id().await?;
2171 Ok(self
2172 .read_outcome_gc_floor(&deployment_id)
2173 .await?
2174 .map(|versioned| versioned.floor))
2175 }
2176
2177 async fn ensure_outcome_not_tombstoned(
2178 &self,
2179 outcome: &CheckpointOutcome,
2180 ) -> Result<(), DecisionError> {
2181 if let Some(floor) = self.current_outcome_gc_floor().await? {
2182 if outcome.epoch < floor.before_epoch {
2183 return Err(DecisionError::Conflict(format!(
2184 "outcome epoch {} checkpoint {} is below durable outcome GC horizon {}",
2185 outcome.epoch, outcome.checkpoint_id, floor.before_epoch
2186 )));
2187 }
2188 }
2189 Ok(())
2190 }
2191
2192 async fn compare_and_swap_outcome_gc_floor(
2197 &self,
2198 floor: &OutcomeGcFloor,
2199 expected: Option<UpdateVersion>,
2200 ) -> Result<bool, DecisionError> {
2201 let path = Self::outcome_gc_floor_path(&floor.deployment_id);
2202 let payload =
2203 Self::encode_control_record("outcome GC floor", floor, OUTCOME_GC_FLOOR_MAX_BYTES)?;
2204 let options = PutOptions {
2205 mode: expected.clone().map_or(PutMode::Create, PutMode::Update),
2206 ..PutOptions::default()
2207 };
2208 let result = self
2209 .store
2210 .put_opts(&path, PutPayload::from(payload.clone()), options)
2211 .await;
2212 match result {
2213 Ok(_) => Ok(true),
2214 Err(
2215 object_store::Error::Precondition { .. }
2216 | object_store::Error::AlreadyExists { .. }
2217 | object_store::Error::NotFound { .. },
2218 ) => Ok(false),
2219 Err(object_store::Error::NotImplemented { .. })
2220 if expected.is_some()
2221 && self.update_mode == DecisionStoreUpdateMode::LocalSingleWriter =>
2222 {
2223 let _guard = self
2228 .local_metadata_rmw_lock
2229 .as_ref()
2230 .expect("local decision store has a namespace RMW lock")
2231 .lock()
2232 .await;
2233 let Some(current) = self.read_outcome_gc_floor(&floor.deployment_id).await? else {
2234 return Ok(false);
2235 };
2236 if current.floor.before_epoch >= floor.before_epoch {
2237 return Ok(true);
2238 }
2239 if Some(¤t.update_version) != expected.as_ref() {
2240 return Ok(false);
2241 }
2242 let overwrite = PutOptions {
2243 mode: PutMode::Overwrite,
2244 ..PutOptions::default()
2245 };
2246 match self
2247 .store
2248 .put_opts(&path, PutPayload::from(payload), overwrite)
2249 .await
2250 {
2251 Ok(_) => Ok(true),
2252 Err(error) => match self.read_outcome_gc_floor(&floor.deployment_id).await? {
2253 Some(current) if current.floor.before_epoch >= floor.before_epoch => {
2254 Ok(true)
2255 }
2256 _ => Err(DecisionError::Io(error.to_string())),
2257 },
2258 }
2259 }
2260 Err(error) => match self.read_outcome_gc_floor(&floor.deployment_id).await? {
2261 Some(current) if current.floor.before_epoch >= floor.before_epoch => Ok(true),
2262 _ => Err(DecisionError::Io(error.to_string())),
2263 },
2264 }
2265 }
2266
2267 pub async fn record_outcome(
2276 &self,
2277 epoch: u64,
2278 checkpoint_id: u64,
2279 scope: CheckpointScope,
2280 assignment_fence: Option<CheckpointAssignmentFence>,
2281 leader_proof: Option<LeaderProof>,
2282 verdict: CheckpointVerdict,
2283 recovery_capsule: Option<RecoveryCapsuleRef>,
2284 ) -> Result<RecordOutcomeResult, DecisionError> {
2285 if scope == CheckpointScope::Cluster {
2286 return Err(DecisionError::Conflict(
2287 "cluster outcomes must be admitted through the shared leader authority".into(),
2288 ));
2289 }
2290 let candidate = self
2291 .canonical_outcome(
2292 epoch,
2293 checkpoint_id,
2294 scope,
2295 assignment_fence,
2296 leader_proof,
2297 verdict,
2298 recovery_capsule,
2299 )
2300 .await?;
2301 self.ensure_outcome_not_tombstoned(&candidate).await?;
2302 let result = self.create_outcome(candidate.clone()).await?;
2303 self.ensure_outcome_not_tombstoned(&candidate).await?;
2306 Ok(result)
2307 }
2308
2309 pub async fn outcome(&self, epoch: u64) -> Result<Option<CheckpointOutcome>, DecisionError> {
2316 const FLOOR_RETRIES: usize = 3;
2317 for attempt in 0..FLOOR_RETRIES {
2318 let floor_before = self.current_outcome_gc_floor().await?;
2319 if floor_before
2320 .as_ref()
2321 .is_some_and(|floor| epoch < floor.before_epoch)
2322 {
2323 let floor_after = self.current_outcome_gc_floor().await?;
2324 if floor_before == floor_after {
2325 return Ok(None);
2326 }
2327 } else {
2328 let outcome = self
2329 .read_outcome_record(&Self::outcome_path(epoch), epoch)
2330 .await?;
2331 let floor_after = self.current_outcome_gc_floor().await?;
2332 if floor_before == floor_after {
2333 return Ok(outcome.filter(|outcome| {
2334 floor_after
2335 .as_ref()
2336 .is_none_or(|floor| outcome.epoch >= floor.before_epoch)
2337 }));
2338 }
2339 }
2340 if attempt + 1 < FLOOR_RETRIES {
2341 tokio::task::yield_now().await;
2342 }
2343 }
2344 Err(DecisionError::InventoryChanged(
2345 "outcome GC floor kept advancing during exact lookup".into(),
2346 ))
2347 }
2348
2349 async fn list_outcome_epochs(&self) -> Result<Vec<u64>, DecisionError> {
2350 let mut entries = self.store.list(Some(&Self::outcome_root()));
2351 let mut epochs = Vec::new();
2352 while let Some(entry) = entries.next().await {
2353 let entry = entry.map_err(|error| DecisionError::Io(error.to_string()))?;
2354 let location = entry.location.as_ref();
2355 let epoch = Self::outcome_epoch_segment(location)
2356 .and_then(|segment| segment.parse::<u64>().ok())
2357 .filter(|epoch| *epoch != 0)
2358 .ok_or_else(|| {
2359 DecisionError::Conflict(format!(
2360 "malformed checkpoint outcome path: {location}"
2361 ))
2362 })?;
2363 epochs.push(epoch);
2364 }
2365 epochs.sort_unstable();
2366 if epochs.windows(2).any(|pair| pair[0] == pair[1]) {
2367 return Err(DecisionError::Conflict(
2368 "checkpoint outcome inventory contains a duplicate epoch".into(),
2369 ));
2370 }
2371 Ok(epochs)
2372 }
2373
2374 pub async fn outcomes(&self) -> Result<Vec<CheckpointOutcome>, DecisionError> {
2382 const FLOOR_RETRIES: usize = 3;
2383 for attempt in 0..FLOOR_RETRIES {
2384 let floor_before = self.current_outcome_gc_floor().await?;
2385 let mut outcomes = self.audited_outcomes().await?;
2386 let floor_after = self.current_outcome_gc_floor().await?;
2387 if floor_before == floor_after {
2388 if let Some(floor) = floor_after {
2389 outcomes.retain(|outcome| outcome.epoch >= floor.before_epoch);
2390 }
2391 return Ok(outcomes);
2392 }
2393 if attempt + 1 < FLOOR_RETRIES {
2394 tokio::task::yield_now().await;
2395 }
2396 }
2397 Err(DecisionError::InventoryChanged(
2398 "outcome GC floor kept advancing while selecting live outcomes".into(),
2399 ))
2400 }
2401
2402 async fn audited_outcomes(&self) -> Result<Vec<CheckpointOutcome>, DecisionError> {
2404 const INVENTORY_RETRIES: usize = 3;
2405 for attempt in 0..INVENTORY_RETRIES {
2406 match self.outcomes_once().await {
2407 Err(DecisionError::InventoryChanged(_)) if attempt + 1 < INVENTORY_RETRIES => {
2408 tokio::task::yield_now().await;
2409 }
2410 result => return result,
2411 }
2412 }
2413 Err(DecisionError::InventoryChanged(
2414 "checkpoint outcome inventory exhausted stability retries".into(),
2415 ))
2416 }
2417
2418 async fn outcomes_once(&self) -> Result<Vec<CheckpointOutcome>, DecisionError> {
2419 let floor_before = self.current_outcome_gc_floor().await?;
2420 let min_epoch = floor_before.as_ref().map_or(0, |floor| floor.before_epoch);
2421 let epochs = self
2422 .list_outcome_epochs()
2423 .await?
2424 .into_iter()
2425 .filter(|epoch| *epoch >= min_epoch);
2426
2427 let mut outcomes = Vec::new();
2428 if let Some(anchor) = floor_before
2429 .as_ref()
2430 .and_then(|floor| floor.committed_anchor.as_ref())
2431 {
2432 outcomes.push(anchor.clone());
2433 }
2434 if let Some(anchor) = floor_before
2435 .as_ref()
2436 .and_then(|floor| floor.terminal_anchor.as_ref())
2437 {
2438 if outcomes.last() != Some(anchor) {
2439 outcomes.push(anchor.clone());
2440 }
2441 }
2442 for epoch in epochs {
2443 let outcome = self
2444 .read_outcome_record(&Self::outcome_path(epoch), epoch)
2445 .await?
2446 .ok_or_else(|| {
2447 DecisionError::InventoryChanged(format!(
2448 "checkpoint outcome for epoch {epoch} disappeared during inventory"
2449 ))
2450 })?;
2451 outcomes.push(outcome);
2452 }
2453 let floor_after = self.current_outcome_gc_floor().await?;
2454 if floor_before != floor_after {
2455 return Err(DecisionError::InventoryChanged(
2456 "outcome GC floor advanced during outcome inventory".into(),
2457 ));
2458 }
2459 for pair in outcomes.windows(2) {
2460 let previous = &pair[0];
2461 let current = &pair[1];
2462 if current.epoch <= previous.epoch || current.checkpoint_id <= previous.checkpoint_id {
2463 return Err(DecisionError::Conflict(format!(
2464 "checkpoint outcomes regress from epoch {} checkpoint {} to epoch {} checkpoint {}",
2465 previous.epoch,
2466 previous.checkpoint_id,
2467 current.epoch,
2468 current.checkpoint_id
2469 )));
2470 }
2471 }
2472 Ok(outcomes)
2473 }
2474
2475 pub async fn highest_terminal_outcome(
2481 &self,
2482 ) -> Result<Option<CheckpointOutcome>, DecisionError> {
2483 Ok(self.audited_outcomes().await?.pop())
2484 }
2485
2486 pub async fn outcome_gc_floor_horizon(&self) -> Result<u64, DecisionError> {
2491 Ok(self
2492 .current_outcome_gc_floor()
2493 .await?
2494 .map_or(0, |floor| floor.before_epoch))
2495 }
2496
2497 pub async fn outcome_retention_boundary(
2505 &self,
2506 ) -> Result<OutcomeRetentionBoundary, DecisionError> {
2507 let floor = self.current_outcome_gc_floor().await?;
2508 Ok(floor.map_or(
2509 OutcomeRetentionBoundary {
2510 before_epoch: 0,
2511 committed_checkpoint_id: None,
2512 highest_closed_epoch: None,
2513 },
2514 |floor| OutcomeRetentionBoundary {
2515 before_epoch: floor.before_epoch,
2516 committed_checkpoint_id: floor.committed_anchor.map(|anchor| anchor.checkpoint_id),
2517 highest_closed_epoch: floor.terminal_anchor.map(|anchor| anchor.epoch),
2518 },
2519 ))
2520 }
2521
2522 #[cfg(feature = "cluster")]
2523 async fn quarantine_recovery_capsule_object(
2524 &self,
2525 location: &OsPath,
2526 ) -> Result<(), DecisionError> {
2527 let quarantine = Self::recovery_capsule_quarantine_path(location.as_ref());
2528 match self.store.rename(location, &quarantine).await {
2529 Ok(()) => Ok(()),
2530 Err(error) => match self.store.head(location).await {
2531 Err(object_store::Error::NotFound { .. }) => Ok(()),
2532 Ok(_) => Err(DecisionError::Io(format!(
2533 "failed to quarantine recovery capsule '{location}': {error}"
2534 ))),
2535 Err(head_error) => Err(DecisionError::Io(format!(
2536 "failed to quarantine recovery capsule '{location}' ({error}); reconciliation failed ({head_error})"
2537 ))),
2538 },
2539 }
2540 }
2541
2542 #[cfg(feature = "cluster")]
2547 pub(crate) async fn sweep_recovery_capsules_step(
2548 &self,
2549 before_epoch: u64,
2550 known_live_digests: &std::collections::BTreeSet<String>,
2551 ) -> Result<RecoveryCapsuleGcStep, DecisionError> {
2552 if before_epoch <= 1 {
2553 return Ok(RecoveryCapsuleGcStep {
2554 examined: 0,
2555 deleted: 0,
2556 quarantined: 0,
2557 pending: false,
2558 });
2559 }
2560
2561 let deployment_id = self.load_or_create_deployment_id().await?;
2562 let observed = self.read_recovery_capsule_gc_cursor(&deployment_id).await?;
2563 let (cursor, update_version) = observed.map_or_else(
2564 || {
2565 (
2566 RecoveryCapsuleGcCursor {
2567 version: RECOVERY_CAPSULE_GC_CURSOR_VERSION,
2568 deployment_id: deployment_id.clone(),
2569 offset: None,
2570 },
2571 None,
2572 )
2573 },
2574 |observed| (observed.cursor, Some(observed.update_version)),
2575 );
2576
2577 let root = Self::recovery_capsule_root();
2578 let mut listed = self.store.list(Some(&root));
2579 let mut oldest = BinaryHeap::with_capacity(RECOVERY_CAPSULE_GC_BATCH_SIZE);
2580 let mut eligible = 0usize;
2581 while let Some(entry) = listed.next().await {
2582 let entry = entry.map_err(|error| DecisionError::Io(error.to_string()))?;
2583 if cursor
2584 .offset
2585 .as_deref()
2586 .is_some_and(|offset| entry.location.as_ref() <= offset)
2587 {
2588 continue;
2589 }
2590 eligible = eligible.saturating_add(1);
2591 retain_lexically_oldest_recovery_capsule(&mut oldest, entry);
2592 }
2593 let list_exhausted = eligible <= RECOVERY_CAPSULE_GC_BATCH_SIZE;
2594 let entries = oldest
2595 .into_sorted_vec()
2596 .into_iter()
2597 .map(|candidate| candidate.meta)
2598 .collect::<Vec<_>>();
2599 let examined = entries.len();
2600
2601 let work = futures::stream::iter(entries.iter().cloned())
2602 .map(|entry| async move {
2603 let Some((epoch, checkpoint_id, digest)) =
2604 Self::recovery_capsule_coordinates_from_path(entry.location.as_ref())
2605 else {
2606 return match self
2607 .quarantine_recovery_capsule_object(&entry.location)
2608 .await
2609 {
2610 Ok(()) => RecoveryCapsuleObjectWork::Quarantined,
2611 Err(error) => {
2612 tracing::warn!(path = %entry.location, %error, "recovery capsule quarantine failed; retrying on the next scan pass");
2613 RecoveryCapsuleObjectWork::Failed
2614 }
2615 };
2616 };
2617 if epoch >= before_epoch {
2618 return RecoveryCapsuleObjectWork::Retained;
2619 }
2620 let reference = RecoveryCapsuleRef {
2621 epoch,
2622 checkpoint_id,
2623 sha256: digest.to_owned(),
2624 len: entry.size,
2625 };
2626 if reference.validate().is_err() {
2627 return match self
2628 .quarantine_recovery_capsule_object(&entry.location)
2629 .await
2630 {
2631 Ok(()) => RecoveryCapsuleObjectWork::Quarantined,
2632 Err(error) => {
2633 tracing::warn!(path = %entry.location, %error, "recovery capsule quarantine failed; retrying on the next scan pass");
2634 RecoveryCapsuleObjectWork::Failed
2635 }
2636 };
2637 }
2638 if known_live_digests.contains(&reference.sha256) {
2639 return RecoveryCapsuleObjectWork::Retained;
2640 }
2641 match self.load_recovery_capsule(&reference).await {
2642 Ok(_) => match self.store.delete(&entry.location).await {
2643 Ok(()) | Err(object_store::Error::NotFound { .. }) => {
2644 RecoveryCapsuleObjectWork::Deleted
2645 }
2646 Err(error) => {
2647 tracing::warn!(epoch, checkpoint_id, %error, "recovery capsule delete failed; retrying on the next scan pass");
2648 RecoveryCapsuleObjectWork::Failed
2649 }
2650 },
2651 Err(DecisionError::Conflict(error)) => {
2652 if matches!(
2653 self.store.head(&entry.location).await,
2654 Err(object_store::Error::NotFound { .. })
2655 ) {
2656 return RecoveryCapsuleObjectWork::Deleted;
2657 }
2658 match self
2659 .quarantine_recovery_capsule_object(&entry.location)
2660 .await
2661 {
2662 Ok(()) => {
2663 tracing::warn!(path = %entry.location, %error, "corrupt recovery capsule quarantined");
2664 RecoveryCapsuleObjectWork::Quarantined
2665 }
2666 Err(quarantine_error) => {
2667 tracing::warn!(path = %entry.location, %error, %quarantine_error, "corrupt recovery capsule quarantine failed; retrying on the next scan pass");
2668 RecoveryCapsuleObjectWork::Failed
2669 }
2670 }
2671 }
2672 Err(error) => {
2673 tracing::warn!(path = %entry.location, %error, "recovery capsule read failed; retrying on the next scan pass");
2674 RecoveryCapsuleObjectWork::Failed
2675 }
2676 }
2677 })
2678 .buffer_unordered(4)
2679 .collect::<Vec<_>>()
2680 .await;
2681
2682 let deleted = work
2683 .iter()
2684 .filter(|result| matches!(result, RecoveryCapsuleObjectWork::Deleted))
2685 .count();
2686 let quarantined = work
2687 .iter()
2688 .filter(|result| matches!(result, RecoveryCapsuleObjectWork::Quarantined))
2689 .count();
2690
2691 let mut updated = cursor.clone();
2692 updated.offset = if list_exhausted {
2693 None
2694 } else {
2695 entries.iter().map(|entry| entry.location.to_string()).max()
2696 };
2697 let cursor_was_absent = update_version.is_none();
2698 if updated != cursor
2699 && !self
2700 .compare_and_swap_recovery_capsule_gc_cursor(&updated, update_version)
2701 .await?
2702 {
2703 return Ok(RecoveryCapsuleGcStep {
2704 examined,
2705 deleted,
2706 quarantined,
2707 pending: true,
2708 });
2709 }
2710 if cursor_was_absent && updated == cursor {
2711 let _ = self
2712 .compare_and_swap_recovery_capsule_gc_cursor(&updated, None)
2713 .await?;
2714 }
2715
2716 Ok(RecoveryCapsuleGcStep {
2717 examined,
2718 deleted,
2719 quarantined,
2720 pending: true,
2723 })
2724 }
2725
2726 pub async fn prune_outcomes_before(&self, before: u64) -> Result<u64, DecisionError> {
2738 if before == 0 {
2739 return self.outcome_gc_floor_horizon().await;
2740 }
2741 let deployment_id = self.load_or_create_deployment_id().await?;
2742
2743 loop {
2747 let observed = self.read_outcome_gc_floor(&deployment_id).await?;
2748 if observed
2749 .as_ref()
2750 .is_some_and(|versioned| versioned.floor.before_epoch >= before)
2751 {
2752 break;
2756 }
2757
2758 let outcomes = match self.audited_outcomes().await {
2759 Err(DecisionError::InventoryChanged(_)) => {
2760 tokio::task::yield_now().await;
2761 continue;
2762 }
2763 result => result?,
2764 };
2765 if !outcomes
2766 .iter()
2767 .any(|outcome| outcome.epoch >= before && outcome.is_commit())
2768 {
2769 return Err(DecisionError::Conflict(format!(
2770 "cannot advance outcome GC floor to {before}: no live commit recovery cut would remain"
2771 )));
2772 }
2773 let floor = OutcomeGcFloor {
2774 version: OUTCOME_GC_FLOOR_VERSION,
2775 deployment_id: deployment_id.clone(),
2776 before_epoch: before,
2777 terminal_anchor: outcomes
2778 .iter()
2779 .rev()
2780 .find(|outcome| outcome.epoch < before)
2781 .cloned(),
2782 committed_anchor: outcomes
2783 .iter()
2784 .rev()
2785 .find(|outcome| outcome.epoch < before && outcome.is_commit())
2786 .cloned(),
2787 };
2788 let expected = observed.map(|versioned| versioned.update_version);
2789 if self
2790 .compare_and_swap_outcome_gc_floor(&floor, expected)
2791 .await?
2792 {
2793 break;
2794 }
2795 tokio::task::yield_now().await;
2796 }
2797
2798 let mut swept_horizon = 0;
2799 loop {
2800 let effective = self.current_outcome_gc_floor().await?.ok_or_else(|| {
2801 DecisionError::InventoryChanged(
2802 "outcome GC floor disappeared immediately after publication".into(),
2803 )
2804 })?;
2805 if effective.before_epoch < before || effective.before_epoch < swept_horizon {
2806 return Err(DecisionError::InventoryChanged(format!(
2807 "outcome GC floor regressed to {} after publishing {before}",
2808 effective.before_epoch
2809 )));
2810 }
2811 if effective.before_epoch > swept_horizon {
2812 let raw_epochs = self.list_outcome_epochs().await?;
2813 for epoch in raw_epochs
2814 .into_iter()
2815 .filter(|epoch| *epoch < effective.before_epoch)
2816 {
2817 match self.store.delete(&Self::outcome_path(epoch)).await {
2818 Ok(()) | Err(object_store::Error::NotFound { .. }) => {}
2819 Err(error) => tracing::warn!(
2820 epoch,
2821 %error,
2822 "outcome prune: tombstoned outcome delete failed"
2823 ),
2824 }
2825 }
2826 swept_horizon = effective.before_epoch;
2827 }
2828 let current = self.current_outcome_gc_floor().await?.ok_or_else(|| {
2829 DecisionError::InventoryChanged(
2830 "outcome GC floor disappeared during best-effort sweep".into(),
2831 )
2832 })?;
2833 if current.before_epoch == swept_horizon {
2834 return Ok(swept_horizon);
2835 }
2836 tokio::task::yield_now().await;
2837 }
2838 }
2839}
2840
2841#[cfg(test)]
2842mod tests;