1use {
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
32pub 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
82pub 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 _ => {
108 encode_flat_message_set(records, target, &mut out);
109 }
110 }
111 Ok(out.freeze())
112}
113
114pub fn legacy_to_v2(set_bytes: &[u8]) -> Result<RecordBatch, LegacyRecordsError> {
126 legacy_to_v2_with_policy(set_bytes, RecordDecompressionPolicy::default())
127}
128
129pub 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, ×tamp)| 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 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 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 #[test]
417 fn down_convert_gzip_emits_compressed_wrapper() {
418 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}