Skip to main content

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}