1use crate::agent::wire_id;
2use crate::authz::{
3 Action, Feature, OPERATOR_ASSERTION_KEY_ID_BYTES, OperatorActorAssertion,
4 OperatorAssertionAction,
5};
6use crate::destination::{
7 BackendBinding, DestinationDesiredState, DestinationId, MaterializationDestination,
8 ProjectionRef, QueryRoute, QueryRouteId,
9};
10use crate::error::InvalidError;
11use crate::schema::{Digest32, LogicalSchemaRef, SchemaFingerprint, TypedValue, UuidValue};
12use crate::source::SourceIncarnation;
13use crate::validate::Validate;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeSet;
16
17pub const MAX_CHECKPOINT_PARTITIONS: usize = 65_536;
18pub const MAX_ATTEMPT_OBJECTS: usize = 100_000;
19pub const MAX_CREDENTIAL_GENERATIONS: usize = 32;
20pub const MAX_MANIFEST_IDENTITY_BYTES: usize = 4_096;
21pub const MAX_CHECKPOINT_ERROR_BYTES: usize = 4_096;
22pub const MAX_REPAIR_DETAIL_BYTES: usize = 4_096;
23pub const MAX_CHECKPOINT_LEASE_DURATION_MICROS: u64 = 300_000_000;
24
25wire_id!(
26 CheckpointOwnerId
28);
29
30wire_id!(
31 PreparedAttemptId
33);
34
35wire_id!(
36 CheckpointRequestId
38);
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub struct PartitionCheckpoint {
42 pub incarnation: SourceIncarnation,
43 pub started_at_offset: u64,
44 pub next_offset: u64,
45 pub lifecycle: PartitionLifecycleState,
46}
47
48impl Validate for PartitionCheckpoint {
49 fn validate(&self) -> Result<(), InvalidError> {
50 self.incarnation.validate()?;
51 if self.started_at_offset > self.next_offset {
52 return Err(InvalidError::new(
53 "partition start offset must not exceed its next offset",
54 ));
55 }
56 Ok(())
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62#[non_exhaustive]
63pub enum PartitionLifecycleState {
64 Active,
65 Removed,
66 Recreated,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SourceOffsetRange {
71 pub incarnation: SourceIncarnation,
72 pub start: u64,
73 pub end_exclusive: u64,
74}
75
76impl Validate for SourceOffsetRange {
77 fn validate(&self) -> Result<(), InvalidError> {
78 self.incarnation.validate()?;
79 if self.start >= self.end_exclusive {
80 return Err(InvalidError::new(format!(
81 "source range [{}, {}) is empty or inverted",
82 self.start, self.end_exclusive
83 )));
84 }
85 Ok(())
86 }
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90pub struct CheckpointOwnerLease {
91 pub owner: CheckpointOwnerId,
92 pub epoch: u64,
93 pub sequence: u64,
94 pub deadline_micros: u64,
95}
96
97impl Validate for CheckpointOwnerLease {
98 fn validate(&self) -> Result<(), InvalidError> {
99 if self.owner.as_u128() == 0 {
100 return Err(InvalidError::new("checkpoint owner id must be nonzero"));
101 }
102 if self.epoch == 0 || self.sequence == 0 || self.deadline_micros == 0 {
103 return Err(InvalidError::new(
104 "checkpoint lease epoch, sequence, and deadline must be nonzero",
105 ));
106 }
107 Ok(())
108 }
109}
110
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct CredentialGeneration {
113 pub role: String,
114 pub generation: u64,
115}
116
117impl Validate for CredentialGeneration {
118 fn validate(&self) -> Result<(), InvalidError> {
119 if self.role.is_empty() || self.role.len() > 64 {
120 return Err(InvalidError::new(
121 "credential role must contain 1..=64 bytes",
122 ));
123 }
124 if self
125 .role
126 .bytes()
127 .any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_'))
128 {
129 return Err(InvalidError::new(
130 "credential role contains a disallowed byte",
131 ));
132 }
133 if self.generation == 0 {
134 return Err(InvalidError::new("credential generation must be nonzero"));
135 }
136 Ok(())
137 }
138}
139
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141pub struct AttemptObject {
142 pub identity: String,
143 pub size_bytes: u64,
144 pub row_count: u64,
145 pub sha256: Digest32,
146 pub columns: Vec<AttemptColumnMetrics>,
147}
148
149#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
150pub struct AttemptColumnMetrics {
151 pub field_id: u32,
152 pub value_count: u64,
153 pub null_count: u64,
154 pub nan_count: u64,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub lower_bound: Option<TypedValue>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub upper_bound: Option<TypedValue>,
159}
160
161impl Validate for AttemptObject {
162 fn validate(&self) -> Result<(), InvalidError> {
163 validate_object_identity(&self.identity)?;
164 if self.size_bytes == 0 {
165 return Err(InvalidError::new("attempt object size must be nonzero"));
166 }
167 if self.row_count == 0 {
168 return Err(InvalidError::new(
169 "attempt object row count must be nonzero",
170 ));
171 }
172 self.sha256.validate()?;
173 if self.columns.len() > crate::schema::MAX_LOGICAL_SCHEMA_FIELDS {
174 return Err(InvalidError::new(
175 "attempt object column metric count exceeds the schema field cap",
176 ));
177 }
178 let mut fields = BTreeSet::new();
179 for column in &self.columns {
180 column.validate()?;
181 if !fields.insert(column.field_id) {
182 return Err(InvalidError::new(format!(
183 "attempt object repeats column metric {}",
184 column.field_id
185 )));
186 }
187 }
188 Ok(())
189 }
190}
191
192impl Validate for AttemptColumnMetrics {
193 fn validate(&self) -> Result<(), InvalidError> {
194 if self.field_id == 0
195 || self.null_count > self.value_count
196 || self.nan_count > self.value_count.saturating_sub(self.null_count)
197 {
198 return Err(InvalidError::new("attempt column metrics are invalid"));
199 }
200 if let Some(value) = &self.lower_bound {
201 value.validate()?;
202 }
203 if let Some(value) = &self.upper_bound {
204 value.validate()?;
205 }
206 if self.lower_bound.is_some() != self.upper_bound.is_some() {
207 return Err(InvalidError::new(
208 "attempt column bounds must be both present or both absent",
209 ));
210 }
211 Ok(())
212 }
213}
214
215#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216pub struct PreparedTableRequirements {
217 pub table_uuid: UuidValue,
218 pub base_metadata_identity: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub base_snapshot_id: Option<i64>,
221 pub schema_id: i32,
222 pub partition_spec_id: i32,
223 pub commit_requirements: Vec<IcebergCommitRequirement>,
224}
225
226#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(tag = "kind", rename_all = "snake_case")]
228#[non_exhaustive]
229pub enum IcebergCommitRequirement {
230 AssertTableUuid { table_uuid: UuidValue },
231 AssertMetadataIdentity { identity: String },
232 AssertCurrentSnapshot { snapshot_id: Option<i64> },
233 AssertCurrentSchema { schema_id: i32 },
234 AssertDefaultPartitionSpec { partition_spec_id: i32 },
235}
236
237impl Validate for PreparedTableRequirements {
238 fn validate(&self) -> Result<(), InvalidError> {
239 validate_uuid(&self.table_uuid)?;
240 validate_object_identity(&self.base_metadata_identity)?;
241 if self
242 .base_snapshot_id
243 .is_some_and(|snapshot_id| snapshot_id <= 0)
244 || self.schema_id < 0
245 || self.partition_spec_id < 0
246 {
247 return Err(InvalidError::new(
248 "prepared table snapshot, schema, or partition spec is invalid",
249 ));
250 }
251 let expected = [
252 IcebergCommitRequirement::AssertTableUuid {
253 table_uuid: self.table_uuid.clone(),
254 },
255 IcebergCommitRequirement::AssertMetadataIdentity {
256 identity: self.base_metadata_identity.clone(),
257 },
258 IcebergCommitRequirement::AssertCurrentSnapshot {
259 snapshot_id: self.base_snapshot_id,
260 },
261 IcebergCommitRequirement::AssertCurrentSchema {
262 schema_id: self.schema_id,
263 },
264 IcebergCommitRequirement::AssertDefaultPartitionSpec {
265 partition_spec_id: self.partition_spec_id,
266 },
267 ];
268 if self.commit_requirements.as_slice() != expected.as_slice() {
269 return Err(InvalidError::new(
270 "Iceberg commit requirements must contain the exact frozen table preconditions in canonical order",
271 ));
272 }
273 Ok(())
274 }
275}
276
277#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
278pub struct PreparedAttempt {
279 pub id: PreparedAttemptId,
280 pub destination_id: DestinationId,
281 pub destination_generation: u64,
282 pub backend: BackendBinding,
283 pub owner: CheckpointOwnerId,
284 pub epoch: u64,
285 pub created_at_checkpoint_revision: u64,
286 pub table: PreparedTableRequirements,
287 pub schema_fingerprint: SchemaFingerprint,
288 pub projection: ProjectionRef,
289 pub ranges: Vec<SourceOffsetRange>,
290 pub resulting_boundary: Vec<PartitionCheckpoint>,
291 pub resulting_boundary_digest: Digest32,
292 pub manifest_identity: String,
293 pub manifest_digest: Digest32,
294 pub objects: Vec<AttemptObject>,
295 pub credential_generations: Vec<CredentialGeneration>,
296}
297
298impl Validate for PreparedAttempt {
299 fn validate(&self) -> Result<(), InvalidError> {
300 validate_attempt_identity(self.id, self.destination_id, self.destination_generation)?;
301 self.backend.validate()?;
302 if self.owner.as_u128() == 0 || self.epoch == 0 {
303 return Err(InvalidError::new(
304 "prepared attempt owner and epoch must be nonzero",
305 ));
306 }
307 if self.created_at_checkpoint_revision == 0 {
308 return Err(InvalidError::new(
309 "prepared attempt creation revision must be nonzero",
310 ));
311 }
312 self.table.validate()?;
313 self.schema_fingerprint.validate()?;
314 self.projection.validate()?;
315 self.resulting_boundary_digest.validate()?;
316 self.manifest_digest.validate()?;
317 validate_ranges(&self.ranges)?;
318 validate_resulting_boundary(&self.resulting_boundary, &self.ranges)?;
319 validate_object_identity(&self.manifest_identity)?;
320 if self.objects.is_empty() || self.objects.len() > MAX_ATTEMPT_OBJECTS {
321 return Err(InvalidError::new(format!(
322 "prepared attempt must contain 1..={MAX_ATTEMPT_OBJECTS} objects"
323 )));
324 }
325 let mut object_identities = BTreeSet::new();
326 for object in &self.objects {
327 object.validate()?;
328 if !object_identities.insert(&object.identity) {
329 return Err(InvalidError::new(format!(
330 "prepared attempt repeats object `{}`",
331 object.identity
332 )));
333 }
334 }
335 validate_credential_generations(&self.credential_generations)
336 }
337}
338
339#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
340pub struct PreparedAttemptSummary {
341 pub id: PreparedAttemptId,
342 pub owner: CheckpointOwnerId,
343 pub epoch: u64,
344 pub table: PreparedTableRequirements,
345 pub schema_fingerprint: SchemaFingerprint,
346 pub projection: ProjectionRef,
347 pub manifest_identity: String,
348 pub manifest_digest: Digest32,
349 pub resulting_boundary_digest: Digest32,
350 pub ranges: Vec<SourceOffsetRange>,
351 pub object_count: u32,
352 pub credential_generations: Vec<CredentialGeneration>,
353}
354
355impl Validate for PreparedAttemptSummary {
356 fn validate(&self) -> Result<(), InvalidError> {
357 if self.id.as_u128() == 0 || self.owner.as_u128() == 0 || self.epoch == 0 {
358 return Err(InvalidError::new(
359 "prepared attempt summary identity must be nonzero",
360 ));
361 }
362 self.table.validate()?;
363 self.schema_fingerprint.validate()?;
364 self.projection.validate()?;
365 validate_object_identity(&self.manifest_identity)?;
366 self.manifest_digest.validate()?;
367 self.resulting_boundary_digest.validate()?;
368 validate_ranges(&self.ranges)?;
369 if self.object_count == 0 || self.object_count as usize > MAX_ATTEMPT_OBJECTS {
370 return Err(InvalidError::new(
371 "prepared attempt summary object count is invalid",
372 ));
373 }
374 validate_credential_generations(&self.credential_generations)
375 }
376}
377
378#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
379pub struct CompletedAttempt {
380 pub id: PreparedAttemptId,
381 pub table_uuid: UuidValue,
382 pub snapshot_id: i64,
383 pub manifest_digest: Digest32,
384 pub resulting_boundary_digest: Digest32,
385 pub ranges: Vec<SourceOffsetRange>,
386 pub completion_revision: u64,
387}
388
389impl Validate for CompletedAttempt {
390 fn validate(&self) -> Result<(), InvalidError> {
391 if self.id.as_u128() == 0 {
392 return Err(InvalidError::new("completed attempt id must be nonzero"));
393 }
394 if self.table_uuid.as_bytes().len() != UuidValue::BYTES {
395 return Err(InvalidError::new("completed attempt table UUID is invalid"));
396 }
397 if self.snapshot_id <= 0 {
398 return Err(InvalidError::new(
399 "completed attempt snapshot id must be positive",
400 ));
401 }
402 self.manifest_digest.validate()?;
403 self.resulting_boundary_digest.validate()?;
404 if self.completion_revision == 0 {
405 return Err(InvalidError::new(
406 "completed attempt revision must be nonzero",
407 ));
408 }
409 validate_ranges(&self.ranges)
410 }
411}
412
413#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
414pub struct RetentionGap {
415 pub incarnation: SourceIncarnation,
416 pub required_next_offset: u64,
417 pub retained_start: u64,
418}
419
420impl Validate for RetentionGap {
421 fn validate(&self) -> Result<(), InvalidError> {
422 self.incarnation.validate()?;
423 if self.required_next_offset >= self.retained_start {
424 return Err(InvalidError::new(
425 "retention gap requires next offset below retained start",
426 ));
427 }
428 Ok(())
429 }
430}
431
432#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case")]
434#[non_exhaustive]
435pub enum DestinationBlockCode {
436 Decode,
437 Schema,
438 Projection,
439 Value,
440 Size,
441 RetentionGap,
442 PreparedAttempt,
443 BackendGeneration,
444 BackendUnavailable,
445 TableIdentity,
446 CatalogOutcomeUnknown,
447 SourceIncarnation,
448 Authorization,
449}
450
451#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
452pub struct DestinationBlock {
453 pub code: DestinationBlockCode,
454 pub message: String,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub incarnation: Option<SourceIncarnation>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub offset: Option<u64>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub row_ordinal: Option<u32>,
461}
462
463impl Validate for DestinationBlock {
464 fn validate(&self) -> Result<(), InvalidError> {
465 if self.message.is_empty() || self.message.len() > MAX_CHECKPOINT_ERROR_BYTES {
466 return Err(InvalidError::new(format!(
467 "checkpoint error message must contain 1..={MAX_CHECKPOINT_ERROR_BYTES} bytes"
468 )));
469 }
470 if let Some(incarnation) = &self.incarnation {
471 incarnation.validate()?;
472 }
473 Ok(())
474 }
475}
476
477#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479#[non_exhaustive]
480pub enum CheckpointReadConsistency {
481 Linearizable,
482 #[default]
483 PotentiallyStale,
484}
485
486#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(rename_all = "snake_case")]
488#[non_exhaustive]
489pub enum DestinationEffectiveState {
490 Disabled,
491 WaitingForBackend,
492 Ready,
493 Running,
494 Blocked,
495}
496
497#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
498pub struct DestinationCheckpointStatus {
499 pub destination_id: DestinationId,
500 pub destination_generation: u64,
501 pub backend: BackendBinding,
502 pub schema: LogicalSchemaRef,
503 pub projection: ProjectionRef,
504 pub global_state_revision: u64,
505 pub definition_revision: u64,
506 pub checkpoint_revision: u64,
507 pub desired_state: DestinationDesiredState,
508 pub effective_state: DestinationEffectiveState,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub table_uuid: Option<UuidValue>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub owner: Option<CheckpointOwnerLease>,
513 pub partitions: Vec<PartitionCheckpoint>,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub prepared_attempt: Option<PreparedAttemptSummary>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub last_completion: Option<CompletedAttempt>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub retention_gap: Option<RetentionGap>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub block: Option<DestinationBlock>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub last_repair: Option<RepairRecord>,
524 pub consistency: CheckpointReadConsistency,
525}
526
527impl Validate for DestinationCheckpointStatus {
528 fn validate(&self) -> Result<(), InvalidError> {
529 if self.destination_id.as_u128() == 0 || self.destination_generation == 0 {
530 return Err(InvalidError::new(
531 "checkpoint status destination identity must be nonzero",
532 ));
533 }
534 self.backend.validate()?;
535 self.schema.validate()?;
536 self.projection.validate()?;
537 if self.global_state_revision == 0 || self.definition_revision == 0 {
538 return Err(InvalidError::new(
539 "checkpoint status global and definition revisions must be nonzero",
540 ));
541 }
542 if self.partitions.len() > MAX_CHECKPOINT_PARTITIONS {
543 return Err(InvalidError::new(format!(
544 "checkpoint status exceeds {MAX_CHECKPOINT_PARTITIONS} partitions"
545 )));
546 }
547 let mut partitions = BTreeSet::new();
548 let mut namespace = None;
549 for partition in &self.partitions {
550 partition.validate()?;
551 let current = (
552 partition.incarnation.cluster,
553 partition.incarnation.stream_id,
554 partition.incarnation.topic_id,
555 );
556 if namespace.is_some_and(|expected| expected != current) {
557 return Err(InvalidError::new(
558 "checkpoint partitions must share one cluster, stream, and topic",
559 ));
560 }
561 namespace = Some(current);
562 if !partitions.insert(partition.incarnation.partition_id) {
563 return Err(InvalidError::new(format!(
564 "checkpoint status repeats partition {}",
565 partition.incarnation.partition_id
566 )));
567 }
568 }
569 if let Some(owner) = &self.owner {
570 owner.validate()?;
571 }
572 if let Some(table_uuid) = &self.table_uuid {
573 validate_uuid(table_uuid)?;
574 }
575 if let Some(prepared) = &self.prepared_attempt {
576 prepared.validate()?;
577 }
578 if let Some(completion) = &self.last_completion {
579 completion.validate()?;
580 }
581 if let Some(gap) = &self.retention_gap {
582 gap.validate()?;
583 }
584 if let Some(block) = &self.block {
585 block.validate()?;
586 }
587 if let Some(repair) = &self.last_repair {
588 repair.validate()?;
589 }
590 Ok(())
591 }
592}
593
594#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
595pub struct CheckpointRequestEnvelope {
596 pub v: u32,
597 pub request_id: CheckpointRequestId,
598 pub expected_global_state_revision: u64,
599 pub mutation: PublicCheckpointMutation,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub operator_assertion: Option<OperatorActorAssertion>,
602}
603
604impl CheckpointRequestEnvelope {
605 pub const fn new(
606 request_id: CheckpointRequestId,
607 expected_global_state_revision: u64,
608 mutation: PublicCheckpointMutation,
609 ) -> Self {
610 Self {
611 v: crate::codes::CHECKPOINT_OP_VERSION,
612 request_id,
613 expected_global_state_revision,
614 mutation,
615 operator_assertion: None,
616 }
617 }
618
619 #[must_use]
620 pub fn with_operator_assertion(mut self, assertion: OperatorActorAssertion) -> Self {
621 self.operator_assertion = Some(assertion);
622 self
623 }
624}
625
626impl Validate for CheckpointRequestEnvelope {
627 fn validate(&self) -> Result<(), InvalidError> {
628 if self.v != crate::codes::CHECKPOINT_OP_VERSION {
629 return Err(InvalidError::new(format!(
630 "checkpoint version must be {}, got {}",
631 crate::codes::CHECKPOINT_OP_VERSION,
632 self.v
633 )));
634 }
635 if self.request_id.as_u128() == 0 {
636 return Err(InvalidError::new("checkpoint request id must be nonzero"));
637 }
638 self.mutation.validate()?;
639 validate_operator_assertion(self)
640 }
641}
642
643#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
644#[serde(tag = "kind", rename_all = "snake_case")]
645#[non_exhaustive]
646pub enum PublicCheckpointMutation {
647 RegisterDestination {
648 destination: MaterializationDestination,
649 },
650 RegisterQueryRoute {
651 route: QueryRoute,
652 },
653 RemoveQueryRoute {
654 route_id: QueryRouteId,
655 route_generation: u64,
656 expected_definition_revision: u64,
657 },
658 BindTable {
659 destination_id: DestinationId,
660 destination_generation: u64,
661 expected_definition_revision: u64,
662 table_uuid: UuidValue,
663 },
664 SetDesiredState {
665 destination_id: DestinationId,
666 destination_generation: u64,
667 expected_definition_revision: u64,
668 desired_state: DestinationDesiredState,
669 },
670 AddPartition {
671 destination_id: DestinationId,
672 destination_generation: u64,
673 expected_checkpoint_revision: u64,
674 partition_id: u32,
675 },
676 ObservePartitionLifecycle {
677 destination_id: DestinationId,
678 destination_generation: u64,
679 expected_checkpoint_revision: u64,
680 partition_id: u32,
681 },
682 AcquireLease {
683 destination_id: DestinationId,
684 destination_generation: u64,
685 owner: CheckpointOwnerId,
686 expected_lease_sequence: u64,
687 lease_duration_micros: u64,
688 },
689 RenewLease {
690 destination_id: DestinationId,
691 destination_generation: u64,
692 owner: CheckpointOwnerId,
693 epoch: u64,
694 expected_lease_sequence: u64,
695 lease_duration_micros: u64,
696 },
697 TakeoverLease {
698 destination_id: DestinationId,
699 destination_generation: u64,
700 owner: CheckpointOwnerId,
701 expected_lease_sequence: u64,
702 lease_duration_micros: u64,
703 },
704 Prepare {
705 expected_checkpoint_revision: u64,
706 attempt: PreparedAttempt,
707 },
708 Complete {
709 destination_id: DestinationId,
710 destination_generation: u64,
711 owner: CheckpointOwnerId,
712 epoch: u64,
713 expected_checkpoint_revision: u64,
714 completion: CompletedAttempt,
715 },
716 RecordBlock {
717 destination_id: DestinationId,
718 destination_generation: u64,
719 expected_checkpoint_revision: u64,
720 block: DestinationBlock,
721 },
722 ClearBlock {
723 destination_id: DestinationId,
724 destination_generation: u64,
725 expected_checkpoint_revision: u64,
726 expected_code: DestinationBlockCode,
727 },
728 RecordRetentionGap {
729 destination_id: DestinationId,
730 destination_generation: u64,
731 expected_checkpoint_revision: u64,
732 gap: RetentionGap,
733 },
734 AcceptRetentionGap {
735 destination_id: DestinationId,
736 destination_generation: u64,
737 expected_checkpoint_revision: u64,
738 next_offset: u64,
739 },
740 SupersedeGeneration {
741 expected_definition_revision: u64,
742 replacement: MaterializationDestination,
743 },
744 RecordRepair {
745 destination_id: DestinationId,
746 destination_generation: u64,
747 expected_checkpoint_revision: u64,
748 repair: RepairRecord,
749 },
750}
751
752impl Validate for PublicCheckpointMutation {
753 fn validate(&self) -> Result<(), InvalidError> {
754 match self {
755 Self::RegisterDestination { destination } => destination.validate(),
756 Self::RegisterQueryRoute { route } => route.validate(),
757 Self::RemoveQueryRoute {
758 route_id,
759 route_generation,
760 expected_definition_revision,
761 } => {
762 if route_id.as_u128() == 0
763 || *route_generation == 0
764 || *expected_definition_revision == 0
765 {
766 return Err(InvalidError::new(
767 "query route identity, generation, and revision must be nonzero",
768 ));
769 }
770 Ok(())
771 }
772 Self::BindTable {
773 destination_id,
774 destination_generation,
775 expected_definition_revision,
776 table_uuid,
777 } => {
778 validate_destination_revision(
779 *destination_id,
780 *destination_generation,
781 *expected_definition_revision,
782 "definition",
783 )?;
784 validate_uuid(table_uuid)
785 }
786 Self::SetDesiredState {
787 destination_id,
788 destination_generation,
789 expected_definition_revision,
790 ..
791 } => validate_destination_revision(
792 *destination_id,
793 *destination_generation,
794 *expected_definition_revision,
795 "definition",
796 ),
797 Self::AddPartition {
798 destination_id,
799 destination_generation,
800 expected_checkpoint_revision,
801 ..
802 }
803 | Self::ObservePartitionLifecycle {
804 destination_id,
805 destination_generation,
806 expected_checkpoint_revision,
807 ..
808 }
809 | Self::ClearBlock {
810 destination_id,
811 destination_generation,
812 expected_checkpoint_revision,
813 ..
814 }
815 | Self::AcceptRetentionGap {
816 destination_id,
817 destination_generation,
818 expected_checkpoint_revision,
819 ..
820 } => validate_destination_revision(
821 *destination_id,
822 *destination_generation,
823 *expected_checkpoint_revision,
824 "checkpoint",
825 ),
826 Self::AcquireLease {
827 destination_id,
828 destination_generation,
829 owner,
830 lease_duration_micros,
831 ..
832 }
833 | Self::TakeoverLease {
834 destination_id,
835 destination_generation,
836 owner,
837 lease_duration_micros,
838 ..
839 } => validate_lease_request(
840 *destination_id,
841 *destination_generation,
842 *owner,
843 *lease_duration_micros,
844 ),
845 Self::RenewLease {
846 destination_id,
847 destination_generation,
848 owner,
849 epoch,
850 lease_duration_micros,
851 ..
852 } => {
853 validate_lease_request(
854 *destination_id,
855 *destination_generation,
856 *owner,
857 *lease_duration_micros,
858 )?;
859 if *epoch == 0 {
860 return Err(InvalidError::new("lease epoch must be nonzero"));
861 }
862 Ok(())
863 }
864 Self::Prepare {
865 expected_checkpoint_revision,
866 attempt,
867 } => {
868 if *expected_checkpoint_revision == 0 {
869 return Err(InvalidError::new(
870 "expected checkpoint revision must be nonzero",
871 ));
872 }
873 attempt.validate()
874 }
875 Self::Complete {
876 destination_id,
877 destination_generation,
878 owner,
879 epoch,
880 expected_checkpoint_revision,
881 completion,
882 } => {
883 validate_destination_revision(
884 *destination_id,
885 *destination_generation,
886 *expected_checkpoint_revision,
887 "checkpoint",
888 )?;
889 if owner.as_u128() == 0 || *epoch == 0 {
890 return Err(InvalidError::new(
891 "completion owner and epoch must be nonzero",
892 ));
893 }
894 completion.validate()
895 }
896 Self::RecordBlock {
897 destination_id,
898 destination_generation,
899 expected_checkpoint_revision,
900 block,
901 } => {
902 validate_destination_revision(
903 *destination_id,
904 *destination_generation,
905 *expected_checkpoint_revision,
906 "checkpoint",
907 )?;
908 block.validate()
909 }
910 Self::RecordRetentionGap {
911 destination_id,
912 destination_generation,
913 expected_checkpoint_revision,
914 gap,
915 } => {
916 validate_destination_revision(
917 *destination_id,
918 *destination_generation,
919 *expected_checkpoint_revision,
920 "checkpoint",
921 )?;
922 gap.validate()
923 }
924 Self::SupersedeGeneration {
925 expected_definition_revision,
926 replacement,
927 } => {
928 if *expected_definition_revision == 0 {
929 return Err(InvalidError::new(
930 "expected definition revision must be nonzero",
931 ));
932 }
933 replacement.validate()
934 }
935 Self::RecordRepair {
936 destination_id,
937 destination_generation,
938 expected_checkpoint_revision,
939 repair,
940 } => {
941 validate_destination_revision(
942 *destination_id,
943 *destination_generation,
944 *expected_checkpoint_revision,
945 "checkpoint",
946 )?;
947 repair.validate()
948 }
949 }
950 }
951}
952
953impl PublicCheckpointMutation {
954 pub const fn required_capability(&self) -> (Feature, Action) {
955 match self {
956 Self::RegisterDestination { .. }
957 | Self::RegisterQueryRoute { .. }
958 | Self::RemoveQueryRoute { .. }
959 | Self::BindTable { .. }
960 | Self::SetDesiredState { .. }
961 | Self::AddPartition { .. }
962 | Self::ObservePartitionLifecycle { .. } => (Feature::Destination, Action::Write),
963 Self::AcceptRetentionGap { .. }
964 | Self::SupersedeGeneration { .. }
965 | Self::RecordRepair { .. } => (Feature::Checkpoint, Action::Admin),
966 Self::AcquireLease { .. }
967 | Self::RenewLease { .. }
968 | Self::TakeoverLease { .. }
969 | Self::Prepare { .. }
970 | Self::Complete { .. }
971 | Self::RecordBlock { .. }
972 | Self::ClearBlock { .. }
973 | Self::RecordRetentionGap { .. } => (Feature::Checkpoint, Action::Write),
974 }
975 }
976}
977
978#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
979#[serde(rename_all = "snake_case")]
980#[non_exhaustive]
981pub enum PartitionLifecycleChange {
982 Removed,
983 Recreated,
984}
985
986#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
987pub struct RepairRecord {
988 pub action: RepairAction,
989 pub detail: String,
990}
991
992impl Validate for RepairRecord {
993 fn validate(&self) -> Result<(), InvalidError> {
994 if self.detail.is_empty() || self.detail.len() > MAX_REPAIR_DETAIL_BYTES {
995 return Err(InvalidError::new(format!(
996 "repair detail must contain 1..={MAX_REPAIR_DETAIL_BYTES} bytes"
997 )));
998 }
999 Ok(())
1000 }
1001}
1002
1003#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(rename_all = "snake_case")]
1005#[non_exhaustive]
1006pub enum RepairAction {
1007 ReconciledPreparedAttempt,
1008 AcceptedRetentionGap,
1009 ClearedRetryableBlock,
1010 SupersededGeneration,
1011}
1012
1013#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1014pub struct CheckpointMutationStamp {
1015 pub committed_at_micros: u64,
1016 pub iggy_actor_id: u32,
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub operator_actor: Option<VerifiedOperatorActor>,
1019}
1020
1021#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1022pub struct VerifiedOperatorActor {
1023 pub request_id: CheckpointRequestId,
1024 pub deployment_id: u32,
1025 pub cloud_user_id: u32,
1026 pub action: OperatorAssertionAction,
1027 pub destination_id: DestinationId,
1028 pub destination_generation: u64,
1029 pub expected_revision: u64,
1030 #[serde(with = "crate::encoding::bin_bytes")]
1031 pub key_id: Vec<u8>,
1032 pub issued_at_micros: u64,
1033}
1034
1035impl Validate for CheckpointMutationStamp {
1036 fn validate(&self) -> Result<(), InvalidError> {
1037 if self.committed_at_micros == 0 {
1038 return Err(InvalidError::new(
1039 "checkpoint committed timestamp must be nonzero",
1040 ));
1041 }
1042 if let Some(actor) = &self.operator_actor
1043 && (actor.request_id.as_u128() == 0
1044 || actor.deployment_id == 0
1045 || actor.cloud_user_id == 0
1046 || actor.destination_id.as_u128() == 0
1047 || actor.destination_generation == 0
1048 || actor.expected_revision == 0
1049 || actor.issued_at_micros == 0
1050 || actor.key_id.len() != OPERATOR_ASSERTION_KEY_ID_BYTES)
1051 {
1052 return Err(InvalidError::new(
1053 "verified operator actor evidence is invalid",
1054 ));
1055 }
1056 Ok(())
1057 }
1058}
1059
1060#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1061pub struct ReplicatedCheckpointMutation {
1062 pub request_id: CheckpointRequestId,
1063 pub expected_global_state_revision: u64,
1064 pub stamp: CheckpointMutationStamp,
1065 pub mutation: ReplicatedCheckpointMutationBody,
1066}
1067
1068#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1069#[serde(tag = "kind", rename_all = "snake_case")]
1070#[non_exhaustive]
1071pub enum ReplicatedCheckpointMutationBody {
1072 RegisterDestination {
1073 destination: MaterializationDestination,
1074 },
1075 RegisterQueryRoute {
1076 route: QueryRoute,
1077 },
1078 RemoveQueryRoute {
1079 route_id: QueryRouteId,
1080 route_generation: u64,
1081 expected_definition_revision: u64,
1082 },
1083 BindTable {
1084 destination_id: DestinationId,
1085 destination_generation: u64,
1086 expected_definition_revision: u64,
1087 table_uuid: UuidValue,
1088 },
1089 Activate {
1090 destination_id: DestinationId,
1091 destination_generation: u64,
1092 expected_definition_revision: u64,
1093 source_cut: crate::source::SourceCut,
1094 },
1095 Disable {
1096 destination_id: DestinationId,
1097 destination_generation: u64,
1098 expected_definition_revision: u64,
1099 },
1100 AddPartition {
1101 destination_id: DestinationId,
1102 destination_generation: u64,
1103 expected_checkpoint_revision: u64,
1104 partition: crate::source::SourcePartitionCut,
1105 },
1106 ObservePartitionLifecycle {
1107 destination_id: DestinationId,
1108 destination_generation: u64,
1109 expected_checkpoint_revision: u64,
1110 incarnation: SourceIncarnation,
1111 change: PartitionLifecycleChange,
1112 },
1113 AcquireLease {
1114 destination_id: DestinationId,
1115 destination_generation: u64,
1116 owner: CheckpointOwnerId,
1117 expected_lease_sequence: u64,
1118 deadline_micros: u64,
1119 },
1120 RenewLease {
1121 destination_id: DestinationId,
1122 destination_generation: u64,
1123 owner: CheckpointOwnerId,
1124 epoch: u64,
1125 expected_lease_sequence: u64,
1126 deadline_micros: u64,
1127 },
1128 TakeoverLease {
1129 destination_id: DestinationId,
1130 destination_generation: u64,
1131 owner: CheckpointOwnerId,
1132 expected_lease_sequence: u64,
1133 deadline_micros: u64,
1134 },
1135 Prepare {
1136 expected_checkpoint_revision: u64,
1137 attempt: PreparedAttempt,
1138 },
1139 Complete {
1140 destination_id: DestinationId,
1141 destination_generation: u64,
1142 owner: CheckpointOwnerId,
1143 epoch: u64,
1144 expected_checkpoint_revision: u64,
1145 completion: CompletedAttempt,
1146 },
1147 RecordBlock {
1148 destination_id: DestinationId,
1149 destination_generation: u64,
1150 expected_checkpoint_revision: u64,
1151 block: DestinationBlock,
1152 },
1153 ClearBlock {
1154 destination_id: DestinationId,
1155 destination_generation: u64,
1156 expected_checkpoint_revision: u64,
1157 expected_code: DestinationBlockCode,
1158 },
1159 RecordRetentionGap {
1160 destination_id: DestinationId,
1161 destination_generation: u64,
1162 expected_checkpoint_revision: u64,
1163 gap: RetentionGap,
1164 },
1165 AcceptRetentionGap {
1166 destination_id: DestinationId,
1167 destination_generation: u64,
1168 expected_checkpoint_revision: u64,
1169 incarnation: SourceIncarnation,
1170 next_offset: u64,
1171 repair: RepairRecord,
1172 },
1173 SupersedeGeneration {
1174 expected_definition_revision: u64,
1175 replacement: MaterializationDestination,
1176 repair: RepairRecord,
1177 },
1178 RecordRepair {
1179 destination_id: DestinationId,
1180 destination_generation: u64,
1181 expected_checkpoint_revision: u64,
1182 repair: RepairRecord,
1183 },
1184}
1185
1186impl Validate for ReplicatedCheckpointMutation {
1187 fn validate(&self) -> Result<(), InvalidError> {
1188 if self.request_id.as_u128() == 0 {
1189 return Err(InvalidError::new(
1190 "replicated checkpoint request id must be nonzero",
1191 ));
1192 }
1193 self.stamp.validate()?;
1194 self.mutation.validate()
1195 }
1196}
1197
1198impl Validate for ReplicatedCheckpointMutationBody {
1199 fn validate(&self) -> Result<(), InvalidError> {
1200 match self {
1201 Self::RegisterDestination { destination } => destination.validate(),
1202 Self::RegisterQueryRoute { route } => route.validate(),
1203 Self::RemoveQueryRoute {
1204 route_id,
1205 route_generation,
1206 expected_definition_revision,
1207 } => {
1208 if route_id.as_u128() == 0
1209 || *route_generation == 0
1210 || *expected_definition_revision == 0
1211 {
1212 return Err(InvalidError::new(
1213 "replicated query route identity and revision must be nonzero",
1214 ));
1215 }
1216 Ok(())
1217 }
1218 Self::BindTable {
1219 destination_id,
1220 destination_generation,
1221 expected_definition_revision,
1222 table_uuid,
1223 } => {
1224 validate_destination_revision(
1225 *destination_id,
1226 *destination_generation,
1227 *expected_definition_revision,
1228 "definition",
1229 )?;
1230 validate_uuid(table_uuid)
1231 }
1232 Self::Activate {
1233 destination_id,
1234 destination_generation,
1235 expected_definition_revision,
1236 source_cut,
1237 } => {
1238 validate_destination_revision(
1239 *destination_id,
1240 *destination_generation,
1241 *expected_definition_revision,
1242 "definition",
1243 )?;
1244 source_cut.validate()
1245 }
1246 Self::Disable {
1247 destination_id,
1248 destination_generation,
1249 expected_definition_revision,
1250 } => validate_destination_revision(
1251 *destination_id,
1252 *destination_generation,
1253 *expected_definition_revision,
1254 "definition",
1255 ),
1256 Self::AddPartition {
1257 destination_id,
1258 destination_generation,
1259 expected_checkpoint_revision,
1260 partition,
1261 } => {
1262 validate_destination_revision(
1263 *destination_id,
1264 *destination_generation,
1265 *expected_checkpoint_revision,
1266 "checkpoint",
1267 )?;
1268 partition.validate()
1269 }
1270 Self::ObservePartitionLifecycle {
1271 destination_id,
1272 destination_generation,
1273 expected_checkpoint_revision,
1274 incarnation,
1275 ..
1276 } => {
1277 validate_destination_revision(
1278 *destination_id,
1279 *destination_generation,
1280 *expected_checkpoint_revision,
1281 "checkpoint",
1282 )?;
1283 incarnation.validate()
1284 }
1285 Self::AcquireLease {
1286 destination_id,
1287 destination_generation,
1288 owner,
1289 deadline_micros,
1290 ..
1291 }
1292 | Self::TakeoverLease {
1293 destination_id,
1294 destination_generation,
1295 owner,
1296 deadline_micros,
1297 ..
1298 } => validate_lease_request(
1299 *destination_id,
1300 *destination_generation,
1301 *owner,
1302 *deadline_micros,
1303 ),
1304 Self::RenewLease {
1305 destination_id,
1306 destination_generation,
1307 owner,
1308 epoch,
1309 deadline_micros,
1310 ..
1311 } => {
1312 validate_lease_request(
1313 *destination_id,
1314 *destination_generation,
1315 *owner,
1316 *deadline_micros,
1317 )?;
1318 if *epoch == 0 {
1319 return Err(InvalidError::new("replicated lease epoch must be nonzero"));
1320 }
1321 Ok(())
1322 }
1323 Self::Prepare {
1324 expected_checkpoint_revision,
1325 attempt,
1326 } => {
1327 if *expected_checkpoint_revision == 0 {
1328 return Err(InvalidError::new(
1329 "expected checkpoint revision must be nonzero",
1330 ));
1331 }
1332 attempt.validate()
1333 }
1334 Self::Complete {
1335 destination_id,
1336 destination_generation,
1337 owner,
1338 epoch,
1339 expected_checkpoint_revision,
1340 completion,
1341 } => {
1342 validate_destination_revision(
1343 *destination_id,
1344 *destination_generation,
1345 *expected_checkpoint_revision,
1346 "checkpoint",
1347 )?;
1348 if owner.as_u128() == 0 || *epoch == 0 {
1349 return Err(InvalidError::new(
1350 "replicated completion owner and epoch must be nonzero",
1351 ));
1352 }
1353 completion.validate()
1354 }
1355 Self::RecordBlock {
1356 destination_id,
1357 destination_generation,
1358 expected_checkpoint_revision,
1359 block,
1360 } => {
1361 validate_destination_revision(
1362 *destination_id,
1363 *destination_generation,
1364 *expected_checkpoint_revision,
1365 "checkpoint",
1366 )?;
1367 block.validate()
1368 }
1369 Self::ClearBlock {
1370 destination_id,
1371 destination_generation,
1372 expected_checkpoint_revision,
1373 ..
1374 } => validate_destination_revision(
1375 *destination_id,
1376 *destination_generation,
1377 *expected_checkpoint_revision,
1378 "checkpoint",
1379 ),
1380 Self::RecordRetentionGap {
1381 destination_id,
1382 destination_generation,
1383 expected_checkpoint_revision,
1384 gap,
1385 } => {
1386 validate_destination_revision(
1387 *destination_id,
1388 *destination_generation,
1389 *expected_checkpoint_revision,
1390 "checkpoint",
1391 )?;
1392 gap.validate()
1393 }
1394 Self::AcceptRetentionGap {
1395 destination_id,
1396 destination_generation,
1397 expected_checkpoint_revision,
1398 incarnation,
1399 repair,
1400 ..
1401 } => {
1402 validate_destination_revision(
1403 *destination_id,
1404 *destination_generation,
1405 *expected_checkpoint_revision,
1406 "checkpoint",
1407 )?;
1408 incarnation.validate()?;
1409 repair.validate()
1410 }
1411 Self::SupersedeGeneration {
1412 expected_definition_revision,
1413 replacement,
1414 repair,
1415 } => {
1416 if *expected_definition_revision == 0 {
1417 return Err(InvalidError::new(
1418 "expected definition revision must be nonzero",
1419 ));
1420 }
1421 replacement.validate()?;
1422 repair.validate()
1423 }
1424 Self::RecordRepair {
1425 destination_id,
1426 destination_generation,
1427 expected_checkpoint_revision,
1428 repair,
1429 } => {
1430 validate_destination_revision(
1431 *destination_id,
1432 *destination_generation,
1433 *expected_checkpoint_revision,
1434 "checkpoint",
1435 )?;
1436 repair.validate()
1437 }
1438 }
1439 }
1440}
1441
1442#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1443#[serde(tag = "kind", rename_all = "snake_case")]
1444#[non_exhaustive]
1445pub enum CheckpointMutationResult {
1446 Destination {
1447 request_id: CheckpointRequestId,
1448 destination_id: DestinationId,
1449 destination_generation: u64,
1450 global_state_revision: u64,
1451 definition_revision: u64,
1452 checkpoint_revision: u64,
1453 #[serde(default, skip_serializing_if = "Option::is_none")]
1454 lease: Option<CheckpointOwnerLease>,
1455 },
1456 QueryRoute {
1457 request_id: CheckpointRequestId,
1458 route_id: QueryRouteId,
1459 route_generation: u64,
1460 global_state_revision: u64,
1461 definition_revision: u64,
1462 },
1463}
1464
1465#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1466#[non_exhaustive]
1467pub enum CheckpointError {
1468 #[error("checkpoint request is invalid: {0}")]
1469 Invalid(String),
1470 #[error("checkpoint destination was not found")]
1471 NotFound,
1472 #[error("checkpoint mutation conflicts with revision {observed_revision}")]
1473 Conflict { observed_revision: u64 },
1474 #[error("checkpoint owner lease was lost")]
1475 LeaseLost,
1476 #[error("checkpoint mutation is unauthorized")]
1477 Unauthorized,
1478 #[error("checkpoint service is unavailable: {0}")]
1479 Unavailable(String),
1480 #[error("checkpoint version mismatch: expected {expected}, got {got}")]
1481 Version { expected: u32, got: u32 },
1482}
1483
1484#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1485#[non_exhaustive]
1486pub enum CheckpointReply {
1487 Ok(CheckpointMutationResult),
1488 Err(CheckpointError),
1489}
1490
1491#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1492pub struct DestinationGetRequest {
1493 pub v: u32,
1494 pub destination_id: DestinationId,
1495 pub consistency: CheckpointReadConsistency,
1496}
1497
1498impl DestinationGetRequest {
1499 pub const fn new(
1500 destination_id: DestinationId,
1501 consistency: CheckpointReadConsistency,
1502 ) -> Self {
1503 Self {
1504 v: crate::codes::CHECKPOINT_OP_VERSION,
1505 destination_id,
1506 consistency,
1507 }
1508 }
1509}
1510
1511#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1512pub struct DestinationListFilter {
1513 #[serde(default, skip_serializing_if = "Option::is_none")]
1514 pub source_stream: Option<String>,
1515 #[serde(default, skip_serializing_if = "Option::is_none")]
1516 pub source_topic: Option<String>,
1517 #[serde(default, skip_serializing_if = "Option::is_none")]
1518 pub name_contains: Option<String>,
1519}
1520
1521#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1522pub struct DestinationListRequest {
1523 pub v: u32,
1524 #[serde(default)]
1525 pub filter: DestinationListFilter,
1526 #[serde(default, skip_serializing_if = "Option::is_none")]
1527 pub after: Option<DestinationId>,
1528 pub limit: u32,
1529 pub consistency: CheckpointReadConsistency,
1530}
1531
1532#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1533pub struct QueryRouteListRequest {
1534 pub v: u32,
1535 #[serde(default, skip_serializing_if = "Option::is_none")]
1536 pub name_contains: Option<String>,
1537 #[serde(default, skip_serializing_if = "Option::is_none")]
1538 pub after: Option<QueryRouteId>,
1539 pub limit: u32,
1540 pub consistency: CheckpointReadConsistency,
1541}
1542
1543#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1544pub struct DestinationCheckpointView {
1545 pub destination: MaterializationDestination,
1546 pub status: DestinationCheckpointStatus,
1547}
1548
1549#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1550pub struct DestinationCheckpointPage {
1551 pub destinations: Vec<DestinationCheckpointView>,
1552 #[serde(default, skip_serializing_if = "Option::is_none")]
1553 pub next_after: Option<DestinationId>,
1554 pub global_state_revision: u64,
1555 pub consistency: CheckpointReadConsistency,
1556}
1557
1558#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1559pub struct QueryRoutePage {
1560 pub routes: Vec<QueryRoute>,
1561 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 pub next_after: Option<QueryRouteId>,
1563 pub global_state_revision: u64,
1564 pub consistency: CheckpointReadConsistency,
1565}
1566
1567#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1568#[non_exhaustive]
1569pub enum CheckpointReadReply {
1570 Destination(Option<Box<DestinationCheckpointView>>),
1571 Destinations(DestinationCheckpointPage),
1572 QueryRoutes(QueryRoutePage),
1573 Err(CheckpointError),
1574}
1575
1576impl Validate for DestinationGetRequest {
1577 fn validate(&self) -> Result<(), InvalidError> {
1578 validate_checkpoint_read(self.v)?;
1579 if self.destination_id.as_u128() == 0 {
1580 return Err(InvalidError::new("destination id must be nonzero"));
1581 }
1582 Ok(())
1583 }
1584}
1585
1586impl Validate for DestinationListRequest {
1587 fn validate(&self) -> Result<(), InvalidError> {
1588 validate_checkpoint_read(self.v)?;
1589 validate_read_limit(self.limit)?;
1590 validate_optional_filter("source stream", self.filter.source_stream.as_deref())?;
1591 validate_optional_filter("source topic", self.filter.source_topic.as_deref())?;
1592 validate_optional_filter("destination name", self.filter.name_contains.as_deref())
1593 }
1594}
1595
1596impl Validate for QueryRouteListRequest {
1597 fn validate(&self) -> Result<(), InvalidError> {
1598 validate_checkpoint_read(self.v)?;
1599 validate_read_limit(self.limit)?;
1600 validate_optional_filter("query route name", self.name_contains.as_deref())
1601 }
1602}
1603
1604impl Validate for CheckpointMutationResult {
1605 fn validate(&self) -> Result<(), InvalidError> {
1606 match self {
1607 Self::Destination {
1608 request_id,
1609 destination_id,
1610 destination_generation,
1611 global_state_revision,
1612 definition_revision,
1613 lease,
1614 ..
1615 } => {
1616 if request_id.as_u128() == 0
1617 || destination_id.as_u128() == 0
1618 || *destination_generation == 0
1619 || *global_state_revision == 0
1620 || *definition_revision == 0
1621 {
1622 return Err(InvalidError::new(
1623 "checkpoint destination result identity, generation, global revision, and definition revision must be nonzero",
1624 ));
1625 }
1626 if let Some(lease) = lease {
1627 lease.validate()?;
1628 }
1629 }
1630 Self::QueryRoute {
1631 request_id,
1632 route_id,
1633 route_generation,
1634 global_state_revision,
1635 definition_revision,
1636 } => {
1637 if request_id.as_u128() == 0
1638 || route_id.as_u128() == 0
1639 || *route_generation == 0
1640 || *global_state_revision == 0
1641 || *definition_revision == 0
1642 {
1643 return Err(InvalidError::new(
1644 "checkpoint query route result identity, generation, global revision, and definition revision must be nonzero",
1645 ));
1646 }
1647 }
1648 }
1649 Ok(())
1650 }
1651}
1652
1653impl Validate for DestinationCheckpointView {
1654 fn validate(&self) -> Result<(), InvalidError> {
1655 self.destination.validate()?;
1656 self.status.validate()?;
1657 if self.destination.id != self.status.destination_id
1658 || self.destination.generation != self.status.destination_generation
1659 || self.destination.definition_revision != self.status.definition_revision
1660 || self.destination.backend != self.status.backend
1661 || self.destination.schema != self.status.schema
1662 || self.destination.projection != self.status.projection
1663 {
1664 return Err(InvalidError::new(
1665 "destination declaration and checkpoint status do not describe the same generation",
1666 ));
1667 }
1668 Ok(())
1669 }
1670}
1671
1672impl Validate for DestinationCheckpointPage {
1673 fn validate(&self) -> Result<(), InvalidError> {
1674 if self.global_state_revision == 0 || self.destinations.len() > crate::limits::MAX_PAGE_SIZE
1675 {
1676 return Err(InvalidError::new("destination page metadata is invalid"));
1677 }
1678 let mut ids = BTreeSet::new();
1679 for destination in &self.destinations {
1680 destination.validate()?;
1681 if destination.status.global_state_revision > self.global_state_revision
1682 || !ids.insert(destination.destination.id)
1683 {
1684 return Err(InvalidError::new(
1685 "destination page contains duplicate or future state",
1686 ));
1687 }
1688 }
1689 Ok(())
1690 }
1691}
1692
1693impl Validate for QueryRoutePage {
1694 fn validate(&self) -> Result<(), InvalidError> {
1695 if self.global_state_revision == 0 || self.routes.len() > crate::limits::MAX_PAGE_SIZE {
1696 return Err(InvalidError::new("query route page metadata is invalid"));
1697 }
1698 let mut ids = BTreeSet::new();
1699 for route in &self.routes {
1700 route.validate()?;
1701 if !ids.insert(route.id) {
1702 return Err(InvalidError::new("query route page repeats a route id"));
1703 }
1704 }
1705 Ok(())
1706 }
1707}
1708
1709impl Validate for CheckpointReadReply {
1710 fn validate(&self) -> Result<(), InvalidError> {
1711 match self {
1712 Self::Destination(Some(destination)) => destination.validate(),
1713 Self::Destination(None) | Self::Err(_) => Ok(()),
1714 Self::Destinations(page) => page.validate(),
1715 Self::QueryRoutes(page) => page.validate(),
1716 }
1717 }
1718}
1719
1720fn validate_checkpoint_read(version: u32) -> Result<(), InvalidError> {
1721 if version != crate::codes::CHECKPOINT_OP_VERSION {
1722 return Err(InvalidError::new(format!(
1723 "checkpoint read version must be {}, got {version}",
1724 crate::codes::CHECKPOINT_OP_VERSION
1725 )));
1726 }
1727 Ok(())
1728}
1729
1730fn validate_read_limit(limit: u32) -> Result<(), InvalidError> {
1731 if limit == 0 || limit > crate::limits::MAX_PAGE_SIZE as u32 {
1732 return Err(InvalidError::new(format!(
1733 "checkpoint read limit must be in 1..={}",
1734 crate::limits::MAX_PAGE_SIZE
1735 )));
1736 }
1737 Ok(())
1738}
1739
1740fn validate_optional_filter(label: &str, value: Option<&str>) -> Result<(), InvalidError> {
1741 if let Some(value) = value
1742 && (value.is_empty() || value.len() > 255 || value.chars().any(char::is_control))
1743 {
1744 return Err(InvalidError::new(format!(
1745 "{label} filter must contain 1..=255 bytes without control characters"
1746 )));
1747 }
1748 Ok(())
1749}
1750
1751fn validate_operator_assertion(envelope: &CheckpointRequestEnvelope) -> Result<(), InvalidError> {
1752 let required = match &envelope.mutation {
1753 PublicCheckpointMutation::AcceptRetentionGap {
1754 destination_id,
1755 destination_generation,
1756 expected_checkpoint_revision,
1757 ..
1758 } => Some((
1759 OperatorAssertionAction::AcceptRetentionGap,
1760 *destination_id,
1761 *destination_generation,
1762 *expected_checkpoint_revision,
1763 )),
1764 PublicCheckpointMutation::SupersedeGeneration {
1765 expected_definition_revision,
1766 replacement,
1767 } => Some((
1768 OperatorAssertionAction::SupersedeGeneration,
1769 replacement.id,
1770 replacement.generation,
1771 *expected_definition_revision,
1772 )),
1773 PublicCheckpointMutation::RecordRepair {
1774 destination_id,
1775 destination_generation,
1776 expected_checkpoint_revision,
1777 ..
1778 } => Some((
1779 OperatorAssertionAction::RecordRepair,
1780 *destination_id,
1781 *destination_generation,
1782 *expected_checkpoint_revision,
1783 )),
1784 _ => None,
1785 };
1786 match (required, &envelope.operator_assertion) {
1787 (None, None) => Ok(()),
1788 (None, Some(_)) => Err(InvalidError::new(
1789 "operator assertion is not accepted for this checkpoint mutation",
1790 )),
1791 (Some(_), None) => Err(InvalidError::new(
1792 "high-risk checkpoint mutation requires a operator assertion",
1793 )),
1794 (
1795 Some((action, destination_id, destination_generation, expected_revision)),
1796 Some(assertion),
1797 ) => {
1798 assertion.validate()?;
1799 if assertion.claims.request_id != envelope.request_id
1800 || assertion.claims.action != action
1801 || assertion.claims.destination_id != destination_id
1802 || assertion.claims.destination_generation != destination_generation
1803 || assertion.claims.expected_revision != Some(expected_revision)
1804 {
1805 return Err(InvalidError::new(
1806 "operator assertion is not bound to this request, action, and destination",
1807 ));
1808 }
1809 Ok(())
1810 }
1811 }
1812}
1813
1814fn validate_destination_revision(
1815 destination_id: DestinationId,
1816 destination_generation: u64,
1817 revision: u64,
1818 revision_kind: &str,
1819) -> Result<(), InvalidError> {
1820 if destination_id.as_u128() == 0 || destination_generation == 0 {
1821 return Err(InvalidError::new(
1822 "checkpoint destination identity and generation must be nonzero",
1823 ));
1824 }
1825 if revision == 0 {
1826 return Err(InvalidError::new(format!(
1827 "expected {revision_kind} revision must be nonzero"
1828 )));
1829 }
1830 Ok(())
1831}
1832
1833fn validate_uuid(value: &UuidValue) -> Result<(), InvalidError> {
1834 if value.as_bytes().len() != UuidValue::BYTES {
1835 return Err(InvalidError::new(format!(
1836 "table UUID must be {} bytes",
1837 UuidValue::BYTES
1838 )));
1839 }
1840 Ok(())
1841}
1842
1843fn validate_lease_request(
1844 destination_id: DestinationId,
1845 destination_generation: u64,
1846 owner: CheckpointOwnerId,
1847 lease_duration_micros: u64,
1848) -> Result<(), InvalidError> {
1849 if destination_id.as_u128() == 0
1850 || destination_generation == 0
1851 || owner.as_u128() == 0
1852 || lease_duration_micros == 0
1853 || lease_duration_micros > MAX_CHECKPOINT_LEASE_DURATION_MICROS
1854 {
1855 return Err(InvalidError::new(
1856 "lease destination, owner, and generation must be nonzero, and duration must be within the configured cap",
1857 ));
1858 }
1859 Ok(())
1860}
1861
1862fn validate_attempt_identity(
1863 attempt_id: PreparedAttemptId,
1864 destination_id: DestinationId,
1865 destination_generation: u64,
1866) -> Result<(), InvalidError> {
1867 if attempt_id.as_u128() == 0 {
1868 return Err(InvalidError::new("prepared attempt id must be nonzero"));
1869 }
1870 if destination_id.as_u128() == 0 || destination_generation == 0 {
1871 return Err(InvalidError::new(
1872 "prepared attempt destination identity must be nonzero",
1873 ));
1874 }
1875 Ok(())
1876}
1877
1878fn validate_ranges(ranges: &[SourceOffsetRange]) -> Result<(), InvalidError> {
1879 if ranges.is_empty() || ranges.len() > MAX_CHECKPOINT_PARTITIONS {
1880 return Err(InvalidError::new(format!(
1881 "source ranges must contain 1..={MAX_CHECKPOINT_PARTITIONS} partitions"
1882 )));
1883 }
1884 let mut partitions = BTreeSet::new();
1885 let mut previous_partition = None;
1886 let mut namespace = None;
1887 for range in ranges {
1888 range.validate()?;
1889 let current = (
1890 range.incarnation.cluster,
1891 range.incarnation.stream_id,
1892 range.incarnation.topic_id,
1893 );
1894 if namespace.is_some_and(|expected| expected != current) {
1895 return Err(InvalidError::new(
1896 "source ranges must share one cluster, stream, and topic",
1897 ));
1898 }
1899 namespace = Some(current);
1900 if !partitions.insert(range.incarnation.partition_id) {
1901 return Err(InvalidError::new(format!(
1902 "source ranges repeat partition {}",
1903 range.incarnation.partition_id
1904 )));
1905 }
1906 if previous_partition.is_some_and(|previous| previous >= range.incarnation.partition_id) {
1907 return Err(InvalidError::new(
1908 "source ranges must be ordered by ascending partition id",
1909 ));
1910 }
1911 previous_partition = Some(range.incarnation.partition_id);
1912 }
1913 Ok(())
1914}
1915
1916fn validate_resulting_boundary(
1917 boundary: &[PartitionCheckpoint],
1918 ranges: &[SourceOffsetRange],
1919) -> Result<(), InvalidError> {
1920 if boundary.len() != ranges.len() {
1921 return Err(InvalidError::new(
1922 "prepared attempt boundary must cover every source range exactly once",
1923 ));
1924 }
1925 for (range, checkpoint) in ranges.iter().zip(boundary) {
1926 checkpoint.validate()?;
1927 if checkpoint.incarnation != range.incarnation
1928 || checkpoint.next_offset != range.end_exclusive
1929 || checkpoint.lifecycle != PartitionLifecycleState::Active
1930 {
1931 return Err(InvalidError::new(
1932 "prepared attempt boundary does not match its source ranges",
1933 ));
1934 }
1935 }
1936 Ok(())
1937}
1938
1939fn validate_credential_generations(
1940 generations: &[CredentialGeneration],
1941) -> Result<(), InvalidError> {
1942 if generations.is_empty() || generations.len() > MAX_CREDENTIAL_GENERATIONS {
1943 return Err(InvalidError::new(format!(
1944 "credential generations must contain 1..={MAX_CREDENTIAL_GENERATIONS} roles"
1945 )));
1946 }
1947 let mut roles = BTreeSet::new();
1948 for generation in generations {
1949 generation.validate()?;
1950 if !roles.insert(&generation.role) {
1951 return Err(InvalidError::new(format!(
1952 "credential role `{}` appears more than once",
1953 generation.role
1954 )));
1955 }
1956 }
1957 Ok(())
1958}
1959
1960fn validate_object_identity(identity: &str) -> Result<(), InvalidError> {
1961 if identity.is_empty() || identity.len() > MAX_MANIFEST_IDENTITY_BYTES {
1962 return Err(InvalidError::new(format!(
1963 "object identity must contain 1..={MAX_MANIFEST_IDENTITY_BYTES} bytes"
1964 )));
1965 }
1966 if identity.contains('?')
1967 || identity.contains('#')
1968 || identity.contains('@')
1969 || identity.contains("//")
1970 {
1971 return Err(InvalidError::new(
1972 "object identity must be canonical, provider-relative, and secret-free",
1973 ));
1974 }
1975 Ok(())
1976}
1977
1978#[cfg(test)]
1979mod tests {
1980 use super::*;
1981 #[cfg(feature = "cbor")]
1982 use crate::destination::{
1983 BackendResourceId, DestinationErrorPolicy, FileFormat, NewPartitionPolicy, PhysicalTable,
1984 RecreatedPartitionPolicy, StartPolicy, TableFormat,
1985 };
1986 #[cfg(feature = "cbor")]
1987 use crate::framing::{decode_named, encode_named};
1988 #[cfg(feature = "cbor")]
1989 use crate::schema::{LogicalSchemaId, SchemaFingerprint};
1990 use crate::source::PhysicalClusterIncarnation;
1991 #[cfg(feature = "cbor")]
1992 use crate::source::SourceScope;
1993
1994 fn incarnation(partition_id: u32) -> SourceIncarnation {
1995 SourceIncarnation {
1996 cluster: PhysicalClusterIncarnation::from_u128(1),
1997 stream_id: 2,
1998 topic_id: 3,
1999 partition_id,
2000 partition_created_revision: 4,
2001 }
2002 }
2003
2004 #[cfg(feature = "cbor")]
2005 fn destination() -> MaterializationDestination {
2006 MaterializationDestination {
2007 id: DestinationId::from_u128(10),
2008 generation: 1,
2009 definition_revision: 1,
2010 name: "readings-lakehouse".to_owned(),
2011 source: SourceScope::new("fleet", "readings"),
2012 recreated_partition_policy: RecreatedPartitionPolicy::Reject,
2013 projection: ProjectionRef {
2014 id: crate::control::ProjectionId::new("reading.v1"),
2015 version: 1,
2016 },
2017 schema: LogicalSchemaRef {
2018 id: LogicalSchemaId::from_u128(11),
2019 version: 1,
2020 fingerprint: SchemaFingerprint::new([12; 32]),
2021 },
2022 backend: BackendBinding {
2023 resource_id: BackendResourceId::from_u128(13),
2024 generation: 2,
2025 },
2026 table: PhysicalTable {
2027 namespace: vec!["fleet".to_owned()],
2028 table: "readings".to_owned(),
2029 expected_table_uuid: None,
2030 },
2031 file_format: FileFormat::Parquet,
2032 table_format: TableFormat::IcebergV2,
2033 start_policy: StartPolicy::CapturedLatest,
2034 new_partition_policy: NewPartitionPolicy::CapturedLatest,
2035 error_policy: DestinationErrorPolicy::Block,
2036 desired_state: DestinationDesiredState::Disabled,
2037 }
2038 }
2039
2040 fn table_requirements() -> PreparedTableRequirements {
2041 let table_uuid = UuidValue::new([14; 16]);
2042 let base_metadata_identity = "warehouse/fleet/readings/metadata/00001.json".to_owned();
2043 PreparedTableRequirements {
2044 table_uuid: table_uuid.clone(),
2045 base_metadata_identity: base_metadata_identity.clone(),
2046 base_snapshot_id: Some(15),
2047 schema_id: 1,
2048 partition_spec_id: 0,
2049 commit_requirements: vec![
2050 IcebergCommitRequirement::AssertTableUuid { table_uuid },
2051 IcebergCommitRequirement::AssertMetadataIdentity {
2052 identity: base_metadata_identity,
2053 },
2054 IcebergCommitRequirement::AssertCurrentSnapshot {
2055 snapshot_id: Some(15),
2056 },
2057 IcebergCommitRequirement::AssertCurrentSchema { schema_id: 1 },
2058 IcebergCommitRequirement::AssertDefaultPartitionSpec {
2059 partition_spec_id: 0,
2060 },
2061 ],
2062 }
2063 }
2064
2065 #[cfg(feature = "cbor")]
2066 #[test]
2067 fn given_a_public_request_when_decoded_as_a_replicated_mutation_then_should_fail() {
2068 let request = CheckpointRequestEnvelope::new(
2069 CheckpointRequestId::from_u128(20),
2070 0,
2071 PublicCheckpointMutation::RegisterDestination {
2072 destination: destination(),
2073 },
2074 );
2075 request.validate().expect("public request is valid");
2076 let bytes = encode_named(&request).expect("public request encodes");
2077 assert!(decode_named::<ReplicatedCheckpointMutation>(&bytes).is_err());
2078 }
2079
2080 #[cfg(feature = "cbor")]
2081 #[test]
2082 fn given_a_replicated_mutation_when_decoded_as_a_public_request_then_should_fail() {
2083 let mutation = ReplicatedCheckpointMutation {
2084 request_id: CheckpointRequestId::from_u128(21),
2085 expected_global_state_revision: 0,
2086 stamp: CheckpointMutationStamp {
2087 committed_at_micros: 22,
2088 iggy_actor_id: 23,
2089 operator_actor: None,
2090 },
2091 mutation: ReplicatedCheckpointMutationBody::RegisterDestination {
2092 destination: destination(),
2093 },
2094 };
2095 mutation.validate().expect("replicated mutation is valid");
2096 let bytes = encode_named(&mutation).expect("replicated mutation encodes");
2097 assert!(decode_named::<CheckpointRequestEnvelope>(&bytes).is_err());
2098 }
2099
2100 #[test]
2101 fn given_prepared_table_requirements_when_validated_then_should_require_every_frozen_iceberg_precondition_in_order()
2102 {
2103 let valid = table_requirements();
2104 valid.validate().expect("requirements are complete");
2105
2106 let mut missing = valid.clone();
2107 missing.commit_requirements.pop();
2108 assert!(missing.validate().is_err());
2109
2110 let mut reordered = valid;
2111 reordered.commit_requirements.swap(0, 1);
2112 assert!(reordered.validate().is_err());
2113 }
2114
2115 #[test]
2116 fn given_a_lease_duration_when_validated_then_should_be_bounded_before_managed_log_submission()
2117 {
2118 let mutation = PublicCheckpointMutation::AcquireLease {
2119 destination_id: DestinationId::from_u128(10),
2120 destination_generation: 1,
2121 owner: CheckpointOwnerId::from_u128(30),
2122 expected_lease_sequence: 0,
2123 lease_duration_micros: MAX_CHECKPOINT_LEASE_DURATION_MICROS + 1,
2124 };
2125 assert!(mutation.validate().is_err());
2126 }
2127
2128 #[test]
2129 fn given_a_root_authored_stamp_when_validated_then_should_preserve_actor_zero() {
2130 let stamp = CheckpointMutationStamp {
2131 committed_at_micros: 1,
2132 iggy_actor_id: 0,
2133 operator_actor: None,
2134 };
2135
2136 stamp
2137 .validate()
2138 .expect("root actor zero is a valid Iggy identity");
2139 }
2140
2141 #[test]
2142 fn given_an_unserved_checkpoint_request_version_when_validated_then_should_reject_it() {
2143 let mut request = CheckpointRequestEnvelope::new(
2144 CheckpointRequestId::from_u128(1),
2145 0,
2146 PublicCheckpointMutation::RegisterDestination {
2147 destination: destination(),
2148 },
2149 );
2150 request.v = crate::codes::CHECKPOINT_OP_VERSION + 1;
2151
2152 assert!(request.validate().is_err());
2153 }
2154
2155 #[test]
2156 fn given_destination_and_query_route_results_when_validated_then_should_require_variant_specific_identity()
2157 {
2158 CheckpointMutationResult::Destination {
2159 request_id: CheckpointRequestId::from_u128(1),
2160 destination_id: DestinationId::from_u128(2),
2161 destination_generation: 3,
2162 global_state_revision: 4,
2163 definition_revision: 5,
2164 checkpoint_revision: 0,
2165 lease: None,
2166 }
2167 .validate()
2168 .expect("destination result is valid");
2169 CheckpointMutationResult::QueryRoute {
2170 request_id: CheckpointRequestId::from_u128(6),
2171 route_id: QueryRouteId::from_u128(7),
2172 route_generation: 8,
2173 global_state_revision: 9,
2174 definition_revision: 10,
2175 }
2176 .validate()
2177 .expect("query route result is valid");
2178 }
2179
2180 #[test]
2181 fn given_source_ranges_when_out_of_canonical_partition_order_then_should_reject() {
2182 let ranges = vec![
2183 SourceOffsetRange {
2184 incarnation: incarnation(2),
2185 start: 0,
2186 end_exclusive: 1,
2187 },
2188 SourceOffsetRange {
2189 incarnation: incarnation(1),
2190 start: 0,
2191 end_exclusive: 1,
2192 },
2193 ];
2194 assert!(validate_ranges(&ranges).is_err());
2195 }
2196}