1use krabka_protocol::records::{Record, RecordBatch};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum FilteredBatch {
22 Unchanged,
29 Filtered(RecordBatch),
31 Empty,
38}
39
40#[must_use]
57pub fn filter_batch<F>(batch: &RecordBatch, mut keep: F) -> FilteredBatch
58where
59 F: FnMut(&Record) -> bool,
60{
61 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 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 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 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 #[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}