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