1use 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| ¤t.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(¤t.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 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(¤t.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(¤t.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 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 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 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 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 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}