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}