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