use serde::Deserialize;
use spate_core::config::{ComponentConfig, ConfigError};
use std::time::Duration;
pub(crate) const DEFAULT_EPOCH_MS: i64 = 1_767_225_600_000;
const MAX_PARTITIONS: u32 = 1_024;
pub(crate) const AVRO_FEATURE_OFF: &str = "source.datagen.encoding: avro needs spate-datagen's `avro` feature \
(the `datagen-avro` feature on the spate facade); it is off in this build";
fn default_partitions() -> u32 {
4
}
fn default_tick_interval() -> Duration {
Duration::from_millis(100)
}
fn default_events_per_tick() -> u32 {
10
}
fn default_epoch_ms() -> i64 {
DEFAULT_EPOCH_MS
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Dataset {
#[default]
Storefront,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Encoding {
#[default]
Json,
Avro,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Clock {
#[default]
Fixed,
Wall,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct DatagenSourceConfig {
#[serde(default)]
pub dataset: Dataset,
#[serde(default)]
pub encoding: Encoding,
#[serde(default = "default_partitions")]
pub partitions: u32,
#[serde(default)]
pub seed: u64,
#[serde(default = "default_tick_interval", with = "humantime_serde")]
pub tick_interval: Duration,
#[serde(default = "default_events_per_tick")]
pub events_per_tick: u32,
#[serde(default)]
pub count: Option<u64>,
#[serde(default)]
pub clock: Clock,
#[serde(default = "default_epoch_ms")]
pub epoch_ms: i64,
}
impl Default for DatagenSourceConfig {
fn default() -> DatagenSourceConfig {
DatagenSourceConfig {
dataset: Dataset::default(),
encoding: Encoding::default(),
partitions: default_partitions(),
seed: 0,
tick_interval: default_tick_interval(),
events_per_tick: default_events_per_tick(),
count: None,
clock: Clock::default(),
epoch_ms: default_epoch_ms(),
}
}
}
impl DatagenSourceConfig {
pub fn from_component_config(section: &ComponentConfig) -> Result<Self, ConfigError> {
let cfg: DatagenSourceConfig = section.deserialize_into()?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.partitions == 0 {
return Err(ConfigError::Validation(
"source.datagen.partitions must be at least 1".into(),
));
}
if self.partitions > MAX_PARTITIONS {
return Err(ConfigError::Validation(format!(
"source.datagen.partitions ({}) is above the {MAX_PARTITIONS} this source \
builds: every lane holds its own generator, its rings and an arena sized \
to one batch, and all of it is committed at open",
self.partitions,
)));
}
if self.events_per_tick == 0 {
return Err(ConfigError::Validation(
"source.datagen.events_per_tick must be at least 1 (set tick_interval: 0s \
to run unthrottled instead)"
.into(),
));
}
if let Some(count) = self.count {
if count == 0 {
return Err(ConfigError::Validation(
"source.datagen.count must be at least 1; omit it for an unbounded stream"
.into(),
));
}
if count < u64::from(self.partitions) {
return Err(ConfigError::Validation(format!(
"source.datagen.count ({count}) is below source.datagen.partitions ({}): \
the total splits as count / partitions = {} events per lane with the \
first {} lanes taking one more, so {} lane(s) would be born exhausted \
— lower partitions or raise count",
self.partitions,
count / u64::from(self.partitions),
count % u64::from(self.partitions),
u64::from(self.partitions) - count,
)));
}
}
if self.encoding == Encoding::Avro && !cfg!(feature = "avro") {
return Err(ConfigError::Validation(AVRO_FEATURE_OFF.into()));
}
Ok(())
}
pub(crate) fn budgets(&self) -> Option<Vec<u64>> {
let count = self.count?;
let partitions = u64::from(self.partitions);
Some(
(0..partitions)
.map(|i| count / partitions + u64::from(i < count % partitions))
.collect(),
)
}
pub(crate) fn lane_seed(&self, lane: u32) -> u64 {
self.seed ^ u64::from(lane).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn section(body: &str) -> ComponentConfig {
let yaml = format!("datagen:\n{body}");
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
ComponentConfig::new("datagen", value["datagen"].clone())
}
#[test]
fn an_empty_section_deserializes_to_the_default_config() {
let cfg = DatagenSourceConfig::from_component_config(§ion(" {}\n")).unwrap();
assert_eq!(cfg, DatagenSourceConfig::default());
assert_eq!(cfg.partitions, 4);
assert_eq!(cfg.tick_interval, Duration::from_millis(100));
assert_eq!(cfg.events_per_tick, 10);
assert_eq!(cfg.epoch_ms, 1_767_225_600_000);
assert!(cfg.count.is_none());
}
#[test]
fn every_key_parses() {
let cfg = DatagenSourceConfig::from_component_config(§ion(
" dataset: storefront\n encoding: json\n partitions: 2\n seed: 99\n \
tick_interval: 250ms\n events_per_tick: 32\n count: 1000\n clock: wall\n \
epoch_ms: 42\n",
))
.unwrap();
assert_eq!(cfg.partitions, 2);
assert_eq!(cfg.seed, 99);
assert_eq!(cfg.tick_interval, Duration::from_millis(250));
assert_eq!(cfg.events_per_tick, 32);
assert_eq!(cfg.count, Some(1000));
assert_eq!(cfg.clock, Clock::Wall);
assert_eq!(cfg.epoch_ms, 42);
}
#[test]
fn zero_tick_interval_is_the_unthrottled_spelling_and_is_accepted() {
let cfg =
DatagenSourceConfig::from_component_config(§ion(" tick_interval: 0s\n")).unwrap();
assert!(cfg.tick_interval.is_zero());
}
#[test]
fn degenerate_values_are_rejected() {
for (body, wanted) in [
(" partitions: 0\n", "partitions"),
(" partitions: 1025\n", "partitions"),
(" events_per_tick: 0\n", "events_per_tick"),
(" count: 0\n", "count"),
(" partitions: 8\n count: 4\n", "count"),
] {
let err = DatagenSourceConfig::from_component_config(§ion(body))
.expect_err("must reject: {body}");
assert!(err.to_string().contains(wanted), "{err}");
}
}
#[test]
fn the_short_count_message_spells_out_the_split() {
let err =
DatagenSourceConfig::from_component_config(§ion(" partitions: 8\n count: 4\n"))
.unwrap_err()
.to_string();
assert!(err.contains("count / partitions"), "{err}");
assert!(err.contains("4 lane(s) would be born exhausted"), "{err}");
}
#[test]
fn unknown_keys_and_unknown_variants_are_rejected() {
for body in [
" rate: 1000\n",
" fields:\n id: int\n",
" dataset: auctions\n",
" encoding: protobuf\n",
" clock: monotonic\n",
] {
assert!(
DatagenSourceConfig::from_component_config(§ion(body)).is_err(),
"must reject: {body}"
);
}
}
#[test]
fn avro_is_accepted_only_when_the_feature_is_on() {
let parsed = DatagenSourceConfig::from_component_config(§ion(" encoding: avro\n"));
if cfg!(feature = "avro") {
assert_eq!(parsed.unwrap().encoding, Encoding::Avro);
} else {
let err = parsed.unwrap_err().to_string();
assert!(err.contains("avro"), "{err}");
}
}
#[test]
fn budgets_sum_to_count_and_differ_by_at_most_one() {
assert!(DatagenSourceConfig::default().budgets().is_none());
for (partitions, count) in [(4, 100), (4, 101), (3, 10), (1, 7), (7, 7)] {
let cfg = DatagenSourceConfig {
partitions,
count: Some(count),
..DatagenSourceConfig::default()
};
cfg.validate().unwrap();
let budgets = cfg.budgets().unwrap();
assert_eq!(budgets.len(), partitions as usize);
assert_eq!(budgets.iter().sum::<u64>(), count, "{partitions}/{count}");
let (lo, hi) = (
*budgets.iter().min().unwrap(),
*budgets.iter().max().unwrap(),
);
assert!(hi - lo <= 1, "{budgets:?} is not an even split");
}
}
#[test]
fn lane_seeds_are_distinct_and_follow_the_configured_seed() {
let cfg = DatagenSourceConfig {
seed: 5,
..DatagenSourceConfig::default()
};
let seeds: Vec<_> = (0..8).map(|i| cfg.lane_seed(i)).collect();
assert_eq!(seeds[0], 5, "lane 0 is the configured seed itself");
let unique: std::collections::BTreeSet<_> = seeds.iter().collect();
assert_eq!(unique.len(), seeds.len(), "lane seeds collide: {seeds:?}");
let other = DatagenSourceConfig {
seed: 6,
..DatagenSourceConfig::default()
};
assert_ne!(cfg.lane_seed(3), other.lane_seed(3));
}
}