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 #[must_use]
66 pub const fn epoch(&self) -> u64 {
67 self.committed.epoch
68 }
69
70 #[must_use]
72 pub const fn source_offsets(&self) -> &BTreeMap<String, ConnectorCheckpoint> {
73 &self.committed.source_offsets
74 }
75
76 #[must_use]
78 pub fn channel_progress(&self) -> &[ChannelProgress] {
79 &self.committed.channel_progress
80 }
81
82 #[must_use]
84 pub const fn checkpoint_watermark(&self) -> Option<i64> {
85 self.committed.checkpoint_watermark
86 }
87}
88
89pub 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 #[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 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;