This is an automated email from the ASF dual-hosted git repository. krishvishal pushed a commit to branch kafka-record-codec in repository https://gitbox.apache.org/repos/asf/iggy.git
commit 43a9b760ea2abbac7b13723bf1c18371c79200f3 Author: Krishna Vishal <[email protected]> AuthorDate: Sat Sep 19 17:52:49 2026 +0530 feat(gateways): map Kafka records to Iggy messages and back Produce and Fetch both sit on this, so it lands as one pure module with no Iggy calls. --- Cargo.lock | 24 ++ Cargo.toml | 8 +- gateways/kafka/Cargo.toml | 8 +- gateways/kafka/docs/BRIDGE_MAPPING.md | 19 +- gateways/kafka/src/lib.rs | 1 + gateways/kafka/src/records.rs | 723 ++++++++++++++++++++++++++++++++++ 6 files changed, 775 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27491fac9..e26c5b477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7013,6 +7013,7 @@ dependencies = [ name = "iggy-gateway-kafka" version = "0.1.0" dependencies = [ + "anyhow", "bytes", "iggy", "kafka-protocol", @@ -8161,8 +8162,12 @@ dependencies = [ "bytes", "crc", "crc32c", + "flate2", "indexmap 2.14.2", + "lz4", + "snap", "uuid", + "zstd 0.13.3", ] [[package]] @@ -8642,6 +8647,25 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "lz4_flex" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index 3a8243d89..a11fbdec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,7 +214,13 @@ ipnet = "2.12.2" journal = { path = "core/journal" } js-sys = "0.3" jsonwebtoken = { version = "11.0.0", features = ["rust_crypto"] } -kafka-protocol = { version = "0.18.0", default-features = false, features = ["broker"] } +kafka-protocol = { version = "0.18.0", default-features = false, features = [ + "broker", + "gzip", + "lz4", + "snappy", + "zstd", +] } keyring-core = "1.0.0" lapin = "2.5.1" lazy_static = "1.5.0" diff --git a/gateways/kafka/Cargo.toml b/gateways/kafka/Cargo.toml index f2c76e644..29ef77e29 100644 --- a/gateways/kafka/Cargo.toml +++ b/gateways/kafka/Cargo.toml @@ -33,11 +33,13 @@ name = "iggy-gateway-kafka" path = "src/main.rs" [dependencies] +# Required by kafka-protocol: its custom-decompression hook is typed on anyhow::Result. +anyhow = { workspace = true } bytes = { workspace = true } iggy = { workspace = true } -# Broker-role only: decodes requests and encodes responses. Default features also pull in -# client-role codec paths and compression codecs (gzip/lz4/snappy/zstd) this gateway never -# uses, since RecordBatch payloads stay opaque `Bytes` here. +# Broker-role plus the four compression codecs, which `records::decode_batches` needs to read a +# compressed Produce batch. Client-role codec paths stay off: this gateway answers requests, it +# does not send them. kafka-protocol = { workspace = true } libc = { workspace = true } secrecy = { workspace = true } diff --git a/gateways/kafka/docs/BRIDGE_MAPPING.md b/gateways/kafka/docs/BRIDGE_MAPPING.md index da213deeb..4457206dc 100644 --- a/gateways/kafka/docs/BRIDGE_MAPPING.md +++ b/gateways/kafka/docs/BRIDGE_MAPPING.md @@ -95,9 +95,13 @@ A record takes the fallback when any of these hold: - the key is longer than 255 bytes - a header name, prefixed with `kafka.h.`, is longer than 255 bytes - a header value is null, empty, or longer than 255 bytes -- two headers share a name - the headers together would exceed the 100 KB user-header budget +A repeated header name belonged on that list and is not reachable. Kafka allows one, but +`kafka_protocol` decodes headers into an `IndexMap` and inserts each in turn (`records.rs:919`), +so a repeat overwrites its earlier entry before any gateway code runs. The last value wins and +the record takes the native path. Catching the case needs a decoder this gateway does not have. + Such a record is stored with a `kafka.envelope` header whose value is one byte, the format version, currently `1`. The payload holds the key, the value and the headers in the layout below. Fetch checks for that header first and takes the plain path only when it is absent. @@ -161,9 +165,16 @@ therefore still admit 4096 times that much output. Produce keeps a single decompression budget for the whole request, set to `max_frame_size`, so a compressed request can never yield more than the same client could have sent uncompressed. A -batch that exhausts the budget is rejected with `MESSAGE_TOO_LARGE` (10) before the output is -allocated. Each decompressed record value has to clear Iggy's own `MAX_PAYLOAD_SIZE` (64 MB, -`iggy_message.rs:44`) separately, since one record becomes one message. +batch that exhausts the budget is rejected with `MESSAGE_TOO_LARGE` (10). Each decompressed +record value has to clear Iggy's own `MAX_PAYLOAD_SIZE` (64 MB, `iggy_message.rs:44`) separately, +since one record becomes one message. + +The budget bounds what a request accumulates, not what one batch allocates. `kafka_protocol`'s +decompressors write the whole stream out before they hand it over (`compression/gzip.rs:46` and +its three siblings), so a single batch reaches its full decompressed size in memory and the +budget rejects it one step later. Bounding the peak needs a size-limited reader per codec, which +means owning Kafka's snappy and lz4 framing rather than borrowing it. That is worth doing and it +is not done here. Nothing decompresses today. The record batch stays an opaque `Bytes` on both paths, so the bound above is a requirement on [#3535](https://github.com/apache/iggy/issues/3535) rather than a diff --git a/gateways/kafka/src/lib.rs b/gateways/kafka/src/lib.rs index 5ccd935ae..043fe9e3d 100644 --- a/gateways/kafka/src/lib.rs +++ b/gateways/kafka/src/lib.rs @@ -20,6 +20,7 @@ pub mod bridge; pub mod error; pub mod protocol; +pub mod records; pub mod server; pub use server::{GatewayConfig, KafkaGateway}; diff --git a/gateways/kafka/src/records.rs b/gateways/kafka/src/records.rs new file mode 100644 index 000000000..e7dd8f6e1 --- /dev/null +++ b/gateways/kafka/src/records.rs @@ -0,0 +1,723 @@ +// 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. + +//! One Kafka record to and from one Iggy message. +//! +//! `docs/BRIDGE_MAPPING.md` is the specification. This module implements it and nothing else: +//! no batch framing, no Iggy calls, no handler wiring. + +use std::cell::Cell; +use std::collections::BTreeMap; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use iggy::prelude::{HeaderKey, HeaderValue, IggyError, IggyMessage}; +use kafka_protocol::indexmap::IndexMap; +use kafka_protocol::protocol::StrBytes; +use kafka_protocol::records::{ + Compression, NO_PARTITION_LEADER_EPOCH, NO_PRODUCER_EPOCH, NO_PRODUCER_ID, NO_SEQUENCE, Record, + RecordBatchDecoder, RecordBatchEncoder, RecordEncodeOptions, TimestampType, +}; +use thiserror::Error; + +/// Iggy header carrying the Kafka record key. +pub const KEY_HEADER: &str = "kafka.key"; +/// Iggy header naming which of null or empty a placeholder payload stands for. +pub const VALUE_MARKER_HEADER: &str = "kafka.value"; +/// Prefix every Kafka record header name is stored under. +pub const HEADER_PREFIX: &str = "kafka.h."; +/// Iggy header whose one-byte value is the envelope format version. +pub const ENVELOPE_HEADER: &str = "kafka.envelope"; +/// Envelope format version this build writes and reads. +pub const ENVELOPE_VERSION: u8 = 1; + +/// Kafka sends this for a record with no timestamp. +const NO_TIMESTAMP: i64 = -1; +/// Stored in place of a null or empty value, discarded on the way back. +const PLACEHOLDER: &[u8] = &[0x00]; +/// Iggy caps one header name and one header value at this many bytes. +const MAX_FIELD: usize = 255; + +const MARKER_NULL: &[u8] = b"null"; +const MARKER_EMPTY: &[u8] = b"empty"; + +const FLAG_KEY: u8 = 0b01; +const FLAG_VALUE: u8 = 0b10; + +/// Record batch version this gateway writes. v2 is the only shape `kafka_protocol` encodes. +const BATCH_VERSION: i8 = 2; + +#[derive(Debug, Error)] +pub enum RecordCodecError { + #[error("Iggy rejected the message: {0}")] + Iggy(#[from] IggyError), + #[error("record timestamp {0} ms does not fit Iggy's microsecond field")] + TimestampOutOfRange(i64), + #[error("envelope is truncated: needed {needed} bytes, {remaining} remain")] + EnvelopeTruncated { needed: usize, remaining: usize }, + #[error("envelope format version {0} is not {ENVELOPE_VERSION}")] + EnvelopeVersion(u8), + #[error("envelope header name is not UTF-8")] + EnvelopeHeaderName, + #[error("record batch is malformed: {0}")] + Batch(String), + #[error("decompressed {produced} bytes with {remaining} left in the request budget")] + BudgetExceeded { produced: usize, remaining: usize }, +} + +type Result<T> = std::result::Result<T, RecordCodecError>; + +/// Encodes one Kafka record as one Iggy message. +/// +/// Takes the native path when Iggy can hold every field, and the envelope otherwise. A caller +/// cannot tell which from the return value, which is the point: `from_iggy` reverses both. +/// +/// # Errors +/// +/// Returns an error when the timestamp does not fit, or when Iggy rejects the message for a +/// reason the envelope does not fix, such as a payload over `MAX_PAYLOAD_SIZE`. +pub fn to_iggy(record: &Record) -> Result<IggyMessage> { + if needs_envelope(record) { + return envelope_message(record); + } + let (payload, marker) = split_value(record.value.as_ref()); + let mut headers = BTreeMap::new(); + if let Some(marker) = marker { + headers.insert(header_key(VALUE_MARKER_HEADER), header_value(marker)); + } + if let Some(key) = record.key.as_ref() { + headers.insert(header_key(KEY_HEADER), header_value(key)); + } + for (name, value) in &record.headers { + // `needs_envelope` rejected the shapes that cannot be built here, so both are infallible. + let Some(value) = value.as_ref() else { + continue; + }; + headers.insert( + header_key(&format!("{HEADER_PREFIX}{}", name.as_str())), + header_value(value), + ); + } + + // The only limit left is the 100 KB budget over all headers together, which no per-field + // check can see. Let the constructor rule on it rather than duplicating its arithmetic. + build(payload, headers, record.timestamp)?.map_or_else(|| envelope_message(record), Ok) +} + +/// Decodes one Iggy message as one Kafka record at `offset`. +/// +/// A message with no `kafka.` headers was written by an Iggy client, not through this gateway. +/// It gets a null key and its own user headers. +/// +/// # Errors +/// +/// Returns an error when the message carries a malformed envelope. +pub fn from_iggy(message: &IggyMessage, offset: i64) -> Result<Record> { + let stored = message.user_headers_map()?.unwrap_or_default(); + let envelope = stored.get(&header_key(ENVELOPE_HEADER)); + let (key, value, headers) = if let Some(version) = envelope { + decode_envelope(version.value().as_ref(), &message.payload)? + } else { + native_fields(&stored, message) + }; + Ok(record(key, value, headers, offset, timestamp_out(message))) +} + +/// Kafka counts milliseconds, Iggy counts microseconds, and `-1` means the broker assigns one. +fn timestamp_in(millis: i64) -> Result<u64> { + if millis == NO_TIMESTAMP { + return Ok(0); + } + millis + .checked_mul(1000) + .and_then(|micros| u64::try_from(micros).ok()) + .ok_or(RecordCodecError::TimestampOutOfRange(millis)) +} + +/// Zero means the producer sent no timestamp, so the server-assigned one stands in. +fn timestamp_out(message: &IggyMessage) -> i64 { + let micros = if message.header.origin_timestamp == 0 { + message.header.timestamp + } else { + message.header.origin_timestamp + }; + i64::try_from(micros / 1000).unwrap_or(NO_TIMESTAMP) +} + +/// Whether any field of `record` is one Iggy refuses to hold natively. +/// +/// A repeated header name is on the list in `BRIDGE_MAPPING.md` and is absent here, because +/// `kafka_protocol` decodes headers into an `IndexMap` (`records.rs:919`). A repeat overwrites +/// its earlier entry before this code runs, so the case cannot be observed. +fn needs_envelope(record: &Record) -> bool { + let key_unholdable = record + .key + .as_ref() + .is_some_and(|key| key.is_empty() || key.len() > MAX_FIELD); + if key_unholdable { + return true; + } + record.headers.iter().any(|(name, value)| { + HEADER_PREFIX.len() + name.as_str().len() > MAX_FIELD + || value + .as_ref() + .is_none_or(|value| value.is_empty() || value.len() > MAX_FIELD) + }) +} + +/// Payload to store, and the marker naming what the original value was when it is not the payload. +fn split_value(value: Option<&Bytes>) -> (Bytes, Option<&'static [u8]>) { + match value { + None => (Bytes::from_static(PLACEHOLDER), Some(MARKER_NULL)), + Some(value) if value.is_empty() => (Bytes::from_static(PLACEHOLDER), Some(MARKER_EMPTY)), + Some(value) => (value.clone(), None), + } +} + +/// `Ok(None)` when the headers together pass Iggy's budget, which the envelope then carries. +fn build( + payload: Bytes, + headers: BTreeMap<HeaderKey, HeaderValue>, + timestamp: i64, +) -> Result<Option<IggyMessage>> { + let mut message = match IggyMessage::builder() + .payload(payload) + .user_headers(headers) + .build() + { + Ok(message) => message, + Err(IggyError::TooBigUserHeaders) => return Ok(None), + Err(error) => return Err(error.into()), + }; + message.header.origin_timestamp = timestamp_in(timestamp)?; + Ok(Some(message)) +} + +fn envelope_message(record: &Record) -> Result<IggyMessage> { + let mut headers = BTreeMap::new(); + headers.insert( + header_key(ENVELOPE_HEADER), + header_value(&[ENVELOPE_VERSION]), + ); + build(encode_envelope(record), headers, record.timestamp)? + .ok_or(IggyError::TooBigUserHeaders) + .map_err(Into::into) +} + +/// 13 bytes of fixed overhead plus 9 per header, little-endian throughout. +fn encode_envelope(record: &Record) -> Bytes { + let mut flags = 0u8; + if record.key.is_some() { + flags |= FLAG_KEY; + } + if record.value.is_some() { + flags |= FLAG_VALUE; + } + + let mut buf = BytesMut::new(); + buf.put_u8(flags); + put_field(&mut buf, record.key.as_ref()); + put_field(&mut buf, record.value.as_ref()); + buf.put_u32_le(u32::try_from(record.headers.len()).unwrap_or(u32::MAX)); + for (name, value) in &record.headers { + let name = name.as_str().as_bytes(); + buf.put_u32_le(u32::try_from(name.len()).unwrap_or(u32::MAX)); + buf.put_slice(name); + buf.put_u8(u8::from(value.is_some())); + put_field(&mut buf, value.as_ref()); + } + buf.freeze() +} + +type EnvelopeFields = ( + Option<Bytes>, + Option<Bytes>, + IndexMap<StrBytes, Option<Bytes>>, +); + +fn decode_envelope(version: &[u8], payload: &Bytes) -> Result<EnvelopeFields> { + match version.first() { + Some(&ENVELOPE_VERSION) => {} + Some(&other) => return Err(RecordCodecError::EnvelopeVersion(other)), + None => return Err(RecordCodecError::EnvelopeVersion(0)), + } + + let mut buf = payload.clone(); + let flags = take(&mut buf, 1)?[0]; + let key = take_field(&mut buf)?; + let value = take_field(&mut buf)?; + let count = u32::from_le_bytes(take(&mut buf, 4)?.as_ref().try_into().unwrap_or_default()); + + let mut headers = IndexMap::with_capacity(count as usize); + for _ in 0..count { + let name = take_field(&mut buf)?; + let name = + String::from_utf8(name.to_vec()).map_err(|_| RecordCodecError::EnvelopeHeaderName)?; + let present = take(&mut buf, 1)?[0] != 0; + let value = take_field(&mut buf)?; + headers.insert(StrBytes::from_string(name), present.then_some(value)); + } + + Ok(( + (flags & FLAG_KEY != 0).then_some(key), + (flags & FLAG_VALUE != 0).then_some(value), + headers, + )) +} + +fn native_fields( + stored: &BTreeMap<HeaderKey, HeaderValue>, + message: &IggyMessage, +) -> EnvelopeFields { + let gateway_written = stored + .keys() + .any(|key| key.as_str().is_ok_and(|key| key.starts_with("kafka."))); + let key = stored.get(&header_key(KEY_HEADER)).map(HeaderValue::value); + let value = match stored + .get(&header_key(VALUE_MARKER_HEADER)) + .map(HeaderValue::value) + { + Some(marker) if marker.as_ref() == MARKER_NULL => None, + Some(_) => Some(Bytes::new()), + None => Some(message.payload.clone()), + }; + + let mut headers = IndexMap::new(); + for (name, stored_value) in stored { + // A name Iggy holds but Kafka cannot carry. Only an Iggy producer can write one. + let Ok(name) = name.as_str() else { + continue; + }; + let name = if gateway_written { + match name.strip_prefix(HEADER_PREFIX) { + Some(name) => name, + None => continue, + } + } else { + name + }; + headers.insert( + StrBytes::from_string(name.to_string()), + Some(stored_value.value()), + ); + } + (key, value, headers) +} + +const fn record( + key: Option<Bytes>, + value: Option<Bytes>, + headers: IndexMap<StrBytes, Option<Bytes>>, + offset: i64, + timestamp: i64, +) -> Record { + Record { + transactional: false, + control: false, + delete_horizon: false, + partition_leader_epoch: NO_PARTITION_LEADER_EPOCH, + producer_id: NO_PRODUCER_ID, + producer_epoch: NO_PRODUCER_EPOCH, + timestamp_type: TimestampType::Creation, + offset, + sequence: NO_SEQUENCE, + timestamp, + key, + value, + headers, + } +} + +fn put_field(buf: &mut BytesMut, field: Option<&Bytes>) { + let field = field.map_or(&[][..], |field| field.as_ref()); + buf.put_u32_le(u32::try_from(field.len()).unwrap_or(u32::MAX)); + buf.put_slice(field); +} + +fn take(buf: &mut Bytes, needed: usize) -> Result<Bytes> { + if buf.remaining() < needed { + return Err(RecordCodecError::EnvelopeTruncated { + needed, + remaining: buf.remaining(), + }); + } + Ok(buf.split_to(needed)) +} + +/// Length-prefixed bytes, possibly empty. Presence is the flags byte's job, not the length's, +/// so that an empty key stays distinct from a null one. +fn take_field(buf: &mut Bytes) -> Result<Bytes> { + let len = u32::from_le_bytes(take(buf, 4)?.as_ref().try_into().unwrap_or_default()) as usize; + take(buf, len) +} + +/// Both are infallible for the names and values this module builds: every one is non-empty and +/// within `MAX_FIELD`, which `needs_envelope` guarantees for caller-supplied bytes. +fn header_key(name: &str) -> HeaderKey { + HeaderKey::try_from(name).unwrap_or_else(|_| unreachable!("header name {name} is out of range")) +} + +fn header_value(value: &[u8]) -> HeaderValue { + HeaderValue::try_from(value).unwrap_or_else(|_| unreachable!("header value is out of range")) +} + +/// What one Produce request may decompress to, in total. +/// +/// Charged across every batch in the request, because one frame carries many batches and a cap +/// applied to each on its own admits as many multiples of it as the frame holds entries. +/// +/// This bounds accumulation, not peak. `kafka_protocol`'s decompressors write the whole stream +/// out before handing it over (`compression/gzip.rs:46` and its siblings), so one batch can still +/// allocate past the budget and be rejected only afterwards. Bounding peak needs a size-limited +/// reader per codec, which means owning Kafka's snappy and lz4 framing rather than borrowing it. +pub struct DecompressionBudget { + remaining: Cell<usize>, + /// What the charge was when it first tripped. The decompression hook reports failure through + /// `anyhow`, which the decoder stringifies, so the typed reason is kept here instead. + overflow: Cell<Option<(usize, usize)>>, +} + +impl DecompressionBudget { + #[must_use] + pub const fn new(bytes: usize) -> Self { + Self { + remaining: Cell::new(bytes), + overflow: Cell::new(None), + } + } + + fn charge(&self, produced: usize) -> anyhow::Result<()> { + let remaining = self.remaining.get(); + if produced > remaining { + self.overflow.set(Some((produced, remaining))); + anyhow::bail!("decompressed {produced} bytes with {remaining} left in the budget"); + } + self.remaining.set(remaining - produced); + Ok(()) + } +} + +/// Decodes every record batch a Produce partition entry carries. +/// +/// A partition's `records` field is one blob that holds one or more batches back to back, so +/// this drains `buf` rather than reading a single batch. +/// +/// # Errors +/// +/// Returns an error when a batch is malformed, or when the request decompresses to more than +/// `budget` allows. +pub fn decode_batches(buf: &mut Bytes, budget: &DecompressionBudget) -> Result<Vec<Record>> { + let mut records = Vec::new(); + while buf.has_remaining() { + let set = RecordBatchDecoder::decode_with_custom_compression( + buf, + Some(|compressed: &mut Bytes, compression| decompress(compressed, compression, budget)), + ) + .map_err(|error| { + budget.overflow.get().map_or_else( + || RecordCodecError::Batch(error.to_string()), + |(produced, remaining)| RecordCodecError::BudgetExceeded { + produced, + remaining, + }, + ) + })?; + records.extend(set.records); + } + Ok(records) +} + +/// Encodes records as one uncompressed v2 batch. +/// +/// Fetch always emits uncompressed, so the read path spends no CPU on a codec the client did +/// not ask for. `BRIDGE_MAPPING.md` records that as a default open to revisiting. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the batch. +pub fn encode_batch(records: &[Record]) -> Result<Bytes> { + let mut buf = BytesMut::new(); + let options = RecordEncodeOptions { + version: BATCH_VERSION, + compression: Compression::None, + }; + RecordBatchEncoder::encode(&mut buf, records, &options) + .map_err(|error| RecordCodecError::Batch(error.to_string()))?; + Ok(buf.freeze()) +} + +/// Decompresses one batch and charges what it produced against the request budget. +fn decompress( + compressed: &mut Bytes, + compression: Compression, + budget: &DecompressionBudget, +) -> anyhow::Result<Bytes> { + use kafka_protocol::compression::{Decompressor, Gzip, Lz4, Snappy, Zstd}; + + let take = + |buf: &mut Bytes| -> anyhow::Result<Bytes> { Ok(buf.copy_to_bytes(buf.remaining())) }; + let produced = match compression { + Compression::None => take(compressed)?, + Compression::Gzip => Gzip::decompress(compressed, take)?, + Compression::Snappy => Snappy::decompress(compressed, take)?, + Compression::Lz4 => Lz4::decompress(compressed, take)?, + Compression::Zstd => Zstd::decompress(compressed, take)?, + }; + budget.charge(produced.len())?; + Ok(produced) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record_with( + key: Option<&[u8]>, + value: Option<&[u8]>, + headers: &[(&str, Option<&[u8]>)], + ) -> Record { + let headers = headers + .iter() + .map(|(name, value)| { + ( + StrBytes::from_string((*name).to_string()), + value.map(Bytes::copy_from_slice), + ) + }) + .collect(); + record( + key.map(Bytes::copy_from_slice), + value.map(Bytes::copy_from_slice), + headers, + 0, + 1_700_000_000_000, + ) + } + + fn is_enveloped(message: &IggyMessage) -> bool { + message + .user_headers_map() + .unwrap() + .unwrap_or_default() + .contains_key(&header_key(ENVELOPE_HEADER)) + } + + #[test] + fn given_a_plain_record_when_round_tripped_should_keep_key_value_and_headers() { + let original = record_with(Some(b"k"), Some(b"v"), &[("trace", Some(b"abc"))]); + let message = to_iggy(&original).unwrap(); + assert!(!is_enveloped(&message)); + assert_eq!(message.payload.as_ref(), b"v"); + + let back = from_iggy(&message, 7).unwrap(); + assert_eq!(back.key.as_deref(), Some(&b"k"[..])); + assert_eq!(back.value.as_deref(), Some(&b"v"[..])); + assert_eq!(back.offset, 7); + assert_eq!( + back.headers.get(&StrBytes::from_static_str("trace")), + Some(&Some(Bytes::from_static(b"abc"))) + ); + } + + #[test] + fn given_a_null_value_when_round_tripped_should_stay_null() { + let message = to_iggy(&record_with(Some(b"k"), None, &[])).unwrap(); + assert!( + !is_enveloped(&message), + "a tombstone stays on the fast path" + ); + assert_eq!(message.payload.as_ref(), PLACEHOLDER); + assert_eq!(from_iggy(&message, 0).unwrap().value, None); + } + + #[test] + fn given_an_empty_value_when_round_tripped_should_stay_empty_and_not_null() { + let message = to_iggy(&record_with(Some(b"k"), Some(b""), &[])).unwrap(); + assert_eq!(message.payload.as_ref(), PLACEHOLDER); + assert_eq!( + from_iggy(&message, 0).unwrap().value.as_deref(), + Some(&[][..]) + ); + } + + #[test] + fn given_an_empty_key_when_stored_should_take_the_envelope() { + let original = record_with(Some(b""), Some(b"v"), &[]); + let message = to_iggy(&original).unwrap(); + assert!(is_enveloped(&message)); + let back = from_iggy(&message, 0).unwrap(); + assert_eq!(back.key.as_deref(), Some(&[][..]), "empty, not null"); + assert_eq!(back.value.as_deref(), Some(&b"v"[..])); + } + + #[test] + fn given_an_oversized_key_when_stored_should_take_the_envelope() { + let key = vec![b'x'; MAX_FIELD + 1]; + let message = to_iggy(&record_with(Some(&key), Some(b"v"), &[])).unwrap(); + assert!(is_enveloped(&message)); + assert_eq!( + from_iggy(&message, 0).unwrap().key.as_deref(), + Some(&key[..]) + ); + } + + #[test] + fn given_a_null_header_value_when_stored_should_take_the_envelope() { + let message = to_iggy(&record_with(Some(b"k"), Some(b"v"), &[("flag", None)])).unwrap(); + assert!(is_enveloped(&message)); + assert_eq!( + from_iggy(&message, 0) + .unwrap() + .headers + .get(&StrBytes::from_static_str("flag")), + Some(&None), + "a null header value survives the envelope as null" + ); + } + + #[test] + fn given_an_oversized_header_name_when_stored_should_take_the_envelope() { + let name = "n".repeat(MAX_FIELD - HEADER_PREFIX.len() + 1); + let message = + to_iggy(&record_with(Some(b"k"), Some(b"v"), &[(&name, Some(b"v"))])).unwrap(); + assert!(is_enveloped(&message)); + assert!( + from_iggy(&message, 0) + .unwrap() + .headers + .contains_key(&StrBytes::from_string(name)) + ); + } + + #[test] + fn given_an_iggy_written_message_when_encoded_should_have_a_null_key_and_its_own_headers() { + let mut headers = BTreeMap::new(); + headers.insert(header_key("source"), header_value(b"connector")); + let message = IggyMessage::builder() + .payload(Bytes::from_static(b"{}")) + .user_headers(headers) + .build() + .unwrap(); + + let record = from_iggy(&message, 3).unwrap(); + assert_eq!(record.key, None); + assert_eq!(record.value.as_deref(), Some(&b"{}"[..])); + assert_eq!( + record.headers.get(&StrBytes::from_static_str("source")), + Some(&Some(Bytes::from_static(b"connector"))) + ); + } + + #[test] + fn given_a_create_time_when_round_tripped_should_convert_between_milliseconds_and_micros() { + let millis = 1_700_000_000_123; + let message = to_iggy(&record_with(Some(b"k"), Some(b"v"), &[])).unwrap(); + assert_eq!(message.header.origin_timestamp, 1_700_000_000_000 * 1000); + assert_eq!(timestamp_in(millis).unwrap(), millis.cast_unsigned() * 1000); + } + + #[test] + fn given_no_timestamp_when_stored_should_store_zero() { + assert_eq!(timestamp_in(NO_TIMESTAMP).unwrap(), 0); + } + + #[test] + fn given_an_out_of_range_timestamp_when_stored_should_fail() { + assert!(matches!( + timestamp_in(i64::MAX), + Err(RecordCodecError::TimestampOutOfRange(_)) + )); + } + + #[test] + fn given_a_truncated_envelope_when_decoded_should_fail() { + let message = to_iggy(&record_with(Some(b""), Some(b"v"), &[])).unwrap(); + let mut truncated = message; + truncated.payload = truncated.payload.slice(0..3); + assert!(matches!( + from_iggy(&truncated, 0), + Err(RecordCodecError::EnvelopeTruncated { .. }) + )); + } + fn encode_with(records: &[Record], compression: Compression) -> Bytes { + let mut buf = BytesMut::new(); + let options = RecordEncodeOptions { + version: BATCH_VERSION, + compression, + }; + RecordBatchEncoder::encode(&mut buf, records, &options).unwrap(); + buf.freeze() + } + + #[test] + fn given_an_uncompressed_batch_when_round_tripped_should_keep_every_record() { + let records = vec![ + record_with(Some(b"a"), Some(b"1"), &[]), + record_with(Some(b"b"), Some(b"2"), &[]), + ]; + let mut encoded = encode_batch(&records).unwrap(); + let budget = DecompressionBudget::new(1024); + let decoded = decode_batches(&mut encoded, &budget).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[1].value.as_deref(), Some(&b"2"[..])); + } + + #[test] + fn given_two_batches_in_one_blob_when_decoded_should_drain_both() { + let mut blob = BytesMut::new(); + blob.extend_from_slice(&encode_batch(&[record_with(Some(b"a"), Some(b"1"), &[])]).unwrap()); + blob.extend_from_slice(&encode_batch(&[record_with(Some(b"b"), Some(b"2"), &[])]).unwrap()); + + let budget = DecompressionBudget::new(1024); + let decoded = decode_batches(&mut blob.freeze(), &budget).unwrap(); + assert_eq!(decoded.len(), 2, "a partition blob can hold many batches"); + } + + #[test] + fn given_a_gzip_batch_when_decoded_should_read_it() { + let records = vec![record_with(Some(b"a"), Some(b"compressed"), &[])]; + let mut encoded = encode_with(&records, Compression::Gzip); + let budget = DecompressionBudget::new(1024); + let decoded = decode_batches(&mut encoded, &budget).unwrap(); + assert_eq!(decoded[0].value.as_deref(), Some(&b"compressed"[..])); + } + + #[test] + fn given_a_budget_smaller_than_the_batch_when_decoded_should_reject() { + let records = vec![record_with(Some(b"a"), Some(&[b'x'; 512]), &[])]; + let mut encoded = encode_with(&records, Compression::Gzip); + let budget = DecompressionBudget::new(8); + assert!(matches!( + decode_batches(&mut encoded, &budget), + Err(RecordCodecError::BudgetExceeded { .. }) + )); + } + + #[test] + fn given_two_batches_when_the_second_passes_the_budget_should_reject() { + let big = record_with(Some(b"a"), Some(&[b'x'; 256]), &[]); + let mut blob = BytesMut::new(); + blob.extend_from_slice(&encode_batch(std::slice::from_ref(&big)).unwrap()); + blob.extend_from_slice(&encode_batch(std::slice::from_ref(&big)).unwrap()); + + // Enough for one batch, not for both: the budget is per request, not per batch. + let budget = DecompressionBudget::new(400); + assert!(matches!( + decode_batches(&mut blob.freeze(), &budget), + Err(RecordCodecError::BudgetExceeded { .. }) + )); + } +}
