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
5mod 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
27/// Current committed-checkpoint index format.
28pub const COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 5;
29const LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 3;
30/// The format before committed view frontiers.
31pub(super) const SOURCE_WATERMARK_INDEX_VERSION: u32 = 4;
32
33/// Hard bound on one canonical committed-checkpoint index.
34pub const MAX_COMMITTED_CHECKPOINT_INDEX_BYTES: usize = 8 * 1024 * 1024;
35
36/// Recovery domain covered by a committed checkpoint.
37#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum CheckpointScope {
40    /// One process owns the complete vnode domain.
41    Local,
42    /// An assignment-fenced participant set owns the vnode domain.
43    Cluster,
44}
45
46/// Event-time position of a complete checkpoint cut.
47#[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    /// No active input has established a frontier.
51    #[default]
52    Uninitialized,
53    /// Every channel in the cut is explicitly idle.
54    Idle,
55    /// Minimum watermark across active initialized channels.
56    Active(i64),
57}
58
59impl CheckpointWatermark {
60    /// Fold two participant watermarks into a safe cluster status.
61    #[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    /// Numeric frontier for an active cut.
76    #[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    /// Reject the reserved uninitialized sentinel.
85    ///
86    /// # Errors
87    /// Returns an error when an active watermark uses the reserved sentinel.
88    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/// Exact immutable participant objects admitted into a committed cut.
97#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct CommittedParticipantRef {
100    /// Stable participant node identifier.
101    pub participant_id: u64,
102    /// Exact persisted manifest length.
103    pub manifest_len: u64,
104    /// SHA-256 of the exact persisted manifest bytes.
105    pub manifest_sha256: String,
106    /// Exact immutable node-data object length.
107    pub node_data_len: u64,
108    /// SHA-256 of the complete immutable node-data object.
109    pub node_data_sha256: String,
110}
111
112impl CommittedParticipantRef {
113    /// Bind an already validated manifest and its exact persisted bytes.
114    ///
115    /// # Errors
116    /// Returns an error if the manifest bytes do not encode the supplied manifest.
117    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    /// Validate the reference encoding.
139    ///
140    /// # Errors
141    /// Returns an error when an identifier, length, or digest is invalid.
142    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    /// Verify one loaded manifest against this exact reference.
155    ///
156    /// # Errors
157    /// Returns an error when the manifest or its bytes differ from this reference.
158    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/// Content-addressed reference carried by a terminal Commit outcome.
195#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct CommittedCheckpointRef {
198    /// Exact epoch encoded by the index and its object path.
199    pub epoch: u64,
200    /// Exact checkpoint ID encoded by the index and its object path.
201    pub checkpoint_id: u64,
202    /// SHA-256 of the exact canonical index body.
203    pub sha256: String,
204    /// Exact canonical index body length.
205    pub len: u64,
206}
207
208impl CommittedCheckpointRef {
209    /// Validate the reference encoding.
210    ///
211    /// # Errors
212    /// Returns an error when the attempt, digest, or encoded length is invalid.
213    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/// Canonical global recovery index selected by one Commit outcome.
233#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct CommittedCheckpointIndex {
236    /// Exact index format.
237    pub version: u32,
238    /// Durable deployment incarnation owning the cut.
239    pub deployment_id: String,
240    /// Logical pipeline and state ABI identity.
241    pub pipeline_identity: PipelineIdentity,
242    /// Terminal epoch.
243    pub epoch: u64,
244    /// Exact checkpoint ID; equal to `epoch`.
245    pub checkpoint_id: u64,
246    /// Recovery domain covered by the cut.
247    pub scope: CheckpointScope,
248    /// Exact vnode domain used by every participant manifest.
249    pub vnode_count: u16,
250    /// Exact cluster assignment; absent in local mode.
251    pub assignment_fence: Option<CheckpointAssignmentFence>,
252    /// Whether every participant captured a cut portable across vnode reassignment.
253    pub reassignment_portable: bool,
254    /// Direct prior committed index used for explicit, LIST-free retention traversal.
255    pub predecessor: Option<CommittedCheckpointRef>,
256    /// Canonically sorted exact participant objects.
257    pub participants: Vec<CommittedParticipantRef>,
258    /// Canonically sorted registered source names.
259    pub source_names: Vec<String>,
260    /// Complete merged connector source cut.
261    pub source_offsets: BTreeMap<String, ConnectorCheckpoint>,
262    /// Complete merged per-channel event-time progress.
263    pub channel_progress: Vec<ChannelProgress>,
264    /// Monotonic decision frontier retained for every source, including sources whose current
265    /// physical input-channel inventory is empty.
266    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
267    pub source_watermarks: BTreeMap<String, i64>,
268    /// Safe event-time frontier at this cut.
269    pub checkpoint_watermark: Option<i64>,
270    /// Committed event-time frontier of each append-only view in a cluster cut. Every
271    /// participant's retention follows it, so which participant holds a row never decides
272    /// whether a read keeps it. See [`fold_view_frontiers`].
273    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
274    pub view_frontiers: BTreeMap<String, i64>,
275}
276
277impl CommittedCheckpointIndex {
278    /// Validate canonical shape and cross-field invariants.
279    ///
280    /// # Errors
281    /// Returns an error when the index is not a complete canonical checkpoint cut.
282    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    /// Validate metadata continuity for an explicitly loaded predecessor index.
418    ///
419    /// # Errors
420    /// Returns an error when either index is invalid or continuity is broken.
421    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    /// Return the source-keyed decision cuts represented by this index.
493    ///
494    /// Version 3 encoded only physical channel progress, so its effective map is derived. Version
495    /// 4 carries the cumulative map explicitly so an empty current inventory retains its cut.
496    ///
497    /// # Errors
498    /// Returns an error when channel progress contains an invalid watermark sentinel.
499    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    /// Encode the canonical index and derive its exact content reference.
514    ///
515    /// # Errors
516    /// Returns an error when the index is invalid, cannot be encoded, or exceeds its bound.
517    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(&current) {
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
909/// Serialize JSON with recursively ordered object keys.
910///
911/// # Errors
912/// Returns an error when the value cannot be represented as JSON.
913pub 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
918/// SHA-256 of canonical JSON, encoded as lowercase hexadecimal.
919///
920/// # Errors
921/// Returns an error when the value cannot be represented as JSON.
922pub 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;