1use crate::agent::wire_id;
2use crate::control::ProjectionId;
3use crate::error::InvalidError;
4use crate::schema::{LogicalSchemaRef, UuidValue};
5use crate::source::{SourceIncarnation, SourceScope};
6use crate::validate::Validate;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeSet;
9
10pub const MAX_DESTINATION_NAME_BYTES: usize = 255;
11pub const MAX_NAMESPACE_PARTS: usize = 16;
12pub const MAX_TABLE_NAME_BYTES: usize = 255;
13pub const MAX_EXPLICIT_PARTITION_STARTS: usize = 65_536;
14
15wire_id!(
16 DestinationId
18);
19
20wire_id!(
21 BackendResourceId
23);
24
25wire_id!(
26 QueryRouteId
28);
29
30wire_id!(
31 DestinationOperationId
33);
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct ProjectionRef {
37 pub id: ProjectionId,
38 pub version: u32,
39}
40
41impl Validate for ProjectionRef {
42 fn validate(&self) -> Result<(), InvalidError> {
43 if self.id.as_str().is_empty() {
44 return Err(InvalidError::new("projection id must not be empty"));
45 }
46 if self.version == 0 {
47 return Err(InvalidError::new("projection version must be nonzero"));
48 }
49 Ok(())
50 }
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct BackendBinding {
55 pub resource_id: BackendResourceId,
56 pub generation: u64,
57}
58
59impl Validate for BackendBinding {
60 fn validate(&self) -> Result<(), InvalidError> {
61 if self.resource_id.as_u128() == 0 {
62 return Err(InvalidError::new("backend resource id must be nonzero"));
63 }
64 if self.generation == 0 {
65 return Err(InvalidError::new("backend generation must be nonzero"));
66 }
67 Ok(())
68 }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub struct PhysicalTable {
73 pub namespace: Vec<String>,
74 pub table: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub expected_table_uuid: Option<UuidValue>,
77}
78
79impl Validate for PhysicalTable {
80 fn validate(&self) -> Result<(), InvalidError> {
81 if self.namespace.is_empty() || self.namespace.len() > MAX_NAMESPACE_PARTS {
82 return Err(InvalidError::new(format!(
83 "table namespace must contain 1..={MAX_NAMESPACE_PARTS} parts"
84 )));
85 }
86 for part in &self.namespace {
87 validate_table_name("namespace part", part)?;
88 }
89 validate_table_name("table", &self.table)?;
90 if let Some(table_uuid) = &self.expected_table_uuid
91 && table_uuid.as_bytes().len() != UuidValue::BYTES
92 {
93 return Err(InvalidError::new(format!(
94 "table UUID must be {} bytes",
95 UuidValue::BYTES
96 )));
97 }
98 Ok(())
99 }
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104#[non_exhaustive]
105pub enum FileFormat {
106 Parquet,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111#[non_exhaustive]
112pub enum TableFormat {
113 IcebergV2,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "kind", rename_all = "snake_case")]
118#[non_exhaustive]
119pub enum StartPolicy {
120 Beginning,
121 CapturedLatest,
122 Explicit { partitions: Vec<PartitionStart> },
123}
124
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
126pub struct PartitionStart {
127 pub incarnation: SourceIncarnation,
128 pub next_offset: u64,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133#[non_exhaustive]
134pub enum NewPartitionPolicy {
135 Beginning,
136 CapturedLatest,
137 Reject,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142#[non_exhaustive]
143pub enum RecreatedPartitionPolicy {
144 Reject,
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149#[non_exhaustive]
150pub enum DestinationErrorPolicy {
151 Block,
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156#[non_exhaustive]
157pub enum DestinationDesiredState {
158 Disabled,
159 Enabled,
160}
161
162#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub struct MaterializationDestination {
164 pub id: DestinationId,
165 pub generation: u64,
166 pub definition_revision: u64,
167 pub name: String,
168 pub source: SourceScope,
169 pub recreated_partition_policy: RecreatedPartitionPolicy,
170 pub projection: ProjectionRef,
171 pub schema: LogicalSchemaRef,
172 pub backend: BackendBinding,
173 pub table: PhysicalTable,
174 pub file_format: FileFormat,
175 pub table_format: TableFormat,
176 pub start_policy: StartPolicy,
177 pub new_partition_policy: NewPartitionPolicy,
178 pub error_policy: DestinationErrorPolicy,
179 pub desired_state: DestinationDesiredState,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct QueryRoute {
184 pub id: QueryRouteId,
185 pub generation: u64,
186 pub definition_revision: u64,
187 pub name: String,
188 pub target: QueryRouteTarget,
189 pub desired_state: DestinationDesiredState,
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(tag = "kind", rename_all = "snake_case")]
194#[non_exhaustive]
195pub enum QueryRouteTarget {
196 Operational {
197 index: String,
198 },
199 Lakehouse {
200 destination_id: DestinationId,
201 destination_generation: u64,
202 },
203}
204
205impl Validate for QueryRoute {
206 fn validate(&self) -> Result<(), InvalidError> {
207 if self.id.as_u128() == 0 || self.generation == 0 || self.definition_revision == 0 {
208 return Err(InvalidError::new(
209 "query route identity, generation, and definition revision must be nonzero",
210 ));
211 }
212 validate_destination_name(&self.name)?;
213 match &self.target {
214 QueryRouteTarget::Operational { index } => validate_table_name("index", index),
215 QueryRouteTarget::Lakehouse {
216 destination_id,
217 destination_generation,
218 } => {
219 if destination_id.as_u128() == 0 || *destination_generation == 0 {
220 return Err(InvalidError::new(
221 "query route destination identity and generation must be nonzero",
222 ));
223 }
224 Ok(())
225 }
226 }
227 }
228}
229
230impl Validate for MaterializationDestination {
231 fn validate(&self) -> Result<(), InvalidError> {
232 if self.id.as_u128() == 0 {
233 return Err(InvalidError::new("destination id must be nonzero"));
234 }
235 if self.generation == 0 {
236 return Err(InvalidError::new("destination generation must be nonzero"));
237 }
238 if self.definition_revision == 0 {
239 return Err(InvalidError::new(
240 "destination definition revision must be nonzero",
241 ));
242 }
243 validate_destination_name(&self.name)?;
244 self.source.validate()?;
245 self.projection.validate()?;
246 self.schema.validate()?;
247 self.backend.validate()?;
248 self.table.validate()?;
249 validate_start_policy(&self.start_policy)
250 }
251}
252
253fn validate_start_policy(policy: &StartPolicy) -> Result<(), InvalidError> {
254 let StartPolicy::Explicit { partitions } = policy else {
255 return Ok(());
256 };
257 if partitions.is_empty() || partitions.len() > MAX_EXPLICIT_PARTITION_STARTS {
258 return Err(InvalidError::new(format!(
259 "explicit start must contain 1..={MAX_EXPLICIT_PARTITION_STARTS} partitions"
260 )));
261 }
262 let mut partition_ids = BTreeSet::new();
263 let mut previous_partition = None;
264 let mut namespace = None;
265 for partition in partitions {
266 partition.incarnation.validate()?;
267 let current = (
268 partition.incarnation.cluster,
269 partition.incarnation.stream_id,
270 partition.incarnation.topic_id,
271 );
272 if namespace.is_some_and(|expected| expected != current) {
273 return Err(InvalidError::new(
274 "explicit start partitions must share one cluster, stream, and topic",
275 ));
276 }
277 namespace = Some(current);
278 if !partition_ids.insert(partition.incarnation.partition_id) {
279 return Err(InvalidError::new(format!(
280 "explicit start repeats partition {}",
281 partition.incarnation.partition_id
282 )));
283 }
284 if previous_partition.is_some_and(|previous| previous >= partition.incarnation.partition_id)
285 {
286 return Err(InvalidError::new(
287 "explicit start partitions must be ordered by ascending partition id",
288 ));
289 }
290 previous_partition = Some(partition.incarnation.partition_id);
291 }
292 Ok(())
293}
294
295fn validate_destination_name(value: &str) -> Result<(), InvalidError> {
296 if value.is_empty() || value.len() > MAX_DESTINATION_NAME_BYTES {
297 return Err(InvalidError::new(format!(
298 "destination name must contain 1..={MAX_DESTINATION_NAME_BYTES} bytes"
299 )));
300 }
301 if value.chars().any(char::is_control) {
302 return Err(InvalidError::new(
303 "destination name contains a control character",
304 ));
305 }
306 Ok(())
307}
308
309fn validate_table_name(label: &str, value: &str) -> Result<(), InvalidError> {
310 if value.is_empty() || value.len() > MAX_TABLE_NAME_BYTES {
311 return Err(InvalidError::new(format!(
312 "{label} must contain 1..={MAX_TABLE_NAME_BYTES} bytes"
313 )));
314 }
315 if value.chars().any(char::is_control) {
316 return Err(InvalidError::new(format!(
317 "{label} contains a control character"
318 )));
319 }
320 Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::schema::{LogicalSchemaId, SchemaFingerprint};
327 use crate::source::PhysicalClusterIncarnation;
328
329 fn incarnation(partition_id: u32) -> SourceIncarnation {
330 SourceIncarnation {
331 cluster: PhysicalClusterIncarnation::from_u128(1),
332 stream_id: 10,
333 topic_id: 20,
334 partition_id,
335 partition_created_revision: 7,
336 }
337 }
338
339 fn destination() -> MaterializationDestination {
340 MaterializationDestination {
341 id: DestinationId::from_u128(1),
342 generation: 1,
343 definition_revision: 1,
344 name: "readings-lakehouse".to_owned(),
345 source: SourceScope::new("fleet", "readings"),
346 recreated_partition_policy: RecreatedPartitionPolicy::Reject,
347 projection: ProjectionRef {
348 id: ProjectionId::new("reading.v1"),
349 version: 1,
350 },
351 schema: LogicalSchemaRef {
352 id: LogicalSchemaId::from_u128(2),
353 version: 1,
354 fingerprint: SchemaFingerprint::new([3; 32]),
355 },
356 backend: BackendBinding {
357 resource_id: BackendResourceId::from_u128(4),
358 generation: 2,
359 },
360 table: PhysicalTable {
361 namespace: vec!["fleet".to_owned()],
362 table: "readings".to_owned(),
363 expected_table_uuid: None,
364 },
365 file_format: FileFormat::Parquet,
366 table_format: TableFormat::IcebergV2,
367 start_policy: StartPolicy::CapturedLatest,
368 new_partition_policy: NewPartitionPolicy::CapturedLatest,
369 error_policy: DestinationErrorPolicy::Block,
370 desired_state: DestinationDesiredState::Disabled,
371 }
372 }
373
374 #[test]
375 fn given_a_destination_when_validated_then_should_bind_schema_projection_backend_and_table() {
376 destination().validate().expect("destination is valid");
377 let mut invalid = destination();
378 invalid.backend.generation = 0;
379 assert!(invalid.validate().is_err());
380 }
381
382 #[test]
383 fn given_an_explicit_start_when_a_partition_repeats_then_should_reject() {
384 let mut invalid = destination();
385 invalid.start_policy = StartPolicy::Explicit {
386 partitions: vec![
387 PartitionStart {
388 incarnation: incarnation(1),
389 next_offset: 0,
390 },
391 PartitionStart {
392 incarnation: incarnation(1),
393 next_offset: 1,
394 },
395 ],
396 };
397 assert!(invalid.validate().is_err());
398 }
399
400 #[test]
401 fn given_a_lakehouse_route_when_validated_then_should_require_a_specific_destination_generation()
402 {
403 let route = QueryRoute {
404 id: QueryRouteId::from_u128(5),
405 generation: 1,
406 definition_revision: 1,
407 name: "readings".to_owned(),
408 target: QueryRouteTarget::Lakehouse {
409 destination_id: destination().id,
410 destination_generation: 1,
411 },
412 desired_state: DestinationDesiredState::Enabled,
413 };
414 route.validate().expect("route is valid");
415 }
416}