1use crate::agent::wire_id;
2use crate::authz::{
3 Action, Feature, SUPERVISOR_ASSERTION_KEY_ID_BYTES, SupervisorActorAssertion,
4 SupervisorAssertionAction,
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 supervisor_assertion: Option<SupervisorActorAssertion>,
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 supervisor_assertion: None,
616 }
617 }
618
619 #[must_use]
620 pub fn with_supervisor_assertion(mut self, assertion: SupervisorActorAssertion) -> Self {
621 self.supervisor_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_supervisor_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 primary_timestamp_micros: u64,
1016 pub iggy_actor_id: u32,
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub supervisor_actor: Option<VerifiedSupervisorActor>,
1019}
1020
1021#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1022pub struct VerifiedSupervisorActor {
1023 pub request_id: CheckpointRequestId,
1024 pub deployment_id: u32,
1025 pub cloud_user_id: u32,
1026 pub action: SupervisorAssertionAction,
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.primary_timestamp_micros == 0 || self.iggy_actor_id == 0 {
1038 return Err(InvalidError::new(
1039 "checkpoint primary timestamp and Iggy actor must be nonzero",
1040 ));
1041 }
1042 if let Some(actor) = &self.supervisor_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() != SUPERVISOR_ASSERTION_KEY_ID_BYTES)
1051 {
1052 return Err(InvalidError::new(
1053 "verified supervisor 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)]
1443pub struct CheckpointMutationResult {
1444 pub request_id: CheckpointRequestId,
1445 pub destination_id: DestinationId,
1446 pub destination_generation: u64,
1447 pub global_state_revision: u64,
1448 pub definition_revision: u64,
1449 pub checkpoint_revision: u64,
1450 #[serde(default, skip_serializing_if = "Option::is_none")]
1451 pub lease: Option<CheckpointOwnerLease>,
1452}
1453
1454#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1455#[non_exhaustive]
1456pub enum CheckpointError {
1457 #[error("checkpoint request is invalid: {0}")]
1458 Invalid(String),
1459 #[error("checkpoint destination was not found")]
1460 NotFound,
1461 #[error("checkpoint mutation conflicts with revision {observed_revision}")]
1462 Conflict { observed_revision: u64 },
1463 #[error("checkpoint owner lease was lost")]
1464 LeaseLost,
1465 #[error("checkpoint mutation is unauthorized")]
1466 Unauthorized,
1467 #[error("checkpoint service is unavailable: {0}")]
1468 Unavailable(String),
1469 #[error("checkpoint version mismatch: expected {expected}, got {got}")]
1470 Version { expected: u32, got: u32 },
1471}
1472
1473#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1474#[non_exhaustive]
1475pub enum CheckpointReply {
1476 Ok(CheckpointMutationResult),
1477 Err(CheckpointError),
1478}
1479
1480#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1481pub struct DestinationGetRequest {
1482 pub v: u32,
1483 pub destination_id: DestinationId,
1484 pub consistency: CheckpointReadConsistency,
1485}
1486
1487impl DestinationGetRequest {
1488 pub const fn new(
1489 destination_id: DestinationId,
1490 consistency: CheckpointReadConsistency,
1491 ) -> Self {
1492 Self {
1493 v: crate::codes::CHECKPOINT_OP_VERSION,
1494 destination_id,
1495 consistency,
1496 }
1497 }
1498}
1499
1500#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1501pub struct DestinationListFilter {
1502 #[serde(default, skip_serializing_if = "Option::is_none")]
1503 pub source_stream: Option<String>,
1504 #[serde(default, skip_serializing_if = "Option::is_none")]
1505 pub source_topic: Option<String>,
1506 #[serde(default, skip_serializing_if = "Option::is_none")]
1507 pub name_contains: Option<String>,
1508}
1509
1510#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1511pub struct DestinationListRequest {
1512 pub v: u32,
1513 #[serde(default)]
1514 pub filter: DestinationListFilter,
1515 #[serde(default, skip_serializing_if = "Option::is_none")]
1516 pub after: Option<DestinationId>,
1517 pub limit: u32,
1518 pub consistency: CheckpointReadConsistency,
1519}
1520
1521#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1522pub struct QueryRouteListRequest {
1523 pub v: u32,
1524 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub name_contains: Option<String>,
1526 #[serde(default, skip_serializing_if = "Option::is_none")]
1527 pub after: Option<QueryRouteId>,
1528 pub limit: u32,
1529 pub consistency: CheckpointReadConsistency,
1530}
1531
1532#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1533pub struct DestinationCheckpointView {
1534 pub destination: MaterializationDestination,
1535 pub status: DestinationCheckpointStatus,
1536}
1537
1538#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1539pub struct DestinationCheckpointPage {
1540 pub destinations: Vec<DestinationCheckpointView>,
1541 #[serde(default, skip_serializing_if = "Option::is_none")]
1542 pub next_after: Option<DestinationId>,
1543 pub global_state_revision: u64,
1544 pub consistency: CheckpointReadConsistency,
1545}
1546
1547#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1548pub struct QueryRoutePage {
1549 pub routes: Vec<QueryRoute>,
1550 #[serde(default, skip_serializing_if = "Option::is_none")]
1551 pub next_after: Option<QueryRouteId>,
1552 pub global_state_revision: u64,
1553 pub consistency: CheckpointReadConsistency,
1554}
1555
1556#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1557#[non_exhaustive]
1558pub enum CheckpointReadReply {
1559 Destination(Option<Box<DestinationCheckpointView>>),
1560 Destinations(DestinationCheckpointPage),
1561 QueryRoutes(QueryRoutePage),
1562 Err(CheckpointError),
1563}
1564
1565impl Validate for DestinationGetRequest {
1566 fn validate(&self) -> Result<(), InvalidError> {
1567 validate_checkpoint_read(self.v)?;
1568 if self.destination_id.as_u128() == 0 {
1569 return Err(InvalidError::new("destination id must be nonzero"));
1570 }
1571 Ok(())
1572 }
1573}
1574
1575impl Validate for DestinationListRequest {
1576 fn validate(&self) -> Result<(), InvalidError> {
1577 validate_checkpoint_read(self.v)?;
1578 validate_read_limit(self.limit)?;
1579 validate_optional_filter("source stream", self.filter.source_stream.as_deref())?;
1580 validate_optional_filter("source topic", self.filter.source_topic.as_deref())?;
1581 validate_optional_filter("destination name", self.filter.name_contains.as_deref())
1582 }
1583}
1584
1585impl Validate for QueryRouteListRequest {
1586 fn validate(&self) -> Result<(), InvalidError> {
1587 validate_checkpoint_read(self.v)?;
1588 validate_read_limit(self.limit)?;
1589 validate_optional_filter("query route name", self.name_contains.as_deref())
1590 }
1591}
1592
1593impl Validate for CheckpointMutationResult {
1594 fn validate(&self) -> Result<(), InvalidError> {
1595 if self.request_id.as_u128() == 0
1596 || self.destination_id.as_u128() == 0
1597 || self.destination_generation == 0
1598 || self.global_state_revision == 0
1599 || self.definition_revision == 0
1600 {
1601 return Err(InvalidError::new(
1602 "checkpoint mutation result identity, generation, global revision, and definition revision must be nonzero",
1603 ));
1604 }
1605 if let Some(lease) = &self.lease {
1606 lease.validate()?;
1607 }
1608 Ok(())
1609 }
1610}
1611
1612impl Validate for DestinationCheckpointView {
1613 fn validate(&self) -> Result<(), InvalidError> {
1614 self.destination.validate()?;
1615 self.status.validate()?;
1616 if self.destination.id != self.status.destination_id
1617 || self.destination.generation != self.status.destination_generation
1618 || self.destination.definition_revision != self.status.definition_revision
1619 || self.destination.backend != self.status.backend
1620 || self.destination.schema != self.status.schema
1621 || self.destination.projection != self.status.projection
1622 {
1623 return Err(InvalidError::new(
1624 "destination declaration and checkpoint status do not describe the same generation",
1625 ));
1626 }
1627 Ok(())
1628 }
1629}
1630
1631impl Validate for DestinationCheckpointPage {
1632 fn validate(&self) -> Result<(), InvalidError> {
1633 if self.global_state_revision == 0 || self.destinations.len() > crate::limits::MAX_PAGE_SIZE
1634 {
1635 return Err(InvalidError::new("destination page metadata is invalid"));
1636 }
1637 let mut ids = BTreeSet::new();
1638 for destination in &self.destinations {
1639 destination.validate()?;
1640 if destination.status.global_state_revision > self.global_state_revision
1641 || !ids.insert(destination.destination.id)
1642 {
1643 return Err(InvalidError::new(
1644 "destination page contains duplicate or future state",
1645 ));
1646 }
1647 }
1648 Ok(())
1649 }
1650}
1651
1652impl Validate for QueryRoutePage {
1653 fn validate(&self) -> Result<(), InvalidError> {
1654 if self.global_state_revision == 0 || self.routes.len() > crate::limits::MAX_PAGE_SIZE {
1655 return Err(InvalidError::new("query route page metadata is invalid"));
1656 }
1657 let mut ids = BTreeSet::new();
1658 for route in &self.routes {
1659 route.validate()?;
1660 if !ids.insert(route.id) {
1661 return Err(InvalidError::new("query route page repeats a route id"));
1662 }
1663 }
1664 Ok(())
1665 }
1666}
1667
1668impl Validate for CheckpointReadReply {
1669 fn validate(&self) -> Result<(), InvalidError> {
1670 match self {
1671 Self::Destination(Some(destination)) => destination.validate(),
1672 Self::Destination(None) | Self::Err(_) => Ok(()),
1673 Self::Destinations(page) => page.validate(),
1674 Self::QueryRoutes(page) => page.validate(),
1675 }
1676 }
1677}
1678
1679fn validate_checkpoint_read(version: u32) -> Result<(), InvalidError> {
1680 if version != crate::codes::CHECKPOINT_OP_VERSION {
1681 return Err(InvalidError::new(format!(
1682 "checkpoint read version must be {}, got {version}",
1683 crate::codes::CHECKPOINT_OP_VERSION
1684 )));
1685 }
1686 Ok(())
1687}
1688
1689fn validate_read_limit(limit: u32) -> Result<(), InvalidError> {
1690 if limit == 0 || limit > crate::limits::MAX_PAGE_SIZE as u32 {
1691 return Err(InvalidError::new(format!(
1692 "checkpoint read limit must be in 1..={}",
1693 crate::limits::MAX_PAGE_SIZE
1694 )));
1695 }
1696 Ok(())
1697}
1698
1699fn validate_optional_filter(label: &str, value: Option<&str>) -> Result<(), InvalidError> {
1700 if let Some(value) = value
1701 && (value.is_empty() || value.len() > 255 || value.chars().any(char::is_control))
1702 {
1703 return Err(InvalidError::new(format!(
1704 "{label} filter must contain 1..=255 bytes without control characters"
1705 )));
1706 }
1707 Ok(())
1708}
1709
1710fn validate_supervisor_assertion(envelope: &CheckpointRequestEnvelope) -> Result<(), InvalidError> {
1711 let required = match &envelope.mutation {
1712 PublicCheckpointMutation::AcceptRetentionGap {
1713 destination_id,
1714 destination_generation,
1715 expected_checkpoint_revision,
1716 ..
1717 } => Some((
1718 SupervisorAssertionAction::AcceptRetentionGap,
1719 *destination_id,
1720 *destination_generation,
1721 *expected_checkpoint_revision,
1722 )),
1723 PublicCheckpointMutation::SupersedeGeneration {
1724 expected_definition_revision,
1725 replacement,
1726 } => Some((
1727 SupervisorAssertionAction::SupersedeGeneration,
1728 replacement.id,
1729 replacement.generation,
1730 *expected_definition_revision,
1731 )),
1732 PublicCheckpointMutation::RecordRepair {
1733 destination_id,
1734 destination_generation,
1735 expected_checkpoint_revision,
1736 ..
1737 } => Some((
1738 SupervisorAssertionAction::RecordRepair,
1739 *destination_id,
1740 *destination_generation,
1741 *expected_checkpoint_revision,
1742 )),
1743 _ => None,
1744 };
1745 match (required, &envelope.supervisor_assertion) {
1746 (None, None) => Ok(()),
1747 (None, Some(_)) => Err(InvalidError::new(
1748 "supervisor assertion is not accepted for this checkpoint mutation",
1749 )),
1750 (Some(_), None) => Err(InvalidError::new(
1751 "high-risk checkpoint mutation requires a supervisor assertion",
1752 )),
1753 (
1754 Some((action, destination_id, destination_generation, expected_revision)),
1755 Some(assertion),
1756 ) => {
1757 assertion.validate()?;
1758 if assertion.claims.request_id != envelope.request_id
1759 || assertion.claims.action != action
1760 || assertion.claims.destination_id != destination_id
1761 || assertion.claims.destination_generation != destination_generation
1762 || assertion.claims.expected_revision != Some(expected_revision)
1763 {
1764 return Err(InvalidError::new(
1765 "supervisor assertion is not bound to this request, action, and destination",
1766 ));
1767 }
1768 Ok(())
1769 }
1770 }
1771}
1772
1773fn validate_destination_revision(
1774 destination_id: DestinationId,
1775 destination_generation: u64,
1776 revision: u64,
1777 revision_kind: &str,
1778) -> Result<(), InvalidError> {
1779 if destination_id.as_u128() == 0 || destination_generation == 0 {
1780 return Err(InvalidError::new(
1781 "checkpoint destination identity and generation must be nonzero",
1782 ));
1783 }
1784 if revision == 0 {
1785 return Err(InvalidError::new(format!(
1786 "expected {revision_kind} revision must be nonzero"
1787 )));
1788 }
1789 Ok(())
1790}
1791
1792fn validate_uuid(value: &UuidValue) -> Result<(), InvalidError> {
1793 if value.as_bytes().len() != UuidValue::BYTES {
1794 return Err(InvalidError::new(format!(
1795 "table UUID must be {} bytes",
1796 UuidValue::BYTES
1797 )));
1798 }
1799 Ok(())
1800}
1801
1802fn validate_lease_request(
1803 destination_id: DestinationId,
1804 destination_generation: u64,
1805 owner: CheckpointOwnerId,
1806 lease_duration_micros: u64,
1807) -> Result<(), InvalidError> {
1808 if destination_id.as_u128() == 0
1809 || destination_generation == 0
1810 || owner.as_u128() == 0
1811 || lease_duration_micros == 0
1812 || lease_duration_micros > MAX_CHECKPOINT_LEASE_DURATION_MICROS
1813 {
1814 return Err(InvalidError::new(
1815 "lease destination, owner, and generation must be nonzero, and duration must be within the configured cap",
1816 ));
1817 }
1818 Ok(())
1819}
1820
1821fn validate_attempt_identity(
1822 attempt_id: PreparedAttemptId,
1823 destination_id: DestinationId,
1824 destination_generation: u64,
1825) -> Result<(), InvalidError> {
1826 if attempt_id.as_u128() == 0 {
1827 return Err(InvalidError::new("prepared attempt id must be nonzero"));
1828 }
1829 if destination_id.as_u128() == 0 || destination_generation == 0 {
1830 return Err(InvalidError::new(
1831 "prepared attempt destination identity must be nonzero",
1832 ));
1833 }
1834 Ok(())
1835}
1836
1837fn validate_ranges(ranges: &[SourceOffsetRange]) -> Result<(), InvalidError> {
1838 if ranges.is_empty() || ranges.len() > MAX_CHECKPOINT_PARTITIONS {
1839 return Err(InvalidError::new(format!(
1840 "source ranges must contain 1..={MAX_CHECKPOINT_PARTITIONS} partitions"
1841 )));
1842 }
1843 let mut partitions = BTreeSet::new();
1844 let mut previous_partition = None;
1845 let mut namespace = None;
1846 for range in ranges {
1847 range.validate()?;
1848 let current = (
1849 range.incarnation.cluster,
1850 range.incarnation.stream_id,
1851 range.incarnation.topic_id,
1852 );
1853 if namespace.is_some_and(|expected| expected != current) {
1854 return Err(InvalidError::new(
1855 "source ranges must share one cluster, stream, and topic",
1856 ));
1857 }
1858 namespace = Some(current);
1859 if !partitions.insert(range.incarnation.partition_id) {
1860 return Err(InvalidError::new(format!(
1861 "source ranges repeat partition {}",
1862 range.incarnation.partition_id
1863 )));
1864 }
1865 if previous_partition.is_some_and(|previous| previous >= range.incarnation.partition_id) {
1866 return Err(InvalidError::new(
1867 "source ranges must be ordered by ascending partition id",
1868 ));
1869 }
1870 previous_partition = Some(range.incarnation.partition_id);
1871 }
1872 Ok(())
1873}
1874
1875fn validate_resulting_boundary(
1876 boundary: &[PartitionCheckpoint],
1877 ranges: &[SourceOffsetRange],
1878) -> Result<(), InvalidError> {
1879 if boundary.len() != ranges.len() {
1880 return Err(InvalidError::new(
1881 "prepared attempt boundary must cover every source range exactly once",
1882 ));
1883 }
1884 for (range, checkpoint) in ranges.iter().zip(boundary) {
1885 checkpoint.validate()?;
1886 if checkpoint.incarnation != range.incarnation
1887 || checkpoint.next_offset != range.end_exclusive
1888 || checkpoint.lifecycle != PartitionLifecycleState::Active
1889 {
1890 return Err(InvalidError::new(
1891 "prepared attempt boundary does not match its source ranges",
1892 ));
1893 }
1894 }
1895 Ok(())
1896}
1897
1898fn validate_credential_generations(
1899 generations: &[CredentialGeneration],
1900) -> Result<(), InvalidError> {
1901 if generations.is_empty() || generations.len() > MAX_CREDENTIAL_GENERATIONS {
1902 return Err(InvalidError::new(format!(
1903 "credential generations must contain 1..={MAX_CREDENTIAL_GENERATIONS} roles"
1904 )));
1905 }
1906 let mut roles = BTreeSet::new();
1907 for generation in generations {
1908 generation.validate()?;
1909 if !roles.insert(&generation.role) {
1910 return Err(InvalidError::new(format!(
1911 "credential role `{}` appears more than once",
1912 generation.role
1913 )));
1914 }
1915 }
1916 Ok(())
1917}
1918
1919fn validate_object_identity(identity: &str) -> Result<(), InvalidError> {
1920 if identity.is_empty() || identity.len() > MAX_MANIFEST_IDENTITY_BYTES {
1921 return Err(InvalidError::new(format!(
1922 "object identity must contain 1..={MAX_MANIFEST_IDENTITY_BYTES} bytes"
1923 )));
1924 }
1925 if identity.contains('?')
1926 || identity.contains('#')
1927 || identity.contains('@')
1928 || identity.contains("//")
1929 {
1930 return Err(InvalidError::new(
1931 "object identity must be canonical, provider-relative, and secret-free",
1932 ));
1933 }
1934 Ok(())
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939 use super::*;
1940 #[cfg(feature = "cbor")]
1941 use crate::destination::{
1942 BackendResourceId, DestinationErrorPolicy, FileFormat, NewPartitionPolicy, PhysicalTable,
1943 RecreatedPartitionPolicy, StartPolicy, TableFormat,
1944 };
1945 #[cfg(feature = "cbor")]
1946 use crate::framing::{decode_named, encode_named};
1947 #[cfg(feature = "cbor")]
1948 use crate::schema::{LogicalSchemaId, SchemaFingerprint};
1949 use crate::source::PhysicalClusterIncarnation;
1950 #[cfg(feature = "cbor")]
1951 use crate::source::SourceScope;
1952
1953 fn incarnation(partition_id: u32) -> SourceIncarnation {
1954 SourceIncarnation {
1955 cluster: PhysicalClusterIncarnation::from_u128(1),
1956 stream_id: 2,
1957 topic_id: 3,
1958 partition_id,
1959 partition_created_revision: 4,
1960 }
1961 }
1962
1963 #[cfg(feature = "cbor")]
1964 fn destination() -> MaterializationDestination {
1965 MaterializationDestination {
1966 id: DestinationId::from_u128(10),
1967 generation: 1,
1968 definition_revision: 1,
1969 name: "orders-lakehouse".to_owned(),
1970 source: SourceScope::new("shop", "orders"),
1971 recreated_partition_policy: RecreatedPartitionPolicy::Reject,
1972 projection: ProjectionRef {
1973 id: crate::control::ProjectionId::new("order.v1"),
1974 version: 1,
1975 },
1976 schema: LogicalSchemaRef {
1977 id: LogicalSchemaId::from_u128(11),
1978 version: 1,
1979 fingerprint: SchemaFingerprint::new([12; 32]),
1980 },
1981 backend: BackendBinding {
1982 resource_id: BackendResourceId::from_u128(13),
1983 generation: 2,
1984 },
1985 table: PhysicalTable {
1986 namespace: vec!["shop".to_owned()],
1987 table: "orders".to_owned(),
1988 expected_table_uuid: None,
1989 },
1990 file_format: FileFormat::Parquet,
1991 table_format: TableFormat::IcebergV2,
1992 start_policy: StartPolicy::CapturedLatest,
1993 new_partition_policy: NewPartitionPolicy::CapturedLatest,
1994 error_policy: DestinationErrorPolicy::Block,
1995 desired_state: DestinationDesiredState::Disabled,
1996 }
1997 }
1998
1999 fn table_requirements() -> PreparedTableRequirements {
2000 let table_uuid = UuidValue::new([14; 16]);
2001 let base_metadata_identity = "warehouse/shop/orders/metadata/00001.json".to_owned();
2002 PreparedTableRequirements {
2003 table_uuid: table_uuid.clone(),
2004 base_metadata_identity: base_metadata_identity.clone(),
2005 base_snapshot_id: Some(15),
2006 schema_id: 1,
2007 partition_spec_id: 0,
2008 commit_requirements: vec![
2009 IcebergCommitRequirement::AssertTableUuid { table_uuid },
2010 IcebergCommitRequirement::AssertMetadataIdentity {
2011 identity: base_metadata_identity,
2012 },
2013 IcebergCommitRequirement::AssertCurrentSnapshot {
2014 snapshot_id: Some(15),
2015 },
2016 IcebergCommitRequirement::AssertCurrentSchema { schema_id: 1 },
2017 IcebergCommitRequirement::AssertDefaultPartitionSpec {
2018 partition_spec_id: 0,
2019 },
2020 ],
2021 }
2022 }
2023
2024 #[cfg(feature = "cbor")]
2025 #[test]
2026 fn given_a_public_request_when_decoded_as_a_replicated_mutation_then_should_fail() {
2027 let request = CheckpointRequestEnvelope::new(
2028 CheckpointRequestId::from_u128(20),
2029 0,
2030 PublicCheckpointMutation::RegisterDestination {
2031 destination: destination(),
2032 },
2033 );
2034 request.validate().expect("public request is valid");
2035 let bytes = encode_named(&request).expect("public request encodes");
2036 assert!(decode_named::<ReplicatedCheckpointMutation>(&bytes).is_err());
2037 }
2038
2039 #[cfg(feature = "cbor")]
2040 #[test]
2041 fn given_a_replicated_mutation_when_decoded_as_a_public_request_then_should_fail() {
2042 let mutation = ReplicatedCheckpointMutation {
2043 request_id: CheckpointRequestId::from_u128(21),
2044 expected_global_state_revision: 0,
2045 stamp: CheckpointMutationStamp {
2046 primary_timestamp_micros: 22,
2047 iggy_actor_id: 23,
2048 supervisor_actor: None,
2049 },
2050 mutation: ReplicatedCheckpointMutationBody::RegisterDestination {
2051 destination: destination(),
2052 },
2053 };
2054 mutation.validate().expect("replicated mutation is valid");
2055 let bytes = encode_named(&mutation).expect("replicated mutation encodes");
2056 assert!(decode_named::<CheckpointRequestEnvelope>(&bytes).is_err());
2057 }
2058
2059 #[test]
2060 fn given_prepared_table_requirements_when_validated_then_should_require_every_frozen_iceberg_precondition_in_order()
2061 {
2062 let valid = table_requirements();
2063 valid.validate().expect("requirements are complete");
2064
2065 let mut missing = valid.clone();
2066 missing.commit_requirements.pop();
2067 assert!(missing.validate().is_err());
2068
2069 let mut reordered = valid;
2070 reordered.commit_requirements.swap(0, 1);
2071 assert!(reordered.validate().is_err());
2072 }
2073
2074 #[test]
2075 fn given_a_lease_duration_when_validated_then_should_be_bounded_before_consensus_submission() {
2076 let mutation = PublicCheckpointMutation::AcquireLease {
2077 destination_id: DestinationId::from_u128(10),
2078 destination_generation: 1,
2079 owner: CheckpointOwnerId::from_u128(30),
2080 expected_lease_sequence: 0,
2081 lease_duration_micros: MAX_CHECKPOINT_LEASE_DURATION_MICROS + 1,
2082 };
2083 assert!(mutation.validate().is_err());
2084 }
2085
2086 #[test]
2087 fn given_source_ranges_when_out_of_canonical_partition_order_then_should_reject() {
2088 let ranges = vec![
2089 SourceOffsetRange {
2090 incarnation: incarnation(2),
2091 start: 0,
2092 end_exclusive: 1,
2093 },
2094 SourceOffsetRange {
2095 incarnation: incarnation(1),
2096 start: 0,
2097 end_exclusive: 1,
2098 },
2099 ];
2100 assert!(validate_ranges(&ranges).is_err());
2101 }
2102}