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