blob: 995abd174df151300aedfc8a97b22952b0990fa8 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! The message batch: the one layout a `SendMessages` body, the replicated
//! prepare, the persisted segment record, and the poll reply all share.
//!
//! ```text
//! [batch header: 256 bytes][blob: message frames]
//! frame = [header: 48 bytes][payload][user_headers]
//! ```
//!
//! Producers encode this batch directly on the wire (after the routing
//! metadata section); the server stamps `partition_id`, `base_offset`, and
//! `base_timestamp` at ingestion/persistence and replicates the bytes
//! verbatim; polls serve the stored records back. There is no other message
//! encoding.
use crate::WireError;
use std::hash::Hasher;
use twox_hash::XxHash3_64;
/// Size of the batch header. The remainder past the fields below is reserved
/// and must be zero.
pub const BATCH_HEADER_SIZE: usize = 256;
/// Size of a message frame header inside the blob.
pub const BATCH_MESSAGE_HEADER_SIZE: usize = 48;
/// Byte offset of `batch_checksum` inside the batch header.
pub const BATCH_CHECKSUM_OFFSET: usize = 40;
/// Byte offset of `message_count` inside the batch header.
pub const BATCH_MESSAGE_COUNT_OFFSET: usize = 48;
/// Byte offset where the reserved region of the batch header starts.
pub const BATCH_RESERVED_OFFSET: usize = 52;
/// Upper bound on a message's `timestamp_delta`: the field is a `u32`
/// microsecond delta against the batch `origin_timestamp`, so a single batch
/// spans at most ~71.6 minutes of producer clock.
pub const MAX_TIMESTAMP_DELTA_MICROS: u64 = u32::MAX as u64;
/// The batch header.
///
/// A producer encodes it with `partition_id`, `base_offset`, and
/// `base_timestamp` zero; the server owns those three fields and stamps them
/// at ingestion (`partition_id`) and persistence (`base_offset`,
/// `base_timestamp`, plus the `batch_checksum` recompute).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BatchHeader {
pub partition_id: u64,
pub base_offset: u64,
pub base_timestamp: u64,
pub origin_timestamp: u64,
/// Total batch size: `BATCH_HEADER_SIZE` + blob length.
pub batch_length: u64,
pub batch_checksum: u64,
pub message_count: u32,
}
impl BatchHeader {
#[must_use]
pub const fn new(
partition_id: u64,
origin_timestamp: u64,
batch_length: u64,
message_count: u32,
) -> Self {
Self {
partition_id,
base_offset: 0,
base_timestamp: 0,
origin_timestamp,
batch_length,
batch_checksum: 0,
message_count,
}
}
/// # Errors
/// [`WireError::UnexpectedEof`] on a short buffer;
/// [`WireError::Validation`] on a `batch_length` smaller than the header
/// or nonzero reserved bytes.
pub fn decode(bytes: &[u8]) -> Result<Self, WireError> {
if bytes.len() < BATCH_HEADER_SIZE {
return Err(WireError::UnexpectedEof {
offset: 0,
need: BATCH_HEADER_SIZE,
have: bytes.len(),
});
}
let batch_length = read_u64(bytes, 32);
if batch_length < BATCH_HEADER_SIZE as u64 {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch length must cover the batch header",
)));
}
// Every encoder zeroes the reserved region. Admitting nonzero bytes
// would let unchecksummed data ride the header to disk and replicas.
if bytes[BATCH_RESERVED_OFFSET..BATCH_HEADER_SIZE]
.iter()
.any(|&reserved_byte| reserved_byte != 0)
{
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch header reserved bytes must be zero",
)));
}
Ok(Self {
partition_id: read_u64(bytes, 0),
base_offset: read_u64(bytes, 8),
base_timestamp: read_u64(bytes, 16),
origin_timestamp: read_u64(bytes, 24),
batch_length,
batch_checksum: read_u64(bytes, BATCH_CHECKSUM_OFFSET),
message_count: read_u32(bytes, BATCH_MESSAGE_COUNT_OFFSET),
})
}
/// # Panics
/// Panics if `bytes` is shorter than [`BATCH_HEADER_SIZE`].
pub fn encode_into(&self, bytes: &mut [u8]) {
assert!(bytes.len() >= BATCH_HEADER_SIZE);
bytes[..BATCH_HEADER_SIZE].fill(0);
bytes[0..8].copy_from_slice(&self.partition_id.to_le_bytes());
bytes[8..16].copy_from_slice(&self.base_offset.to_le_bytes());
bytes[16..24].copy_from_slice(&self.base_timestamp.to_le_bytes());
bytes[24..32].copy_from_slice(&self.origin_timestamp.to_le_bytes());
bytes[32..40].copy_from_slice(&self.batch_length.to_le_bytes());
bytes[BATCH_CHECKSUM_OFFSET..BATCH_CHECKSUM_OFFSET + 8]
.copy_from_slice(&self.batch_checksum.to_le_bytes());
bytes[BATCH_MESSAGE_COUNT_OFFSET..BATCH_MESSAGE_COUNT_OFFSET + 4]
.copy_from_slice(&self.message_count.to_le_bytes());
}
/// Total batch size in bytes (header + blob).
///
/// # Panics
/// Panics if `batch_length` exceeds `usize::MAX`.
#[must_use]
pub fn total_size(&self) -> usize {
usize::try_from(self.batch_length).expect("batch length exceeds usize::MAX")
}
/// # Errors
/// [`WireError::Validation`] if `batch_length` does not cover the header
/// or exceeds `usize`.
pub fn blob_len(&self) -> Result<usize, WireError> {
self.batch_length
.checked_sub(BATCH_HEADER_SIZE as u64)
.and_then(|len| usize::try_from(len).ok())
.ok_or(WireError::Validation(std::borrow::Cow::Borrowed(
"batch length must cover the batch header",
)))
}
#[must_use]
pub fn checksum_for_blob(&self, blob: &[u8]) -> u64 {
calculate_batch_checksum(self, blob)
}
}
/// A decoded batch borrowing its blob.
#[derive(Debug, Clone, Copy)]
pub struct BatchRef<'a> {
pub header: BatchHeader,
blob: &'a [u8],
}
impl<'a> BatchRef<'a> {
#[must_use]
pub const fn new(header: BatchHeader, blob: &'a [u8]) -> Self {
Self { header, blob }
}
#[must_use]
pub const fn iter(&self) -> BatchIterator<'a> {
BatchIterator {
blob: self.blob,
position: 0,
}
}
#[must_use]
pub const fn iter_with_offsets(&self) -> BatchIteratorWithOffsets<'a> {
BatchIteratorWithOffsets {
blob: self.blob,
position: 0,
}
}
#[must_use]
pub const fn blob(&self) -> &'a [u8] {
self.blob
}
#[must_use]
pub const fn message_count(&self) -> u32 {
self.header.message_count
}
}
impl<'a> IntoIterator for &BatchRef<'a> {
type Item = BatchMessageView<'a>;
type IntoIter = BatchIterator<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
/// A message frame header inside the blob.
#[derive(Debug, Clone, Copy)]
pub struct BatchMessageHeader {
/// `XxHash3_64` over `frame[8..48] || payload || user_headers`.
pub checksum: u64,
pub id: u128,
pub offset_delta: u32,
/// Microsecond delta from [`BatchHeader::origin_timestamp`].
pub timestamp_delta: u32,
pub user_headers_length: u32,
pub payload_length: u32,
}
impl BatchMessageHeader {
/// # Errors
/// [`WireError::UnexpectedEof`] on a short buffer; [`WireError::Validation`]
/// on nonzero reserved bytes.
pub fn decode(bytes: &[u8]) -> Result<Self, WireError> {
if bytes.len() < BATCH_MESSAGE_HEADER_SIZE {
return Err(WireError::UnexpectedEof {
offset: 0,
need: BATCH_MESSAGE_HEADER_SIZE,
have: bytes.len(),
});
}
if read_u64(bytes, 40) != 0 {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"message frame reserved bytes must be zero",
)));
}
Ok(Self {
checksum: read_u64(bytes, 0),
id: read_u128(bytes, 8),
offset_delta: read_u32(bytes, 24),
timestamp_delta: read_u32(bytes, 28),
user_headers_length: read_u32(bytes, 32),
payload_length: read_u32(bytes, 36),
})
}
/// Frame size: header + payload + user headers.
#[must_use]
pub const fn total_size(&self) -> usize {
BATCH_MESSAGE_HEADER_SIZE + self.user_headers_length as usize + self.payload_length as usize
}
}
/// A message frame view borrowing payload and user headers from the blob.
#[derive(Debug, Clone, Copy)]
pub struct BatchMessageView<'a> {
pub header: BatchMessageHeader,
pub user_headers: &'a [u8],
pub payload: &'a [u8],
}
/// Infallible frame walk over a blob whose layout has already been proven by
/// [`decode_batch_slice_with`]: on an unvalidated blob it stops at the first
/// malformed frame instead of erroring.
pub struct BatchIterator<'a> {
blob: &'a [u8],
position: usize,
}
impl<'a> Iterator for BatchIterator<'a> {
type Item = BatchMessageView<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.position >= self.blob.len() {
return None;
}
let header = BatchMessageHeader::decode(&self.blob[self.position..]).ok()?;
let start = self.position + BATCH_MESSAGE_HEADER_SIZE;
let payload_end = start + header.payload_length as usize;
let headers_end = payload_end + header.user_headers_length as usize;
let payload = self.blob.get(start..payload_end)?;
let user_headers = self.blob.get(payload_end..headers_end)?;
self.position += header.total_size();
Some(BatchMessageView {
header,
user_headers,
payload,
})
}
}
/// A frame view plus its byte range inside the blob.
#[derive(Debug, Clone, Copy)]
pub struct BatchMessageViewWithOffsets<'a> {
pub message: BatchMessageView<'a>,
pub start: usize,
pub end: usize,
}
pub struct BatchIteratorWithOffsets<'a> {
blob: &'a [u8],
position: usize,
}
impl<'a> Iterator for BatchIteratorWithOffsets<'a> {
type Item = BatchMessageViewWithOffsets<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.position >= self.blob.len() {
return None;
}
let start = self.position;
let header = BatchMessageHeader::decode(&self.blob[self.position..]).ok()?;
let message_start = self.position + BATCH_MESSAGE_HEADER_SIZE;
let payload_end = message_start + header.payload_length as usize;
let headers_end = payload_end + header.user_headers_length as usize;
let payload = self.blob.get(message_start..payload_end)?;
let user_headers = self.blob.get(payload_end..headers_end)?;
self.position += header.total_size();
Some(BatchMessageViewWithOffsets {
message: BatchMessageView {
header,
user_headers,
payload,
},
start,
end: self.position,
})
}
}
/// How much of a batch record [`decode_batch_slice_with`] proves before returning it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchIntegrity {
/// Re-hash the batch and reject it unless it matches its own `batch_checksum`.
Verify,
/// Check the framing only, and hand back whatever it describes. The caller is
/// accepting bytes that may not be the ones written.
LayoutOnly,
}
/// Decode one batch record (`[256B header][blob]`), verifying the batch
/// checksum and every per-message checksum.
///
/// `body` may extend past the batch: readers walking a stream of records hand
/// in the rest of the buffer and step by `batch_length`. Callers whose buffer
/// is meant to BE the batch must reject the surplus themselves.
///
/// # Errors
/// [`WireError`] on a short or self-inconsistent record, and
/// [`WireError::InvalidBatchChecksum`] / [`WireError::InvalidMessageChecksum`]
/// on an integrity mismatch.
pub fn decode_batch_slice(body: &[u8]) -> Result<BatchRef<'_>, WireError> {
decode_batch_slice_with(body, BatchIntegrity::Verify)
}
/// [`decode_batch_slice`] with the integrity level chosen by the caller.
///
/// Layout checks are not optional either way: a short or self-inconsistent
/// record is rejected regardless, because the caller would otherwise index
/// past it.
///
/// # Errors
/// See [`decode_batch_slice`]; [`BatchIntegrity::LayoutOnly`] skips only the
/// checksum comparison.
pub fn decode_batch_slice_with(
body: &[u8],
integrity: BatchIntegrity,
) -> Result<BatchRef<'_>, WireError> {
let header = BatchHeader::decode(body)?;
let blob_len = header.blob_len()?;
if body.len() < header.total_size() {
return Err(WireError::UnexpectedEof {
offset: 0,
need: header.total_size(),
have: body.len(),
});
}
let blob = &body[BATCH_HEADER_SIZE..BATCH_HEADER_SIZE + blob_len];
let batch = BatchRef { header, blob };
match integrity {
BatchIntegrity::Verify => {
let expected_checksum = verify_and_recompute_batch_checksum(&batch)?;
if header.batch_checksum != expected_checksum {
return Err(WireError::InvalidBatchChecksum {
stored: header.batch_checksum,
computed: expected_checksum,
base_offset: header.base_offset,
});
}
}
BatchIntegrity::LayoutOnly => validate_batch_layout(&batch)?,
}
Ok(batch)
}
/// Batch checksum: streaming `XxHash3_64` over the six batch header meta
/// fields followed by each message's stored 8-byte checksum field in message
/// order - NOT the message bodies.
///
/// Bodies are bound only transitively: each per-message checksum already covers
/// `frame[8..48] || payload || user_headers`, so hashing the checksum fields
/// binds every body byte IFF a reader also re-verifies the per-message
/// checksums. Stamping hashes `N * 8` bytes instead of the whole blob;
/// validating decoders pay the one body pass as the per-message verify in
/// [`verify_and_recompute_batch_checksum`], which hashes the checksum-field
/// bytes in the same order so its recompute matches a compute here.
///
/// Assumes a well-formed blob whose frames tile exactly; every compute site
/// builds the blob and satisfies this.
#[must_use]
pub fn calculate_batch_checksum(header: &BatchHeader, blob: &[u8]) -> u64 {
let mut hasher = XxHash3_64::new();
write_batch_header_fields(&mut hasher, header);
let batch = BatchRef {
header: *header,
blob,
};
for framed in batch.iter_with_offsets() {
hasher.write(&blob[framed.start..framed.start + 8]);
}
hasher.finish()
}
fn write_batch_header_fields(hasher: &mut XxHash3_64, header: &BatchHeader) {
hasher.write(&header.partition_id.to_le_bytes());
hasher.write(&header.base_offset.to_le_bytes());
hasher.write(&header.base_timestamp.to_le_bytes());
hasher.write(&header.origin_timestamp.to_le_bytes());
hasher.write(&header.batch_length.to_le_bytes());
hasher.write(&header.message_count.to_le_bytes());
}
/// Verify every per-message checksum in `batch` and return the recomputed
/// batch checksum (see [`calculate_batch_checksum`]) from a single frame walk.
///
/// The per-message pass is the equal-integrity half of the scheme: the batch
/// value binds bodies only through the checksum fields, so a validating decode
/// must re-verify each message here or body corruption that leaves the
/// checksum field intact would pass. This is the one full-body pass a
/// validating decode pays; the caller then compares the returned value against
/// the stored `batch_checksum`.
///
/// # Errors
/// [`WireError::InvalidMessageChecksum`] on the first per-message mismatch;
/// [`WireError::Validation`] if the frames do not tile `message_count` exactly.
pub fn verify_and_recompute_batch_checksum(batch: &BatchRef<'_>) -> Result<u64, WireError> {
let blob = batch.blob();
let mut hasher = XxHash3_64::new();
write_batch_header_fields(&mut hasher, &batch.header);
let mut verified = 0u32;
let mut covered = 0usize;
for framed in batch.iter_with_offsets() {
// Cover (`frame[8..48] || payload || user_headers`) hashed raw from the
// blob, byte-exact with the encoder's, so a flipped body byte fails even
// when the stored checksum field is left intact.
let stored = framed.message.header.checksum;
let expected = XxHash3_64::oneshot(&blob[framed.start + 8..framed.end]);
if expected != stored {
return Err(WireError::InvalidMessageChecksum {
stored,
computed: expected,
offset: batch
.header
.base_offset
.saturating_add(u64::from(framed.message.header.offset_delta)),
});
}
hasher.write(&blob[framed.start..framed.start + 8]);
verified += 1;
covered = framed.end;
}
if verified != batch.message_count() || covered != blob.len() {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch frames do not tile message_count exactly",
)));
}
Ok(hasher.finish())
}
/// Layout-only twin of [`verify_and_recompute_batch_checksum`]: prove the
/// frames tile `message_count` exactly without touching any checksum.
///
/// # Errors
/// [`WireError::Validation`] if the frames do not tile `message_count` exactly.
fn validate_batch_layout(batch: &BatchRef<'_>) -> Result<(), WireError> {
let blob = batch.blob();
let mut counted = 0u32;
let mut covered = 0usize;
for framed in batch.iter_with_offsets() {
counted += 1;
covered = framed.end;
}
if counted != batch.message_count() || covered != blob.len() {
return Err(WireError::Validation(std::borrow::Cow::Borrowed(
"batch frames do not tile message_count exactly",
)));
}
Ok(())
}
fn read_u32(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("4-byte slice"))
}
fn read_u64(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("8-byte slice"))
}
fn read_u128(bytes: &[u8], offset: usize) -> u128 {
u128::from_le_bytes(
bytes[offset..offset + 16]
.try_into()
.expect("16-byte slice"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::cast_possible_truncation)]
fn frame(id: u128, offset_delta: u32, timestamp_delta: u32, payload: &[u8]) -> Vec<u8> {
let mut bytes = vec![0u8; BATCH_MESSAGE_HEADER_SIZE];
bytes[8..24].copy_from_slice(&id.to_le_bytes());
bytes[24..28].copy_from_slice(&offset_delta.to_le_bytes());
bytes[28..32].copy_from_slice(&timestamp_delta.to_le_bytes());
bytes[36..40].copy_from_slice(&(payload.len() as u32).to_le_bytes());
bytes.extend_from_slice(payload);
let checksum = XxHash3_64::oneshot(&bytes[8..]);
bytes[0..8].copy_from_slice(&checksum.to_le_bytes());
bytes
}
#[allow(clippy::cast_possible_truncation)]
fn batch_bytes(frames: &[Vec<u8>]) -> Vec<u8> {
let blob: Vec<u8> = frames.concat();
let mut header = BatchHeader::new(
7,
1_000,
(BATCH_HEADER_SIZE + blob.len()) as u64,
frames.len() as u32,
);
header.batch_checksum = calculate_batch_checksum(&header, &blob);
let mut bytes = vec![0u8; BATCH_HEADER_SIZE];
header.encode_into(&mut bytes);
bytes.extend_from_slice(&blob);
bytes
}
#[test]
fn header_roundtrip() {
let mut header = BatchHeader::new(1, 2, 300, 4);
header.base_offset = 10;
header.base_timestamp = 20;
header.batch_checksum = 30;
let mut bytes = vec![0u8; BATCH_HEADER_SIZE];
header.encode_into(&mut bytes);
let decoded = BatchHeader::decode(&bytes).unwrap();
assert_eq!(decoded, header);
}
#[test]
fn decode_verifies_checksums() {
let bytes = batch_bytes(&[frame(1, 0, 0, b"first"), frame(2, 1, 5, b"second")]);
let batch = decode_batch_slice(&bytes).unwrap();
assert_eq!(batch.message_count(), 2);
let views: Vec<_> = batch.iter().collect();
assert_eq!(views[0].payload, b"first");
assert_eq!(views[1].payload, b"second");
assert_eq!(views[1].header.offset_delta, 1);
}
#[test]
fn decode_rejects_flipped_body_byte() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
assert!(matches!(
decode_batch_slice(&bytes),
Err(WireError::InvalidMessageChecksum { .. })
));
}
#[test]
fn decode_rejects_flipped_batch_checksum() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_CHECKSUM_OFFSET] ^= 0xFF;
assert!(matches!(
decode_batch_slice(&bytes),
Err(WireError::InvalidBatchChecksum { .. })
));
}
#[test]
fn layout_only_accepts_zero_checksums() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_CHECKSUM_OFFSET..BATCH_CHECKSUM_OFFSET + 8].fill(0);
let batch = decode_batch_slice_with(&bytes, BatchIntegrity::LayoutOnly).unwrap();
assert_eq!(batch.message_count(), 1);
}
#[test]
fn decode_rejects_miscounted_batch() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_MESSAGE_COUNT_OFFSET..BATCH_MESSAGE_COUNT_OFFSET + 4]
.copy_from_slice(&2u32.to_le_bytes());
assert!(decode_batch_slice_with(&bytes, BatchIntegrity::LayoutOnly).is_err());
}
#[test]
fn decode_rejects_truncated_batch() {
let bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
assert!(decode_batch_slice(&bytes[..bytes.len() - 1]).is_err());
}
#[test]
fn decode_rejects_nonzero_reserved() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_HEADER_SIZE + 40] = 1;
assert!(decode_batch_slice_with(&bytes, BatchIntegrity::LayoutOnly).is_err());
}
#[test]
fn decode_rejects_nonzero_header_reserved() {
let mut bytes = batch_bytes(&[frame(1, 0, 0, b"payload")]);
bytes[BATCH_RESERVED_OFFSET] = 1;
assert!(matches!(
BatchHeader::decode(&bytes),
Err(WireError::Validation(_))
));
bytes[BATCH_RESERVED_OFFSET] = 0;
bytes[BATCH_HEADER_SIZE - 1] = 1;
assert!(BatchHeader::decode(&bytes).is_err());
}
}