Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 145 additions & 14 deletions core/sdk/src/clients/producer_sharding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,23 @@ impl Sizeable for ShardMessage {

pub struct ShardMessageWithPermit {
pub inner: ShardMessage,
_bytes_permit: Option<OwnedSemaphorePermit>,
bytes_permit: OwnedSemaphorePermit,
}

impl ShardMessageWithPermit {
pub fn new(msg: ShardMessage, permit_bytes: OwnedSemaphorePermit) -> Self {
Self {
inner: msg,
_bytes_permit: Some(permit_bytes),
bytes_permit: permit_bytes,
}
}

/// Takes over `other`'s messages together with its byte permit, so the buffered bytes stay
/// charged against the `max_buffer_size` budget until the merged batch has been written.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc states the new contract correctly, but the public documentation of max_buffer_size no longer matches it.

core/sdk/src/clients/producer_config.rs:108-109 still says:

Upper bound for the bytes held in memory across all shards.

After this change permits are held through core.send_internal until the write returns, so the budget covers buffered plus in-flight bytes, and it now couples to max_in_flight. That is the user-visible semantic change this PR makes, and it is currently undocumented.

The practical consequence is that Block-mode producers can block where they previously did not, most visibly on the linger-only shape (batch_length(0) + batch_size(0)) that this repo's own integration tests use at core/integration/tests/sdk/producer/background.rs:135-136,177-178,216-217, which pins close to the whole budget for a full write RTT. Default config pins under 3% (32 MiB budget against a 1 MiB batch_size), so there is no out-of-the-box cliff.

Suggest amending the max_buffer_size doc to say the budget spans buffered and in-flight bytes until the write completes, and calling the behavior change out in the release notes.

fn merge(&mut self, other: Self) {
self.inner.messages.extend(other.inner.messages);
self.bytes_permit.merge(other.bytes_permit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OwnedSemaphorePermit::merge sums u32 permit counts, and after this change the sum is bounded only by max_buffer_size — which nothing validates.

IggyByteSize::from(u64) accepts any value and BackgroundConfig has no validator, so a max_buffer_size above 4 GiB is constructible. With batch_size(0) + batch_length(0) and a long linger, one shard buffer accumulates adjacent same-destination batches until the merged sum passes u32::MAX. Note this needs only normal-sized batches — each individual charge is lossless, e.g. 4096 x 1 MiB against a 6 GiB budget; it is the sum that wraps.

Reproduced on tokio 1.53.1:

  • release: wraps silently and permanently destroys permits (6 GiB budget leaves 1.7 GiB available after one merge + drop), after which the default BackpressureMode::Block blocks send() forever with no error and no log.
  • debug: panics attempt to add with overflow inside the shard task, where it is swallowed — the JoinHandle at line 135 is never awaited, so no test in this file can observe it.

Worth separating from the pre-existing issues nearby: this is a new failure mode, since nothing summed permits before this change. On master the same configuration merely under-enforces the budget and the producer keeps running.

Suggested fix is one line at construction — reject max_buffer_size > u32::MAX in BackgroundConfig / ProducerDispatcher::new. That also closes the related truncation at producer_dispatcher.rs:135, where IggyByteSize::as_bytes_u32() charges size mod 2^32 (exactly 0 at whole multiples of 4 GiB, so such a batch bypasses the budget entirely).

}
}

pub struct Shard {
Expand Down Expand Up @@ -212,6 +219,27 @@ impl Shard {
}
}

/// Drains the buffer into batches.
///
/// If adjacent ShardMessages have the same destination (stream, topic, partition combination),
/// merge to avoid multiple sends.
/// The merge happens on both, the inner [`IggyMessage`] and the [`OwnedSemaphorePermit`].
fn merge_batches(buffer: &mut Vec<ShardMessageWithPermit>) -> Vec<ShardMessageWithPermit> {
let mut merged_batches: Vec<ShardMessageWithPermit> = Vec::with_capacity(buffer.len());
// Since buffer is a mutable reference, the drain leaves the buffer intact but empty,
// such that it can be filled up again.
for msg in buffer.drain(..) {
if let Some(last) = merged_batches.last_mut()
&& Self::same_destination(&last.inner, &msg.inner)
{
last.merge(msg);
continue;
}
merged_batches.push(msg);
}
merged_batches
}

async fn flush_buffer(
core: &Arc<impl ProducerCoreBackend>,
slots_permit: &Arc<Semaphore>,
Expand All @@ -223,18 +251,7 @@ impl Shard {
return;
}

let mut merged_batches: Vec<ShardMessageWithPermit> = Vec::new();
for msg in buffer.drain(..) {
if let Some(last) = merged_batches.last_mut()
&& Self::same_destination(&last.inner, &msg.inner)
{
last.inner.messages.extend(msg.inner.messages);
continue;
}
merged_batches.push(msg);
}

for msg in merged_batches {
for msg in Self::merge_batches(buffer) {
let _slot_permit = slots_permit.acquire().await;

let result = core
Expand Down Expand Up @@ -312,6 +329,120 @@ mod tests {
.unwrap()
}

async fn charged_batch(
budget: &Arc<Semaphore>,
stream: Arc<Identifier>,
topic: Arc<Identifier>,
payload_size: usize,
) -> ShardMessageWithPermit {
let message = ShardMessage {
stream,
topic,
messages: vec![dummy_message(payload_size)],
partitioning: None,
};
let permit = budget
.clone()
.acquire_many_owned(message.get_size_bytes().as_bytes_u32())
.await
.unwrap();
ShardMessageWithPermit::new(message, permit)
}

#[tokio::test]
async fn test_merge_batches_keeps_permits_of_merged_batches_charged() {
let budget = Arc::new(Semaphore::new(10_000));
let stream = dummy_identifier();
let topic = dummy_identifier();

let mut buffer = Vec::new();
for _ in 0..3 {
buffer.push(charged_batch(&budget, stream.clone(), topic.clone(), 10).await);
}
let charged = 10_000 - budget.available_permits();
let merged = Shard::merge_batches(&mut buffer);

// The original buffer should be drained.
assert!(buffer.is_empty());
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].inner.messages.len(), 3);
assert_eq!(budget.available_permits(), 10_000 - charged);

// Dropping merged gives back to the semaphore.
drop(merged);
assert_eq!(budget.available_permits(), 10_000);
}

#[tokio::test]
async fn test_shard_keeps_budget_charged_until_merged_batch_is_written() {
const BUDGET: usize = 10_000;

// The two channels simulate the timing of a real write, which `flush_buffer` awaits at
// `core.send_internal`. Receiving on `write_started_rx` means the worker has entered that
// call, so the batch is on the wire and its bytes are still buffered from the producer's
// point of view. Sending on `release_write_tx` simulates that the call
// returns, the loop iteration ends and the batch drops and frees its permit.
let (write_started_tx, write_started_rx) = flume::unbounded::<()>();
let (release_write_tx, release_write_rx) = flume::unbounded::<()>();

let mut mock = MockProducerCoreBackend::new();
mock.expect_send_internal()
.times(1)
.returning(move |_, _, _, _| {
let write_started_tx = write_started_tx.clone();
let release_write_rx = release_write_rx.clone();
Box::pin(async move {
write_started_tx.send_async(()).await.unwrap();
release_write_rx.recv_async().await.unwrap();
Ok(no_confirmations())
})
});

let bb = BackgroundConfig::builder()
.batch_length(3)
.batch_size(0)
.linger_time(IggyDuration::new_from_secs(60));
let config = Arc::new(bb.build());

let budget = Arc::new(Semaphore::new(BUDGET));
let slots_permit = Arc::new(Semaphore::new(100));

let (_stop_tx, stop_rx) = broadcast::channel(1);
let shard = Shard::new(
Arc::new(mock),
config,
slots_permit,
flume::unbounded().0,
stop_rx,
);

let stream = dummy_identifier();
let topic = dummy_identifier();
for _ in 0..3 {
let batch = charged_batch(&budget, stream.clone(), topic.clone(), 100).await;
shard.send(batch).await.unwrap();
}
let charged = BUDGET - budget.available_permits();
assert!(charged > 0);

// The three batches share a destination, so the flush merges them into a single write.
write_started_rx.recv_async().await.unwrap();
assert_eq!(
budget.available_permits(),
BUDGET - charged,
"the merged batch must hold every permit it absorbed until the write completes"
);

release_write_tx.send_async(()).await.unwrap();
tokio::time::timeout(Duration::from_secs(1), async {
while budget.available_permits() != BUDGET {
sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("the written batch must give its permits back");
}

#[tokio::test]
async fn test_shard_flushes_by_batch_length() {
let mut mock = MockProducerCoreBackend::new();
Expand Down
Loading