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