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 — no `futures::Stream`. The control plane surfaces
5//! 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. See
9//! `docs/DESIGN.md` (§ Source abstraction, § Frozen v1 contracts).
10
11mod barrier;
12
13pub use barrier::DrainBarrier;
14
15use crate::checkpoint::AckIssuer;
16use crate::checkpoint::AckRef;
17use crate::error::SourceError;
18use crate::framing::FramingContract;
19use crate::metrics::{Meter, SourceMetrics};
20use crate::record::{PartitionId, RawPayload};
21use std::sync::Arc;
22use std::time::Duration;
23
24/// Identifier of one source lane within an assignment (dense,
25/// source-assigned; stable until the lane is revoked).
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub struct LaneId(pub u32);
28
29/// One poll's worth of borrowed payloads. Streaming — payloads are handed
30/// out one at a time, and every payload shares the batch lifetime `'buf`.
31/// The batch carries exactly one [`AckRef`], issued by the lane through its
32/// [`AckIssuer`]; records derived from these payloads clone it.
33pub trait PayloadBatch<'buf> {
34    /// The next payload, or `None` when the batch is exhausted.
35    fn next_payload(&mut self) -> Option<RawPayload<'buf>>;
36
37    /// The acknowledgement handle covering every payload in this batch.
38    fn ack(&self) -> &AckRef;
39}
40
41/// Data-plane pollable unit of a source, owned by one pipeline thread.
42///
43/// Contract: payloads yielded by [`SourceLane::poll`] are valid only until
44/// the returned batch is dropped, which happens before the next `poll` call
45/// on the same lane — records must be consumed or encoded within that
46/// window (the operator chain guarantees this by construction).
47pub trait SourceLane: Send {
48    /// The borrowed batch type (a GAT so payloads can borrow lane buffers).
49    type Batch<'a>: PayloadBatch<'a>
50    where
51        Self: 'a;
52
53    /// This lane's identity within the current assignment.
54    fn id(&self) -> LaneId;
55
56    /// The source partition this lane reads. Used for checkpoint issuing
57    /// and shard routing fallback.
58    fn partition(&self) -> PartitionId;
59
60    /// Poll up to `max_records` payloads, waiting at most `timeout`.
61    /// `Ok(None)` means nothing arrived — the driver treats it as idle.
62    /// Implementations must not busy-spin when idle: block up to `timeout`.
63    fn poll(
64        &mut self,
65        max_records: usize,
66        timeout: Duration,
67    ) -> Result<Option<Self::Batch<'_>>, SourceError>;
68}
69
70/// Control-plane event returned by [`Source::poll_events`].
71#[derive(Debug)]
72#[non_exhaustive]
73pub enum SourceEvent<L> {
74    /// New lanes were assigned; the runtime distributes them across
75    /// pipeline threads. The source bumps its assignment epoch first.
76    LanesAssigned(Vec<L>),
77    /// Additional lanes join the *current* assignment epoch; existing
78    /// lanes are untouched and their in-flight batches keep resolving.
79    /// Coordinated sources emit this for incremental split gains so a
80    /// routine gain never drains flowing lanes (contrast
81    /// [`SourceEvent::LanesAssigned`], whose eager-rebalance contract
82    /// replaces the full lane set). Lane ids must be new — never reuse an
83    /// id from this source's lifetime — and each lane's partition must be
84    /// fresh, not one revoked earlier in the epoch.
85    LanesAdded(Vec<L>),
86    /// Lanes are being revoked. The runtime trips the [`DrainBarrier`] for
87    /// the owning threads, which stop the lanes, flush in-flight records,
88    /// and arrive; the source completes the revocation (final synchronous
89    /// commit included) only after [`DrainBarrier::wait`] returns.
90    LanesRevoked {
91        /// Which lanes to stop.
92        lanes: Vec<LaneId>,
93        /// Barrier the owning pipeline threads arrive at once drained.
94        barrier: DrainBarrier,
95    },
96    /// Lanes whose work is finished are leaving the assignment: their
97    /// input is fully delivered, acknowledged, *and committed* (e.g. a
98    /// coordinated split whose terminal progress reached the store), so
99    /// by contract nothing unflushed or uncommitted can exist behind
100    /// them. The runtime removes them without a drain barrier — pure
101    /// bookkeeping, no pipeline stall. Sources must use
102    /// [`SourceEvent::LanesRevoked`] instead whenever any in-flight data
103    /// or uncommitted acknowledgement may remain.
104    LanesRetired {
105        /// The finished lanes.
106        lanes: Vec<LaneId>,
107    },
108    /// Nothing happened within the timeout.
109    Idle,
110    /// A hint that a commit outside the periodic tick is worthwhile *now*
111    /// for the named partitions: their lanes have decided end-of-input,
112    /// and the source cannot finalize their unit of work (e.g. complete a
113    /// coordinated split, freeing its working-set slot) until the acked
114    /// watermark reaches it through `Source::commit`. The runtime responds
115    /// by briefly tightening its commit cadence *for those partitions
116    /// only* — flowing partitions keep the periodic tick — until their
117    /// acks quiesce or one commit interval elapses. Purely a latency
118    /// optimization: correctness never depends on it, and sources that
119    /// never emit it get the periodic cadence.
120    CommitReady {
121        /// Partitions whose final acks are worth chasing.
122        partitions: Vec<PartitionId>,
123    },
124    /// The source has permanently exhausted its input: every lane has
125    /// yielded its final batch and will only ever return `Ok(None)` again.
126    /// Bounded sources (backfills) emit this to request a graceful drain;
127    /// the runtime flushes chains, drains sinks, runs a final synchronous
128    /// commit, and exits with [`ExitState::Completed`](crate::pipeline::ExitState).
129    ///
130    /// Contract: a source must not report `Drained` while any lane still
131    /// holds unemitted data — a lane's exhaustion may only be decided by a
132    /// `poll` that returned `Ok(None)` after its final batch was consumed
133    /// (the poll→push→poll sequencing on the owning thread then guarantees
134    /// the final batch was fully pushed downstream). Emitting `Drained` is
135    /// idempotent: sources should keep returning it once drained.
136    /// Unbounded sources never emit it.
137    Drained,
138}
139
140/// Everything a source receives at [`Source::open`].
141#[derive(Debug)]
142#[non_exhaustive]
143pub struct SourceCtx {
144    /// Issuer for batch acknowledgement handles. Sources clone it into
145    /// every lane they construct; each lane issues one [`AckRef`] per poll
146    /// batch (`issue(partition, last_offset)`).
147    pub issuer: AckIssuer,
148    /// A [`Meter`] scoped `spate_<component_type>_source_*` for the source's own
149    /// metric families (e.g. consumer lag, broker statistics), pre-labelled
150    /// with the standard `pipeline`/`component`/`component_type`. `None` unless
151    /// the source declared a [`Source::component_type`] that is a usable,
152    /// non-reserved namespace — a reserved default (`"source"`) opts out
153    /// silently, a malformed value is logged and also yields `None`. Resolve
154    /// handles from it once here in `open`; never on the poll path.
155    pub meter: Option<Meter>,
156    /// The framework's own source-stage handles (`spate_source_*`), shared with
157    /// the controller. A source that can observe its own consumer lag
158    /// publishes it here — [`SourceMetrics::set_partition_lag`] and
159    /// [`SourceMetrics::retain_partitions`] have no other caller, because the
160    /// framework cannot compute lag without the client's view of the log end,
161    /// nor tell which partitions the client still owns. `None` only when the
162    /// source is driven outside a pipeline (tests, or a direct `open` call),
163    /// in which case lag simply goes unpublished.
164    ///
165    /// Everything else on these handles — records, bytes, poll duration,
166    /// rebalances, active lanes — is recorded by the runtime itself; a source
167    /// must not touch those.
168    pub stage_metrics: Option<Arc<SourceMetrics>>,
169    /// Whether cardinality-sensitive per-partition series are enabled
170    /// (`metrics.per_partition_detail`). Gates a connector's own per-partition
171    /// families: when `false`, register and emit only aggregate
172    /// (per-component or per-broker) series. It does **not** gate
173    /// `spate_source_lag_records` — consumer lag has no aggregate series to fall
174    /// back to, so it always publishes per partition.
175    pub per_partition_detail: bool,
176}
177
178impl SourceCtx {
179    /// Context wrapping the checkpointer's issuer. The custom-metrics
180    /// [`meter`](Self::meter) is `None`; the runtime attaches one via
181    /// [`with_meter`](Self::with_meter).
182    #[must_use]
183    pub fn new(issuer: AckIssuer) -> Self {
184        SourceCtx {
185            issuer,
186            meter: None,
187            stage_metrics: None,
188            per_partition_detail: false,
189        }
190    }
191
192    /// Attach the source's custom-metrics scope. Called by the runtime, which
193    /// builds it from the source's `component_type`.
194    #[must_use]
195    pub fn with_meter(mut self, meter: Option<Meter>) -> Self {
196        self.meter = meter;
197        self
198    }
199
200    /// Share the framework's source-stage handles so the source can publish
201    /// the one series only it can measure: consumer lag. Called by the
202    /// runtime with the same instance the controller records against.
203    #[must_use]
204    pub fn with_stage_metrics(mut self, metrics: Option<Arc<SourceMetrics>>) -> Self {
205        self.stage_metrics = metrics;
206        self
207    }
208
209    /// Enable cardinality-sensitive per-partition series. Called by the
210    /// runtime from `metrics.per_partition_detail`.
211    #[must_use]
212    pub fn with_partition_detail(mut self, enabled: bool) -> Self {
213        self.per_partition_detail = enabled;
214        self
215    }
216}
217
218/// Control plane of a source. Driven by the runtime's controller from a
219/// single thread; lanes run on pipeline threads.
220pub trait Source: Send {
221    /// The lane type this source produces.
222    type Lane: SourceLane;
223
224    /// The `component_type` metric label for this source (e.g. `"kafka"`),
225    /// mirroring [`SinkParts::with_component_type`](crate::sink::SinkParts::with_component_type)
226    /// on the sink side. It is also the namespace of the source's custom-metrics
227    /// [`Meter`](SourceCtx::meter): declaring `"kafka"` scopes the source's own
228    /// families under `spate_kafka_source_*`. The default `"source"` is a reserved
229    /// root, so a source that does not override this gets no custom `Meter`
230    /// (its framework stage metrics are unaffected).
231    fn component_type(&self) -> &str {
232        "source"
233    }
234
235    /// How the payloads this source emits are framed, so the framework can
236    /// pair it with a deserializer without the two being coordinated by hand
237    /// (see [`FramingContract`]). A source that splits its own bytes into one
238    /// record per payload returns [`FramingContract::PerRecord`]; the default
239    /// is [`FramingContract::WholePayload`] — the source emits whole payloads
240    /// and the deserializer owns framing (Kafka, and any source that does not
241    /// frame).
242    fn framing_contract(&self) -> FramingContract {
243        FramingContract::WholePayload
244    }
245
246    /// Connect and prepare. Called once before any other method.
247    fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError>;
248
249    /// Service control-plane work (rebalance callbacks, statistics) and
250    /// return the next event, waiting at most `timeout`. Must be called
251    /// regularly regardless of backpressure state.
252    fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<Self::Lane>, SourceError>;
253
254    /// Store per-partition committable positions (each is the offset one
255    /// past the last acknowledged record). Positions are durable per the
256    /// source's own policy (e.g. interval auto-commit of stored offsets).
257    fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError>;
258
259    /// Synchronously flush stored positions (shutdown, revocation).
260    fn flush_commits(&mut self) -> Result<(), SourceError> {
261        Ok(())
262    }
263
264    /// Stop fetching for `lanes` (backpressure). Optional capability:
265    /// sources that cannot pause rely on bounded-queue pushback alone.
266    fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
267        let _ = lanes;
268        Ok(())
269    }
270
271    /// Resume fetching for `lanes`.
272    fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
273        let _ = lanes;
274        Ok(())
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::SourceCtx;
281    use crate::checkpoint::Checkpointer;
282
283    #[test]
284    fn source_ctx_partition_detail_defaults_off_and_round_trips() {
285        let cp = Checkpointer::new();
286        let ctx = SourceCtx::new(cp.handle());
287        assert!(!ctx.per_partition_detail);
288        assert!(ctx.meter.is_none());
289        let ctx = SourceCtx::new(cp.handle()).with_partition_detail(true);
290        assert!(ctx.per_partition_detail);
291    }
292}