Skip to main content

spate_test/
source.rs

1//! In-memory source mock with a scripting handle.
2//!
3//! [`MemorySource`] implements [`Source`]; the paired [`SourceHandle`]
4//! scripts the control plane (assignments, revocations, pushed payloads)
5//! and observes everything the source is asked to do (commits, pauses,
6//! flushes). The mock is deliberately strict: misuse that would indicate a
7//! runtime bug — pausing an unassigned lane, revoking an unknown lane,
8//! duplicate lane ids — panics with a clear message.
9
10use spate_core::checkpoint::AckIssuer;
11use spate_core::error::{ErrorClass, SourceError};
12use spate_core::record::{PartitionId, RawPayload};
13use spate_core::source::{
14    DrainBarrier, LaneId, PayloadBatch, Source, SourceCtx, SourceEvent, SourceLane,
15};
16use std::collections::{BTreeSet, HashMap, VecDeque};
17use std::sync::{Arc, Condvar, Mutex, MutexGuard};
18use std::time::{Duration, Instant};
19
20/// One queued payload with its pre-assigned offset.
21#[derive(Debug)]
22struct PayloadData {
23    key: Option<Vec<u8>>,
24    bytes: Vec<u8>,
25    offset: i64,
26    timestamp_ms: i64,
27}
28
29#[derive(Debug, Default)]
30struct PartitionQueue {
31    queue: VecDeque<PayloadData>,
32    next_offset: i64,
33}
34
35#[derive(Debug)]
36enum ScriptEvent {
37    Assign(Vec<(LaneId, PartitionId)>),
38    Revoke {
39        lanes: Vec<LaneId>,
40        barrier: DrainBarrier,
41    },
42}
43
44#[derive(Debug, Default)]
45struct Shared {
46    partitions: HashMap<PartitionId, PartitionQueue>,
47    active: HashMap<LaneId, PartitionId>,
48    paused: BTreeSet<LaneId>,
49    committed: Vec<(PartitionId, i64)>,
50    flush_commits_calls: usize,
51    open: bool,
52    events: VecDeque<ScriptEvent>,
53}
54
55#[derive(Debug, Default)]
56struct SharedSync {
57    state: Mutex<Shared>,
58    /// Notified on push and resume — wakes waiting lane polls.
59    data_cv: Condvar,
60    /// Notified on assign/revoke — wakes a waiting `poll_events`.
61    event_cv: Condvar,
62    /// Notified on commit — wakes a waiting `wait_committed`.
63    commit_cv: Condvar,
64}
65
66impl SharedSync {
67    fn lock(&self) -> MutexGuard<'_, Shared> {
68        self.state.lock().expect("spate-test source state poisoned")
69    }
70}
71
72/// Build a paired in-memory source and its scripting handle.
73#[must_use]
74pub fn memory_source() -> (MemorySource, SourceHandle) {
75    let shared = Arc::new(SharedSync::default());
76    (
77        MemorySource {
78            shared: Arc::clone(&shared),
79            issuer: None,
80        },
81        SourceHandle { shared },
82    )
83}
84
85/// Scripts and observes a [`MemorySource`]. Cloneable; all clones address
86/// the same source.
87#[derive(Clone, Debug)]
88pub struct SourceHandle {
89    shared: Arc<SharedSync>,
90}
91
92impl SourceHandle {
93    /// Queue an assignment event: the source's next
94    /// [`poll_events`](Source::poll_events) returns
95    /// [`SourceEvent::LanesAssigned`] with one [`MemoryLane`] per pair.
96    ///
97    /// # Panics
98    ///
99    /// Panics on duplicate lane ids (within the call or against currently
100    /// active lanes) or on a partition that is already served by an active
101    /// lane — both would be runtime bugs in a real deployment.
102    pub fn assign_lanes(&self, lanes: &[(LaneId, PartitionId)]) {
103        let mut st = self.shared.lock();
104        for &(lane, partition) in lanes {
105            assert!(
106                !st.active.contains_key(&lane),
107                "assign_lanes: lane {lane:?} is already active"
108            );
109            assert!(
110                !st.active.values().any(|&p| p == partition),
111                "assign_lanes: partition {partition:?} is already served by an active lane"
112            );
113            st.active.insert(lane, partition);
114            st.partitions.entry(partition).or_default();
115        }
116        let seen: BTreeSet<_> = lanes.iter().map(|&(l, _)| l).collect();
117        assert_eq!(seen.len(), lanes.len(), "assign_lanes: duplicate lane ids");
118        st.events.push_back(ScriptEvent::Assign(lanes.to_vec()));
119        drop(st);
120        self.shared.event_cv.notify_all();
121    }
122
123    /// Queue a revocation event for `lanes` and return a probe over its
124    /// [`DrainBarrier`]. The barrier expects **one arrival per revoked
125    /// lane** (a pipeline thread owning several of them arrives once per
126    /// lane it stops).
127    ///
128    /// # Panics
129    ///
130    /// Panics if any lane is not currently active.
131    pub fn revoke_lanes(&self, lanes: &[LaneId]) -> DrainBarrierProbe {
132        let barrier = DrainBarrier::new(lanes.len());
133        let mut st = self.shared.lock();
134        for lane in lanes {
135            assert!(
136                st.active.remove(lane).is_some(),
137                "revoke_lanes: lane {lane:?} is not active"
138            );
139            st.paused.remove(lane);
140        }
141        st.events.push_back(ScriptEvent::Revoke {
142            lanes: lanes.to_vec(),
143            barrier: barrier.clone(),
144        });
145        drop(st);
146        self.shared.event_cv.notify_all();
147        DrainBarrierProbe { barrier }
148    }
149
150    /// Queue one payload on `partition` with `timestamp_ms` equal to the
151    /// assigned offset. Returns that offset. Pushing to a partition that
152    /// has no active lane yet is allowed — payloads wait.
153    pub fn push(&self, partition: PartitionId, key: Option<&[u8]>, payload: &[u8]) -> i64 {
154        self.push_at(partition, key, payload, None)
155    }
156
157    /// [`push`](Self::push) with an explicit event timestamp.
158    pub fn push_at(
159        &self,
160        partition: PartitionId,
161        key: Option<&[u8]>,
162        payload: &[u8],
163        timestamp_ms: Option<i64>,
164    ) -> i64 {
165        let mut st = self.shared.lock();
166        let q = st.partitions.entry(partition).or_default();
167        let offset = q.next_offset;
168        q.next_offset += 1;
169        q.queue.push_back(PayloadData {
170            key: key.map(<[u8]>::to_vec),
171            bytes: payload.to_vec(),
172            offset,
173            timestamp_ms: timestamp_ms.unwrap_or(offset),
174        });
175        drop(st);
176        self.shared.data_cv.notify_all();
177        offset
178    }
179
180    /// Queue several keyless payloads; returns their offsets.
181    pub fn push_many<I, P>(&self, partition: PartitionId, payloads: I) -> Vec<i64>
182    where
183        I: IntoIterator<Item = P>,
184        P: AsRef<[u8]>,
185    {
186        payloads
187            .into_iter()
188            .map(|p| self.push(partition, None, p.as_ref()))
189            .collect()
190    }
191
192    /// Every watermark ever passed to [`Source::commit`], in call order
193    /// (flattened).
194    #[must_use]
195    pub fn committed(&self) -> Vec<(PartitionId, i64)> {
196        self.shared.lock().committed.clone()
197    }
198
199    /// The most recent committed position for `partition`, if any.
200    #[must_use]
201    pub fn last_committed(&self, partition: PartitionId) -> Option<i64> {
202        self.shared
203            .lock()
204            .committed
205            .iter()
206            .rev()
207            .find(|&&(p, _)| p == partition)
208            .map(|&(_, o)| o)
209    }
210
211    /// Block until `partition`'s most recent committed offset reaches at least
212    /// `offset`, or `timeout` elapses; returns whether the target was met.
213    ///
214    /// The blocking counterpart to [`last_committed`](Self::last_committed):
215    /// waking on each [`Source::commit`] rather than polling. Pass the
216    /// one-past-last watermark (e.g. `last_offset + 1`) to wait for every
217    /// pushed record to be durably committed.
218    #[must_use]
219    pub fn wait_committed(&self, partition: PartitionId, offset: i64, timeout: Duration) -> bool {
220        let deadline = Instant::now() + timeout;
221        let mut st = self.shared.lock();
222        loop {
223            let reached = st
224                .committed
225                .iter()
226                .rev()
227                .find(|&&(p, _)| p == partition)
228                .is_some_and(|&(_, o)| o >= offset);
229            if reached {
230                return true;
231            }
232            let now = Instant::now();
233            if now >= deadline {
234                return false;
235            }
236            let (guard, _) = self
237                .shared
238                .commit_cv
239                .wait_timeout(st, deadline - now)
240                .expect("spate-test source state poisoned");
241            st = guard;
242        }
243    }
244
245    /// Lanes currently paused via [`Source::pause`], ascending.
246    #[must_use]
247    pub fn paused_lanes(&self) -> Vec<LaneId> {
248        self.shared.lock().paused.iter().copied().collect()
249    }
250
251    /// How many times [`Source::flush_commits`] was called.
252    #[must_use]
253    pub fn flush_commits_calls(&self) -> usize {
254        self.shared.lock().flush_commits_calls
255    }
256
257    /// Whether [`Source::open`] has been called.
258    #[must_use]
259    pub fn is_open(&self) -> bool {
260        self.shared.lock().open
261    }
262}
263
264/// Observation side of a revocation's [`DrainBarrier`].
265#[derive(Clone, Debug)]
266pub struct DrainBarrierProbe {
267    barrier: DrainBarrier,
268}
269
270impl DrainBarrierProbe {
271    /// Whether every party has arrived.
272    #[must_use]
273    pub fn completed(&self) -> bool {
274        self.barrier.remaining() == 0
275    }
276
277    /// Parties still outstanding.
278    #[must_use]
279    pub fn remaining(&self) -> usize {
280        self.barrier.remaining()
281    }
282
283    /// Block until the drain completes or `timeout` elapses; returns
284    /// whether it completed.
285    #[must_use]
286    pub fn wait(&self, timeout: Duration) -> bool {
287        self.barrier.wait(timeout)
288    }
289}
290
291/// In-memory [`Source`] driven entirely by its [`SourceHandle`].
292#[derive(Debug)]
293pub struct MemorySource {
294    shared: Arc<SharedSync>,
295    issuer: Option<AckIssuer>,
296}
297
298impl Source for MemorySource {
299    type Lane = MemoryLane;
300
301    /// Scopes the source's own metric families under `spate_memory_source_*`.
302    fn component_type(&self) -> &str {
303        "memory"
304    }
305
306    fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
307        // Exercise the connector-metrics seam: a family under the source's
308        // `spate_memory_source_*` namespace, resolved once at open.
309        if let Some(meter) = &ctx.meter {
310            meter.counter("opens_total", &[]).increment(1);
311        }
312        self.issuer = Some(ctx.issuer);
313        self.shared.lock().open = true;
314        Ok(())
315    }
316
317    fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<MemoryLane>, SourceError> {
318        let issuer = self.issuer.as_ref().ok_or_else(|| SourceError::Client {
319            class: ErrorClass::Fatal,
320            reason: "poll_events called before open()".to_owned(),
321        })?;
322
323        let deadline = Instant::now() + timeout;
324        let mut st = self.shared.lock();
325        let event = loop {
326            if let Some(event) = st.events.pop_front() {
327                break event;
328            }
329            let now = Instant::now();
330            if now >= deadline {
331                return Ok(SourceEvent::Idle);
332            }
333            let (guard, _) = self
334                .shared
335                .event_cv
336                .wait_timeout(st, deadline - now)
337                .expect("spate-test source state poisoned");
338            st = guard;
339        };
340        drop(st);
341
342        Ok(match event {
343            ScriptEvent::Assign(pairs) => SourceEvent::LanesAssigned(
344                pairs
345                    .into_iter()
346                    .map(|(id, partition)| MemoryLane {
347                        id,
348                        partition,
349                        shared: Arc::clone(&self.shared),
350                        issuer: issuer.clone(),
351                        buf: Vec::new(),
352                    })
353                    .collect(),
354            ),
355            ScriptEvent::Revoke { lanes, barrier } => SourceEvent::LanesRevoked { lanes, barrier },
356        })
357    }
358
359    fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
360        self.shared.lock().committed.extend_from_slice(watermarks);
361        self.shared.commit_cv.notify_all();
362        Ok(())
363    }
364
365    fn flush_commits(&mut self) -> Result<(), SourceError> {
366        self.shared.lock().flush_commits_calls += 1;
367        Ok(())
368    }
369
370    fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
371        let mut st = self.shared.lock();
372        for lane in lanes {
373            assert!(
374                st.active.contains_key(lane),
375                "pause: lane {lane:?} is not active — runtime bug"
376            );
377            st.paused.insert(*lane);
378        }
379        Ok(())
380    }
381
382    fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
383        let mut st = self.shared.lock();
384        for lane in lanes {
385            assert!(
386                st.active.contains_key(lane),
387                "resume: lane {lane:?} is not active — runtime bug"
388            );
389            st.paused.remove(lane);
390        }
391        drop(st);
392        self.shared.data_cv.notify_all();
393        Ok(())
394    }
395}
396
397/// Data-plane lane of a [`MemorySource`]: drains pushed payloads for one
398/// partition. Polls block (up to the timeout) while the lane is paused or
399/// its queue is empty, waking on push and resume.
400#[derive(Debug)]
401pub struct MemoryLane {
402    id: LaneId,
403    partition: PartitionId,
404    shared: Arc<SharedSync>,
405    issuer: AckIssuer,
406    buf: Vec<PayloadData>,
407}
408
409impl SourceLane for MemoryLane {
410    type Batch<'a> = MemoryBatch<'a>;
411
412    fn id(&self) -> LaneId {
413        self.id
414    }
415
416    fn partition(&self) -> PartitionId {
417        self.partition
418    }
419
420    fn poll(
421        &mut self,
422        max_records: usize,
423        timeout: Duration,
424    ) -> Result<Option<MemoryBatch<'_>>, SourceError> {
425        if max_records == 0 {
426            return Ok(None);
427        }
428        let deadline = Instant::now() + timeout;
429        {
430            let mut st = self.shared.lock();
431            self.buf = loop {
432                let available = !st.paused.contains(&self.id)
433                    && st
434                        .partitions
435                        .get(&self.partition)
436                        .is_some_and(|q| !q.queue.is_empty());
437                if available {
438                    let q = st
439                        .partitions
440                        .get_mut(&self.partition)
441                        .expect("partition queue exists");
442                    let n = q.queue.len().min(max_records);
443                    break q.queue.drain(..n).collect();
444                }
445                let now = Instant::now();
446                if now >= deadline {
447                    return Ok(None);
448                }
449                let (guard, _) = self
450                    .shared
451                    .data_cv
452                    .wait_timeout(st, deadline - now)
453                    .expect("spate-test source state poisoned");
454                st = guard;
455            };
456        }
457        let last_offset = self.buf.last().expect("non-empty batch").offset;
458        let ack = self.issuer.issue(self.partition, last_offset);
459        Ok(Some(MemoryBatch {
460            items: &self.buf,
461            next: 0,
462            partition: self.partition,
463            ack,
464        }))
465    }
466}
467
468/// One poll's payloads, borrowing the lane's buffer.
469#[derive(Debug)]
470pub struct MemoryBatch<'a> {
471    items: &'a [PayloadData],
472    next: usize,
473    partition: PartitionId,
474    ack: spate_core::checkpoint::AckRef,
475}
476
477impl<'a> PayloadBatch<'a> for MemoryBatch<'a> {
478    fn next_payload(&mut self) -> Option<RawPayload<'a>> {
479        let item = self.items.get(self.next)?;
480        self.next += 1;
481        Some(RawPayload {
482            bytes: &item.bytes,
483            key: item.key.as_deref(),
484            partition: self.partition,
485            offset: item.offset,
486            timestamp_ms: item.timestamp_ms,
487        })
488    }
489
490    fn ack(&self) -> &spate_core::checkpoint::AckRef {
491        &self.ack
492    }
493}