Skip to main content

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}