Skip to main content

laminar_core/checkpoint/committed_checkpoint/
mod.rs

1//! Immutable global index for one committed checkpoint cut.
2
3#![allow(clippy::disallowed_types)] // cold-path canonical checkpoint metadata
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10use super::{
11    channel_progress_frontiers_by_source, classify_channel_progress,
12    merge_node_subscription_manifests, ChannelProgress, CheckpointAssignmentFence,
13    CheckpointManifest, ConnectorCheckpoint, PipelineIdentity, SINGLETON_WATERMARK_CHANNEL,
14};
15use crate::state::{KeyGroupCount, LOCAL_NODE_ID};
16
17/// Current committed-checkpoint index format.
18pub const COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 4;
19const LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 3;
20
21/// Hard bound on one canonical committed-checkpoint index.
22pub const MAX_COMMITTED_CHECKPOINT_INDEX_BYTES: usize = 8 * 1024 * 1024;
23
24/// Recovery domain covered by a committed checkpoint.
25#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum CheckpointScope {
28    /// One process owns the complete vnode domain.
29    Local,
30    /// An assignment-fenced participant set owns the vnode domain.
31    Cluster,
32}
33
34/// Event-time position of a complete checkpoint cut.
35#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case", tag = "state", content = "watermark_ms")]
37pub enum CheckpointWatermark {
38    /// No active input has established a frontier.
39    #[default]
40    Uninitialized,
41    /// Every channel in the cut is explicitly idle.
42    Idle,
43    /// Minimum watermark across active initialized channels.
44    Active(i64),
45}
46
47impl CheckpointWatermark {
48    /// Fold two participant watermarks into a safe cluster status.
49    #[must_use]
50    pub const fn cluster_min(self, other: Self) -> Self {
51        match (self, other) {
52            (Self::Uninitialized, _) | (_, Self::Uninitialized) => Self::Uninitialized,
53            (Self::Idle, Self::Idle) => Self::Idle,
54            (Self::Idle, Self::Active(value)) | (Self::Active(value), Self::Idle) => {
55                Self::Active(value)
56            }
57            (Self::Active(left), Self::Active(right)) => {
58                Self::Active(if left < right { left } else { right })
59            }
60        }
61    }
62
63    /// Numeric frontier for an active cut.
64    #[must_use]
65    pub const fn active_value(self) -> Option<i64> {
66        match self {
67            Self::Active(value) => Some(value),
68            Self::Uninitialized | Self::Idle => None,
69        }
70    }
71
72    /// Reject the reserved uninitialized sentinel.
73    ///
74    /// # Errors
75    /// Returns an error when an active watermark uses the reserved sentinel.
76    pub fn validate(self) -> Result<(), String> {
77        if self == Self::Active(i64::MIN) {
78            return Err("active checkpoint watermark cannot use the uninitialized sentinel".into());
79        }
80        Ok(())
81    }
82}
83
84/// Exact immutable participant objects admitted into a committed cut.
85#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct CommittedParticipantRef {
88    /// Stable participant node identifier.
89    pub participant_id: u64,
90    /// Exact persisted manifest length.
91    pub manifest_len: u64,
92    /// SHA-256 of the exact persisted manifest bytes.
93    pub manifest_sha256: String,
94    /// Exact immutable node-data object length.
95    pub node_data_len: u64,
96    /// SHA-256 of the complete immutable node-data object.
97    pub node_data_sha256: String,
98}
99
100impl CommittedParticipantRef {
101    /// Bind an already validated manifest and its exact persisted bytes.
102    ///
103    /// # Errors
104    /// Returns an error if the manifest bytes do not encode the supplied manifest.
105    pub fn from_manifest(
106        manifest: &CheckpointManifest,
107        persisted_manifest: &[u8],
108    ) -> Result<Self, String> {
109        let decoded: CheckpointManifest = serde_json::from_slice(persisted_manifest)
110            .map_err(|error| format!("participant manifest is not valid JSON: {error}"))?;
111        if decoded != *manifest {
112            return Err("persisted participant manifest differs from the supplied manifest".into());
113        }
114        let reference = Self {
115            participant_id: manifest.participant_id,
116            manifest_len: u64::try_from(persisted_manifest.len())
117                .map_err(|_| "participant manifest length overflowed".to_owned())?,
118            manifest_sha256: sha256_hex(persisted_manifest),
119            node_data_len: manifest.node_data.object_length,
120            node_data_sha256: manifest.node_data.sha256.clone(),
121        };
122        reference.validate()?;
123        Ok(reference)
124    }
125
126    /// Validate the reference encoding.
127    ///
128    /// # Errors
129    /// Returns an error when an identifier, length, or digest is invalid.
130    pub fn validate(&self) -> Result<(), String> {
131        if self.participant_id == 0 {
132            return Err("committed participant ID cannot be zero".into());
133        }
134        if self.manifest_len == 0 {
135            return Err("committed participant manifest cannot be empty".into());
136        }
137        validate_digest("participant manifest", &self.manifest_sha256)?;
138        validate_digest("participant node data", &self.node_data_sha256)?;
139        Ok(())
140    }
141
142    /// Verify one loaded manifest against this exact reference.
143    ///
144    /// # Errors
145    /// Returns an error when the manifest or its bytes differ from this reference.
146    pub fn verify_manifest(
147        &self,
148        manifest: &CheckpointManifest,
149        persisted_manifest: &[u8],
150    ) -> Result<(), String> {
151        self.validate()?;
152        if u64::try_from(persisted_manifest.len()).unwrap_or(u64::MAX) != self.manifest_len
153            || sha256_hex(persisted_manifest) != self.manifest_sha256
154        {
155            return Err(format!(
156                "participant {} manifest length or digest differs from the committed index",
157                self.participant_id
158            ));
159        }
160        let decoded: CheckpointManifest = serde_json::from_slice(persisted_manifest)
161            .map_err(|error| format!("participant manifest is not valid JSON: {error}"))?;
162        if decoded != *manifest {
163            return Err(format!(
164                "participant {} decoded manifest differs from its persisted bytes",
165                self.participant_id
166            ));
167        }
168        if manifest.participant_id != self.participant_id
169            || manifest.node_data.chunk.participant_id != self.participant_id
170            || manifest.node_data.object_length != self.node_data_len
171            || manifest.node_data.sha256 != self.node_data_sha256
172        {
173            return Err(format!(
174                "participant {} manifest does not match its committed object reference",
175                self.participant_id
176            ));
177        }
178        Ok(())
179    }
180}
181
182/// Content-addressed reference carried by a terminal Commit outcome.
183#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct CommittedCheckpointRef {
186    /// Exact epoch encoded by the index and its object path.
187    pub epoch: u64,
188    /// Exact checkpoint ID encoded by the index and its object path.
189    pub checkpoint_id: u64,
190    /// SHA-256 of the exact canonical index body.
191    pub sha256: String,
192    /// Exact canonical index body length.
193    pub len: u64,
194}
195
196impl CommittedCheckpointRef {
197    /// Validate the reference encoding.
198    ///
199    /// # Errors
200    /// Returns an error when the attempt, digest, or encoded length is invalid.
201    pub fn validate(&self) -> Result<(), String> {
202        validate_attempt(
203            self.epoch,
204            self.checkpoint_id,
205            "committed checkpoint reference",
206        )?;
207        validate_digest("committed checkpoint", &self.sha256)?;
208        if self.len == 0
209            || self.len > u64::try_from(MAX_COMMITTED_CHECKPOINT_INDEX_BYTES).unwrap_or(u64::MAX)
210        {
211            return Err(format!(
212                "committed checkpoint index length {} is outside 1..={MAX_COMMITTED_CHECKPOINT_INDEX_BYTES}",
213                self.len
214            ));
215        }
216        Ok(())
217    }
218}
219
220/// Canonical global recovery index selected by one Commit outcome.
221#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct CommittedCheckpointIndex {
224    /// Exact index format.
225    pub version: u32,
226    /// Durable deployment incarnation owning the cut.
227    pub deployment_id: String,
228    /// Logical pipeline and state ABI identity.
229    pub pipeline_identity: PipelineIdentity,
230    /// Terminal epoch.
231    pub epoch: u64,
232    /// Exact checkpoint ID; equal to `epoch`.
233    pub checkpoint_id: u64,
234    /// Recovery domain covered by the cut.
235    pub scope: CheckpointScope,
236    /// Exact vnode domain used by every participant manifest.
237    pub vnode_count: u16,
238    /// Exact cluster assignment; absent in local mode.
239    pub assignment_fence: Option<CheckpointAssignmentFence>,
240    /// Whether every participant captured a cut portable across vnode reassignment.
241    pub reassignment_portable: bool,
242    /// Direct prior committed index used for explicit, LIST-free retention traversal.
243    pub predecessor: Option<CommittedCheckpointRef>,
244    /// Canonically sorted exact participant objects.
245    pub participants: Vec<CommittedParticipantRef>,
246    /// Canonically sorted registered source names.
247    pub source_names: Vec<String>,
248    /// Complete merged connector source cut.
249    pub source_offsets: BTreeMap<String, ConnectorCheckpoint>,
250    /// Complete merged per-channel event-time progress.
251    pub channel_progress: Vec<ChannelProgress>,
252    /// Monotonic decision frontier retained for every source, including sources whose current
253    /// physical input-channel inventory is empty.
254    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255    pub source_watermarks: BTreeMap<String, i64>,
256    /// Safe event-time frontier at this cut.
257    pub checkpoint_watermark: Option<i64>,
258}
259
260impl CommittedCheckpointIndex {
261    /// Validate canonical shape and cross-field invariants.
262    ///
263    /// # Errors
264    /// Returns an error when the index is not a complete canonical checkpoint cut.
265    pub fn validate(&self) -> Result<(), String> {
266        if !matches!(
267            self.version,
268            LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION | COMMITTED_CHECKPOINT_INDEX_VERSION
269        ) {
270            return Err(format!(
271                "unsupported committed checkpoint index version {}; expected {} or {COMMITTED_CHECKPOINT_INDEX_VERSION}",
272                self.version, LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION
273            ));
274        }
275        validate_attempt(self.epoch, self.checkpoint_id, "committed checkpoint index")?;
276        validate_deployment(&self.deployment_id)?;
277        if !self.pipeline_identity.is_canonical() {
278            return Err("committed checkpoint pipeline identity is not canonical".into());
279        }
280        KeyGroupCount::try_from(u32::from(self.vnode_count))
281            .map_err(|_| "committed checkpoint vnode count is invalid".to_owned())?;
282
283        if self.participants.is_empty() {
284            return Err("committed checkpoint has no participants".into());
285        }
286        for participant in &self.participants {
287            participant.validate()?;
288        }
289        if !self
290            .participants
291            .windows(2)
292            .all(|pair| pair[0].participant_id < pair[1].participant_id)
293        {
294            return Err(
295                "committed participants must be sorted and unique by participant ID".into(),
296            );
297        }
298
299        match (self.scope, self.assignment_fence.as_ref()) {
300            (CheckpointScope::Local, None)
301                if self.participants.len() == 1
302                    && self.participants[0].participant_id == LOCAL_NODE_ID.0
303                    && !self.reassignment_portable => {}
304            (CheckpointScope::Local, None) if self.reassignment_portable => {
305                return Err(
306                    "local committed checkpoint cannot claim vnode reassignment portability".into(),
307                );
308            }
309            (CheckpointScope::Local, None) => {
310                return Err("local committed checkpoint requires only LOCAL_NODE_ID".into());
311            }
312            (CheckpointScope::Local, Some(_)) => {
313                return Err("local committed checkpoint cannot carry an assignment fence".into());
314            }
315            (CheckpointScope::Cluster, Some(fence)) => {
316                if !self.reassignment_portable {
317                    return Err(
318                        "cluster committed checkpoint must be portable across vnode reassignment"
319                            .into(),
320                    );
321                }
322                if !fence.is_canonical() || fence.vnode_count != u32::from(self.vnode_count) {
323                    return Err(
324                        "cluster committed checkpoint has an invalid assignment fence".into(),
325                    );
326                }
327                let actual: Vec<_> = self
328                    .participants
329                    .iter()
330                    .map(|participant| participant.participant_id)
331                    .collect();
332                if actual != fence.participant_ids() {
333                    return Err(
334                        "committed participants do not exactly cover the assignment fence".into(),
335                    );
336                }
337            }
338            (CheckpointScope::Cluster, None) => {
339                return Err("cluster committed checkpoint requires an assignment fence".into());
340            }
341        }
342
343        if let Some(predecessor) = &self.predecessor {
344            predecessor.validate()?;
345            if predecessor.epoch >= self.epoch || predecessor.checkpoint_id >= self.checkpoint_id {
346                return Err("committed checkpoint predecessor must be strictly older".into());
347            }
348        }
349
350        validate_source_topology(&self.source_names, &self.source_offsets)?;
351        validate_sources(&self.source_offsets, self.assignment_fence.as_ref())?;
352        validate_channel_progress(
353            &self.source_names,
354            &self.channel_progress,
355            self.checkpoint_watermark,
356        )?;
357        if self.version == LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION {
358            if !self.source_watermarks.is_empty() {
359                return Err("legacy committed checkpoint cannot carry source watermarks".into());
360            }
361        } else {
362            validate_source_watermarks(
363                &self.source_names,
364                &self.channel_progress,
365                &self.source_watermarks,
366            )?;
367            if self.predecessor.is_none() {
368                let exact = channel_progress_frontiers_by_source(&self.channel_progress)?
369                    .into_iter()
370                    .filter_map(|(source, frontier)| {
371                        frontier.map(|frontier| (source.to_owned(), frontier))
372                    })
373                    .collect::<BTreeMap<_, _>>();
374                if self.source_watermarks != exact {
375                    return Err(
376                        "initial committed source watermarks do not exactly match channel progress"
377                            .into(),
378                    );
379                }
380            }
381        }
382        validate_channel_rosters(&self.source_offsets, &self.channel_progress)?;
383        if self.channel_progress.iter().any(|channel| {
384            self.participants
385                .binary_search_by_key(&channel.participant_id, |participant| {
386                    participant.participant_id
387                })
388                .is_err()
389        }) {
390            return Err("channel progress names a participant outside the committed cut".into());
391        }
392        Ok(())
393    }
394
395    /// Validate metadata continuity for an explicitly loaded predecessor index.
396    ///
397    /// # Errors
398    /// Returns an error when either index is invalid or continuity is broken.
399    pub fn validate_predecessor_index(
400        &self,
401        predecessor: &CommittedCheckpointIndex,
402    ) -> Result<(), String> {
403        self.validate()?;
404        predecessor.validate()?;
405        if self.version < predecessor.version {
406            return Err(
407                "committed checkpoint index version regresses across its predecessor".into(),
408            );
409        }
410        let expected = self
411            .predecessor
412            .as_ref()
413            .ok_or_else(|| "committed checkpoint does not declare a predecessor".to_owned())?;
414        let (_, actual) = predecessor.encode_and_reference()?;
415        if &actual != expected
416            || predecessor.deployment_id != self.deployment_id
417            || predecessor.pipeline_identity != self.pipeline_identity
418            || predecessor.scope != self.scope
419            || predecessor.vnode_count != self.vnode_count
420            || predecessor.source_names != self.source_names
421        {
422            return Err("committed checkpoint predecessor breaks recovery continuity".into());
423        }
424        if self.version == COMMITTED_CHECKPOINT_INDEX_VERSION {
425            let mut expected = predecessor
426                .effective_source_watermarks()?
427                .into_iter()
428                .filter(|(source, _)| self.source_names.binary_search(source).is_ok())
429                .collect::<BTreeMap<_, _>>();
430            for (source, frontier) in channel_progress_frontiers_by_source(&self.channel_progress)?
431            {
432                let Some(frontier) = frontier else {
433                    continue;
434                };
435                if expected
436                    .get(source)
437                    .is_some_and(|predecessor| *predecessor > frontier)
438                {
439                    return Err(format!(
440                        "committed source watermark for '{source}' regresses across its predecessor"
441                    ));
442                }
443                expected.insert(source.to_owned(), frontier);
444            }
445            if self.source_watermarks != expected {
446                return Err(
447                    "committed source watermarks do not exactly continue their predecessor".into(),
448                );
449            }
450        }
451        Ok(())
452    }
453
454    /// Return the source-keyed decision cuts represented by this index.
455    ///
456    /// Version 3 encoded only physical channel progress, so its effective map is derived. Version
457    /// 4 carries the cumulative map explicitly so an empty current inventory retains its cut.
458    ///
459    /// # Errors
460    /// Returns an error when channel progress contains an invalid watermark sentinel.
461    pub fn effective_source_watermarks(&self) -> Result<BTreeMap<String, i64>, String> {
462        if self.version == LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION {
463            return channel_progress_frontiers_by_source(&self.channel_progress).map(|frontiers| {
464                frontiers
465                    .into_iter()
466                    .filter_map(|(source, frontier)| {
467                        frontier.map(|frontier| (source.to_owned(), frontier))
468                    })
469                    .collect()
470            });
471        }
472        Ok(self.source_watermarks.clone())
473    }
474
475    /// Verify exact participant manifest bytes and complete, exclusive vnode ownership.
476    ///
477    /// # Errors
478    /// Returns an error when the manifests do not exactly represent this committed cut.
479    pub fn validate_participant_manifests(
480        &self,
481        manifests: &[(&CheckpointManifest, &[u8])],
482    ) -> Result<(), String> {
483        self.validate()?;
484        if manifests.len() != self.participants.len() {
485            return Err("participant manifest count differs from the committed index".into());
486        }
487
488        let key_group_count = KeyGroupCount::try_from(u32::from(self.vnode_count))
489            .map_err(|_| "committed checkpoint vnode count is invalid".to_owned())?;
490        let mut owners = vec![None; usize::from(self.vnode_count)];
491        let mut sink_names = None;
492        for ((manifest, encoded), reference) in manifests.iter().zip(&self.participants) {
493            reference.verify_manifest(manifest, encoded)?;
494            let errors = manifest.validate(key_group_count);
495            if let Some(error) = errors.first() {
496                return Err(format!(
497                    "participant {} manifest is invalid: {error}",
498                    reference.participant_id
499                ));
500            }
501            if manifest.checkpoint_id != self.checkpoint_id
502                || manifest.epoch != self.epoch
503                || manifest.deployment_id != self.deployment_id
504                || manifest.pipeline_identity != self.pipeline_identity
505                || manifest.vnode_count != self.vnode_count
506                || manifest.assignment_fence != self.assignment_fence
507                || manifest.reassignment_portable != self.reassignment_portable
508            {
509                return Err(format!(
510                    "participant {} manifest belongs to a different checkpoint cut",
511                    reference.participant_id
512                ));
513            }
514
515            if manifest.source_names != self.source_names {
516                return Err(
517                    "participant manifest source topology differs from the committed index".into(),
518                );
519            }
520            match sink_names {
521                Some(expected) if expected != manifest.sink_names.as_slice() => {
522                    return Err(
523                        "participant manifests disagree on the registered sink topology".into(),
524                    );
525                }
526                None => sink_names = Some(manifest.sink_names.as_slice()),
527                Some(_) => {}
528            }
529            for vnode in &manifest.owned_vnodes {
530                let owner = owners
531                    .get_mut(usize::from(*vnode))
532                    .ok_or_else(|| format!("manifest owns out-of-range vnode {vnode}"))?;
533                if owner.replace(manifest.participant_id).is_some() {
534                    return Err(format!(
535                        "vnode {vnode} is owned by more than one participant"
536                    ));
537                }
538            }
539        }
540        if owners.iter().any(Option::is_none) {
541            return Err("participant manifests do not exactly cover the vnode domain".into());
542        }
543        if let Some(fence) = &self.assignment_fence {
544            let owner_map = owners.into_iter().flatten().collect::<Vec<_>>();
545            if !fence.matches_owner_map(&owner_map) {
546                return Err(
547                    "committed manifest vnode owners do not match the assignment fence".into(),
548                );
549            }
550            let subscription_manifests = manifests
551                .iter()
552                .filter_map(|(manifest, _)| {
553                    manifest
554                        .subscription_output
555                        .as_ref()
556                        .map(|output| (output, manifest.owned_vnodes.as_slice()))
557                })
558                .collect::<Vec<_>>();
559            if !subscription_manifests.is_empty() {
560                if subscription_manifests.len() != manifests.len() {
561                    return Err(
562                        "participant manifests do not agree on subscription output presence".into(),
563                    );
564                }
565                if subscription_manifests.iter().any(|(manifest, _)| {
566                    manifest.streams.iter().any(|stream| {
567                        stream.distribution_certificate.pipeline_identity != self.pipeline_identity
568                    })
569                }) {
570                    return Err(
571                        "subscription output pipeline identity differs from the committed index"
572                            .into(),
573                    );
574                }
575                merge_node_subscription_manifests(
576                    self.epoch,
577                    self.checkpoint_id,
578                    fence,
579                    &subscription_manifests,
580                )
581                .map_err(|error| format!("committed subscription output is invalid: {error}"))?;
582            }
583        }
584        Ok(())
585    }
586
587    /// Encode the canonical index and derive its exact content reference.
588    ///
589    /// # Errors
590    /// Returns an error when the index is invalid, cannot be encoded, or exceeds its bound.
591    pub fn encode_and_reference(&self) -> Result<(Vec<u8>, CommittedCheckpointRef), String> {
592        self.validate()?;
593        let encoded = canonical_json_bytes(self).map_err(|error| error.to_string())?;
594        if encoded.len() > MAX_COMMITTED_CHECKPOINT_INDEX_BYTES {
595            return Err(format!(
596                "committed checkpoint index is {} bytes; maximum is {MAX_COMMITTED_CHECKPOINT_INDEX_BYTES}",
597                encoded.len()
598            ));
599        }
600        let reference = CommittedCheckpointRef {
601            epoch: self.epoch,
602            checkpoint_id: self.checkpoint_id,
603            sha256: sha256_hex(&encoded),
604            len: u64::try_from(encoded.len())
605                .map_err(|_| "committed checkpoint index length overflowed".to_owned())?,
606        };
607        reference.validate()?;
608        Ok((encoded, reference))
609    }
610}
611
612fn validate_source_topology(
613    source_names: &[String],
614    sources: &BTreeMap<String, ConnectorCheckpoint>,
615) -> Result<(), String> {
616    for source in source_names {
617        validate_name("source", source)?;
618    }
619    if !source_names.windows(2).all(|pair| pair[0] < pair[1]) {
620        return Err("committed source names must be sorted and unique".into());
621    }
622    if let Some(source) = sources
623        .keys()
624        .find(|source| source_names.binary_search(source).is_err())
625    {
626        return Err(format!(
627            "source offset '{source}' is absent from the committed source topology"
628        ));
629    }
630    Ok(())
631}
632
633fn validate_sources(
634    sources: &BTreeMap<String, ConnectorCheckpoint>,
635    fence: Option<&CheckpointAssignmentFence>,
636) -> Result<(), String> {
637    for (source, checkpoint) in sources {
638        validate_name("source", source)?;
639        for key in checkpoint.offsets.keys() {
640            validate_name("source offset key", key)?;
641        }
642        for key in checkpoint.metadata.keys() {
643            validate_name("source metadata key", key)?;
644        }
645        if let Some(channels) = &checkpoint.input_channels {
646            if channels.iter().any(Vec::is_empty)
647                || !channels.windows(2).all(|pair| pair[0] < pair[1])
648            {
649                return Err(format!(
650                    "source '{source}' input channels must contain non-empty, sorted unique identities"
651                ));
652            }
653            if channels
654                .iter()
655                .any(|channel| channel == SINGLETON_WATERMARK_CHANNEL)
656            {
657                return Err(format!(
658                    "source '{source}' input channels contain the reserved logical watermark channel"
659                ));
660            }
661        }
662        if let (Some(version), Some(fence)) = (checkpoint.source_assignment_version, fence) {
663            if version.get() != fence.assignment_version {
664                return Err(format!(
665                    "source '{source}' assignment version is {}; expected {}",
666                    version, fence.assignment_version
667                ));
668            }
669        }
670    }
671    Ok(())
672}
673
674fn validate_channel_progress(
675    source_names: &[String],
676    channels: &[ChannelProgress],
677    checkpoint_watermark: Option<i64>,
678) -> Result<(), String> {
679    for channel in channels {
680        if channel.participant_id == 0 {
681            return Err("channel progress participant ID cannot be zero".into());
682        }
683        validate_name("channel source", &channel.source_name)?;
684        if source_names.binary_search(&channel.source_name).is_err() {
685            return Err(format!(
686                "channel progress source '{}' is absent from the committed source topology",
687                channel.source_name
688            ));
689        }
690        if channel.input_channel.is_empty() {
691            return Err("channel progress input channel cannot be empty".into());
692        }
693    }
694    if !channels.windows(2).all(|pair| {
695        (
696            &pair[0].participant_id,
697            &pair[0].source_name,
698            &pair[0].input_channel,
699        ) < (
700            &pair[1].participant_id,
701            &pair[1].source_name,
702            &pair[1].input_channel,
703        )
704    }) {
705        return Err(
706            "channel progress must be sorted and unique by participant, source, and input channel"
707                .into(),
708        );
709    }
710    let mut logical_sources = BTreeSet::new();
711    let mut physical_sources = BTreeSet::new();
712    let mut physical_channels = BTreeSet::new();
713    for channel in channels {
714        if channel.input_channel == SINGLETON_WATERMARK_CHANNEL {
715            logical_sources.insert((channel.participant_id, channel.source_name.as_str()));
716            continue;
717        }
718        physical_sources.insert((channel.participant_id, channel.source_name.as_str()));
719        if !physical_channels.insert((&channel.source_name, &channel.input_channel)) {
720            return Err(format!(
721                "source '{}' input channel is owned by multiple participants",
722                channel.source_name
723            ));
724        }
725    }
726    if let Some((participant_id, source)) = logical_sources.intersection(&physical_sources).next() {
727        return Err(format!(
728            "channel progress participant {participant_id} source '{source}' mixes logical and physical input channels"
729        ));
730    }
731    let classification = classify_channel_progress(channels)?;
732    if checkpoint_watermark != classification.active_value() {
733        return Err("checkpoint watermark does not match channel progress".into());
734    }
735    Ok(())
736}
737
738fn validate_channel_rosters(
739    sources: &BTreeMap<String, ConnectorCheckpoint>,
740    channels: &[ChannelProgress],
741) -> Result<(), String> {
742    for (source, checkpoint) in sources {
743        let Some(expected) = checkpoint.input_channels.as_ref() else {
744            continue;
745        };
746        let mut actual = channels
747            .iter()
748            .filter(|channel| {
749                channel.source_name == *source
750                    && channel.input_channel != SINGLETON_WATERMARK_CHANNEL
751            })
752            .map(|channel| channel.input_channel.as_slice())
753            .collect::<Vec<_>>();
754        if actual.is_empty() {
755            continue;
756        }
757        actual.sort_unstable();
758        if !actual.into_iter().eq(expected.iter().map(Vec::as_slice)) {
759            return Err(format!(
760                "source '{source}' input channels do not match its channel progress roster"
761            ));
762        }
763    }
764    Ok(())
765}
766
767fn validate_source_watermarks(
768    source_names: &[String],
769    channels: &[ChannelProgress],
770    source_watermarks: &BTreeMap<String, i64>,
771) -> Result<(), String> {
772    for (source, watermark) in source_watermarks {
773        validate_name("source watermark", source)?;
774        if source_names.binary_search(source).is_err() {
775            return Err(format!(
776                "source watermark '{source}' is absent from the committed source topology"
777            ));
778        }
779        if *watermark == i64::MIN {
780            return Err(format!(
781                "source watermark '{source}' uses the reserved uninitialized value"
782            ));
783        }
784    }
785    for (source, current) in channel_progress_frontiers_by_source(channels)? {
786        let Some(current) = current else {
787            continue;
788        };
789        if source_watermarks.get(source) != Some(&current) {
790            return Err(format!(
791                "source watermark '{source}' does not match its exact channel progress"
792            ));
793        }
794    }
795    Ok(())
796}
797
798fn validate_attempt(epoch: u64, checkpoint_id: u64, kind: &str) -> Result<(), String> {
799    if epoch == 0 || checkpoint_id != epoch {
800        return Err(format!(
801            "{kind} must use one nonzero canonical checkpoint ID"
802        ));
803    }
804    Ok(())
805}
806
807fn validate_deployment(value: &str) -> Result<(), String> {
808    let deployment = uuid::Uuid::parse_str(value)
809        .map_err(|error| format!("committed checkpoint deployment identity is invalid: {error}"))?;
810    if deployment.is_nil() || deployment.to_string() != value {
811        return Err(
812            "committed checkpoint deployment identity must be canonical and non-nil".into(),
813        );
814    }
815    Ok(())
816}
817
818fn validate_name(kind: &str, value: &str) -> Result<(), String> {
819    if value.is_empty() || value.trim() != value {
820        return Err(format!(
821            "committed checkpoint has a non-canonical {kind} name"
822        ));
823    }
824    Ok(())
825}
826
827fn validate_digest(kind: &str, value: &str) -> Result<(), String> {
828    if value.len() != 64
829        || !value
830            .bytes()
831            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
832    {
833        return Err(format!(
834            "{kind} SHA-256 must be 64 lowercase hexadecimal characters"
835        ));
836    }
837    Ok(())
838}
839
840fn sha256_hex(bytes: &[u8]) -> String {
841    format!("{:x}", Sha256::digest(bytes))
842}
843
844#[derive(Serialize)]
845#[serde(untagged)]
846enum CanonicalJsonValue {
847    Null,
848    Bool(bool),
849    Number(serde_json::Number),
850    String(String),
851    Array(Vec<Self>),
852    Object(BTreeMap<String, Self>),
853}
854
855impl From<serde_json::Value> for CanonicalJsonValue {
856    fn from(value: serde_json::Value) -> Self {
857        match value {
858            serde_json::Value::Null => Self::Null,
859            serde_json::Value::Bool(value) => Self::Bool(value),
860            serde_json::Value::Number(value) => Self::Number(value),
861            serde_json::Value::String(value) => Self::String(value),
862            serde_json::Value::Array(values) => {
863                Self::Array(values.into_iter().map(Self::from).collect())
864            }
865            serde_json::Value::Object(values) => Self::Object(
866                values
867                    .into_iter()
868                    .map(|(key, value)| (key, Self::from(value)))
869                    .collect(),
870            ),
871        }
872    }
873}
874
875/// Serialize JSON with recursively ordered object keys.
876///
877/// # Errors
878/// Returns an error when the value cannot be represented as JSON.
879pub fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
880    let value = serde_json::to_value(value)?;
881    serde_json::to_vec(&CanonicalJsonValue::from(value))
882}
883
884/// SHA-256 of canonical JSON, encoded as lowercase hexadecimal.
885///
886/// # Errors
887/// Returns an error when the value cannot be represented as JSON.
888pub fn canonical_json_sha256<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
889    canonical_json_bytes(value).map(|bytes| sha256_hex(&bytes))
890}
891
892#[cfg(test)]
893mod tests;