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