Skip to main content

krabka_log/
filter.rs

1//! Record-level filtering of a [`RecordBatch`] that keeps Kafka's
2//! compacted-log offset shape.
3//!
4//! A caller that drops records from a batch must not renumber the survivors.
5//! Kafka's cleaner keeps `base_offset` and every surviving record's
6//! `offset_delta`, so an absolute offset never moves and a dropped record
7//! leaves a gap in the offset sequence. Consumers depend on that: an offset a
8//! client already holds must still name the same record after the rewrite, and
9//! the offset index maps offsets to file positions on the same assumption.
10//! Only `last_offset_delta` changes, to the highest delta that survives.
11//!
12//! [`filter_batch`] holds that arithmetic in one place. Log compaction drops
13//! records under the KIP-534 retain rules, and a point-in-time restore drops
14//! records under an operator's rule, but both owe the log the same offset
15//! shape.
16
17use krabka_protocol::records::{Record, RecordBatch};
18
19/// What [`filter_batch`] made of a batch.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum FilteredBatch {
22    /// No record was dropped, so the batch needs no rewrite.
23    ///
24    /// A caller that still holds the batch's original bytes can copy them
25    /// verbatim. That path skips the re-encode and keeps the original CRC,
26    /// which is the strongest evidence that the rewrite did not damage the
27    /// record data.
28    Unchanged,
29    /// At least one record was dropped. Carries the rewritten batch.
30    Filtered(RecordBatch),
31    /// The batch had records and every one of them was dropped.
32    ///
33    /// The caller decides what an emptied batch becomes. Compaction re-emits a
34    /// bare header when the batch is the last one of an active producer, so
35    /// the producer's sequence state survives; a restore may drop the batch
36    /// outright. That choice is policy, so this module does not make it.
37    Empty,
38}
39
40/// Drop the records of `batch` for which `keep` returns `false`.
41///
42/// `keep` runs exactly once per record, in batch order, so a caller may use it
43/// to collect per-record facts as a side effect.
44///
45/// The result keeps Kafka's compacted-log shape: `base_offset` never moves,
46/// each surviving record keeps its `offset_delta`, and `last_offset_delta`
47/// becomes the highest surviving delta. Every other header field carries over
48/// unchanged. That includes `max_timestamp`, which stays put even when the
49/// record that carried the newest timestamp is dropped, because the time index
50/// and the retention check read the header and must not see the batch move
51/// backwards in time.
52///
53/// A batch that holds no records is [`FilteredBatch::Unchanged`], not
54/// [`FilteredBatch::Empty`]: nothing was dropped from it. A bare header that an
55/// earlier rewrite left behind therefore survives this one intact.
56#[must_use]
57pub fn filter_batch<F>(batch: &RecordBatch, mut keep: F) -> FilteredBatch
58where
59    F: FnMut(&Record) -> bool,
60{
61    // Borrow the survivors on the first pass. The all-survive case is the
62    // common one for a restore, and it must not pay for a clone of the records
63    // it is about to discard.
64    let mut kept: Vec<&Record> = Vec::with_capacity(batch.records.len());
65    for record in &batch.records {
66        if keep(record) {
67            kept.push(record);
68        }
69    }
70
71    if kept.len() == batch.records.len() {
72        return FilteredBatch::Unchanged;
73    }
74    let Some(last_offset_delta) = kept.iter().map(|record| record.offset_delta).max() else {
75        return FilteredBatch::Empty;
76    };
77
78    FilteredBatch::Filtered(RecordBatch {
79        base_offset: batch.base_offset,
80        partition_leader_epoch: batch.partition_leader_epoch,
81        attributes: batch.attributes,
82        last_offset_delta,
83        base_timestamp: batch.base_timestamp,
84        max_timestamp: batch.max_timestamp,
85        producer_id: batch.producer_id,
86        producer_epoch: batch.producer_epoch,
87        base_sequence: batch.base_sequence,
88        records: kept.into_iter().cloned().collect(),
89    })
90}
91
92#[cfg(test)]
93mod tests {
94    use {
95        super::*,
96        assert2::{assert, check},
97        bytes::{Bytes, BytesMut},
98        krabka_protocol::records::{Attributes, RecordHeader},
99        proptest::{bool::ANY, collection},
100    };
101
102    const BASE_OFFSET: i64 = 1_000;
103    const BASE_TIMESTAMP: i64 = 1_700_000_000_000;
104
105    fn record(offset_delta: i32) -> Record {
106        Record {
107            attributes: 0,
108            timestamp_delta: i64::from(offset_delta) * 10,
109            offset_delta,
110            key: Some(Bytes::from(format!("k{offset_delta}"))),
111            value: Some(Bytes::from(format!("v{offset_delta}"))),
112            headers: vec![RecordHeader {
113                key: "trace".to_owned(),
114                value: Some(Bytes::from_static(b"h")),
115            }],
116        }
117    }
118
119    // Every header field holds a distinctive value, so a whole-struct
120    // comparison catches any field the filter fails to carry over.
121    fn batch(offset_deltas: &[i32]) -> RecordBatch {
122        RecordBatch {
123            base_offset: BASE_OFFSET,
124            partition_leader_epoch: 7,
125            attributes: Attributes::default().with_transactional(true),
126            last_offset_delta: offset_deltas.iter().copied().max().unwrap_or(0),
127            base_timestamp: BASE_TIMESTAMP,
128            max_timestamp: BASE_TIMESTAMP + 30,
129            producer_id: 4_242,
130            producer_epoch: 3,
131            base_sequence: 11,
132            records: offset_deltas.iter().copied().map(record).collect(),
133        }
134    }
135
136    // The batch `filter_batch` must produce, with `last_offset_delta` stated
137    // outright rather than derived by the rule under test.
138    fn expected(input: &[i32], survivors: &[i32], last_offset_delta: i32) -> RecordBatch {
139        RecordBatch {
140            last_offset_delta,
141            records: survivors.iter().copied().map(record).collect(),
142            ..batch(input)
143        }
144    }
145
146    enum Outcome {
147        Unchanged,
148        Empty,
149        // The surviving offset deltas, then the recomputed `last_offset_delta`.
150        Filtered(&'static [i32], i32),
151    }
152
153    struct Case {
154        name: &'static str,
155        offset_deltas: &'static [i32],
156        dropped: &'static [i32],
157        outcome: Outcome,
158    }
159
160    #[test]
161    fn filter_batch_keeps_the_compacted_log_shape() {
162        let cases = [
163            Case {
164                name: "every record survives",
165                offset_deltas: &[0, 1, 2, 3],
166                dropped: &[],
167                outcome: Outcome::Unchanged,
168            },
169            Case {
170                name: "a middle record is dropped",
171                offset_deltas: &[0, 1, 2, 3],
172                dropped: &[1],
173                outcome: Outcome::Filtered(&[0, 2, 3], 3),
174            },
175            Case {
176                name: "the first record is dropped",
177                offset_deltas: &[0, 1, 2, 3],
178                dropped: &[0],
179                outcome: Outcome::Filtered(&[1, 2, 3], 3),
180            },
181            Case {
182                name: "the last record is dropped",
183                offset_deltas: &[0, 1, 2, 3],
184                dropped: &[3],
185                outcome: Outcome::Filtered(&[0, 1, 2], 2),
186            },
187            Case {
188                name: "the first and last records are dropped",
189                offset_deltas: &[0, 1, 2, 3],
190                dropped: &[0, 3],
191                outcome: Outcome::Filtered(&[1, 2], 2),
192            },
193            Case {
194                name: "every record is dropped",
195                offset_deltas: &[0, 1, 2, 3],
196                dropped: &[0, 1, 2, 3],
197                outcome: Outcome::Empty,
198            },
199            Case {
200                name: "a single-record batch survives",
201                offset_deltas: &[0],
202                dropped: &[],
203                outcome: Outcome::Unchanged,
204            },
205            Case {
206                name: "a single-record batch is emptied",
207                offset_deltas: &[0],
208                dropped: &[0],
209                outcome: Outcome::Empty,
210            },
211            Case {
212                name: "an already-compacted batch survives",
213                offset_deltas: &[0, 3, 7],
214                dropped: &[],
215                outcome: Outcome::Unchanged,
216            },
217            Case {
218                name: "an already-compacted batch loses its first record",
219                offset_deltas: &[0, 3, 7],
220                dropped: &[0],
221                outcome: Outcome::Filtered(&[3, 7], 7),
222            },
223            Case {
224                name: "an already-compacted batch loses its middle record",
225                offset_deltas: &[0, 3, 7],
226                dropped: &[3],
227                outcome: Outcome::Filtered(&[0, 7], 7),
228            },
229            Case {
230                name: "an already-compacted batch loses its last record",
231                offset_deltas: &[0, 3, 7],
232                dropped: &[7],
233                outcome: Outcome::Filtered(&[0, 3], 3),
234            },
235            Case {
236                name: "a batch without records has nothing to drop",
237                offset_deltas: &[],
238                dropped: &[],
239                outcome: Outcome::Unchanged,
240            },
241        ];
242
243        for case in cases {
244            let input = batch(case.offset_deltas);
245            let dropped = case.dropped;
246            let got = filter_batch(&input, |record| !dropped.contains(&record.offset_delta));
247            let want = match case.outcome {
248                Outcome::Unchanged => FilteredBatch::Unchanged,
249                Outcome::Empty => FilteredBatch::Empty,
250                Outcome::Filtered(survivors, last_offset_delta) => FilteredBatch::Filtered(
251                    expected(case.offset_deltas, survivors, last_offset_delta),
252                ),
253            };
254            check!(got == want, "case: {}", case.name);
255        }
256    }
257
258    #[test]
259    fn dropping_the_first_record_leaves_absolute_offsets_alone() {
260        let input = batch(&[0, 1, 2]);
261        let original: Vec<i64> = input
262            .records
263            .iter()
264            .map(|record| input.base_offset + i64::from(record.offset_delta))
265            .collect();
266
267        let got = filter_batch(&input, |record| record.offset_delta != 0);
268
269        let FilteredBatch::Filtered(out) = got else {
270            panic!("dropping a record must produce a filtered batch");
271        };
272        check!(out.base_offset == BASE_OFFSET);
273        let survivors: Vec<i64> = out
274            .records
275            .iter()
276            .map(|record| out.base_offset + i64::from(record.offset_delta))
277            .collect();
278        check!(survivors == original[1..].to_vec());
279    }
280
281    #[test]
282    fn keep_sees_every_record_once_in_batch_order() {
283        let input = batch(&[0, 3, 7]);
284        let mut seen = Vec::new();
285
286        let got = filter_batch(&input, |record| {
287            seen.push(record.offset_delta);
288            true
289        });
290
291        check!(seen == [0, 3, 7]);
292        check!(got == FilteredBatch::Unchanged);
293    }
294
295    #[test]
296    fn filtered_batches_round_trip_through_the_wire() {
297        struct Case {
298            dropped: &'static [i32],
299            survivors: &'static [i32],
300            last_offset_delta: i32,
301        }
302        let offset_deltas: &[i32] = &[0, 1, 2, 3];
303        let cases = [
304            Case {
305                dropped: &[1],
306                survivors: &[0, 2, 3],
307                last_offset_delta: 3,
308            },
309            Case {
310                dropped: &[3],
311                survivors: &[0, 1, 2],
312                last_offset_delta: 2,
313            },
314            Case {
315                dropped: &[0],
316                survivors: &[1, 2, 3],
317                last_offset_delta: 3,
318            },
319        ];
320
321        for Case {
322            dropped,
323            survivors,
324            last_offset_delta,
325        } in cases
326        {
327            let input = batch(offset_deltas);
328            let got = filter_batch(&input, |record| !dropped.contains(&record.offset_delta));
329
330            let FilteredBatch::Filtered(out) = got else {
331                panic!("dropping {dropped:?} must produce a filtered batch");
332            };
333            let mut buf = BytesMut::with_capacity(out.encoded_len());
334            out.encode(&mut buf).unwrap();
335            let mut cursor: &[u8] = &buf[..];
336            let decoded = RecordBatch::decode(&mut cursor).unwrap();
337
338            assert!(cursor.is_empty());
339            check!(decoded == expected(offset_deltas, survivors, last_offset_delta));
340        }
341    }
342
343    proptest::proptest! {
344        // Arbitrary batch shapes and arbitrary keep decisions: the surviving
345        // records keep their absolute offsets, and `last_offset_delta` follows
346        // the survivors and nothing else.
347        #[test]
348        fn survivors_keep_their_absolute_offsets(
349            offset_deltas in collection::hash_set(0i32..64, 1..12),
350            keep_mask in collection::vec(ANY, 12),
351        ) {
352            let mut offset_deltas: Vec<i32> = offset_deltas.into_iter().collect();
353            offset_deltas.sort_unstable();
354            let input = batch(&offset_deltas);
355
356            let mut next = 0usize;
357            let got = filter_batch(&input, |_| {
358                let keep = keep_mask[next];
359                next += 1;
360                keep
361            });
362
363            let survivors: Vec<i32> = offset_deltas
364                .iter()
365                .copied()
366                .zip(&keep_mask)
367                .filter(|(_, keep)| **keep)
368                .map(|(offset_delta, _)| offset_delta)
369                .collect();
370            match got {
371                FilteredBatch::Unchanged => proptest::prop_assert_eq!(&survivors, &offset_deltas),
372                FilteredBatch::Empty => proptest::prop_assert!(survivors.is_empty()),
373                FilteredBatch::Filtered(out) => {
374                    proptest::prop_assert_eq!(out.base_offset, input.base_offset);
375                    let kept: Vec<i32> =
376                        out.records.iter().map(|record| record.offset_delta).collect();
377                    proptest::prop_assert_eq!(&kept, &survivors);
378                    proptest::prop_assert_eq!(
379                        out.last_offset_delta,
380                        survivors.iter().copied().max().unwrap_or_default()
381                    );
382                }
383            }
384        }
385    }
386}