Skip to main content

spate_datagen/
config.rs

1//! Configuration of a `DatagenSource`, deserialized
2//! from the pipeline's opaque `source: { datagen: ... }` section.
3//!
4//! Deliberately absent: a raw option passthrough. Every other connector has
5//! one because it wraps a client with its own configuration surface, and the
6//! deployer needs a way to reach it. This source wraps nothing, so there is
7//! no second surface to pass through to, and a map that accepted keys nothing
8//! reads would be worse than no map at all.
9//!
10//! Also deliberately absent: a `rate:` key. The release rate is
11//! `partitions × events_per_tick ÷ tick_interval`, which is 400 events/s at
12//! the defaults. Expressing it twice is how the two spellings come to
13//! disagree.
14
15use serde::Deserialize;
16use spate_core::config::{ComponentConfig, ConfigError};
17use std::time::Duration;
18
19/// 2026-01-01T00:00:00Z, in milliseconds. The base for the `fixed` clock:
20/// a round instant a reader recognizes as synthetic.
21pub(crate) const DEFAULT_EPOCH_MS: i64 = 1_767_225_600_000;
22
23/// Most lanes a source will build. Every lane commits a generator, its rings
24/// and a batch-sized arena at `open`, so the count is bounded rather than left
25/// to a typo in a `u32` field.
26const MAX_PARTITIONS: u32 = 1_024;
27
28/// What `encoding: avro` reports when the feature that implements it is off.
29/// One string for both the load-time rejection below and the `open`-time one
30/// in [`crate::encode`], which answer for the same condition.
31pub(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/// Which built-in dataset to generate.
51///
52/// A named dataset, not a schema: see the crate docs for why a `fields:` map
53/// is out of scope.
54#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "kebab-case")]
56#[non_exhaustive]
57pub enum Dataset {
58    /// Orders, payments and refunds over a small catalog, the model in
59    /// [`crate::storefront`].
60    #[default]
61    Storefront,
62}
63
64/// Wire format of the generated payloads.
65#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "kebab-case")]
67#[non_exhaustive]
68pub enum Encoding {
69    /// One JSON document per payload, internally tagged by `type`.
70    #[default]
71    Json,
72    /// One bare Avro datum per payload, matching
73    /// [`EVENT_SCHEMA_JSON`](crate::EVENT_SCHEMA_JSON). Needs this crate's
74    /// `avro` feature; without it the value is rejected at load time.
75    Avro,
76}
77
78/// Where event timestamps come from.
79#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "kebab-case")]
81#[non_exhaustive]
82pub enum Clock {
83    /// Deterministic: `epoch_ms` plus one millisecond per event the lane has
84    /// released. Two runs with the same seed produce byte-identical payloads,
85    /// which is what lets a test assert on them.
86    #[default]
87    Fixed,
88    /// The host clock, for a demo whose dashboard has a time axis.
89    Wall,
90}
91
92/// Configuration of a `DatagenSource`.
93///
94/// Every key has a default, so `source: { datagen: {} }` is a complete
95/// section.
96#[derive(Clone, Debug, Deserialize, PartialEq)]
97#[serde(deny_unknown_fields)]
98#[non_exhaustive]
99pub struct DatagenSourceConfig {
100    /// The built-in dataset to generate.
101    #[serde(default)]
102    pub dataset: Dataset,
103    /// Wire format of the payloads.
104    #[serde(default)]
105    pub encoding: Encoding,
106    /// How many lanes to run, and therefore how many framework partitions
107    /// the pipeline sees. Each lane owns a disjoint slice of the order-id
108    /// space and generates independently; no lane ever reads another's
109    /// state. At least 1, and at most 1024.
110    #[serde(default = "default_partitions")]
111    pub partitions: u32,
112    /// Seed of the whole stream. Lane `i` derives its own stream from it, so
113    /// changing `partitions` reshuffles which lane mints which order but
114    /// leaves each lane reproducible.
115    #[serde(default)]
116    pub seed: u64,
117    /// How often a lane releases a batch. **`0s` means unthrottled**: the
118    /// lane generates as fast as the pipeline consumes, which is what a
119    /// throughput measurement wants and what a demo does not.
120    #[serde(default = "default_tick_interval", with = "humantime_serde")]
121    pub tick_interval: Duration,
122    /// Events released per lane per tick. Ignored when unthrottled. At
123    /// least 1.
124    #[serde(default = "default_events_per_tick")]
125    pub events_per_tick: u32,
126    /// Stop after this many events **across all lanes**, then drain the
127    /// pipeline to a clean exit. Absent means the stream never ends.
128    #[serde(default)]
129    pub count: Option<u64>,
130    /// Where event timestamps come from.
131    #[serde(default)]
132    pub clock: Clock,
133    /// Base instant for the `fixed` clock, in milliseconds since the Unix
134    /// epoch. Ignored by the `wall` clock.
135    #[serde(default = "default_epoch_ms")]
136    pub epoch_ms: i64,
137}
138
139// Hand-written rather than derived, so the defaults a hand-built config gets
140// are the same function calls serde reaches for. A test below asserts the two
141// agree; derive would have let them drift.
142impl 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    /// Deserialize and validate from the pipeline's opaque component section.
160    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    /// Cross-field validation.
167    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    /// Per-lane event budgets, summing to exactly `count`. Lane `i` takes the
215    /// integer share plus one more while `i` is below the remainder, so the
216    /// split is deterministic and every event is accounted for exactly once.
217    /// `None` when the stream is unbounded.
218    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    /// Lane `i`'s seed. The multiplier is the same 64-bit Weyl constant the
229    /// generator uses, so adjacent lane indices land far apart in the stream
230    /// rather than one step along it.
231    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    /// The two default paths, serde's and `Default::default()`, must not be
247    /// able to drift. This assertion keeps them one thing.
248    #[test]
249    fn an_empty_section_deserializes_to_the_default_config() {
250        let cfg = DatagenSourceConfig::from_component_config(&section("  {}\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(&section(
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(&section("  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(&section(body))
293                .expect_err("must reject: {body}");
294            assert!(err.to_string().contains(wanted), "{err}");
295        }
296    }
297
298    /// The message has to say what the arithmetic did, or "count 4 with 8
299    /// partitions" reads like an arbitrary refusal.
300    #[test]
301    fn the_short_count_message_spells_out_the_split() {
302        let err =
303            DatagenSourceConfig::from_component_config(&section("  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(&section(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(&section("  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}