Skip to main content

spate_datagen/
source.rs

1//! The control plane: [`DatagenSource`] hands out its lanes once, publishes
2//! the gauges, and decides when a bounded run is finished.
3//!
4//! There is no I/O anywhere in this crate: the constructors take no runtime
5//! handle and nothing here depends on `tokio`. [`DatagenSource::new`] takes a
6//! validated config; [`DatagenSource::from_component_config`] reads the
7//! pipeline's opaque section.
8
9use 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/// What `open` builds and everything afterwards reads.
26#[derive(Debug)]
27struct OpenState {
28    issuer: AckIssuer,
29    shared: Arc<Shared>,
30    encoder: Arc<Encoder>,
31    metrics: Option<DatagenMetrics>,
32    /// Per-lane event budgets, summing to `count`. `None` when unbounded.
33    budgets: Option<Vec<u64>>,
34}
35
36/// Synthetic storefront-event source. See the crate docs for the dataset, the
37/// referential-integrity mechanism, and what this source deliberately does not
38/// promise.
39#[derive(Debug)]
40pub struct DatagenSource {
41    config: DatagenSourceConfig,
42    state: Option<OpenState>,
43    /// The entire progress store: a map, in this process, gone at exit.
44    watermarks: BTreeMap<PartitionId, i64>,
45    handed_out: bool,
46    drained: bool,
47}
48
49impl DatagenSource {
50    /// A source over `config`.
51    #[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    /// Build from the pipeline's opaque `source: { datagen: ... }` section.
63    pub fn from_component_config(section: &ComponentConfig) -> Result<DatagenSource, ConfigError> {
64        Ok(DatagenSource::new(
65            DatagenSourceConfig::from_component_config(section)?,
66        ))
67    }
68
69    /// The Avro schema the `avro` encoding writes against, as JSON. The same
70    /// string as [`EVENT_SCHEMA_JSON`](crate::EVENT_SCHEMA_JSON).
71    #[must_use]
72    pub fn avro_schema() -> &'static str {
73        crate::EVENT_SCHEMA_JSON
74    }
75
76    /// Every watermark this source has been asked to commit. In memory, and
77    /// nowhere else; see the crate docs.
78    #[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        // One event per payload, always. The generator writes the frames, so
100        // there is nothing for a deserializer to split.
101        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        // A hand-constructed config has not been through the deserializer,
112        // which is where a loaded one is validated.
113        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            // Set once the assignment exists, never before the lookup above:
162            // a failure there must leave the hand-out still to be made.
163            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        // The run-progress gauges are written here and nowhere else;
170        // `committed_offset` is written by `commit` below. Both run on the
171        // controller thread, so each series has one live writer (INV-10).
172        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                // An unbounded stream has no remainder. A sentinel here would
182                // read as a real figure.
183                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            // Idempotent by contract, so repeats must not spin the controller.
202            if std::mem::replace(&mut self.drained, true) {
203                park(timeout);
204            }
205            return Ok(SourceEvent::Drained);
206        }
207
208        // This source never emits `CommitReady`: nothing downstream of a
209        // commit here is waiting on it.
210        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    // `flush_commits` keeps the trait's no-op default: there is nothing to
227    // flush a map to.
228
229    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            // Unthrottled: these tests are about the drain, not the cadence.
263            tick_interval: Duration::ZERO,
264            ..DatagenSourceConfig::default()
265        }
266    }
267
268    /// Open a source and take its one assignment.
269    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    /// Drain a lane to exhaustion, returning every payload it produced as
280    /// `(offset, key, decoded event)`.
281    fn drain(lane: &mut DatagenLane, max_records: usize) -> Vec<(i64, String, StorefrontEvent)> {
282        let mut out = Vec::new();
283        // Bounded: a lane that never exhausts would otherwise hang the suite.
284        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    /// The budget split, observed through what the lanes release: exactly
314    /// `count` events, and never more than one lane apart.
315    #[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    /// The drain contract: a lane's exhaustion is only declared by a `poll`
334    /// that returned `Ok(None)` *after* its final batch was consumed, so
335    /// `Drained` cannot be reported while data is still unemitted.
336    #[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        // Release every event, but stop before the poll that finds nothing.
341        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        // The poll that proves the last batch was consumed.
359        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    /// The routing property: an order, its payment and its refund all carry
387    /// the same key, on one partition, at increasing offsets.
388    #[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    /// Watermarks go into a map and nowhere else, and a fresh source over the
415    /// same configuration starts from nothing.
416    #[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        // A fresh source over the same configuration knows nothing.
427        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    /// A throttled lane releases its quota and then parks, rather than
448    /// draining its whole budget on the first poll.
449    #[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    /// A tick's quota survives a `max_records` smaller than itself: it is
476    /// spread across polls of the same cadence rather than truncated, so the
477    /// documented release rate holds at any `events_per_tick`.
478    #[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        // One cadence, polled in batches of 8. The second tick is a minute
490        // out, so everything counted here belongs to the first.
491        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    /// `events_remaining` answers for a bounded run from the moment the lanes
501    /// exist, before any of them has generated anything, when a scrape would
502    /// otherwise read the finished value.
503    #[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        // An unbounded stream has no remainder to seed.
516        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}