1use crate::config::DatagenSourceConfig;
10use crate::encode::Encoder;
11use crate::lane::{DatagenLane, LaneParts, Shared, park};
12use crate::metrics::DatagenMetrics;
13use crate::plan::EventPlan;
14use spate_core::checkpoint::AckIssuer;
15use spate_core::config::{ComponentConfig, ConfigError};
16use spate_core::error::{ErrorClass, SourceError};
17use spate_core::framing::FramingContract;
18use spate_core::record::PartitionId;
19use spate_core::source::{LaneId, Source, SourceCtx, SourceEvent};
20use std::collections::BTreeMap;
21use std::sync::Arc;
22use std::sync::atomic::Ordering;
23use std::time::Duration;
24
25#[derive(Debug)]
27struct OpenState {
28 issuer: AckIssuer,
29 shared: Arc<Shared>,
30 encoder: Arc<Encoder>,
31 metrics: Option<DatagenMetrics>,
32 budgets: Option<Vec<u64>>,
34}
35
36#[derive(Debug)]
40pub struct DatagenSource {
41 config: DatagenSourceConfig,
42 state: Option<OpenState>,
43 watermarks: BTreeMap<PartitionId, i64>,
45 handed_out: bool,
46 drained: bool,
47}
48
49impl DatagenSource {
50 #[must_use]
52 pub fn new(config: DatagenSourceConfig) -> DatagenSource {
53 DatagenSource {
54 config,
55 state: None,
56 watermarks: BTreeMap::new(),
57 handed_out: false,
58 drained: false,
59 }
60 }
61
62 pub fn from_component_config(section: &ComponentConfig) -> Result<DatagenSource, ConfigError> {
64 Ok(DatagenSource::new(
65 DatagenSourceConfig::from_component_config(section)?,
66 ))
67 }
68
69 #[must_use]
72 pub fn avro_schema() -> &'static str {
73 crate::EVENT_SCHEMA_JSON
74 }
75
76 #[must_use]
79 pub fn committed(&self) -> &BTreeMap<PartitionId, i64> {
80 &self.watermarks
81 }
82
83 fn open_state(&mut self) -> Result<&mut OpenState, SourceError> {
84 self.state.as_mut().ok_or_else(|| SourceError::Client {
85 class: ErrorClass::Fatal,
86 reason: "DatagenSource used before open()".into(),
87 })
88 }
89}
90
91impl Source for DatagenSource {
92 type Lane = DatagenLane;
93
94 fn component_type(&self) -> &str {
95 "datagen"
96 }
97
98 fn framing_contract(&self) -> FramingContract {
99 FramingContract::PerRecord
102 }
103
104 fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
105 if self.state.is_some() {
106 return Err(SourceError::Client {
107 class: ErrorClass::Fatal,
108 reason: "source opened twice".into(),
109 });
110 }
111 self.config.validate().map_err(|e| SourceError::Client {
114 class: ErrorClass::Fatal,
115 reason: e.to_string(),
116 })?;
117
118 tracing::warn!(
119 "spate-datagen keeps no durable progress; every run regenerates its stream from \
120 the beginning. It is a demo and test source — do not build a production pipeline \
121 on it."
122 );
123
124 let partitions = self.config.partitions as usize;
125 let budgets = self.config.budgets();
126 self.state = Some(OpenState {
127 issuer: ctx.issuer,
128 shared: Arc::new(Shared::new(partitions, budgets.as_deref())),
129 encoder: Arc::new(Encoder::new(self.config.encoding)?),
130 metrics: ctx.meter.as_ref().map(|meter| {
131 DatagenMetrics::new(meter, self.config.partitions, ctx.per_partition_detail)
132 }),
133 budgets,
134 });
135 Ok(())
136 }
137
138 fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<DatagenLane>, SourceError> {
139 if !self.handed_out {
140 let config = self.config.clone();
141 let state = self.open_state()?;
142 let lanes = (0..config.partitions)
143 .map(|index| {
144 DatagenLane::new(LaneParts {
145 id: LaneId(index),
146 index: index as usize,
147 issuer: state.issuer.clone(),
148 plan: EventPlan::new(&config, index),
149 encoder: Arc::clone(&state.encoder),
150 counters: state.metrics.as_ref().map(DatagenMetrics::counters),
151 shared: Arc::clone(&state.shared),
152 budget: state
153 .budgets
154 .as_ref()
155 .map_or(u64::MAX, |b| b[index as usize]),
156 tick_interval: config.tick_interval,
157 events_per_tick: config.events_per_tick as usize,
158 })
159 })
160 .collect();
161 self.handed_out = true;
164 return Ok(SourceEvent::LanesAssigned(lanes));
165 }
166
167 let bounded = self.config.count.is_some();
168 let state = self.open_state()?;
169 if let Some(metrics) = &state.metrics {
173 let remaining = if bounded {
174 state
175 .shared
176 .remaining
177 .iter()
178 .map(|r| r.load(Ordering::Acquire))
179 .sum()
180 } else {
181 0
184 };
185 let open = state
186 .shared
187 .open
188 .iter()
189 .map(|o| o.load(Ordering::Acquire))
190 .sum();
191 metrics.publish(remaining, open);
192 }
193
194 let finished = bounded
195 && state
196 .shared
197 .exhausted
198 .iter()
199 .all(|e| e.load(Ordering::Acquire));
200 if finished {
201 if std::mem::replace(&mut self.drained, true) {
203 park(timeout);
204 }
205 return Ok(SourceEvent::Drained);
206 }
207
208 park(timeout);
211 Ok(SourceEvent::Idle)
212 }
213
214 fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
215 for &(partition, offset) in watermarks {
216 self.watermarks.insert(partition, offset);
217 }
218 if let Some(metrics) = self.state.as_ref().and_then(|s| s.metrics.as_ref()) {
219 for &(partition, offset) in watermarks {
220 metrics.set_committed(partition.0, offset);
221 }
222 }
223 Ok(())
224 }
225
226 fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
230 set_paused(self.state.as_ref(), lanes, true);
231 Ok(())
232 }
233
234 fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
235 set_paused(self.state.as_ref(), lanes, false);
236 Ok(())
237 }
238}
239
240fn set_paused(state: Option<&OpenState>, lanes: &[LaneId], paused: bool) {
241 let Some(state) = state else { return };
242 for lane in lanes {
243 if let Some(flag) = state.shared.paused.get(lane.0 as usize) {
244 flag.store(paused, Ordering::Release);
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::events::StorefrontEvent;
253 use spate_core::checkpoint::Checkpointer;
254 use spate_core::source::{PayloadBatch, SourceLane};
255
256 const POLL: Duration = Duration::from_millis(1);
257
258 fn config(partitions: u32, count: Option<u64>) -> DatagenSourceConfig {
259 DatagenSourceConfig {
260 partitions,
261 count,
262 tick_interval: Duration::ZERO,
264 ..DatagenSourceConfig::default()
265 }
266 }
267
268 fn start(config: DatagenSourceConfig) -> (DatagenSource, Checkpointer, Vec<DatagenLane>) {
270 let cp = Checkpointer::new();
271 let mut source = DatagenSource::new(config);
272 source.open(SourceCtx::new(cp.handle())).unwrap();
273 let SourceEvent::LanesAssigned(lanes) = source.poll_events(POLL).unwrap() else {
274 panic!("the first poll assigns every lane");
275 };
276 (source, cp, lanes)
277 }
278
279 fn drain(lane: &mut DatagenLane, max_records: usize) -> Vec<(i64, String, StorefrontEvent)> {
282 let mut out = Vec::new();
283 for _ in 0..10_000 {
285 let Some(mut batch) = lane.poll(max_records, POLL).unwrap() else {
286 break;
287 };
288 while let Some(payload) = batch.next_payload() {
289 out.push((
290 payload.offset,
291 String::from_utf8(payload.key.expect("keyed").to_vec()).unwrap(),
292 serde_json::from_slice(payload.bytes).unwrap(),
293 ));
294 }
295 }
296 out
297 }
298
299 #[test]
300 fn the_assignment_is_one_lane_per_partition_handed_out_once() {
301 let (mut source, _cp, lanes) = start(config(3, Some(30)));
302 assert_eq!(lanes.len(), 3);
303 for (index, lane) in lanes.iter().enumerate() {
304 assert_eq!(lane.id(), LaneId(index as u32));
305 assert_eq!(lane.partition(), PartitionId(index as u32));
306 }
307 assert!(
308 matches!(source.poll_events(POLL).unwrap(), SourceEvent::Idle),
309 "a second poll must not reassign"
310 );
311 }
312
313 #[test]
316 fn a_bounded_run_releases_exactly_count_events() {
317 for (partitions, count) in [(4u32, 100u64), (4, 101), (3, 10), (1, 9)] {
318 let (_source, _cp, mut lanes) = start(config(partitions, Some(count)));
319 let per_lane: Vec<usize> = lanes.iter_mut().map(|l| drain(l, 8).len()).collect();
320 assert_eq!(
321 per_lane.iter().sum::<usize>(),
322 count as usize,
323 "{partitions} lanes / {count}: {per_lane:?}"
324 );
325 let (lo, hi) = (
326 per_lane.iter().min().unwrap(),
327 per_lane.iter().max().unwrap(),
328 );
329 assert!(hi - lo <= 1, "uneven split {per_lane:?}");
330 }
331 }
332
333 #[test]
337 fn drained_arrives_only_after_every_lane_is_polled_past_its_last_batch() {
338 let (mut source, _cp, mut lanes) = start(config(2, Some(20)));
339
340 let mut released = 0;
342 for lane in &mut lanes {
343 while let Some(mut batch) = lane.poll(4, POLL).unwrap() {
344 while batch.next_payload().is_some() {
345 released += 1;
346 }
347 if released % 10 == 0 {
348 break;
349 }
350 }
351 }
352 assert_eq!(released, 20, "every event was handed over");
353 assert!(
354 matches!(source.poll_events(POLL).unwrap(), SourceEvent::Idle),
355 "no lane has been polled past its last batch yet"
356 );
357
358 for lane in &mut lanes {
360 assert!(lane.poll(4, POLL).unwrap().is_none());
361 }
362 assert!(matches!(
363 source.poll_events(POLL).unwrap(),
364 SourceEvent::Drained
365 ));
366 assert!(
367 matches!(source.poll_events(POLL).unwrap(), SourceEvent::Drained),
368 "Drained is idempotent"
369 );
370 }
371
372 #[test]
373 fn an_unbounded_source_never_drains() {
374 let (mut source, _cp, mut lanes) = start(config(2, None));
375 for lane in &mut lanes {
376 assert!(lane.poll(16, POLL).unwrap().is_some());
377 }
378 for _ in 0..3 {
379 assert!(matches!(
380 source.poll_events(POLL).unwrap(),
381 SourceEvent::Idle
382 ));
383 }
384 }
385
386 #[test]
389 fn payloads_are_keyed_by_order_id_at_monotonic_offsets() {
390 let (_source, _cp, mut lanes) = start(config(2, Some(4_000)));
391 let payloads = drain(&mut lanes[1], 64);
392 assert!(payloads.len() > 100);
393 for (index, (offset, key, event)) in payloads.iter().enumerate() {
394 assert_eq!(*offset, index as i64, "offsets are dense and monotonic");
395 assert_eq!(key, &event.order_id().to_string(), "key is the order id");
396 assert_eq!(event.order_id() % 2, 1, "lane 1 owns the odd id slice");
397 }
398 }
399
400 #[test]
401 fn a_paused_lane_yields_nothing_and_resumes_where_it_left_off() {
402 let (mut source, _cp, mut lanes) = start(config(1, Some(100)));
403 assert!(lanes[0].poll(4, POLL).unwrap().is_some());
404
405 source.pause(&[LaneId(0)]).unwrap();
406 assert!(lanes[0].poll(4, POLL).unwrap().is_none());
407
408 source.resume(&[LaneId(0)]).unwrap();
409 let mut batch = lanes[0].poll(4, POLL).unwrap().expect("resumed");
410 let first = batch.next_payload().expect("a payload").offset;
411 assert_eq!(first, 4, "the lane continued rather than restarting");
412 }
413
414 #[test]
417 fn commits_are_kept_in_memory_only() {
418 let (mut source, _cp, _lanes) = start(config(2, Some(20)));
419 source
420 .commit(&[(PartitionId(0), 5), (PartitionId(1), 7)])
421 .unwrap();
422 source.commit(&[(PartitionId(0), 9)]).unwrap();
423 assert_eq!(source.committed()[&PartitionId(0)], 9);
424 assert_eq!(source.committed()[&PartitionId(1)], 7);
425
426 let (restarted, _cp, _lanes) = start(config(2, Some(20)));
428 assert!(restarted.committed().is_empty());
429 }
430
431 #[test]
432 fn the_declared_contracts_are_the_ones_the_runtime_reads() {
433 let source = DatagenSource::new(config(1, None));
434 assert_eq!(source.component_type(), "datagen");
435 assert_eq!(source.framing_contract(), FramingContract::PerRecord);
436 assert_eq!(DatagenSource::avro_schema(), crate::EVENT_SCHEMA_JSON);
437 }
438
439 #[test]
440 fn opening_twice_is_refused() {
441 let cp = Checkpointer::new();
442 let mut source = DatagenSource::new(config(1, None));
443 source.open(SourceCtx::new(cp.handle())).unwrap();
444 assert!(source.open(SourceCtx::new(cp.handle())).is_err());
445 }
446
447 #[test]
450 fn the_rate_gate_releases_one_quota_per_cadence() {
451 let cfg = DatagenSourceConfig {
452 partitions: 1,
453 count: Some(100),
454 tick_interval: Duration::from_millis(50),
455 events_per_tick: 3,
456 ..DatagenSourceConfig::default()
457 };
458 let (_source, _cp, mut lanes) = start(cfg);
459 let mut batch = lanes[0].poll(64, POLL).unwrap().expect("the first tick");
460 let mut released = 0;
461 while batch.next_payload().is_some() {
462 released += 1;
463 }
464 assert_eq!(
465 released, 3,
466 "a tick releases events_per_tick, not the budget"
467 );
468 drop(batch);
469 assert!(
470 lanes[0].poll(64, POLL).unwrap().is_none(),
471 "the next cadence is not due yet"
472 );
473 }
474
475 #[test]
479 fn a_quota_larger_than_max_records_is_released_across_polls_of_one_tick() {
480 let cfg = DatagenSourceConfig {
481 partitions: 1,
482 count: Some(1_000),
483 tick_interval: Duration::from_secs(60),
484 events_per_tick: 50,
485 ..DatagenSourceConfig::default()
486 };
487 let (_source, _cp, mut lanes) = start(cfg);
488
489 let mut released = 0;
492 while let Some(mut batch) = lanes[0].poll(8, POLL).unwrap() {
493 while batch.next_payload().is_some() {
494 released += 1;
495 }
496 }
497 assert_eq!(released, 50, "the whole quota, not one max_records batch");
498 }
499
500 #[test]
504 fn events_remaining_reads_the_budget_before_the_first_fill() {
505 let (source, _cp, _lanes) = start(config(4, Some(1_000)));
506 let state = source.state.as_ref().expect("opened");
507 let remaining: u64 = state
508 .shared
509 .remaining
510 .iter()
511 .map(|r| r.load(Ordering::Acquire))
512 .sum();
513 assert_eq!(remaining, 1_000, "no lane has filled yet");
514
515 let (unbounded, _cp, _lanes) = start(config(4, None));
517 let state = unbounded.state.as_ref().expect("opened");
518 assert!(
519 state
520 .shared
521 .remaining
522 .iter()
523 .all(|r| r.load(Ordering::Acquire) == 0)
524 );
525 }
526}