1use krabka_compression::CompressionType;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TimestampType {
9 CreateTime,
10 LogAppendTime,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub struct Attributes(pub i16);
24
25impl Attributes {
26 pub const TIMESTAMP_TYPE_BIT: i16 = 1 << 3;
27 pub const TRANSACTIONAL_BIT: i16 = 1 << 4;
28 pub const CONTROL_BIT: i16 = 1 << 5;
29 pub const DELETE_HORIZON_BIT: i16 = 1 << 6; #[must_use]
32 pub fn compression(self) -> CompressionType {
33 let byte = (self.0 & 0x07).to_le_bytes()[0];
35 CompressionType::from_attribute_bits(byte).unwrap_or(CompressionType::None)
36 }
37
38 #[must_use]
39 pub fn timestamp_type(self) -> TimestampType {
40 if self.0 & Self::TIMESTAMP_TYPE_BIT != 0 {
41 TimestampType::LogAppendTime
42 } else {
43 TimestampType::CreateTime
44 }
45 }
46
47 #[must_use]
48 pub fn is_transactional(self) -> bool {
49 self.0 & Self::TRANSACTIONAL_BIT != 0
50 }
51
52 #[must_use]
53 pub fn is_control_batch(self) -> bool {
54 self.0 & Self::CONTROL_BIT != 0
55 }
56
57 #[must_use]
58 pub fn has_delete_horizon(self) -> bool {
59 self.0 & Self::DELETE_HORIZON_BIT != 0
60 }
61
62 #[must_use]
63 pub fn with_compression(self, c: CompressionType) -> Self {
64 let cleared = self.0 & !0x07;
65 Self(cleared | i16::from(c.as_attribute_bits()))
66 }
67
68 #[must_use]
69 pub fn with_timestamp_type(self, t: TimestampType) -> Self {
70 match t {
71 TimestampType::CreateTime => Self(self.0 & !Self::TIMESTAMP_TYPE_BIT),
72 TimestampType::LogAppendTime => Self(self.0 | Self::TIMESTAMP_TYPE_BIT),
73 }
74 }
75
76 #[must_use]
77 pub fn with_transactional(self, b: bool) -> Self {
78 if b {
79 Self(self.0 | Self::TRANSACTIONAL_BIT)
80 } else {
81 Self(self.0 & !Self::TRANSACTIONAL_BIT)
82 }
83 }
84
85 #[must_use]
86 pub fn with_control(self, b: bool) -> Self {
87 if b {
88 Self(self.0 | Self::CONTROL_BIT)
89 } else {
90 Self(self.0 & !Self::CONTROL_BIT)
91 }
92 }
93
94 #[must_use]
95 pub fn with_delete_horizon(self, set: bool) -> Self {
96 if set {
97 Self(self.0 | Self::DELETE_HORIZON_BIT)
98 } else {
99 Self(self.0 & !Self::DELETE_HORIZON_BIT)
100 }
101 }
102}
103
104use std::mem::size_of;
105
106use zerocopy::{
107 BigEndian, FromBytes, Immutable, KnownLayout, Unaligned,
108 byteorder::{I16, I32, I64, U32},
109};
110
111#[derive(Debug, Clone, Copy, FromBytes, KnownLayout, Immutable, Unaligned)]
114#[repr(C)]
115pub struct RecordBatchHeader {
116 pub base_offset: I64<BigEndian>,
117 pub batch_length: I32<BigEndian>,
118 pub partition_leader_epoch: I32<BigEndian>,
119 pub magic: i8,
120 pub crc: U32<BigEndian>,
121 pub attributes: I16<BigEndian>,
122 pub last_offset_delta: I32<BigEndian>,
123 pub base_timestamp: I64<BigEndian>,
124 pub max_timestamp: I64<BigEndian>,
125 pub producer_id: I64<BigEndian>,
126 pub producer_epoch: I16<BigEndian>,
127 pub base_sequence: I32<BigEndian>,
128 pub records_count: I32<BigEndian>,
129}
130
131pub const HEADER_LEN: usize = 61;
133
134const _: [(); HEADER_LEN] = [(); size_of::<RecordBatchHeader>()];
136
137pub const BASE_OFFSET_RANGE: std::ops::Range<usize> = 0..8;
140
141pub const LEADER_EPOCH_RANGE: std::ops::Range<usize> = 12..16;
144
145pub const CRC_COVERAGE_START: usize = 21;
151
152pub fn patch_base_offset_and_leader_epoch(buf: &mut [u8], base_offset: i64, leader_epoch: i32) {
168 assert2::assert!(buf.len() >= HEADER_LEN);
169 buf[BASE_OFFSET_RANGE].copy_from_slice(&base_offset.to_be_bytes());
170 buf[LEADER_EPOCH_RANGE].copy_from_slice(&leader_epoch.to_be_bytes());
171}
172
173#[cfg(test)]
174mod tests {
175 use assert2::check;
176 use krabka_compression::CompressionType;
177
178 use super::*;
179
180 struct AttributeCase {
181 bits: i16,
182 compression: CompressionType,
183 timestamp_type: TimestampType,
184 transactional: bool,
185 control_batch: bool,
186 delete_horizon: bool,
187 }
188
189 fn assert_attribute_case(case: &AttributeCase) {
190 let attributes = Attributes(case.bits);
191 assert2::assert!(attributes.compression() == case.compression);
192 assert2::assert!(attributes.timestamp_type() == case.timestamp_type);
193 assert2::assert!(attributes.is_transactional() == case.transactional);
194 assert2::assert!(attributes.is_control_batch() == case.control_batch);
195 assert2::assert!(attributes.has_delete_horizon() == case.delete_horizon);
196 }
197
198 #[test]
199 fn attribute_compression_cases() {
200 for case in [
201 AttributeCase {
202 bits: 0,
203 compression: CompressionType::None,
204 timestamp_type: TimestampType::CreateTime,
205 transactional: false,
206 control_batch: false,
207 delete_horizon: false,
208 },
209 AttributeCase {
210 bits: 0x01,
211 compression: CompressionType::Gzip,
212 timestamp_type: TimestampType::CreateTime,
213 transactional: false,
214 control_batch: false,
215 delete_horizon: false,
216 },
217 AttributeCase {
218 bits: 0x02,
219 compression: CompressionType::Snappy,
220 timestamp_type: TimestampType::CreateTime,
221 transactional: false,
222 control_batch: false,
223 delete_horizon: false,
224 },
225 AttributeCase {
226 bits: 0x03,
227 compression: CompressionType::Lz4,
228 timestamp_type: TimestampType::CreateTime,
229 transactional: false,
230 control_batch: false,
231 delete_horizon: false,
232 },
233 AttributeCase {
234 bits: 0x04,
235 compression: CompressionType::Zstd,
236 timestamp_type: TimestampType::CreateTime,
237 transactional: false,
238 control_batch: false,
239 delete_horizon: false,
240 },
241 ] {
242 assert_attribute_case(&case);
243 }
244 }
245
246 #[test]
247 fn attribute_flag_cases() {
248 for case in [
249 AttributeCase {
250 bits: 0x08,
251 compression: CompressionType::None,
252 timestamp_type: TimestampType::LogAppendTime,
253 transactional: false,
254 control_batch: false,
255 delete_horizon: false,
256 },
257 AttributeCase {
258 bits: 0x10,
259 compression: CompressionType::None,
260 timestamp_type: TimestampType::CreateTime,
261 transactional: true,
262 control_batch: false,
263 delete_horizon: false,
264 },
265 AttributeCase {
266 bits: 0x20,
267 compression: CompressionType::None,
268 timestamp_type: TimestampType::CreateTime,
269 transactional: false,
270 control_batch: true,
271 delete_horizon: false,
272 },
273 AttributeCase {
274 bits: 0x40,
275 compression: CompressionType::None,
276 timestamp_type: TimestampType::CreateTime,
277 transactional: false,
278 control_batch: false,
279 delete_horizon: true,
280 },
281 AttributeCase {
282 bits: 0x7c,
283 compression: CompressionType::Zstd,
284 timestamp_type: TimestampType::LogAppendTime,
285 transactional: true,
286 control_batch: true,
287 delete_horizon: true,
288 },
289 ] {
290 assert_attribute_case(&case);
291 }
292 }
293
294 #[test]
295 fn builder_round_trip() {
296 let a = Attributes::default()
297 .with_compression(CompressionType::Snappy)
298 .with_timestamp_type(TimestampType::LogAppendTime)
299 .with_transactional(true)
300 .with_control(false);
301
302 assert2::assert!(a == Attributes(0b0000_0000_0001_1010));
304 }
305
306 #[test]
307 fn replacing_compression_clears_old_bits() {
308 let a = Attributes::default().with_compression(CompressionType::Lz4);
311 let b = a.with_compression(CompressionType::Gzip);
312 assert2::assert!(b.compression() == CompressionType::Gzip);
313 assert2::assert!(b.0 & 0x07 == 1);
314 }
315
316 #[test]
317 fn delete_horizon_bit_round_trips() {
318 let base = Attributes::default();
320 assert2::assert!(!base.has_delete_horizon());
321
322 let set = base.with_delete_horizon(true);
324 assert2::assert!(set.has_delete_horizon());
325 assert2::assert!(set.0 & Attributes::DELETE_HORIZON_BIT == 0x40);
326
327 let combo = Attributes::default()
330 .with_control(true)
331 .with_transactional(true)
332 .with_delete_horizon(true);
333 assert2::assert!(combo == Attributes(0b0000_0000_0111_0000));
335
336 let cleared = combo.with_delete_horizon(false);
338 assert2::assert!(cleared == Attributes(0b0000_0000_0011_0000));
339 }
340
341 fn sample_header_bytes() -> [u8; HEADER_LEN] {
344 let mut buf = [0u8; HEADER_LEN];
345 buf[0..8].copy_from_slice(&100i64.to_be_bytes()); buf[8..12].copy_from_slice(&77i32.to_be_bytes()); buf[12..16].copy_from_slice(&1i32.to_be_bytes()); buf[16] = 2; buf[17..21].copy_from_slice(&0x1234_5678u32.to_be_bytes()); buf[21..23].copy_from_slice(&0x1234i16.to_be_bytes()); buf[23..27].copy_from_slice(&3i32.to_be_bytes()); buf[27..35].copy_from_slice(&111i64.to_be_bytes()); buf[35..43].copy_from_slice(&222i64.to_be_bytes()); buf[43..51].copy_from_slice(&(-1i64).to_be_bytes()); buf[51..53].copy_from_slice(&7i16.to_be_bytes()); buf[53..57].copy_from_slice(&(-1i32).to_be_bytes()); buf[57..61].copy_from_slice(&4i32.to_be_bytes()); buf
359 }
360
361 #[derive(Debug, PartialEq)]
362 struct HeaderProjection {
363 base_offset: i64,
364 batch_length: i32,
365 partition_leader_epoch: i32,
366 magic: i8,
367 crc: u32,
368 attributes: i16,
369 last_offset_delta: i32,
370 base_timestamp: i64,
371 max_timestamp: i64,
372 producer_id: i64,
373 producer_epoch: i16,
374 base_sequence: i32,
375 records_count: i32,
376 }
377
378 #[test]
379 fn reads_complete_header() {
380 let buf = sample_header_bytes();
381 let h = RecordBatchHeader::ref_from_bytes(&buf[..]).unwrap();
382 let actual = HeaderProjection {
383 base_offset: h.base_offset.get(),
384 batch_length: h.batch_length.get(),
385 partition_leader_epoch: h.partition_leader_epoch.get(),
386 magic: h.magic,
387 crc: h.crc.get(),
388 attributes: h.attributes.get(),
389 last_offset_delta: h.last_offset_delta.get(),
390 base_timestamp: h.base_timestamp.get(),
391 max_timestamp: h.max_timestamp.get(),
392 producer_id: h.producer_id.get(),
393 producer_epoch: h.producer_epoch.get(),
394 base_sequence: h.base_sequence.get(),
395 records_count: h.records_count.get(),
396 };
397 let expected = HeaderProjection {
398 base_offset: 100,
399 batch_length: 77,
400 partition_leader_epoch: 1,
401 magic: 2,
402 crc: 0x1234_5678,
403 attributes: 0x1234,
404 last_offset_delta: 3,
405 base_timestamp: 111,
406 max_timestamp: 222,
407 producer_id: -1,
408 producer_epoch: 7,
409 base_sequence: -1,
410 records_count: 4,
411 };
412 assert2::assert!(actual == expected);
413 }
414
415 #[test]
416 fn header_is_exactly_61_bytes() {
417 assert2::assert!(std::mem::size_of::<RecordBatchHeader>() == HEADER_LEN);
418 }
419
420 #[test]
421 fn too_short_buffer_errors() {
422 let buf = [0u8; HEADER_LEN - 1];
423 assert2::assert!(RecordBatchHeader::ref_from_bytes(&buf[..]).is_err());
424 }
425
426 #[test]
427 fn patch_writes_only_pre_crc_fields() {
428 let mut buf = sample_header_bytes().to_vec();
429 let crc_region_before = buf[CRC_COVERAGE_START..].to_vec();
430 let crc_field_before = buf[17..21].to_vec();
431
432 patch_base_offset_and_leader_epoch(&mut buf, 9_001, 42);
433
434 let h = RecordBatchHeader::ref_from_bytes(&buf[..]).unwrap();
436 check!(h.base_offset.get() == 9_001);
437 check!(h.partition_leader_epoch.get() == 42);
438
439 check!(&buf[17..21] == &crc_field_before[..]);
441 check!(&buf[CRC_COVERAGE_START..] == &crc_region_before[..]);
443 }
444
445 #[test]
446 fn crc_coverage_constants_match_field_layout() {
447 check!(BASE_OFFSET_RANGE.end <= CRC_COVERAGE_START);
450 check!(LEADER_EPOCH_RANGE.end <= CRC_COVERAGE_START);
451 check!(CRC_COVERAGE_START == 21);
452 }
453}