1use serde::Deserialize;
16use spate_core::config::{ComponentConfig, ConfigError};
17use std::time::Duration;
18
19pub(crate) const DEFAULT_EPOCH_MS: i64 = 1_767_225_600_000;
22
23const MAX_PARTITIONS: u32 = 1_024;
27
28pub(crate) const AVRO_FEATURE_OFF: &str = "source.datagen.encoding: avro needs spate-datagen's `avro` feature \
32 (the `datagen-avro` feature on the spate facade); it is off in this build";
33
34fn default_partitions() -> u32 {
35 4
36}
37
38fn default_tick_interval() -> Duration {
39 Duration::from_millis(100)
40}
41
42fn default_events_per_tick() -> u32 {
43 10
44}
45
46fn default_epoch_ms() -> i64 {
47 DEFAULT_EPOCH_MS
48}
49
50#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "kebab-case")]
56#[non_exhaustive]
57pub enum Dataset {
58 #[default]
61 Storefront,
62}
63
64#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "kebab-case")]
67#[non_exhaustive]
68pub enum Encoding {
69 #[default]
71 Json,
72 Avro,
76}
77
78#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "kebab-case")]
81#[non_exhaustive]
82pub enum Clock {
83 #[default]
87 Fixed,
88 Wall,
90}
91
92#[derive(Clone, Debug, Deserialize, PartialEq)]
97#[serde(deny_unknown_fields)]
98#[non_exhaustive]
99pub struct DatagenSourceConfig {
100 #[serde(default)]
102 pub dataset: Dataset,
103 #[serde(default)]
105 pub encoding: Encoding,
106 #[serde(default = "default_partitions")]
111 pub partitions: u32,
112 #[serde(default)]
116 pub seed: u64,
117 #[serde(default = "default_tick_interval", with = "humantime_serde")]
121 pub tick_interval: Duration,
122 #[serde(default = "default_events_per_tick")]
125 pub events_per_tick: u32,
126 #[serde(default)]
129 pub count: Option<u64>,
130 #[serde(default)]
132 pub clock: Clock,
133 #[serde(default = "default_epoch_ms")]
136 pub epoch_ms: i64,
137}
138
139impl Default for DatagenSourceConfig {
143 fn default() -> DatagenSourceConfig {
144 DatagenSourceConfig {
145 dataset: Dataset::default(),
146 encoding: Encoding::default(),
147 partitions: default_partitions(),
148 seed: 0,
149 tick_interval: default_tick_interval(),
150 events_per_tick: default_events_per_tick(),
151 count: None,
152 clock: Clock::default(),
153 epoch_ms: default_epoch_ms(),
154 }
155 }
156}
157
158impl DatagenSourceConfig {
159 pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
161 let cfg: DatagenSourceConfig = section.deserialize_into()?;
162 cfg.validate()?;
163 Ok(cfg)
164 }
165
166 pub fn validate(&self) -> Result<(), ConfigError> {
168 if self.partitions == 0 {
169 return Err(ConfigError::Validation(
170 "source.datagen.partitions must be at least 1".into(),
171 ));
172 }
173 if self.partitions > MAX_PARTITIONS {
174 return Err(ConfigError::Validation(format!(
175 "source.datagen.partitions ({}) is above the {MAX_PARTITIONS} this source \
176 builds: every lane holds its own generator, its rings and an arena sized \
177 to one batch, and all of it is committed at open",
178 self.partitions,
179 )));
180 }
181 if self.events_per_tick == 0 {
182 return Err(ConfigError::Validation(
183 "source.datagen.events_per_tick must be at least 1 (set tick_interval: 0s \
184 to run unthrottled instead)"
185 .into(),
186 ));
187 }
188 if let Some(count) = self.count {
189 if count == 0 {
190 return Err(ConfigError::Validation(
191 "source.datagen.count must be at least 1; omit it for an unbounded stream"
192 .into(),
193 ));
194 }
195 if count < u64::from(self.partitions) {
196 return Err(ConfigError::Validation(format!(
197 "source.datagen.count ({count}) is below source.datagen.partitions ({}): \
198 the total splits as count / partitions = {} events per lane with the \
199 first {} lanes taking one more, so {} lane(s) would be born exhausted \
200 — lower partitions or raise count",
201 self.partitions,
202 count / u64::from(self.partitions),
203 count % u64::from(self.partitions),
204 u64::from(self.partitions) - count,
205 )));
206 }
207 }
208 if self.encoding == Encoding::Avro && !cfg!(feature = "avro") {
209 return Err(ConfigError::Validation(AVRO_FEATURE_OFF.into()));
210 }
211 Ok(())
212 }
213
214 pub(crate) fn budgets(&self) -> Option<Vec<u64>> {
219 let count = self.count?;
220 let partitions = u64::from(self.partitions);
221 Some(
222 (0..partitions)
223 .map(|i| count / partitions + u64::from(i < count % partitions))
224 .collect(),
225 )
226 }
227
228 pub(crate) fn lane_seed(&self, lane: u32) -> u64 {
232 self.seed ^ u64::from(lane).wrapping_mul(0x9E37_79B9_7F4A_7C15)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn section(body: &str) -> ComponentConfig {
241 let yaml = format!("datagen:\n{body}");
242 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
243 ComponentConfig::new("datagen", value["datagen"].clone())
244 }
245
246 #[test]
249 fn an_empty_section_deserializes_to_the_default_config() {
250 let cfg = DatagenSourceConfig::from_component_config(§ion(" {}\n")).unwrap();
251 assert_eq!(cfg, DatagenSourceConfig::default());
252 assert_eq!(cfg.partitions, 4);
253 assert_eq!(cfg.tick_interval, Duration::from_millis(100));
254 assert_eq!(cfg.events_per_tick, 10);
255 assert_eq!(cfg.epoch_ms, 1_767_225_600_000);
256 assert!(cfg.count.is_none());
257 }
258
259 #[test]
260 fn every_key_parses() {
261 let cfg = DatagenSourceConfig::from_component_config(§ion(
262 " dataset: storefront\n encoding: json\n partitions: 2\n seed: 99\n \
263 tick_interval: 250ms\n events_per_tick: 32\n count: 1000\n clock: wall\n \
264 epoch_ms: 42\n",
265 ))
266 .unwrap();
267 assert_eq!(cfg.partitions, 2);
268 assert_eq!(cfg.seed, 99);
269 assert_eq!(cfg.tick_interval, Duration::from_millis(250));
270 assert_eq!(cfg.events_per_tick, 32);
271 assert_eq!(cfg.count, Some(1000));
272 assert_eq!(cfg.clock, Clock::Wall);
273 assert_eq!(cfg.epoch_ms, 42);
274 }
275
276 #[test]
277 fn zero_tick_interval_is_the_unthrottled_spelling_and_is_accepted() {
278 let cfg =
279 DatagenSourceConfig::from_component_config(§ion(" tick_interval: 0s\n")).unwrap();
280 assert!(cfg.tick_interval.is_zero());
281 }
282
283 #[test]
284 fn degenerate_values_are_rejected() {
285 for (body, wanted) in [
286 (" partitions: 0\n", "partitions"),
287 (" partitions: 1025\n", "partitions"),
288 (" events_per_tick: 0\n", "events_per_tick"),
289 (" count: 0\n", "count"),
290 (" partitions: 8\n count: 4\n", "count"),
291 ] {
292 let err = DatagenSourceConfig::from_component_config(§ion(body))
293 .expect_err("must reject: {body}");
294 assert!(err.to_string().contains(wanted), "{err}");
295 }
296 }
297
298 #[test]
301 fn the_short_count_message_spells_out_the_split() {
302 let err =
303 DatagenSourceConfig::from_component_config(§ion(" partitions: 8\n count: 4\n"))
304 .unwrap_err()
305 .to_string();
306 assert!(err.contains("count / partitions"), "{err}");
307 assert!(err.contains("4 lane(s) would be born exhausted"), "{err}");
308 }
309
310 #[test]
311 fn unknown_keys_and_unknown_variants_are_rejected() {
312 for body in [
313 " rate: 1000\n",
314 " fields:\n id: int\n",
315 " dataset: auctions\n",
316 " encoding: protobuf\n",
317 " clock: monotonic\n",
318 ] {
319 assert!(
320 DatagenSourceConfig::from_component_config(§ion(body)).is_err(),
321 "must reject: {body}"
322 );
323 }
324 }
325
326 #[test]
327 fn avro_is_accepted_only_when_the_feature_is_on() {
328 let parsed = DatagenSourceConfig::from_component_config(§ion(" encoding: avro\n"));
329 if cfg!(feature = "avro") {
330 assert_eq!(parsed.unwrap().encoding, Encoding::Avro);
331 } else {
332 let err = parsed.unwrap_err().to_string();
333 assert!(err.contains("avro"), "{err}");
334 }
335 }
336
337 #[test]
338 fn budgets_sum_to_count_and_differ_by_at_most_one() {
339 assert!(DatagenSourceConfig::default().budgets().is_none());
340 for (partitions, count) in [(4, 100), (4, 101), (3, 10), (1, 7), (7, 7)] {
341 let cfg = DatagenSourceConfig {
342 partitions,
343 count: Some(count),
344 ..DatagenSourceConfig::default()
345 };
346 cfg.validate().unwrap();
347 let budgets = cfg.budgets().unwrap();
348 assert_eq!(budgets.len(), partitions as usize);
349 assert_eq!(budgets.iter().sum::<u64>(), count, "{partitions}/{count}");
350 let (lo, hi) = (
351 *budgets.iter().min().unwrap(),
352 *budgets.iter().max().unwrap(),
353 );
354 assert!(hi - lo <= 1, "{budgets:?} is not an even split");
355 }
356 }
357
358 #[test]
359 fn lane_seeds_are_distinct_and_follow_the_configured_seed() {
360 let cfg = DatagenSourceConfig {
361 seed: 5,
362 ..DatagenSourceConfig::default()
363 };
364 let seeds: Vec<_> = (0..8).map(|i| cfg.lane_seed(i)).collect();
365 assert_eq!(seeds[0], 5, "lane 0 is the configured seed itself");
366 let unique: std::collections::BTreeSet<_> = seeds.iter().collect();
367 assert_eq!(unique.len(), seeds.len(), "lane seeds collide: {seeds:?}");
368
369 let other = DatagenSourceConfig {
370 seed: 6,
371 ..DatagenSourceConfig::default()
372 };
373 assert_ne!(cfg.lane_seed(3), other.lane_seed(3));
374 }
375}