Skip to content

feat(server): add deferred PollMessages wait timeout#3605

Draft
arunsingh wants to merge 20 commits into
apache:masterfrom
arunsingh:feat/poll-messages-wait-timeout
Draft

feat(server): add deferred PollMessages wait timeout#3605
arunsingh wants to merge 20 commits into
apache:masterfrom
arunsingh:feat/poll-messages-wait-timeout

Conversation

@arunsingh

@arunsingh arunsingh commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #3470.

Adds deferred polling to PollMessages with a timeout. Timeout 0 preserves existing immediate behavior. Non-zero timeout waits until messages are readable or the timeout expires.

Design

  • binary protocol uses u64 timeout in microseconds
  • Rust SDK exposes an idiomatic timeout API
  • timeout expiry returns a normal empty poll response
  • auto-commit only happens when messages are returned
  • HTTP transport is intentionally out of first scope
  • WebSocket-facing behavior is covered
  • consumer groups check assigned partitions so one empty partition does not hide data on another assigned partition
  • waiter/notifier state is cleaned up and bounded
  • current core/server implementation is kept small for upcoming server-ng port

Out of scope

  • HTTP transport support
  • push notification frames
  • subscribe/unsubscribe commands
  • generic control-plane notifications
  • backlog notification API
  • metadata panic/abort behavior
  • all foreign SDKs in this PR

Tests

  • protocol roundtrip with timeout
  • backward compatibility for old poll request
  • partial trailing timeout decode error
  • immediate poll behavior unchanged
  • timeout-empty response
  • wake-on-produce
  • disconnect cleanup
  • auto-commit unchanged
  • consumer-group assigned partition path
  • WebSocket-facing path
  • Bench CLI comparison support

Local checks

Commands run:

cargo test -p iggy_binary_protocol poll_messages
Result: passed, 13 passed.

cargo test -p integration poll_messages_wait_timeout
Result: passed, 7 passed.

cargo test -p iggy-bench poll_wait_timeout
Result: passed, 2 passed.

cargo test -p integration poll
Result: passed, 30 passed.

cargo clippy --all-targets --all-features -- -D warnings
Result: passed.

cargo build
Result: passed.

typos
Result: passed.

cargo sort --workspace
Result: passed.

cargo fmt --all
Result: passed.

git diff --check
Result: passed.

RUST_LOG=info,iggy_connectors::benchmark=info,iggy_connectors=debug cargo test -p integration connectors::runtime::benchmark -- --test-threads=1
Result: passed, 13 passed.

cargo machete
Result: reports `rust-s3` in S3/integration; inspected usage through `s3::` crate path, treated as existing false positive and not changed in this PR.

Full cargo test is not claimed as passing locally. Remaining isolated unrelated failures:

  • cli::system::test_snapshot_cmd::should_be_successful
  • server::scenarios::log_rotation_scenario::log_rotation_should_be_valid::config_unlimited_archives_expects

AI usage

Used AI assistance for design planning and review and local verification were reviewed by the author.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.71038% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.99%. Comparing base (7257940) to head (a945759).
⚠️ Report is 12 commits behind head on master.

Files with missing lines Patch % Lines
core/server/src/shard/waiters.rs 84.44% 13 Missing and 8 partials ⚠️
.../binary/handlers/messages/poll_messages_handler.rs 94.11% 0 Missing and 5 partials ⚠️
core/server/src/shard/system/utils.rs 88.09% 5 Missing ⚠️
core/common/src/traits/binary_impls/messages.rs 75.00% 2 Missing ⚠️
core/server/src/shard/builder.rs 85.71% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3605      +/-   ##
============================================
- Coverage     74.10%   73.99%   -0.11%     
  Complexity      937      937              
============================================
  Files          1258     1265       +7     
  Lines        131485   133155    +1670     
  Branches     107354   108836    +1482     
============================================
+ Hits          97435    98528    +1093     
- Misses        30963    31462     +499     
- Partials       3087     3165      +78     
Components Coverage Δ
Rust Core 74.57% <90.71%> (-0.16%) ⬇️
Java SDK 62.44% <ø> (ø)
C# SDK 71.41% <ø> (-0.71%) ⬇️
Python SDK 91.65% <ø> (+2.76%) ⬆️
PHP SDK 84.29% <ø> (ø)
Node SDK 91.35% <ø> (ø)
Go SDK 40.14% <ø> (ø)
Files with missing lines Coverage Δ
...ry_protocol/src/requests/messages/poll_messages.rs 100.00% <100.00%> (ø)
core/common/src/traits/message_client.rs 100.00% <100.00%> (ø)
...e/sdk/src/client_wrappers/binary_message_client.rs 100.00% <100.00%> (ø)
core/sdk/src/clients/binary_message.rs 100.00% <100.00%> (ø)
core/server/src/main.rs 62.83% <100.00%> (+0.33%) ⬆️
core/server/src/shard/handlers.rs 77.97% <100.00%> (+0.28%) ⬆️
core/server/src/shard/mod.rs 84.00% <ø> (ø)
core/server/src/shard/system/messages.rs 88.19% <100.00%> (+0.43%) ⬆️
core/server/src/shard/builder.rs 99.09% <85.71%> (-0.91%) ⬇️
core/common/src/traits/binary_impls/messages.rs 89.47% <75.00%> (-10.53%) ⬇️
... and 3 more

... and 74 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@arunsingh arunsingh changed the title Add deferred PollMessages wait timeout feat(server): add deferred PollMessages wait timeout Jul 4, 2026
@arunsingh arunsingh force-pushed the feat/poll-messages-wait-timeout branch from bbfb304 to fbd4e0f Compare July 4, 2026 09:19

@hubcio hubcio left a comment

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.

review priorities, most important first: (1) user-facing API ergonomics, (2) tests, (3) implementation in core/server-ng, (4) implementation in core/server - core/server is getting replaced by server-ng in about a month, so comments on it matter mostly as the blueprint for the server-ng port, not as long-lived code.

findings that don't map to changed lines:

  • server-ng ignores the new wait_timeout_us entirely: decode_poll_request in core/server-ng/src/dispatch.rs builds PollingArgs::new(strategy, count, auto_commit) and drops the decoded timeout; there is no wait/waiter logic anywhere in that crate. a client calling poll_messages_with_timeout with a non-zero timeout against server-ng gets an immediate empty response - no wait, no error, nothing detectable. since server-ng is about to become the server, shipping the wait path only in core/server means the feature effectively vanishes on the switch. minimum: server-ng should reject non-zero timeouts loudly; better: port the wait path as an immediate follow-up.
  • cg_vsr.rs no longer exists on current master, so this branch needs a rebase and the new vsr test needs a new home; the modify/delete conflict is guaranteed.
  • edge on the append path (core/server/src/shard/system/messages.rs around 392-433, unchanged lines): the offset advances (data becomes readable) before the threshold persist_frozen_batches_to_disk().await?; if that persist errors, the request handler returns before wake_poll_waiters, so a waiter sleeps out its full timeout despite readable data. self-heals on the next poll - worth a comment or waking before the persist block.
  • pre-existing, just amplified by long polls: auto-commit commits the offset before the response is sent, so a disconnect between commit and delivery skips that batch for the consumer (at-most-once). worth documenting on auto_commit now that polls can be parked for seconds.

also, please remove the markdown file scripts/poll-wait-timeout-comparison.md - it has way too strong LLM smell for my liking ;)

.await
.unwrap();

assert_eq!(polled.partition_id, 1);

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 assert fails under --features vsr: cargo test -p integration --features vsr poll_messages_wait_timeout_consumer_group panics right here with left: 0, right: 1. the chain: the poll passes Some(0) with a group consumer; client-side vsr group resolution only kicks in when partition_id is None, so this goes out as an explicit partition-0 read; server-ng serves exactly that partition (no owned-partition scan, no wait) while the message sits on partition 1. CI never runs the integration suite with the vsr feature, so this stays green there and only fails locally. either cfg-gate run_consumer_group_checks out of vsr like the sibling checks, or implement the owned-partition scan in server-ng first.

separate but related: the non-vsr run of this scenario passes via the pre-wait re-check (data already present before the poll), so even the green path never exercises a real wait-then-wake for consumer groups - a produce-after-delay variant like the one in run_wake_after_append_checks would cover it.

.await?;

shard.metrics.increment_messages(messages_count as u64);
shard.wake_poll_waiters(&namespace);

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.

wake_poll_waiters grabs the global std::sync::Mutex around PollWaiterRegistry on every append (same at line 423), even when nobody waits. on the thread-per-core runtime that's a blocking mutex shared by all shard threads on the hottest path - contention or the cache-line bounce stalls the whole core, not just one task. cheap fix: an AtomicUsize live-waiter count checked before locking, skip when zero; the existing register-then-repoll sequence already closes the race, so no wake can be lost.

.poll_messages(
client_id,
topic,
consumer.clone(),

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.

consumer.clone() here runs on every poll, before the zero-timeout gate - Consumer carries an Identifier backed by Vec<u8>, so that's a heap alloc per poll even for plain immediate polls, plus one more clone per namespace in poll_wait_namespaces (line 165). IggyShard::poll_messages only borrows the consumer internally and both callers are in this file, so taking &Consumer removes all of them.

let partition_id = req.partition_id;
let count = req.count;
let auto_commit = req.auto_commit;
let wait_timeout = Duration::from_micros(req.wait_timeout_us);

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.

no server-side cap on the client-supplied timeout - u64::MAX micros (~584k years) is accepted and there's no config to bound it, so clients can park connections in the wait state indefinitely. related: once a namespace hits MAX_WAITERS_PER_NAMESPACE (1024), register returns None and the next poller silently degrades to an immediate empty response instead of waiting. a configurable max wait (clamped, not rejected) plus a debug log on the cap path would cover both.

batch = next_batch;
}

if batch.is_empty() && !waiters.is_empty() {

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.

one wake buys exactly one re-poll: if a group peer drains the partition between wake and re-poll, this returns empty with most of the timeout budget unused and the client goes back to busy-looping - the thing this feature exists to kill. also, matches!(..., Ok(true)) means a wake permit landing exactly at the deadline can lose to the timer and skip the final re-poll. looping wait+re-poll until the deadline fixes both.

auto_commit: bool,
wait_timeout: Duration,
) -> Result<PolledMessages, IggyError> {
if wait_timeout.is_zero() {

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.

mutual delegation trap: this default delegates zero-timeout to self.poll_messages, while IggyClient and ClientWrapper implement poll_messages by calling poll_messages_with_timeout(Duration::ZERO). it only terminates because those impls also override poll_messages_with_timeout - remove one of those overrides later and it compiles fine, then stack-overflows at runtime. worth a doc line on the trait: implementors must override at least one of the pair with a real implementation.

.await;
}

Err(IggyError::FeatureUnavailable)

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.

non-zero timeout on a transport without an override surfaces as bare FeatureUnavailable ("Feature is unavailable") - nothing points at the wait timeout as the culprit. HTTP users hit this first; a doc note on the method (and ideally the transport in the error path) would save them the head-scratch.


/// Poll wait timeout in human readable format, e.g. "10ms", "1s"
#[arg(long, default_value_t = IggyDuration::from_str(DEFAULT_POLL_WAIT_TIMEOUT).unwrap(), value_parser = IggyDuration::from_str)]
pub poll_wait_timeout: IggyDuration,

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.

IggyDuration::from_str maps 0, unlimited, disabled and none all to zero - so --poll-wait-timeout unlimited reads like 'wait forever' but silently turns deferred polling off, and since the conflict guards check is_zero() it sails past those too. doc that 0s disables; rejecting the word forms for this flag would be even better.

BUSY_LOOP_BATCHES=200
fi

wait_timeouts=("0s" "10ms" "100ms" "1s")

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 comparison structurally can't see the one always-on cost the PR adds: wake_poll_waiters runs on the produce path unconditionally, so it costs the same in the 0s and deferred arms and cancels out of every comparison here - and all suites run a single producer, so the cross-shard mutex contention never shows up either. measuring the append-path overhead needs a multi-producer/multi-stream run against a master-built server, not 0s vs deferred on the patched binary.

local command="$3"
local log_file
local resource_log
local sampler_pid=""

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.

sampler_pid is local to run_suite, so the on_interrupt/EXIT traps can't reach it - ctrl-c mid-suite leaks the sampler as a forever pgrep+sleep loop. hoist it to a global and kill it in the trap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add deferred polling timeout to PollMessages

2 participants