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}