spate_core/source/mod.rs
1//! Source abstraction: a control plane ([`Source`]) and a data plane
2//! ([`SourceLane`]).
3//!
4//! A source is poll-based rather than a `futures::Stream`. The control plane
5//! surfaces lane assignment and revocation as events and owns commits and
6//! pause/resume; each lane is a pollable unit pinned to one pipeline thread
7//! (for Kafka: a partition queue), yielding payloads that **borrow** the
8//! source's buffers for the duration of one `push_batch` call (ADR-0003).
9//! The obligations an implementor takes on are spelled out in
10//! [the connector contracts][contracts].
11//!
12//! [contracts]: https://spate.kainth.dev/docs/user-guide/extending/contracts
13
14mod barrier;
15
16pub use barrier::DrainBarrier;
17
18use crate::checkpoint::AckIssuer;
19use crate::checkpoint::AckRef;
20use crate::config::{ConfigError, FeedSection};
21use crate::error::SourceError;
22use crate::feed::{Feed, FeedOrigin};
23use crate::framing::FramingContract;
24use crate::metrics::{Meter, SourceMetrics};
25use crate::record::{PartitionId, RawPayload};
26use std::num::NonZeroUsize;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::time::Duration;
30
31/// Identifier of one source lane within an assignment (dense,
32/// source-assigned; stable until the lane is revoked).
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct LaneId(pub u32);
35
36/// One poll's worth of borrowed payloads. Payloads are handed out one at a
37/// time, and every payload shares the batch lifetime `'buf`.
38/// The batch carries exactly one [`AckRef`], issued by the lane through its
39/// [`AckIssuer`]; records derived from these payloads clone it.
40pub trait PayloadBatch<'buf> {
41 /// The next payload, or `None` when the batch is exhausted.
42 fn next_payload(&mut self) -> Option<RawPayload<'buf>>;
43
44 /// The acknowledgment handle covering every payload in this batch.
45 fn ack(&self) -> &AckRef;
46}
47
48/// Data-plane pollable unit of a source, owned by one pipeline thread.
49///
50/// Contract: payloads yielded by [`SourceLane::poll`] are valid only until
51/// the returned batch is dropped, which happens before the next `poll` call
52/// on the same lane. Records must be consumed or encoded within that
53/// window (the operator chain guarantees this by construction).
54pub trait SourceLane: Send {
55 /// The borrowed batch type (a GAT so payloads can borrow lane buffers).
56 type Batch<'a>: PayloadBatch<'a>
57 where
58 Self: 'a;
59
60 /// This lane's identity within the current assignment.
61 fn id(&self) -> LaneId;
62
63 /// The source partition this lane reads. Used for checkpoint issuing
64 /// and shard routing fallback.
65 fn partition(&self) -> PartitionId;
66
67 /// Poll up to `max_records` payloads, waiting at most `timeout`.
68 /// `Ok(None)` means nothing arrived; the driver treats it as idle.
69 /// Implementations must not busy-spin when idle; block up to `timeout`.
70 /// A lane that reports [`finished`](Self::finished) may return its
71 /// end-of-input `Ok(None)` without waiting out `timeout`, because the
72 /// pipeline thread stops polling it.
73 fn poll(
74 &mut self,
75 max_records: usize,
76 timeout: Duration,
77 ) -> Result<Option<Self::Batch<'_>>, SourceError>;
78
79 /// Whether this lane has reached the end of its input and will not be
80 /// polled again.
81 ///
82 /// It may turn `true` only after a `poll` on this lane returned the
83 /// `Ok(None)` that decided its end-of-input, with no batch left to emit,
84 /// the rule [`SourceEvent::Drained`] states. A lane that reports `true`
85 /// while it still holds data has that data never read. Once `true`, it
86 /// stays `true` for the life of the lane.
87 ///
88 /// The pipeline thread reads it before every poll and does not poll a
89 /// lane that reports `true`; the lane still receives its stop, drop and
90 /// shutdown. It is read on every turn, so it must be a cheap read that
91 /// does no I/O and never blocks. The default `false` keeps the lane
92 /// polled.
93 fn finished(&self) -> bool {
94 false
95 }
96}
97
98/// Control-plane event returned by [`Source::poll_events`].
99#[derive(Debug)]
100#[non_exhaustive]
101pub enum SourceEvent<L> {
102 /// New lanes were assigned; the runtime distributes them across
103 /// pipeline threads. The source bumps its assignment epoch first.
104 LanesAssigned(Vec<L>),
105 /// Additional lanes join the *current* assignment epoch; existing
106 /// lanes are untouched and their in-flight batches keep resolving.
107 /// A feed reader emits this for each unit part it takes up, so a routine
108 /// gain never drains flowing lanes (contrast
109 /// [`SourceEvent::LanesAssigned`], whose eager-rebalance contract
110 /// replaces the full lane set). Lane ids must be new (never reuse an
111 /// id from this source's lifetime), and each lane's partition must be
112 /// above every partition admitted to the epoch so far, revoked and
113 /// retired ones included, so a revoked or retired partition returns only
114 /// in a new epoch.
115 LanesAdded(Vec<L>),
116 /// Lanes are being revoked. The runtime trips the [`DrainBarrier`] for
117 /// the owning threads, which stop the lanes, flush in-flight records,
118 /// and arrive; the source completes the revocation (final synchronous
119 /// commit included) only after [`DrainBarrier::wait`] returns.
120 LanesRevoked {
121 /// Which lanes to stop.
122 lanes: Vec<LaneId>,
123 /// Barrier the owning pipeline threads arrive at once drained.
124 barrier: DrainBarrier,
125 },
126 /// Lanes whose work is finished are leaving the assignment: their
127 /// input is fully delivered, acknowledged, *and committed* (e.g. a feed
128 /// unit part whose last record was acknowledged and settled), so
129 /// by contract nothing unflushed or uncommitted can exist behind
130 /// them. The runtime removes them without a drain barrier, so there is
131 /// no pipeline stall. Sources must use
132 /// [`SourceEvent::LanesRevoked`] instead whenever any in-flight data
133 /// or uncommitted acknowledgment may remain.
134 LanesRetired {
135 /// The finished lanes.
136 lanes: Vec<LaneId>,
137 },
138 /// Nothing happened within the timeout.
139 Idle,
140 /// A hint that a commit outside the periodic tick is worthwhile *now*
141 /// for the named partitions: their lanes have decided end-of-input,
142 /// and the source cannot finalize their unit of work (e.g. settle a
143 /// feed unit part, freeing its held-unit slot) until the acked
144 /// watermark reaches it through `Source::commit`. The runtime responds
145 /// by committing those partitions *only* (flowing partitions keep the
146 /// periodic tick) at a tightened cadence for one commit interval, then
147 /// on each control-plane poll that finds a partition's acked watermark
148 /// advanced, while any of its lanes remains assigned. The periodic tick
149 /// still commits them and retries a failed commit. A partition leaves
150 /// when its lanes are retired or revoked, or a new assignment begins.
151 /// This is a latency optimization; correctness does not depend on it,
152 /// and sources that never emit it get the periodic cadence.
153 CommitReady {
154 /// Partitions whose final acks are worth chasing.
155 partitions: Vec<PartitionId>,
156 },
157 /// The source has permanently exhausted its input: every lane has
158 /// yielded its final batch and will only ever return `Ok(None)` again.
159 /// Bounded sources (backfills) emit this to request a graceful drain;
160 /// the runtime flushes chains, drains sinks, runs a final synchronous
161 /// commit, and exits with [`ExitState::Completed`](crate::pipeline::ExitState).
162 ///
163 /// Contract: a source must not report `Drained` while any lane still
164 /// holds unemitted data. A lane's exhaustion may only be decided by a
165 /// `poll` that returned `Ok(None)` after its final batch was consumed
166 /// (the poll→push→poll sequencing on the owning thread then guarantees
167 /// the final batch was fully pushed downstream). Emitting `Drained` is
168 /// idempotent: sources should keep returning it once drained.
169 /// Unbounded sources never emit it.
170 Drained,
171}
172
173/// The run's stop as a source's control plane sees it. It is set from the
174/// moment the stop begins until the drain begins, before the final commit.
175///
176/// A clone reads the same state. The default is never set.
177#[derive(Clone, Debug, Default)]
178pub struct StopSignal(Option<Arc<StopState>>);
179
180#[derive(Debug)]
181struct StopState {
182 flag: Arc<AtomicBool>,
183 open: AtomicBool,
184}
185
186impl StopSignal {
187 /// A signal that is set while `flag` is.
188 #[must_use]
189 pub fn new(flag: Arc<AtomicBool>) -> Self {
190 StopSignal(Some(Arc::new(StopState {
191 flag,
192 open: AtomicBool::new(true),
193 })))
194 }
195
196 /// Whether the stop has begun and the drain has not.
197 #[must_use]
198 pub fn is_set(&self) -> bool {
199 self.0
200 .as_ref()
201 .is_some_and(|s| s.open.load(Ordering::Acquire) && s.flag.load(Ordering::Relaxed))
202 }
203
204 /// Clear the signal for good, in every clone.
205 pub(crate) fn close(&self) {
206 if let Some(s) = &self.0 {
207 s.open.store(false, Ordering::Release);
208 }
209 }
210}
211
212/// Everything a source receives at [`Source::open`].
213#[derive(Debug)]
214#[non_exhaustive]
215pub struct SourceCtx {
216 /// Issuer for batch acknowledgment handles. Sources clone it into
217 /// every lane they construct; each lane issues one [`AckRef`] per poll
218 /// batch (`issue(partition, last_offset)`).
219 pub issuer: AckIssuer,
220 /// A [`Meter`] scoped `spate_<component_type>_source_*` for the source's own
221 /// metric families (e.g. consumer lag, broker statistics), pre-labeled
222 /// with the standard `pipeline`/`component`/`component_type`. `None` unless
223 /// the source declared a [`Source::component_type`] that is a usable,
224 /// non-reserved namespace. A reserved default (`"source"`) opts out
225 /// silently, and a malformed value is logged and also yields `None`. Resolve
226 /// handles from it once here in `open`; never on the poll path.
227 pub meter: Option<Meter>,
228 /// The framework's own source-stage handles (`spate_source_*`), shared with
229 /// the controller. A source that can observe its own consumer lag
230 /// publishes it here. [`SourceMetrics::set_partition_lag`] and
231 /// [`SourceMetrics::retain_partitions`] have no other caller, because the
232 /// framework cannot compute lag without the client's view of the log end,
233 /// nor tell which partitions the client still owns. `None` only when the
234 /// source is driven outside a pipeline (tests, or a direct `open` call),
235 /// in which case lag goes unpublished.
236 ///
237 /// Everything else on these handles (records, bytes, poll duration,
238 /// rebalances, active lanes) is recorded by the runtime itself; a source
239 /// must not touch those.
240 pub stage_metrics: Option<Arc<SourceMetrics>>,
241 /// Whether cardinality-sensitive per-partition series are enabled
242 /// (`metrics.per_partition_detail`). Gates a connector's own per-partition
243 /// families: when `false`, register and emit only aggregate
244 /// (per-component or per-broker) series. It does **not** gate
245 /// `spate_source_lag_records`; consumer lag has no aggregate series to fall
246 /// back to, so it always publishes per partition.
247 pub per_partition_detail: bool,
248 /// The pipeline thread count the runtime resolved, the value
249 /// `spate_pipeline_threads` reports, for a source that sizes its read
250 /// parallelism from it. `None` only when the source is driven outside a
251 /// pipeline (tests, or a direct `open` call).
252 pub threads: Option<NonZeroUsize>,
253 /// The run's stop. A feed-reading source reads it to stop receiving and to answer
254 /// [`Source::reading_on_stop`].
255 pub stop: StopSignal,
256}
257
258impl SourceCtx {
259 /// Context wrapping the checkpointer's issuer. The custom-metrics
260 /// [`meter`](Self::meter) is `None`; the runtime attaches one via
261 /// [`with_meter`](Self::with_meter).
262 #[must_use]
263 pub fn new(issuer: AckIssuer) -> Self {
264 SourceCtx {
265 issuer,
266 meter: None,
267 stage_metrics: None,
268 per_partition_detail: false,
269 threads: None,
270 stop: StopSignal::default(),
271 }
272 }
273
274 /// Attach the source's custom-metrics scope. Called by the runtime, which
275 /// builds it from the source's `component_type`.
276 #[must_use]
277 pub fn with_meter(mut self, meter: Option<Meter>) -> Self {
278 self.meter = meter;
279 self
280 }
281
282 /// Share the framework's source-stage handles so the source can publish
283 /// the one series only it can measure, consumer lag. Called by the
284 /// runtime with the same instance the controller records against.
285 #[must_use]
286 pub fn with_stage_metrics(mut self, metrics: Option<Arc<SourceMetrics>>) -> Self {
287 self.stage_metrics = metrics;
288 self
289 }
290
291 /// Attach the run's stop. Called by the runtime.
292 #[must_use]
293 pub fn with_stop(mut self, stop: StopSignal) -> Self {
294 self.stop = stop;
295 self
296 }
297
298 /// Enable cardinality-sensitive per-partition series. Called by the
299 /// runtime from `metrics.per_partition_detail`.
300 #[must_use]
301 pub fn with_partition_detail(mut self, enabled: bool) -> Self {
302 self.per_partition_detail = enabled;
303 self
304 }
305
306 /// Attach the pipeline thread count. Called by the runtime.
307 #[must_use]
308 pub fn with_threads(mut self, threads: Option<NonZeroUsize>) -> Self {
309 self.threads = threads;
310 self
311 }
312}
313
314/// Control plane of a source. Driven by the runtime's controller from a
315/// single thread; lanes run on pipeline threads.
316pub trait Source: Send {
317 /// The lane type this source produces.
318 type Lane: SourceLane;
319
320 /// The `component_type` metric label for this source (e.g. `"kafka"`),
321 /// mirroring [`SinkParts::with_component_type`](crate::sink::SinkParts::with_component_type)
322 /// on the sink side. It is also the namespace of the source's custom-metrics
323 /// [`Meter`](SourceCtx::meter): declaring `"kafka"` scopes the source's own
324 /// families under `spate_kafka_source_*`. The default `"source"` is a reserved
325 /// root, so a source that does not override this gets no custom `Meter`
326 /// (its framework stage metrics are unaffected).
327 fn component_type(&self) -> &str {
328 "source"
329 }
330
331 /// How the payloads this source emits are framed, so the framework can
332 /// pair it with a deserializer without the two being coordinated by hand
333 /// (see [`FramingContract`]). A source that splits its own bytes into one
334 /// record per payload returns [`FramingContract::PerRecord`]; the default
335 /// is [`FramingContract::WholePayload`], where the source emits whole
336 /// payloads and the deserializer owns framing (Kafka, and any source that
337 /// does not frame).
338 fn framing_contract(&self) -> FramingContract {
339 FramingContract::WholePayload
340 }
341
342 /// Accept the pipeline's `feed:` section and the feed it names. The
343 /// runtime calls this once, before [`open`](Self::open) and before the
344 /// pipeline threads start, and only when the section is present.
345 ///
346 /// A feed-reading source keeps `feed` to read its work units from, and
347 /// reads the section's common keys, such as
348 /// [`units_held`](FeedSection::units_held). The backend's own keys were
349 /// already applied when `feed` was built. `origin` says whether code
350 /// passed `feed` in, and so may publish into it outside the pipeline.
351 /// The default rejects the section, so a source that reads no feed fails
352 /// at startup when one is set.
353 fn configure_feed(
354 &mut self,
355 section: &FeedSection,
356 feed: Arc<dyn Feed>,
357 origin: FeedOrigin,
358 ) -> Result<(), ConfigError> {
359 let _ = (section, feed, origin);
360 Err(ConfigError::Component {
361 context: "feed".into(),
362 message: format!(
363 "the source (`{}`) reads no feed; remove the `feed:` section",
364 self.component_type()
365 ),
366 })
367 }
368
369 /// Connect and prepare. Called once before any other method.
370 fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError>;
371
372 /// Service control-plane work (rebalance callbacks, statistics) and
373 /// return the next event, waiting at most `timeout`. Must be called
374 /// regularly regardless of backpressure state.
375 fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<Self::Lane>, SourceError>;
376
377 /// Store per-partition committable positions (each is the offset one
378 /// past the last acknowledged record). Positions are durable per the
379 /// source's own policy (e.g. interval auto-commit of stored offsets).
380 ///
381 /// The last commit of a stop is this call too. It runs on the controller
382 /// thread, so a source that commits to a remote store bounds the call
383 /// itself.
384 fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError>;
385
386 /// Synchronously flush stored positions (shutdown, revocation).
387 fn flush_commits(&mut self) -> Result<(), SourceError> {
388 Ok(())
389 }
390
391 /// Stop fetching for `lanes` (backpressure). Optional capability:
392 /// sources that cannot pause rely on bounded-queue pushback alone.
393 fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
394 let _ = lanes;
395 Ok(())
396 }
397
398 /// Resume fetching for `lanes`.
399 fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
400 let _ = lanes;
401 Ok(())
402 }
403
404 /// Renew the feed units the source holds, and nothing else.
405 ///
406 /// The controller calls this on each pass of a revocation's wait for its
407 /// drain. That wait lasts up to `checkpoint.drain_timeout` and makes no
408 /// [`poll_events`](Self::poll_events) call, so a source that holds leased
409 /// units renews them here. Once a stop is set, the wait also asks
410 /// [`reading_on_stop`](Self::reading_on_stop) on each pass. Never waits. Settles, acknowledges and
411 /// emits nothing: what the renewal finds is reported by the next
412 /// [`poll_events`](Self::poll_events). The default does nothing.
413 fn renew_held(&mut self) {}
414
415 /// Whether the source is still reading units it holds after a graceful
416 /// stop began. The controller asks on each pass between the stop and the
417 /// drain, and begins the drain on the first `false` or at
418 /// [`STOP_RESERVE`](crate::feed::STOP_RESERVE) before the deadline. Never
419 /// waits. The default returns `false`, so the drain begins at once.
420 fn reading_on_stop(&mut self) -> bool {
421 false
422 }
423
424 /// End the stop's reading: hand back every unit not read to its end.
425 /// Called once, after the last [`reading_on_stop`](Self::reading_on_stop)
426 /// on a graceful stop, or at once when the run fails, and before the
427 /// pipeline threads drain. Never waits. The default does nothing.
428 ///
429 /// # Errors
430 ///
431 /// The runtime logs an error and goes on with the drain; units it did not
432 /// hand back return through redelivery.
433 fn stop_reading(&mut self) -> Result<(), SourceError> {
434 Ok(())
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::{SourceCtx, StopSignal};
441 use crate::checkpoint::Checkpointer;
442 use std::num::NonZeroUsize;
443 use std::sync::Arc;
444 use std::sync::atomic::{AtomicBool, Ordering};
445 use std::time::Duration;
446
447 /// A default signal never reads set.
448 #[test]
449 fn a_default_stop_signal_is_never_set() {
450 let stop = StopSignal::default();
451 assert!(!stop.is_set());
452 stop.close();
453 assert!(!stop.is_set());
454 }
455
456 /// A signal reads its flag until it is closed, and every clone agrees.
457 #[test]
458 fn a_stop_signal_follows_its_flag_until_closed() {
459 let flag = Arc::new(AtomicBool::new(false));
460 let stop = StopSignal::new(Arc::clone(&flag));
461 let clone = stop.clone();
462 assert!(!stop.is_set() && !clone.is_set());
463 flag.store(true, Ordering::Relaxed);
464 assert!(stop.is_set() && clone.is_set());
465 stop.close();
466 assert!(!stop.is_set() && !clone.is_set());
467 }
468
469 #[test]
470 fn source_ctx_partition_detail_defaults_off_and_round_trips() {
471 let cp = Checkpointer::new();
472 let ctx = SourceCtx::new(cp.handle());
473 assert!(!ctx.per_partition_detail);
474 assert!(ctx.meter.is_none());
475 let ctx = SourceCtx::new(cp.handle()).with_partition_detail(true);
476 assert!(ctx.per_partition_detail);
477 }
478
479 /// Pins that a context carries no thread count unless one is attached,
480 /// and carries the one attached.
481 #[test]
482 fn source_ctx_threads_defaults_to_none_and_round_trips() {
483 let cp = Checkpointer::new();
484 assert_eq!(SourceCtx::new(cp.handle()).threads, None);
485 let ctx = SourceCtx::new(cp.handle()).with_threads(NonZeroUsize::new(4));
486 assert_eq!(ctx.threads, NonZeroUsize::new(4));
487 }
488
489 /// A lane on every provided default; its batches are never built.
490 struct DefaultLane;
491
492 /// The batch type `DefaultLane` names and never returns.
493 struct NoBatch;
494
495 impl<'a> super::PayloadBatch<'a> for NoBatch {
496 fn next_payload(&mut self) -> Option<crate::record::RawPayload<'a>> {
497 None
498 }
499 fn ack(&self) -> &crate::checkpoint::AckRef {
500 unreachable!("`DefaultLane` never returns a batch")
501 }
502 }
503
504 impl super::SourceLane for DefaultLane {
505 type Batch<'a> = NoBatch;
506 fn id(&self) -> super::LaneId {
507 super::LaneId(0)
508 }
509 fn partition(&self) -> crate::record::PartitionId {
510 crate::record::PartitionId(0)
511 }
512 fn poll(
513 &mut self,
514 _max_records: usize,
515 _timeout: Duration,
516 ) -> Result<Option<NoBatch>, crate::error::SourceError> {
517 Ok(None)
518 }
519 }
520
521 /// Pins that a lane which does not override `finished` reports that it
522 /// has not finished, before and after a poll that returns nothing.
523 #[test]
524 fn a_lane_is_unfinished_unless_it_says_so() {
525 use super::SourceLane;
526 let mut lane = DefaultLane;
527 assert!(!lane.finished());
528 assert!(lane.poll(1, Duration::ZERO).unwrap().is_none());
529 assert!(!lane.finished());
530 }
531}