1#![allow(clippy::disallowed_types)] mod participant_validation;
6mod view_frontiers;
7mod view_log_references;
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13
14use super::{
15 channel_progress_frontiers_by_source, classify_channel_progress,
16 merge_node_subscription_manifests, ChannelProgress, CheckpointAssignmentFence,
17 CheckpointManifest, ConnectorCheckpoint, PipelineIdentity, SINGLETON_WATERMARK_CHANNEL,
18};
19use crate::state::{KeyGroupCount, LOCAL_NODE_ID};
20
21pub use view_frontiers::fold_view_frontiers;
22pub use view_log_references::{
23 adopted_view_logs, check_view_log_adoption, validate_cut_view_logs,
24 validate_departed_view_logs, AdoptedSegment, AdoptedViewLog,
25};
26
27pub const COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 5;
29const LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 3;
30pub(super) const SOURCE_WATERMARK_INDEX_VERSION: u32 = 4;
32
33pub const MAX_COMMITTED_CHECKPOINT_INDEX_BYTES: usize = 8 * 1024 * 1024;
35
36#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum CheckpointScope {
40 Local,
42 Cluster,
44}
45
46#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "snake_case", tag = "state", content = "watermark_ms")]
49pub enum CheckpointWatermark {
50 #[default]
52 Uninitialized,
53 Idle,
55 Active(i64),
57}
58
59impl CheckpointWatermark {
60 #[must_use]
62 pub const fn cluster_min(self, other: Self) -> Self {
63 match (self, other) {
64 (Self::Uninitialized, _) | (_, Self::Uninitialized) => Self::Uninitialized,
65 (Self::Idle, Self::Idle) => Self::Idle,
66 (Self::Idle, Self::Active(value)) | (Self::Active(value), Self::Idle) => {
67 Self::Active(value)
68 }
69 (Self::Active(left), Self::Active(right)) => {
70 Self::Active(if left < right { left } else { right })
71 }
72 }
73 }
74
75 #[must_use]
77 pub const fn active_value(self) -> Option<i64> {
78 match self {
79 Self::Active(value) => Some(value),
80 Self::Uninitialized | Self::Idle => None,
81 }
82 }
83
84 pub fn validate(self) -> Result<(), String> {
89 if self == Self::Active(i64::MIN) {
90 return Err("active checkpoint watermark cannot use the uninitialized sentinel".into());
91 }
92 Ok(())
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct CommittedParticipantRef {
100 pub participant_id: u64,
102 pub manifest_len: u64,
104 pub manifest_sha256: String,
106 pub node_data_len: u64,
108 pub node_data_sha256: String,
110}
111
112impl CommittedParticipantRef {
113 pub fn from_manifest(
118 manifest: &CheckpointManifest,
119 persisted_manifest: &[u8],
120 ) -> Result<Self, String> {
121 let decoded: CheckpointManifest = serde_json::from_slice(persisted_manifest)
122 .map_err(|error| format!("participant manifest is not valid JSON: {error}"))?;
123 if decoded != *manifest {
124 return Err("persisted participant manifest differs from the supplied manifest".into());
125 }
126 let reference = Self {
127 participant_id: manifest.participant_id,
128 manifest_len: u64::try_from(persisted_manifest.len())
129 .map_err(|_| "participant manifest length overflowed".to_owned())?,
130 manifest_sha256: sha256_hex(persisted_manifest),
131 node_data_len: manifest.node_data.object_length,
132 node_data_sha256: manifest.node_data.sha256.clone(),
133 };
134 reference.validate()?;
135 Ok(reference)
136 }
137
138 pub fn validate(&self) -> Result<(), String> {
143 if self.participant_id == 0 {
144 return Err("committed participant ID cannot be zero".into());
145 }
146 if self.manifest_len == 0 {
147 return Err("committed participant manifest cannot be empty".into());
148 }
149 validate_digest("participant manifest", &self.manifest_sha256)?;
150 validate_digest("participant node data", &self.node_data_sha256)?;
151 Ok(())
152 }
153
154 pub fn verify_manifest(
159 &self,
160 manifest: &CheckpointManifest,
161 persisted_manifest: &[u8],
162 ) -> Result<(), String> {
163 self.validate()?;
164 if u64::try_from(persisted_manifest.len()).unwrap_or(u64::MAX) != self.manifest_len
165 || sha256_hex(persisted_manifest) != self.manifest_sha256
166 {
167 return Err(format!(
168 "participant {} manifest length or digest differs from the committed index",
169 self.participant_id
170 ));
171 }
172 let decoded: CheckpointManifest = serde_json::from_slice(persisted_manifest)
173 .map_err(|error| format!("participant manifest is not valid JSON: {error}"))?;
174 if decoded != *manifest {
175 return Err(format!(
176 "participant {} decoded manifest differs from its persisted bytes",
177 self.participant_id
178 ));
179 }
180 if manifest.participant_id != self.participant_id
181 || manifest.node_data.chunk.participant_id != self.participant_id
182 || manifest.node_data.object_length != self.node_data_len
183 || manifest.node_data.sha256 != self.node_data_sha256
184 {
185 return Err(format!(
186 "participant {} manifest does not match its committed object reference",
187 self.participant_id
188 ));
189 }
190 Ok(())
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct CommittedCheckpointRef {
198 pub epoch: u64,
200 pub checkpoint_id: u64,
202 pub sha256: String,
204 pub len: u64,
206}
207
208impl CommittedCheckpointRef {
209 pub fn validate(&self) -> Result<(), String> {
214 validate_attempt(
215 self.epoch,
216 self.checkpoint_id,
217 "committed checkpoint reference",
218 )?;
219 validate_digest("committed checkpoint", &self.sha256)?;
220 if self.len == 0
221 || self.len > u64::try_from(MAX_COMMITTED_CHECKPOINT_INDEX_BYTES).unwrap_or(u64::MAX)
222 {
223 return Err(format!(
224 "committed checkpoint index length {} is outside 1..={MAX_COMMITTED_CHECKPOINT_INDEX_BYTES}",
225 self.len
226 ));
227 }
228 Ok(())
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct CommittedCheckpointIndex {
236 pub version: u32,
238 pub deployment_id: String,
240 pub pipeline_identity: PipelineIdentity,
242 pub epoch: u64,
244 pub checkpoint_id: u64,
246 pub scope: CheckpointScope,
248 pub vnode_count: u16,
250 pub assignment_fence: Option<CheckpointAssignmentFence>,
252 pub reassignment_portable: bool,
254 pub predecessor: Option<CommittedCheckpointRef>,
256 pub participants: Vec<CommittedParticipantRef>,
258 pub source_names: Vec<String>,
260 pub source_offsets: BTreeMap<String, ConnectorCheckpoint>,
262 pub channel_progress: Vec<ChannelProgress>,
264 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
267 pub source_watermarks: BTreeMap<String, i64>,
268 pub checkpoint_watermark: Option<i64>,
270 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
274 pub view_frontiers: BTreeMap<String, i64>,
275}
276
277impl CommittedCheckpointIndex {
278 pub fn validate(&self) -> Result<(), String> {
283 if !(LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION..=COMMITTED_CHECKPOINT_INDEX_VERSION)
284 .contains(&self.version)
285 {
286 return Err(format!(
287 "unsupported committed checkpoint index version {}; expected {}..={COMMITTED_CHECKPOINT_INDEX_VERSION}",
288 self.version, LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION
289 ));
290 }
291 validate_attempt(self.epoch, self.checkpoint_id, "committed checkpoint index")?;
292 validate_deployment(&self.deployment_id)?;
293 if !self.pipeline_identity.is_canonical() {
294 return Err("committed checkpoint pipeline identity is not canonical".into());
295 }
296 KeyGroupCount::try_from(u32::from(self.vnode_count))
297 .map_err(|_| "committed checkpoint vnode count is invalid".to_owned())?;
298
299 if self.participants.is_empty() {
300 return Err("committed checkpoint has no participants".into());
301 }
302 for participant in &self.participants {
303 participant.validate()?;
304 }
305 if !self
306 .participants
307 .windows(2)
308 .all(|pair| pair[0].participant_id < pair[1].participant_id)
309 {
310 return Err(
311 "committed participants must be sorted and unique by participant ID".into(),
312 );
313 }
314
315 match (self.scope, self.assignment_fence.as_ref()) {
316 (CheckpointScope::Local, None)
317 if self.participants.len() == 1
318 && self.participants[0].participant_id == LOCAL_NODE_ID.0
319 && !self.reassignment_portable => {}
320 (CheckpointScope::Local, None) if self.reassignment_portable => {
321 return Err(
322 "local committed checkpoint cannot claim vnode reassignment portability".into(),
323 );
324 }
325 (CheckpointScope::Local, None) => {
326 return Err("local committed checkpoint requires only LOCAL_NODE_ID".into());
327 }
328 (CheckpointScope::Local, Some(_)) => {
329 return Err("local committed checkpoint cannot carry an assignment fence".into());
330 }
331 (CheckpointScope::Cluster, Some(fence)) => {
332 if !self.reassignment_portable {
333 return Err(
334 "cluster committed checkpoint must be portable across vnode reassignment"
335 .into(),
336 );
337 }
338 if !fence.is_canonical() || fence.vnode_count != u32::from(self.vnode_count) {
339 return Err(
340 "cluster committed checkpoint has an invalid assignment fence".into(),
341 );
342 }
343 let actual: Vec<_> = self
344 .participants
345 .iter()
346 .map(|participant| participant.participant_id)
347 .collect();
348 if actual != fence.participant_ids() {
349 return Err(
350 "committed participants do not exactly cover the assignment fence".into(),
351 );
352 }
353 }
354 (CheckpointScope::Cluster, None) => {
355 return Err("cluster committed checkpoint requires an assignment fence".into());
356 }
357 }
358
359 if let Some(predecessor) = &self.predecessor {
360 predecessor.validate()?;
361 if predecessor.epoch >= self.epoch || predecessor.checkpoint_id >= self.checkpoint_id {
362 return Err("committed checkpoint predecessor must be strictly older".into());
363 }
364 }
365
366 validate_source_topology(&self.source_names, &self.source_offsets)?;
367 validate_sources(&self.source_offsets, self.assignment_fence.as_ref())?;
368 validate_channel_progress(
369 &self.source_names,
370 &self.channel_progress,
371 self.checkpoint_watermark,
372 )?;
373 self.validate_source_watermarks()?;
374 self.validate_view_frontiers()?;
375 validate_channel_rosters(&self.source_offsets, &self.channel_progress)?;
376 if self.channel_progress.iter().any(|channel| {
377 self.participants
378 .binary_search_by_key(&channel.participant_id, |participant| {
379 participant.participant_id
380 })
381 .is_err()
382 }) {
383 return Err("channel progress names a participant outside the committed cut".into());
384 }
385 Ok(())
386 }
387
388 fn validate_source_watermarks(&self) -> Result<(), String> {
389 if self.version == LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION {
390 if !self.source_watermarks.is_empty() {
391 return Err("legacy committed checkpoint cannot carry source watermarks".into());
392 }
393 return Ok(());
394 }
395 validate_source_watermarks(
396 &self.source_names,
397 &self.channel_progress,
398 &self.source_watermarks,
399 )?;
400 if self.predecessor.is_none() {
401 let exact = channel_progress_frontiers_by_source(&self.channel_progress)?
402 .into_iter()
403 .filter_map(|(source, frontier)| {
404 frontier.map(|frontier| (source.to_owned(), frontier))
405 })
406 .collect::<BTreeMap<_, _>>();
407 if self.source_watermarks != exact {
408 return Err(
409 "initial committed source watermarks do not exactly match channel progress"
410 .into(),
411 );
412 }
413 }
414 Ok(())
415 }
416
417 pub fn validate_predecessor_index(
422 &self,
423 predecessor: &CommittedCheckpointIndex,
424 ) -> Result<(), String> {
425 self.validate()?;
426 predecessor.validate()?;
427 if self.version < predecessor.version {
428 return Err(
429 "committed checkpoint index version regresses across its predecessor".into(),
430 );
431 }
432 let expected = self
433 .predecessor
434 .as_ref()
435 .ok_or_else(|| "committed checkpoint does not declare a predecessor".to_owned())?;
436 let (_, actual) = predecessor.encode_and_reference()?;
437 if &actual != expected
438 || predecessor.deployment_id != self.deployment_id
439 || predecessor.pipeline_identity != self.pipeline_identity
440 || predecessor.scope != self.scope
441 || predecessor.vnode_count != self.vnode_count
442 || predecessor.source_names != self.source_names
443 {
444 return Err("committed checkpoint predecessor breaks recovery continuity".into());
445 }
446 self.validate_predecessor_watermarks(predecessor)
447 }
448
449 pub(crate) fn validate_predecessor_watermarks(
450 &self,
451 predecessor: &CommittedCheckpointIndex,
452 ) -> Result<(), String> {
453 self.validate_source_watermark_continuity(predecessor, &self.source_names)?;
454 self.validate_view_frontier_continuity(predecessor)
455 }
456
457 pub(crate) fn validate_source_watermark_continuity(
458 &self,
459 predecessor: &CommittedCheckpointIndex,
460 inherited_sources: &[String],
461 ) -> Result<(), String> {
462 if self.version >= SOURCE_WATERMARK_INDEX_VERSION {
463 let mut expected = predecessor
464 .effective_source_watermarks()?
465 .into_iter()
466 .filter(|(source, _)| inherited_sources.binary_search(source).is_ok())
467 .collect::<BTreeMap<_, _>>();
468 for (source, frontier) in channel_progress_frontiers_by_source(&self.channel_progress)?
469 {
470 let Some(frontier) = frontier else {
471 continue;
472 };
473 if expected
474 .get(source)
475 .is_some_and(|predecessor| *predecessor > frontier)
476 {
477 return Err(format!(
478 "committed source watermark for '{source}' regresses across its predecessor"
479 ));
480 }
481 expected.insert(source.to_owned(), frontier);
482 }
483 if self.source_watermarks != expected {
484 return Err(
485 "committed source watermarks do not exactly continue their predecessor".into(),
486 );
487 }
488 }
489 Ok(())
490 }
491
492 pub fn effective_source_watermarks(&self) -> Result<BTreeMap<String, i64>, String> {
500 if self.version == LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION {
501 return channel_progress_frontiers_by_source(&self.channel_progress).map(|frontiers| {
502 frontiers
503 .into_iter()
504 .filter_map(|(source, frontier)| {
505 frontier.map(|frontier| (source.to_owned(), frontier))
506 })
507 .collect()
508 });
509 }
510 Ok(self.source_watermarks.clone())
511 }
512
513 pub fn encode_and_reference(&self) -> Result<(Vec<u8>, CommittedCheckpointRef), String> {
518 self.validate()?;
519 let encoded = canonical_json_bytes(self).map_err(|error| error.to_string())?;
520 if encoded.len() > MAX_COMMITTED_CHECKPOINT_INDEX_BYTES {
521 return Err(format!(
522 "committed checkpoint index is {} bytes; maximum is {MAX_COMMITTED_CHECKPOINT_INDEX_BYTES}",
523 encoded.len()
524 ));
525 }
526 let reference = CommittedCheckpointRef {
527 epoch: self.epoch,
528 checkpoint_id: self.checkpoint_id,
529 sha256: sha256_hex(&encoded),
530 len: u64::try_from(encoded.len())
531 .map_err(|_| "committed checkpoint index length overflowed".to_owned())?,
532 };
533 reference.validate()?;
534 Ok((encoded, reference))
535 }
536}
537
538fn validate_source_topology(
539 source_names: &[String],
540 sources: &BTreeMap<String, ConnectorCheckpoint>,
541) -> Result<(), String> {
542 for source in source_names {
543 validate_name("source", source)?;
544 }
545 if !source_names.windows(2).all(|pair| pair[0] < pair[1]) {
546 return Err("committed source names must be sorted and unique".into());
547 }
548 if let Some(source) = sources
549 .keys()
550 .find(|source| source_names.binary_search(source).is_err())
551 {
552 return Err(format!(
553 "source offset '{source}' is absent from the committed source topology"
554 ));
555 }
556 Ok(())
557}
558
559fn validate_sources(
560 sources: &BTreeMap<String, ConnectorCheckpoint>,
561 fence: Option<&CheckpointAssignmentFence>,
562) -> Result<(), String> {
563 for (source, checkpoint) in sources {
564 validate_name("source", source)?;
565 for key in checkpoint.offsets.keys() {
566 validate_name("source offset key", key)?;
567 }
568 for key in checkpoint.metadata.keys() {
569 validate_name("source metadata key", key)?;
570 }
571 if let Some(channels) = &checkpoint.input_channels {
572 if channels.iter().any(Vec::is_empty)
573 || !channels.windows(2).all(|pair| pair[0] < pair[1])
574 {
575 return Err(format!(
576 "source '{source}' input channels must contain non-empty, sorted unique identities"
577 ));
578 }
579 if channels
580 .iter()
581 .any(|channel| channel == SINGLETON_WATERMARK_CHANNEL)
582 {
583 return Err(format!(
584 "source '{source}' input channels contain the reserved logical watermark channel"
585 ));
586 }
587 }
588 if let (Some(version), Some(fence)) = (checkpoint.source_assignment_version, fence) {
589 if version.get() != fence.assignment_version {
590 return Err(format!(
591 "source '{source}' assignment version is {}; expected {}",
592 version, fence.assignment_version
593 ));
594 }
595 }
596 }
597 Ok(())
598}
599
600fn merge_manifest_progress(
601 manifest: &CheckpointManifest,
602 source_offsets: &mut BTreeMap<String, ConnectorCheckpoint>,
603 channel_progress: &mut BTreeMap<(u64, String, Vec<u8>), ChannelProgress>,
604) -> Result<(), String> {
605 for (source, local) in &manifest.source_offsets {
606 let (merged, first_participant) = match source_offsets.entry(source.clone()) {
607 std::collections::btree_map::Entry::Vacant(entry) => (
608 entry.insert(ConnectorCheckpoint {
609 offsets: std::collections::HashMap::new(),
610 metadata: std::collections::HashMap::new(),
611 input_channels: local.input_channels.clone(),
612 source_assignment_version: local.source_assignment_version,
613 }),
614 true,
615 ),
616 std::collections::btree_map::Entry::Occupied(entry) => {
617 if entry.get().source_assignment_version != local.source_assignment_version {
618 return Err(format!(
619 "participant {} source '{source}' has a conflicting assignment version",
620 manifest.participant_id
621 ));
622 }
623 (entry.into_mut(), false)
624 }
625 };
626 merge_connector_map(
627 manifest.participant_id,
628 source,
629 "offset",
630 &mut merged.offsets,
631 &local.offsets,
632 )?;
633 merge_connector_map(
634 manifest.participant_id,
635 source,
636 "metadata",
637 &mut merged.metadata,
638 &local.metadata,
639 )?;
640 if !first_participant {
641 match (&mut merged.input_channels, &local.input_channels) {
642 (None, None) => {}
643 (Some(merged), Some(local)) => {
644 if local
645 .iter()
646 .any(|channel| merged.binary_search(channel).is_ok())
647 {
648 return Err(format!(
649 "source '{source}' input channel is owned by multiple participants"
650 ));
651 }
652 merged.extend(local.iter().cloned());
653 merged.sort_unstable();
654 }
655 _ => {
656 return Err(format!(
657 "source '{source}' participant checkpoints disagree on whether input channels are declared"
658 ));
659 }
660 }
661 }
662 }
663
664 for channel in &manifest.channel_progress {
665 if channel.participant_id != manifest.participant_id {
666 return Err(format!(
667 "participant {} manifest contains source '{}' progress owned by participant {}",
668 manifest.participant_id, channel.source_name, channel.participant_id
669 ));
670 }
671 let key = (
672 channel.participant_id,
673 channel.source_name.clone(),
674 channel.input_channel.clone(),
675 );
676 if let Some(existing) = channel_progress.insert(key, channel.clone()) {
677 if existing == *channel {
678 continue;
679 }
680 return Err(format!(
681 "participant {} source '{}' input channel has conflicting progress",
682 manifest.participant_id, channel.source_name
683 ));
684 }
685 }
686 Ok(())
687}
688
689fn merge_connector_map(
690 participant_id: u64,
691 source: &str,
692 field: &str,
693 merged: &mut std::collections::HashMap<String, String>,
694 local: &std::collections::HashMap<String, String>,
695) -> Result<(), String> {
696 for (key, value) in local {
697 if let Some(existing) = merged.insert(key.clone(), value.clone()) {
698 if existing != *value {
699 return Err(format!(
700 "participant {participant_id} source '{source}' has conflicting {field} '{key}'"
701 ));
702 }
703 }
704 }
705 Ok(())
706}
707
708fn validate_channel_progress(
709 source_names: &[String],
710 channels: &[ChannelProgress],
711 checkpoint_watermark: Option<i64>,
712) -> Result<(), String> {
713 for channel in channels {
714 if channel.participant_id == 0 {
715 return Err("channel progress participant ID cannot be zero".into());
716 }
717 validate_name("channel source", &channel.source_name)?;
718 if source_names.binary_search(&channel.source_name).is_err() {
719 return Err(format!(
720 "channel progress source '{}' is absent from the committed source topology",
721 channel.source_name
722 ));
723 }
724 if channel.input_channel.is_empty() {
725 return Err("channel progress input channel cannot be empty".into());
726 }
727 }
728 if !channels.windows(2).all(|pair| {
729 (
730 &pair[0].participant_id,
731 &pair[0].source_name,
732 &pair[0].input_channel,
733 ) < (
734 &pair[1].participant_id,
735 &pair[1].source_name,
736 &pair[1].input_channel,
737 )
738 }) {
739 return Err(
740 "channel progress must be sorted and unique by participant, source, and input channel"
741 .into(),
742 );
743 }
744 let mut logical_sources = BTreeSet::new();
745 let mut physical_sources = BTreeSet::new();
746 let mut physical_channels = BTreeSet::new();
747 for channel in channels {
748 if channel.input_channel == SINGLETON_WATERMARK_CHANNEL {
749 logical_sources.insert((channel.participant_id, channel.source_name.as_str()));
750 continue;
751 }
752 physical_sources.insert((channel.participant_id, channel.source_name.as_str()));
753 if !physical_channels.insert((&channel.source_name, &channel.input_channel)) {
754 return Err(format!(
755 "source '{}' input channel is owned by multiple participants",
756 channel.source_name
757 ));
758 }
759 }
760 if let Some((participant_id, source)) = logical_sources.intersection(&physical_sources).next() {
761 return Err(format!(
762 "channel progress participant {participant_id} source '{source}' mixes logical and physical input channels"
763 ));
764 }
765 let classification = classify_channel_progress(channels)?;
766 if checkpoint_watermark != classification.active_value() {
767 return Err("checkpoint watermark does not match channel progress".into());
768 }
769 Ok(())
770}
771
772fn validate_channel_rosters(
773 sources: &BTreeMap<String, ConnectorCheckpoint>,
774 channels: &[ChannelProgress],
775) -> Result<(), String> {
776 for (source, checkpoint) in sources {
777 let Some(expected) = checkpoint.input_channels.as_ref() else {
778 continue;
779 };
780 let mut actual = channels
781 .iter()
782 .filter(|channel| {
783 channel.source_name == *source
784 && channel.input_channel != SINGLETON_WATERMARK_CHANNEL
785 })
786 .map(|channel| channel.input_channel.as_slice())
787 .collect::<Vec<_>>();
788 if actual.is_empty() {
789 continue;
790 }
791 actual.sort_unstable();
792 if !actual.into_iter().eq(expected.iter().map(Vec::as_slice)) {
793 return Err(format!(
794 "source '{source}' input channels do not match its channel progress roster"
795 ));
796 }
797 }
798 Ok(())
799}
800
801fn validate_source_watermarks(
802 source_names: &[String],
803 channels: &[ChannelProgress],
804 source_watermarks: &BTreeMap<String, i64>,
805) -> Result<(), String> {
806 for (source, watermark) in source_watermarks {
807 validate_name("source watermark", source)?;
808 if source_names.binary_search(source).is_err() {
809 return Err(format!(
810 "source watermark '{source}' is absent from the committed source topology"
811 ));
812 }
813 if *watermark == i64::MIN {
814 return Err(format!(
815 "source watermark '{source}' uses the reserved uninitialized value"
816 ));
817 }
818 }
819 for (source, current) in channel_progress_frontiers_by_source(channels)? {
820 let Some(current) = current else {
821 continue;
822 };
823 if source_watermarks.get(source) != Some(¤t) {
824 return Err(format!(
825 "source watermark '{source}' does not match its exact channel progress"
826 ));
827 }
828 }
829 Ok(())
830}
831
832fn validate_attempt(epoch: u64, checkpoint_id: u64, kind: &str) -> Result<(), String> {
833 if epoch == 0 || checkpoint_id != epoch {
834 return Err(format!(
835 "{kind} must use one nonzero canonical checkpoint ID"
836 ));
837 }
838 Ok(())
839}
840
841fn validate_deployment(value: &str) -> Result<(), String> {
842 let deployment = uuid::Uuid::parse_str(value)
843 .map_err(|error| format!("committed checkpoint deployment identity is invalid: {error}"))?;
844 if deployment.is_nil() || deployment.to_string() != value {
845 return Err(
846 "committed checkpoint deployment identity must be canonical and non-nil".into(),
847 );
848 }
849 Ok(())
850}
851
852fn validate_name(kind: &str, value: &str) -> Result<(), String> {
853 if value.is_empty() || value.trim() != value {
854 return Err(format!(
855 "committed checkpoint has a non-canonical {kind} name"
856 ));
857 }
858 Ok(())
859}
860
861fn validate_digest(kind: &str, value: &str) -> Result<(), String> {
862 if value.len() != 64
863 || !value
864 .bytes()
865 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
866 {
867 return Err(format!(
868 "{kind} SHA-256 must be 64 lowercase hexadecimal characters"
869 ));
870 }
871 Ok(())
872}
873
874fn sha256_hex(bytes: &[u8]) -> String {
875 format!("{:x}", Sha256::digest(bytes))
876}
877
878#[derive(Serialize)]
879#[serde(untagged)]
880enum CanonicalJsonValue {
881 Null,
882 Bool(bool),
883 Number(serde_json::Number),
884 String(String),
885 Array(Vec<Self>),
886 Object(BTreeMap<String, Self>),
887}
888
889impl From<serde_json::Value> for CanonicalJsonValue {
890 fn from(value: serde_json::Value) -> Self {
891 match value {
892 serde_json::Value::Null => Self::Null,
893 serde_json::Value::Bool(value) => Self::Bool(value),
894 serde_json::Value::Number(value) => Self::Number(value),
895 serde_json::Value::String(value) => Self::String(value),
896 serde_json::Value::Array(values) => {
897 Self::Array(values.into_iter().map(Self::from).collect())
898 }
899 serde_json::Value::Object(values) => Self::Object(
900 values
901 .into_iter()
902 .map(|(key, value)| (key, Self::from(value)))
903 .collect(),
904 ),
905 }
906 }
907}
908
909pub fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
914 let value = serde_json::to_value(value)?;
915 serde_json::to_vec(&CanonicalJsonValue::from(value))
916}
917
918pub fn canonical_json_sha256<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
923 canonical_json_bytes(value).map(|bytes| sha256_hex(&bytes))
924}
925
926#[cfg(test)]
927mod tests;