1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
9
10use crate::state::{KeyGroupCount, LOCAL_KEY_GROUP_COUNT, PARTITIONING_ABI_VERSION};
11
12pub const CHECKPOINT_MANIFEST_VERSION: u32 = 6;
16
17pub const PIPELINE_IDENTITY_VERSION: u16 = 3;
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
25pub struct PipelineIdentity {
26 pub canonical_version: u16,
28 pub sha256: String,
30}
31
32impl PipelineIdentity {
33 #[must_use]
35 pub fn empty() -> Self {
36 Self {
37 canonical_version: PIPELINE_IDENTITY_VERSION,
38 sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into(),
39 }
40 }
41
42 pub(crate) fn validation_error(&self) -> Option<String> {
44 if self.canonical_version != PIPELINE_IDENTITY_VERSION {
45 return Some(format!(
46 "unsupported pipeline identity version {}; expected {PIPELINE_IDENTITY_VERSION}",
47 self.canonical_version
48 ));
49 }
50 if self.sha256.len() != 64
51 || !self
52 .sha256
53 .bytes()
54 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
55 {
56 return Some("pipeline identity must be 64 lowercase hexadecimal characters".into());
57 }
58 None
59 }
60
61 #[must_use]
63 pub(crate) fn is_canonical(&self) -> bool {
64 self.validation_error().is_none()
65 }
66}
67
68#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum DurableCheckpointPhase {
76 Prepared,
78 Finalized,
80}
81
82#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
84pub struct CheckpointManifest {
85 pub version: u32,
87 pub checkpoint_id: u64,
89 pub epoch: u64,
91 pub timestamp_ms: u64,
93 pub durable_phase: DurableCheckpointPhase,
95 pub participant_id: u64,
97
98 #[serde(default)]
101 pub source_offsets: HashMap<String, ConnectorCheckpoint>,
102 #[serde(default)]
104 pub table_offsets: HashMap<String, ConnectorCheckpoint>,
105
106 #[serde(default)]
112 pub operator_states: HashMap<String, OperatorCheckpoint>,
113
114 #[serde(default)]
117 pub table_store_checkpoint_path: Option<String>,
118 #[serde(default)]
121 pub watermark: Option<i64>,
122 #[serde(default)]
124 pub source_watermarks: HashMap<String, i64>,
125
126 #[serde(default)]
132 pub source_names: Vec<String>,
133 #[serde(default)]
135 pub sink_names: Vec<String>,
136
137 pub pipeline_identity: PipelineIdentity,
140 pub deployment_id: String,
143
144 pub partitioning_abi_version: u16,
147 #[serde(default)]
149 pub vnode_count: u16,
150
151 #[serde(default)]
157 pub state_checksum: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ManifestValidationError {
163 pub message: String,
165}
166
167impl std::fmt::Display for ManifestValidationError {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 write!(f, "{}", self.message)
170 }
171}
172
173impl CheckpointManifest {
174 #[must_use]
183 pub fn validate(
184 &self,
185 expected_key_group_count: KeyGroupCount,
186 ) -> Vec<ManifestValidationError> {
187 let mut errors = Vec::new();
188
189 if self.version != CHECKPOINT_MANIFEST_VERSION {
190 errors.push(ManifestValidationError {
191 message: format!(
192 "unsupported manifest version {}; expected {CHECKPOINT_MANIFEST_VERSION}",
193 self.version
194 ),
195 });
196 }
197
198 if self.partitioning_abi_version != PARTITIONING_ABI_VERSION {
199 errors.push(ManifestValidationError {
200 message: format!(
201 "partitioning ABI mismatch: checkpoint has {}, runtime expects {PARTITIONING_ABI_VERSION}",
202 self.partitioning_abi_version
203 ),
204 });
205 }
206
207 if self.checkpoint_id == 0 || self.epoch != self.checkpoint_id {
208 errors.push(ManifestValidationError {
209 message: "checkpoint attempt must use one nonzero canonical checkpoint ID".into(),
210 });
211 }
212
213 if self.timestamp_ms == 0 {
214 errors.push(ManifestValidationError {
215 message: "timestamp_ms is 0 (missing creation time)".into(),
216 });
217 }
218
219 if let Some(message) = self.pipeline_identity.validation_error() {
220 errors.push(ManifestValidationError { message });
221 }
222 if !self.deployment_id.is_empty() {
223 let valid = uuid::Uuid::parse_str(&self.deployment_id)
224 .is_ok_and(|id| !id.is_nil() && id.to_string() == self.deployment_id);
225 if !valid {
226 errors.push(ManifestValidationError {
227 message: "deployment_id must be a canonical non-nil UUID".into(),
228 });
229 }
230 }
231
232 if !self.source_names.is_empty() {
234 for name in self.source_offsets.keys() {
235 if !self.source_names.contains(name) {
236 errors.push(ManifestValidationError {
237 message: format!("source_offsets contains '{name}' not in source_names"),
238 });
239 }
240 }
241 }
242
243 if self.vnode_count == 0 {
244 errors.push(ManifestValidationError {
245 message: "vnode_count is 0 (missing or legacy checkpoint)".into(),
246 });
247 } else if self.vnode_count != expected_key_group_count.get() {
248 errors.push(ManifestValidationError {
249 message: format!(
250 "vnode_count mismatch: checkpoint has {}, runtime expects {expected_key_group_count}",
251 self.vnode_count,
252 ),
253 });
254 }
255
256 if !self.operator_states.is_empty() && self.state_checksum.is_none() {
257 errors.push(ManifestValidationError {
258 message: "operator state is missing its integrity checksum".into(),
259 });
260 }
261
262 errors
263 }
264
265 #[must_use]
269 pub fn new(checkpoint_id: u64, epoch: u64) -> Self {
270 Self::new_with_key_group_count(checkpoint_id, epoch, LOCAL_KEY_GROUP_COUNT)
271 }
272
273 #[must_use]
277 pub fn new_with_key_group_count(
278 checkpoint_id: u64,
279 epoch: u64,
280 key_group_count: KeyGroupCount,
281 ) -> Self {
282 #[allow(clippy::cast_possible_truncation)] let timestamp_ms = std::time::SystemTime::now()
284 .duration_since(std::time::UNIX_EPOCH)
285 .unwrap_or_default()
286 .as_millis() as u64;
287
288 Self {
289 version: CHECKPOINT_MANIFEST_VERSION,
290 checkpoint_id,
291 epoch,
292 timestamp_ms,
293 durable_phase: DurableCheckpointPhase::Prepared,
294 participant_id: 0,
295 source_offsets: HashMap::new(),
296 table_offsets: HashMap::new(),
297 operator_states: HashMap::new(),
298 table_store_checkpoint_path: None,
299 watermark: None,
300 source_watermarks: HashMap::new(),
301 source_names: Vec::new(),
302 sink_names: Vec::new(),
303 pipeline_identity: PipelineIdentity::empty(),
304 deployment_id: String::new(),
305 partitioning_abi_version: PARTITIONING_ABI_VERSION,
306 vnode_count: key_group_count.get(),
307 state_checksum: None,
308 }
309 }
310}
311
312#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
323pub struct ConnectorCheckpoint {
324 pub offsets: HashMap<String, String>,
326 pub metadata: HashMap<String, String>,
328 pub source_assignment_version: Option<std::num::NonZeroU64>,
333}
334
335impl ConnectorCheckpoint {
336 #[must_use]
338 pub fn new() -> Self {
339 Self::default()
340 }
341
342 #[must_use]
344 pub fn with_offsets(offsets: HashMap<String, String>) -> Self {
345 Self {
346 offsets,
347 metadata: HashMap::new(),
348 source_assignment_version: None,
349 }
350 }
351}
352
353#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
355pub struct OperatorCheckpoint {
356 #[serde(default)]
358 pub state_b64: Option<String>,
359 #[serde(default)]
361 pub external: bool,
362 #[serde(default)]
364 pub external_offset: u64,
365 #[serde(default)]
367 pub external_length: u64,
368}
369
370impl OperatorCheckpoint {
371 #[must_use]
375 pub fn inline(data: &[u8]) -> Self {
376 use base64::Engine;
377 Self {
378 state_b64: Some(base64::engine::general_purpose::STANDARD.encode(data)),
379 external: false,
380 external_offset: 0,
381 external_length: 0,
382 }
383 }
384
385 #[must_use]
387 pub fn external(offset: u64, length: u64) -> Self {
388 Self {
389 state_b64: None,
390 external: true,
391 external_offset: offset,
392 external_length: length,
393 }
394 }
395
396 #[must_use]
401 pub fn decode_inline(&self) -> Option<Vec<u8>> {
402 use base64::Engine;
403 self.state_b64.as_ref().and_then(|b64| {
404 match base64::engine::general_purpose::STANDARD.decode(b64) {
405 Ok(data) => Some(data),
406 Err(e) => {
407 tracing::warn!(
408 error = %e,
409 b64_len = b64.len(),
410 "[LDB-4004] Failed to decode inline operator state from base64 — \
411 operator will start from scratch"
412 );
413 None
414 }
415 }
416 })
417 }
418
419 pub fn try_decode_inline(&self) -> Result<Option<Vec<u8>>, String> {
429 use base64::Engine;
430 match &self.state_b64 {
431 None => Ok(None),
432 Some(b64) => base64::engine::general_purpose::STANDARD
433 .decode(b64)
434 .map(Some)
435 .map_err(|e| format!("[LDB-4004] base64 decode failed: {e}")),
436 }
437 }
438
439 #[must_use]
455 #[allow(clippy::cast_possible_truncation)]
456 pub fn from_bytes(
457 data: &[u8],
458 threshold: usize,
459 current_offset: u64,
460 ) -> (Self, Option<Vec<u8>>) {
461 if data.len() <= threshold {
462 (Self::inline(data), None)
463 } else {
464 let length = data.len() as u64;
465 (Self::external(current_offset, length), Some(data.to_vec()))
466 }
467 }
468
469 #[must_use]
477 #[allow(clippy::cast_possible_truncation)]
478 pub fn from_bytes_shared(
479 data: bytes::Bytes,
480 threshold: usize,
481 current_offset: u64,
482 ) -> (Self, Option<bytes::Bytes>) {
483 if data.len() <= threshold {
484 (Self::inline(&data), None)
485 } else {
486 let length = data.len() as u64;
487 (Self::external(current_offset, length), Some(data))
488 }
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn test_manifest_new() {
498 let m = CheckpointManifest::new(5, 5);
499 assert_eq!(m.version, CHECKPOINT_MANIFEST_VERSION);
500 assert_eq!(m.partitioning_abi_version, PARTITIONING_ABI_VERSION);
501 assert_eq!(m.durable_phase, DurableCheckpointPhase::Prepared);
502 assert_eq!(m.checkpoint_id, 5);
503 assert_eq!(m.epoch, 5);
504 assert_eq!(m.vnode_count, LOCAL_KEY_GROUP_COUNT.get());
505 assert!(m.timestamp_ms > 0);
506 assert!(m.source_offsets.is_empty());
507 assert!(m.operator_states.is_empty());
508 }
509
510 #[test]
511 fn test_manifest_new_with_explicit_key_group_count() {
512 let key_group_count = KeyGroupCount::try_from(256_u16).unwrap();
513 let manifest = CheckpointManifest::new_with_key_group_count(5, 5, key_group_count);
514
515 assert_eq!(manifest.vnode_count, key_group_count.get());
516 assert!(manifest.validate(key_group_count).is_empty());
517 assert!(!manifest.validate(LOCAL_KEY_GROUP_COUNT).is_empty());
518 }
519
520 #[test]
521 fn test_manifest_json_round_trip() {
522 let mut m = CheckpointManifest::new(42, 42);
523 let mut source_checkpoint = ConnectorCheckpoint::with_offsets(HashMap::from([
524 ("events:0".into(), "1234".into()),
525 ("events:1".into(), "5678".into()),
526 ]));
527 source_checkpoint.source_assignment_version = std::num::NonZeroU64::new(12);
528 m.source_offsets
529 .insert("kafka-src".into(), source_checkpoint);
530 m.watermark = Some(999_000);
531 m.operator_states
532 .insert("window-agg".into(), OperatorCheckpoint::inline(b"hello"));
533
534 let json = serde_json::to_string_pretty(&m).unwrap();
535 let restored: CheckpointManifest = serde_json::from_str(&json).unwrap();
536
537 assert_eq!(restored.checkpoint_id, 42);
538 assert_eq!(restored.epoch, 42);
539 assert_eq!(restored.watermark, Some(999_000));
540 let src = restored.source_offsets.get("kafka-src").unwrap();
541 assert_eq!(src.offsets.get("events:0"), Some(&"1234".into()));
542 assert_eq!(src.source_assignment_version, std::num::NonZeroU64::new(12));
543
544 let op = restored.operator_states.get("window-agg").unwrap();
545 assert_eq!(op.decode_inline().unwrap(), b"hello");
546 }
547
548 #[test]
549 fn test_manifest_rejects_previous_version() {
550 let mut manifest = CheckpointManifest::new(1, 1);
551 manifest.version = CHECKPOINT_MANIFEST_VERSION - 1;
552 let restored: CheckpointManifest =
553 serde_json::from_str(&serde_json::to_string(&manifest).unwrap()).unwrap();
554 let errors = restored.validate(LOCAL_KEY_GROUP_COUNT);
555 let previous = CHECKPOINT_MANIFEST_VERSION - 1;
556 assert!(
557 errors.iter().any(|error| error
558 .message
559 .contains(&format!("unsupported manifest version {previous}"))),
560 "{errors:?}"
561 );
562 }
563
564 #[test]
565 fn test_manifest_rejects_noncanonical_attempt_identity() {
566 for (checkpoint_id, epoch) in [(0, 0), (5, 0), (0, 5), (5, 6)] {
567 let manifest = CheckpointManifest::new(checkpoint_id, epoch);
568 let errors = manifest.validate(LOCAL_KEY_GROUP_COUNT);
569 assert!(
570 errors.iter().any(|error| error
571 .message
572 .contains("one nonzero canonical checkpoint ID")),
573 "{checkpoint_id}/{epoch}: {errors:?}"
574 );
575 }
576 }
577
578 #[test]
579 fn test_connector_checkpoint_new() {
580 let cp = ConnectorCheckpoint::new();
581 assert!(cp.offsets.is_empty());
582 assert!(cp.metadata.is_empty());
583 assert_eq!(cp.source_assignment_version, None);
584 }
585
586 #[test]
587 fn test_connector_checkpoint_with_offsets() {
588 let offsets = HashMap::from([("lsn".into(), "0/ABCD".into())]);
589 let cp = ConnectorCheckpoint::with_offsets(offsets);
590 assert_eq!(cp.offsets.get("lsn"), Some(&"0/ABCD".into()));
591 assert_eq!(cp.source_assignment_version, None);
592 }
593
594 #[test]
595 fn test_operator_checkpoint_inline() {
596 let op = OperatorCheckpoint::inline(b"state-data");
597 assert!(!op.external);
598 assert!(op.state_b64.is_some());
599 assert_eq!(op.decode_inline().unwrap(), b"state-data");
600 }
601
602 #[test]
603 fn test_operator_checkpoint_external() {
604 let op = OperatorCheckpoint::external(1024, 256);
605 assert!(op.external);
606 assert_eq!(op.external_offset, 1024);
607 assert_eq!(op.external_length, 256);
608 assert!(op.decode_inline().is_none());
609 }
610
611 #[test]
612 fn test_operator_checkpoint_empty_inline() {
613 let op = OperatorCheckpoint::inline(b"");
614 assert_eq!(op.decode_inline().unwrap(), b"");
615 }
616
617 #[test]
618 fn test_manifest_table_offsets() {
619 let mut m = CheckpointManifest::new(1, 1);
620 m.table_offsets.insert(
621 "instruments".into(),
622 ConnectorCheckpoint::with_offsets(HashMap::from([("lsn".into(), "0/ABCD".into())])),
623 );
624 m.table_store_checkpoint_path = Some("/tmp/table_store_cp".into());
625
626 let json = serde_json::to_string(&m).unwrap();
627 let restored: CheckpointManifest = serde_json::from_str(&json).unwrap();
628
629 assert_eq!(restored.table_offsets.len(), 1);
630 assert_eq!(
631 restored.table_store_checkpoint_path.as_deref(),
632 Some("/tmp/table_store_cp")
633 );
634 }
635
636 #[test]
637 fn test_manifest_topology_fields_round_trip() {
638 let mut m = CheckpointManifest::new(1, 1);
639 m.source_names = vec!["kafka-clicks".into(), "ws-prices".into()];
640 m.sink_names = vec!["pg-sink".into()];
641
642 let json = serde_json::to_string(&m).unwrap();
643 let restored: CheckpointManifest = serde_json::from_str(&json).unwrap();
644
645 assert_eq!(restored.source_names, vec!["kafka-clicks", "ws-prices"]);
646 assert_eq!(restored.sink_names, vec!["pg-sink"]);
647 }
648
649 #[test]
650 fn test_manifest_requires_durable_phase() {
651 let json = r#"{
652 "version": 1,
653 "checkpoint_id": 5,
654 "epoch": 3,
655 "timestamp_ms": 1000
656 }"#;
657 assert!(serde_json::from_str::<CheckpointManifest>(json).is_err());
658 }
659
660 #[test]
661 fn test_manifest_requires_pipeline_identity() {
662 let manifest = CheckpointManifest::new(5, 5);
663 let mut value = serde_json::to_value(manifest).unwrap();
664 value.as_object_mut().unwrap().remove("pipeline_identity");
665 assert!(serde_json::from_value::<CheckpointManifest>(value).is_err());
666 }
667
668 #[test]
669 fn test_manifest_requires_partitioning_abi() {
670 let manifest = CheckpointManifest::new(5, 5);
671 let mut value = serde_json::to_value(manifest).unwrap();
672 value
673 .as_object_mut()
674 .unwrap()
675 .remove("partitioning_abi_version");
676 assert!(serde_json::from_value::<CheckpointManifest>(value).is_err());
677 }
678
679 #[test]
680 fn test_manifest_rejects_wrong_partitioning_abi() {
681 let mut manifest = CheckpointManifest::new(5, 5);
682 manifest.partitioning_abi_version = PARTITIONING_ABI_VERSION + 1;
683
684 let errors = manifest.validate(LOCAL_KEY_GROUP_COUNT);
685 assert!(
686 errors
687 .iter()
688 .any(|error| error.message.contains("partitioning ABI mismatch")),
689 "{errors:?}"
690 );
691 }
692
693 #[test]
694 fn test_validate_orphaned_source_offset() {
695 let mut m = CheckpointManifest::new(1, 1);
696 m.source_names = vec!["a".into(), "b".into()];
697 m.source_offsets
698 .insert("c".into(), ConnectorCheckpoint::new());
699
700 let errors = m.validate(LOCAL_KEY_GROUP_COUNT);
701 assert!(
702 errors
703 .iter()
704 .any(|e| e.message.contains("'c' not in source_names")),
705 "expected orphaned source offset error: {errors:?}"
706 );
707 }
708
709 #[test]
710 fn test_manifest_pipeline_identity_round_trip() {
711 let mut m = CheckpointManifest::new(1, 1);
712 m.pipeline_identity = PipelineIdentity {
713 canonical_version: PIPELINE_IDENTITY_VERSION,
714 sha256: "ab".repeat(32),
715 };
716
717 let json = serde_json::to_string(&m).unwrap();
718 let restored: CheckpointManifest = serde_json::from_str(&json).unwrap();
719
720 assert_eq!(restored.pipeline_identity, m.pipeline_identity);
721 }
722
723 #[test]
724 fn test_from_bytes_inline() {
725 let data = b"small-state";
726 let (op, sidecar) = OperatorCheckpoint::from_bytes(data, 1024, 0);
727 assert!(!op.external);
728 assert!(sidecar.is_none());
729 assert_eq!(op.decode_inline().unwrap(), data);
730 }
731
732 #[test]
733 fn test_from_bytes_external() {
734 let data = vec![0xAB; 2048];
735 let (op, sidecar) = OperatorCheckpoint::from_bytes(&data, 1024, 512);
736 assert!(op.external);
737 assert_eq!(op.external_offset, 512);
738 assert_eq!(op.external_length, 2048);
739 assert!(op.decode_inline().is_none());
740 assert_eq!(sidecar.unwrap(), data);
741 }
742
743 #[test]
744 fn test_from_bytes_at_threshold_boundary() {
745 let data = vec![0xFF; 100];
747 let (op, sidecar) = OperatorCheckpoint::from_bytes(&data, 100, 0);
748 assert!(!op.external);
749 assert!(sidecar.is_none());
750 assert_eq!(op.decode_inline().unwrap(), data);
751
752 let data_over = vec![0xFF; 101];
754 let (op2, sidecar2) = OperatorCheckpoint::from_bytes(&data_over, 100, 0);
755 assert!(op2.external);
756 assert!(sidecar2.is_some());
757 }
758
759 #[test]
760 fn test_from_bytes_empty_data() {
761 let (op, sidecar) = OperatorCheckpoint::from_bytes(b"", 1024, 0);
762 assert!(!op.external);
763 assert!(sidecar.is_none());
764 assert_eq!(op.decode_inline().unwrap(), b"");
765 }
766
767 #[test]
768 fn test_manifest_rejects_missing_v2_fields() {
769 let json = r#"{
770 "version": 1,
771 "checkpoint_id": 1,
772 "epoch": 1,
773 "timestamp_ms": 1000
774 }"#;
775 assert!(serde_json::from_str::<CheckpointManifest>(json).is_err());
776 }
777}