Skip to main content

laminar_core/checkpoint_decision/
outcome.rs

1//! Terminal outcome and artifact-inventory publication.
2
3use super::{
4    CheckpointArtifactInventory, CheckpointArtifactInventoryUpdateResult,
5    CheckpointAssignmentFence, CheckpointAttempt, CheckpointDecisionHead, CheckpointDecisionStore,
6    CheckpointOutcome, CheckpointScope, CheckpointVerdict, CommittedCheckpointIndex,
7    CommittedCheckpointRef, DecisionError, DecisionHeadCasResult, DecisionStoreUpdateMode,
8    DurableCheckpointDecisionHead, LeaderProof, ObjectStore, PutMode, PutOptions, PutPayload,
9    RecordOutcomeResult, UpdateVersion, VersionedCheckpointDecisionHead,
10    CHECKPOINT_DECISION_HEAD_MAX_BYTES, CHECKPOINT_DECISION_HEAD_VERSION,
11    CHECKPOINT_OUTCOME_VERSION,
12};
13
14impl CheckpointDecisionStore {
15    pub(super) fn validate_decision_head_shape(
16        head: &DurableCheckpointDecisionHead,
17        deployment_id: &str,
18    ) -> Result<(), DecisionError> {
19        if head.version != CHECKPOINT_DECISION_HEAD_VERSION || head.deployment_id != deployment_id {
20            return Err(DecisionError::Conflict(
21                "checkpoint decision head has a foreign deployment or unsupported version".into(),
22            ));
23        }
24        if head.latest_terminal.is_none() && head.active_artifacts.is_none() {
25            return Err(DecisionError::Conflict(
26                "checkpoint decision head has neither a terminal outcome nor active artifacts"
27                    .into(),
28            ));
29        }
30        if let Some(terminal) = head.latest_terminal.as_ref() {
31            terminal.validate_shape(terminal.epoch)?;
32            if terminal.scope != CheckpointScope::Local || terminal.deployment_id != deployment_id {
33                return Err(DecisionError::Conflict(
34                    "checkpoint decision head contains a non-local terminal outcome".into(),
35                ));
36            }
37        }
38        match head.latest_commit.as_ref() {
39            Some(commit) => {
40                commit.validate_shape(commit.epoch)?;
41                let terminal = head.latest_terminal.as_ref().ok_or_else(|| {
42                    DecisionError::Conflict(
43                        "checkpoint decision head has a Commit but no terminal outcome".into(),
44                    )
45                })?;
46                if commit.scope != CheckpointScope::Local
47                    || commit.deployment_id != deployment_id
48                    || !commit.is_commit()
49                    || commit.epoch > terminal.epoch
50                    || (!terminal.is_commit() && commit.epoch == terminal.epoch)
51                {
52                    return Err(DecisionError::Conflict(
53                        "checkpoint decision head contains an invalid latest Commit".into(),
54                    ));
55                }
56                if terminal.is_commit() && commit != terminal {
57                    return Err(DecisionError::Conflict(
58                        "terminal Commit does not match the decision head's latest Commit".into(),
59                    ));
60                }
61            }
62            None if head
63                .latest_terminal
64                .as_ref()
65                .is_some_and(CheckpointOutcome::is_commit) =>
66            {
67                return Err(DecisionError::Conflict(
68                    "checkpoint decision head lost its terminal Commit".into(),
69                ));
70            }
71            None => {}
72        }
73        if let Some(inventory) = head.active_artifacts.as_ref() {
74            inventory.validate().map_err(|error| {
75                DecisionError::Conflict(format!(
76                    "checkpoint decision head contains invalid active artifacts: {error}"
77                ))
78            })?;
79            if inventory.deployment_id != deployment_id || inventory.assignment_fence.is_some() {
80                return Err(DecisionError::Conflict(
81                    "local checkpoint decision head contains foreign or cluster artifacts".into(),
82                ));
83            }
84            if let Some(terminal) = head.latest_terminal.as_ref() {
85                if inventory.attempt.epoch < terminal.epoch
86                    || (inventory.attempt.epoch == terminal.epoch
87                        && (terminal.is_commit()
88                            || inventory.attempt.checkpoint_id != terminal.checkpoint_id))
89                {
90                    return Err(DecisionError::Conflict(
91                        "active checkpoint artifacts conflict with the latest terminal outcome"
92                            .into(),
93                    ));
94                }
95            }
96        }
97        Ok(())
98    }
99
100    pub(super) async fn read_decision_head(
101        &self,
102        deployment_id: &str,
103    ) -> Result<Option<VersionedCheckpointDecisionHead>, DecisionError> {
104        let path = Self::decision_head_path(deployment_id);
105        let Some(result) = self
106            .get_control_record(
107                &path,
108                "checkpoint decision head",
109                CHECKPOINT_DECISION_HEAD_MAX_BYTES,
110            )
111            .await?
112        else {
113            return Ok(None);
114        };
115        let update_version = UpdateVersion {
116            e_tag: result.meta.e_tag.clone(),
117            version: result.meta.version.clone(),
118        };
119        self.require_native_cas_token("checkpoint decision head", &update_version)?;
120        let bytes = Self::read_control_record_bytes(
121            result,
122            "checkpoint decision head",
123            CHECKPOINT_DECISION_HEAD_MAX_BYTES,
124            None,
125        )
126        .await?;
127        let head: DurableCheckpointDecisionHead =
128            serde_json::from_slice(&bytes).map_err(|error| {
129                DecisionError::Conflict(format!("checkpoint decision head: {error}"))
130            })?;
131        Self::validate_decision_head_shape(&head, deployment_id)?;
132        let canonical = serde_json::to_vec(&head)
133            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
134        if canonical.as_slice() != bytes.as_ref() {
135            return Err(DecisionError::Conflict(
136                "checkpoint decision head does not use its canonical body".into(),
137            ));
138        }
139        Ok(Some(VersionedCheckpointDecisionHead {
140            head,
141            update_version,
142        }))
143    }
144
145    async fn put_decision_head(
146        &self,
147        observed: Option<VersionedCheckpointDecisionHead>,
148        candidate: DurableCheckpointDecisionHead,
149    ) -> Result<DecisionHeadCasResult, DecisionError> {
150        Self::validate_decision_head_shape(&candidate, &candidate.deployment_id)?;
151        let mode = match (self.update_mode, observed.as_ref()) {
152            (_, None) => PutMode::Create,
153            (DecisionStoreUpdateMode::NativeCas, Some(current)) => {
154                PutMode::Update(current.update_version.clone())
155            }
156            (DecisionStoreUpdateMode::LocalSingleWriter, Some(_)) => PutMode::Overwrite,
157        };
158        let payload = Self::encode_control_record(
159            "checkpoint decision head",
160            &candidate,
161            CHECKPOINT_DECISION_HEAD_MAX_BYTES,
162        )?;
163        let result = self
164            .store
165            .put_opts(
166                &Self::decision_head_path(&candidate.deployment_id),
167                PutPayload::from(payload),
168                PutOptions {
169                    mode,
170                    ..PutOptions::default()
171                },
172            )
173            .await
174            .map(|_| ());
175        if result.is_ok() {
176            return Ok(DecisionHeadCasResult::Applied);
177        }
178
179        let winner = self.read_decision_head(&candidate.deployment_id).await?;
180        if winner
181            .as_ref()
182            .is_some_and(|winner| winner.head == candidate)
183        {
184            return Ok(DecisionHeadCasResult::Unchanged);
185        }
186        let changed = winner.as_ref().map(|winner| &winner.head)
187            != observed.as_ref().map(|current| &current.head);
188        let error = result.expect_err("failed decision-head write has an error");
189        if changed
190            || matches!(
191                error,
192                object_store::Error::Precondition { .. }
193                    | object_store::Error::AlreadyExists { .. }
194                    | object_store::Error::NotFound { .. }
195            )
196        {
197            return Ok(DecisionHeadCasResult::Conflict(
198                winner.map(|winner| Box::new(winner.head)),
199            ));
200        }
201        Err(DecisionError::Io(error.to_string()))
202    }
203
204    fn terminal_aborts_inventory(
205        head: &DurableCheckpointDecisionHead,
206        inventory: &CheckpointArtifactInventory,
207    ) -> bool {
208        head.latest_terminal.as_ref().is_some_and(|terminal| {
209            !terminal.is_commit()
210                && terminal.epoch == inventory.attempt.epoch
211                && terminal.checkpoint_id == inventory.attempt.checkpoint_id
212        })
213    }
214
215    async fn begin_checkpoint_artifact_inventory_inner(
216        &self,
217        inventory: CheckpointArtifactInventory,
218    ) -> Result<CheckpointArtifactInventoryUpdateResult, DecisionError> {
219        inventory.validate().map_err(DecisionError::Conflict)?;
220        if inventory.assignment_fence.is_some() {
221            return Err(DecisionError::Conflict(
222                "local checkpoint artifact inventory cannot carry an assignment fence".into(),
223            ));
224        }
225        let deployment_id = self.load_or_create_deployment_id().await?;
226        if inventory.deployment_id != deployment_id {
227            return Err(DecisionError::Conflict(
228                "checkpoint artifact inventory belongs to a foreign deployment".into(),
229            ));
230        }
231        let observed = self.read_decision_head(&deployment_id).await?;
232        if let Some(active) = observed
233            .as_ref()
234            .and_then(|current| current.head.active_artifacts.as_ref())
235        {
236            return Ok(
237                if active == &inventory
238                    && !observed.as_ref().is_some_and(|current| {
239                        Self::terminal_aborts_inventory(&current.head, &inventory)
240                    })
241                {
242                    CheckpointArtifactInventoryUpdateResult::Unchanged
243                } else {
244                    CheckpointArtifactInventoryUpdateResult::Conflict {
245                        current: Some(active.clone()),
246                    }
247                },
248            );
249        }
250        if observed
251            .as_ref()
252            .and_then(|current| current.head.latest_terminal.as_ref())
253            .is_some_and(|terminal| terminal.epoch >= inventory.attempt.epoch)
254        {
255            return Ok(CheckpointArtifactInventoryUpdateResult::Conflict { current: None });
256        }
257
258        let candidate = DurableCheckpointDecisionHead {
259            version: CHECKPOINT_DECISION_HEAD_VERSION,
260            deployment_id,
261            latest_terminal: observed
262                .as_ref()
263                .and_then(|current| current.head.latest_terminal.clone()),
264            latest_commit: observed
265                .as_ref()
266                .and_then(|current| current.head.latest_commit.clone()),
267            active_artifacts: Some(inventory.clone()),
268        };
269        match self.put_decision_head(observed, candidate).await? {
270            DecisionHeadCasResult::Applied => Ok(CheckpointArtifactInventoryUpdateResult::Applied),
271            DecisionHeadCasResult::Unchanged => {
272                Ok(CheckpointArtifactInventoryUpdateResult::Unchanged)
273            }
274            DecisionHeadCasResult::Conflict(current) => {
275                let exact_unaborted = current.as_ref().is_some_and(|head| {
276                    head.active_artifacts.as_ref() == Some(&inventory)
277                        && !Self::terminal_aborts_inventory(head, &inventory)
278                });
279                let active = current.and_then(|head| head.active_artifacts);
280                if exact_unaborted {
281                    Ok(CheckpointArtifactInventoryUpdateResult::Unchanged)
282                } else {
283                    Ok(CheckpointArtifactInventoryUpdateResult::Conflict { current: active })
284                }
285            }
286        }
287    }
288
289    /// Durably admit one exact local attempt before any checkpoint artifact is written.
290    ///
291    /// Equal retries converge. A different active attempt or a reused terminal attempt conflicts.
292    ///
293    /// # Errors
294    /// Object-store I/O or a malformed, cluster, or foreign-deployment inventory.
295    pub async fn begin_checkpoint_artifact_inventory(
296        &self,
297        inventory: CheckpointArtifactInventory,
298    ) -> Result<CheckpointArtifactInventoryUpdateResult, DecisionError> {
299        if self.update_mode == DecisionStoreUpdateMode::LocalSingleWriter {
300            let lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
301                DecisionError::Conflict(
302                    "local decision store is missing its namespace write lock".into(),
303                )
304            })?;
305            let _guard = lock.lock().await;
306            self.begin_checkpoint_artifact_inventory_inner(inventory)
307                .await
308        } else {
309            self.begin_checkpoint_artifact_inventory_inner(inventory)
310                .await
311        }
312    }
313
314    async fn complete_checkpoint_artifact_cleanup_inner(
315        &self,
316        expected: &CheckpointArtifactInventory,
317    ) -> Result<CheckpointArtifactInventoryUpdateResult, DecisionError> {
318        expected.validate().map_err(DecisionError::Conflict)?;
319        if expected.assignment_fence.is_some() {
320            return Err(DecisionError::Conflict(
321                "local checkpoint artifact inventory cannot carry an assignment fence".into(),
322            ));
323        }
324        let deployment_id = self.load_or_create_deployment_id().await?;
325        if expected.deployment_id != deployment_id {
326            return Err(DecisionError::Conflict(
327                "checkpoint artifact inventory belongs to a foreign deployment".into(),
328            ));
329        }
330        let observed = self.read_decision_head(&deployment_id).await?;
331        let Some(current) = observed.as_ref() else {
332            return Ok(CheckpointArtifactInventoryUpdateResult::Conflict { current: None });
333        };
334        match current.head.active_artifacts.as_ref() {
335            Some(active) if active != expected => {
336                return Ok(CheckpointArtifactInventoryUpdateResult::Conflict {
337                    current: Some(active.clone()),
338                });
339            }
340            None if Self::terminal_aborts_inventory(&current.head, expected) => {
341                return Ok(CheckpointArtifactInventoryUpdateResult::Unchanged);
342            }
343            None => {
344                return Ok(CheckpointArtifactInventoryUpdateResult::Conflict { current: None });
345            }
346            Some(_) => {}
347        }
348        if !Self::terminal_aborts_inventory(&current.head, expected) {
349            return Ok(CheckpointArtifactInventoryUpdateResult::Conflict {
350                current: Some(expected.clone()),
351            });
352        }
353
354        let candidate = DurableCheckpointDecisionHead {
355            version: CHECKPOINT_DECISION_HEAD_VERSION,
356            deployment_id,
357            latest_terminal: current.head.latest_terminal.clone(),
358            latest_commit: current.head.latest_commit.clone(),
359            active_artifacts: None,
360        };
361        match self.put_decision_head(observed, candidate).await? {
362            DecisionHeadCasResult::Applied => Ok(CheckpointArtifactInventoryUpdateResult::Applied),
363            DecisionHeadCasResult::Unchanged => {
364                Ok(CheckpointArtifactInventoryUpdateResult::Unchanged)
365            }
366            DecisionHeadCasResult::Conflict(current) => {
367                let active = current
368                    .as_ref()
369                    .and_then(|head| head.active_artifacts.clone());
370                if active.is_none()
371                    && current
372                        .as_ref()
373                        .is_some_and(|head| Self::terminal_aborts_inventory(head, expected))
374                {
375                    Ok(CheckpointArtifactInventoryUpdateResult::Unchanged)
376                } else {
377                    Ok(CheckpointArtifactInventoryUpdateResult::Conflict { current: active })
378                }
379            }
380        }
381    }
382
383    /// Clear an exact local artifact inventory after its durable Abort paths are sealed.
384    ///
385    /// # Errors
386    /// Object-store I/O or a malformed, cluster, or foreign-deployment inventory.
387    pub async fn complete_checkpoint_artifact_cleanup(
388        &self,
389        expected: &CheckpointArtifactInventory,
390    ) -> Result<CheckpointArtifactInventoryUpdateResult, DecisionError> {
391        if self.update_mode == DecisionStoreUpdateMode::LocalSingleWriter {
392            let lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
393                DecisionError::Conflict(
394                    "local decision store is missing its namespace write lock".into(),
395                )
396            })?;
397            let _guard = lock.lock().await;
398            self.complete_checkpoint_artifact_cleanup_inner(expected)
399                .await
400        } else {
401            self.complete_checkpoint_artifact_cleanup_inner(expected)
402                .await
403        }
404    }
405
406    pub(crate) async fn canonical_outcome_with_index(
407        &self,
408        epoch: u64,
409        checkpoint_id: u64,
410        scope: CheckpointScope,
411        assignment_fence: Option<CheckpointAssignmentFence>,
412        leader_proof: Option<LeaderProof>,
413        verdict: CheckpointVerdict,
414        committed_checkpoint: Option<CommittedCheckpointRef>,
415    ) -> Result<(CheckpointOutcome, Option<CommittedCheckpointIndex>), DecisionError> {
416        let outcome = CheckpointOutcome {
417            version: CHECKPOINT_OUTCOME_VERSION,
418            scope,
419            epoch,
420            checkpoint_id,
421            deployment_id: self.load_or_create_deployment_id().await?,
422            assignment_fence,
423            leader_proof,
424            committed_checkpoint,
425            verdict,
426        };
427        outcome.validate_shape(epoch)?;
428        let index = if outcome.is_commit() {
429            Some(
430                self.validate_committed_checkpoint_for_outcome(&outcome)
431                    .await?,
432            )
433        } else {
434            None
435        };
436        Ok((outcome, index))
437    }
438
439    async fn record_outcome_inner(
440        &self,
441        candidate: CheckpointOutcome,
442        committed_index: Option<CommittedCheckpointIndex>,
443    ) -> Result<RecordOutcomeResult, DecisionError> {
444        let observed = self.read_decision_head(&candidate.deployment_id).await?;
445        if let Some(terminal) = observed
446            .as_ref()
447            .and_then(|current| current.head.latest_terminal.as_ref())
448        {
449            if terminal.epoch == candidate.epoch {
450                return if terminal == &candidate {
451                    Ok(RecordOutcomeResult::Unchanged(candidate))
452                } else {
453                    Ok(RecordOutcomeResult::Conflict {
454                        winner: terminal.clone(),
455                    })
456                };
457            }
458            if terminal.epoch > candidate.epoch {
459                return Ok(RecordOutcomeResult::Conflict {
460                    winner: terminal.clone(),
461                });
462            }
463        }
464
465        let active = observed
466            .as_ref()
467            .and_then(|current| current.head.active_artifacts.as_ref());
468        let candidate_attempt = CheckpointAttempt::new(candidate.epoch, candidate.checkpoint_id);
469        if active.is_some_and(|active| active.attempt != candidate_attempt) {
470            return Err(DecisionError::Conflict(format!(
471                "checkpoint outcome attempt {} does not match the active artifact inventory",
472                candidate.checkpoint_id
473            )));
474        }
475
476        if candidate.is_commit() {
477            let active = active.ok_or_else(|| {
478                DecisionError::Conflict(format!(
479                    "checkpoint Commit {} has no durable artifact inventory",
480                    candidate.checkpoint_id
481                ))
482            })?;
483            let index = committed_index.as_ref().ok_or_else(|| {
484                DecisionError::Conflict("Commit has no validated committed checkpoint".into())
485            })?;
486            if index.pipeline_identity != active.pipeline_identity
487                || index.assignment_fence != active.assignment_fence
488            {
489                return Err(DecisionError::Conflict(
490                    "Commit metadata does not match the active checkpoint artifact inventory"
491                        .into(),
492                ));
493            }
494            let expected_predecessor = observed.as_ref().and_then(|current| {
495                current
496                    .head
497                    .latest_commit
498                    .as_ref()
499                    .and_then(|commit| commit.committed_checkpoint.clone())
500            });
501            if index.predecessor != expected_predecessor {
502                return Err(DecisionError::Conflict(format!(
503                    "Commit epoch {} does not extend the authoritative committed checkpoint",
504                    candidate.epoch
505                )));
506            }
507            if let Some(predecessor_ref) = expected_predecessor.as_ref() {
508                let predecessor = self.load_committed_checkpoint(predecessor_ref).await?;
509                index
510                    .validate_predecessor_index(&predecessor)
511                    .map_err(DecisionError::Conflict)?;
512            }
513        }
514
515        let head = DurableCheckpointDecisionHead {
516            version: CHECKPOINT_DECISION_HEAD_VERSION,
517            deployment_id: candidate.deployment_id.clone(),
518            latest_commit: if candidate.is_commit() {
519                Some(candidate.clone())
520            } else {
521                observed
522                    .as_ref()
523                    .and_then(|current| current.head.latest_commit.clone())
524            },
525            latest_terminal: Some(candidate.clone()),
526            active_artifacts: if candidate.is_commit() {
527                None
528            } else {
529                active.cloned()
530            },
531        };
532        match self.put_decision_head(observed, head).await? {
533            DecisionHeadCasResult::Applied => Ok(RecordOutcomeResult::Created(candidate)),
534            DecisionHeadCasResult::Unchanged => Ok(RecordOutcomeResult::Unchanged(candidate)),
535            DecisionHeadCasResult::Conflict(winner) => {
536                if let Some(terminal) = winner.and_then(|head| head.latest_terminal) {
537                    if terminal.epoch >= candidate.epoch {
538                        return if terminal == candidate {
539                            Ok(RecordOutcomeResult::Unchanged(candidate))
540                        } else {
541                            Ok(RecordOutcomeResult::Conflict { winner: terminal })
542                        };
543                    }
544                }
545                Err(DecisionError::Conflict(format!(
546                    "checkpoint decision head contention did not publish epoch {}",
547                    candidate.epoch
548                )))
549            }
550        }
551    }
552
553    /// Publish the authoritative local terminal outcome.
554    ///
555    /// The singleton CAS is the decision: a crash before it leaves the attempt unresolved, while a
556    /// crash after it leaves both the latest terminal and latest Commit directly recoverable.
557    /// Equal retries converge; stale epochs and conflicting outcomes return the durable winner.
558    ///
559    /// # Errors
560    /// Object-store I/O, malformed metadata, a forked Commit predecessor, or cluster authority.
561    pub async fn record_outcome(
562        &self,
563        epoch: u64,
564        checkpoint_id: u64,
565        scope: CheckpointScope,
566        assignment_fence: Option<CheckpointAssignmentFence>,
567        leader_proof: Option<LeaderProof>,
568        verdict: CheckpointVerdict,
569        committed_checkpoint: Option<CommittedCheckpointRef>,
570    ) -> Result<RecordOutcomeResult, DecisionError> {
571        if scope == CheckpointScope::Cluster {
572            return Err(DecisionError::Conflict(
573                "cluster outcomes must be admitted through the shared leader authority".into(),
574            ));
575        }
576        let (candidate, committed_index) = self
577            .canonical_outcome_with_index(
578                epoch,
579                checkpoint_id,
580                scope,
581                assignment_fence,
582                leader_proof,
583                verdict,
584                committed_checkpoint,
585            )
586            .await?;
587        if self.update_mode == DecisionStoreUpdateMode::LocalSingleWriter {
588            let lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
589                DecisionError::Conflict(
590                    "local decision store is missing its namespace write lock".into(),
591                )
592            })?;
593            let _guard = lock.lock().await;
594            self.record_outcome_inner(candidate, committed_index).await
595        } else {
596            self.record_outcome_inner(candidate, committed_index).await
597        }
598    }
599
600    /// Read the exact authoritative local decision head without listing storage.
601    ///
602    /// # Errors
603    /// Object-store I/O or malformed/foreign head metadata.
604    pub async fn checkpoint_decision_head(
605        &self,
606    ) -> Result<Option<CheckpointDecisionHead>, DecisionError> {
607        let deployment_id = self.load_or_create_deployment_id().await?;
608        Ok(self
609            .read_decision_head(&deployment_id)
610            .await?
611            .map(|versioned| CheckpointDecisionHead {
612                latest_terminal: versioned.head.latest_terminal,
613                latest_commit: versioned.head.latest_commit,
614                active_artifacts: versioned.head.active_artifacts,
615            }))
616    }
617
618    /// Read the latest authoritative local terminal outcome without listing storage.
619    ///
620    /// # Errors
621    /// Object-store I/O or malformed/foreign head metadata.
622    pub async fn latest_terminal_outcome(
623        &self,
624    ) -> Result<Option<CheckpointOutcome>, DecisionError> {
625        Ok(self
626            .checkpoint_decision_head()
627            .await?
628            .and_then(|head| head.latest_terminal))
629    }
630
631    /// Read the latest authoritative local Commit without listing storage.
632    ///
633    /// # Errors
634    /// Object-store I/O or malformed/foreign head metadata.
635    pub async fn latest_committed_outcome(
636        &self,
637    ) -> Result<Option<CheckpointOutcome>, DecisionError> {
638        Ok(self
639            .checkpoint_decision_head()
640            .await?
641            .and_then(|head| head.latest_commit))
642    }
643}