1#![allow(clippy::disallowed_types)] use std::collections::{BTreeMap, BTreeSet};
5
6use bytes::Bytes;
7use futures::{StreamExt, TryStreamExt};
8use laminar_core::checkpoint::CheckpointStore;
9use laminar_core::checkpoint::{
10 checkpoint_manifest_bytes, checkpoint_sha256, ByteRange, ChannelProgress,
11 CheckpointAssignmentFence, CheckpointManifest, CheckpointScope, CommittedCheckpointIndex,
12 ConnectorCheckpoint, PipelineIdentity, StateChunkId, StateFrame, StateFrameKey,
13};
14use laminar_core::checkpoint_decision::{CheckpointOutcome, CheckpointVerdict};
15use laminar_core::state::NodeId;
16
17use crate::error::DbError;
18
19mod frame_selection;
20#[cfg(feature = "cluster")]
21mod subscription_output;
22
23use frame_selection::{
24 select_local_state_frames, select_reassigned_state_frames, select_same_assignment_frames,
25 validate_portable_reassignment,
26};
27
28const PARALLEL_MANIFEST_READS: usize = 8;
29const PARALLEL_CHUNK_READS: usize = 4;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RecoveredStateFrame {
34 pub participant_id: u64,
36 pub key: StateFrameKey,
38 pub payload: Bytes,
40}
41
42#[derive(Debug, Clone)]
44pub struct RecoveredState {
45 pub outcome: CheckpointOutcome,
47 pub committed: CommittedCheckpointIndex,
49 pub manifests: Vec<CheckpointManifest>,
51 pub state_frames: Vec<RecoveredStateFrame>,
53 pub(crate) reassigned: bool,
55 #[cfg(any(feature = "cluster", test))]
57 pub(crate) predecessor_owners: Vec<NodeId>,
58 #[cfg(any(feature = "cluster", test))]
60 pub(crate) target_vnodes: Vec<u32>,
61}
62
63impl RecoveredState {
64 #[cfg(feature = "cluster")]
68 pub(crate) fn validate_topology_assignment(
69 &self,
70 input: &laminar_core::cluster::control::TopologyRestoreInput,
71 ) -> Result<(), DbError> {
72 use laminar_core::cluster::control::TopologyError;
73
74 let predecessor = self
75 .committed
76 .assignment_fence
77 .as_ref()
78 .ok_or(TopologyError::Fenced)?;
79 let target = input.assignment();
80 let owner_ids = self
81 .predecessor_owners
82 .iter()
83 .map(|owner| owner.0)
84 .collect::<Vec<_>>();
85 let owns_same_vnodes = self
86 .predecessor_owners
87 .iter()
88 .enumerate()
89 .filter(|(_, owner)| owner.0 == input.process().participant.node_id)
90 .map(|(vnode, _)| u32::try_from(vnode))
91 .eq(input.owned_vnodes().iter().copied().map(Ok));
92 if !predecessor.is_canonical()
93 || !target.is_canonical()
94 || !predecessor.matches_owner_map(&owner_ids)
95 || predecessor.assignment_version > target.assignment_version
96 || self.reassigned != (predecessor.assignment_version < target.assignment_version)
97 || (!self.reassigned && predecessor != target)
98 || (self.reassigned && !self.committed.reassignment_portable)
99 || predecessor.assignment_digest != target.assignment_digest
100 || predecessor.vnode_count != target.vnode_count
101 || predecessor.partitioning_abi_version != target.partitioning_abi_version
102 || !predecessor
103 .participants
104 .iter()
105 .map(|participant| participant.node_id)
106 .eq(target
107 .participants
108 .iter()
109 .map(|participant| participant.node_id))
110 || self.target_vnodes != input.owned_vnodes()
111 || !owns_same_vnodes
112 {
113 return Err(TopologyError::Fenced.into());
114 }
115 Ok(())
116 }
117
118 #[must_use]
120 pub const fn epoch(&self) -> u64 {
121 self.committed.epoch
122 }
123
124 #[must_use]
126 pub const fn source_offsets(&self) -> &BTreeMap<String, ConnectorCheckpoint> {
127 &self.committed.source_offsets
128 }
129
130 #[must_use]
132 pub fn channel_progress(&self) -> &[ChannelProgress] {
133 &self.committed.channel_progress
134 }
135
136 #[must_use]
138 pub const fn checkpoint_watermark(&self) -> Option<i64> {
139 self.committed.checkpoint_watermark
140 }
141}
142
143pub struct RecoveryManager<'a> {
145 store: &'a dyn CheckpointStore,
146 pipeline_identity: PipelineIdentity,
147 deployment_id: String,
148 scope: CheckpointScope,
149}
150
151#[derive(Debug, Clone)]
152pub(crate) struct ClusterRecoveryTarget {
153 pub(crate) assignment: CheckpointAssignmentFence,
154 pub(crate) owned_vnodes: Vec<u32>,
155 pub(crate) max_graph_payload_bytes: usize,
156}
157
158struct RecoveryFrameSelection {
159 plans: Vec<VerifiedStateFramePlan>,
160 reassigned: bool,
161 #[cfg(any(feature = "cluster", test))]
162 predecessor_owners: Vec<NodeId>,
163 #[cfg(any(feature = "cluster", test))]
164 target_vnodes: Vec<u32>,
165}
166
167#[derive(Debug)]
168struct PendingFrame {
169 participant_id: u64,
170 key: StateFrameKey,
171 range: ByteRange,
172 sha256: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176struct ChunkMetadata {
177 object_length: u64,
178 sha256: String,
179}
180
181#[derive(Debug)]
182pub(crate) struct VerifiedStateFramePlan {
183 chunks: BTreeMap<StateChunkId, ChunkMetadata>,
184 frames: BTreeMap<StateChunkId, Vec<PendingFrame>>,
185}
186
187impl VerifiedStateFramePlan {
188 pub(crate) fn new(
189 manifest: &CheckpointManifest,
190 selected: &[StateFrame],
191 ) -> Result<Self, DbError> {
192 if selected.windows(2).any(|pair| pair[0].key >= pair[1].key) {
193 return Err(checkpoint_error(
194 "selected state frames are not in canonical logical-key order",
195 ));
196 }
197
198 let mut declared = BTreeMap::<StateChunkId, ChunkMetadata>::new();
199 insert_chunk(
200 &mut declared,
201 manifest.node_data.chunk,
202 ChunkMetadata {
203 object_length: manifest.node_data.object_length,
204 sha256: manifest.node_data.sha256.clone(),
205 },
206 )?;
207 for reference in &manifest.referenced_chunks {
208 insert_chunk(
209 &mut declared,
210 reference.chunk,
211 ChunkMetadata {
212 object_length: reference.object_length,
213 sha256: reference.sha256.clone(),
214 },
215 )?;
216 }
217
218 let mut chunks = BTreeMap::new();
219 let mut frames = BTreeMap::<StateChunkId, Vec<PendingFrame>>::new();
220 for frame in selected {
221 let Ok(index) = manifest
222 .state_frames
223 .binary_search_by(|candidate| candidate.key.cmp(&frame.key))
224 else {
225 return Err(checkpoint_error(format!(
226 "selected state frame {:?} is absent from its manifest",
227 frame.key
228 )));
229 };
230 if manifest.state_frames[index] != *frame {
231 return Err(checkpoint_error(format!(
232 "selected state frame {:?} differs from its manifest declaration",
233 frame.key
234 )));
235 }
236 let metadata = declared.get(&frame.chunk).cloned().ok_or_else(|| {
237 checkpoint_error(format!(
238 "state frame {:?} references undeclared node object {:?}",
239 frame.key, frame.chunk
240 ))
241 })?;
242 insert_chunk(&mut chunks, frame.chunk, metadata)?;
243 frames.entry(frame.chunk).or_default().push(PendingFrame {
244 participant_id: manifest.participant_id,
245 key: frame.key.clone(),
246 range: frame.range,
247 sha256: frame.sha256.clone(),
248 });
249 }
250 Ok(Self { chunks, frames })
251 }
252}
253
254impl<'a> RecoveryManager<'a> {
255 #[cfg(feature = "cluster")]
265 pub async fn recover_topology_root(
266 &self,
267 input: &laminar_core::cluster::control::TopologyRestoreInput,
268 max_graph_payload_bytes: usize,
269 ) -> Result<RecoveredState, DbError> {
270 if self.store.participant_id() != input.process().participant.node_id
271 || self.pipeline_identity != input.descriptor().parent_pipeline
272 || self.deployment_id != input.descriptor().deployment_id
273 || self.scope != CheckpointScope::Cluster
274 {
275 return Err(checkpoint_error(
276 "migration restore reader differs from its exact parent/process authority",
277 ));
278 }
279 self.recover_committed_inner(
280 input.outcome(),
281 input.checkpoint(),
282 Some(ClusterRecoveryTarget {
283 assignment: input.assignment().clone(),
284 owned_vnodes: input.owned_vnodes().to_vec(),
285 max_graph_payload_bytes,
286 }),
287 Some(input),
288 )
289 .await
290 }
291
292 #[cfg(feature = "cluster")]
300 pub async fn recover_topology_selection(
301 &self,
302 input: &laminar_core::cluster::control::TopologyRecoveryInput,
303 max_graph_payload_bytes: usize,
304 ) -> Result<RecoveredState, DbError> {
305 use laminar_core::cluster::control::TopologyRecoveryCut;
306
307 if input.cut() == TopologyRecoveryCut::MigrationRoot {
308 return self
309 .recover_topology_root(input.migration(), max_graph_payload_bytes)
310 .await;
311 }
312 if self.store.participant_id() != input.migration().process().participant.node_id
313 || self.pipeline_identity != input.migration().descriptor().target_pipeline
314 || self.deployment_id != input.migration().descriptor().deployment_id
315 || self.scope != CheckpointScope::Cluster
316 {
317 return Err(checkpoint_error(
318 "target checkpoint reader differs from its selected target/process authority",
319 ));
320 }
321 self.recover_committed_for_target(
325 input.outcome(),
326 input.checkpoint(),
327 Some(ClusterRecoveryTarget {
328 assignment: input.migration().assignment().clone(),
329 owned_vnodes: input.migration().owned_vnodes().to_vec(),
330 max_graph_payload_bytes,
331 }),
332 )
333 .await
334 }
335
336 #[must_use]
338 pub fn new(
339 store: &'a dyn CheckpointStore,
340 pipeline_identity: &PipelineIdentity,
341 deployment_id: &str,
342 scope: CheckpointScope,
343 ) -> Self {
344 Self {
345 store,
346 pipeline_identity: pipeline_identity.clone(),
347 deployment_id: deployment_id.to_owned(),
348 scope,
349 }
350 }
351
352 pub async fn recover_committed(
360 &self,
361 outcome: &CheckpointOutcome,
362 committed: &CommittedCheckpointIndex,
363 ) -> Result<RecoveredState, DbError> {
364 self.recover_committed_for_target(outcome, committed, None)
365 .await
366 }
367
368 pub(crate) async fn recover_committed_for_target(
369 &self,
370 outcome: &CheckpointOutcome,
371 committed: &CommittedCheckpointIndex,
372 cluster_target: Option<ClusterRecoveryTarget>,
373 ) -> Result<RecoveredState, DbError> {
374 self.recover_committed_inner(outcome, committed, cluster_target, None)
375 .await
376 }
377
378 async fn recover_committed_inner(
379 &self,
380 outcome: &CheckpointOutcome,
381 committed: &CommittedCheckpointIndex,
382 cluster_target: Option<ClusterRecoveryTarget>,
383 #[cfg(feature = "cluster")] topology: Option<
384 &laminar_core::cluster::control::TopologyRestoreInput,
385 >,
386 #[cfg(not(feature = "cluster"))] _topology: Option<()>,
387 ) -> Result<RecoveredState, DbError> {
388 self.validate_cut(outcome, committed)?;
389
390 let checkpoint_id = committed.checkpoint_id;
391 let reads = committed
392 .participants
393 .iter()
394 .map(|participant| {
395 let store = self.store;
396 async move {
397 store
398 .load_manifest_verified(
399 participant.participant_id,
400 checkpoint_id,
401 participant.manifest_len,
402 &participant.manifest_sha256,
403 )
404 .await
405 .map_err(|error| {
406 checkpoint_error(format!(
407 "participant {} checkpoint {} manifest is unreadable: {error}",
408 participant.participant_id, checkpoint_id
409 ))
410 })?
411 .ok_or_else(|| {
412 checkpoint_error(format!(
413 "participant {} checkpoint {} manifest is missing",
414 participant.participant_id, checkpoint_id
415 ))
416 })
417 }
418 })
419 .collect::<Vec<_>>();
420 let mut manifests = futures::stream::iter(reads)
421 .buffer_unordered(PARALLEL_MANIFEST_READS)
422 .try_collect::<Vec<_>>()
423 .await?;
424 manifests.sort_unstable_by_key(|manifest| manifest.participant_id);
425
426 self.validate_manifests(committed, &manifests)?;
427 #[cfg(feature = "cluster")]
428 if let Some(input) = topology {
429 input.root().validate_restore_cut(
430 input.operation(),
431 input.descriptor(),
432 committed,
433 &manifests,
434 )?;
435 }
436 #[cfg(feature = "cluster")]
437 subscription_output::validate_committed_subscription_segments(self.store, &manifests)
438 .await?;
439 let selection = self.select_state_frames(committed, &manifests, cluster_target.as_ref())?;
440 let state_frames = load_verified_state_frames(self.store, selection.plans).await?;
441
442 Ok(RecoveredState {
443 outcome: outcome.clone(),
444 committed: committed.clone(),
445 manifests,
446 state_frames,
447 reassigned: selection.reassigned,
448 #[cfg(any(feature = "cluster", test))]
449 predecessor_owners: selection.predecessor_owners,
450 #[cfg(any(feature = "cluster", test))]
451 target_vnodes: selection.target_vnodes,
452 })
453 }
454
455 fn select_state_frames(
456 &self,
457 committed: &CommittedCheckpointIndex,
458 manifests: &[CheckpointManifest],
459 cluster_target: Option<&ClusterRecoveryTarget>,
460 ) -> Result<RecoveryFrameSelection, DbError> {
461 let local_participant = self.store.participant_id();
462 let Some(target) = cluster_target else {
463 return select_local_state_frames(manifests, local_participant);
464 };
465 if self.scope != CheckpointScope::Cluster {
466 return Err(checkpoint_error(
467 "a cluster recovery target cannot be used for local recovery",
468 ));
469 }
470
471 let predecessor = committed.assignment_fence.as_ref().ok_or_else(|| {
472 checkpoint_error("cluster checkpoint has no committed assignment fence")
473 })?;
474 validate_cluster_target(target, local_participant, predecessor.vnode_count)?;
475 let predecessor_owners = predecessor_owner_map(manifests, predecessor)?;
476
477 if target.assignment.assignment_version == predecessor.assignment_version {
478 return select_same_assignment_frames(
479 target,
480 predecessor,
481 manifests,
482 local_participant,
483 predecessor_owners,
484 );
485 }
486
487 validate_portable_reassignment(committed, target, predecessor)?;
488 select_reassigned_state_frames(target, manifests, local_participant, predecessor_owners)
489 }
490
491 fn validate_cut(
492 &self,
493 outcome: &CheckpointOutcome,
494 committed: &CommittedCheckpointIndex,
495 ) -> Result<(), DbError> {
496 committed
497 .validate()
498 .map_err(|error| checkpoint_error(format!("committed checkpoint index: {error}")))?;
499 let (_, observed_reference) = committed
500 .encode_and_reference()
501 .map_err(|error| checkpoint_error(format!("committed checkpoint index: {error}")))?;
502 if outcome.committed_checkpoint.as_ref() != Some(&observed_reference) {
503 return Err(checkpoint_error(
504 "outcome does not bind the supplied committed checkpoint index",
505 ));
506 }
507 if outcome.verdict != CheckpointVerdict::Commit {
508 return Err(checkpoint_error(format!(
509 "epoch {} checkpoint {} has an Abort outcome",
510 outcome.epoch, outcome.checkpoint_id
511 )));
512 }
513 if outcome.scope != self.scope || committed.scope != self.scope {
514 return Err(checkpoint_error(format!(
515 "checkpoint scope does not match the active {:?} runtime",
516 self.scope
517 )));
518 }
519 if outcome.epoch != committed.epoch
520 || outcome.checkpoint_id != committed.checkpoint_id
521 || outcome.deployment_id != committed.deployment_id
522 {
523 return Err(checkpoint_error(
524 "outcome does not identify the supplied committed checkpoint index",
525 ));
526 }
527 if committed.pipeline_identity != self.pipeline_identity {
528 return Err(checkpoint_error(format!(
529 "checkpoint pipeline identity {} does not match runtime identity {}",
530 committed.pipeline_identity.sha256, self.pipeline_identity.sha256
531 )));
532 }
533 if committed.deployment_id != self.deployment_id {
534 return Err(checkpoint_error(format!(
535 "checkpoint deployment '{}' does not match runtime deployment '{}'",
536 committed.deployment_id, self.deployment_id
537 )));
538 }
539 if outcome.assignment_fence != committed.assignment_fence {
540 return Err(checkpoint_error(
541 "outcome and committed index assignment fences differ",
542 ));
543 }
544 match (
545 self.scope,
546 committed.assignment_fence.as_ref(),
547 outcome.leader_proof.as_ref(),
548 ) {
549 (CheckpointScope::Local, None, None) => {}
550 (CheckpointScope::Cluster, Some(fence), Some(proof))
551 if proof.is_canonical()
552 && fence.participant_incarnation(proof.owner.node_id)
553 == Some(proof.owner.boot_id) => {}
554 _ => {
555 return Err(checkpoint_error(
556 "outcome authority is not valid for the committed recovery scope",
557 ));
558 }
559 }
560 Ok(())
561 }
562
563 fn validate_manifests(
564 &self,
565 committed: &CommittedCheckpointIndex,
566 manifests: &[CheckpointManifest],
567 ) -> Result<(), DbError> {
568 let encoded = manifests
569 .iter()
570 .map(|manifest| {
571 checkpoint_manifest_bytes(manifest).map_err(|error| {
572 checkpoint_error(format!("encode recovered manifest: {error}"))
573 })
574 })
575 .collect::<Result<Vec<_>, _>>()?;
576 let views = manifests
577 .iter()
578 .zip(&encoded)
579 .map(|(manifest, bytes)| (manifest, bytes.as_slice()))
580 .collect::<Vec<_>>();
581 committed
582 .validate_participant_manifests(&views)
583 .map_err(|error| {
584 checkpoint_error(format!("committed checkpoint manifests: {error}"))
585 })?;
586
587 Ok(())
588 }
589}
590
591fn validate_cluster_target(
592 target: &ClusterRecoveryTarget,
593 local_participant: u64,
594 vnode_count: u32,
595) -> Result<(), DbError> {
596 if !target.assignment.is_canonical() || target.assignment.vnode_count != vnode_count {
597 return Err(checkpoint_error(
598 "cluster recovery target is not canonical or has an incompatible vnode count",
599 ));
600 }
601 if target
602 .owned_vnodes
603 .windows(2)
604 .any(|pair| pair[0] >= pair[1])
605 || target
606 .owned_vnodes
607 .iter()
608 .any(|vnode| *vnode >= vnode_count)
609 {
610 return Err(checkpoint_error(
611 "cluster recovery target vnode roster is not canonical",
612 ));
613 }
614 if target.assignment.contains(local_participant) == target.owned_vnodes.is_empty() {
615 return Err(checkpoint_error(
616 "cluster recovery target participant roster disagrees with local vnode ownership",
617 ));
618 }
619 if target.max_graph_payload_bytes == 0 {
620 return Err(checkpoint_error(
621 "cluster recovery graph payload limit must be greater than zero",
622 ));
623 }
624 Ok(())
625}
626
627fn predecessor_owner_map(
628 manifests: &[CheckpointManifest],
629 predecessor: &CheckpointAssignmentFence,
630) -> Result<Vec<NodeId>, DbError> {
631 let vnode_count = usize::try_from(predecessor.vnode_count)
632 .map_err(|_| checkpoint_error("committed vnode count does not fit this runtime"))?;
633 let mut owners = vec![NodeId::UNASSIGNED; vnode_count];
634 for manifest in manifests {
635 for vnode in &manifest.owned_vnodes {
636 let owner = owners.get_mut(usize::from(*vnode)).ok_or_else(|| {
637 checkpoint_error(format!(
638 "participant {} owns out-of-range vnode {vnode}",
639 manifest.participant_id
640 ))
641 })?;
642 if !owner.is_unassigned() {
643 return Err(checkpoint_error(format!(
644 "vnode {vnode} has more than one committed owner"
645 )));
646 }
647 *owner = NodeId(manifest.participant_id);
648 }
649 }
650 if owners.iter().any(NodeId::is_unassigned) {
651 return Err(checkpoint_error(
652 "committed manifests do not cover every vnode",
653 ));
654 }
655 let owner_ids = owners.iter().map(|owner| owner.0).collect::<Vec<_>>();
656 if !predecessor.matches_owner_map(&owner_ids) {
657 return Err(checkpoint_error(
658 "committed manifests do not reconstruct the assignment fence",
659 ));
660 }
661 Ok(owners)
662}
663
664fn predecessor_owner(owners: &[NodeId], vnode: u32) -> Result<NodeId, DbError> {
665 let index = usize::try_from(vnode)
666 .map_err(|_| checkpoint_error(format!("vnode {vnode} does not fit this runtime")))?;
667 owners
668 .get(index)
669 .copied()
670 .ok_or_else(|| checkpoint_error(format!("vnode {vnode} is out of range")))
671}
672
673fn graph_operator(operator_id: &str) -> Result<bool, DbError> {
674 match operator_id.strip_prefix("graph:") {
675 Some("") => Err(checkpoint_error(
676 "graph state frame has an empty operator identity",
677 )),
678 Some(_) => Ok(true),
679 None => Ok(false),
680 }
681}
682
683fn selected_graph_payload_bytes(frames: &[StateFrame], limit: usize) -> Result<usize, DbError> {
684 frames.iter().try_fold(0usize, |total, frame| {
685 let operator_id = match &frame.key {
686 StateFrameKey::OperatorWhole { operator_id }
687 | StateFrameKey::Vnode { operator_id, .. } => operator_id,
688 };
689 if !operator_id.starts_with("graph:") {
690 return Ok(total);
691 }
692 let length = usize::try_from(frame.range.length).map_err(|_| {
693 checkpoint_error(format!(
694 "graph state frame {:?} length does not fit this runtime",
695 frame.key
696 ))
697 })?;
698 total
699 .checked_add(length)
700 .ok_or_else(|| DbError::ManagedStateBudgetExceeded {
701 context: "checkpoint recovery graph payload".into(),
702 accounted_bytes: usize::MAX,
703 limit_bytes: limit,
704 })
705 })
706}
707
708fn enforce_graph_payload_limit(bytes: usize, limit: usize) -> Result<(), DbError> {
709 if bytes > limit {
710 return Err(DbError::ManagedStateBudgetExceeded {
711 context: "checkpoint recovery graph payload".into(),
712 accounted_bytes: bytes,
713 limit_bytes: limit,
714 });
715 }
716 Ok(())
717}
718
719pub(crate) async fn load_verified_state_frames(
720 store: &dyn CheckpointStore,
721 plans: Vec<VerifiedStateFramePlan>,
722) -> Result<Vec<RecoveredStateFrame>, DbError> {
723 let mut chunks = BTreeMap::<StateChunkId, ChunkMetadata>::new();
724 let mut frames = BTreeMap::<StateChunkId, Vec<PendingFrame>>::new();
725
726 for plan in plans {
727 for (chunk, metadata) in plan.chunks {
728 insert_chunk(&mut chunks, chunk, metadata)?;
729 }
730 for (chunk, mut pending) in plan.frames {
731 frames.entry(chunk).or_default().append(&mut pending);
732 }
733 }
734
735 let work = frames.into_iter().map(|(chunk, requests)| {
736 let expected = chunks.remove(&chunk);
737 async move {
738 let expected = expected.ok_or_else(|| {
739 checkpoint_error(format!(
740 "state frames reference undeclared node object {chunk:?}"
741 ))
742 })?;
743 let ranges = requests
744 .iter()
745 .map(|request| request.range)
746 .collect::<Vec<_>>();
747 let payloads = store
748 .load_node_data_ranges(chunk, expected.object_length, &ranges)
749 .await
750 .map_err(|error| checkpoint_error(format!("node object {chunk:?}: {error}")))?
751 .ok_or_else(|| checkpoint_error(format!("node object {chunk:?} is missing")))?;
752 if payloads.len() != requests.len() {
753 return Err(checkpoint_error(format!(
754 "node object {chunk:?} returned an incomplete range set"
755 )));
756 }
757
758 requests
759 .into_iter()
760 .zip(payloads)
761 .map(|(request, payload)| {
762 let actual = checkpoint_sha256(&payload);
763 if actual != request.sha256 {
764 return Err(checkpoint_error(format!(
765 "state frame {:?} checksum mismatch: expected {}, got {actual}",
766 request.key, request.sha256
767 )));
768 }
769 Ok(RecoveredStateFrame {
770 participant_id: request.participant_id,
771 key: request.key,
772 payload,
773 })
774 })
775 .collect::<Result<Vec<_>, DbError>>()
776 }
777 });
778
779 let mut recovered = futures::stream::iter(work)
780 .buffer_unordered(PARALLEL_CHUNK_READS)
781 .try_collect::<Vec<Vec<RecoveredStateFrame>>>()
782 .await?
783 .into_iter()
784 .flatten()
785 .collect::<Vec<_>>();
786 recovered.sort_unstable_by(|left, right| {
787 (left.participant_id, &left.key).cmp(&(right.participant_id, &right.key))
788 });
789 if recovered
790 .windows(2)
791 .any(|pair| pair[0].participant_id == pair[1].participant_id && pair[0].key == pair[1].key)
792 {
793 return Err(checkpoint_error(
794 "recovered state contains duplicate logical frames",
795 ));
796 }
797 Ok(recovered)
798}
799
800fn insert_chunk(
801 chunks: &mut BTreeMap<StateChunkId, ChunkMetadata>,
802 chunk: StateChunkId,
803 metadata: ChunkMetadata,
804) -> Result<(), DbError> {
805 match chunks.entry(chunk) {
806 std::collections::btree_map::Entry::Vacant(entry) => {
807 entry.insert(metadata);
808 }
809 std::collections::btree_map::Entry::Occupied(entry) => {
810 if entry.get() != &metadata {
811 return Err(checkpoint_error(format!(
812 "immutable node object {chunk:?} has conflicting metadata"
813 )));
814 }
815 }
816 }
817 Ok(())
818}
819
820fn checkpoint_error(message: impl Into<String>) -> DbError {
821 DbError::Checkpoint(format!("[LDB-6041] {}", message.into()))
822}
823
824#[cfg(test)]
825mod tests;