Skip to main content

etl_kafka/
lane.rs

1//! The data plane: one lane per assigned partition, polling its split
2//! partition queue on a pipeline thread.
3//!
4//! # Zero-copy lifetime strategy
5//!
6//! `RawPayload`s must borrow librdkafka's message memory without copying,
7//! and stay valid for exactly as long as the seam contract promises: until
8//! the batch is dropped, which happens before the next `poll` on the same
9//! lane. A `BorrowedMessage<'a>`'s payload is freed when the message is
10//! dropped (`rd_kafka_message_destroy`), so the messages themselves must
11//! outlive every payload reference handed out.
12//!
13//! The lane therefore **owns** the polled messages across the batch's
14//! lifetime: `held` stores them with an erased lifetime, and is cleared
15//! only at the start of the next `poll(&mut self)` — at which point the
16//! borrow checker has already proven no batch (and no payload borrowed
17//! from it) is still alive, because the batch borrows `&mut self`. The
18//! erased lifetime is never observable: everything handed out is re-tied
19//! to the lane borrow.
20
21use crate::context::SourceContext;
22use etl_core::checkpoint::{AckIssuer, AckRef};
23use etl_core::error::SourceError;
24use etl_core::record::{PartitionId, RawPayload};
25use etl_core::source::{LaneId, PayloadBatch, SourceLane};
26use rdkafka::Message;
27use rdkafka::consumer::base_consumer::PartitionQueue;
28use rdkafka::message::BorrowedMessage;
29// NOTE: `PartitionQueue` is not re-exported at `rdkafka::consumer`; the
30// `base_consumer` module path is the public location in 0.39.
31use std::time::Duration;
32
33/// One assigned partition's pollable queue.
34pub struct KafkaLane {
35    id: LaneId,
36    partition: PartitionId,
37    // Declared before `queue`: messages are destroyed before the queue on
38    // lane drop.
39    //
40    // INVARIANT: `held` is cleared and refilled only inside
41    // `poll(&mut self)`; every reference derived from it is tied to a
42    // borrow of the lane, so no payload can outlive the messages backing it.
43    held: Vec<BorrowedMessage<'static>>,
44    queue: PartitionQueue<SourceContext>,
45    issuer: AckIssuer,
46}
47
48impl std::fmt::Debug for KafkaLane {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("KafkaLane")
51            .field("id", &self.id)
52            .field("partition", &self.partition)
53            .field("held", &self.held.len())
54            .finish_non_exhaustive()
55    }
56}
57
58impl KafkaLane {
59    pub(crate) fn new(
60        id: LaneId,
61        partition: PartitionId,
62        queue: PartitionQueue<SourceContext>,
63        issuer: AckIssuer,
64    ) -> Self {
65        KafkaLane {
66            id,
67            partition,
68            held: Vec::new(),
69            queue,
70            issuer,
71        }
72    }
73}
74
75/// Erase a borrowed message's phantom lifetime so the lane can own it.
76///
77/// SAFETY: `BorrowedMessage<'a>` is `{ ptr: NativePtr<RDKafkaMessage>,
78/// _event: Arc<NativeEvent>, _owner: PhantomData<&'a u8> }` (rdkafka
79/// 0.39.0) — the `'a` parameter is phantom only: it affects no layout and
80/// no drop behaviour, so transmuting the lifetime is sound. Validity of
81/// the message memory is self-contained: the message holds an `Arc` of its
82/// owning native event, and destruction happens in the `BorrowedMessage`
83/// drop. The lane additionally keeps the consumer alive through its
84/// `queue` (which holds a consumer `Arc`), with `held` declared to drop
85/// first.
86unsafe fn erase_lifetime(msg: BorrowedMessage<'_>) -> BorrowedMessage<'static> {
87    // SAFETY: lifetime-only transmute; see function docs.
88    unsafe { std::mem::transmute::<BorrowedMessage<'_>, BorrowedMessage<'static>>(msg) }
89}
90
91impl SourceLane for KafkaLane {
92    type Batch<'a> = KafkaBatch<'a>;
93
94    fn id(&self) -> LaneId {
95        self.id
96    }
97
98    fn partition(&self) -> PartitionId {
99        self.partition
100    }
101
102    fn poll(
103        &mut self,
104        max_records: usize,
105        timeout: Duration,
106    ) -> Result<Option<Self::Batch<'_>>, SourceError> {
107        // Exclusive access proves the previous batch (and every payload
108        // borrowed from it) is gone; the messages can be destroyed.
109        self.held.clear();
110
111        // First message: block up to `timeout` (idle lanes must not spin).
112        match self.queue.poll(timeout) {
113            None => return Ok(None),
114            Some(Err(e)) => {
115                // A lane only exists after its partition was assigned, so
116                // any permanent condition here is genuinely post-startup.
117                return Err(SourceError::Client {
118                    class: crate::error::classify_poll_error(&e, true),
119                    reason: format!("partition {} poll: {e}", self.partition.0),
120                });
121            }
122            Some(Ok(msg)) => {
123                // SAFETY: see `erase_lifetime`; the queue (and through it
124                // the consumer) outlives `held` within this lane.
125                self.held.push(unsafe { erase_lifetime(msg) });
126            }
127        }
128        // Batch what is already buffered, without waiting.
129        while self.held.len() < max_records {
130            match self.queue.poll(Duration::ZERO) {
131                Some(Ok(msg)) => {
132                    // SAFETY: as above.
133                    self.held.push(unsafe { erase_lifetime(msg) });
134                }
135                Some(Err(e)) => {
136                    // Deliver what we have; the error will resurface on the
137                    // next poll if it persists.
138                    tracing::debug!(partition = self.partition.0, error = %e,
139                        "queue error while batching; delivering partial batch");
140                    break;
141                }
142                None => break,
143            }
144        }
145
146        let last_offset = self
147            .held
148            .last()
149            .expect("batch has at least the first message")
150            .offset();
151        let ack = self.issuer.issue(self.partition, last_offset);
152        Ok(Some(KafkaBatch {
153            msgs: &self.held,
154            idx: 0,
155            ack,
156            partition: self.partition,
157        }))
158    }
159}
160
161/// One poll's worth of messages, borrowed from the lane.
162#[derive(Debug)]
163pub struct KafkaBatch<'a> {
164    msgs: &'a [BorrowedMessage<'static>],
165    idx: usize,
166    ack: AckRef,
167    partition: PartitionId,
168}
169
170impl<'a> PayloadBatch<'a> for KafkaBatch<'a> {
171    fn next_payload(&mut self) -> Option<RawPayload<'a>> {
172        let msg = self.msgs.get(self.idx)?;
173        self.idx += 1;
174        Some(RawPayload {
175            // A Kafka tombstone (null payload) surfaces as an empty slice;
176            // deserializers treat empty input as a tombstone/skip.
177            bytes: msg.payload().unwrap_or(&[]),
178            key: msg.key(),
179            partition: self.partition,
180            offset: msg.offset(),
181            timestamp_ms: msg.timestamp().to_millis().unwrap_or(0),
182        })
183    }
184
185    fn ack(&self) -> &AckRef {
186        &self.ack
187    }
188}