Skip to content

fix(eventhubs): stop delivery and release ownership on shutdown - #5108

Draft
Johnathan W (j7nw4r) wants to merge 2 commits into
mainfrom
j7nw4r/fix-eventhubs-shutdown-closes-receivers
Draft

fix(eventhubs): stop delivery and release ownership on shutdown#5108
Johnathan W (j7nw4r) wants to merge 2 commits into
mainfrom
j7nw4r/fix-eventhubs-shutdown-closes-receivers

Conversation

@j7nw4r

Copy link
Copy Markdown
Member

Summary

EventProcessor::shutdown now stops the event delivery and releases the ownership records that the instance holds. It set an internal flag and did nothing else before, so a partition client that the application already held kept delivering events after run returned.

Motivation

shutdown set is_running = false and stopped there. run built its consumers map as a local value and stored it nowhere, so shutdown could reach no receiver at all. Every receiver stayed attached, and every ownership record stayed live, so a second instance could not claim those partitions until the records expired. close closed receivers, but it consumes the processor out of its Arc, which a caller that holds a partition client cannot do.

shutdown now guarantees two things. It stops the delivery on every partition client that the processor issued, including one the application still holds, and the stream_events stream of such a client resolves with ConsumerDisconnected. It then releases the ownership records whose owner is this instance, so another instance can claim those partitions without a wait for the expiration. A failed release logs at the warn level and does not fail the call.

close differs from shutdown in what it adds, not in what it stops. close runs the same stop path, and it also consumes the processor, drains the queued partition clients, and closes the consumer client. close is therefore a superset of shutdown.

Changes

  • EventProcessor keeps its consumers map on self, and run uses that map in place of a local one.
  • Added ProcessorConsumersMap::close_all_receivers, which closes each receiver and removes no map entry, so a partition client that the application retains keeps its place.
  • Added a private stop that shutdown and close share. It drops the is_running guard before it awaits, because a std::sync::MutexGuard is not Send and holding it across an await deadlocks.
  • The ownership release keeps only the records whose owner id matches this instance, and it keeps the ETag that the store returned, because claim_ownership rejects a stale ETag.
  • Documented that run reads the shutdown flag only after the update_interval sleep, so run can take up to one full update_interval to return, while the delivery stops as soon as shutdown returns.
  • Added a CHANGELOG entry under the unreleased version.

Test plan

  • Five offline tests in the mod tests of processor.rs were written first and proved red against the unchanged source: shutdown_closes_receivers_of_issued_partition_clients, shutdown_releases_only_this_instances_ownerships, shutdown_continues_when_the_ownership_release_fails, shutdown_twice_succeeds_and_keeps_ownership_released, and close_runs_the_shutdown_stop_path.
  • Each delivery test installs an offline EventReceiver before it asserts, because a partition client with an empty receiver returns a canned error stream that pins nothing. An unclosed offline receiver yields AmqpError, and only the closed path yields ConsumerDisconnected(None).
  • shutdown_future_is_send guards the deadlock. It becomes a compile error, not a failed assertion, if a later change holds the is_running guard across an await.
  • close_continues_past_a_retained_partition_client still passes, which holds the contract that a retained partition client stays in the consumers map.
  • CARGO_BUILD_JOBS=1 cargo test --package azure_messaging_eventhubs --lib -- --test-threads=1 reports 150 passed, 0 failed, 14 ignored.
  • CARGO_BUILD_JOBS=1 RUSTFLAGS=-Dwarnings cargo test --no-run --package azure_messaging_eventhubs exits 0. cargo fmt --check and cargo clippy --all-targets are clean.
  • Assertions were added inside the existing live test receive_events_from_processor, which asserts that a retained partition client stops within 30 seconds of shutdown. No live test ran in this session, so that assertion waits for a live pass.

Closes #5096

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
3 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

`EventProcessor::shutdown` only flips `is_running`. It closes no
receiver and it releases no ownership record, so a partition client
that the application holds keeps delivering events after shutdown
returns.

Add five offline tests that fail against the unchanged source, and one
characterization test that guards the `Send` bound on the shutdown
future. Add the same stop assertions to the live
`receive_events_from_processor` test.

Move `receiver_with_failing_attach` out of the private `mod tests` in
`event_receiver.rs` and behind `#[cfg(test)] pub(crate)`, so the
processor tests can give a partition client a receiver that answers
offline.

The harness still builds its own consumers map, because
`EventProcessor` has no `consumers` field yet. When that field lands,
return `processor.consumers.clone()` from the harness. The test bodies
do not change.

Refs #5096
`EventProcessor::shutdown` only set the `is_running` flag. It closed no
receiver and released no ownership record, so a partition client that the
application already held kept delivering events after `run` returned, and
another instance had to wait for the ownership expiration to take the
partitions.

The processor now keeps its consumers map on `self`, so `run` and
`shutdown` share one map. A new private `stop` sets the flag in a scoped
block, closes the receiver of every partition client in the map, and
releases the ownership records of this instance. It keeps the ETag that
the store returned, because a claim with a stale ETag is rejected. A
failure of the release logs at the warning level and does not fail the
call. `close` runs the same stop path before it drains the queued
partition clients and closes the consumer client.

`stop` drops the `is_running` guard before every await, because a
`std::sync::MutexGuard` is not `Send` and the shutdown future must stay
`Send`. `close_all_receivers` keeps the map entries, because a client
that the application still holds must keep its place.

Fixes #5096
@j7nw4r
Johnathan W (j7nw4r) force-pushed the j7nw4r/fix-eventhubs-shutdown-closes-receivers branch from 3f89beb to 5a4b84a Compare August 25, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Event Hubs] EventProcessor::shutdown() does not stop event delivery or release ownership

1 participant