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;
20#[expect(deprecated, reason = "v0.4.0 removes this with spate-coordination")]
21use crate::config::{ConfigError, CoordinationSection};
22use crate::error::SourceError;
23use crate::framing::FramingContract;
24use crate::metrics::{Meter, SourceMetrics};
25use crate::record::{PartitionId, RawPayload};
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::time::Duration;
29
30/// Identifier of one source lane within an assignment (dense,
31/// source-assigned; stable until the lane is revoked).
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct LaneId(pub u32);
34
35/// One poll's worth of borrowed payloads. Payloads are handed out one at a
36/// time, and every payload shares the batch lifetime `'buf`.
37/// The batch carries exactly one [`AckRef`], issued by the lane through its
38/// [`AckIssuer`]; records derived from these payloads clone it.
39pub trait PayloadBatch<'buf> {
40    /// The next payload, or `None` when the batch is exhausted.
41    fn next_payload(&mut self) -> Option<RawPayload<'buf>>;
42
43    /// The acknowledgment handle covering every payload in this batch.
44    fn ack(&self) -> &AckRef;
45}
46
47/// Data-plane pollable unit of a source, owned by one pipeline thread.
48///
49/// Contract: payloads yielded by [`SourceLane::poll`] are valid only until
50/// the returned batch is dropped, which happens before the next `poll` call
51/// on the same lane. Records must be consumed or encoded within that
52/// window (the operator chain guarantees this by construction).
53pub trait SourceLane: Send {
54    /// The borrowed batch type (a GAT so payloads can borrow lane buffers).
55    type Batch<'a>: PayloadBatch<'a>
56    where
57        Self: 'a;
58
59    /// This lane's identity within the current assignment.
60    fn id(&self) -> LaneId;
61
62    /// The source partition this lane reads. Used for checkpoint issuing
63    /// and shard routing fallback.
64    fn partition(&self) -> PartitionId;
65
66    /// Poll up to `max_records` payloads, waiting at most `timeout`.
67    /// `Ok(None)` means nothing arrived; the driver treats it as idle.
68    /// Implementations must not busy-spin when idle; block up to `timeout`.
69    fn poll(
70        &mut self,
71        max_records: usize,
72        timeout: Duration,
73    ) -> Result<Option<Self::Batch<'_>>, SourceError>;
74}
75
76/// Control-plane event returned by [`Source::poll_events`].
77#[derive(Debug)]
78#[non_exhaustive]
79pub enum SourceEvent<L> {
80    /// New lanes were assigned; the runtime distributes them across
81    /// pipeline threads. The source bumps its assignment epoch first.
82    LanesAssigned(Vec<L>),
83    /// Additional lanes join the *current* assignment epoch; existing
84    /// lanes are untouched and their in-flight batches keep resolving.
85    /// Coordinated sources emit this for incremental split gains so a
86    /// routine gain never drains flowing lanes (contrast
87    /// [`SourceEvent::LanesAssigned`], whose eager-rebalance contract
88    /// replaces the full lane set). Lane ids must be new (never reuse an
89    /// id from this source's lifetime), and each lane's partition must be
90    /// fresh, not one revoked earlier in the epoch.
91    LanesAdded(Vec<L>),
92    /// Lanes are being revoked. The runtime trips the [`DrainBarrier`] for
93    /// the owning threads, which stop the lanes, flush in-flight records,
94    /// and arrive; the source completes the revocation (final synchronous
95    /// commit included) only after [`DrainBarrier::wait`] returns.
96    LanesRevoked {
97        /// Which lanes to stop.
98        lanes: Vec<LaneId>,
99        /// Barrier the owning pipeline threads arrive at once drained.
100        barrier: DrainBarrier,
101    },
102    /// Lanes whose work is finished are leaving the assignment: their
103    /// input is fully delivered, acknowledged, *and committed* (e.g. a
104    /// coordinated split whose terminal progress reached the store), so
105    /// by contract nothing unflushed or uncommitted can exist behind
106    /// them. The runtime removes them without a drain barrier, so there is
107    /// no pipeline stall. Sources must use
108    /// [`SourceEvent::LanesRevoked`] instead whenever any in-flight data
109    /// or uncommitted acknowledgment may remain.
110    LanesRetired {
111        /// The finished lanes.
112        lanes: Vec<LaneId>,
113    },
114    /// Nothing happened within the timeout.
115    Idle,
116    /// A hint that a commit outside the periodic tick is worthwhile *now*
117    /// for the named partitions: their lanes have decided end-of-input,
118    /// and the source cannot finalize their unit of work (e.g. complete a
119    /// coordinated split, freeing its working-set slot) until the acked
120    /// watermark reaches it through `Source::commit`. The runtime responds
121    /// by briefly tightening its commit cadence *for those partitions
122    /// only* (flowing partitions keep the periodic tick) until their
123    /// acks quiesce or one commit interval elapses. This is a latency
124    /// optimization; correctness does not depend on it, and sources that
125    /// never emit it get the periodic cadence.
126    CommitReady {
127        /// Partitions whose final acks are worth chasing.
128        partitions: Vec<PartitionId>,
129    },
130    /// The source has permanently exhausted its input: every lane has
131    /// yielded its final batch and will only ever return `Ok(None)` again.
132    /// Bounded sources (backfills) emit this to request a graceful drain;
133    /// the runtime flushes chains, drains sinks, runs a final synchronous
134    /// commit, and exits with [`ExitState::Completed`](crate::pipeline::ExitState).
135    ///
136    /// Contract: a source must not report `Drained` while any lane still
137    /// holds unemitted data. A lane's exhaustion may only be decided by a
138    /// `poll` that returned `Ok(None)` after its final batch was consumed
139    /// (the poll→push→poll sequencing on the owning thread then guarantees
140    /// the final batch was fully pushed downstream). Emitting `Drained` is
141    /// idempotent: sources should keep returning it once drained.
142    /// Unbounded sources never emit it.
143    Drained,
144}
145
146/// The run's stop as a source's control plane sees it. It is set from the
147/// moment the stop begins until the drain begins, before the final commit.
148///
149/// A clone reads the same state. The default is never set.
150#[derive(Clone, Debug, Default)]
151pub struct StopSignal(Option<Arc<StopState>>);
152
153#[derive(Debug)]
154struct StopState {
155    flag: Arc<AtomicBool>,
156    open: AtomicBool,
157}
158
159impl StopSignal {
160    /// A signal that is set while `flag` is.
161    #[must_use]
162    pub fn new(flag: Arc<AtomicBool>) -> Self {
163        StopSignal(Some(Arc::new(StopState {
164            flag,
165            open: AtomicBool::new(true),
166        })))
167    }
168
169    /// Whether the stop has begun and the drain has not.
170    #[must_use]
171    pub fn is_set(&self) -> bool {
172        self.0
173            .as_ref()
174            .is_some_and(|s| s.open.load(Ordering::Acquire) && s.flag.load(Ordering::Relaxed))
175    }
176
177    /// Clear the signal for good, in every clone.
178    pub(crate) fn close(&self) {
179        if let Some(s) = &self.0 {
180            s.open.store(false, Ordering::Release);
181        }
182    }
183}
184
185/// Everything a source receives at [`Source::open`].
186#[derive(Debug)]
187#[non_exhaustive]
188pub struct SourceCtx {
189    /// Issuer for batch acknowledgment handles. Sources clone it into
190    /// every lane they construct; each lane issues one [`AckRef`] per poll
191    /// batch (`issue(partition, last_offset)`).
192    pub issuer: AckIssuer,
193    /// A [`Meter`] scoped `spate_<component_type>_source_*` for the source's own
194    /// metric families (e.g. consumer lag, broker statistics), pre-labeled
195    /// with the standard `pipeline`/`component`/`component_type`. `None` unless
196    /// the source declared a [`Source::component_type`] that is a usable,
197    /// non-reserved namespace. A reserved default (`"source"`) opts out
198    /// silently, and a malformed value is logged and also yields `None`. Resolve
199    /// handles from it once here in `open`; never on the poll path.
200    pub meter: Option<Meter>,
201    /// The framework's own source-stage handles (`spate_source_*`), shared with
202    /// the controller. A source that can observe its own consumer lag
203    /// publishes it here. [`SourceMetrics::set_partition_lag`] and
204    /// [`SourceMetrics::retain_partitions`] have no other caller, because the
205    /// framework cannot compute lag without the client's view of the log end,
206    /// nor tell which partitions the client still owns. `None` only when the
207    /// source is driven outside a pipeline (tests, or a direct `open` call),
208    /// in which case lag goes unpublished.
209    ///
210    /// Everything else on these handles (records, bytes, poll duration,
211    /// rebalances, active lanes) is recorded by the runtime itself; a source
212    /// must not touch those.
213    pub stage_metrics: Option<Arc<SourceMetrics>>,
214    /// Whether cardinality-sensitive per-partition series are enabled
215    /// (`metrics.per_partition_detail`). Gates a connector's own per-partition
216    /// families: when `false`, register and emit only aggregate
217    /// (per-component or per-broker) series. It does **not** gate
218    /// `spate_source_lag_records`; consumer lag has no aggregate series to fall
219    /// back to, so it always publishes per partition.
220    pub per_partition_detail: bool,
221    /// The run's stop. A coordinated source passes it to
222    /// [`CoordinationDriver::set_stop`](crate::coordination::driver::CoordinationDriver::set_stop).
223    pub stop: StopSignal,
224}
225
226impl SourceCtx {
227    /// Context wrapping the checkpointer's issuer. The custom-metrics
228    /// [`meter`](Self::meter) is `None`; the runtime attaches one via
229    /// [`with_meter`](Self::with_meter).
230    #[must_use]
231    pub fn new(issuer: AckIssuer) -> Self {
232        SourceCtx {
233            issuer,
234            meter: None,
235            stage_metrics: None,
236            per_partition_detail: false,
237            stop: StopSignal::default(),
238        }
239    }
240
241    /// Attach the source's custom-metrics scope. Called by the runtime, which
242    /// builds it from the source's `component_type`.
243    #[must_use]
244    pub fn with_meter(mut self, meter: Option<Meter>) -> Self {
245        self.meter = meter;
246        self
247    }
248
249    /// Share the framework's source-stage handles so the source can publish
250    /// the one series only it can measure, consumer lag. Called by the
251    /// runtime with the same instance the controller records against.
252    #[must_use]
253    pub fn with_stage_metrics(mut self, metrics: Option<Arc<SourceMetrics>>) -> Self {
254        self.stage_metrics = metrics;
255        self
256    }
257
258    /// Attach the run's stop. Called by the runtime.
259    #[must_use]
260    pub fn with_stop(mut self, stop: StopSignal) -> Self {
261        self.stop = stop;
262        self
263    }
264
265    /// Enable cardinality-sensitive per-partition series. Called by the
266    /// runtime from `metrics.per_partition_detail`.
267    #[must_use]
268    pub fn with_partition_detail(mut self, enabled: bool) -> Self {
269        self.per_partition_detail = enabled;
270        self
271    }
272}
273
274/// Control plane of a source. Driven by the runtime's controller from a
275/// single thread; lanes run on pipeline threads.
276pub trait Source: Send {
277    /// The lane type this source produces.
278    type Lane: SourceLane;
279
280    /// The `component_type` metric label for this source (e.g. `"kafka"`),
281    /// mirroring [`SinkParts::with_component_type`](crate::sink::SinkParts::with_component_type)
282    /// on the sink side. It is also the namespace of the source's custom-metrics
283    /// [`Meter`](SourceCtx::meter): declaring `"kafka"` scopes the source's own
284    /// families under `spate_kafka_source_*`. The default `"source"` is a reserved
285    /// root, so a source that does not override this gets no custom `Meter`
286    /// (its framework stage metrics are unaffected).
287    fn component_type(&self) -> &str {
288        "source"
289    }
290
291    /// How the payloads this source emits are framed, so the framework can
292    /// pair it with a deserializer without the two being coordinated by hand
293    /// (see [`FramingContract`]). A source that splits its own bytes into one
294    /// record per payload returns [`FramingContract::PerRecord`]; the default
295    /// is [`FramingContract::WholePayload`], where the source emits whole
296    /// payloads and the deserializer owns framing (Kafka, and any source that
297    /// does not frame).
298    fn framing_contract(&self) -> FramingContract {
299        FramingContract::WholePayload
300    }
301
302    /// Accept the pipeline's `coordination:` section. The runtime calls this
303    /// once, before [`open`](Self::open) and before the pipeline threads start,
304    /// and only when the section is present.
305    ///
306    /// A coordinated source validates the section here and builds its
307    /// coordinator from it in `open`. The default rejects the section, so a
308    /// source that does not coordinate fails at startup when one is set.
309    #[deprecated(
310        since = "0.3.0",
311        note = "removed in v0.4.0, where a work feed replaces split coordination; see https://github.com/spate-etl/spate/issues/1088"
312    )]
313    #[expect(deprecated, reason = "v0.4.0 removes this with spate-coordination")]
314    fn configure_coordination(&mut self, section: &CoordinationSection) -> Result<(), ConfigError> {
315        let _ = section;
316        Err(ConfigError::Component {
317            context: "coordination".into(),
318            message: format!(
319                "the source (`{}`) does not use coordination; remove the section",
320                self.component_type()
321            ),
322        })
323    }
324
325    /// Connect and prepare. Called once before any other method.
326    fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError>;
327
328    /// Service control-plane work (rebalance callbacks, statistics) and
329    /// return the next event, waiting at most `timeout`. Must be called
330    /// regularly regardless of backpressure state.
331    fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<Self::Lane>, SourceError>;
332
333    /// Store per-partition committable positions (each is the offset one
334    /// past the last acknowledged record). Positions are durable per the
335    /// source's own policy (e.g. interval auto-commit of stored offsets).
336    fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError>;
337
338    /// The last commit of a stop, which a source committing to a remote
339    /// store may bound as a whole.
340    ///
341    /// `Ok` lists the partitions among `watermarks` that the source still
342    /// holds and whose commit it could not confirm; the runtime counts every
343    /// other position as committed, as `commit` does for a partition the
344    /// source no longer holds. `Err` counts none of them as committed, and a
345    /// fatal error fails the run. An unconfirmed commit may or may not have
346    /// been stored, and positions past the stored one may replay.
347    #[deprecated(
348        since = "0.3.0",
349        note = "removed in v0.4.0, where a work feed replaces split coordination; see https://github.com/spate-etl/spate/issues/1088"
350    )]
351    fn commit_final(
352        &mut self,
353        watermarks: &[(PartitionId, i64)],
354    ) -> Result<Vec<PartitionId>, SourceError> {
355        self.commit(watermarks).map(|()| Vec::new())
356    }
357
358    /// Synchronously flush stored positions (shutdown, revocation).
359    fn flush_commits(&mut self) -> Result<(), SourceError> {
360        Ok(())
361    }
362
363    /// Stop fetching for `lanes` (backpressure). Optional capability:
364    /// sources that cannot pause rely on bounded-queue pushback alone.
365    fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
366        let _ = lanes;
367        Ok(())
368    }
369
370    /// Resume fetching for `lanes`.
371    fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
372        let _ = lanes;
373        Ok(())
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::{SourceCtx, StopSignal};
380    use crate::checkpoint::Checkpointer;
381    use std::sync::Arc;
382    use std::sync::atomic::{AtomicBool, Ordering};
383
384    /// A default signal never reads set.
385    #[test]
386    fn a_default_stop_signal_is_never_set() {
387        let stop = StopSignal::default();
388        assert!(!stop.is_set());
389        stop.close();
390        assert!(!stop.is_set());
391    }
392
393    /// A signal reads its flag until it is closed, and every clone agrees.
394    #[test]
395    fn a_stop_signal_follows_its_flag_until_closed() {
396        let flag = Arc::new(AtomicBool::new(false));
397        let stop = StopSignal::new(Arc::clone(&flag));
398        let clone = stop.clone();
399        assert!(!stop.is_set() && !clone.is_set());
400        flag.store(true, Ordering::Relaxed);
401        assert!(stop.is_set() && clone.is_set());
402        stop.close();
403        assert!(!stop.is_set() && !clone.is_set());
404    }
405
406    #[test]
407    fn source_ctx_partition_detail_defaults_off_and_round_trips() {
408        let cp = Checkpointer::new();
409        let ctx = SourceCtx::new(cp.handle());
410        assert!(!ctx.per_partition_detail);
411        assert!(ctx.meter.is_none());
412        let ctx = SourceCtx::new(cp.handle()).with_partition_detail(true);
413        assert!(ctx.per_partition_detail);
414    }
415}