Skip to main content

laminar_db/recovery_manager/
mod.rs

1//! Exact-cut recovery from committed v10 checkpoint manifests.
2#![allow(clippy::disallowed_types)] // bounded recovery metadata
3
4use 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/// One checksummed state frame staged during recovery.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RecoveredStateFrame {
34    /// Participant whose manifest declares the logical frame.
35    pub participant_id: u64,
36    /// Stable logical state identity.
37    pub key: StateFrameKey,
38    /// Verified state bytes.
39    pub payload: Bytes,
40}
41
42/// Complete state selected by one immutable Commit outcome.
43#[derive(Debug, Clone)]
44pub struct RecoveredState {
45    /// Terminal outcome authorizing this recovery cut.
46    pub outcome: CheckpointOutcome,
47    /// Verified global checkpoint index. It owns the authoritative source and time cut.
48    pub committed: CommittedCheckpointIndex,
49    /// Exact participant manifests, ordered by participant id.
50    pub manifests: Vec<CheckpointManifest>,
51    /// Verified frame inventory selected for the active runtime assignment.
52    pub state_frames: Vec<RecoveredStateFrame>,
53    /// Whether the active assignment is newer than the committed assignment that owns the cut.
54    pub(crate) reassigned: bool,
55    /// Committed owner of every vnode, in vnode order.
56    #[cfg(any(feature = "cluster", test))]
57    pub(crate) predecessor_owners: Vec<NodeId>,
58    /// Vnodes owned by this participant in the active assignment.
59    #[cfg(any(feature = "cluster", test))]
60    pub(crate) target_vnodes: Vec<u32>,
61}
62
63impl RecoveredState {
64    /// Validate topology recovery under the same complete owner map and stable participant ids.
65    /// Historical assignment bytes remain immutable; only a portable older cut may bootstrap
66    /// the exact current vnode set. This grants neither sink continuation nor intake Release.
67    #[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    /// Recovered epoch.
119    #[must_use]
120    pub const fn epoch(&self) -> u64 {
121        self.committed.epoch
122    }
123
124    /// Authoritative source offsets for replay.
125    #[must_use]
126    pub const fn source_offsets(&self) -> &BTreeMap<String, ConnectorCheckpoint> {
127        &self.committed.source_offsets
128    }
129
130    /// Authoritative per-channel watermark and idleness cut.
131    #[must_use]
132    pub fn channel_progress(&self) -> &[ChannelProgress] {
133        &self.committed.channel_progress
134    }
135
136    /// Global checkpoint watermark derived from active channels.
137    #[must_use]
138    pub const fn checkpoint_watermark(&self) -> Option<i64> {
139        self.committed.checkpoint_watermark
140    }
141}
142
143/// Loads a single explicitly committed checkpoint cut.
144pub 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    /// Load a currently authorized migration cut using the strict parent identity and local roster.
256    /// All historical manifests/segments retain their original identity. The rebuilt root must
257    /// equal the sealed requirements before any state payload is read. This supports private
258    /// target preparation and reconstruction after Commit. Ordinary recovery retains its strict
259    /// pipeline identity checks; this method grants no source/output authorization.
260    ///
261    /// # Errors
262    /// Rejects a reader bound to another participant, pipeline, deployment or scope, divergent
263    /// root metadata, damaged state/output bytes and exceeded configured graph payload limits.
264    #[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    /// Load the authority-selected target checkpoint or explicitly mapped migration root.
293    /// Target checkpoint state uses the ordinary strict target identity and manifest validation;
294    /// it never passes through the parent's migration mapping. This does not authorize actors.
295    ///
296    /// # Errors
297    /// Rejects a foreign reader/process, divergent selected identity, missing/corrupt artifacts
298    /// and managed-state limits. A failed target checkpoint read never falls back to the root.
299    #[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        // Selection proved the same owner map. Keep the exact historical checkpoint bytes;
322        // choose the existing portable bootstrap when its assignment precedes the current one.
323        // Ordinary strict restore remains mandatory for an identical assignment.
324        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    /// Bind recovery to one runtime topology, deployment, and outcome domain.
337    #[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    /// Load every exact participant manifest and stage this node's verified state frames.
353    ///
354    /// Recovery never discovers checkpoints or falls back to an older cut. The caller must load
355    /// the committed index through the exact reference carried by `outcome`.
356    ///
357    /// # Errors
358    /// Returns an error when the committed cut or any referenced object fails validation.
359    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;