This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch server_perf_cut_extra_work
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/server_perf_cut_extra_work by
this push:
new e52aeaad0 fix
e52aeaad0 is described below
commit e52aeaad0f3fd0acb11daa62277519c0336ea8a6
Author: spetz <[email protected]>
AuthorDate: Mon Sep 14 10:40:08 2026 +0200
fix
---
core/journal/src/partition_journal.rs | 86 ++++--
core/partitions/src/iggy_partition.rs | 134 ++++++++-
core/partitions/src/persistence.rs | 25 +-
core/partitions/src/poll_plan.rs | 241 ++++++++++++---
core/server/src/http.rs | 13 +-
core/server/src/http/forward.rs | 322 +++++++++++++++++----
core/server/src/http/state.rs | 2 +-
core/simulator/src/storage/tests.rs | 6 +-
.../org/apache/iggy/serde/BytesSerializer.java | 42 ++-
.../iggy/serde/MessagesBatchWireFormatTest.java | 30 +-
10 files changed, 736 insertions(+), 165 deletions(-)
diff --git a/core/journal/src/partition_journal.rs
b/core/journal/src/partition_journal.rs
index 47f18fb3f..9f03259fb 100644
--- a/core/journal/src/partition_journal.rs
+++ b/core/journal/src/partition_journal.rs
@@ -863,7 +863,7 @@ impl<S: DurableStorage> PartitionPrepareJournal<S> {
}
async fn retain_segment_inodes(
- &self,
+ &mut self,
records: &[(u64, StoredPrepare, usize)],
) -> io::Result<()> {
if self.state.segment_storage.is_some() {
@@ -876,6 +876,11 @@ impl<S: DurableStorage> PartitionPrepareJournal<S> {
&& retained_segments.insert((reference.generation,
reference.start_offset))
{
let retained = reference.path(&self.directory);
+ // A failed unlink leaves its path queued for a retry. This
+ // append is adopting that very link, so the retry would delete
+ // a segment the new generation references. Claiming it back is
+ // what makes reusing the existing inode safe.
+ self.obsolete.retain(|queued| queued != &retained);
if !self.storage.exists(&retained).await? {
let parent = self
.directory
@@ -1074,25 +1079,17 @@ impl<S: DurableStorage> PartitionPrepareJournal<S> {
Ok(())
}
- /// Whether [`Self::reclaim_obsolete`] has anything left to remove.
- #[must_use]
- pub fn has_obsolete(&self) -> bool {
- !self.obsolete.is_empty() || self.cleanup_directory_dirty
- }
-
- /// Remove a bounded batch of the files no published generation retains.
+ /// Remove a bounded batch of the files no published generation retains,
+ /// and retry a directory barrier a previous batch could not complete.
+ /// Does nothing when the queue is empty and no barrier is owed.
///
- /// Separate from the append path so an acknowledgement never waits on
- /// unlinks and a directory barrier for history it does not depend on. Only
- /// a checkpoint and the boot scan ever queue work here, and both do so
- /// after the publication that excludes those files, so the queue holds
- /// nothing a reader or a recovery could still need. The writer owns the
- /// journal, so this runs between mutations and never beside one.
- pub async fn reclaim_obsolete(&mut self) {
- self.cleanup_obsolete().await;
- }
-
- async fn cleanup_obsolete(&mut self) {
+ /// Off the append path, so the unlinks and the barrier do not sit inside a
+ /// group's durability. A generation reaches the queue from the boot scan,
+ /// or from the rewrite behind a checkpoint, a truncate, or a reset, and
+ /// always after the publication that stops naming it, so nothing here is
+ /// still reachable by a reader or a recovery. The writer owns the journal,
+ /// so this runs between mutations and never beside one.
+ pub async fn cleanup_obsolete(&mut self) {
let count = self.obsolete.len().min(16);
for _ in 0..count {
let Some(path) = self.obsolete.pop_front() else {
@@ -1865,18 +1862,55 @@ mod tests {
.unwrap();
journal.sync().await.unwrap();
- assert!(journal.has_obsolete());
assert!(
stale.exists(),
"the acknowledgement must not have waited on the unlink"
);
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
- assert!(!journal.has_obsolete());
assert!(!stale.exists());
}
+ /// A failed unlink leaves its path queued for a retry. When a later append
+ /// references that same segment again it adopts the link already on disk,
+ /// so the retry would delete an inode the live generation depends on.
+ #[compio::test]
+ async fn an_append_reclaims_the_retained_link_it_adopts() {
+ let partition = tempdir().unwrap();
+ let directory = partition.path().join("prepares-7");
+ let mut journal = PartitionPrepareJournal::open(&directory, 42, 7)
+ .await
+ .unwrap();
+
+ let prepare = sized_prepare(1, 0, size_of::<PrepareHeader>() + 4096);
+ let parent = prepare.header().checksum;
+ let reference = write_segment(partition.path(), 0, 0, &prepare).await;
+ let retained = reference.path(&directory);
+ journal
+ .append_batch_referenced_buffered(&[prepare.into_frozen()],
&[Some(reference)])
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ assert!(retained.exists(), "the body link must exist to be adopted");
+
+ // Stand in for a removal that failed and was queued again.
+ journal.obsolete.push_back(retained.clone());
+
+ let second = sized_prepare(2, parent, size_of::<PrepareHeader>() +
4096);
+ journal
+ .append_batch_referenced_buffered(&[second.into_frozen()],
&[Some(reference)])
+ .await
+ .unwrap();
+ journal.sync().await.unwrap();
+ journal.cleanup_obsolete().await;
+
+ assert!(
+ retained.exists(),
+ "reclamation must not delete a link the newest generation
references"
+ );
+ }
+
#[compio::test]
async fn
referenced_bodies_survive_retention_and_checkpoint_without_wal_copies() {
let partition = tempdir().unwrap();
@@ -1919,7 +1953,7 @@ mod tests {
assert_eq!(recovered[1].as_slice(), second.as_slice());
journal.checkpoint(2).await.unwrap();
assert_eq!(journal.size_bytes(), PARTITION_WAL_BLOCK_SIZE as u64);
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
assert!(!first_reference.path(&directory).exists());
assert!(second_reference.path(&directory).exists());
drop(journal);
@@ -2078,7 +2112,7 @@ mod tests {
assert_eq!(journal.purge_marker(), (1, 2));
journal.truncate_from(3).await.unwrap();
assert!(first_reference.path(&directory).exists());
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
assert!(!second_reference.path(&directory).exists());
drop(journal);
let journal = PartitionPrepareJournal::open(&directory, 42, 7)
@@ -2653,7 +2687,7 @@ mod tests {
"purge must not fabricate a committed frontier"
);
assert_eq!(journal.retained_bytes(), retained_bytes);
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
for reference in references {
assert!(!reference.path(&directory).exists());
std::fs::remove_file(
@@ -3278,7 +3312,7 @@ mod tests {
journal.prepares().await.unwrap()[0].as_slice(),
next.as_slice()
);
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
assert_eq!(checkpoint_reference.path(&directory).exists(),
materialized);
let expected = SegmentPosition {
length: initial.length + BODY_BYTES as u64,
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 8f0183ffc..8300980bc 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -29,8 +29,8 @@ use crate::offset_storage::{
};
use crate::persistence::{PartitionPersistence, PersistenceCompletion,
PersistenceNotifier};
use crate::poll_plan::{
- DiskReadPlan, DiskSegment, PartitionDirResolution, PollContext, PollPlan,
PollReadResult,
- PollTier, ResidentTailSnapshot,
+ DISK_POLL_CHUNK_MAX, DiskReadPlan, DiskSegment, PartitionDirResolution,
PollContext, PollPlan,
+ PollReadResult, PollTier, ResidentTailSnapshot,
};
use crate::segment::Segment;
use crate::state_transfer::{PartitionTransferSession, PendingTransferRearm};
@@ -140,6 +140,9 @@ where
/// persisted): a fresh server treats a group as never-polled.
pub last_polled_offsets: Arc<ConsumerGroupOffsets>,
pub stats: Arc<PartitionStats>,
+ /// Widest batch committed here, the floor for a disk poll's first read.
+ /// See [`Self::widest_committed_batch`].
+ widest_batch_bytes: Cell<u64>,
pub created_at: IggyTimestamp,
pub revision_id: u64,
pub(crate) offset_space: OffsetSpace,
@@ -587,6 +590,7 @@ where
consumer_group_offsets:
Arc::new(ConsumerGroupOffsets::with_capacity(1)),
last_polled_offsets:
Arc::new(ConsumerGroupOffsets::with_capacity(1)),
stats,
+ widest_batch_bytes: Cell::new(0),
created_at: IggyTimestamp::now(),
revision_id: 0,
offset_space: OffsetSpace::default(),
@@ -2992,6 +2996,17 @@ where
}
self.check_local_poll_key(kind, consumer_id)
.map_err(|error| self.poll_capacity_error(error))?;
+ // Already durable: the commit this poll would make has happened and
+ // replicated, so applying it locally syncs this replica to something
+ // the group agreed. Safe wherever the read was served, and checked
+ // before the role so a caught-up backup is not refused for a
+ // commit nobody needs.
+ if self
+ .durable_consumer_offsets
+ .covers(kind, consumer_id, offset)
+ {
+ return Ok(None);
+ }
let consensus = self.consensus();
// A replica that cannot originate the prepare cannot record this
// progress anywhere a peer will ever see. `Ok(None)` would leave
@@ -3002,12 +3017,6 @@ where
if !consensus.is_primary() || !consensus.is_normal() ||
consensus.is_transferring() {
return Err(IggyError::TransientNotAccepted);
}
- if self
- .durable_consumer_offsets
- .covers(kind, consumer_id, offset)
- {
- return Ok(None);
- }
let reservation = self
.consumer_offset_capacity_for(kind)
@@ -3890,7 +3899,7 @@ where
};
}
- let (start_segment, start_position) = self.disk_poll_start(&query);
+ let (start_segment, start_position, start_index_offset) =
self.disk_poll_start(&query);
// Cap resident sealed read handles: touch this poll's start segment so
// the LRU keeps the hot set and drops the least-recently-used fd +
// index (a no-op for the active segment, whose slot is bounded by
@@ -3915,9 +3924,11 @@ where
partition_dir: self.partition_dir_resolution(),
segments,
start_position,
+ start_index_offset,
namespace_raw: self.namespace().inner(),
validate_checksum,
bytes_per_message: self.mean_encoded_message_size(),
+ widest_batch_bytes: self.widest_committed_batch(),
};
// Snapshot the resident journal tail now (on the pump, under the
// borrow) so the straddle splice runs off-task on owned data with no
@@ -4146,10 +4157,27 @@ where
(messages > 0).then(|| u32::try_from(bytes /
messages).unwrap_or(u32::MAX))
}
+ /// Widest batch this partition has committed, for the disk walk's chunk
+ /// floor. A batch is the unit that walk can consume, so a read below the
+ /// widest one risks decoding nothing and paying a re-read.
+ ///
+ /// A high-water, never lowered: retention cannot make an older batch
+ /// narrower, and the read path clamps it to the chunk ceiling anyway, so
+ /// the worst a stale value costs is the fixed-size read polls did before
+ /// they were sized at all.
+ fn widest_committed_batch(&self) -> u64 {
+ let widest = self.widest_batch_bytes.get();
+ if widest == 0 || self.recovered_durable_offset.is_some() {
+ widest.max(DISK_POLL_CHUNK_MAX)
+ } else {
+ widest
+ }
+ }
+
/// Starting `(segment index, byte position)` for a disk poll, resolved
/// via each segment's sparse index cache. An index miss starts at the
/// segment's first byte (the walk filters precisely).
- fn disk_poll_start(&self, query: &MessageLookup) -> (usize, u64) {
+ fn disk_poll_start(&self, query: &MessageLookup) -> (usize, u64,
Option<u64>) {
let segments = self.log.segments();
match query {
MessageLookup::Offset { offset, .. } => {
@@ -4157,12 +4185,17 @@ where
.iter()
.rposition(|segment| segment.start_offset <= *offset)
.unwrap_or(0);
- let position = self
+ let entry = self
.log
.segment_indexes(segment_index)
- .and_then(|cache| cache.offset_lower_bound(*offset))
- .map_or(0, |index| index.position);
- (segment_index, position)
+ .and_then(|cache| cache.offset_lower_bound(*offset));
+ let position = entry.map_or(0, |index| index.position);
+ let entry_offset = entry.map(|index| index.offset).or_else(|| {
+ segments
+ .get(segment_index)
+ .map(|segment| segment.start_offset)
+ });
+ (segment_index, position, entry_offset)
}
MessageLookup::Timestamp { timestamp, .. } => {
// Resolve the starting SEGMENT from segment metadata, not from
@@ -4182,7 +4215,7 @@ where
.segment_indexes(segment_index)
.and_then(|cache| cache.timestamp_lower_bound(*timestamp))
.map_or(0, |index| index.position);
- (segment_index, position)
+ (segment_index, position, None)
}
}
}
@@ -6506,6 +6539,8 @@ where
}
if let Some(batch_stats) = batch_stats {
+ self.widest_batch_bytes
+
.set(self.widest_batch_bytes.get().max(batch_stats.size_bytes));
let end_offset = batch_stats.end_offset();
// The committed counter now names data, which is what
makes
// it pollable and persistable. Outside the
recovered-offset
@@ -11016,6 +11051,31 @@ mod tests {
pub(super) type SentFrames = Rc<RefCell<Vec<(u128,
Frozen<MESSAGE_ALIGN>)>>>;
+ #[test]
+ fn recovered_history_keeps_a_conservative_batch_read_floor() {
+ let (mut partition, _) = recording_partition();
+ assert_eq!(partition.widest_committed_batch(), DISK_POLL_CHUNK_MAX);
+ partition.widest_batch_bytes.set(4096);
+ assert_eq!(partition.widest_committed_batch(), 4096);
+ partition.recovered_durable_offset = Some(100);
+ assert_eq!(partition.widest_committed_batch(), DISK_POLL_CHUNK_MAX);
+ }
+
+ #[test]
+ fn active_disk_poll_keeps_the_sparse_index_offset() {
+ let (mut partition, _) = recording_partition();
+ partition.log.ensure_indexes();
+ let index = partition.log.active_indexes_mut().unwrap();
+ index.insert(0, 0, 0);
+ index.insert(100, 100, 6400);
+ let query = MessageLookup::Offset {
+ offset: 150,
+ count: 10,
+ ceiling: 200,
+ };
+ assert_eq!(partition.disk_poll_start(&query), (0, 6400, Some(100)));
+ }
+
fn recording_partition() -> (IggyPartition<RecordingBus>, SentFrames) {
recording_partition_at(0, 1)
}
@@ -12185,6 +12245,24 @@ mod tests {
assert_eq!(partition.consensus.pipeline_len(), 0);
}
+ /// A backup whose durable table already covers the offset has nothing to
+ /// replicate, so there is no divergence to prevent and the refusal must
+ /// not reach it. Otherwise a caught-up follower would fail reads over a
+ /// commit the group already agreed.
+ #[test]
+ fn
given_backup_when_auto_commit_offset_is_already_durable_should_be_accepted() {
+ let (mut partition, _) = recording_partition_at(1, 3);
+ let consumer = PollingConsumer::Consumer(7, 0);
+ partition.seed_recovered_consumer_offset(ConsumerKind::Consumer, 7, 9,
9);
+ let read_result = poll_read_result(&partition, consumer, true,
Some(9));
+
+ let completion = partition
+ .complete_poll(read_result)
+ .expect("an already-durable offset admits no commit");
+ assert!(completion.replication.is_none());
+ assert_eq!(partition.consensus.pipeline_len(), 0);
+ }
+
/// An empty read never reaches automatic-commit admission and mutates no
/// progress, so the refusal above must not spread to it: a backup has to
/// keep answering the tail of a partition it is caught up on.
@@ -13902,6 +13980,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::Resolved(partition_dir),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![
DiskSegment {
@@ -13918,6 +13997,7 @@ mod tests {
},
],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
@@ -13976,6 +14056,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::Resolved(partition_dir),
bytes_per_message: Some(1),
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -13984,6 +14065,7 @@ mod tests {
sealed: false,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
@@ -14062,6 +14144,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::Resolved(partition_dir),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![
DiskSegment {
@@ -14078,6 +14161,7 @@ mod tests {
},
],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
@@ -14138,6 +14222,7 @@ mod tests {
let plan = |validate_checksum| DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14146,6 +14231,7 @@ mod tests {
sealed: false,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let query = MessageLookup::Offset {
@@ -14178,6 +14264,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::NoFiles,
bytes_per_message: None,
+ widest_batch_bytes: 0,
segments: vec![DiskSegment {
start_offset: 0,
persisted: 512,
@@ -14185,6 +14272,7 @@ mod tests {
sealed: false,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: IggyNamespace::new(1, 1, 0).inner(),
validate_checksum: true,
};
@@ -14211,6 +14299,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::Unresolvable,
bytes_per_message: None,
+ widest_batch_bytes: 0,
segments: vec![DiskSegment {
start_offset: 0,
persisted: 512,
@@ -14218,6 +14307,7 @@ mod tests {
sealed: false,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: IggyNamespace::new(1, 1, 0).inner(),
validate_checksum: true,
};
@@ -14279,6 +14369,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14287,6 +14378,7 @@ mod tests {
sealed: true,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let first = plan
@@ -14312,6 +14404,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14320,6 +14413,7 @@ mod tests {
sealed: true,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let second = plan
@@ -14374,6 +14468,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14382,6 +14477,7 @@ mod tests {
sealed: true,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let outcome = plan
@@ -14460,6 +14556,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14470,6 +14567,7 @@ mod tests {
// Byte 0, exactly what disk_poll_start returns for a sealed
segment
// whose resident index was dropped.
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let outcome = plan
@@ -14561,6 +14659,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14569,6 +14668,7 @@ mod tests {
sealed: true,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let outcome = plan
@@ -14640,6 +14740,7 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14648,6 +14749,7 @@ mod tests {
sealed: true,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let before_purge = plan
@@ -14690,6 +14792,7 @@ mod tests {
let resumed = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(partition_dir.clone()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
validate_checksum: true,
segments: vec![DiskSegment {
start_offset: 0,
@@ -14698,6 +14801,7 @@ mod tests {
sealed: true,
}],
start_position: 0,
+ start_index_offset: None,
namespace_raw: namespace.inner(),
};
let after_purge = resumed
diff --git a/core/partitions/src/persistence.rs
b/core/partitions/src/persistence.rs
index 4c9255b03..026872152 100644
--- a/core/partitions/src/persistence.rs
+++ b/core/partitions/src/persistence.rs
@@ -45,6 +45,12 @@ use nix::sys::resource::{Resource, getrlimit};
const APPEND_BATCH_BYTES_MAX: u64 = 8 * 1024 * 1024;
const APPEND_BATCH_OPS_MAX: usize = 256;
const CHECKPOINT_DIRTY_FILES_MAX: usize = 1024;
+/// Mutations a partition may apply before its obsolete files are reclaimed
+/// whether or not the queue has drained. Reclaiming only on an idle queue
+/// keeps the unlinks off every acknowledgement, but a partition under
+/// continuous load never goes idle and would hold its old generations until
+/// it did.
+const RECLAIM_MUTATIONS_MAX: u32 = 64;
#[cfg(unix)]
const OFFSET_FILES_TOTAL_MAX: usize = 1024;
const OFFSET_FILES_PER_PARTITION_MAX: usize = 64;
@@ -1112,6 +1118,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
guard.complete = true;
return;
};
+ let mut mutations_since_reclaim = 0u32;
loop {
if self.retired.get() {
break;
@@ -1150,14 +1157,16 @@ impl<S: DurableStorage> PartitionPersistence<S> {
if epoch == self.epoch.get() && !self.retired.get() {
self.publish_mutation(journal, rebuild_references);
}
- // After the notification, never before the barrier it would delay.
- // A checkpoint queues the generation it replaced, so reclaiming it
- // here keeps the unlinks and the directory barrier out of the
- // acknowledgement the next append is waiting on, while still
- // running once per mutation so a busy partition reclaims as
- // promptly as an idle one.
- if journal.has_obsolete() {
- journal.reclaim_obsolete().await;
+ // Reclaim when nothing is queued behind this mutation, so no
+ // acknowledgement pays for the unlinks and the directory barrier.
+ // A partition that never drains would then never reclaim, so force
+ // a pass every `RECLAIM_MUTATIONS_MAX` mutations and accept that
+ // one group's latency.
+ mutations_since_reclaim += 1;
+ let idle = self.queue.borrow().is_empty();
+ if idle || mutations_since_reclaim >= RECLAIM_MUTATIONS_MAX {
+ mutations_since_reclaim = 0;
+ journal.cleanup_obsolete().await;
}
}
guard.complete = true;
diff --git a/core/partitions/src/poll_plan.rs b/core/partitions/src/poll_plan.rs
index b0906d54b..64cdfdcbc 100644
--- a/core/partitions/src/poll_plan.rs
+++ b/core/partitions/src/poll_plan.rs
@@ -28,10 +28,11 @@ use crate::journal::{
};
use crate::{PollFragments, PollingConsumer};
use compio::io::AsyncReadAtExt;
+use iggy_binary_protocol::{WireError, batch};
use iggy_common::{ConsumerKind, IggyError};
use server_common::iobuf::{Frozen, Owned};
use server_common::poll::PollHistoryId;
-use server_common::send_messages::{BatchIntegrity, COMMAND_HEADER_SIZE,
decode_batch_slice_with};
+use server_common::send_messages::{BatchIntegrity, COMMAND_HEADER_SIZE};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use tracing::{error, warn};
@@ -135,14 +136,21 @@ pub struct DiskReadPlan {
/// first one.
pub(crate) segments: Vec<DiskSegment>,
pub(crate) start_position: u64,
+ pub(crate) start_index_offset: Option<u64>,
pub(crate) namespace_raw: u64,
/// Whether to verify each batch's `batch_checksum` against the bytes read.
/// Detection only; a mismatch fails the poll closed and repairs nothing.
pub(crate) validate_checksum: bool,
/// Mean encoded bytes per message on this partition, or `None` before it
- /// has committed anything. Sizes the disk walk's reads; never a bound on
- /// what a read may return, since messages vary in size within a partition.
+ /// has committed anything. Sizes the disk walk's reads, and is never a
+ /// bound on what a read may return, since messages vary in size within a
+ /// partition.
pub(crate) bytes_per_message: Option<u32>,
+ /// Widest batch this partition has committed, which is the smallest read
+ /// that is guaranteed to contain a whole one. The walk cannot advance on a
+ /// chunk holding no complete batch, so a count-derived estimate below this
+ /// buys nothing and costs the re-read it triggers.
+ pub(crate) widest_batch_bytes: u64,
}
pub struct DiskSegment {
@@ -390,7 +398,7 @@ pub enum DiskReadOutcome {
/// Largest first read of a disk poll, and the size every poll used to read
/// whatever it asked for. A batch wider than this still grows past it through
/// the re-read path below; this bounds only where a walk starts.
-const DISK_POLL_CHUNK_MAX: u64 = 1 << 20;
+pub const DISK_POLL_CHUNK_MAX: u64 = 1 << 20;
/// Smallest first read of a disk poll. Below this the syscall and the segment
/// walk cost more than the bytes the smaller read saves, and a poll for a
@@ -411,6 +419,10 @@ enum SegmentWalk {
struct DiskWalk {
/// Byte offset into the segment being walked; reset at each boundary.
position: u64,
+ /// Messages between the resolved index entry and the requested offset,
+ /// which the first read has to cover on top of what the poll asked for.
+ /// Cleared once anything matches, since the walk is then at the target.
+ skipped: u32,
matched: u32,
fragments: PollFragments<4096>,
last_matching_offset: Option<u64>,
@@ -421,9 +433,16 @@ struct DiskWalk {
}
impl DiskWalk {
- fn starting_at(position: u64) -> Self {
+ /// Messages the next read has to cover: what the poll still wants, plus
+ /// the run the sparse index left in front of the first match.
+ const fn remaining_to_read(&self, count: u32) -> u32 {
+ (count - self.matched).saturating_add(self.skipped)
+ }
+
+ fn starting_at(position: u64, skipped: u32) -> Self {
Self {
position,
+ skipped,
matched: 0,
fragments: PollFragments::new(),
last_matching_offset: None,
@@ -438,15 +457,25 @@ impl DiskWalk {
impl DiskReadPlan {
/// Bytes to read for the next `remaining` messages.
///
- /// A poll asks for a message count, and the walk reads bytes, so the two
- /// are bridged by the partition's own mean encoded size. Reading a fixed
+ /// A poll asks for a message count and the walk reads bytes, so the two
are
+ /// bridged by the partition's own mean encoded size. Reading a fixed
/// megabyte instead costs a poll for a thousand hundred-byte messages ten
/// times the bytes it returns, and the sparse-selection copy that follows
/// scales with the chunk rather than with the selection.
///
- /// The estimate is deliberately not a bound. Messages vary in size, the
- /// starting offset can sit inside a batch the index resolved before it,
- /// and an underestimate only costs another read of the next chunk.
+ /// The count-derived estimate alone is not enough, because a batch is the
+ /// unit the walk can consume. A poll for fewer messages than a producer
put
+ /// in one batch estimates below that batch, decodes nothing, and pays the
+ /// quadrupling re-read below: at one message short of a full batch that is
+ /// five times the bytes a flat megabyte read would have taken. So the
+ /// estimate is floored at the widest batch this partition has committed,
+ /// which is the smallest read guaranteed to hold a whole one.
+ ///
+ /// The result is still not a bound. Messages vary in size, the starting
+ /// offset can sit inside a batch the index resolved before it, and a batch
+ /// wider than the ceiling still grows through the re-read path. The
ceiling
+ /// is what every poll read before it was sized at all, so no poll reads
+ /// more than it used to.
fn chunk_len(&self, remaining: u32) -> u64 {
let Some(bytes_per_message) = self.bytes_per_message else {
return DISK_POLL_CHUNK_MAX;
@@ -454,6 +483,7 @@ impl DiskReadPlan {
u64::from(bytes_per_message)
.saturating_mul(u64::from(remaining))
.saturating_add(COMMAND_HEADER_SIZE as u64)
+ .max(self.widest_batch_bytes)
.clamp(DISK_POLL_CHUNK_MIN, DISK_POLL_CHUNK_MAX)
}
@@ -500,14 +530,25 @@ impl DiskReadPlan {
// miss or load failure keeps `start_position` (the pre-existing
// full-scan fallback). An active first segment keeps its
// resident-index-resolved `start_position` untouched.
- let position = match self.segments.first() {
- Some(first) => self
- .resolve_sealed_start(first, query, partition_dir)
- .await
- .unwrap_or(self.start_position),
- None => self.start_position,
+ let resolved = match self.segments.first() {
+ Some(first) => self.resolve_sealed_start(first, query,
partition_dir).await,
+ None => None,
+ };
+ let position = resolved.map_or(self.start_position, |(position, _)|
position);
+ // The index is sparse, so the entry it resolved can sit a whole flush
+ // group before the requested offset. The walk has to read that run to
+ // reach the first match, and sizing the read from the requested count
+ // alone would cross it in floor-sized reads.
+ let entry_offset = resolved
+ .map(|(_, offset)| offset)
+ .or(self.start_index_offset);
+ let skipped = match (entry_offset, query) {
+ (Some(entry_offset), MessageLookup::Offset { offset, .. }) => {
+
u32::try_from(offset.saturating_sub(entry_offset)).unwrap_or(u32::MAX)
+ }
+ _ => 0,
};
- let mut walk = DiskWalk::starting_at(position);
+ let mut walk = DiskWalk::starting_at(position, skipped);
// Set when an open/read retry exhausts. The walk breaks immediately so
// later segments are never read into the result (which would leave a
// gap at the faulted segment). Pre-fault matches are still served.
@@ -577,8 +618,10 @@ impl DiskReadPlan {
/// Read one segment from `walk.position` until the count is filled, the
/// segment is exhausted, or the walk must fail closed.
///
- /// The chunk length is already bounded by `DISK_POLL_CHUNK_MAX` and by the
- /// segment's persisted bytes, so narrowing it to a `usize` cannot
truncate.
+ /// The read length is the chunk clipped to the segment's persisted bytes,
+ /// so narrowing it to a `usize` cannot truncate. The chunk itself is not
+ /// bounded by `DISK_POLL_CHUNK_MAX`: a batch wider than the ceiling grows
+ /// past it below.
#[allow(clippy::cast_possible_truncation)]
async fn walk_segment(
&self,
@@ -588,7 +631,7 @@ impl DiskReadPlan {
persisted: u64,
walk: &mut DiskWalk,
) -> SegmentWalk {
- let mut chunk_len = self.chunk_len(count - walk.matched);
+ let mut chunk_len = self.chunk_len(walk.remaining_to_read(count));
while walk.matched < count && walk.position < persisted {
let len = (persisted - walk.position).min(chunk_len) as usize;
let Some(chunk) = self.read_chunk_with_retry(file, len,
walk).await else {
@@ -597,7 +640,11 @@ impl DiskReadPlan {
return SegmentWalk::Faulted;
};
let fragments_before_chunk = walk.fragments.len();
- let ChunkWalk { consumed, corrupt } = walk_disk_chunk(
+ let ChunkWalk {
+ consumed,
+ needed,
+ corrupt,
+ } = walk_disk_chunk(
&chunk,
query,
count,
@@ -632,11 +679,23 @@ impl DiskReadPlan {
// run, which would punch a silent gap into the poll.
return SegmentWalk::Faulted;
}
- // A single batch larger than the chunk: grow and re-read.
- chunk_len = chunk_len.saturating_mul(4);
+ // A single batch larger than the chunk. Its own header says
+ // how wide it is, so re-read exactly that; only a header this
+ // read could not reach leaves the old quadrupling.
+ if needed as u64 > persisted - walk.position {
+ return SegmentWalk::Faulted;
+ }
+ chunk_len = if needed > len {
+ needed as u64
+ } else {
+ chunk_len.saturating_mul(4)
+ };
continue;
}
- chunk_len = self.chunk_len(count - walk.matched);
+ if walk.matched > 0 {
+ walk.skipped = 0;
+ }
+ chunk_len = self.chunk_len(walk.remaining_to_read(count));
walk.position += consumed as u64;
}
SegmentWalk::Done
@@ -682,7 +741,7 @@ impl DiskReadPlan {
segment: &DiskSegment,
query: MessageLookup,
partition_dir: &str,
- ) -> Option<u64> {
+ ) -> Option<(u64, u64)> {
// The active segment grows under the reader, so neither the shared
// sparse index nor the offset memo can describe it; its own resident
// index already resolved `start_position`.
@@ -707,7 +766,7 @@ impl DiskReadPlan {
&& offset >= cursor.offset
&& offset < cursor.valid_until
{
- return Some(cursor.position);
+ return Some((cursor.position, cursor.offset));
}
let path = format!("{partition_dir}/{:0>20}.index",
segment.start_offset);
let reader = match IggyIndexReader::new(&path).await {
@@ -758,7 +817,7 @@ impl DiskReadPlan {
}
};
match looked_up {
- Ok(entry) => entry.map(|entry| entry.position),
+ Ok(entry) => entry.map(|entry| (entry.position, entry.offset)),
Err(error) => {
self.warn_sparse_index_fallback(&path, "lower_bound", &error);
None
@@ -848,12 +907,15 @@ impl DiskReadPlan {
/// timestamp, or `None` when the query is below the first indexed entry (the
/// caller then scans from the segment start). Mirrors `disk_poll_start`'s
/// resident-index resolution for the sealed, off-pump path.
-fn resolve_index_position(index: &IggyIndexCache, query: MessageLookup) ->
Option<u64> {
+/// Start byte for `query`, and the offset of the index entry it resolved to.
+/// The entry sits at or before the requested offset, so the difference is the
+/// run the walk has to skip before it can match anything.
+fn resolve_index_position(index: &IggyIndexCache, query: MessageLookup) ->
Option<(u64, u64)> {
match query {
MessageLookup::Offset { offset, .. } =>
index.offset_lower_bound(offset),
MessageLookup::Timestamp { timestamp, .. } =>
index.timestamp_lower_bound(timestamp),
}
- .map(|entry| entry.position)
+ .map(|entry| (entry.position, entry.offset))
}
/// Walk stamped `[256B BatchHeader][blob]` batches in one disk
@@ -873,11 +935,16 @@ fn walk_disk_chunk(
) -> ChunkWalk {
let bytes: &[u8] = chunk;
let mut cursor = 0usize;
+ let mut needed = 0usize;
while *matched < count && cursor + COMMAND_HEADER_SIZE <= bytes.len() {
- let batch = match decode_batch_slice_with(&bytes[cursor..], integrity)
{
+ let batch = match batch::decode_batch_slice_with(&bytes[cursor..],
integrity) {
Ok(batch) => batch,
- Err(IggyError::InvalidBatchChecksum(found, expected, base_offset))
=> {
+ Err(WireError::InvalidBatchChecksum {
+ stored: found,
+ computed: expected,
+ base_offset,
+ }) => {
// Distinguished from the incomplete-tail case below: this
batch is
// entirely present and fails its own checksum, so it is
damaged at rest.
error!(
@@ -892,13 +959,24 @@ fn walk_disk_chunk(
);
return ChunkWalk {
consumed: cursor.min(bytes.len()),
+ needed: 0,
corrupt: true,
};
}
- Err(_) => {
- // Incomplete tail batch: hand the position back to re-read or
bail.
+ Err(WireError::UnexpectedEof { need, .. })
+ if need <= journal::partition_journal::PREPARE_BYTES_MAX =>
+ {
+ needed = need;
break;
}
+ Err(error) => {
+ error!(namespace_raw, position = cursor, %error, "invalid disk
batch");
+ return ChunkWalk {
+ consumed: cursor,
+ needed: 0,
+ corrupt: true,
+ };
+ }
};
let total_size = batch.header.total_size();
@@ -921,6 +999,7 @@ fn walk_disk_chunk(
ChunkWalk {
consumed: cursor.min(bytes.len()),
+ needed,
corrupt: false,
}
}
@@ -929,6 +1008,10 @@ fn walk_disk_chunk(
/// than on a batch that simply did not fit in the chunk.
struct ChunkWalk {
consumed: usize,
+ /// Bytes the batch that did not fit needs in full, from its own header, or
+ /// zero when that header could not be read. Lets the caller re-read
+ /// exactly the batch instead of doubling its way up to it.
+ needed: usize,
corrupt: bool,
}
@@ -936,8 +1019,13 @@ struct ChunkWalk {
mod tests {
use super::*;
use crate::iggy_index::IggyIndex;
+ use bytes::Bytes;
use compio::io::AsyncWriteAtExt;
use server_common::iobuf::Owned;
+ use server_common::send_messages::{
+ IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
+ };
+ use server_common::sharding::IggyNamespace;
/// Write a sealed-segment index file too large to materialize
/// (`entry_count * IGGY_INDEX_SIZE > SEALED_INDEX_RESIDENT_MAX_BYTES`), so
@@ -962,16 +1050,47 @@ mod tests {
entry_count
}
- #[test]
- fn chunk_len_sizes_the_first_read_from_the_requested_count() {
- let plan = |bytes_per_message| DiskReadPlan {
+ fn sizing_plan(bytes_per_message: Option<u32>, widest_batch_bytes: u64) ->
DiskReadPlan {
+ DiskReadPlan {
partition_dir: PartitionDirResolution::NoFiles,
bytes_per_message,
+ widest_batch_bytes,
segments: Vec::new(),
start_position: 0,
+ start_index_offset: None,
namespace_raw: 0,
validate_checksum: false,
- };
+ }
+ }
+
+ /// A batch is the unit the walk can consume, so a count-derived estimate
+ /// that lands under one decodes nothing and pays the quadrupling re-read.
+ /// A poll one message short of a producer's batch was the worst case,
+ /// reading about five times what a flat megabyte would have.
+ #[test]
+ fn chunk_len_never_lands_under_a_whole_batch() {
+ let batch = COMMAND_HEADER_SIZE as u64 + 1000 * 1000;
+ let mean = u32::try_from(batch / 1000).expect("mean fits");
+ let plan = sizing_plan(Some(mean), batch);
+
+ assert!(plan.chunk_len(999) >= batch);
+ assert!(plan.chunk_len(500) >= batch);
+ assert!(plan.chunk_len(1) >= batch);
+ // The floor never pushes a read above what an unsized poll would take.
+ assert_eq!(
+ sizing_plan(Some(mean), 4 << 20).chunk_len(1),
+ DISK_POLL_CHUNK_MAX
+ );
+ // A count wide enough to matter still wins over the floor.
+ assert_eq!(
+ sizing_plan(Some(10), 2048).chunk_len(1000),
+ DISK_POLL_CHUNK_MIN
+ );
+ }
+
+ #[test]
+ fn chunk_len_sizes_the_first_read_from_the_requested_count() {
+ let plan = |bytes_per_message| sizing_plan(bytes_per_message, 0);
// Nothing committed yet, so nothing bridges a count to bytes.
assert_eq!(plan(None).chunk_len(1000), DISK_POLL_CHUNK_MAX);
@@ -1001,12 +1120,14 @@ mod tests {
let plan = DiskReadPlan {
partition_dir: PartitionDirResolution::NoFiles,
bytes_per_message: None,
+ widest_batch_bytes: 0,
segments: Vec::new(),
start_position: 0,
+ start_index_offset: None,
namespace_raw: 0,
validate_checksum: true,
};
- let mut walk = DiskWalk::starting_at(0);
+ let mut walk = DiskWalk::starting_at(0, 0);
assert!(
plan.read_chunk_with_retry(&file, 64, &mut walk)
.await
@@ -1017,6 +1138,42 @@ mod tests {
assert_eq!(walk.matched, 0);
}
+ #[cfg(feature = "poll-diagnostics")]
+ #[compio::test]
+ async fn incomplete_batch_reread_uses_its_exact_length() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut messages = IggyMessages::with_capacity(1);
+ messages.push(IggyMessage {
+ header: IggyMessageHeader {
+ payload_length: 128 << 10,
+ ..Default::default()
+ },
+ payload: Bytes::from(vec![1; 128 << 10]),
+ user_headers: None,
+ });
+ let batch =
+ SendMessagesOwned::from_messages(IggyNamespace::new(1, 1, 0),
&messages).unwrap();
+ let length = batch.header.total_size();
+ let mut record = vec![0; length];
+ batch.header.encode_into(&mut record);
+ record[COMMAND_HEADER_SIZE..].copy_from_slice(&batch.blob);
+ let mut file =
compio::fs::File::create(directory.path().join("batches.log"))
+ .await
+ .unwrap();
+ let (written, _) = file.write_all_at(record.repeat(4), 0).await.into();
+ written.unwrap();
+ let plan = sizing_plan(Some(1), 0);
+ let mut walk = DiskWalk::starting_at(0, 0);
+ assert!(matches!(
+ plan.walk_segment(&file, offset_query(0), 1, (length * 4) as u64,
&mut walk)
+ .await,
+ SegmentWalk::Done
+ ));
+ assert_eq!(walk.matched, 1);
+ assert_eq!(walk.chunk_reads, 2);
+ assert_eq!(walk.requested_bytes, DISK_POLL_CHUNK_MIN + length as u64);
+ }
+
fn offset_query(offset: u64) -> MessageLookup {
MessageLookup::Offset {
offset,
@@ -1048,8 +1205,10 @@ mod tests {
let plan = DiskReadPlan {
partition_dir:
PartitionDirResolution::Resolved(dir.display().to_string()),
bytes_per_message: None,
+ widest_batch_bytes: 0,
segments: Vec::new(),
start_position: 0,
+ start_index_offset: None,
namespace_raw: 0,
validate_checksum: false,
};
@@ -1060,7 +1219,9 @@ mod tests {
let first = plan
.resolve_sealed_start(&segment, offset_query(25), &partition_dir)
.await;
- assert_eq!(first, Some(200));
+ // The entry offset rides along so the caller can size its first read
+ // to cover the run between that entry and the requested offset.
+ assert_eq!(first, Some((200, 20)));
let cursor = handle.offset_cursor.get().expect("cursor memoized");
assert_eq!(
(cursor.offset, cursor.valid_until, cursor.position),
@@ -1073,7 +1234,7 @@ mod tests {
let in_interval = plan
.resolve_sealed_start(&segment, offset_query(29), &partition_dir)
.await;
- assert_eq!(in_interval, Some(200));
+ assert_eq!(in_interval, Some((200, 20)));
// ...while an offset past the interval misses the cursor, reaches for
// the (now gone) file, and falls back to the byte-0 scan.
diff --git a/core/server/src/http.rs b/core/server/src/http.rs
index e7ecdc1ff..9d04339f5 100644
--- a/core/server/src/http.rs
+++ b/core/server/src/http.rs
@@ -350,10 +350,6 @@ fn router(
"/personal-access-tokens/login",
post(login_with_personal_access_token),
)
- .route(
- "/streams/{stream_id}/topics/{topic_id}/messages",
- get(poll_messages),
- )
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
get(get_consumer_offset),
@@ -418,11 +414,18 @@ fn router(
/// Acknowledged partition writes use a bounded HTTP roster fallback. This is
/// a correctness path for the existing stateless HTTP transport. Direct
/// partition-primary routing remains the scalable long-term design.
+///
+/// A poll rides the same fallback, because an automatic commit is a partition
+/// write in every sense but the verb: only a replica that can originate the
+/// offset operation may serve one, and the refusal it would otherwise return
+/// is terminal for an HTTP caller, which has no roster of its own to walk.
+/// [`forward::forward_partition_write`] leaves a poll without automatic commit
+/// entirely local, so an ordinary read still pays nothing for this.
fn partition_write_routes(state: HttpState) -> Router<HttpState> {
Router::new()
.route(
"/streams/{stream_id}/topics/{topic_id}/messages",
- post(send_messages),
+ post(send_messages).get(poll_messages),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
diff --git a/core/server/src/http/forward.rs b/core/server/src/http/forward.rs
index 423f073a4..d8e513675 100644
--- a/core/server/src/http/forward.rs
+++ b/core/server/src/http/forward.rs
@@ -54,11 +54,12 @@
use std::cell::Cell;
use std::net::SocketAddr;
use std::path::Path;
+use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::body::{Body, to_bytes};
-use axum::extract::{Request, State};
+use axum::extract::{Query, Request, State};
use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware::Next;
@@ -66,7 +67,7 @@ use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use configs::http::HttpTlsConfig;
use consensus::MetadataHandle;
-use futures::StreamExt;
+use futures::{Stream, StreamExt};
use iggy_common::IggyError;
use message_bus::transports::tls::load_pem;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified,
ServerCertVerifier};
@@ -118,9 +119,9 @@ const MAX_IN_FLIGHT_FORWARDS: u32 = 128;
/// Bound on a relayed response body, enforced twice: against a declared
/// `content-length` before the read, and as a running cap on the streamed
-/// bytes so a length-less reply is bounded too. Forwarded routes answer
-/// entity JSON, not message batches (poll and snapshot are served locally),
-/// so this is a backstop, not a working limit.
+/// bytes so a length-less reply is bounded too. Successful poll responses
+/// stream separately because their automatic commit may already have advanced
+/// progress, so rejecting their total size would discard acknowledged data.
const RESPONSE_BODY_LIMIT: usize = 64 * 1024 * 1024;
/// Pre-allocation hint cap for a relayed body: honest control-plane replies
@@ -220,7 +221,7 @@ pub(in crate::http) fn build_forward_state(
client,
scheme,
body_limit,
- in_flight: Cell::new(0),
+ in_flight: Rc::new(Cell::new(0)),
})
}
@@ -334,6 +335,14 @@ async fn forward_partition_or_pass(state: HttpState,
request: Request, next: Nex
if !state.forward.active ||
request.headers().contains_key(FORWARDED_HEADER) {
return next.run(request).await;
}
+ // A read that does not move consumer progress is answered by whichever
+ // replica the caller reached, which is the point of reading from one. Only
+ // an automatic commit needs a replica that can originate the offset
+ // operation, so only that poll pays for the buffering and the credential
+ // resolution below.
+ if request.method() == Method::GET &&
!wants_auto_commit(request.uri().query()) {
+ return next.run(request).await;
+ }
let bearer = match bearer_token(request.headers()) {
Ok(bearer) => bearer,
Err(error) => return CustomError::from(error).into_response(),
@@ -364,7 +373,7 @@ async fn forward_partition_or_pass(state: HttpState,
request: Request, next: Nex
return response;
}
- let Some(_guard) = ForwardGuard::admit(&state.forward.in_flight) else {
+ let Some(guard) = ForwardGuard::admit(&state.forward.in_flight) else {
return with_retry_after(error_response(
StatusCode::SERVICE_UNAVAILABLE,
"forward_busy",
@@ -385,7 +394,7 @@ async fn forward_partition_or_pass(state: HttpState,
request: Request, next: Nex
}
let url = format!("{}://{socket}{path_and_query}",
state.forward.scheme);
match attempt(&state, &method, &request_headers, &body, &url,
false).await {
- AttemptOutcome::Relay(response) => return response,
+ AttemptOutcome::Relay(response) => return
retain_forward_guard(response, guard),
AttemptOutcome::Retry => {}
}
}
@@ -476,51 +485,7 @@ async fn attempt(
Ok(response) => response,
Err(error) => return classify_transport_error(&error),
};
- let status = response.status();
- // Only the relayed subset survives; the response is consumed by the
- // body stream below, so the values are pulled out first.
- let relayed_headers: Vec<(HeaderName, HeaderValue)> =
RELAYED_RESPONSE_HEADERS
- .into_iter()
- .filter_map(|name| {
- let value = response.headers().get(&name)?.clone();
- Some((name, value))
- })
- .collect();
- let declared = response.content_length();
- if declared.is_some_and(|length| length > RESPONSE_BODY_LIMIT as u64) {
- warn!(?declared, "relayed response exceeds the body bound");
- return AttemptOutcome::Relay(bad_gateway());
- }
- // Streamed with a running cap so a length-less reply is bounded by
- // the limit, not merely by the attempt timeout. The capacity hint is
- // clamped to RESPONSE_CAPACITY_HINT so a mis-declared content-length
- // cannot pre-reserve the full bound. The running cap still bounds the
- // real total.
- let mut body = Vec::with_capacity(
- declared
- .and_then(|length| usize::try_from(length).ok())
- .unwrap_or(0)
- .min(RESPONSE_CAPACITY_HINT),
- );
- let mut stream = response.bytes_stream();
- while let Some(chunk) = stream.next().await {
- let chunk = match chunk {
- Ok(chunk) => chunk,
- Err(error) => {
- warn!(%error, "forward response body read failed; outcome
unknown");
- return AttemptOutcome::Relay(bad_gateway());
- }
- };
- if body.len() + chunk.len() > RESPONSE_BODY_LIMIT {
- warn!(
- received = body.len() + chunk.len(),
- "relayed response exceeds the body bound"
- );
- return AttemptOutcome::Relay(bad_gateway());
- }
- body.extend_from_slice(&chunk);
- }
- classify_reply(status, relayed_headers, Bytes::from(body),
retry_redirect)
+ classify_forwarded_reply(response, method, retry_redirect).await
};
match compio::time::timeout(FORWARD_ATTEMPT_TIMEOUT, attempt).await {
// Elapsed: the request may be mid-commit on the primary. Outcome
@@ -534,6 +499,90 @@ async fn attempt(
}
}
+async fn classify_forwarded_reply(
+ response: cyper::Response,
+ method: &Method,
+ retry_redirect: bool,
+) -> AttemptOutcome {
+ let status = response.status();
+ // Only the relayed subset survives; the response is consumed by the
+ // body stream below, so the values are pulled out first.
+ let relayed_headers: Vec<(HeaderName, HeaderValue)> =
RELAYED_RESPONSE_HEADERS
+ .into_iter()
+ .filter_map(|name| {
+ let value = response.headers().get(&name)?.clone();
+ Some((name, value))
+ })
+ .collect();
+ if method == Method::GET && !retry_redirect && status.is_success() {
+ let mut response =
Response::new(stream_poll_body(response.bytes_stream()));
+ *response.status_mut() = status;
+ for (name, value) in relayed_headers {
+ response.headers_mut().insert(name, value);
+ }
+ return AttemptOutcome::Relay(response);
+ }
+ let declared = response.content_length();
+ if declared.is_some_and(|length| length > RESPONSE_BODY_LIMIT as u64) {
+ warn!(?declared, "relayed response exceeds the body bound");
+ return AttemptOutcome::Relay(bad_gateway());
+ }
+ // Streamed with a running cap so a length-less reply is bounded by
+ // the limit, not merely by the attempt timeout. The capacity hint is
+ // clamped to RESPONSE_CAPACITY_HINT so a mis-declared content-length
+ // cannot pre-reserve the full bound. The running cap still bounds the
+ // real total.
+ let mut body = Vec::with_capacity(
+ declared
+ .and_then(|length| usize::try_from(length).ok())
+ .unwrap_or(0)
+ .min(RESPONSE_CAPACITY_HINT),
+ );
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = match chunk {
+ Ok(chunk) => chunk,
+ Err(error) => {
+ warn!(%error, "forward response body read failed; outcome
unknown");
+ return AttemptOutcome::Relay(bad_gateway());
+ }
+ };
+ if body.len() + chunk.len() > RESPONSE_BODY_LIMIT {
+ warn!(
+ received = body.len() + chunk.len(),
+ "relayed response exceeds the body bound"
+ );
+ return AttemptOutcome::Relay(bad_gateway());
+ }
+ body.extend_from_slice(&chunk);
+ }
+ classify_reply(status, relayed_headers, Bytes::from(body), retry_redirect)
+}
+
+fn stream_poll_body(stream: impl Stream<Item = Result<Bytes, cyper::Error>> +
'static) -> Body {
+ let stream = futures::stream::try_unfold(Box::pin(stream), |mut stream|
async move {
+ match compio::time::timeout(FORWARD_ATTEMPT_TIMEOUT,
stream.next()).await {
+ Ok(Some(Ok(bytes))) => Ok(Some((bytes, stream))),
+ Ok(None) => Ok(None),
+ Ok(Some(Err(error))) =>
Err(std::io::Error::other(error.to_string())),
+ Err(_) => Err(std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "forwarded poll response stalled",
+ )),
+ }
+ });
+ Body::from_stream(SendWrapper::new(stream))
+}
+
+fn retain_forward_guard(response: Response, guard: ForwardGuard) -> Response {
+ let (parts, body) = response.into_parts();
+ let stream = futures::stream::unfold(
+ (body.into_data_stream(), guard),
+ |(mut stream, guard)| async move { stream.next().await.map(|chunk|
(chunk, (stream, guard))) },
+ );
+ Response::from_parts(parts, Body::from_stream(SendWrapper::new(stream)))
+}
+
/// Copy the forwardable request headers: the bearer (the primary
/// re-authenticates it) and the content type. Everything else - including any
/// client-supplied forward marker, which `forward_or_pass` already bounced -
@@ -599,6 +648,9 @@ fn classify_reply(
/// Inspect a response produced on this node without changing any terminal
/// response. Only the typed never-admitted denial opens the roster fallback.
async fn classify_local_partition_reply(response: Response) -> AttemptOutcome {
+ if response.status() != StatusCode::SERVICE_UNAVAILABLE {
+ return AttemptOutcome::Relay(response);
+ }
let (parts, body) = response.into_parts();
let body = match to_bytes(body, RESPONSE_BODY_LIMIT).await {
Ok(body) => body,
@@ -654,6 +706,24 @@ fn partition_http_sockets(
.collect()
}
+/// Whether a poll asks the server to store its offset after serving it.
+///
+/// Match the handler's URL decoding and boolean parsing. An invalid query
+/// reaches the handler through the forwarding layer for its normal rejection.
+fn wants_auto_commit(query: Option<&str>) -> bool {
+ #[derive(Default, Deserialize)]
+ struct AutoCommitQuery {
+ #[serde(default)]
+ auto_commit: bool,
+ }
+ query.is_some_and(|query| {
+ let Ok(uri) = format!("/?{query}").parse() else {
+ return true;
+ };
+ Query::<AutoCommitQuery>::try_from_uri(&uri).map_or(true,
|Query(query)| query.auto_commit)
+ })
+}
+
fn wants_linearizable(query: Option<&str>) -> bool {
query.is_some_and(|query| {
query
@@ -672,21 +742,23 @@ fn bad_gateway() -> Response {
/// RAII admission against [`MAX_IN_FLIGHT_FORWARDS`]; releases on drop, so a
/// client disconnect mid-forward frees the slot.
-struct ForwardGuard<'a> {
- in_flight: &'a Cell<u32>,
+struct ForwardGuard {
+ in_flight: Rc<Cell<u32>>,
}
-impl<'a> ForwardGuard<'a> {
- fn admit(in_flight: &'a Cell<u32>) -> Option<Self> {
+impl ForwardGuard {
+ fn admit(in_flight: &Rc<Cell<u32>>) -> Option<Self> {
if in_flight.get() >= MAX_IN_FLIGHT_FORWARDS {
return None;
}
in_flight.set(in_flight.get() + 1);
- Some(Self { in_flight })
+ Some(Self {
+ in_flight: Rc::clone(in_flight),
+ })
}
}
-impl Drop for ForwardGuard<'_> {
+impl Drop for ForwardGuard {
fn drop(&mut self) {
self.in_flight.set(self.in_flight.get() - 1);
}
@@ -748,6 +820,8 @@ impl ServerCertVerifier for PinnedCertVerifier {
mod tests {
use super::*;
+ use std::io::{Read, Write};
+
use configs::cluster::{ClusterNodeConfig, ResolvedClusterNode,
TransportPorts};
fn node(replica_id: u8, ip: &str, http: Option<u16>) -> ClusterNodeConfig {
@@ -793,6 +867,21 @@ mod tests {
assert!(!wants_linearizable(None));
}
+ #[test]
+ fn auto_commit_query_matches_handler_decoding() {
+ assert!(wants_auto_commit(Some("auto_commit=true")));
+ assert!(wants_auto_commit(Some("auto_commit=1")));
+ assert!(wants_auto_commit(Some("count=10&auto_commit=true")));
+ assert!(wants_auto_commit(Some("auto_commit=yes")));
+ assert!(!wants_auto_commit(Some("auto_commit=false")));
+ assert!(wants_auto_commit(Some("auto_commit=0")));
+ assert!(!wants_auto_commit(Some("count=10")));
+ assert!(!wants_auto_commit(None));
+ assert!(wants_auto_commit(Some("%61uto_commit=true")));
+ assert!(wants_auto_commit(Some("auto_commit=%74rue")));
+ assert!(!wants_auto_commit(Some("%61uto_commit=%66alse")));
+ }
+
#[test]
fn transient_not_accepted_body_matches_only_its_code() {
let accepted = format!(
@@ -811,7 +900,7 @@ mod tests {
#[test]
fn forward_guard_caps_and_releases() {
- let in_flight = Cell::new(0);
+ let in_flight = Rc::new(Cell::new(0));
let guards: Vec<_> = (0..MAX_IN_FLIGHT_FORWARDS)
.map(|_| ForwardGuard::admit(&in_flight).expect("under cap"))
.collect();
@@ -852,4 +941,117 @@ mod tests {
};
assert_eq!(terminal.status(), StatusCode::SERVICE_UNAVAILABLE);
}
+
+ #[compio::test]
+ async fn successful_local_poll_body_is_not_collected_or_size_limited() {
+ let polled = Rc::new(Cell::new(0));
+ let observed = Rc::clone(&polled);
+ let chunk = Bytes::from(vec![0; 1024 * 1024]);
+ let stream = futures::stream::iter((0..65).map(move |_| {
+ observed.set(observed.get() + 1);
+ Ok::<_, std::io::Error>(chunk.clone())
+ }));
+ let response =
Response::new(Body::from_stream(SendWrapper::new(stream)));
+ let AttemptOutcome::Relay(response) =
classify_local_partition_reply(response).await else {
+ panic!("successful response must not be retried")
+ };
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(polled.get(), 0);
+ let mut stream = response.into_body().into_data_stream();
+ let mut received = 0;
+ while let Some(chunk) = stream.next().await {
+ received += chunk.expect("successful chunk").len();
+ }
+ assert_eq!(received, 65 * 1024 * 1024);
+ }
+
+ #[compio::test]
+ async fn forwarded_poll_streams_large_body_and_retains_admission() {
+ let in_flight = Rc::new(Cell::new(0));
+ let guard = ForwardGuard::admit(&in_flight).expect("forward admitted");
+ let polled = Rc::new(Cell::new(0));
+ let observed = Rc::clone(&polled);
+ let chunk = Bytes::from(vec![0; 1024 * 1024]);
+ let stream = futures::stream::iter((0..65).map(move |_| {
+ observed.set(observed.get() + 1);
+ Ok::<_, cyper::Error>(chunk.clone())
+ }));
+ let response =
retain_forward_guard(Response::new(stream_poll_body(stream)), guard);
+ assert_eq!(polled.get(), 0);
+ assert_eq!(in_flight.get(), 1);
+ let mut stream = response.into_body().into_data_stream();
+ let mut received = 0;
+ while let Some(chunk) = stream.next().await {
+ received += chunk.expect("successful chunk").len();
+ assert_eq!(in_flight.get(), 1);
+ }
+ assert_eq!(received, 65 * 1024 * 1024);
+ assert_eq!(in_flight.get(), 0);
+ }
+
+ #[compio::test]
+ async fn dropping_forwarded_poll_body_releases_admission() {
+ let in_flight = Rc::new(Cell::new(0));
+ let guard = ForwardGuard::admit(&in_flight).expect("forward admitted");
+ let response = retain_forward_guard(
+ Response::new(stream_poll_body(futures::stream::pending())),
+ guard,
+ );
+ assert_eq!(in_flight.get(), 1);
+ drop(response);
+ assert_eq!(in_flight.get(), 0);
+ }
+
+ #[compio::test]
+ async fn forwarded_poll_accepts_large_http_content_length() {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ let forward =
+ build_forward_state(&HttpTlsConfig::default(), 1024,
true).expect("forward client");
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("HTTP
listener");
+ let address = listener.local_addr().expect("listener address");
+ let server = std::thread::spawn(move || {
+ let (mut socket, _) = listener.accept().expect("client
connection");
+ socket
+ .set_read_timeout(Some(Duration::from_secs(10)))
+ .expect("read timeout");
+ socket
+ .set_write_timeout(Some(Duration::from_secs(10)))
+ .expect("write timeout");
+ let mut request = Vec::new();
+ let mut bytes = [0; 1024];
+ while !request.windows(4).any(|window| window == b"\r\n\r\n") {
+ let read = socket.read(&mut bytes).expect("request bytes");
+ assert_ne!(read, 0);
+ request.extend_from_slice(&bytes[..read]);
+ }
+ write!(socket, "HTTP/1.1 200 OK\r\nContent-Length:
{}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n", 65 * 1024 *
1024)
+ .expect("response headers");
+ let chunk = vec![0; 1024 * 1024];
+ for _ in 0..65 {
+ socket.write_all(&chunk).expect("response chunk");
+ }
+ });
+ let response = forward
+ .client
+ .get(format!("http://{address}/messages"))
+ .expect("poll request")
+ .send()
+ .await
+ .expect("poll response");
+ assert_eq!(response.content_length(), Some(65 * 1024 * 1024));
+ let AttemptOutcome::Relay(response) =
+ classify_forwarded_reply(response, &Method::GET, false).await
+ else {
+ panic!("successful poll must not be retried")
+ };
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
+ let mut stream = response.into_body().into_data_stream();
+ let mut received = 0;
+ while let Some(chunk) = stream.next().await {
+ received += chunk.expect("response chunk").len();
+ }
+ assert_eq!(received, 65 * 1024 * 1024);
+ server.join().expect("HTTP server finished");
+ }
}
diff --git a/core/server/src/http/state.rs b/core/server/src/http/state.rs
index 5c64f4c21..f3ef51700 100644
--- a/core/server/src/http/state.rs
+++ b/core/server/src/http/state.rs
@@ -146,7 +146,7 @@ pub(in crate::http) struct ForwardState {
/// the same scheme as this node (uniform cluster HTTP config).
pub(in crate::http) scheme: &'static str,
pub(in crate::http) body_limit: usize,
- pub(in crate::http) in_flight: Cell<u32>,
+ pub(in crate::http) in_flight: Rc<Cell<u32>>,
}
/// Shared shard-0 HTTP state.
diff --git a/core/simulator/src/storage/tests.rs
b/core/simulator/src/storage/tests.rs
index 7a63f85f8..51f58cc50 100644
--- a/core/simulator/src/storage/tests.rs
+++ b/core/simulator/src/storage/tests.rs
@@ -1210,7 +1210,7 @@ fn
obsolete_wal_generations_are_reclaimed_after_restart_and_failed_unlink() {
let (storage, mut journal) = baseline().await;
storage.clear_trace();
journal.checkpoint(2).await.unwrap();
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
let unlink = storage
.trace()
.iter()
@@ -1220,7 +1220,7 @@ fn
obsolete_wal_generations_are_reclaimed_after_restart_and_failed_unlink() {
let (storage, mut journal) = baseline().await;
storage.fail_at(unlink, FaultMode::Before);
journal.checkpoint(2).await.unwrap();
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
storage.clear_trace();
let obsolete = Path::new("/partition/wal/prepares-0.wal");
assert!(storage.exists(obsolete).await.unwrap());
@@ -1256,7 +1256,7 @@ fn
obsolete_wal_generations_are_reclaimed_after_restart_and_failed_unlink() {
// have waited on the retry, and the writer's own maintenance
// pass is what must still take it.
assert!(storage.exists(obsolete).await.unwrap());
- journal.reclaim_obsolete().await;
+ journal.cleanup_obsolete().await;
}
assert!(!storage.exists(obsolete).await.unwrap());
assert_eq!(journal.checkpoint_op(), 2);
diff --git
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesSerializer.java
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesSerializer.java
index 7166861e7..db89596bd 100644
---
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesSerializer.java
+++
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesSerializer.java
@@ -67,6 +67,9 @@ public final class BytesSerializer {
/** The timestamp delta is a u32 microsecond offset from the batch origin
timestamp. */
private static final BigInteger MAX_TIMESTAMP_DELTA_MICROS =
BigInteger.valueOf(0xFFFF_FFFFL);
+ /** Encoded user headers of a message that carries none. */
+ private static final byte[] EMPTY_USER_HEADERS = new byte[0];
+
/** Batch checksum input: five u64 header fields plus the u32 message
count. */
private static final int BATCH_CHECKSUM_FIXED_INPUT_BYTES = 5 * Long.BYTES
+ Integer.BYTES;
@@ -281,7 +284,7 @@ public final class BytesSerializer {
encodedMessageId(message.header().id()),
message.header().originTimestamp(),
message.payload(),
- readAllBytes(toBytes(message.userHeaders()))));
+ encodedUserHeaders(message.userHeaders())));
}
return rawMessages;
}
@@ -300,21 +303,33 @@ public final class BytesSerializer {
private static BatchExtent measureBatch(List<RawMessage> messages, long
capacityAllowance) {
var originTimestamp = messages.get(0).originTimestamp();
var latestTimestamp = originTimestamp;
+ var latestIndex = 0;
long length = BATCH_HEADER_SIZE;
- for (RawMessage message : messages) {
+ for (int index = 0; index < messages.size(); index++) {
+ RawMessage message = messages.get(index);
var timestamp = message.originTimestamp();
if (timestamp.signum() < 0 || timestamp.bitLength() > Long.SIZE) {
- throw new IggyInvalidArgumentException("Message origin
timestamp is outside unsigned 64-bit range");
+ throw new IggyInvalidArgumentException("Message " + index + "
origin timestamp " + timestamp
+ + " is outside the unsigned 64-bit range");
}
originTimestamp = originTimestamp.min(timestamp);
- latestTimestamp = latestTimestamp.max(timestamp);
+ if (timestamp.compareTo(latestTimestamp) > 0) {
+ latestTimestamp = timestamp;
+ latestIndex = index;
+ }
length += (long) MessageHeader.SIZE + message.payload().length +
message.userHeaders().length;
if (length > capacityAllowance) {
throw new IggyInvalidArgumentException("Message batch exceeds
the output buffer capacity");
}
}
- if
(latestTimestamp.subtract(originTimestamp).compareTo(MAX_TIMESTAMP_DELTA_MICROS)
> 0) {
- throw new IggyInvalidArgumentException("Message origin timestamp
delta exceeds unsigned 32-bit range");
+ // Name the offending message and its delta, the way the server's own
+ // InvalidMessageTimestampDelta does: the batch origin is whichever
+ // message is oldest, so neither is obvious from the caller's input.
+ var delta = latestTimestamp.subtract(originTimestamp);
+ if (delta.compareTo(MAX_TIMESTAMP_DELTA_MICROS) > 0) {
+ throw new IggyInvalidArgumentException("Message " + latestIndex
+ + " origin timestamp exceeds the batch origin by " + delta
+ + " microseconds, more than the timestamp delta field can
hold");
}
return new BatchExtent(originTimestamp, length);
}
@@ -411,6 +426,21 @@ public final class BytesSerializer {
return Hashing.xxh3_64().hashBytesToLong(bytes);
}
+ /**
+ * Encoded user headers, or an empty array when there are none.
+ *
+ * <p>Returns before a buffer exists for the empty case. {@link
#toBytes(Map)} answers that case
+ * with the shared {@link Unpooled#EMPTY_BUFFER}, which {@link
#readAllBytes(ByteBuf)} would then
+ * release. That release happens to be a no-op on the singleton, which is
the only reason the
+ * previous shape was safe.
+ */
+ private static byte[] encodedUserHeaders(Map<HeaderKey, HeaderValue>
userHeaders) {
+ if (userHeaders == null || userHeaders.isEmpty()) {
+ return EMPTY_USER_HEADERS;
+ }
+ return readAllBytes(toBytes(userHeaders));
+ }
+
private static byte[] readAllBytes(ByteBuf buffer) {
try {
var bytes = new byte[buffer.readableBytes()];
diff --git
a/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/MessagesBatchWireFormatTest.java
b/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/MessagesBatchWireFormatTest.java
index 5351f3c0f..23a5c114a 100644
---
a/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/MessagesBatchWireFormatTest.java
+++
b/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/MessagesBatchWireFormatTest.java
@@ -243,7 +243,35 @@ class MessagesBatchWireFormatTest {
var messages = List.of(message(1, 0, "a", Map.of()), message(2,
0x1_0000_0000L, "b", Map.of()));
assertThatThrownBy(() -> BytesSerializer.toMessagesBatch(messages))
- .isInstanceOf(IggyInvalidArgumentException.class);
+ .isInstanceOf(IggyInvalidArgumentException.class)
+ .hasMessageContaining("Message 1")
+ .hasMessageContaining("4294967296 microseconds");
+ }
+
+ @Test
+ void shouldReportTimestampDeltaAgainstTheOldestMessageRegardlessOfOrder() {
+ var messages = List.of(
+ message(1, 0x1_0000_0000L + 7, "latest", Map.of()),
+ message(2, 7, "oldest", Map.of()),
+ message(3, 8, "middle", Map.of()));
+
+ assertThatThrownBy(() -> BytesSerializer.toMessagesBatch(messages))
+ .isInstanceOf(IggyInvalidArgumentException.class)
+ .hasMessageContaining("Message 0")
+ .hasMessageContaining("4294967296 microseconds");
+ }
+
+ @Test
+ void shouldEncodeNullAndEmptyUserHeadersIdentically() {
+ var absent = BytesSerializer.toMessagesBatch(List.of(message(1, 7,
"payload", null)));
+ var empty = BytesSerializer.toMessagesBatch(List.of(message(1, 7,
"payload", Map.of())));
+ try {
+
assertThat(ByteBufUtil.hexDump(absent)).isEqualTo(ByteBufUtil.hexDump(empty));
+ assertThat(absent.getIntLE(256 + 32)).isZero();
+ } finally {
+ absent.release();
+ empty.release();
+ }
}
@Test