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