1use crate::agent::wire_id;
2use crate::error::InvalidError;
3use crate::validate::Validate;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeSet;
6
7pub const MAX_SOURCE_PARTITIONS: usize = 65_536;
8
9wire_id!(
10 PhysicalClusterIncarnation
12);
13
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15pub struct SourceIncarnation {
16 pub cluster: PhysicalClusterIncarnation,
17 pub stream_id: u32,
18 pub topic_id: u32,
19 pub partition_id: u32,
20 pub partition_incarnation: u64,
21}
22
23impl Validate for SourceIncarnation {
24 fn validate(&self) -> Result<(), InvalidError> {
25 if self.cluster.as_u128() == 0 {
26 return Err(InvalidError::new(
27 "physical cluster incarnation must be nonzero",
28 ));
29 }
30 if self.partition_incarnation == 0 {
31 return Err(InvalidError::new("partition incarnation must be nonzero"));
32 }
33 Ok(())
34 }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SourceScope {
39 pub stream: String,
40 pub topic: String,
41}
42
43impl SourceScope {
44 pub fn new(stream: impl Into<String>, topic: impl Into<String>) -> Self {
45 Self {
46 stream: stream.into(),
47 topic: topic.into(),
48 }
49 }
50}
51
52impl Validate for SourceScope {
53 fn validate(&self) -> Result<(), InvalidError> {
54 validate_source_name("stream", &self.stream)?;
55 validate_source_name("topic", &self.topic)
56 }
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SourcePartitionCut {
61 pub incarnation: SourceIncarnation,
62 pub retained_start: u64,
63 pub end_exclusive: u64,
64}
65
66impl Validate for SourcePartitionCut {
67 fn validate(&self) -> Result<(), InvalidError> {
68 self.incarnation.validate()?;
69 if self.retained_start > self.end_exclusive {
70 return Err(InvalidError::new(format!(
71 "retained start {} is above end-exclusive head {}",
72 self.retained_start, self.end_exclusive
73 )));
74 }
75 Ok(())
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80pub struct SourceCut {
81 pub partitions: Vec<SourcePartitionCut>,
82}
83
84impl Validate for SourceCut {
85 fn validate(&self) -> Result<(), InvalidError> {
86 if self.partitions.is_empty() || self.partitions.len() > MAX_SOURCE_PARTITIONS {
87 return Err(InvalidError::new(format!(
88 "source cut must contain 1..={MAX_SOURCE_PARTITIONS} partitions"
89 )));
90 }
91 let mut partitions = BTreeSet::new();
92 let mut previous_partition = None;
93 let mut namespace = None;
94 for partition in &self.partitions {
95 partition.validate()?;
96 let current = (
97 partition.incarnation.cluster,
98 partition.incarnation.stream_id,
99 partition.incarnation.topic_id,
100 );
101 if namespace.is_some_and(|expected| expected != current) {
102 return Err(InvalidError::new(
103 "source cut partitions must share one cluster, stream, and topic",
104 ));
105 }
106 namespace = Some(current);
107 if !partitions.insert(partition.incarnation.partition_id) {
108 return Err(InvalidError::new(format!(
109 "source cut repeats partition {}",
110 partition.incarnation.partition_id
111 )));
112 }
113 if previous_partition
114 .is_some_and(|previous| previous >= partition.incarnation.partition_id)
115 {
116 return Err(InvalidError::new(
117 "source cut partitions must be ordered by ascending partition id",
118 ));
119 }
120 previous_partition = Some(partition.incarnation.partition_id);
121 }
122 Ok(())
123 }
124}
125
126fn validate_source_name(label: &str, value: &str) -> Result<(), InvalidError> {
127 if value.is_empty() {
128 return Err(InvalidError::new(format!("{label} must not be empty")));
129 }
130 if value.len() > 255 {
131 return Err(InvalidError::new(format!(
132 "{label} is {} bytes, exceeds cap 255",
133 value.len()
134 )));
135 }
136 if value.chars().any(char::is_control) {
137 return Err(InvalidError::new(format!(
138 "{label} contains a control character"
139 )));
140 }
141 Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn incarnation(partition_id: u32, partition_incarnation: u64) -> SourceIncarnation {
149 SourceIncarnation {
150 cluster: PhysicalClusterIncarnation::from_u128(1),
151 stream_id: 10,
152 topic_id: 20,
153 partition_id,
154 partition_incarnation,
155 }
156 }
157
158 #[test]
159 fn given_a_recreated_partition_when_incarnations_are_compared_then_should_differ() {
160 assert_ne!(incarnation(1, 7), incarnation(1, 8));
161 incarnation(1, 7).validate().expect("incarnation is valid");
162 }
163
164 #[test]
165 fn given_duplicate_partitions_or_inverted_heads_when_a_source_cut_is_validated_then_should_reject()
166 {
167 let duplicate = SourceCut {
168 partitions: vec![
169 SourcePartitionCut {
170 incarnation: incarnation(1, 7),
171 retained_start: 0,
172 end_exclusive: 10,
173 },
174 SourcePartitionCut {
175 incarnation: incarnation(1, 7),
176 retained_start: 2,
177 end_exclusive: 10,
178 },
179 ],
180 };
181 assert!(duplicate.validate().is_err());
182
183 let inverted = SourcePartitionCut {
184 incarnation: incarnation(1, 7),
185 retained_start: 11,
186 end_exclusive: 10,
187 };
188 assert!(inverted.validate().is_err());
189 }
190}