Skip to content

feat(server)!: add bounded deferred (long) polling - #4629

Merged
hubcio merged 29 commits into
masterfrom
feat/deferred-polling-pr
Oct 27, 2026
Merged

hubcio merged 29 commits into
masterfrom
feat/deferred-polling-pr

Conversation

@hubcio

@hubcio hubcio commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Idle consumers repeatedly poll quiet partitions. Park requests until
data arrives or the wait expires, with bounded deadlines, reply
sizes and capacity.

Default Rust consumers to deferred polling with manual commits;
expose bounded polling through Java TCP and HTTP. Route readiness
waits across partition leaders, preserve cancellation order and
refresh budgets on each attempt.

Keep progress ordering separate from producers, bound shared
client admission, and distinguish capacity pressure from routing
failures. Preserve retained offset operations and report uncertain
delivery without replaying progress polls.

Tie auto-commit fetches to demand, preserve partition positions and
document lost-reply uncertainty. Cover limits, oversized records,
cancellation, retries, session changes and cross-leader delivery.

BREAKING CHANGE: Rust consumers default to AutoCommit::Disabled
instead of committing on poll, and the stream builder starts at
PollingStrategy::next() instead of last(). The Python consumer_group
and the PHP consumerGroup take the same auto-commit default. An
application that never stores offsets reads everything again after a
restart, and a new stream-builder group starts at offset zero.
poll_options replaces the consumer builder's poll_interval and
without_poll_interval methods, the IggyConsumerConfig builder's
polling_interval setter and the IggyConsumerConfig::polling_interval
getter. IggyConsumerConfig::new, IggyConsumerConfig::from_stream_topic
and IggyStreamConfig::from_stream_topic take DeferredPollOptions
instead of a polling interval, and IggyConsumer::poll_next fails with
InvalidConfiguration until init() runs. The Python consumer_group
drops poll_interval and makes the arguments after
auto_join_consumer_group keyword-only, and the PHP consumerGroup
refuses a non-null $poll_interval_micros. High-level consumers and
connector sinks need a server that supports deferred polls (command
106), readiness waits (107) and cancels (108). Over HTTP they need
GET /streams/{stream_id}/topics/{topic_id}/messages/deferred. They do
not fall back to plain polls. The connectors runtime ignores the sink
poll_interval setting: sinks wait with max_wait and min_count, which
default to 1 s and 1 message.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Oct 23, 2026
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.97426% with 1941 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.27%. Comparing base (8c9b9d2) to head (9f87617).

Files with missing lines Patch % Lines
core/sdk/src/driver.rs 56.75% 421 Missing and 24 partials ⚠️
core/sdk/src/clients/consumer/deferred.rs 72.90% 363 Missing and 21 partials ⚠️
core/partitions/src/poll_plan.rs 72.13% 174 Missing and 6 partials ⚠️
core/sdk/src/session_owner/lanes.rs 40.98% 106 Missing and 2 partials ⚠️
core/connectors/runtime/src/sink.rs 59.16% 92 Missing and 6 partials ⚠️
...ry_protocol/src/requests/messages/poll_messages.rs 27.61% 93 Missing and 4 partials ⚠️
core/sdk/src/links/mux.rs 74.30% 84 Missing and 8 partials ⚠️
core/sdk/src/http/http_client.rs 70.61% 62 Missing and 10 partials ⚠️
core/shard/src/poll/deferred.rs 92.30% 49 Missing and 22 partials ⚠️
core/server/src/dispatch/mod.rs 81.01% 44 Missing and 1 partial ⚠️
... and 36 more

❌ Your patch check has failed because the patch coverage (10.64%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage.

❗ There is a different number of reports uploaded between BASE (8c9b9d2) and HEAD (9f87617). Click for more details.

HEAD has 2 uploads less than BASE
Flag BASE (8c9b9d2) HEAD (9f87617)
rust 4 2
Additional details and impacted files
@@              Coverage Diff              @@
##             master    #4629       +/-   ##
=============================================
- Coverage     90.73%   70.27%   -20.46%     
- Complexity     2989     3102      +113     
=============================================
  Files          1459     1465        +6     
  Lines        348351   356185     +7834     
  Branches     292933   300420     +7487     
=============================================
- Hits         316084   250323    -65761     
- Misses        25619    99107    +73488     
- Partials       6648     6755      +107     
Components Coverage Δ
Rust Core 67.36% <73.97%> (-24.69%) ⬇️
Java SDK 78.17% <ø> (+0.36%) ⬆️
C# SDK 83.59% <ø> (-0.16%) ⬇️
Python SDK 85.67% <ø> (-0.02%) ⬇️
PHP SDK 90.71% <ø> (+0.04%) ⬆️
Node SDK 96.44% <ø> (+<0.01%) ⬆️
Go SDK 70.15% <ø> (+0.18%) ⬆️
C++ SDK 85.51% <ø> (ø)
Files with missing lines Coverage Δ
core/binary_protocol/src/codes.rs 46.15% <ø> (-53.85%) ⬇️
core/binary_protocol/src/consensus/request.rs 76.34% <100.00%> (-18.66%) ⬇️
core/binary_protocol/src/dispatch.rs 60.86% <100.00%> (-28.02%) ⬇️
core/common/src/traits/binary_impls/mod.rs 88.88% <ø> (-8.55%) ⬇️
core/connectors/runtime/src/api/mod.rs 66.57% <100.00%> (-26.32%) ⬇️
core/connectors/runtime/src/api/sink.rs 56.38% <ø> (-0.24%) ⬇️
core/connectors/runtime/src/configs/connectors.rs 39.70% <ø> (-26.97%) ⬇️
core/connectors/runtime/src/source.rs 72.26% <100.00%> (-19.25%) ⬇️
core/connectors/runtime/src/stream.rs 60.35% <100.00%> (-30.80%) ⬇️
core/message_bus/src/transports/tcp.rs 75.00% <100.00%> (-22.92%) ⬇️
... and 95 more

... and 476 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.

@spetz spetz 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.

Reviewed at f6eb3c3, master merge included. The merge resolution holds: the dispatch table, the metadata frontier gate and the HTTP routes carry both features. No critical findings. The four inline comments are warnings: a consumer that stalls with no signal on capacity refusals, two disk read budget problems in the deferred pump, and a breaking SDK default change without !. Smaller items (per-reply hashing and header walks on the pump, the Java frame clamp, doc drift) can follow separately.

Comment thread core/sdk/src/clients/consumer/deferred.rs Outdated
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/sdk/src/clients/consumer_builder.rs
@hubcio
hubcio force-pushed the feat/deferred-polling-pr branch from f6eb3c3 to 84c4122 Compare October 26, 2026 11:16
@hubcio hubcio changed the title feat(server): add bounded deferred (long) polling feat(server)!: add bounded deferred (long) polling Oct 26, 2026
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/sdk/src/clients/consumer_builder.rs
Comment thread core/sdk/src/driver.rs
Comment thread core/connectors/runtime/src/sink.rs Outdated
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/shard/src/lib.rs
Comment thread core/shard/src/poll/deferred.rs Outdated
Comment thread core/shard/src/poll/deferred.rs
Comment thread core/sdk/src/driver.rs
Comment thread core/sdk/src/clients/consumer.rs
Comment thread core/sdk/src/stream_builder/config/config_iggy_consumer.rs
Comment thread core/connectors/runtime/src/sink.rs Outdated
Comment thread core/sdk/src/clients/consumer.rs
hubcio added 21 commits October 27, 2026 01:12
Idle consumers repeatedly poll quiet partitions. Park requests until
data arrives or the wait expires, with bounded deadlines, reply
sizes and capacity.

Default Rust consumers to deferred polling with manual commits;
expose bounded polling through Java TCP and HTTP. Route readiness
waits across partition leaders, preserve cancellation order and
refresh budgets on each attempt.

Keep progress ordering separate from producers, bound shared
client admission, and distinguish capacity pressure from routing
failures. Preserve retained offset operations and report uncertain
delivery without replaying progress polls.

Tie auto-commit fetches to demand, preserve partition positions and
document lost-reply uncertainty. Cover limits, oversized records,
cancellation, retries, session changes and cross-leader delivery.

BREAKING CHANGE: Rust consumers default to AutoCommit::Disabled
instead of committing on poll, and the stream builder starts at
PollingStrategy::next() instead of last(). An application that never
stores offsets reads everything again after a restart, and a new
stream-builder group starts at offset zero. The consumer builder's
poll_interval methods are gone, and IggyConsumerConfig::new,
IggyConsumerConfig::from_stream_topic and IggyStreamConfig::new take
DeferredPollOptions instead of a polling interval.
Java and PHP tests still use outdated polling API arguments. Java's
protocol parity table omits deferred commands, and Swift's error
table omits the new deferred capacity refusal.

Update the test calls and synchronize the protocol fixtures with
the server's command and error codes.
The worker dropped TransientNotAccepted and
DeferredPollCapacityExceeded, which reach it only after the driver
retried for the whole request timeout. A server that kept refusing
left next() pending with no error, log or metric. Deliver both like
any other poll failure, as master did.

A readiness wait that times out on a lost replica now reaches the
application as TransientNotAccepted, where a master poll with
auto-commit reported TransientNotCommitted.
A waiter that reserve_read refused went back without a wake, so only
a commit, its deadline or maintenance (16 waiters per 10 ms tick)
probed it again, and waiters whose reads kept coming back took freed
slots first. A refused read reached its request deadline although
its messages were on disk.

Refused reads now queue by first refusal. Only the oldest one may
take a freed slot, and the pump wakes it when the budget has room.
A waiter whose read stayed below min_count read again from its start
offset on every commit. On the disk tier each read reserved
max_read_bytes, so a few such waiters held the shard's read budget
for their whole wait and other disk reads were refused.

The waiter now keeps the messages and bytes of its last read and
counts only the resident messages committed after it. A full read
starts once that total can reach min_count or the byte limit, so
early replies at the byte cap still happen. When the new messages
already left the journal, the waiter reads in full as before.
A waiter below min_count counted the messages committed after its
last read with a trial read of the journal. That worked only for
offset polls whose new messages were still resident. Timestamp
polls, and every poll on a persisted single-replica topic, where a
batch leaves the journal as it commits, still read in full on each
commit. With the default min_count of 1, every filled wake built
and selected its plan twice.

The waiter now bounds the next read by the offsets and batch bytes
committed after its last read. Offsets are consecutive, so the
bound holds for every polling kind and needs no read. Partitions
count the batch bytes that their commits make visible.
Freed read room pushed the oldest refused read to the back of the
ready queue. Every disk read probed before it was refused, since
none was first, so the room stayed unused while up to a full
registry of waiters went ahead, each building a disk plan first.
Only one refused read woke per pass, and room left after a service
turn waited for the next 10 ms tick.

Freed room now goes to the oldest refused read before other wakes,
the pump keeps turning while one can start, and a queued read
waits for its turn without building another plan.
The raw replica request refused polls and consumer-offset reads,
because a partition read needs the partition's primary and its
incarnation, and a request to a chosen replica carries neither.
Deferred polls, readiness waits and deferred-request cancels
passed the check. The server answers no cancel, so a raw cancel
only waited for its deadline. The Rust and Java SDKs now refuse
these codes before anything is sent.
The raw Java request sent deferred polls, readiness waits and
deferred-request cancels as they were. A deferred poll carried no
route and no partition context, a wait did not go to the primary
of each partition it names, and a cancel only waited for its
deadline. The Rust raw API routes the first two and refuses the
third.

A raw deferred poll now takes the routed path of a typed one, with
its deadline set by the request timeout in its payload. Limits
that the server's decoder refuses fail before anything is sent.
A raw wait fails with FEATURE_UNAVAILABLE, because this client has
no readiness-wait route, and a raw cancel fails with
INVALID_COMMAND, as in Rust. Neither is sent.
A sink commits on poll. When a reply ends short of the partition
head, the loop holds its messages until more arrive or the batch is
full. A stop signal in that window ended the loop and dropped the
held batch. The task then dropped the consumer without
IggyConsumer::shutdown, so the batches inside the consumer stayed
committed as well. The next run started after messages that the
plugin never got.

On a stop, or when the stream ends, the loop now gives the held
batch to the plugin before it exits. The task then awaits
IggyConsumer::shutdown, which moves the committed offset back to the
last message that the plugin got and leaves the consumer group.
A readiness wait (command 107) takes no partitions after it starts.
The worker kept one wait per group and skipped a group while any
partition of it waited. A partition that drained in that time had
no wait until that wait ended, which took up to max_wait. A
committing member started waits only when no partition was ready.
A backlog on one partition then hid new messages on the others
until it drained.

A partition without a known message and without a wait now
restarts the wait of its group. Committing members keep their
waits running while they fetch and between the polls of the
application. A restart cancels the old wait, so each group still
holds one deferred request. A second wait for each drained
partition would hold more, and fetches wait behind them. New
readiness groups restart every wait, so a restart never leaves a
partition of another group behind a cancelled wait. A late reply of
a replaced wait changes nothing.
A rebalance that left a partition with its member cleared its
fetch position, so the partition restarted from its initial
strategy. Under manual commits it read delivered messages again
or, under Last, skipped some. Under commit-on-poll, a lost reply
was retried with Next, which starts after the batch if the
server committed it, so that batch never reached the application.

The server keeps the owner of a partition that stays with its
member. Within one session, a rebalance now drops only the
positions of the partitions that left, and a new session still
clears them all. A partition that moved away and back has a new
owner, and the server refuses a poll from its old position. A
committing poll now drops the position on that refusal, like a
poll that commits nothing, so the partition resolves its
strategy again.
Under commit-on-poll the server commits each batch that it reads.
A fetch whose reply was lost left the stored record of its
partition at the last delivered message, so a shutdown before the
retry stored nothing. The stop also aborted a fetch in flight. In
both cases a server that committed the batch kept its offset past
messages that the application never got, and the next run skipped
them.

A committing fetch now carries a guard. Unless the fetch settles
with a delivered batch or a refusal, the guard forgets the stored
record, and the shutdown stores the last delivered message again.
The stop wakes the worker, which then starts nothing new and waits
up to offset_drain_timeout for its fetches. A batch that arrives in
that time comes back with the undelivered batches and is rewound.
After that time the worker is aborted. An aborted fetch drops on
its own task, so its guard holds the batch channel open and the
stop drains the channel only after every guard is gone.

The rustdoc no longer claims that only a lost reply to the first
Next poll of a partition can skip its batch.
The server turns a deferred Next poll into an explicit offset when
it admits the poll, and the poll can then wait up to max_wait. An
offset that another session stored during the wait did not reach
the waiting poll. After a rewind, the poll read from its old start,
and its commit moved the offset past the rewound messages, so no
poll read them again. After a store ahead, it returned messages
that the store marked as read.

A waiting Next poll now compares its start with the start that the
current stored offset gives, before each read and before its
commit. If the two differ, the server refuses the poll with
TransientNotAccepted. Nothing was applied, so the client sends the
poll again, and the server resolves Next from the new offset, as it
does for a plain poll. Resolving Next again at each read does not
cover a store that lands during a disk read, and the waiter counts
its selection from a fixed start.
A commit on a watched partition queued every deferred poll that
waited on it. Each probe then validated its poll in full: the
session, the user, the permissions, the metadata and the partition
state. For a group consumer, it also scanned the group members. A
poll whose min_count the new messages could not reach then only
parked again. With many batching polls on a busy partition, this
work ran in front of every inbound frame of the shard.

The commit now reads the history, visibility and view of the
partition once. If a read could not finish a poll, and the poll
keeps its history and view, the poll stays parked without a probe.
It moves its bound to the new visibility, as its probe did. Every
other poll is queued as before. A probe still validates each poll
before its read and before its commit. Maintenance and the deadline
still probe the polls that stay parked.
A commit woke each parked readiness wait on its partition, and the
probe ran every check on every target of the wait. A wait on 1000
partitions cost 1000 evaluations per commit, and maintenance probed
16 waits per tick whatever their size.

A probe now evaluates the targets whose partitions changed, plus a
window that maintenance assigns by counting targets, and checks the
caller once per wait. A reply can leave out a next partition whose
stored offset moved back without a commit. The client waits on that
partition again, and admission evaluates every target.
A deferred disk read walked with a third of its 16 MiB reservation
and ignored the request's max_bytes. The budget admitted a second
1 MiB chunk, so a poll at the SDK default of 1 MiB fetched nearly
2 MiB, the reply kept 1 MiB, and the next poll read the rest again.

The walk now takes the reply's byte limit, min(max_bytes,
max_read_bytes) minus the response header. It sizes each read to
what the reply can still keep plus the run in front of the first
match, and it stops once its selection fills that limit. A first
record above max_bytes is still read whole, so the SDK can fetch
it alone. Plain polls pass no limit and read as before. The
reservation per read is unchanged.
A cancel (command 108) can reach the server while its deferred poll
still waits in the connection queue. cancel_deferred_request returns
true even when nothing matched, so the queue removal in
enqueue_client_request is the only step that keeps the poll from
running later. No test covered it. The queue test never sent a
cancel, and the integration test cancels a parked poll. Without the
removal, the poll would park with nothing left to abort it, and it
would commit a batch that no client waits for.

The new tests queue an auto-commit poll behind a full cap and send
its cancel the way the transport does. The poll leaves the queue and
never reaches its owner. A cancel of another request leaves it
queued until the cap admits it. The deferred poll frame of the
existing queue test moves into a helper that both tests use.
A partition primary that had no free relay slot refused a relayed
deferred poll with 429 or 503. The body id was the HTTP status and
the body code named the relay cap. The Rust and Java deferred
clients back off only on error 58 or 90. Rust reads the body id and
Java reads the body code. So a full relay budget failed the poll at
once, but the SDK README says that clients back off on a capacity
refusal.

A refused deferred relay now carries the id and code of
DeferredPollCapacityExceeded (90). The status, the reason and
Retry-After do not change, and a refused immediate relay keeps its
old body. The SDK README names this case.
A refused readiness wait marks the route of every partition in its
group stale, and one wait covers up to 1000 partitions. The driver
then looked the routes up one after another before it waited again.
A primary that held 333 of 1000 partitions on 3 nodes cost 333
round trips, and every consumer of the group stalled that long in a
view change.

The lookups now run 16 at a time. Each group keeps the order of the
partitions, so a consumer does not see a changed group and restart
its waits, and the first failed lookup in that order is still the
error. No request returns the routes of a whole topic, so each
partition still costs one lookup.
poll_next keeps the batch of a commit-on-poll fetch, because the
server already committed it. A new fetch generation or assignment
must not clear that batch from the buffer, and a batch that carries
an older generation must still reach the application. poll_next
also skips the messages up to the last delivered offset when a
batch reads them again, unless replay is on. No test failed when
one of these checks was removed.

The new tests call poll_next on a buffered batch across a
generation change and an assignment change, on a committing fetch
whose generation changes while it is in flight, and on a batch
that reads delivered messages again, with and without replay.
IggyConsumer::poll_next fails with InvalidConfiguration until init()
runs. Earlier releases polled without init(), so a loop that
continues on errors now spins without a single log line.

The first poll before init() logs a warning that names the
consumer. Later polls skip the warning, and every poll still fails
with the same error.
The consumer defaults moved to AutoCommit::Disabled and Next, but the
stream-builder path gives no signal. A MessageConsumer gets no handle
to store offsets, and the shutdown warning never fires when the
consumer is dropped inside a task, so an app on the default config
reads from offset 0 on every start without a log line.

consume_messages now warns once at its start for Disabled with Next,
sharing the shutdown check. IggyConsumerConfig::with_auto_commit lets
a default config set a mode, and the stream-consumer and stream-basic
examples keep committing on poll.
The DeferredPollRequest doc said that the owner shard sets both
deadlines when the request arrives. partition_read runs on the
origin shard and stamps them from the shared bus clock before it
routes the request to the owner.
Every deferred disk read reserved deferred_poll_max_read_bytes,
16 MiB by default, whatever its max_bytes. So a shard with the
default 64 MiB budget ran four deferred disk reads at once, even
when each asked for 1 MiB.

A read now reserves three times its max_bytes, one part each for
its snapshot, disk selection and index loading, but no more than
deferred_poll_max_read_bytes. A part is never smaller than the
1 MiB disk chunk, because one read can take a 1 MiB chunk
whatever its max_bytes, so small polls cannot start more reads
than the budget holds. At the defaults, 21 reads fit one shard.
The disk walk still gets a third of the reservation, so a 1 MiB
poll now walks with 1 MiB of buffers instead of 5.3 MiB. The
gauge moves by the size of each reservation.

Reads now differ in size, so the pump wakes and the service loop
starts the head of the refused queue only when that read fits. A
smaller read behind it keeps its place instead of overtaking it.
Deferred polls replaced the sink polling loop, and the runtime
only logged a warning for a stream that still set poll_interval.
A sink that batched with it, such as Delta with 3 s, fell back to
min_count 1 and max_wait 1 s and made many small commits. One log
line at setup was the only signal.

Sink setup now refuses such a stream on the path that refuses an
invalid max_wait. The sink fails to start with an error that names
max_wait and min_count, and the other connectors keep running. The
field stays in the config, because serde ignores an unknown field
without notice. The in-repo sink configs and the runtime README
drop the setting.
All sinks shared one consumer client. Its progress lane per partition
and its 64 deferred requests were shared too, so a sink that waited
with min_count and max_wait held back the polls and offset stores of
other sinks on the same partition, and about 16 sink consumers on a
cluster used up the requests of all sinks. A restart also rejoined
the group as the same client, so a stopped instance that left late
removed the new member.

Each sink start now connects its own client. Its consume tasks share
it, and the task that ends last logs it out and shuts it down,
because a consumer shutdown can still rewind offsets after the 5 s
stop wait. A start that fails after the connect logs its client out
at once. A closed connection alone keeps its session, and with it
the group members of earlier streams, until the session times out.
The restart path connects before the plugin starts, so a refused
connection leaves no plugin instance behind.
The README said that consumers on one partition share its progress
lane, but not what that costs a consumer's own offset calls. They
wait while a progress poll is parked there, and a wait close to the
30 s operation timeout makes them fail before they are sent.
@hubcio
hubcio force-pushed the feat/deferred-polling-pr branch from 9a8c3ac to 9f87617 Compare October 27, 2026 00:13
@hubcio
hubcio merged commit dc4006d into master Oct 27, 2026
116 checks passed
@hubcio
hubcio deleted the feat/deferred-polling-pr branch October 27, 2026 07:05
@github-actions github-actions Bot removed the S-waiting-on-review PR is waiting on a reviewer label Oct 27, 2026
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.

3 participants