1use 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#[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 #[cfg(test)]
55 parked_polls: usize,
56}
57
58#[derive(Debug, Default)]
59struct SharedSync {
60 state: Mutex<Shared>,
61 data_cv: Condvar,
63 event_cv: Condvar,
65 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#[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#[derive(Clone, Debug)]
91pub struct SourceHandle {
92 shared: Arc<SharedSync>,
93}
94
95impl SourceHandle {
96 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 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 pub fn push(&self, partition: PartitionId, key: Option<&[u8]>, payload: &[u8]) -> i64 {
157 self.push_at(partition, key, payload, None)
158 }
159
160 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 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 #[must_use]
198 pub fn committed(&self) -> Vec<(PartitionId, i64)> {
199 self.shared.lock().committed.clone()
200 }
201
202 #[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 #[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 #[must_use]
250 pub fn paused_lanes(&self) -> Vec<LaneId> {
251 self.shared.lock().paused.iter().copied().collect()
252 }
253
254 #[must_use]
256 pub fn flush_commits_calls(&self) -> usize {
257 self.shared.lock().flush_commits_calls
258 }
259
260 #[must_use]
262 pub fn is_open(&self) -> bool {
263 self.shared.lock().open
264 }
265}
266
267#[derive(Clone, Debug)]
269pub struct DrainBarrierProbe {
270 barrier: DrainBarrier,
271}
272
273impl DrainBarrierProbe {
274 #[must_use]
276 pub fn completed(&self) -> bool {
277 self.barrier.remaining() == 0
278 }
279
280 #[must_use]
282 pub fn remaining(&self) -> usize {
283 self.barrier.remaining()
284 }
285
286 #[must_use]
289 pub fn wait(&self, timeout: Duration) -> bool {
290 self.barrier.wait(timeout)
291 }
292}
293
294#[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 fn component_type(&self) -> &str {
306 "memory"
307 }
308
309 fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
310 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#[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#[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 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}