1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
6use std::sync::Arc;
7
8use bytes::Bytes;
9use futures::{StreamExt, TryStreamExt};
10#[cfg(feature = "cluster")]
11use laminar_core::checkpoint::ClusterRecoveryCapsule;
12use laminar_core::checkpoint_decision::{CheckpointOutcome, CheckpointScope, CheckpointVerdict};
13use laminar_core::state::{CheckpointAttempt, CheckpointSealInventory, StateBackend};
14#[cfg(feature = "cluster")]
15use laminar_core::storage::checkpoint_manifest::ConnectorCheckpoint;
16use laminar_core::storage::checkpoint_manifest::{
17 CheckpointManifest, DurableCheckpointPhase, PipelineIdentity,
18};
19use laminar_core::storage::checkpoint_store::{
20 CheckpointArtifacts, CheckpointStore, CheckpointStoreError,
21};
22use sha2::{Digest, Sha256};
23use tracing::{debug, error, info, warn};
24
25use crate::error::DbError;
26
27const VNODE_REHYDRATION_CONCURRENCY: usize = 32;
28
29#[derive(Debug)]
31pub struct RecoveredState {
32 pub manifest: CheckpointManifest,
34 outcome: Option<CheckpointOutcome>,
35 #[cfg(feature = "cluster")]
36 cluster_capsule: Option<ClusterRecoveryCapsule>,
37}
38
39impl RecoveredState {
40 #[must_use]
42 pub fn epoch(&self) -> u64 {
43 self.manifest.epoch
44 }
45
46 #[cfg(feature = "cluster")]
47 pub(crate) fn outcome(&self) -> Option<&CheckpointOutcome> {
48 self.outcome.as_ref()
49 }
50
51 #[cfg(feature = "cluster")]
52 pub(crate) fn set_cluster_capsule(&mut self, capsule: ClusterRecoveryCapsule) {
53 self.cluster_capsule = Some(capsule);
54 }
55
56 #[cfg(feature = "cluster")]
57 pub(crate) fn cluster_capsule(&self) -> Option<&ClusterRecoveryCapsule> {
58 self.cluster_capsule.as_ref()
59 }
60}
61
62#[derive(Debug, Default)]
64pub struct VnodeRehydration {
65 pub attempt: Option<CheckpointAttempt>,
67 pub restored: HashMap<u32, Vec<Bytes>>,
69}
70
71impl VnodeRehydration {
72 #[must_use]
74 pub fn restored_count(&self) -> usize {
75 self.restored.len()
76 }
77}
78
79pub struct VnodeRehydrator<'a> {
82 backend: &'a dyn StateBackend,
83 seal_cache: tokio::sync::Mutex<HashMap<CheckpointAttempt, Arc<CheckpointSealInventory>>>,
84}
85
86impl<'a> VnodeRehydrator<'a> {
87 #[must_use]
89 pub fn new(backend: &'a dyn StateBackend) -> Self {
90 Self {
91 backend,
92 seal_cache: tokio::sync::Mutex::new(HashMap::new()),
93 }
94 }
95
96 fn require_canonical_attempt(attempt: CheckpointAttempt) -> Result<(), DbError> {
97 if attempt.is_canonical() {
98 return Ok(());
99 }
100 Err(DbError::Checkpoint(format!(
101 "[LDB-6050] vnode rehydration requires one nonzero canonical checkpoint ID; received epoch {} checkpoint {}",
102 attempt.epoch, attempt.checkpoint_id
103 )))
104 }
105
106 async fn sealed_inventory(
107 &self,
108 attempt: CheckpointAttempt,
109 ) -> Result<Arc<CheckpointSealInventory>, DbError> {
110 Self::require_canonical_attempt(attempt)?;
111 if let Some(inventory) = self.seal_cache.lock().await.get(&attempt).cloned() {
112 return Ok(inventory);
113 }
114 let inventory = self
115 .backend
116 .checkpoint_seal_inventory(attempt)
117 .await
118 .map_err(|error| {
119 DbError::Checkpoint(format!(
120 "[LDB-6050] failed to read exact state seal for checkpoint {} epoch {}: {error}",
121 attempt.checkpoint_id, attempt.epoch
122 ))
123 })?
124 .ok_or_else(|| {
125 DbError::Checkpoint(format!(
126 "[LDB-6050] checkpoint {} epoch {} has no exact state seal",
127 attempt.checkpoint_id, attempt.epoch
128 ))
129 })?;
130 if inventory.attempt != attempt {
131 return Err(DbError::Checkpoint(format!(
132 "[LDB-6050] requested state attempt {attempt:?} does not match seal inventory attempt {:?}",
133 inventory.attempt
134 )));
135 }
136 let partial_vnodes: Vec<u32> = inventory
137 .sealed_partials
138 .iter()
139 .map(|partial| partial.vnode)
140 .collect();
141 if inventory
142 .required_vnodes
143 .windows(2)
144 .any(|pair| pair[0] >= pair[1])
145 || partial_vnodes != inventory.required_vnodes
146 || inventory
147 .sealed_partials
148 .iter()
149 .any(|partial| partial.assignment_version != inventory.assignment_version)
150 {
151 return Err(DbError::Checkpoint(format!(
152 "[LDB-6050] checkpoint {} epoch {} has a non-canonical sealed vnode inventory",
153 attempt.checkpoint_id, attempt.epoch
154 )));
155 }
156 let inventory = Arc::new(inventory);
157 self.seal_cache
158 .lock()
159 .await
160 .insert(attempt, Arc::clone(&inventory));
161 Ok(inventory)
162 }
163
164 async fn read_verified_partial(
165 &self,
166 attempt: CheckpointAttempt,
167 vnode: u32,
168 ) -> Result<Bytes, DbError> {
169 let inventory = self.sealed_inventory(attempt).await?;
170 let index = inventory
171 .sealed_partials
172 .binary_search_by_key(&vnode, |partial| partial.vnode)
173 .map_err(|_| {
174 DbError::Checkpoint(format!(
175 "[LDB-6050] vnode {vnode} is absent from the exact state seal for checkpoint {} epoch {}",
176 attempt.checkpoint_id, attempt.epoch
177 ))
178 })?;
179 let attestation = &inventory.sealed_partials[index];
180 let bytes = self
181 .backend
182 .read_partial(attempt, vnode)
183 .await
184 .map_err(|error| {
185 DbError::Checkpoint(format!(
186 "[LDB-6051] failed to read vnode {vnode} at sealed epoch {} checkpoint {}: {error}",
187 attempt.epoch, attempt.checkpoint_id
188 ))
189 })?
190 .ok_or_else(|| {
191 DbError::Checkpoint(format!(
192 "[LDB-6051] vnode {vnode} is missing at sealed epoch {} checkpoint {}",
193 attempt.epoch, attempt.checkpoint_id
194 ))
195 })?;
196 let actual_digest = format!("{:x}", Sha256::digest(&bytes));
197 if u64::try_from(bytes.len()).ok() != Some(attestation.payload_len)
198 || actual_digest != attestation.payload_sha256
199 {
200 return Err(DbError::Checkpoint(format!(
201 "[LDB-6051] vnode {vnode} payload does not match the exact state seal for checkpoint {} epoch {}",
202 attempt.checkpoint_id, attempt.epoch
203 )));
204 }
205 Ok(bytes)
206 }
207
208 pub async fn rehydrate_at(
214 &self,
215 vnodes: &[u32],
216 attempt: CheckpointAttempt,
217 ) -> Result<VnodeRehydration, DbError> {
218 Self::require_canonical_attempt(attempt)?;
219 let mut report = VnodeRehydration::default();
220 if vnodes.is_empty() {
221 return Ok(report);
222 }
223 let inventory = self.sealed_inventory(attempt).await?;
224 if let Some(unsealed) = vnodes
225 .iter()
226 .find(|vnode| inventory.required_vnodes.binary_search(vnode).is_err())
227 {
228 return Err(DbError::Checkpoint(format!(
229 "[LDB-6050] vnode {unsealed} is absent from the exact state seal for checkpoint {} epoch {}",
230 attempt.checkpoint_id, attempt.epoch
231 )));
232 }
233 report.attempt = Some(attempt);
234
235 let chains = futures::stream::iter(vnodes.iter().copied().map(|vnode| async move {
236 let chain = self.collect_chain(vnode, attempt).await?;
237 Ok::<_, DbError>((vnode, chain))
238 }))
239 .buffer_unordered(VNODE_REHYDRATION_CONCURRENCY)
240 .try_collect::<Vec<_>>()
241 .await?;
242 for (vnode, chain) in chains {
243 debug!(
244 vnode,
245 epoch = attempt.epoch,
246 checkpoint_id = attempt.checkpoint_id,
247 links = chain.len(),
248 "rehydrated vnode chain"
249 );
250 report.restored.insert(vnode, chain);
251 }
252
253 info!(
254 epoch = attempt.epoch,
255 checkpoint_id = attempt.checkpoint_id,
256 restored = report.restored.len(),
257 "vnode rehydration complete"
258 );
259 Ok(report)
260 }
261
262 async fn collect_chain(
265 &self,
266 vnode: u32,
267 attempt: CheckpointAttempt,
268 ) -> Result<Vec<Bytes>, DbError> {
269 use crate::vnode_partial::VnodePartial;
270
271 let (bytes, mut current, head) = self.resolve_reference_head(vnode, attempt).await?;
272 let mut need: std::collections::HashSet<String> = head
274 .deltas
275 .iter()
276 .map(|(n, _)| n.clone())
277 .filter(|n| !head.operators.iter().any(|(on, _)| on == n))
278 .collect();
279 let mut chain_rev: Vec<Bytes> = vec![bytes];
280 let mut cur = head;
281 while !need.is_empty() {
282 let Some(parent) = cur.base else {
283 return Err(DbError::Checkpoint(format!(
284 "[LDB-6051] vnode {vnode} delta chain has no FULL base for operators {need:?}"
285 )));
286 };
287 if parent.epoch >= current.epoch || parent.checkpoint_id >= current.checkpoint_id {
288 return Err(DbError::Checkpoint(format!(
289 "[LDB-6051] vnode {vnode} has non-decreasing delta link \
290 {current:?}->{parent:?}"
291 )));
292 }
293 let pbytes = self.read_verified_partial(parent, vnode).await.map_err(|error| {
294 DbError::Checkpoint(format!(
295 "[LDB-6051] vnode {vnode} delta parent {parent:?} failed seal verification: {error}"
296 ))
297 })?;
298 let pp = VnodePartial::decode(&pbytes).map_err(|e| {
299 DbError::Checkpoint(format!(
300 "[LDB-6051] vnode {vnode} delta parent {parent:?} is invalid: {e}"
301 ))
302 })?;
303 for (n, _) in &pp.operators {
304 need.remove(n);
305 }
306 chain_rev.push(pbytes);
307 current = parent;
308 cur = pp;
309 }
310 chain_rev.reverse();
311 Ok(chain_rev)
312 }
313
314 async fn resolve_reference_head(
317 &self,
318 vnode: u32,
319 attempt: CheckpointAttempt,
320 ) -> Result<(Bytes, CheckpointAttempt, crate::vnode_partial::VnodePartial), DbError> {
321 use crate::vnode_partial::VnodePartial;
322
323 let mut bytes = self.read_verified_partial(attempt, vnode).await?;
324
325 let mut current = attempt;
326 loop {
327 let partial = VnodePartial::decode(&bytes).map_err(|error| {
328 DbError::Checkpoint(format!(
329 "[LDB-6051] vnode {vnode} has an invalid partial at epoch {} checkpoint {}: \
330 {error}",
331 current.epoch, current.checkpoint_id
332 ))
333 })?;
334 if !partial.operators.is_empty() || !partial.deltas.is_empty() {
335 return Ok((bytes, current, partial));
336 }
337 let Some(base) = partial.base else {
338 return Ok((bytes, current, partial));
339 };
340 if base.epoch >= current.epoch || base.checkpoint_id >= current.checkpoint_id {
341 return Err(DbError::Checkpoint(format!(
342 "[LDB-6051] vnode {vnode} has non-decreasing reference \
343 {current:?}->{base:?}"
344 )));
345 }
346 bytes = self.read_verified_partial(base, vnode).await.map_err(|error| {
347 DbError::Checkpoint(format!(
348 "[LDB-6051] vnode {vnode} reference base {base:?} failed seal verification: {error}"
349 ))
350 })?;
351 current = base;
352 }
353 }
354}
355
356#[cfg(feature = "cluster")]
358pub(crate) type ResolvedOpChain<'a> = (&'a [u8], Vec<&'a [u8]>);
359
360#[cfg(feature = "cluster")]
363#[must_use]
364pub(crate) fn resolve_op_chain<'a>(
365 chain: &'a [crate::vnode_partial::VnodePartial],
366 op: &str,
367) -> Option<ResolvedOpChain<'a>> {
368 let base_idx = chain
369 .iter()
370 .rposition(|p| p.operators.iter().any(|(n, _)| n == op))?;
371 let base = chain[base_idx]
372 .operators
373 .iter()
374 .find(|(n, _)| n == op)
375 .map(|(_, b)| b.as_slice())?;
376 let mut deltas = Vec::new();
377 for p in &chain[base_idx + 1..] {
378 if let Some((_, d)) = p.deltas.iter().find(|(n, _)| n == op) {
379 deltas.push(d.as_slice());
380 }
381 }
382 Some((base, deltas))
383}
384
385pub struct RecoveryManager<'a> {
387 store: &'a dyn CheckpointStore,
388 expected_pipeline_identity: PipelineIdentity,
389 expected_deployment_id: String,
390 expected_outcome_scope: CheckpointScope,
391}
392
393#[derive(Clone, Copy)]
394enum RecoveryOutcomeAuthority<'a> {
395 Local(&'a laminar_core::checkpoint_decision::CheckpointDecisionStore),
396 #[cfg(feature = "cluster")]
397 Cluster {
398 outcomes: &'a laminar_core::cluster::control::LeaderLeaseStore,
399 capsules: &'a laminar_core::checkpoint_decision::CheckpointDecisionStore,
400 },
401}
402
403#[derive(Debug, Clone, Copy)]
404struct ClusterPreparedDominance {
405 highest_terminal_epoch: u64,
406 highest_terminal_checkpoint_id: u64,
407}
408
409#[cfg(feature = "cluster")]
410fn validate_cluster_candidate_manifest_binding(
411 manifest: &CheckpointManifest,
412 participant: &laminar_core::checkpoint::ParticipantRecoveryRef,
413 outcome: &CheckpointOutcome,
414 expected_pipeline_identity: &PipelineIdentity,
415 expected_deployment_id: &str,
416 expected_portable_state_sha256: &str,
417) -> Result<(), String> {
418 if manifest.epoch != outcome.epoch
419 || manifest.checkpoint_id != outcome.checkpoint_id
420 || manifest.participant_id != participant.participant_id
421 || manifest.pipeline_identity != *expected_pipeline_identity
422 || manifest.deployment_id != expected_deployment_id
423 {
424 return Err("manifest does not identify the committed runtime cut".into());
425 }
426 let (manifest_sha256, portable_state_sha256) =
427 crate::cluster_recovery_capsule::manifest_digests(manifest)
428 .map_err(|error| format!("manifest is not portable: {error}"))?;
429 if manifest_sha256 != participant.manifest_sha256
430 || portable_state_sha256 != participant.portable_state_sha256
431 || portable_state_sha256 != expected_portable_state_sha256
432 {
433 return Err("manifest digest does not match the recovery capsule".into());
434 }
435 Ok(())
436}
437
438#[cfg(feature = "cluster")]
439fn declared_sidecar_len(manifest: &CheckpointManifest) -> Result<Option<u64>, String> {
440 let mut ranges = manifest
441 .operator_states
442 .iter()
443 .filter(|(_, state)| state.external)
444 .map(|(name, state)| {
445 let end = state
446 .external_offset
447 .checked_add(state.external_length)
448 .ok_or_else(|| format!("operator '{name}' sidecar range overflows"))?;
449 if state.external_length == 0 {
450 return Err(format!(
451 "operator '{name}' has an empty external sidecar range"
452 ));
453 }
454 Ok((state.external_offset, end, name.as_str()))
455 })
456 .collect::<Result<Vec<_>, String>>()?;
457 if ranges.is_empty() {
458 if manifest.operator_states.is_empty() && manifest.state_checksum.is_some() {
459 return Err("sidecar checksum has no external operator ranges".into());
460 }
461 return Ok(None);
462 }
463 ranges.sort_unstable();
464 let mut expected_offset = 0;
465 for (start, end, name) in ranges {
466 if start != expected_offset {
467 return Err(format!(
468 "operator '{name}' sidecar range starts at {start}, expected {expected_offset}"
469 ));
470 }
471 expected_offset = end;
472 }
473 Ok(Some(expected_offset))
474}
475
476impl ClusterPreparedDominance {
477 #[cfg(feature = "cluster")]
478 fn from_outcomes(outcomes: &[CheckpointOutcome]) -> Option<Self> {
479 let terminal = outcomes.last()?;
480 Some(Self {
481 highest_terminal_epoch: terminal.epoch,
482 highest_terminal_checkpoint_id: terminal.checkpoint_id,
483 })
484 }
485}
486
487impl RecoveryOutcomeAuthority<'_> {
488 const fn scope(self) -> CheckpointScope {
489 match self {
490 Self::Local(_) => CheckpointScope::Local,
491 #[cfg(feature = "cluster")]
492 Self::Cluster { .. } => CheckpointScope::Cluster,
493 }
494 }
495
496 async fn outcome(self, epoch: u64) -> Result<Option<CheckpointOutcome>, DbError> {
497 match self {
498 Self::Local(store) => store
499 .outcome(epoch)
500 .await
501 .map_err(|error| DbError::Checkpoint(error.to_string())),
502 #[cfg(feature = "cluster")]
503 Self::Cluster { outcomes, .. } => outcomes
504 .cluster_outcome(epoch)
505 .await
506 .map_err(|error| DbError::Checkpoint(error.to_string())),
507 }
508 }
509
510 async fn outcomes(self) -> Result<Vec<CheckpointOutcome>, DbError> {
511 match self {
512 Self::Local(store) => store
513 .outcomes()
514 .await
515 .map_err(|error| DbError::Checkpoint(error.to_string())),
516 #[cfg(feature = "cluster")]
517 Self::Cluster { outcomes, .. } => outcomes
518 .cluster_outcome_inventory()
519 .await
520 .map(|inventory| inventory.outcomes)
521 .map_err(|error| DbError::Checkpoint(error.to_string())),
522 }
523 }
524}
525
526#[cfg(feature = "cluster")]
527impl<'a> RecoveryOutcomeAuthority<'a> {
528 const fn capsule_store(
529 self,
530 ) -> Option<&'a laminar_core::checkpoint_decision::CheckpointDecisionStore> {
531 match self {
532 Self::Local(_) => None,
533 Self::Cluster { capsules, .. } => Some(capsules),
534 }
535 }
536}
537
538impl<'a> RecoveryManager<'a> {
539 #[must_use]
541 pub fn new(store: &'a dyn CheckpointStore) -> Self {
542 Self {
543 store,
544 expected_pipeline_identity: PipelineIdentity::empty(),
545 expected_deployment_id: String::new(),
546 expected_outcome_scope: CheckpointScope::Local,
547 }
548 }
549
550 #[must_use]
552 pub fn with_pipeline_identity(mut self, identity: &PipelineIdentity) -> Self {
553 self.expected_pipeline_identity.clone_from(identity);
554 self
555 }
556
557 #[must_use]
559 pub fn with_deployment_id(mut self, deployment_id: &str) -> Self {
560 deployment_id.clone_into(&mut self.expected_deployment_id);
561 self
562 }
563
564 #[must_use]
566 pub(crate) fn with_outcome_scope(mut self, scope: CheckpointScope) -> Self {
567 self.expected_outcome_scope = scope;
568 self
569 }
570
571 async fn restore_committed_outcome(
572 &self,
573 outcome: &CheckpointOutcome,
574 authority: RecoveryOutcomeAuthority<'_>,
575 ) -> Result<Option<RecoveredState>, DbError> {
576 if outcome.scope != self.expected_outcome_scope {
577 return Err(DbError::Checkpoint(format!(
578 "[LDB-6041] durable outcome scope {:?} does not match active runtime scope {:?}",
579 outcome.scope, self.expected_outcome_scope
580 )));
581 }
582 if outcome.deployment_id != self.expected_deployment_id {
583 return Err(DbError::Checkpoint(format!(
584 "[LDB-6043] durable outcome deployment '{}' does not match runtime deployment '{}'",
585 outcome.deployment_id, self.expected_deployment_id
586 )));
587 }
588 match &outcome.verdict {
589 CheckpointVerdict::Commit => {}
590 CheckpointVerdict::Abort => {
591 return Err(DbError::Checkpoint(format!(
592 "[LDB-6041] epoch {} checkpoint {} has an Abort outcome and is not a recovery cut",
593 outcome.epoch, outcome.checkpoint_id
594 )));
595 }
596 }
597
598 if outcome.scope == CheckpointScope::Cluster {
599 #[cfg(feature = "cluster")]
600 {
601 return self
602 .load_cluster_committed_outcome(
603 outcome,
604 authority.capsule_store().ok_or_else(|| {
605 DbError::Checkpoint(
606 "[LDB-6041] cluster recovery has no recovery capsule store".into(),
607 )
608 })?,
609 true,
610 )
611 .await;
612 }
613 #[cfg(not(feature = "cluster"))]
614 {
615 return Err(DbError::Checkpoint(
616 "[LDB-6041] cluster recovery requires the 'cluster' feature".into(),
617 ));
618 }
619 }
620
621 let local_participant = self.store.participant_id();
622 if local_participant != 0 {
623 return Err(DbError::Checkpoint(format!(
624 "[LDB-6041] local outcome recovery requires participant 0, but the checkpoint store is participant {local_participant}"
625 )));
626 }
627 if outcome.recovery_capsule.is_some() {
628 return Err(DbError::Checkpoint(format!(
629 "[LDB-6041] local epoch {} checkpoint {} unexpectedly binds a cluster recovery capsule",
630 outcome.epoch, outcome.checkpoint_id
631 )));
632 }
633
634 let artifacts = self
635 .store
636 .load_checkpoint_artifacts_for_participant(0, outcome.checkpoint_id)
637 .await
638 .map_err(|error| {
639 DbError::Checkpoint(format!(
640 "[LDB-6041] committed epoch {} checkpoint {} participant {} artifacts are unreadable: {error}",
641 outcome.epoch, outcome.checkpoint_id, local_participant
642 ))
643 })?
644 .ok_or_else(|| {
645 DbError::Checkpoint(format!(
646 "[LDB-6041] committed checkpoint {} is absent from participant \
647 {local_participant} recovery inventory",
648 outcome.checkpoint_id
649 ))
650 })?;
651 let manifest = &artifacts.manifest;
652 if manifest.epoch != outcome.epoch || manifest.checkpoint_id != outcome.checkpoint_id {
653 return Err(DbError::Checkpoint(format!(
654 "[LDB-6041] outcome epoch {} checkpoint {} does not match participant \
655 {local_participant} manifest epoch {} checkpoint {}",
656 outcome.epoch, outcome.checkpoint_id, manifest.epoch, manifest.checkpoint_id
657 )));
658 }
659 self.try_restore(
660 outcome.checkpoint_id,
661 artifacts,
662 Some(authority),
663 0,
664 Some(outcome),
665 )
666 .await
667 }
668
669 #[cfg(feature = "cluster")]
670 async fn load_cluster_committed_outcome(
671 &self,
672 outcome: &CheckpointOutcome,
673 decision_store: &laminar_core::checkpoint_decision::CheckpointDecisionStore,
674 finalize_local_manifest: bool,
675 ) -> Result<Option<RecoveredState>, DbError> {
676 let reference = outcome.recovery_capsule.as_ref().ok_or_else(|| {
677 DbError::Checkpoint(format!(
678 "[LDB-6041] cluster epoch {} checkpoint {} has no recovery capsule",
679 outcome.epoch, outcome.checkpoint_id
680 ))
681 })?;
682 let capsule = decision_store
683 .load_recovery_capsule(reference)
684 .await
685 .map_err(|error| {
686 DbError::Checkpoint(format!(
687 "[LDB-6041] cluster epoch {} checkpoint {} recovery capsule is unreadable: {error}",
688 outcome.epoch, outcome.checkpoint_id
689 ))
690 })?;
691 let expected_attempt = CheckpointAttempt::new(outcome.epoch, outcome.checkpoint_id);
692 if capsule.attempt != expected_attempt
693 || capsule.deployment_id != outcome.deployment_id
694 || capsule.deployment_id != self.expected_deployment_id
695 || capsule.pipeline_identity != self.expected_pipeline_identity
696 || outcome.assignment_fence.as_ref() != Some(&capsule.assignment_fence)
697 {
698 return Err(DbError::Checkpoint(format!(
699 "[LDB-6041] cluster epoch {} checkpoint {} recovery capsule does not match the active attempt, deployment, pipeline identity, and assignment fence",
700 outcome.epoch, outcome.checkpoint_id
701 )));
702 }
703
704 let local_participant = self.store.participant_id();
705 let mut candidates = capsule.participants.iter().collect::<Vec<_>>();
706 if let Some(local_index) = candidates
707 .iter()
708 .position(|participant| participant.participant_id == local_participant)
709 {
710 candidates[..=local_index].rotate_right(1);
711 }
712
713 let mut rejected = 0_usize;
714 let mut first_failure = None;
715 let mut last_failure = None;
716 let mut reject_candidate = |failure: String| {
717 rejected += 1;
718 if first_failure.is_none() {
719 first_failure = Some(failure.clone());
720 }
721 last_failure = Some(failure);
722 };
723 for participant in candidates {
724 let storage_participant = participant.participant_id;
725 info!(
726 local_participant,
727 storage_participant,
728 epoch = outcome.epoch,
729 checkpoint_id = outcome.checkpoint_id,
730 assignment_version = capsule.assignment_fence.assignment_version,
731 "cluster recovery: validating participant checkpoint artifacts"
732 );
733
734 let artifacts = match self
735 .store
736 .load_checkpoint_artifacts_for_participant(
737 storage_participant,
738 outcome.checkpoint_id,
739 )
740 .await
741 {
742 Ok(Some(artifacts)) => artifacts,
743 Ok(None) => {
744 reject_candidate(format!(
745 "participant {storage_participant} manifest is absent"
746 ));
747 warn!(
748 storage_participant,
749 checkpoint_id = outcome.checkpoint_id,
750 "cluster recovery candidate manifest is absent"
751 );
752 continue;
753 }
754 Err(error) => {
755 reject_candidate(format!(
756 "participant {storage_participant} artifacts are unreadable: {error}"
757 ));
758 warn!(
759 storage_participant,
760 checkpoint_id = outcome.checkpoint_id,
761 %error,
762 "cluster recovery candidate artifacts are unreadable"
763 );
764 continue;
765 }
766 };
767
768 let manifest = &artifacts.manifest;
769 if let Err(failure) = validate_cluster_candidate_manifest_binding(
770 manifest,
771 participant,
772 outcome,
773 &self.expected_pipeline_identity,
774 &self.expected_deployment_id,
775 &capsule.portable_state_sha256,
776 ) {
777 reject_candidate(format!("participant {storage_participant} {failure}"));
778 warn!(
779 storage_participant,
780 checkpoint_id = outcome.checkpoint_id,
781 %failure,
782 "cluster recovery candidate manifest binding is invalid"
783 );
784 continue;
785 }
786 let (artifacts, validation) = artifacts
787 .validate(
788 outcome.checkpoint_id,
789 storage_participant,
790 self.store.key_group_count(),
791 self.store.max_state_data_bytes(),
792 )
793 .await
794 .map_err(DbError::from)?;
795 if !validation.valid {
796 reject_candidate(format!(
797 "participant {storage_participant} artifact integrity failed: {:?}",
798 validation.issues
799 ));
800 warn!(
801 storage_participant,
802 checkpoint_id = outcome.checkpoint_id,
803 issues = ?validation.issues,
804 "cluster recovery candidate artifact integrity failed"
805 );
806 continue;
807 }
808
809 let mut recovered = match Self::restore_from(artifacts) {
810 Ok(recovered) => recovered,
811 Err(error) => {
812 reject_candidate(format!(
813 "participant {storage_participant} sidecar cannot be resolved: {error}"
814 ));
815 warn!(
816 storage_participant,
817 checkpoint_id = outcome.checkpoint_id,
818 %error,
819 "cluster recovery candidate sidecar cannot be resolved"
820 );
821 continue;
822 }
823 };
824
825 recovered.manifest.source_offsets = capsule
826 .source_offsets
827 .iter()
828 .map(|(source, offsets)| {
829 let metadata = capsule
830 .source_metadata
831 .get(source)
832 .expect("validated recovery capsule has matching source metadata");
833 (
834 source.clone(),
835 ConnectorCheckpoint {
836 offsets: offsets.clone().into_iter().collect(),
837 metadata: metadata.clone().into_iter().collect(),
838 source_assignment_version: capsule
839 .source_assignment_versions
840 .get(source)
841 .copied(),
842 },
843 )
844 })
845 .collect();
846 recovered.manifest.source_names = capsule.source_offsets.keys().cloned().collect();
847 recovered.manifest.source_watermarks =
848 capsule.source_watermarks.clone().into_iter().collect();
849 recovered.manifest.watermark = capsule.recovery_watermark_frontier;
850 let mut recovered = if finalize_local_manifest {
851 self.complete_restore(
852 recovered,
853 Some(outcome.clone()),
854 outcome.checkpoint_id,
855 storage_participant,
856 )
857 .await?
858 } else {
859 recovered.outcome = Some(outcome.clone());
860 recovered
861 };
862 recovered.set_cluster_capsule(capsule);
863 return Ok(Some(recovered));
864 }
865
866 let failure_summary = match (first_failure, last_failure) {
867 (Some(first), Some(last)) if first != last => {
868 format!("; first failure: {first}; last failure: {last}")
869 }
870 (Some(failure), _) => format!("; failure: {failure}"),
871 _ => String::new(),
872 };
873 Err(DbError::Checkpoint(format!(
874 "[LDB-6041] committed cluster checkpoint {} has no usable participant artifact replica; {rejected} candidate(s) rejected{}",
875 outcome.checkpoint_id,
876 failure_summary
877 )))
878 }
879
880 #[cfg(feature = "cluster")]
881 pub(crate) async fn preflight_cluster_committed_outcome(
882 &self,
883 outcome: &CheckpointOutcome,
884 decision_store: &laminar_core::checkpoint_decision::CheckpointDecisionStore,
885 ) -> Result<RecoveredState, DbError> {
886 self.load_cluster_committed_outcome(outcome, decision_store, false)
887 .await?
888 .ok_or_else(|| {
889 DbError::Checkpoint(format!(
890 "[LDB-6041] cluster checkpoint {} has no recoverable participant artifacts",
891 outcome.checkpoint_id
892 ))
893 })
894 }
895
896 #[cfg(feature = "cluster")]
897 async fn preflight_cluster_candidate_metadata(
898 &self,
899 participant: &laminar_core::checkpoint::ParticipantRecoveryRef,
900 outcome: &CheckpointOutcome,
901 capsule: &ClusterRecoveryCapsule,
902 ) -> Result<(), String> {
903 let participant_id = participant.participant_id;
904 let manifest = self
905 .store
906 .load_manifest_for_participant(participant_id, outcome.checkpoint_id)
907 .await
908 .map_err(|error| {
909 format!("participant {participant_id} manifest is unreadable: {error}")
910 })?
911 .ok_or_else(|| format!("participant {participant_id} manifest is absent"))?;
912 let validation = manifest.validate(self.store.key_group_count());
913 if let Some(failure) = validation.first() {
914 return Err(format!(
915 "participant {participant_id} manifest metadata is invalid: {failure}"
916 ));
917 }
918 validate_cluster_candidate_manifest_binding(
919 &manifest,
920 participant,
921 outcome,
922 &self.expected_pipeline_identity,
923 &self.expected_deployment_id,
924 &capsule.portable_state_sha256,
925 )
926 .map_err(|failure| format!("participant {participant_id} {failure}"))?;
927
928 let Some(expected_len) = declared_sidecar_len(&manifest).map_err(|failure| {
929 format!("participant {participant_id} manifest sidecar shape is invalid: {failure}")
930 })?
931 else {
932 return Ok(());
933 };
934 match self
935 .store
936 .state_data_len_for_participant(participant_id, outcome.checkpoint_id)
937 .await
938 .map_err(|error| {
939 format!("participant {participant_id} sidecar metadata is unreadable: {error}")
940 })?
941 {
942 Some(actual_len) if actual_len == expected_len => Ok(()),
943 Some(actual_len) => Err(format!(
944 "participant {participant_id} sidecar is {actual_len} bytes; expected {expected_len}"
945 )),
946 None => Err(format!("participant {participant_id} sidecar is absent")),
947 }
948 }
949
950 #[cfg(feature = "cluster")]
951 pub(crate) async fn preflight_cluster_committed_metadata(
954 &self,
955 outcome: &CheckpointOutcome,
956 capsule: &ClusterRecoveryCapsule,
957 ) -> Result<(), DbError> {
958 capsule
959 .validate()
960 .map_err(|error| DbError::Checkpoint(format!("[LDB-6041] {error}")))?;
961 let expected_attempt = CheckpointAttempt::new(outcome.epoch, outcome.checkpoint_id);
962 if !outcome.is_commit()
963 || outcome.scope != CheckpointScope::Cluster
964 || capsule.attempt != expected_attempt
965 || capsule.deployment_id != outcome.deployment_id
966 || capsule.deployment_id != self.expected_deployment_id
967 || capsule.pipeline_identity != self.expected_pipeline_identity
968 || outcome.assignment_fence.as_ref() != Some(&capsule.assignment_fence)
969 {
970 return Err(DbError::Checkpoint(format!(
971 "[LDB-6041] cluster epoch {} checkpoint {} manifest preflight does not match the committed runtime cut",
972 outcome.epoch, outcome.checkpoint_id
973 )));
974 }
975
976 let local_participant = self.store.participant_id();
977 let mut candidates = capsule.participants.iter().collect::<Vec<_>>();
978 if let Some(local_index) = candidates
979 .iter()
980 .position(|participant| participant.participant_id == local_participant)
981 {
982 candidates[..=local_index].rotate_right(1);
983 }
984 let mut rejected = 0_usize;
985 let mut last_failure = None;
986 for participant in candidates {
987 match self
988 .preflight_cluster_candidate_metadata(participant, outcome, capsule)
989 .await
990 {
991 Ok(()) => return Ok(()),
992 Err(failure) => {
993 rejected += 1;
994 last_failure = Some(failure);
995 }
996 }
997 }
998
999 let failure = last_failure.map_or_else(String::new, |failure| format!("; {failure}"));
1000 Err(DbError::Checkpoint(format!(
1001 "[LDB-6041] committed cluster checkpoint {} has no usable participant manifest metadata; {rejected} candidate(s) rejected{failure}",
1002 outcome.checkpoint_id
1003 )))
1004 }
1005
1006 #[cfg(feature = "cluster")]
1007 async fn validated_cluster_recovery_outcomes(
1008 &self,
1009 authority: &laminar_core::cluster::control::LeaderLeaseStore,
1010 capsule_store: &laminar_core::checkpoint_decision::CheckpointDecisionStore,
1011 ) -> Result<Vec<CheckpointOutcome>, DbError> {
1012 let inventory = authority
1013 .validated_cluster_outcome_inventory(|outcome| async move {
1014 self.preflight_cluster_committed_outcome(&outcome, capsule_store)
1015 .await
1016 .map(drop)
1017 .map_err(|error| error.to_string())
1018 })
1019 .await
1020 .map_err(|error| DbError::Checkpoint(error.to_string()))?;
1021 Ok(inventory.outcomes)
1022 }
1023
1024 async fn settle_local_prepared_attempts(
1025 &self,
1026 decision_store: &laminar_core::checkpoint_decision::CheckpointDecisionStore,
1027 outcomes: &[CheckpointOutcome],
1028 ) -> Result<(), DbError> {
1029 let authority_deployment = decision_store
1030 .load_or_create_deployment_id()
1031 .await
1032 .map_err(|error| DbError::Checkpoint(error.to_string()))?;
1033 if !self.expected_deployment_id.is_empty()
1034 && self.expected_deployment_id != authority_deployment
1035 {
1036 return Err(DbError::Checkpoint(format!(
1037 "[LDB-6043] local decision authority deployment '{}' does not match runtime deployment '{}'",
1038 authority_deployment, self.expected_deployment_id
1039 )));
1040 }
1041 let floor = decision_store
1042 .outcome_retention_boundary()
1043 .await
1044 .map_err(|error| DbError::Checkpoint(error.to_string()))?
1045 .before_epoch;
1046 let checkpoint_ids = self.store.list_ids().await.map_err(DbError::from)?;
1047 for checkpoint_id in checkpoint_ids {
1048 if outcomes
1049 .iter()
1050 .any(|outcome| outcome.checkpoint_id == checkpoint_id)
1051 {
1052 continue;
1053 }
1054 let manifest = self
1055 .store
1056 .load_by_id(checkpoint_id)
1057 .await
1058 .map_err(|error| {
1059 DbError::Checkpoint(format!(
1060 "[LDB-6041] checkpoint {checkpoint_id} recovery inventory is unreadable while settling Prepared witnesses: {error}"
1061 ))
1062 })?
1063 .ok_or_else(|| {
1064 DbError::Checkpoint(format!(
1065 "[LDB-6041] checkpoint {checkpoint_id} disappeared while settling prepared recovery inventory"
1066 ))
1067 })?;
1068 if manifest.durable_phase != DurableCheckpointPhase::Prepared || manifest.epoch < floor
1069 {
1070 continue;
1071 }
1072 if let Some(outcome) = outcomes
1073 .iter()
1074 .find(|outcome| outcome.epoch == manifest.epoch)
1075 {
1076 if outcome.checkpoint_id != manifest.checkpoint_id {
1077 return Err(DbError::Checkpoint(format!(
1078 "[LDB-6041] prepared checkpoint {} epoch {} conflicts with durable outcome checkpoint {}",
1079 manifest.checkpoint_id, manifest.epoch, outcome.checkpoint_id
1080 )));
1081 }
1082 continue;
1083 }
1084
1085 let validation = manifest.validate(self.store.key_group_count());
1086 if !validation.is_empty() {
1087 return Err(DbError::Checkpoint(format!(
1088 "[LDB-6041] prepared checkpoint {} epoch {} is invalid and cannot be used to mint an Abort outcome: {}",
1089 manifest.checkpoint_id,
1090 manifest.epoch,
1091 validation
1092 .iter()
1093 .map(ToString::to_string)
1094 .collect::<Vec<_>>()
1095 .join("; ")
1096 )));
1097 }
1098 if manifest.participant_id != 0
1099 || manifest.deployment_id != self.expected_deployment_id
1100 || manifest.pipeline_identity != self.expected_pipeline_identity
1101 {
1102 return Err(DbError::Checkpoint(format!(
1103 "[LDB-6043] prepared checkpoint {} epoch {} does not belong to local participant 0 and the active deployment/pipeline identity",
1104 manifest.checkpoint_id, manifest.epoch
1105 )));
1106 }
1107
1108 let settled = decision_store
1109 .record_outcome(
1110 manifest.epoch,
1111 manifest.checkpoint_id,
1112 CheckpointScope::Local,
1113 None,
1114 None,
1115 CheckpointVerdict::Abort,
1116 None,
1117 )
1118 .await
1119 .map_err(|error| {
1120 DbError::Checkpoint(format!(
1121 "[LDB-6041] prepared checkpoint {} epoch {} could not be durably settled as Abort: {error}",
1122 manifest.checkpoint_id, manifest.epoch
1123 ))
1124 })?;
1125 let outcome = match settled {
1126 laminar_core::checkpoint_decision::RecordOutcomeResult::Created(outcome)
1127 | laminar_core::checkpoint_decision::RecordOutcomeResult::Unchanged(outcome) => {
1128 outcome
1129 }
1130 laminar_core::checkpoint_decision::RecordOutcomeResult::Conflict { winner } => {
1131 winner
1132 }
1133 };
1134 if outcome.epoch != manifest.epoch || outcome.checkpoint_id != manifest.checkpoint_id {
1135 return Err(DbError::Checkpoint(format!(
1136 "[LDB-6041] prepared checkpoint {} epoch {} was not settled by an exact durable terminal outcome",
1137 manifest.checkpoint_id, manifest.epoch
1138 )));
1139 }
1140 }
1141 Ok(())
1142 }
1143
1144 async fn validate_newer_durable_attempts(
1145 &self,
1146 selected: &CheckpointOutcome,
1147 outcomes: &[CheckpointOutcome],
1148 cluster_dominance: Option<ClusterPreparedDominance>,
1149 ) -> Result<(), DbError> {
1150 let checkpoint_ids = self.store.list_ids().await.map_err(DbError::from)?;
1151 for checkpoint_id in checkpoint_ids {
1152 if checkpoint_id == selected.checkpoint_id {
1153 continue;
1154 }
1155 let manifest = self
1156 .store
1157 .load_by_id(checkpoint_id)
1158 .await
1159 .map_err(DbError::from)?
1160 .ok_or_else(|| {
1161 DbError::Checkpoint(format!(
1162 "[LDB-6041] checkpoint {checkpoint_id} disappeared while auditing recovery inventory"
1163 ))
1164 })?;
1165 if manifest.epoch <= selected.epoch && manifest.checkpoint_id <= selected.checkpoint_id
1166 {
1167 if cluster_dominance.is_none() {
1168 continue;
1169 }
1170 if manifest.epoch < selected.epoch
1171 && manifest.checkpoint_id < selected.checkpoint_id
1172 {
1173 if manifest.durable_phase == DurableCheckpointPhase::Prepared
1174 && !outcomes
1175 .iter()
1176 .any(|outcome| outcome.epoch == manifest.epoch)
1177 {
1178 self.validate_dominated_prepared_manifest(checkpoint_id, &manifest)?;
1179 }
1180 continue;
1181 }
1182 }
1183 let Some(outcome) = outcomes
1184 .iter()
1185 .find(|outcome| outcome.epoch == manifest.epoch)
1186 else {
1187 if let Some(outcome) = outcomes
1188 .iter()
1189 .find(|outcome| outcome.checkpoint_id == manifest.checkpoint_id)
1190 {
1191 return Err(DbError::Checkpoint(format!(
1192 "[LDB-6041] checkpoint {} is reused by manifest epoch {} and terminal outcome epoch {}",
1193 manifest.checkpoint_id, manifest.epoch, outcome.epoch
1194 )));
1195 }
1196 let dominated = cluster_dominance.is_some_and(|boundary| {
1197 manifest.epoch < boundary.highest_terminal_epoch
1198 && manifest.checkpoint_id < boundary.highest_terminal_checkpoint_id
1199 });
1200 if manifest.durable_phase == DurableCheckpointPhase::Prepared && dominated {
1201 self.validate_dominated_prepared_manifest(checkpoint_id, &manifest)?;
1202 continue;
1203 }
1204 return Err(DbError::Checkpoint(format!(
1205 "[LDB-6041] newer {:?} checkpoint {} epoch {} is not settled by an exact terminal outcome; refusing to restore older checkpoint {} epoch {}",
1206 manifest.durable_phase,
1207 manifest.checkpoint_id,
1208 manifest.epoch,
1209 selected.checkpoint_id,
1210 selected.epoch
1211 )));
1212 };
1213 let expected_verdict = match manifest.durable_phase {
1214 DurableCheckpointPhase::Prepared => CheckpointVerdict::Abort,
1215 DurableCheckpointPhase::Finalized => CheckpointVerdict::Commit,
1216 };
1217 if outcome.checkpoint_id != manifest.checkpoint_id
1218 || outcome.verdict != expected_verdict
1219 {
1220 return Err(DbError::Checkpoint(format!(
1221 "[LDB-6041] newer {:?} checkpoint {} epoch {} conflicts with terminal outcome checkpoint {} {:?}",
1222 manifest.durable_phase,
1223 manifest.checkpoint_id,
1224 manifest.epoch,
1225 outcome.checkpoint_id,
1226 outcome.verdict
1227 )));
1228 }
1229 }
1230 Ok(())
1231 }
1232
1233 fn validate_dominated_prepared_manifest(
1234 &self,
1235 storage_id: u64,
1236 manifest: &CheckpointManifest,
1237 ) -> Result<(), DbError> {
1238 if manifest.checkpoint_id != storage_id {
1239 return Err(DbError::Checkpoint(format!(
1240 "[LDB-6041] storage checkpoint {storage_id} contains dominated Prepared manifest checkpoint {}",
1241 manifest.checkpoint_id
1242 )));
1243 }
1244 let validation = manifest.validate(self.store.key_group_count());
1245 if !validation.is_empty() {
1246 return Err(DbError::Checkpoint(format!(
1247 "[LDB-6041] dominated Prepared checkpoint {} epoch {} is invalid: {}",
1248 manifest.checkpoint_id,
1249 manifest.epoch,
1250 validation
1251 .iter()
1252 .map(ToString::to_string)
1253 .collect::<Vec<_>>()
1254 .join("; ")
1255 )));
1256 }
1257 if manifest.participant_id != self.store.participant_id()
1258 || manifest.deployment_id != self.expected_deployment_id
1259 || manifest.pipeline_identity != self.expected_pipeline_identity
1260 {
1261 return Err(DbError::Checkpoint(format!(
1262 "[LDB-6043] dominated Prepared checkpoint {} epoch {} does not belong to storage participant {} and the active deployment/pipeline identity",
1263 manifest.checkpoint_id,
1264 manifest.epoch,
1265 self.store.participant_id()
1266 )));
1267 }
1268 Ok(())
1269 }
1270
1271 async fn validate_no_commit_outcome_genesis(&self) -> Result<(), DbError> {
1272 let published = self.store.load_latest().await.map_err(|error| {
1276 DbError::Checkpoint(format!(
1277 "[LDB-6041] checkpoint recovery pointer is invalid while no durable Commit outcome exists: {error}"
1278 ))
1279 })?;
1280 if let Some(manifest) = published {
1281 return Err(DbError::Checkpoint(format!(
1282 "[LDB-6041] published finalized checkpoint {} epoch {} exists but no durable Commit outcome exists",
1283 manifest.checkpoint_id, manifest.epoch
1284 )));
1285 }
1286 let checkpoint_ids = self.store.list_ids().await.map_err(|error| {
1287 DbError::Checkpoint(format!(
1288 "[LDB-6041] recovery inventory cannot be enumerated while no durable Commit outcome exists: {error}"
1289 ))
1290 })?;
1291 for checkpoint_id in checkpoint_ids {
1292 let manifest = self
1293 .store
1294 .load_by_id(checkpoint_id)
1295 .await
1296 .map_err(|error| {
1297 DbError::Checkpoint(format!(
1298 "[LDB-6041] checkpoint {checkpoint_id} recovery inventory is unreadable while no durable Commit outcome exists: {error}"
1299 ))
1300 })?
1301 .ok_or_else(|| {
1302 DbError::Checkpoint(format!(
1303 "[LDB-6041] checkpoint {checkpoint_id} disappeared from recovery inventory while no durable Commit outcome exists"
1304 ))
1305 })?;
1306 if manifest.checkpoint_id != checkpoint_id {
1307 return Err(DbError::Checkpoint(format!(
1308 "[LDB-6041] storage checkpoint {checkpoint_id} contains manifest checkpoint {} while no durable Commit outcome exists",
1309 manifest.checkpoint_id
1310 )));
1311 }
1312 if manifest.durable_phase == DurableCheckpointPhase::Finalized {
1313 return Err(DbError::Checkpoint(format!(
1314 "[LDB-6041] finalized checkpoint {} epoch {} exists in recovery inventory but no durable Commit outcome exists",
1315 manifest.checkpoint_id, manifest.epoch
1316 )));
1317 }
1318 }
1319 Ok(())
1320 }
1321
1322 pub(crate) async fn recover(
1331 &self,
1332 decision_store: Option<&laminar_core::checkpoint_decision::CheckpointDecisionStore>,
1333 ) -> Result<Option<RecoveredState>, DbError> {
1334 self.recover_with_authority(decision_store.map(RecoveryOutcomeAuthority::Local))
1335 .await
1336 }
1337
1338 #[cfg(feature = "cluster")]
1339 pub(crate) async fn recover_cluster(
1340 &self,
1341 authority: &laminar_core::cluster::control::LeaderLeaseStore,
1342 capsule_store: &laminar_core::checkpoint_decision::CheckpointDecisionStore,
1343 ) -> Result<Option<RecoveredState>, DbError> {
1344 self.recover_with_authority(Some(RecoveryOutcomeAuthority::Cluster {
1345 outcomes: authority,
1346 capsules: capsule_store,
1347 }))
1348 .await
1349 }
1350
1351 async fn recover_with_authority(
1352 &self,
1353 authority: Option<RecoveryOutcomeAuthority<'_>>,
1354 ) -> Result<Option<RecoveredState>, DbError> {
1355 self.validate_outcome_authority(authority)?;
1356 if let Some(authority) = authority {
1361 let (outcomes, cluster_dominance) = match authority {
1362 RecoveryOutcomeAuthority::Local(decision_store) => {
1363 let mut outcomes = authority.outcomes().await?;
1364 self.settle_local_prepared_attempts(decision_store, &outcomes)
1369 .await?;
1370 outcomes = authority.outcomes().await?;
1371 (outcomes, None)
1372 }
1373 #[cfg(feature = "cluster")]
1374 RecoveryOutcomeAuthority::Cluster {
1375 outcomes: cluster_authority,
1376 capsules,
1377 } => {
1378 let outcomes = self
1379 .validated_cluster_recovery_outcomes(cluster_authority, capsules)
1380 .await?;
1381 let dominance = ClusterPreparedDominance::from_outcomes(&outcomes);
1382 (outcomes, dominance)
1383 }
1384 };
1385 let outcome = outcomes
1386 .iter()
1387 .rev()
1388 .find(|outcome| outcome.is_commit())
1389 .cloned();
1390 if let Some(outcome) = outcome {
1391 self.validate_newer_durable_attempts(&outcome, &outcomes, cluster_dominance)
1392 .await?;
1393 return self.restore_committed_outcome(&outcome, authority).await;
1394 }
1395
1396 self.validate_no_commit_outcome_genesis().await?;
1397 info!(
1398 "no Commit outcome exists; ignoring aborted or unresolved Prepared artifacts and starting fresh"
1399 );
1400 return Ok(None);
1401 }
1402 let mut checkpoint_ids = self.store.list_ids().await.map_err(DbError::from)?;
1403 if checkpoint_ids.is_empty() {
1404 info!("checkpoint store is empty, starting fresh");
1405 return Ok(None);
1406 }
1407 checkpoint_ids.reverse();
1408 self.restore_first_for_participant(&checkpoint_ids, authority, self.store.participant_id())
1409 .await
1410 }
1411
1412 pub(crate) async fn recover_to_epoch(
1418 &self,
1419 target_epoch: u64,
1420 decision_store: Option<&laminar_core::checkpoint_decision::CheckpointDecisionStore>,
1421 ) -> Result<Option<RecoveredState>, DbError> {
1422 self.recover_to_epoch_with_authority(
1423 target_epoch,
1424 decision_store.map(RecoveryOutcomeAuthority::Local),
1425 )
1426 .await
1427 }
1428
1429 #[cfg(feature = "cluster")]
1430 pub(crate) async fn recover_cluster_to_epoch(
1431 &self,
1432 target_epoch: u64,
1433 authority: &laminar_core::cluster::control::LeaderLeaseStore,
1434 capsule_store: &laminar_core::checkpoint_decision::CheckpointDecisionStore,
1435 ) -> Result<Option<RecoveredState>, DbError> {
1436 self.recover_to_epoch_with_authority(
1437 target_epoch,
1438 Some(RecoveryOutcomeAuthority::Cluster {
1439 outcomes: authority,
1440 capsules: capsule_store,
1441 }),
1442 )
1443 .await
1444 }
1445
1446 async fn recover_to_epoch_with_authority(
1447 &self,
1448 target_epoch: u64,
1449 authority: Option<RecoveryOutcomeAuthority<'_>>,
1450 ) -> Result<Option<RecoveredState>, DbError> {
1451 self.validate_outcome_authority(authority)?;
1452 if let Some(authority) = authority {
1453 let (outcomes, cluster_dominance) = match authority {
1456 RecoveryOutcomeAuthority::Local(decision_store) => {
1457 let mut outcomes = authority.outcomes().await?;
1458 self.settle_local_prepared_attempts(decision_store, &outcomes)
1459 .await?;
1460 outcomes = authority.outcomes().await?;
1461 (outcomes, None)
1462 }
1463 #[cfg(feature = "cluster")]
1464 RecoveryOutcomeAuthority::Cluster {
1465 outcomes: cluster_authority,
1466 capsules,
1467 } => {
1468 let outcomes = self
1469 .validated_cluster_recovery_outcomes(cluster_authority, capsules)
1470 .await?;
1471 let dominance = ClusterPreparedDominance::from_outcomes(&outcomes);
1472 (outcomes, dominance)
1473 }
1474 };
1475 let committed = outcomes
1476 .iter()
1477 .rev()
1478 .find(|outcome| outcome.is_commit())
1479 .cloned();
1480 return match committed {
1481 Some(outcome) if outcome.epoch == target_epoch => {
1482 self.validate_newer_durable_attempts(
1483 &outcome,
1484 &outcomes,
1485 cluster_dominance,
1486 )
1487 .await?;
1488 self.restore_committed_outcome(&outcome, authority).await
1489 }
1490 Some(outcome) => Err(DbError::Checkpoint(format!(
1491 "[LDB-6041] recovery target epoch {target_epoch} is not the highest durable Commit outcome: epoch {} checkpoint {} is authoritative",
1492 outcome.epoch, outcome.checkpoint_id
1493 ))),
1494 None if target_epoch == 0 => {
1495 self.validate_no_commit_outcome_genesis().await?;
1496 Ok(None)
1497 }
1498 None => Err(DbError::Checkpoint(format!(
1499 "[LDB-6041] recovery target epoch {target_epoch} has no Commit outcome"
1500 ))),
1501 };
1502 }
1503
1504 let checkpoint_ids = self.store.list_ids().await.map_err(DbError::from)?;
1505 if checkpoint_ids.is_empty() {
1506 return if target_epoch == 0 {
1507 Ok(None)
1508 } else {
1509 Err(DbError::Checkpoint(format!(
1510 "[LDB-6041] recovery target epoch {target_epoch} has no checkpoint history"
1511 )))
1512 };
1513 }
1514
1515 let mut checkpoints = Vec::with_capacity(checkpoint_ids.len());
1516 for &id in &checkpoint_ids {
1517 match self.store.load_by_id(id).await {
1518 Ok(Some(manifest)) if manifest.checkpoint_id == id => {
1519 if manifest.epoch <= target_epoch {
1520 checkpoints.push((id, manifest.epoch));
1521 }
1522 }
1523 Ok(Some(manifest)) => warn!(
1524 storage_id = id,
1525 manifest_id = manifest.checkpoint_id,
1526 "checkpoint identity mismatch — skipping"
1527 ),
1528 Ok(None) => {
1529 return Err(DbError::Checkpoint(format!(
1530 "[LDB-6041] checkpoint {id} disappeared during recovery inventory"
1531 )));
1532 }
1533 Err(CheckpointStoreError::Serde(e)) => {
1534 warn!(checkpoint_id = id, error = %e, "corrupt checkpoint manifest — skipping");
1535 }
1536 Err(e) => return Err(DbError::from(e)),
1537 }
1538 }
1539 checkpoints.sort_by_key(|&(id, epoch)| std::cmp::Reverse((epoch, id)));
1542 if checkpoints.is_empty() {
1543 return if target_epoch == 0 {
1544 Ok(None)
1545 } else {
1546 Err(DbError::Checkpoint(format!(
1547 "[LDB-6041] no usable checkpoint exists at or before recovery target \
1548 epoch {target_epoch}"
1549 )))
1550 };
1551 }
1552 let candidate_ids: Vec<u64> = checkpoints.iter().map(|&(id, _)| id).collect();
1553 self.restore_first_for_participant(&candidate_ids, authority, self.store.participant_id())
1554 .await
1555 }
1556
1557 fn validate_outcome_authority(
1558 &self,
1559 authority: Option<RecoveryOutcomeAuthority<'_>>,
1560 ) -> Result<(), DbError> {
1561 match authority {
1562 Some(authority) if authority.scope() != self.expected_outcome_scope => {
1563 Err(DbError::Checkpoint(format!(
1564 "[LDB-6041] recovery authority scope {:?} does not match active runtime scope {:?}",
1565 authority.scope(),
1566 self.expected_outcome_scope
1567 )))
1568 }
1569 None if self.expected_outcome_scope == CheckpointScope::Cluster => {
1570 Err(DbError::Checkpoint(
1571 "[LDB-6041] cluster recovery requires the exact checkpoint authority".into(),
1572 ))
1573 }
1574 _ => Ok(()),
1575 }
1576 }
1577
1578 fn resolve_external_states(
1583 manifest: &mut CheckpointManifest,
1584 state_data: Option<&[u8]>,
1585 ) -> Result<(), DbError> {
1586 let external_ops: Vec<String> = manifest
1587 .operator_states
1588 .iter()
1589 .filter(|(_, op)| op.external)
1590 .map(|(name, _)| name.clone())
1591 .collect();
1592
1593 if external_ops.is_empty() {
1594 return Ok(());
1595 }
1596
1597 let Some(state_data) = state_data else {
1598 return Err(DbError::Checkpoint(format!(
1599 "[LDB-6010] checkpoint {} sidecar is missing for external operators \
1600 {external_ops:?}",
1601 manifest.checkpoint_id
1602 )));
1603 };
1604
1605 for (name, op) in &mut manifest.operator_states {
1606 if op.external {
1607 let range = match (
1609 usize::try_from(op.external_offset),
1610 usize::try_from(op.external_length),
1611 ) {
1612 (Ok(start), Ok(len)) => start.checked_add(len).map(|end| (start, end)),
1613 _ => None,
1614 }
1615 .filter(|&(_, end)| end <= state_data.len());
1616 if let Some((start, end)) = range {
1617 let external_offset = op.external_offset;
1618 let external_length = op.external_length;
1619 let data = &state_data[start..end];
1620 *op = laminar_core::storage::checkpoint_manifest::OperatorCheckpoint::inline(
1621 data,
1622 );
1623 debug!(
1624 operator = %name,
1625 offset = external_offset,
1626 length = external_length,
1627 "resolved external operator state from sidecar"
1628 );
1629 } else {
1630 return Err(DbError::Checkpoint(format!(
1631 "[LDB-6010] checkpoint {} sidecar range for operator '{name}' is invalid \
1632 (offset {}, length {}, sidecar {})",
1633 manifest.checkpoint_id,
1634 op.external_offset,
1635 op.external_length,
1636 state_data.len()
1637 )));
1638 }
1639 }
1640 }
1641 Ok(())
1642 }
1643
1644 fn restore_from(artifacts: CheckpointArtifacts) -> Result<RecoveredState, DbError> {
1646 let CheckpointArtifacts {
1647 mut manifest,
1648 state_data,
1649 } = artifacts;
1650 Self::resolve_external_states(&mut manifest, state_data.as_deref())?;
1651
1652 info!(
1653 checkpoint_id = manifest.checkpoint_id,
1654 epoch = manifest.epoch,
1655 "recovering from checkpoint"
1656 );
1657 Ok(RecoveredState {
1658 manifest,
1659 outcome: None,
1660 #[cfg(feature = "cluster")]
1661 cluster_capsule: None,
1662 })
1663 }
1664
1665 fn validate_outcome_manifest_binding(
1666 &self,
1667 outcome: &CheckpointOutcome,
1668 storage_id: u64,
1669 manifest: &CheckpointManifest,
1670 storage_participant: u64,
1671 ) -> Result<(), DbError> {
1672 if outcome.scope != self.expected_outcome_scope {
1673 return Err(DbError::Checkpoint(format!(
1674 "[LDB-6041] durable outcome scope {:?} does not match active runtime scope {:?}",
1675 outcome.scope, self.expected_outcome_scope
1676 )));
1677 }
1678 if outcome.checkpoint_id != storage_id {
1679 return Err(DbError::Checkpoint(format!(
1680 "[LDB-6041] epoch {} commits checkpoint {}, but storage candidate is {storage_id}",
1681 manifest.epoch, outcome.checkpoint_id
1682 )));
1683 }
1684 if outcome.deployment_id != manifest.deployment_id {
1685 return Err(DbError::Checkpoint(format!(
1686 "[LDB-6041] epoch {} checkpoint {storage_id} outcome deployment '{}' does not match manifest deployment '{}'",
1687 manifest.epoch, outcome.deployment_id, manifest.deployment_id
1688 )));
1689 }
1690 let outcome_participants = outcome.assignment_fence.as_ref().map_or_else(
1691 || vec![0],
1692 laminar_core::checkpoint::CheckpointAssignmentFence::participant_ids,
1693 );
1694 if outcome_participants
1695 .binary_search(&storage_participant)
1696 .is_err()
1697 {
1698 return Err(DbError::Checkpoint(format!(
1699 "[LDB-6041] epoch {} checkpoint {storage_id} storage participant {storage_participant} is absent from outcome participants {:?}",
1700 manifest.epoch, outcome_participants
1701 )));
1702 }
1703 if outcome.verdict != CheckpointVerdict::Commit {
1704 return Err(DbError::Checkpoint(format!(
1705 "[LDB-6041] epoch {} checkpoint {storage_id} has an Abort outcome",
1706 manifest.epoch
1707 )));
1708 }
1709 match (outcome.scope, outcome.recovery_capsule.as_ref()) {
1710 (CheckpointScope::Local, None) | (CheckpointScope::Cluster, Some(_)) => {}
1711 (CheckpointScope::Local, Some(_)) => {
1712 return Err(DbError::Checkpoint(format!(
1713 "[LDB-6041] local epoch {} checkpoint {storage_id} unexpectedly binds a cluster recovery capsule",
1714 manifest.epoch
1715 )));
1716 }
1717 (CheckpointScope::Cluster, None) => {
1718 return Err(DbError::Checkpoint(format!(
1719 "[LDB-6041] cluster epoch {} checkpoint {storage_id} has no recovery capsule",
1720 manifest.epoch
1721 )));
1722 }
1723 }
1724 Ok(())
1725 }
1726
1727 async fn complete_restore(
1728 &self,
1729 mut state: RecoveredState,
1730 outcome: Option<CheckpointOutcome>,
1731 storage_id: u64,
1732 storage_participant: u64,
1733 ) -> Result<RecoveredState, DbError> {
1734 state.outcome = outcome;
1735 if state.manifest.durable_phase == DurableCheckpointPhase::Prepared {
1736 if storage_participant == self.store.participant_id() {
1737 self.store
1738 .finalize(storage_id)
1739 .await
1740 .map_err(DbError::from)?;
1741 }
1742 state.manifest.durable_phase = DurableCheckpointPhase::Finalized;
1744 }
1745 Ok(state)
1746 }
1747
1748 async fn try_restore(
1751 &self,
1752 storage_id: u64,
1753 artifacts: CheckpointArtifacts,
1754 authority: Option<RecoveryOutcomeAuthority<'_>>,
1755 storage_participant: u64,
1756 known_outcome: Option<&CheckpointOutcome>,
1757 ) -> Result<Option<RecoveredState>, DbError> {
1758 let manifest = &artifacts.manifest;
1759 let (checkpoint_id, epoch) = (manifest.checkpoint_id, manifest.epoch);
1760 if manifest.participant_id != storage_participant {
1761 return Err(DbError::Checkpoint(format!(
1762 "[LDB-6041] checkpoint {storage_id} manifest participant {} does not match \
1763 storage participant {storage_participant}",
1764 manifest.participant_id
1765 )));
1766 }
1767 if manifest.pipeline_identity != self.expected_pipeline_identity {
1768 return Err(DbError::Checkpoint(format!(
1769 "[LDB-6043] checkpoint {storage_id} pipeline identity {} does not match runtime \
1770 identity {}; explicit checkpoint reset or savepoint migration is required",
1771 manifest.pipeline_identity.sha256, self.expected_pipeline_identity.sha256
1772 )));
1773 }
1774 if manifest.deployment_id != self.expected_deployment_id {
1775 return Err(DbError::Checkpoint(format!(
1776 "[LDB-6043] checkpoint {storage_id} deployment identity '{}' does not match \
1777 runtime identity '{}'; a partial storage reset is unsafe",
1778 manifest.deployment_id, self.expected_deployment_id
1779 )));
1780 }
1781 let outcome = match known_outcome {
1782 Some(outcome) => Some(outcome.clone()),
1783 None => match authority {
1784 Some(authority) => authority
1785 .outcome(epoch)
1786 .await?
1787 .filter(CheckpointOutcome::is_commit),
1788 None => None,
1789 },
1790 };
1791 if let Some(ref outcome) = outcome {
1792 self.validate_outcome_manifest_binding(
1793 outcome,
1794 storage_id,
1795 manifest,
1796 storage_participant,
1797 )?;
1798 }
1799
1800 if outcome.is_none()
1805 && (manifest.durable_phase == DurableCheckpointPhase::Prepared || authority.is_some())
1806 {
1807 warn!(
1808 checkpoint_id,
1809 epoch,
1810 phase = ?manifest.durable_phase,
1811 "checkpoint has no exact Commit outcome; trying older"
1812 );
1813 return Ok(None);
1814 }
1815
1816 let (artifacts, validation) = artifacts
1817 .validate(
1818 storage_id,
1819 storage_participant,
1820 self.store.key_group_count(),
1821 self.store.max_state_data_bytes(),
1822 )
1823 .await
1824 .map_err(DbError::from)?;
1825 if !validation.valid {
1826 error!(
1827 checkpoint_id,
1828 issues = ?validation.issues,
1829 "[LDB-6010] checkpoint integrity check failed"
1830 );
1831 warn!(
1832 checkpoint_id,
1833 epoch, "[LDB-6010] checkpoint corrupt, trying older"
1834 );
1835 if outcome.is_some() {
1836 return Err(DbError::Checkpoint(format!(
1837 "[LDB-6041] committed checkpoint {storage_id} is corrupt"
1838 )));
1839 }
1840 return Ok(None);
1841 }
1842 let state = Self::restore_from(artifacts)?;
1843 Ok(Some(
1844 self.complete_restore(state, outcome, storage_id, storage_participant)
1845 .await?,
1846 ))
1847 }
1848
1849 async fn restore_first_for_participant(
1851 &self,
1852 candidates: &[u64],
1853 authority: Option<RecoveryOutcomeAuthority<'_>>,
1854 storage_participant: u64,
1855 ) -> Result<Option<RecoveredState>, DbError> {
1856 for &checkpoint_id in candidates {
1857 match self
1858 .store
1859 .load_checkpoint_artifacts_for_participant(storage_participant, checkpoint_id)
1860 .await
1861 {
1862 Ok(Some(artifacts)) => {
1863 if let Some(state) = self
1864 .try_restore(
1865 checkpoint_id,
1866 artifacts,
1867 authority,
1868 storage_participant,
1869 None,
1870 )
1871 .await?
1872 {
1873 return Ok(Some(state));
1874 }
1875 }
1876 Ok(None) => {
1877 return Err(DbError::Checkpoint(format!(
1878 "[LDB-6041] checkpoint {checkpoint_id} is absent from participant \
1879 {storage_participant} recovery inventory"
1880 )));
1881 }
1882 Err(CheckpointStoreError::Serde(e)) => {
1883 warn!(checkpoint_id, error = %e, "corrupt checkpoint manifest, trying older");
1884 }
1885 Err(e) => return Err(DbError::from(e)),
1886 }
1887 }
1888 Err(Self::no_usable_checkpoint_error(candidates))
1889 }
1890
1891 fn no_usable_checkpoint_error(checkpoint_ids: &[u64]) -> DbError {
1892 DbError::Checkpoint(format!(
1893 "[LDB-6041] checkpoint history exists but none is usable; \
1894 refusing to start with empty state (checkpoint ids: {checkpoint_ids:?})"
1895 ))
1896 }
1897}
1898
1899#[cfg(test)]
1900mod tests;
1901
1902#[cfg(test)]
1903mod rehydration_tests;