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