Skip to main content

laser_wire/
checkpoint.rs

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    /// Stable identity of one Plane process incarnation that may own a destination.
27    CheckpointOwnerId
28);
29
30wire_id!(
31    /// Immutable identity of one prepared materialization attempt.
32    PreparedAttemptId
33);
34
35wire_id!(
36    /// Stable request identity used to deduplicate a checkpoint mutation retry.
37    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}