fix(eventhubs): stop delivery and release ownership on shutdown - #5108
Johnathan W (j7nw4r) wants to merge 3 commits into
Conversation
|
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. |
3f89beb to
5a4b84a
Compare
`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
5a4b84a to
60343e3
Compare
|
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. |
There was a problem hiding this comment.
🟡 Changes recommended
Shutdown can race with an active dispatch, allowing delivery and ownership claims after the stop sequence.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Updates EventProcessor shutdown behavior to stop event delivery and release partition ownership promptly.
Changes:
- Shares the consumer map across processor lifecycle operations.
- Adds receiver shutdown and ownership release logic with tests.
- Documents the behavior and updates the changelog.
File summaries
| File | Description |
|---|---|
src/event_processor/processor.rs |
Implements shared stop logic and tests. |
src/consumer/event_receiver.rs |
Exposes a test-only receiver helper. |
tests/eventhubs_processor.rs |
Verifies retained-client shutdown behavior. |
CHANGELOG.md |
Records the shutdown fix. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
`stop` does not hold a lock that `dispatch` also takes, so a dispatch that is already in flight can claim an ownership after `release_ownerships` listed the records, and can set a receiver and queue the partition client after `close_all_receivers` snapshotted the map. Both leave the shutdown contract broken: the application keeps receiving events, and another instance waits for the ownership to expire. `stop` cannot wait for the dispatch, because `dispatch` can park on the bounded partition client queue and the wait would hang the shutdown. `dispatch` re-reads the `is_running` flag instead, once after its claim and once after its receiver opens. The mutex around the flag orders the two sides, so exactly one of them cleans up.
|
One lifecycle edge case looks worth covering before merge: reusing the same Before this change, every As a result, a subsequent |
Summary
EventProcessor::shutdownnow 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 afterrunreturned.Motivation
shutdownsetis_running = falseand stopped there.runbuilt its consumers map as a local value and stored it nowhere, soshutdowncould 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.closeclosed receivers, but it consumes the processor out of itsArc, which a caller that holds a partition client cannot do.shutdownnow guarantees two things. It stops the delivery on every partition client that the processor issued, including one the application still holds, and thestream_eventsstream of such a client resolves withConsumerDisconnected. 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.closediffers fromshutdownin what it adds, not in what it stops.closeruns the same stop path, and it also consumes the processor, drains the queued partition clients, and closes the consumer client.closeis therefore a superset ofshutdown.Changes
EventProcessorkeeps its consumers map onself, andrunuses that map in place of a local one.ProcessorConsumersMap::close_all_receivers, which closes each receiver and removes no map entry, so a partition client that the application retains keeps its place.stopthatshutdownandcloseshare. It drops theis_runningguard before it awaits, because astd::sync::MutexGuardis notSendand holding it across an await deadlocks.claim_ownershiprejects a stale ETag.runreads the shutdown flag only after theupdate_intervalsleep, soruncan take up to one fullupdate_intervalto return, while the delivery stops as soon asshutdownreturns.Test plan
mod testsofprocessor.rswere 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, andclose_runs_the_shutdown_stop_path.EventReceiverbefore it asserts, because a partition client with an empty receiver returns a canned error stream that pins nothing. An unclosed offline receiver yieldsAmqpError, and only the closed path yieldsConsumerDisconnected(None).shutdown_future_is_sendguards the deadlock. It becomes a compile error, not a failed assertion, if a later change holds theis_runningguard across an await.close_continues_past_a_retained_partition_clientstill 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=1reports 150 passed, 0 failed, 14 ignored.CARGO_BUILD_JOBS=1 RUSTFLAGS=-Dwarnings cargo test --no-run --package azure_messaging_eventhubsexits 0.cargo fmt --checkandcargo clippy --all-targetsare clean.receive_events_from_processor, which asserts that a retained partition client stops within 30 seconds ofshutdown. No live test ran in this session, so that assertion waits for a live pass.Closes #5096