Skip to main content

barnabas_core/
consumer.rs

1//! Assign-only consumer state: fetch positions, and READ_COMMITTED filtering.
2//!
3//! There is no consumer group protocol here and there will not be one. Callers
4//! assign partitions themselves — in Slipstream's case a vnode owns partitions
5//! and a lease decides which node owns the vnode — so there is no `JoinGroup`,
6//! no heartbeat, no rebalance, and no generation fencing. That is the hardest
7//! half of a Kafka client, and it is out of scope by construction.
8//!
9//! # READ_COMMITTED is the client's job
10//!
11//! P0 found this against a real broker, and it is worth stating plainly because
12//! the API's shape suggests otherwise: setting `isolation_level = 1` does
13//! **not** make the broker withhold aborted records. It returns them, together
14//! with a list of aborted transactions and a last-stable-offset, and the
15//! consumer filters. A client that only sets the flag hands aborted data to its
16//! caller and reports it as committed — with no error anywhere, which is the
17//! silent exactly-once failure this crate is most concerned with.
18//!
19//! [`filter`] implements the three rules, and its tests are the specification.
20
21use std::collections::HashSet;
22
23use kafka_protocol::records::Record;
24
25/// Whether aborted records are visible.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum IsolationLevel {
28    /// Everything the broker has, aborted transactions included.
29    ReadUncommitted,
30    /// Only committed data, and only below the last stable offset.
31    ReadCommitted,
32}
33
34impl IsolationLevel {
35    /// The wire value for `FetchRequest::isolation_level`.
36    #[must_use]
37    pub fn as_i8(self) -> i8 {
38        match self {
39            Self::ReadUncommitted => 0,
40            Self::ReadCommitted => 1,
41        }
42    }
43}
44
45/// An aborted transaction, as the broker reports it in a fetch response.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct AbortedTransaction {
48    pub producer_id: i64,
49    pub first_offset: i64,
50}
51
52/// Where a consumer is in one partition.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct FetchPosition {
55    pub topic: String,
56    pub partition: i32,
57    /// The offset the next fetch asks for.
58    pub next_offset: i64,
59}
60
61impl FetchPosition {
62    #[must_use]
63    pub fn new(topic: impl Into<String>, partition: i32, next_offset: i64) -> Self {
64        Self {
65            topic: topic.into(),
66            partition,
67            next_offset,
68        }
69    }
70}
71
72/// What a fetch yielded, after filtering.
73#[derive(Debug, Default)]
74pub struct Fetched {
75    /// Records the caller may have.
76    pub records: Vec<Record>,
77    /// Where the next fetch should start.
78    ///
79    /// **Advances past filtered records too**, which is not a detail: a fetch
80    /// whose every record was aborted must still make progress, or the consumer
81    /// re-requests the same offset forever and stalls on a partition that is
82    /// perfectly healthy.
83    pub next_offset: i64,
84}
85
86/// Kafka's control-record types, read from a control record's key.
87///
88/// The key of a control record is a 2-byte version followed by a 2-byte type.
89const CONTROL_ABORT: i16 = 0;
90
91fn control_type(record: &Record) -> Option<i16> {
92    let key = record.key.as_ref()?;
93    if key.len() < 4 {
94        return None;
95    }
96    Some(i16::from_be_bytes([key[2], key[3]]))
97}
98
99/// Apply the READ_COMMITTED rules to one partition's records.
100///
101/// `records` must be in offset order, which is how the broker sends them.
102/// `last_stable_offset` is the partition's LSO from the same fetch response.
103///
104/// Three rules:
105/// 1. **Nothing at or past the LSO.** Above it, the outcome of a transaction is
106///    not yet decided.
107/// 2. **Control records are never data.** They are the commit and abort markers
108///    themselves, and they are dropped under *both* isolation levels — a caller
109///    asking for READ_UNCOMMITTED wants uncommitted records, not protocol
110///    machinery.
111/// 3. **Nothing below `fetch_offset`.** The broker returns whole record
112///    batches, so a fetch from the middle of a batch comes back with the
113///    records before it too. Dropping them is the client's job; a consumer that
114///    forgets re-delivers records it has already emitted, which for a
115///    checkpointing caller is a duplicate after every restore.
116/// 4. **Records from an aborted transaction are dropped**, over the range from
117///    that transaction's `first_offset` to the producer's abort marker. The
118///    range matters: one producer can interleave an aborted and a committed
119///    transaction within a single fetch response, and a rule that drops
120///    everything from an aborted producer after `first_offset` would silently
121///    discard the committed records that follow.
122#[must_use]
123pub fn filter(
124    records: Vec<Record>,
125    aborted: &[AbortedTransaction],
126    last_stable_offset: i64,
127    isolation: IsolationLevel,
128    fetch_offset: i64,
129) -> Fetched {
130    let mut sorted: Vec<AbortedTransaction> = aborted.to_vec();
131    sorted.sort_by_key(|a| a.first_offset);
132    let mut pending = sorted.into_iter().peekable();
133
134    // Producers whose transaction is open-and-aborted at the current offset.
135    let mut aborted_producers: HashSet<i64> = HashSet::new();
136
137    let read_committed = isolation == IsolationLevel::ReadCommitted;
138
139    // **The common case is that nothing is dropped**, and moving a million
140    // records into a second `Vec` to discover that is most of the cost of
141    // consuming. A scan that touches only three fields per record decides it
142    // without moving anything, and hands the input straight back.
143    //
144    // The conditions are exactly the four rules below, negated: no aborted
145    // ranges to track, nothing above the LSO to withhold, no control records to
146    // strip, and nothing below `fetch_offset` to skip.
147    if aborted.is_empty()
148        && !records.iter().any(|r| {
149            r.control
150                || r.offset < fetch_offset
151                || (read_committed && r.offset >= last_stable_offset)
152        })
153    {
154        let next_offset = records.last().map_or(fetch_offset, |r| r.offset + 1);
155        return Fetched {
156            records,
157            next_offset,
158        };
159    }
160
161    let mut kept = Vec::with_capacity(records.len());
162    let mut next_offset = fetch_offset;
163
164    for record in records {
165        if read_committed && record.offset >= last_stable_offset {
166            // Rule 1. Everything after this is also above the LSO, so stop —
167            // and do not advance past it.
168            break;
169        }
170
171        // Progress is recorded for every record the broker sent, whether or not
172        // the caller gets to see it. See `Fetched::next_offset`.
173        next_offset = record.offset + 1;
174
175        // Rule 4, first half: a transaction becomes aborted at its first
176        // offset. Done before the `fetch_offset` skip below, so a transaction
177        // that began in an earlier batch is still known to be aborted.
178        while pending
179            .peek()
180            .is_some_and(|a| a.first_offset <= record.offset)
181        {
182            let a = pending.next().expect("peeked");
183            aborted_producers.insert(a.producer_id);
184        }
185
186        if record.control {
187            // Rule 4, second half: the abort marker closes the range, so a
188            // later transaction from the same producer is judged on its own.
189            if control_type(&record) == Some(CONTROL_ABORT) {
190                aborted_producers.remove(&record.producer_id);
191            }
192            // Rule 2.
193            continue;
194        }
195
196        if read_committed && record.transactional && aborted_producers.contains(&record.producer_id)
197        {
198            continue;
199        }
200
201        // Rule 3, applied last so the aborted-range bookkeeping above still
202        // sees every record the broker sent.
203        if record.offset < fetch_offset {
204            continue;
205        }
206
207        kept.push(record);
208    }
209
210    Fetched {
211        records: kept,
212        next_offset,
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use bytes::Bytes;
220    use kafka_protocol::records::TimestampType;
221
222    fn record(offset: i64, producer_id: i64, transactional: bool) -> Record {
223        Record {
224            transactional,
225            control: false,
226            partition_leader_epoch: 0,
227            producer_id,
228            producer_epoch: 0,
229            timestamp_type: TimestampType::Creation,
230            offset,
231            sequence: offset as i32,
232            timestamp: 0,
233            key: Some(Bytes::from(format!("k{offset}"))),
234            value: Some(Bytes::from(format!("v{offset}"))),
235            headers: Default::default(),
236        }
237    }
238
239    /// A marker as the broker writes it: control record whose key is
240    /// `[version:i16][type:i16]`.
241    fn marker(offset: i64, producer_id: i64, control_type: i16) -> Record {
242        let mut key = Vec::new();
243        key.extend_from_slice(&0i16.to_be_bytes());
244        key.extend_from_slice(&control_type.to_be_bytes());
245        Record {
246            transactional: true,
247            control: true,
248            partition_leader_epoch: 0,
249            producer_id,
250            producer_epoch: 0,
251            timestamp_type: TimestampType::Creation,
252            offset,
253            sequence: 0,
254            timestamp: 0,
255            key: Some(Bytes::from(key)),
256            value: None,
257            headers: Default::default(),
258        }
259    }
260
261    const ABORT: i16 = 0;
262    const COMMIT: i16 = 1;
263
264    fn offsets(f: &Fetched) -> Vec<i64> {
265        f.records.iter().map(|r| r.offset).collect()
266    }
267
268    /// The plain case: non-transactional data passes through untouched.
269    #[test]
270    fn plain_records_pass_through() {
271        let recs = vec![record(0, -1, false), record(1, -1, false)];
272        let out = filter(recs, &[], 2, IsolationLevel::ReadCommitted, 0);
273        assert_eq!(offsets(&out), vec![0, 1]);
274        assert_eq!(out.next_offset, 2);
275    }
276
277    /// **The bug P0 hit.** Aborted records must not reach the caller.
278    #[test]
279    fn aborted_records_are_dropped_under_read_committed() {
280        let recs = vec![record(0, 7, true), record(1, 7, true), marker(2, 7, ABORT)];
281        let aborted = [AbortedTransaction {
282            producer_id: 7,
283            first_offset: 0,
284        }];
285        let out = filter(recs, &aborted, 3, IsolationLevel::ReadCommitted, 0);
286        assert!(out.records.is_empty(), "aborted data reached the caller");
287    }
288
289    /// A fetch that is entirely aborted must still advance, or the consumer
290    /// re-requests the same offset forever.
291    #[test]
292    fn an_entirely_aborted_fetch_still_makes_progress() {
293        let recs = vec![record(10, 7, true), marker(11, 7, ABORT)];
294        let aborted = [AbortedTransaction {
295            producer_id: 7,
296            first_offset: 10,
297        }];
298        let out = filter(recs, &aborted, 12, IsolationLevel::ReadCommitted, 10);
299        assert!(out.records.is_empty());
300        assert_eq!(
301            out.next_offset, 12,
302            "a fully-filtered fetch must advance the position"
303        );
304    }
305
306    /// **The case a naive rule gets wrong**, and the reason the abort marker
307    /// closes the range: one producer, an aborted transaction followed by a
308    /// committed one, in a single fetch response.
309    #[test]
310    fn a_committed_transaction_after_an_aborted_one_survives() {
311        let recs = vec![
312            record(0, 7, true),  // aborted
313            record(1, 7, true),  // aborted
314            marker(2, 7, ABORT), // closes the aborted range
315            record(3, 7, true),  // committed
316            record(4, 7, true),  // committed
317            marker(5, 7, COMMIT),
318        ];
319        let aborted = [AbortedTransaction {
320            producer_id: 7,
321            first_offset: 0,
322        }];
323        let out = filter(recs, &aborted, 6, IsolationLevel::ReadCommitted, 0);
324        assert_eq!(
325            offsets(&out),
326            vec![3, 4],
327            "the committed transaction after an abort was discarded"
328        );
329        assert_eq!(out.next_offset, 6);
330    }
331
332    /// Two producers interleaved: only the aborted one's records go.
333    #[test]
334    fn only_the_aborted_producer_is_filtered() {
335        let recs = vec![
336            record(0, 7, true),
337            record(1, 8, true),
338            record(2, 7, true),
339            record(3, 8, true),
340            marker(4, 7, ABORT),
341            marker(5, 8, COMMIT),
342        ];
343        let aborted = [AbortedTransaction {
344            producer_id: 7,
345            first_offset: 0,
346        }];
347        let out = filter(recs, &aborted, 6, IsolationLevel::ReadCommitted, 0);
348        assert_eq!(offsets(&out), vec![1, 3]);
349    }
350
351    /// Rule 1: nothing at or past the last stable offset, and the position does
352    /// not advance past it either — those records are re-fetched once their
353    /// transaction resolves.
354    #[test]
355    fn records_at_or_past_the_lso_are_withheld() {
356        let recs = vec![record(0, -1, false), record(1, 9, true), record(2, 9, true)];
357        let out = filter(recs, &[], 1, IsolationLevel::ReadCommitted, 0);
358        assert_eq!(offsets(&out), vec![0]);
359        assert_eq!(
360            out.next_offset, 1,
361            "the position must not advance past the LSO"
362        );
363    }
364
365    /// Rule 2 holds under both isolation levels: markers are protocol
366    /// machinery, never data.
367    #[test]
368    fn control_records_are_never_returned() {
369        for isolation in [
370            IsolationLevel::ReadCommitted,
371            IsolationLevel::ReadUncommitted,
372        ] {
373            let recs = vec![record(0, 7, true), marker(1, 7, COMMIT)];
374            let out = filter(recs, &[], 2, isolation, 0);
375            assert_eq!(offsets(&out), vec![0], "isolation {isolation:?}");
376        }
377    }
378
379    /// READ_UNCOMMITTED means what it says: aborted records are visible, and
380    /// the LSO does not apply.
381    #[test]
382    fn read_uncommitted_sees_aborted_records() {
383        let recs = vec![record(0, 7, true), record(1, 7, true)];
384        let aborted = [AbortedTransaction {
385            producer_id: 7,
386            first_offset: 0,
387        }];
388        let out = filter(recs, &aborted, 0, IsolationLevel::ReadUncommitted, 0);
389        assert_eq!(offsets(&out), vec![0, 1]);
390    }
391
392    /// The aborted list arrives in whatever order the broker chose; the filter
393    /// sorts it rather than assuming.
394    #[test]
395    fn the_aborted_list_need_not_be_sorted() {
396        let recs = vec![
397            record(0, 7, true),
398            marker(1, 7, ABORT),
399            record(2, 8, true),
400            marker(3, 8, ABORT),
401        ];
402        let aborted = [
403            AbortedTransaction {
404                producer_id: 8,
405                first_offset: 2,
406            },
407            AbortedTransaction {
408                producer_id: 7,
409                first_offset: 0,
410            },
411        ];
412        let out = filter(recs, &aborted, 4, IsolationLevel::ReadCommitted, 0);
413        assert!(out.records.is_empty());
414    }
415
416    /// **The broker returns whole batches.** A fetch from the middle of one
417    /// comes back with the earlier records too, and returning them would
418    /// re-deliver data the caller has already seen.
419    #[test]
420    fn records_below_the_fetch_offset_are_dropped() {
421        let recs = vec![
422            record(0, -1, false),
423            record(1, -1, false),
424            record(2, -1, false),
425        ];
426        let out = filter(recs, &[], 3, IsolationLevel::ReadCommitted, 2);
427        assert_eq!(offsets(&out), vec![2]);
428        assert_eq!(out.next_offset, 3);
429    }
430
431    /// The skip must not lose the aborted-range bookkeeping: a transaction that
432    /// began before the fetch offset is still aborted after it.
433    #[test]
434    fn an_abort_beginning_before_the_fetch_offset_still_applies() {
435        let recs = vec![
436            record(0, 7, true),
437            record(1, 7, true),
438            record(2, 7, true),
439            marker(3, 7, ABORT),
440        ];
441        let aborted = [AbortedTransaction {
442            producer_id: 7,
443            first_offset: 0,
444        }];
445        let out = filter(recs, &aborted, 4, IsolationLevel::ReadCommitted, 2);
446        assert!(
447            out.records.is_empty(),
448            "an abort that began before the fetch offset was forgotten"
449        );
450    }
451
452    /// An empty fetch leaves the position where it was.
453    #[test]
454    fn an_empty_fetch_does_not_move_the_position() {
455        let out = filter(Vec::new(), &[], 5, IsolationLevel::ReadCommitted, 5);
456        assert!(out.records.is_empty());
457        assert_eq!(out.next_offset, 5);
458    }
459}