spate_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 rdkafka::Message;
23use rdkafka::consumer::base_consumer::PartitionQueue;
24use rdkafka::message::BorrowedMessage;
25use spate_core::checkpoint::{AckIssuer, AckRef};
26use spate_core::error::SourceError;
27use spate_core::record::{PartitionId, RawPayload};
28use spate_core::source::{LaneId, PayloadBatch, SourceLane};
29use std::time::Duration;
30
31/// One assigned partition's pollable queue.
32pub struct KafkaLane {
33 id: LaneId,
34 partition: PartitionId,
35 // Declared before `queue`: the messages are destroyed while the consumer
36 // behind the queue is still alive.
37 held: Vec<BorrowedMessage<'static>>,
38 queue: PartitionQueue<SourceContext>,
39 issuer: AckIssuer,
40}
41
42impl std::fmt::Debug for KafkaLane {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.debug_struct("KafkaLane")
45 .field("id", &self.id)
46 .field("partition", &self.partition)
47 .field("held", &self.held.len())
48 .finish_non_exhaustive()
49 }
50}
51
52impl KafkaLane {
53 pub(crate) fn new(
54 id: LaneId,
55 partition: PartitionId,
56 queue: PartitionQueue<SourceContext>,
57 issuer: AckIssuer,
58 ) -> Self {
59 KafkaLane {
60 id,
61 partition,
62 held: Vec::new(),
63 queue,
64 issuer,
65 }
66 }
67}
68
69/// Erase a borrowed message's phantom lifetime so the lane can own it.
70///
71/// SAFETY: `BorrowedMessage<'a>` is `{ ptr: NativePtr<RDKafkaMessage>,
72/// _event: Arc<NativeEvent>, _owner: PhantomData<&'a u8> }` (rdkafka
73/// 0.39.0). The `'a` parameter is phantom only: it affects no layout and
74/// no drop behavior, so transmuting the lifetime is sound. Validity of
75/// the message memory is self-contained: the message holds an `Arc` of its
76/// owning native event, and destruction happens in the `BorrowedMessage`
77/// drop. The lane additionally keeps the consumer alive through its
78/// `queue` (which holds a consumer `Arc`), with `held` declared to drop
79/// first.
80unsafe fn erase_lifetime(msg: BorrowedMessage<'_>) -> BorrowedMessage<'static> {
81 // SAFETY: lifetime-only transmute; see function docs.
82 unsafe { std::mem::transmute::<BorrowedMessage<'_>, BorrowedMessage<'static>>(msg) }
83}
84
85impl SourceLane for KafkaLane {
86 type Batch<'a> = KafkaBatch<'a>;
87
88 fn id(&self) -> LaneId {
89 self.id
90 }
91
92 fn partition(&self) -> PartitionId {
93 self.partition
94 }
95
96 fn poll(
97 &mut self,
98 max_records: usize,
99 timeout: Duration,
100 ) -> Result<Option<Self::Batch<'_>>, SourceError> {
101 self.held.clear();
102
103 // First message: block up to `timeout` (idle lanes must not spin).
104 match self.queue.poll(timeout) {
105 None => return Ok(None),
106 Some(Err(e)) => {
107 // Post-startup: a lane exists only once its partition is assigned.
108 return Err(SourceError::Client {
109 class: crate::error::classify_poll_error(&e, true),
110 reason: format!("partition {} poll: {e}", self.partition.0),
111 });
112 }
113 Some(Ok(msg)) => {
114 // SAFETY: see `erase_lifetime`; the queue (and through it
115 // the consumer) outlives `held` within this lane.
116 self.held.push(unsafe { erase_lifetime(msg) });
117 }
118 }
119 while self.held.len() < max_records {
120 match self.queue.poll(Duration::ZERO) {
121 Some(Ok(msg)) => {
122 // SAFETY: as above.
123 self.held.push(unsafe { erase_lifetime(msg) });
124 }
125 Some(Err(e)) => {
126 // Deliver what we have; a persisting error resurfaces on the next poll.
127 tracing::debug!(partition = self.partition.0, error = %e,
128 "queue error while batching; delivering partial batch");
129 break;
130 }
131 None => break,
132 }
133 }
134
135 let last_offset = self
136 .held
137 .last()
138 .expect("batch has at least the first message")
139 .offset();
140 let ack = self.issuer.issue(self.partition, last_offset);
141 Ok(Some(KafkaBatch {
142 msgs: &self.held,
143 idx: 0,
144 ack,
145 partition: self.partition,
146 }))
147 }
148}
149
150/// One poll's worth of messages, borrowed from the lane.
151#[derive(Debug)]
152pub struct KafkaBatch<'a> {
153 msgs: &'a [BorrowedMessage<'static>],
154 idx: usize,
155 ack: AckRef,
156 partition: PartitionId,
157}
158
159impl<'a> PayloadBatch<'a> for KafkaBatch<'a> {
160 fn next_payload(&mut self) -> Option<RawPayload<'a>> {
161 let msg = self.msgs.get(self.idx)?;
162 self.idx += 1;
163 Some(RawPayload {
164 bytes: msg.payload().unwrap_or(&[]),
165 key: msg.key(),
166 partition: self.partition,
167 offset: msg.offset(),
168 timestamp_ms: msg.timestamp().to_millis().unwrap_or(0),
169 })
170 }
171
172 fn ack(&self) -> &AckRef {
173 &self.ack
174 }
175}