1#![allow(clippy::disallowed_types)] use std::collections::{BTreeMap, HashMap};
6use std::num::{NonZeroU32, NonZeroU64};
7
8use sha2::{Digest, Sha256};
9
10use crate::checkpoint::assignment::CheckpointAssignmentFence;
11use crate::checkpoint::NodeSubscriptionManifest;
12use crate::state::{
13 KeyGroupCount, DEFAULT_KEY_GROUP_COUNT, LOCAL_NODE_ID, PARTITIONING_ABI_VERSION,
14};
15
16mod sink_artifacts;
17pub use sink_artifacts::{
18 checkpoint_artifact_intent_sha256, PreparedSinkArtifactIntent, PreparedSinkDescriptor,
19};
20
21pub const CHECKPOINT_MANIFEST_VERSION: u32 = 10;
23
24pub const PIPELINE_IDENTITY_VERSION: u16 = 7;
26
27pub const PREPARED_SINK_DESCRIPTOR_VERSION: u16 = 1;
29
30const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
34#[serde(deny_unknown_fields)]
35pub struct PipelineIdentity {
36 pub canonical_version: u16,
38 pub sha256: String,
40}
41
42impl PipelineIdentity {
43 #[must_use]
45 pub fn empty() -> Self {
46 Self {
47 canonical_version: PIPELINE_IDENTITY_VERSION,
48 sha256: EMPTY_SHA256.into(),
49 }
50 }
51
52 pub(crate) fn validation_error(&self) -> Option<String> {
53 if self.canonical_version != PIPELINE_IDENTITY_VERSION {
54 return Some(format!(
55 "unsupported pipeline identity version {}; expected {PIPELINE_IDENTITY_VERSION}",
56 self.canonical_version
57 ));
58 }
59 (!is_sha256(&self.sha256))
60 .then(|| "pipeline identity must be 64 lowercase hexadecimal characters".into())
61 }
62
63 #[must_use]
65 pub fn is_canonical(&self) -> bool {
66 self.validation_error().is_none()
67 }
68}
69
70#[derive(
72 Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord,
73)]
74#[serde(deny_unknown_fields)]
75pub struct StateChunkId {
76 pub participant_id: u64,
78 pub checkpoint_id: u64,
80}
81
82#[derive(
84 Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord,
85)]
86#[serde(deny_unknown_fields)]
87pub struct ByteRange {
88 pub offset: u64,
90 pub length: u64,
92}
93
94impl ByteRange {
95 #[must_use]
97 pub fn end(self) -> Option<u64> {
98 self.offset.checked_add(self.length)
99 }
100}
101
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
104#[serde(deny_unknown_fields)]
105pub struct NodeDataObject {
106 pub chunk: StateChunkId,
108 pub object_length: u64,
110 pub sha256: String,
112}
113
114#[derive(
116 Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord,
117)]
118#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
119pub enum StateFrameKey {
120 OperatorWhole {
122 operator_id: String,
124 },
125 Vnode {
127 operator_id: String,
129 vnode: u16,
131 },
132}
133
134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
136#[serde(deny_unknown_fields)]
137pub struct StateFrame {
138 pub key: StateFrameKey,
140 pub chunk: StateChunkId,
142 pub range: ByteRange,
144 pub sha256: String,
146}
147
148#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
150#[serde(deny_unknown_fields)]
151pub struct ChannelProgress {
152 pub participant_id: u64,
154 pub source_name: String,
156 pub input_channel: Vec<u8>,
158 pub watermark: Option<i64>,
160 pub idle: bool,
162}
163
164#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
166#[serde(deny_unknown_fields)]
167pub struct ReferencedStateChunk {
168 pub chunk: StateChunkId,
170 pub object_length: u64,
172 pub sha256: String,
174 pub ref_count: NonZeroU32,
178}
179
180#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
182#[serde(deny_unknown_fields)]
183pub struct CheckpointManifest {
184 pub version: u32,
186 pub checkpoint_id: u64,
188 pub epoch: u64,
190 pub timestamp_ms: u64,
192 pub participant_id: u64,
194 pub source_offsets: HashMap<String, ConnectorCheckpoint>,
196 pub source_names: Vec<String>,
198 pub sink_names: Vec<String>,
200 pub pipeline_identity: PipelineIdentity,
202 pub deployment_id: String,
204 pub partitioning_abi_version: u16,
206 pub vnode_count: u16,
208 pub assignment_fence: Option<CheckpointAssignmentFence>,
210 pub reassignment_portable: bool,
215 pub owned_vnodes: Vec<u16>,
217 pub checkpoint_watermark: Option<i64>,
219 pub channel_progress: Vec<ChannelProgress>,
221 pub node_data: NodeDataObject,
223 pub state_frames: Vec<StateFrame>,
225 pub prepared_sinks: Vec<PreparedSinkDescriptor>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub sink_artifact_intents: Vec<PreparedSinkArtifactIntent>,
230 pub referenced_chunks: Vec<ReferencedStateChunk>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub subscription_output: Option<NodeSubscriptionManifest>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct ManifestValidationError {
240 pub message: String,
242}
243
244impl std::fmt::Display for ManifestValidationError {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.write_str(&self.message)
247 }
248}
249
250impl CheckpointManifest {
251 #[must_use]
253 pub fn new(checkpoint_id: u64, epoch: u64) -> Self {
254 Self::new_with_key_group_count(checkpoint_id, epoch, DEFAULT_KEY_GROUP_COUNT)
255 }
256
257 #[must_use]
259 pub fn new_with_key_group_count(
260 checkpoint_id: u64,
261 epoch: u64,
262 key_group_count: KeyGroupCount,
263 ) -> Self {
264 #[allow(clippy::cast_possible_truncation)]
265 let timestamp_ms = std::time::SystemTime::now()
266 .duration_since(std::time::UNIX_EPOCH)
267 .unwrap_or_default()
268 .as_millis() as u64;
269
270 Self {
271 version: CHECKPOINT_MANIFEST_VERSION,
272 checkpoint_id,
273 epoch,
274 timestamp_ms,
275 participant_id: LOCAL_NODE_ID.0,
276 source_offsets: HashMap::new(),
277 source_names: Vec::new(),
278 sink_names: Vec::new(),
279 pipeline_identity: PipelineIdentity::empty(),
280 deployment_id: String::new(),
281 partitioning_abi_version: PARTITIONING_ABI_VERSION,
282 vnode_count: key_group_count.get(),
283 assignment_fence: None,
284 reassignment_portable: false,
285 owned_vnodes: (0..key_group_count.get()).collect(),
286 checkpoint_watermark: None,
287 channel_progress: Vec::new(),
288 node_data: NodeDataObject {
289 chunk: StateChunkId {
290 participant_id: LOCAL_NODE_ID.0,
291 checkpoint_id,
292 },
293 object_length: 0,
294 sha256: EMPTY_SHA256.into(),
295 },
296 state_frames: Vec::new(),
297 prepared_sinks: Vec::new(),
298 sink_artifact_intents: Vec::new(),
299 referenced_chunks: Vec::new(),
300 subscription_output: None,
301 }
302 }
303
304 pub fn bind_participant(&mut self, participant_id: u64) {
306 self.participant_id = participant_id;
307 self.node_data.chunk.participant_id = participant_id;
308 }
309
310 #[must_use]
312 pub fn validate(
313 &self,
314 expected_key_group_count: KeyGroupCount,
315 ) -> Vec<ManifestValidationError> {
316 let mut errors = Vec::new();
317 let mut error = |message| errors.push(ManifestValidationError { message });
318
319 if self.version != CHECKPOINT_MANIFEST_VERSION {
320 error(format!(
321 "unsupported manifest version {}; expected {CHECKPOINT_MANIFEST_VERSION}",
322 self.version
323 ));
324 }
325 if self.partitioning_abi_version != PARTITIONING_ABI_VERSION {
326 error(format!(
327 "partitioning ABI mismatch: checkpoint has {}, runtime expects {PARTITIONING_ABI_VERSION}",
328 self.partitioning_abi_version
329 ));
330 }
331 if self.checkpoint_id == 0 || self.epoch != self.checkpoint_id {
332 error("checkpoint attempt must use one nonzero canonical checkpoint ID".into());
333 }
334 if self.timestamp_ms == 0 {
335 error("timestamp_ms must be nonzero".into());
336 }
337 if let Some(message) = self.pipeline_identity.validation_error() {
338 error(message);
339 }
340 let valid_deployment = uuid::Uuid::parse_str(&self.deployment_id)
341 .is_ok_and(|id| !id.is_nil() && id.to_string() == self.deployment_id);
342 if !valid_deployment {
343 error("deployment_id must be a canonical non-nil UUID".into());
344 }
345 validate_topology(self, expected_key_group_count, &mut error);
346 validate_sorted_unique("source_names", &self.source_names, &mut error);
347 validate_sorted_unique("sink_names", &self.sink_names, &mut error);
348 if !self.channel_progress.windows(2).all(|pair| {
349 (
350 &pair[0].participant_id,
351 &pair[0].source_name,
352 &pair[0].input_channel,
353 ) < (
354 &pair[1].participant_id,
355 &pair[1].source_name,
356 &pair[1].input_channel,
357 )
358 }) {
359 error(
360 "channel_progress must be strictly ordered by participant, source, and input channel"
361 .into(),
362 );
363 }
364 let mut channel_modes = BTreeMap::new();
365 for channel in &self.channel_progress {
366 if channel.participant_id != self.participant_id {
367 error("channel_progress participant_id must match the manifest participant".into());
368 }
369 if channel.source_name.is_empty() {
370 error("channel_progress source_name must not be empty".into());
371 } else if self
372 .source_names
373 .binary_search(&channel.source_name)
374 .is_err()
375 {
376 error(format!(
377 "channel_progress source '{}' not in source_names",
378 channel.source_name
379 ));
380 }
381 if channel.input_channel.is_empty() {
382 error("channel_progress input_channel must not be empty".into());
383 }
384 let modes = channel_modes
385 .entry(channel.source_name.as_str())
386 .or_insert((false, false));
387 if channel.input_channel == super::SINGLETON_WATERMARK_CHANNEL {
388 modes.0 = true;
389 } else {
390 modes.1 = true;
391 }
392 }
393 for (source, (logical, physical)) in channel_modes {
394 if logical && physical {
395 error(format!(
396 "channel_progress source '{source}' mixes logical and physical input channels"
397 ));
398 }
399 }
400 match super::classify_channel_progress(&self.channel_progress) {
401 Ok(classification) if self.checkpoint_watermark != classification.active_value() => {
402 error("checkpoint_watermark does not match channel progress".into());
403 }
404 Err(message) => error(message),
405 Ok(_) => {}
406 }
407 for name in self.source_offsets.keys() {
408 if self.source_names.binary_search(name).is_err() {
409 error(format!(
410 "source_offsets contains '{name}' not in source_names"
411 ));
412 }
413 }
414 for (name, checkpoint) in &self.source_offsets {
415 if let Some(channels) = &checkpoint.input_channels {
416 if channels.iter().any(Vec::is_empty)
417 || !channels.windows(2).all(|pair| pair[0] < pair[1])
418 {
419 error(format!(
420 "source '{name}' input_channels must contain non-empty, strictly ordered unique identities"
421 ));
422 }
423 if channels
424 .iter()
425 .any(|channel| channel == super::SINGLETON_WATERMARK_CHANNEL)
426 {
427 error(format!(
428 "source '{name}' input_channels contains the reserved logical watermark channel"
429 ));
430 }
431 let mut progress = self
432 .channel_progress
433 .iter()
434 .filter(|channel| channel.source_name == *name)
435 .map(|channel| channel.input_channel.as_slice())
436 .collect::<Vec<_>>();
437 if !progress.is_empty()
438 && !progress
439 .iter()
440 .all(|channel| *channel == super::SINGLETON_WATERMARK_CHANNEL)
441 {
442 progress.sort_unstable();
443 if !progress.into_iter().eq(channels.iter().map(Vec::as_slice)) {
444 error(format!(
445 "source '{name}' input_channels do not match its channel_progress roster"
446 ));
447 }
448 }
449 }
450 }
451
452 let current = self.node_data.chunk;
453 if current.participant_id != self.participant_id
454 || current.checkpoint_id != self.checkpoint_id
455 {
456 error("node_data chunk must match the manifest participant and checkpoint".into());
457 }
458 if !is_sha256(&self.node_data.sha256) {
459 error("node_data digest must be lowercase SHA-256".into());
460 }
461
462 let referenced_by_id = self
463 .referenced_chunks
464 .iter()
465 .map(|reference| (reference.chunk, reference))
466 .collect::<HashMap<_, _>>();
467 if referenced_by_id.len() != self.referenced_chunks.len()
468 || !self
469 .referenced_chunks
470 .windows(2)
471 .all(|pair| pair[0].chunk < pair[1].chunk)
472 {
473 error("referenced_chunks must be strictly ordered and unique".into());
474 }
475 for reference in &self.referenced_chunks {
476 if reference.chunk == current
477 || reference.chunk.checkpoint_id >= self.checkpoint_id
478 || reference.chunk.checkpoint_id == 0
479 {
480 error(format!(
481 "referenced chunk {:?} is not an older immutable object",
482 reference.chunk
483 ));
484 }
485 if !is_sha256(&reference.sha256) {
486 error(format!(
487 "referenced chunk {:?} digest must be lowercase SHA-256",
488 reference.chunk
489 ));
490 }
491 }
492
493 let mut prior_counts = HashMap::<StateChunkId, u32>::new();
494 let mut current_ranges = Vec::new();
495 if !self
496 .state_frames
497 .windows(2)
498 .all(|pair| pair[0].key < pair[1].key)
499 {
500 error("state_frames must be strictly ordered by logical key".into());
501 }
502 for frame in &self.state_frames {
503 if let StateFrameKey::Vnode { vnode, .. } = &frame.key {
504 if *vnode >= self.vnode_count {
505 error(format!(
506 "state frame vnode {vnode} is outside the vnode domain"
507 ));
508 }
509 if self.owned_vnodes.binary_search(vnode).is_err() {
510 error(format!(
511 "state frame vnode {vnode} is not owned by participant {}",
512 self.participant_id
513 ));
514 }
515 }
516 if frame.range.length == 0 {
517 error(format!("state frame {:?} has an empty payload", frame.key));
518 }
519 if !is_sha256(&frame.sha256) {
520 error(format!(
521 "state frame {:?} digest must be lowercase SHA-256",
522 frame.key
523 ));
524 }
525 let object_length = if frame.chunk == current {
526 current_ranges.push((frame.range, format!("state frame {:?}", frame.key)));
527 Some(self.node_data.object_length)
528 } else {
529 let reference = referenced_by_id.get(&frame.chunk).copied();
530 if let Some(reference) = reference {
531 let count = prior_counts.entry(frame.chunk).or_default();
532 *count = count.saturating_add(1);
533 Some(reference.object_length)
534 } else {
535 error(format!(
536 "state frame {:?} references an untracked chunk {:?}",
537 frame.key, frame.chunk
538 ));
539 None
540 }
541 };
542 validate_range(frame.range, object_length, "state frame", &mut error);
543 }
544 for reference in &self.referenced_chunks {
545 let actual = prior_counts.get(&reference.chunk).copied().unwrap_or(0);
546 if actual != reference.ref_count.get() {
547 error(format!(
548 "referenced chunk {:?} declares ref_count {}, but {actual} state frames reference it",
549 reference.chunk,
550 reference.ref_count
551 ));
552 }
553 }
554
555 sink_artifacts::validate_sink_artifacts(self, &mut current_ranges, &mut error);
556
557 let mut expected_offset = 0_u64;
558 for (range, owner) in current_ranges {
559 if range.offset != expected_offset {
560 error(format!(
561 "{owner} starts at {}, expected {expected_offset} in the canonical node object",
562 range.offset
563 ));
564 }
565 if let Some(end) = range.end() {
566 expected_offset = end;
567 }
568 }
569 if expected_offset != self.node_data.object_length {
570 error(format!(
571 "current frame and sink ranges cover {expected_offset} bytes, but node_data declares {}",
572 self.node_data.object_length
573 ));
574 }
575
576 errors
577 }
578}
579
580fn validate_topology(
581 manifest: &CheckpointManifest,
582 expected_key_group_count: KeyGroupCount,
583 error: &mut impl FnMut(String),
584) {
585 if manifest.vnode_count != expected_key_group_count.get() {
586 error(format!(
587 "vnode_count mismatch: checkpoint has {}, runtime expects {expected_key_group_count}",
588 manifest.vnode_count
589 ));
590 }
591 if manifest.participant_id == 0 {
592 error("participant_id must be nonzero".into());
593 }
594 if manifest.owned_vnodes.is_empty() {
595 error("owned_vnodes must not be empty".into());
596 } else if !manifest
597 .owned_vnodes
598 .windows(2)
599 .all(|pair| pair[0] < pair[1])
600 {
601 error("owned_vnodes must be strictly ordered and unique".into());
602 }
603 if manifest
604 .owned_vnodes
605 .iter()
606 .any(|vnode| *vnode >= manifest.vnode_count)
607 {
608 error("owned_vnodes contains a vnode outside the manifest domain".into());
609 }
610 match &manifest.assignment_fence {
611 Some(fence) => validate_cluster_topology(manifest, fence, error),
612 None => validate_local_topology(manifest, error),
613 }
614 if let Some(message) = subscription_output_validation_error(manifest) {
615 error(message);
616 }
617}
618
619fn validate_cluster_topology(
620 manifest: &CheckpointManifest,
621 fence: &CheckpointAssignmentFence,
622 error: &mut impl FnMut(String),
623) {
624 if fence.vnode_count != u32::from(manifest.vnode_count)
625 || fence.partitioning_abi_version != manifest.partitioning_abi_version
626 || !fence.contains(manifest.participant_id)
627 {
628 error("assignment_fence does not cover this manifest topology and participant".into());
629 }
630 if !manifest.reassignment_portable {
631 error("a cluster manifest must be proven portable across vnode reassignment".into());
632 }
633}
634
635fn validate_local_topology(manifest: &CheckpointManifest, error: &mut impl FnMut(String)) {
636 let owns_complete_domain = manifest.owned_vnodes.len() == usize::from(manifest.vnode_count)
637 && manifest
638 .owned_vnodes
639 .iter()
640 .copied()
641 .eq(0..manifest.vnode_count);
642 if manifest.participant_id != LOCAL_NODE_ID.0 || !owns_complete_domain {
643 error("a local manifest must use LOCAL_NODE_ID and own every vnode".into());
644 }
645 if manifest.reassignment_portable {
646 error("a local manifest cannot claim vnode reassignment portability".into());
647 }
648}
649
650fn subscription_output_validation_error(manifest: &CheckpointManifest) -> Option<String> {
651 let output = manifest.subscription_output.as_ref()?;
652 let Some(fence) = manifest.assignment_fence.as_ref() else {
653 return Some("a local manifest cannot carry cluster subscription output".into());
654 };
655 if output.epoch != manifest.epoch
656 || output.checkpoint_id != manifest.checkpoint_id
657 || output.participant_id != manifest.participant_id
658 || output.assignment_certificate != *fence
659 {
660 return Some("subscription output manifest belongs to a different participant cut".into());
661 }
662 output
663 .validate(&manifest.owned_vnodes)
664 .err()
665 .map(|error| format!("subscription output manifest is invalid: {error}"))
666}
667
668#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
670#[serde(deny_unknown_fields)]
671pub struct ConnectorCheckpoint {
672 pub offsets: HashMap<String, String>,
674 pub metadata: HashMap<String, String>,
676 pub input_channels: Option<Vec<Vec<u8>>>,
678 pub source_assignment_version: Option<NonZeroU64>,
680}
681
682impl ConnectorCheckpoint {
683 #[must_use]
685 pub fn new() -> Self {
686 Self::default()
687 }
688
689 #[must_use]
691 pub fn with_offsets(offsets: HashMap<String, String>) -> Self {
692 Self {
693 offsets,
694 ..Self::default()
695 }
696 }
697}
698
699fn is_sha256(value: &str) -> bool {
700 value.len() == 64
701 && value
702 .bytes()
703 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
704}
705
706fn validate_sorted_unique(field: &str, values: &[String], error: &mut impl FnMut(String)) {
707 if !values.windows(2).all(|pair| pair[0] < pair[1]) {
708 error(format!("{field} must be strictly ordered and unique"));
709 }
710}
711
712fn validate_range(
713 range: ByteRange,
714 object_length: Option<u64>,
715 owner: &str,
716 error: &mut impl FnMut(String),
717) {
718 let Some(end) = range.end() else {
719 error(format!("{owner} byte range overflows"));
720 return;
721 };
722 if object_length.is_some_and(|length| end > length) {
723 error(format!("{owner} byte range ends beyond its object"));
724 }
725}
726
727#[must_use]
729pub fn checkpoint_sha256(bytes: &[u8]) -> String {
730 format!("{:x}", Sha256::digest(bytes))
731}
732
733#[must_use]
735pub fn checkpoint_descriptor_sha256(payload: Option<&[u8]>) -> String {
736 let mut hasher = Sha256::new();
737 hasher.update(b"laminardb-prepared-sink-descriptor-v1\0");
738 match payload {
739 None => hasher.update([0]),
740 Some(bytes) => {
741 hasher.update([1]);
742 hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
743 hasher.update(bytes);
744 }
745 }
746 format!("{:x}", hasher.finalize())
747}
748
749#[cfg(test)]
750mod tests;