Skip to main content

krabka_records_legacy/
bridge.rs

1//! Convert between v2 `RecordBatch` and v0/v1 `MessageSet` payloads.
2//!
3//! The v2 types come from `krabka-protocol`.
4//!
5//! Down-conversion goes from v2 to v0/v1. It builds a [`ParsedRecord`]
6//! stream from a v2 batch and decompresses the v2 body one time. It then
7//! re-emits that stream as a flat or compressed `MessageSet`.
8//!
9//! Up-conversion goes from v0/v1 to v2. It decodes a `MessageSet` into
10//! [`ParsedRecord`] values. It then builds a v2 `RecordBatch` with one
11//! record per legacy message.
12//!
13//! This module filters out control records, that is, v2 batches with
14//! `is_control_batch == true`. v0/v1 has no concept of them, and Kafka's
15//! reference broker drops them on down-conversion.
16
17use {
18    crate::{
19        error::LegacyRecordsError,
20        message::Magic,
21        set::{
22            ParsedRecord, decode_message_set_with_policy, encode_compressed_message_set,
23            encode_flat_message_set,
24        },
25    },
26    bytes::{Bytes, BytesMut},
27    krabka_compression::{CompressionType, RecordDecompressionPolicy},
28    krabka_ids::Offset,
29    krabka_protocol::records::{Attributes, Record, RecordBatch, RecordsError},
30};
31
32/// Iterate the v2 batch's records and drop control batches entirely.
33///
34/// Each emitted [`ParsedRecord`] gets its absolute offset as
35/// `base_offset + offset_delta`. For a v1 target, each record also gets
36/// its absolute timestamp as `base_timestamp + timestamp_delta`.
37///
38/// # Errors
39/// Returns an error if an absolute offset or timestamp exceeds `i64`.
40pub fn parsed_from_v2(
41    batch: &RecordBatch,
42    target: Magic,
43) -> Result<Vec<ParsedRecord>, LegacyRecordsError> {
44    if batch.attributes.is_control_batch() {
45        return Ok(Vec::new());
46    }
47    batch
48        .records
49        .iter()
50        .map(|r| {
51            Ok(ParsedRecord {
52                offset: Offset(
53                    batch
54                        .base_offset
55                        .checked_add(i64::from(r.offset_delta))
56                        .ok_or_else(|| {
57                            LegacyRecordsError::Malformed(
58                                "absolute record offset exceeds i64".into(),
59                            )
60                        })?,
61                ),
62                timestamp: match target {
63                    Magic::V0 => None,
64                    Magic::V1 => Some(
65                        batch
66                            .base_timestamp
67                            .checked_add(r.timestamp_delta)
68                            .ok_or_else(|| {
69                                LegacyRecordsError::Malformed(
70                                    "absolute record timestamp exceeds i64".into(),
71                                )
72                            })?,
73                    ),
74                },
75                key: r.key.clone(),
76                value: r.value.clone(),
77            })
78        })
79        .collect()
80}
81
82/// Down-convert a v2 [`RecordBatch`] to v0/v1 `MessageSet` bytes.
83///
84/// If the v2 batch carried gzip or snappy compression, the output uses
85/// that codec in a wrapped `MessageSet`. The output keeps LZ4 in the same
86/// way. v0/v1 cannot represent `Zstd`, so this function emits an
87/// uncompressed `MessageSet` for a `Zstd` batch. The v2 decode path has
88/// already decompressed those records.
89///
90/// # Errors
91/// Returns an error if an absolute offset or timestamp exceeds `i64`, or if
92/// this function cannot encode the legacy message set.
93pub fn v2_to_legacy(batch: &RecordBatch, target: Magic) -> Result<Bytes, LegacyRecordsError> {
94    let records = parsed_from_v2(batch, target)?;
95    let mut out = BytesMut::new();
96    if records.is_empty() {
97        return Ok(out.freeze());
98    }
99    let codec = batch.attributes.compression();
100    match codec {
101        CompressionType::Gzip | CompressionType::Snappy | CompressionType::Lz4 => {
102            encode_compressed_message_set(&records, target, codec, &mut out)?;
103        }
104        // None, Zstd, or any future variant: emit uncompressed. Zstd can't
105        // be represented in v0/v1; the v2 decode path already produced
106        // decompressed records for us, so emitting flat is correct.
107        _ => {
108            encode_flat_message_set(records, target, &mut out);
109        }
110    }
111    Ok(out.freeze())
112}
113
114/// Up-convert a v0/v1 `MessageSet` to a v2 [`RecordBatch`].
115///
116/// The result is suitable for the log write path. This function sets
117/// `partition_leader_epoch` to `-1`, which is Kafka's sentinel for
118/// "unknown". The caller, usually the Produce handler, should overwrite
119/// it with the current leader epoch before append.
120///
121/// # Errors
122/// Returns an error if this function cannot decode the legacy message set.
123/// Returns an error if offsets exceed the v2 delta fields or timestamp
124/// differences exceed `i64`.
125pub fn legacy_to_v2(set_bytes: &[u8]) -> Result<RecordBatch, LegacyRecordsError> {
126    legacy_to_v2_with_policy(set_bytes, RecordDecompressionPolicy::default())
127}
128
129/// Up-convert a legacy `MessageSet` using explicit decompression limits.
130///
131/// # Errors
132///
133/// Returns an error if this function cannot decode the legacy message set
134/// within the limits in `policy`. Returns an error if the offsets overflow
135/// the v2 delta fields or timestamp differences exceed `i64`.
136pub fn legacy_to_v2_with_policy(
137    set_bytes: &[u8],
138    policy: RecordDecompressionPolicy,
139) -> Result<RecordBatch, LegacyRecordsError> {
140    let mut cur = set_bytes;
141    let records = decode_message_set_with_policy(&mut cur, set_bytes.len(), policy)?;
142    if records.is_empty() {
143        return Ok(RecordBatch {
144            partition_leader_epoch: -1,
145            ..RecordBatch::default()
146        });
147    }
148    let base_offset = records
149        .first()
150        .ok_or_else(|| LegacyRecordsError::Malformed("legacy record set is empty".into()))?
151        .offset;
152    let base_timestamp = records
153        .iter()
154        .filter_map(|r| r.timestamp)
155        .min()
156        .unwrap_or(-1);
157    let max_timestamp = records
158        .iter()
159        .filter_map(|r| r.timestamp)
160        .max()
161        .unwrap_or(-1);
162    let last_offset = records
163        .last()
164        .ok_or_else(|| LegacyRecordsError::Malformed("legacy record set is empty".into()))?
165        .offset;
166    let last_offset_delta = i32::try_from(i128::from(last_offset.0) - i128::from(base_offset.0))
167        .map_err(|_| LegacyRecordsError::Malformed("last offset delta exceeds i32".into()))?;
168
169    let out_records: Vec<Record> = records
170        .iter()
171        .map(|r| {
172            Ok(Record {
173                attributes: 0,
174                timestamp_delta: r.timestamp.map_or(Ok(0), |ts| {
175                    ts.checked_sub(base_timestamp).ok_or_else(|| {
176                        LegacyRecordsError::Malformed("record timestamp delta exceeds i64".into())
177                    })
178                })?,
179                offset_delta: i32::try_from(i128::from(r.offset.0) - i128::from(base_offset.0))
180                    .map_err(|_| {
181                        LegacyRecordsError::Malformed("record offset delta exceeds i32".into())
182                    })?,
183                key: r.key.clone(),
184                value: r.value.clone(),
185                headers: Vec::new(),
186            })
187        })
188        .collect::<Result<_, LegacyRecordsError>>()?;
189
190    Ok(RecordBatch {
191        base_offset: base_offset.0,
192        partition_leader_epoch: -1,
193        attributes: Attributes::default(),
194        last_offset_delta,
195        base_timestamp,
196        max_timestamp,
197        producer_id: -1,
198        producer_epoch: -1,
199        base_sequence: -1,
200        records: out_records,
201    })
202}
203
204impl From<RecordsError> for LegacyRecordsError {
205    fn from(e: RecordsError) -> Self { Self::Malformed(format!("v2 records error: {e}")) }
206}
207
208#[cfg(test)]
209mod tests {
210
211    use {
212        super::*,
213        crate::{decode_message_set, message::Message, set::test_support::sample_records_v1_at},
214        bytes::Bytes,
215        krabka_compression::{CompressionError, RecordDecompressionPolicy},
216        krabka_protocol::records::{Record, RecordBatch, RecordHeader},
217        krabka_units::{bytes, fraction},
218    };
219
220    fn v2_batch(codec: CompressionType) -> RecordBatch {
221        RecordBatch {
222            base_offset: 1000,
223            partition_leader_epoch: 5,
224            attributes: Attributes::default().with_compression(codec),
225            last_offset_delta: 2,
226            base_timestamp: 1_700_000_000,
227            max_timestamp: 1_700_000_500,
228            producer_id: -1,
229            producer_epoch: -1,
230            base_sequence: -1,
231            records: vec![
232                Record {
233                    attributes: 0,
234                    offset_delta: 0,
235                    timestamp_delta: 0,
236                    key: Some(Bytes::from_static(b"a")),
237                    value: Some(Bytes::from_static(b"1")),
238                    headers: vec![],
239                },
240                Record {
241                    attributes: 0,
242                    offset_delta: 1,
243                    timestamp_delta: 100,
244                    key: Some(Bytes::from_static(b"b")),
245                    value: Some(Bytes::from_static(b"2")),
246                    headers: vec![],
247                },
248                Record {
249                    attributes: 0,
250                    offset_delta: 2,
251                    timestamp_delta: 500,
252                    key: None,
253                    value: Some(Bytes::from_static(b"3")),
254                    headers: vec![],
255                },
256            ],
257        }
258    }
259
260    fn flat_v1(offsets: &[i64], timestamps: &[i64]) -> BytesMut {
261        let records = offsets
262            .iter()
263            .zip(timestamps)
264            .map(|(&offset, &timestamp)| ParsedRecord {
265                offset: Offset(offset),
266                timestamp: Some(timestamp),
267                key: None,
268                value: None,
269            });
270        let mut wire = BytesMut::new();
271        encode_flat_message_set(records, Magic::V1, &mut wire);
272        wire
273    }
274
275    #[yare::parameterized(
276        descending_extremes = { &[i64::MIN, i64::MAX][..], &[0, 0][..] },
277        descending_max_to_min = { &[i64::MAX, i64::MIN][..], &[0, 0][..] },
278        offset_delta_overflow = { &[0, i64::MAX, 1][..], &[0, 0, 0][..] },
279        timestamp_delta_overflow = { &[0, 1][..], &[i64::MIN, i64::MAX][..] },
280    )]
281    fn up_conversion_rejects_extreme_offset_and_timestamp_deltas(
282        offsets: &[i64],
283        timestamps: &[i64],
284    ) {
285        let wire = flat_v1(offsets, timestamps);
286        assert2::assert!(matches!(
287            legacy_to_v2(&wire),
288            Err(LegacyRecordsError::Malformed(_))
289        ));
290    }
291
292    #[yare::parameterized(
293        minimum = { i64::MIN },
294        maximum = { i64::MAX - 1 },
295    )]
296    fn conversion_preserves_representable_extreme_coordinates(base: i64) {
297        let wire = flat_v1(&[base, base + 1], &[base, base + 1]);
298        let batch = legacy_to_v2(&wire).unwrap();
299        assert2::assert!(batch.base_offset == base && batch.last_offset_delta == 1);
300        assert2::assert!(batch.base_timestamp == base && batch.max_timestamp == base + 1);
301        assert2::assert!(
302            batch
303                .records
304                .iter()
305                .map(|r| (r.offset_delta, r.timestamp_delta))
306                .collect::<Vec<_>>()
307                == vec![(0, 0), (1, 1)]
308        );
309        assert2::assert!(v2_to_legacy(&batch, Magic::V1).unwrap() == wire);
310    }
311
312    #[yare::parameterized(
313        positive_overflow = { i64::MAX, 1 },
314        negative_overflow = { i64::MIN, -1 },
315    )]
316    fn down_conversion_rejects_absolute_coordinate_overflow(base: i64, delta: i32) {
317        let mut batch = v2_batch(CompressionType::None);
318        batch.base_offset = base;
319        batch.records[0].offset_delta = delta;
320        assert2::assert!(matches!(
321            v2_to_legacy(&batch, Magic::V0),
322            Err(LegacyRecordsError::Malformed(_))
323        ));
324        batch.base_offset = 0;
325        batch.records[0].offset_delta = 0;
326        batch.base_timestamp = base;
327        batch.records[0].timestamp_delta = i64::from(delta);
328        assert2::assert!(matches!(
329            v2_to_legacy(&batch, Magic::V1),
330            Err(LegacyRecordsError::Malformed(_))
331        ));
332        // v0 does not encode timestamps and therefore needs no timestamp arithmetic.
333        assert2::assert!(v2_to_legacy(&batch, Magic::V0).is_ok());
334    }
335
336    #[yare::parameterized(
337        v1_uncompressed = { Magic::V1, CompressionType::None },
338        v0_uncompressed = { Magic::V0, CompressionType::None },
339        v1_gzip = { Magic::V1, CompressionType::Gzip },
340        v1_snappy = { Magic::V1, CompressionType::Snappy },
341    )]
342    fn down_then_up_round_trips_complete_batches(magic: Magic, codec: CompressionType) {
343        let v2 = v2_batch(codec);
344        let legacy_bytes = v2_to_legacy(&v2, magic).unwrap();
345        let round = legacy_to_v2(&legacy_bytes).unwrap();
346
347        let mut expected = v2;
348        expected.partition_leader_epoch = -1;
349        expected.attributes = Attributes::default();
350        if magic == Magic::V0 {
351            expected.base_timestamp = -1;
352            expected.max_timestamp = -1;
353            for record in &mut expected.records {
354                record.timestamp_delta = 0;
355            }
356        }
357        assert2::assert!(round == expected);
358    }
359
360    krabka_macros::legacy_policy_fixture!(policy_wire, crate);
361
362    #[test]
363    fn decompression_policy_limits_legacy_upconversion() {
364        let wire = policy_wire();
365
366        legacy_to_v2(&wire).unwrap();
367
368        let policy = RecordDecompressionPolicy::new(fraction(1.0), bytes(1), bytes(32)).unwrap();
369        assert2::assert!(matches!(
370            legacy_to_v2_with_policy(&wire, policy),
371            Err(LegacyRecordsError::Compression(
372                CompressionError::TooLarge { limit: 32 }
373            ))
374        ));
375    }
376
377    #[test]
378    fn control_batch_filtered() {
379        let mut v2 = v2_batch(CompressionType::None);
380        v2.attributes = v2.attributes.with_control(true);
381        let legacy_bytes = v2_to_legacy(&v2, Magic::V1).unwrap();
382        assert2::assert!(legacy_bytes.is_empty());
383    }
384
385    #[test]
386    fn empty_records_no_output() {
387        let mut v2 = v2_batch(CompressionType::None);
388        v2.records.clear();
389        v2.last_offset_delta = 0;
390        let legacy_bytes = v2_to_legacy(&v2, Magic::V1).unwrap();
391        assert2::assert!(legacy_bytes.is_empty());
392    }
393
394    #[test]
395    fn headers_are_dropped_on_down_conversion() {
396        let mut v2 = v2_batch(CompressionType::None);
397        v2.records[0].headers = vec![RecordHeader {
398            key: "x".to_owned(),
399            value: Some(Bytes::from_static(b"y")),
400        }];
401        let legacy_bytes = v2_to_legacy(&v2, Magic::V1).unwrap();
402        let mut cur: &[u8] = &legacy_bytes;
403        let recs = decode_message_set(&mut cur, legacy_bytes.len()).unwrap();
404        // No structural representation of headers in v0/v1.
405        assert2::assert!(
406            recs == sample_records_v1_at(1000, [1_700_000_000, 1_700_000_100, 1_700_000_500])
407        );
408    }
409
410    // --- mutation-coverage tests --------------------------------------------
411    //
412    // The round-trips above unwrap compression transparently and never assert
413    // the v2 batch's sentinel/offset fields, so they pass through a flat-vs-
414    // compressed swap and through sentinel/arithmetic flips. These pin them.
415
416    #[test]
417    fn down_convert_gzip_emits_compressed_wrapper() {
418        // A gzip v2 batch must down-convert to a COMPRESSED wrapper, not a flat
419        // (uncompressed) set: deleting the Gzip|Snappy|Lz4 arm would fall
420        // through to the flat encoder, which still round-trips but is wrong.
421        use bytes::Buf;
422        let bytes = v2_to_legacy(&v2_batch(CompressionType::Gzip), Magic::V1).unwrap();
423        let mut cur: &[u8] = &bytes;
424        let _offset = cur.get_i64();
425        let size = usize::try_from(cur.get_i32()).unwrap();
426        let msg = Message::decode_from(&mut cur, size).unwrap();
427        assert2::assert!(msg.compression() == CompressionType::Gzip);
428    }
429
430    #[test]
431    fn up_convert_empty_set_returns_sentinels() {
432        let rb = legacy_to_v2(&[]).unwrap();
433        assert2::assert!(
434            rb == RecordBatch {
435                base_offset: 0,
436                partition_leader_epoch: -1,
437                attributes: Attributes::default(),
438                last_offset_delta: 0,
439                base_timestamp: 0,
440                max_timestamp: 0,
441                producer_id: -1,
442                producer_epoch: -1,
443                base_sequence: -1,
444                records: Vec::new(),
445            }
446        );
447    }
448}