Skip to main content

etl_kafka/
source.rs

1//! The control plane: a single consumer whose partitions fan out to lanes.
2//!
3//! # Rebalance choreography (spike-verified, deferred completion)
4//!
5//! librdkafka runs rebalance callbacks inside `poll()` on the thread that
6//! calls it — here, the runtime controller calling
7//! [`Source::poll_events`]. The callback ([`SourceContext::rebalance`])
8//! only records an intent and returns without acknowledging, which leaves
9//! the rebalance legally in progress until we call `assign`/`unassign`.
10//! Completion then happens on the controller thread, interleaved with the
11//! runtime's own drain choreography:
12//!
13//! **Assignment** (all inside one `poll_events` call):
14//! 1. `assign(tpl)` — accept the partitions;
15//! 2. `pause(tpl)` immediately — no fetch may complete before the split,
16//!    so no message can leak onto the main queue;
17//! 3. `split_partition_queue` per partition (must be redone after *every*
18//!    assign — assign deactivates existing queues) and build lanes;
19//! 4. `resume(tpl)` — messages start flowing into the split queues, which
20//!    buffer until pipeline threads take the lanes over;
21//! 5. return [`SourceEvent::LanesAssigned`].
22//!
23//! **Revocation** (spans two `poll_events` calls):
24//! 1. surface [`SourceEvent::LanesRevoked`] with a [`DrainBarrier`] sized
25//!    by lane count (the runtime's drivers arrive once per stopped lane);
26//! 2. the runtime stops the lanes, waits for the barrier, drains the
27//!    checkpointer, calls [`Source::commit`] + [`Source::flush_commits`] —
28//!    the sync commit happens while this member still owns the partitions
29//!    (the rebalance is not yet acknowledged, so the group generation is
30//!    still valid);
31//! 3. the controller loops back into `poll_events`, which sees the pending
32//!    completion and calls `unassign()`, letting the rebalance finish.
33//!
34//! Revoked lanes' queues go silent immediately (fetching stops); dropping
35//! a `PartitionQueue` before `unassign` would restore forwarding to the
36//! main queue, which is why any message that ever appears on the main
37//! queue is defensively rewound with `seek` rather than dropped — its
38//! offset would otherwise be committed past without processing.
39
40use crate::config::KafkaSourceConfig;
41use crate::context::{Intent, SourceContext};
42use crate::lane::KafkaLane;
43use etl_core::checkpoint::AckIssuer;
44use etl_core::error::{ErrorClass, SourceError};
45use etl_core::metrics::SourceMetrics;
46use etl_core::record::PartitionId;
47use etl_core::source::{DrainBarrier, LaneId, Source, SourceCtx, SourceEvent};
48use rdkafka::consumer::{BaseConsumer, Consumer};
49use rdkafka::message::Message;
50use rdkafka::{Offset, TopicPartitionList};
51use std::collections::HashMap;
52use std::sync::Arc;
53use std::time::{Duration, Instant};
54
55/// Kafka source: one consumer-group member per process, partitions split
56/// into per-lane queues polled by pipeline threads. Constructed from
57/// config ([`KafkaSource::new`]) or a pipeline component section
58/// ([`KafkaSource::from_component_config`]).
59pub struct KafkaSource {
60    config: KafkaSourceConfig,
61    consumer: Option<Arc<BaseConsumer<SourceContext>>>,
62    issuer: Option<AckIssuer>,
63    metrics: Option<SourceMetrics>,
64    /// Lanes of the current assignment, by id.
65    assignment: HashMap<LaneId, i32>,
66    /// Lanes surfaced as revoked but not yet released by `unassign`. The
67    /// member still owns these partitions (the rebalance is not acknowledged
68    /// until `unassign`), so the post-drain final commit must still store
69    /// their offsets — `commit` consults this alongside `assignment`. Cleared
70    /// when `unassign` completes the revocation.
71    revoking: HashMap<LaneId, i32>,
72    next_lane: u32,
73    opened_at: Option<Instant>,
74    saw_first_assignment: bool,
75    /// A revocation was surfaced; `unassign` completes it on the next
76    /// `poll_events` call (after the runtime finished drain + commit).
77    pending_unassign: bool,
78    /// Messages that leaked onto the main queue and were rewound.
79    main_queue_rewinds: u64,
80}
81
82impl std::fmt::Debug for KafkaSource {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("KafkaSource")
85            .field("topic", &self.config.topic)
86            .field("group_id", &self.config.group_id)
87            .field("lanes", &self.assignment.len())
88            .finish_non_exhaustive()
89    }
90}
91
92impl KafkaSource {
93    /// Create a source from validated configuration.
94    #[must_use]
95    pub fn new(config: KafkaSourceConfig) -> Self {
96        KafkaSource {
97            config,
98            consumer: None,
99            issuer: None,
100            metrics: None,
101            assignment: HashMap::new(),
102            revoking: HashMap::new(),
103            next_lane: 0,
104            opened_at: None,
105            saw_first_assignment: false,
106            pending_unassign: false,
107            main_queue_rewinds: 0,
108        }
109    }
110
111    /// Create a source from the pipeline's opaque `source: { kafka: ... }`
112    /// section.
113    pub fn from_component_config(
114        section: &etl_core::config::ComponentConfig,
115    ) -> Result<Self, etl_core::config::ConfigError> {
116        Ok(Self::new(KafkaSourceConfig::from_component_config(
117            section,
118        )?))
119    }
120
121    /// Attach pre-registered source metrics (consumer lag, rebalances).
122    /// Optional; without it the source only logs.
123    #[must_use]
124    pub fn with_metrics(mut self, metrics: SourceMetrics) -> Self {
125        self.metrics = Some(metrics);
126        self
127    }
128
129    fn consumer(&self) -> Result<&Arc<BaseConsumer<SourceContext>>, SourceError> {
130        self.consumer.as_ref().ok_or_else(|| SourceError::Client {
131            class: ErrorClass::Fatal,
132            reason: "source used before open()".into(),
133        })
134    }
135
136    fn tpl_for(&self, partitions: impl IntoIterator<Item = i32>) -> TopicPartitionList {
137        let mut tpl = TopicPartitionList::new();
138        for p in partitions {
139            tpl.add_partition(&self.config.topic, p);
140        }
141        tpl
142    }
143
144    fn lanes_tpl(&self, lanes: &[LaneId]) -> TopicPartitionList {
145        self.tpl_for(lanes.iter().filter_map(|l| self.assignment.get(l).copied()))
146    }
147
148    /// Partitions still owned (the current assignment), as `PartitionId`s.
149    /// Used to prune per-partition metric series when partitions are revoked
150    /// so a moved-away partition's lag gauge does not persist forever.
151    fn retained_partition_ids(&self) -> Vec<PartitionId> {
152        self.assignment
153            .values()
154            .map(|p| PartitionId(u32::try_from(*p).unwrap_or(0)))
155            .collect()
156    }
157
158    /// Partitions whose offsets this member may still store: the live
159    /// assignment plus partitions being revoked but not yet released by
160    /// `unassign` (ownership stays valid until the rebalance is acknowledged).
161    fn committable_partitions(&self) -> Vec<i32> {
162        self.assignment
163            .values()
164            .chain(self.revoking.values())
165            .copied()
166            .collect()
167    }
168
169    /// Accept an assignment: assign → pause → split → resume → lanes.
170    fn accept_assignment(
171        &mut self,
172        tpl: &TopicPartitionList,
173    ) -> Result<Vec<KafkaLane>, SourceError> {
174        let consumer = Arc::clone(self.consumer()?);
175        let issuer = self.issuer.as_ref().ok_or_else(|| SourceError::Client {
176            class: ErrorClass::Fatal,
177            reason: "assignment before open()".into(),
178        })?;
179
180        consumer.assign(tpl).map_err(fatal("assign"))?;
181        // Pause before any fetch can complete: prevents pre-split messages
182        // from reaching the main queue (spike-verified choreography).
183        consumer.pause(tpl).map_err(fatal("pause new assignment"))?;
184
185        let mut lanes = Vec::new();
186        for elem in tpl.elements() {
187            let partition = elem.partition();
188            let queue = consumer
189                .split_partition_queue(&self.config.topic, partition)
190                .ok_or_else(|| SourceError::Client {
191                    class: ErrorClass::Fatal,
192                    reason: format!("no queue for assigned partition {partition}"),
193                })?;
194            let lane_id = LaneId(self.next_lane);
195            self.next_lane += 1;
196            self.assignment.insert(lane_id, partition);
197            lanes.push(KafkaLane::new(
198                lane_id,
199                PartitionId(u32::try_from(partition).unwrap_or(0)),
200                queue,
201                issuer.clone(),
202            ));
203        }
204        consumer
205            .resume(tpl)
206            .map_err(fatal("resume new assignment"))?;
207        self.saw_first_assignment = true;
208        tracing::info!(
209            partitions = lanes.len(),
210            topic = %self.config.topic,
211            "accepted assignment"
212        );
213        Ok(lanes)
214    }
215
216    /// Feed the latest librdkafka statistics into the lag metrics.
217    fn publish_stats(&mut self) {
218        let Some(consumer) = self.consumer.as_ref() else {
219            return;
220        };
221        let Some(stats) = consumer.context().stats.lock().expect("stats lock").take() else {
222            return;
223        };
224        let Some(metrics) = self.metrics.as_ref() else {
225            return;
226        };
227        let mut max_lag: u64 = 0;
228        if let Some(topic) = stats.topics.get(&self.config.topic) {
229            for (pid, p) in &topic.partitions {
230                if p.consumer_lag >= 0
231                    && let Ok(part) = u32::try_from(*pid)
232                {
233                    let lag = u64::try_from(p.consumer_lag).unwrap_or(0);
234                    max_lag = max_lag.max(lag);
235                    metrics.set_partition_lag(PartitionId(part), lag);
236                }
237            }
238        }
239        metrics.set_lag_max(max_lag);
240    }
241}
242
243fn fatal(what: &'static str) -> impl Fn(rdkafka::error::KafkaError) -> SourceError {
244    move |e| SourceError::Client {
245        class: ErrorClass::Fatal,
246        reason: format!("{what}: {e}"),
247    }
248}
249
250impl Source for KafkaSource {
251    type Lane = KafkaLane;
252
253    fn open(&mut self, ctx: SourceCtx) -> Result<(), SourceError> {
254        if self.consumer.is_some() {
255            return Err(SourceError::Client {
256                class: ErrorClass::Fatal,
257                reason: "open() called twice".into(),
258            });
259        }
260        let consumer: BaseConsumer<SourceContext> = self
261            .config
262            .client_config()
263            .create_with_context(SourceContext::default())
264            .map_err(fatal("create consumer"))?;
265        consumer
266            .subscribe(&[&self.config.topic])
267            .map_err(fatal("subscribe"))?;
268        self.consumer = Some(Arc::new(consumer));
269        self.issuer = Some(ctx.issuer);
270        self.opened_at = Some(Instant::now());
271        Ok(())
272    }
273
274    fn poll_events(&mut self, timeout: Duration) -> Result<SourceEvent<KafkaLane>, SourceError> {
275        // Startup deadline first: with unreachable brokers every poll below
276        // surfaces a Retryable transport error and returns early — checked
277        // last, this deadline would never fire and a misconfigured pipeline
278        // would retry forever instead of failing fast.
279        if !self.saw_first_assignment
280            && let Some(at) = self.opened_at
281            && at.elapsed() > self.config.startup_timeout
282        {
283            return Err(SourceError::Client {
284                class: ErrorClass::Fatal,
285                reason: format!(
286                    "no partition assignment within {:?} (topic {:?}, brokers {:?})",
287                    self.config.startup_timeout, self.config.topic, self.config.brokers
288                ),
289            });
290        }
291
292        // Complete a deferred revocation first: the runtime has finished
293        // draining and committing by the time it calls poll_events again.
294        if self.pending_unassign {
295            self.pending_unassign = false;
296            let consumer = Arc::clone(self.consumer()?);
297            if let Err(e) = consumer.unassign() {
298                tracing::warn!(error = %e, "unassign after drained revocation");
299            }
300            // The revoked partitions are now released; any late commit for
301            // them must be refused again.
302            self.revoking.clear();
303        }
304
305        let consumer = Arc::clone(self.consumer()?);
306
307        // Serve callbacks; with all partitions split and choreographed
308        // correctly no message should ever surface here. If one does,
309        // rewind so it is refetched through its split queue — dropping it
310        // would let the watermark commit past an unprocessed record.
311        if let Some(result) = consumer.poll(timeout) {
312            match result {
313                Ok(msg) => {
314                    self.main_queue_rewinds += 1;
315                    tracing::warn!(
316                        partition = msg.partition(),
317                        offset = msg.offset(),
318                        total = self.main_queue_rewinds,
319                        "message on the main queue; rewinding partition"
320                    );
321                    let tpl = self.tpl_for([msg.partition()]);
322                    let _ = consumer.pause(&tpl);
323                    if let Err(e) = consumer.seek(
324                        &self.config.topic,
325                        msg.partition(),
326                        Offset::Offset(msg.offset()),
327                        Duration::from_secs(5),
328                    ) {
329                        tracing::error!(error = %e, "seek for main-queue rewind failed");
330                    }
331                    let _ = consumer.resume(&tpl);
332                }
333                Err(e) => {
334                    // Permanent broker-side failures (authorization revoked,
335                    // deleted topic, unsupported protocol) must fail fast
336                    // rather than retry forever behind a green health probe.
337                    return Err(SourceError::Client {
338                        class: crate::error::classify_poll_error(&e, self.saw_first_assignment),
339                        reason: format!("consumer poll: {e}"),
340                    });
341                }
342            }
343        }
344
345        self.publish_stats();
346
347        // Rebalance intents recorded by the callback during the poll above
348        // (or a previous one).
349        let intent = {
350            let ctx = self.consumer()?.context().clone();
351            let mut intents = ctx.intents.lock().expect("intent lock");
352            intents.pop_front()
353        };
354        if let Some(intent) = intent {
355            match intent {
356                Intent::Assign(tpl) => {
357                    if let Some(m) = &self.metrics {
358                        m.rebalance_assigned();
359                        m.set_lanes_active(tpl.count());
360                    }
361                    if tpl.count() == 0 {
362                        // Empty assignment (no partitions for this member).
363                        // The rebalance protocol still MUST be acknowledged:
364                        // under deferred completion librdkafka keeps the
365                        // rebalance in progress until we call `assign`, even
366                        // for an empty set. Skipping it wedges the member —
367                        // it can never complete a later rebalance.
368                        let consumer = Arc::clone(self.consumer()?);
369                        consumer.assign(&tpl).map_err(fatal("assign empty"))?;
370                        self.saw_first_assignment = true;
371                        return Ok(SourceEvent::Idle);
372                    }
373                    let lanes = self.accept_assignment(&tpl)?;
374                    return Ok(SourceEvent::LanesAssigned(lanes));
375                }
376                Intent::Revoke(tpl) => {
377                    if let Some(m) = &self.metrics {
378                        m.rebalance_revoked();
379                    }
380                    // Map revoked partitions back to lane ids.
381                    let revoked: Vec<i32> = tpl.elements().iter().map(|e| e.partition()).collect();
382                    let lanes: Vec<LaneId> = self
383                        .assignment
384                        .iter()
385                        .filter(|(_, p)| revoked.contains(p))
386                        .map(|(l, _)| *l)
387                        .collect();
388                    // Move revoked lanes out of the live assignment but keep
389                    // them in `revoking`: the member still owns these
390                    // partitions until `unassign`, so the runtime's post-drain
391                    // final commit must be allowed to store their offsets.
392                    // `commit` consults `revoking`; the next `poll_events`
393                    // clears it once `unassign` releases the partitions.
394                    for lane in &lanes {
395                        if let Some(p) = self.assignment.remove(lane) {
396                            self.revoking.insert(*lane, p);
397                        }
398                    }
399                    // The revoked partitions have moved to another member;
400                    // drop their per-partition lag series so it does not
401                    // persist frozen at its last value.
402                    if let Some(m) = &self.metrics {
403                        m.retain_partitions(&self.retained_partition_ids());
404                    }
405                    // Complete with unassign on the next call, after the
406                    // runtime drained and committed.
407                    self.pending_unassign = true;
408                    if lanes.is_empty() {
409                        return Ok(SourceEvent::Idle);
410                    }
411                    let barrier = DrainBarrier::new(lanes.len());
412                    return Ok(SourceEvent::LanesRevoked { lanes, barrier });
413                }
414                Intent::Error(reason) => {
415                    return Err(SourceError::Client {
416                        class: ErrorClass::Retryable,
417                        reason: format!("rebalance error: {reason}"),
418                    });
419                }
420            }
421        }
422
423        Ok(SourceEvent::Idle)
424    }
425
426    fn commit(&mut self, watermarks: &[(PartitionId, i64)]) -> Result<(), SourceError> {
427        if watermarks.is_empty() {
428            return Ok(());
429        }
430        let consumer = Arc::clone(self.consumer()?);
431        // Partitions this member still owns: the live assignment plus any
432        // being revoked but not yet released by `unassign`. The revocation
433        // choreography drains and commits those partitions while ownership is
434        // still valid — filtering them out here would silently drop exactly
435        // the offsets the drain produced, replaying that work after the move.
436        let owned = self.committable_partitions();
437        let mut tpl = TopicPartitionList::new();
438        for (p, offset) in watermarks {
439            let partition = i32::try_from(p.0).unwrap_or(-1);
440            if owned.contains(&partition) {
441                tpl.add_partition_offset(&self.config.topic, partition, Offset::Offset(*offset))
442                    .map_err(fatal("build offset list"))?;
443            } else {
444                tracing::debug!(
445                    partition = p.0,
446                    offset,
447                    "skipping store for partition no longer owned"
448                );
449            }
450        }
451        if tpl.count() == 0 {
452            return Ok(());
453        }
454        consumer
455            .store_offsets(&tpl)
456            .map_err(|e| SourceError::Client {
457                class: ErrorClass::Retryable,
458                reason: format!("store offsets: {e}"),
459            })
460    }
461
462    fn flush_commits(&mut self) -> Result<(), SourceError> {
463        let consumer = Arc::clone(self.consumer()?);
464        match consumer.commit_consumer_state(rdkafka::consumer::CommitMode::Sync) {
465            Ok(()) => Ok(()),
466            // Nothing stored since the last commit: not an error.
467            Err(rdkafka::error::KafkaError::ConsumerCommit(
468                rdkafka::error::RDKafkaErrorCode::NoOffset,
469            )) => Ok(()),
470            Err(e) => Err(SourceError::Client {
471                class: ErrorClass::Retryable,
472                reason: format!("sync commit: {e}"),
473            }),
474        }
475    }
476
477    fn pause(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
478        let tpl = self.lanes_tpl(lanes);
479        if tpl.count() == 0 {
480            return Ok(());
481        }
482        self.consumer()?
483            .pause(&tpl)
484            .map_err(|e| SourceError::Client {
485                class: ErrorClass::Retryable,
486                reason: format!("pause: {e}"),
487            })
488    }
489
490    fn resume(&mut self, lanes: &[LaneId]) -> Result<(), SourceError> {
491        let tpl = self.lanes_tpl(lanes);
492        if tpl.count() == 0 {
493            return Ok(());
494        }
495        self.consumer()?
496            .resume(&tpl)
497            .map_err(|e| SourceError::Client {
498                class: ErrorClass::Retryable,
499                reason: format!("resume: {e}"),
500            })
501    }
502}
503
504/// Teardown: consumer close (inside `BaseConsumer::drop`) triggers a final
505/// revoke and then polls until the rebalance protocol completes — with the
506/// deferred-intent design, nothing would ever complete it and the drop
507/// would hang forever. Flip the context to inline-completion mode and
508/// settle any revocation that was surfaced but not yet acknowledged.
509impl Drop for KafkaSource {
510    fn drop(&mut self) {
511        if let Some(consumer) = &self.consumer {
512            consumer
513                .context()
514                .closing
515                .store(true, std::sync::atomic::Ordering::Release);
516            let deferred_revoke = self.pending_unassign
517                || consumer
518                    .context()
519                    .intents
520                    .lock()
521                    .map(|q| q.iter().any(|i| matches!(i, Intent::Revoke(_))))
522                    .unwrap_or(false);
523            if deferred_revoke && let Err(e) = consumer.unassign() {
524                tracing::warn!(error = %e, "unassign during source teardown failed");
525            }
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn test_config() -> KafkaSourceConfig {
535        KafkaSourceConfig {
536            brokers: "localhost:9092".into(),
537            topic: "orders".into(),
538            group_id: "test".into(),
539            commit_interval: Duration::from_secs(5),
540            startup_timeout: Duration::from_secs(30),
541            statistics_interval: Duration::ZERO,
542            rdkafka: std::collections::BTreeMap::new(),
543        }
544    }
545
546    /// Reproduces the assignment bookkeeping of a partial revocation: lanes
547    /// for the revoked partitions move from `assignment` into `revoking`.
548    fn revoke_lanes(source: &mut KafkaSource, revoked: &[i32]) {
549        let lanes: Vec<LaneId> = source
550            .assignment
551            .iter()
552            .filter(|(_, p)| revoked.contains(p))
553            .map(|(l, _)| *l)
554            .collect();
555        for lane in &lanes {
556            if let Some(p) = source.assignment.remove(lane) {
557                source.revoking.insert(*lane, p);
558            }
559        }
560    }
561
562    /// After a revocation the offsets of the partitions being revoked must
563    /// still be committable — they are drained and committed while the member
564    /// still owns them — while truly unowned partitions stay filtered out.
565    #[test]
566    fn committable_partitions_include_revoking_until_released() {
567        let mut source = KafkaSource::new(test_config());
568        for (lane, part) in [(0u32, 0i32), (1, 1), (2, 2), (3, 3)] {
569            source.assignment.insert(LaneId(lane), part);
570        }
571
572        revoke_lanes(&mut source, &[2, 3]);
573
574        let mut owned = source.committable_partitions();
575        owned.sort_unstable();
576        assert_eq!(
577            owned,
578            vec![0, 1, 2, 3],
579            "revoked partitions stay committable until unassign releases them"
580        );
581
582        // Releasing the revocation (what `unassign` completion does) removes
583        // them: a late commit for a released partition is refused.
584        source.revoking.clear();
585        let mut owned = source.committable_partitions();
586        owned.sort_unstable();
587        assert_eq!(owned, vec![0, 1]);
588    }
589
590    /// The retained set that prunes per-partition metric series must exclude
591    /// revoked partitions so their lag gauge does not persist after the move.
592    #[test]
593    fn retained_partition_ids_drop_revoked_partitions() {
594        let mut source = KafkaSource::new(test_config());
595        for (lane, part) in [(0u32, 0i32), (1, 1), (2, 2)] {
596            source.assignment.insert(LaneId(lane), part);
597        }
598
599        revoke_lanes(&mut source, &[2]);
600
601        let mut kept: Vec<u32> = source
602            .retained_partition_ids()
603            .iter()
604            .map(|p| p.0)
605            .collect();
606        kept.sort_unstable();
607        assert_eq!(kept, vec![0, 1], "revoked partition 2 is not retained");
608    }
609}