Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Increased `DEFAULT_PARTITION_EXPIRATION_DURATION` from 10 seconds to 60 seconds. The previous default was shorter than `DEFAULT_UPDATE_INTERVAL` (30 seconds), so ownership records expired between load-balancing cycles. The load balancer perpetually saw `current=0` for every consumer and continuously re-claimed partitions, causing widespread duplicate event processing. `EventProcessorBuilder::build` now rejects configurations where `partition_expiration_duration <= update_interval`. ([#3851](https://github.com/Azure/azure-sdk-for-rust/issues/3851))
- A partition stolen by a higher-or-equal-epoch attacher now surfaces as `ErrorKind::ConsumerDisconnected` when the broker reports `amqp:link:stolen` on a re-attach, not only on an in-flight receive. Other attach failures inside the receive loop now classify by their own kind. The wrapper reported all of them as a message error, which the retry decider treated as non-retryable.
- The `EventProcessor`'s load-balancer reconciliation now closes the underlying AMQP receiver for any partition that has been reassigned to another consumer, so the consumer's `stream_events()` resolves and the loop can terminate. Previously a stolen partition's client could continue to attempt receives until the broker tore down the link.
- Fixed a deadlock when a CBS failure during management-client creation started connection recovery. ([#4728](https://github.com/Azure/azure-sdk-for-rust/issues/4728))

### Other Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ pub(crate) struct RecoverableConnection {
pub(super) url: Url,
application_id: Option<String>,
custom_endpoint: Option<Url>,
mgmt_client: AsyncMutex<Option<Arc<AmqpManagement>>>,
// The management client is a single cached instance, held in a `OnceCell`
// for the same reason the per-path caches are: the expensive build (connect
// + session begin + CBS authorize + link attach) must not run while a lock
// is held. The build authorizes the `$management` path, and a CBS failure
// there runs the recovery hook, which invalidates this cache. Holding a
// guard across the build made that a same-task self-deadlock. The `RwLock`
// only guards the *cell pointer*, so recovery can swap in a fresh cell
// without waiting for a build in flight.
mgmt_client: RwLock<Arc<OnceCell<Arc<AmqpManagement>>>>,
// The sender, session, and receiver caches are keyed by path. Each entry is
// an independently-initialized `OnceCell`, so concurrent operations on
// *different* partitions never serialize on a shared lock, and the expensive
Expand Down Expand Up @@ -203,7 +211,7 @@ impl RecoverableConnection {
session_instances: RwLock::new(HashMap::new()),
sender_instances: RwLock::new(HashMap::new()),
receiver_instances: RwLock::new(HashMap::new()),
mgmt_client: AsyncMutex::new(None),
mgmt_client: RwLock::new(Arc::new(OnceCell::new())),
authorizer,
#[cfg(test)]
forced_error: Mutex::new(None),
Expand Down Expand Up @@ -300,8 +308,16 @@ impl RecoverableConnection {
"Closing recoverable connection."
);

let mut management_client = self.mgmt_client.lock().await;
if let Some(management_client) = management_client.take() {
// Swap the cell out under the write lock, then detach without holding
// it. The guard is a separate binding so the lock scope is visible and
// a debugger can read it.
let mut cell_slot = self.mgmt_client.write().await;
let management_cell = std::mem::replace(&mut *cell_slot, Arc::new(OnceCell::new()));
drop(cell_slot);
if let Some(Some(management_client)) = Arc::try_unwrap(management_cell)
.ok()
.map(OnceCell::into_inner)
{
trace!("Closing management client for {}.", self.url);
if let Ok(management_client) = Arc::try_unwrap(management_client) {
trace!("Detaching management client for {}.", self.url);
Expand Down Expand Up @@ -611,22 +627,21 @@ impl RecoverableConnection {
pub(super) async fn ensure_amqp_management(
self: &Arc<Self>,
) -> azure_core_amqp::Result<Arc<AmqpManagement>> {
let mut management_client = self.mgmt_client.lock().await;
if management_client.is_none() {
*management_client = Some(
// Take the cell pointer under a brief read lock, then build without any
// lock held. The build reaches the CBS retry loop, whose recovery hook
// can invalidate this cache on the same task; holding a guard here would
// deadlock that task.
let cell = self.mgmt_client.read().await.clone();
let management_client = cell
.get_or_try_init(|| async {
RecoverableManagementClient::create_management_client(
self.clone(),
&self.retry_options,
)
.await?,
);
}
if let Some(management_client) = management_client.as_ref() {
return Ok(management_client.clone());
}

warn!("Management client is None, cannot ensure management client.");
Err(AmqpError::with_message("Missing Management Client"))
.await
})
.await?;
Ok(management_client.clone())
}

/// Ensures that the AMQP Claims-Based Security (CBS) client is created and attached.
Expand Down Expand Up @@ -873,7 +888,11 @@ impl RecoverableConnection {
debug!(connection_id = %connection_id, count, "Recovery: cleared cached receivers.");
}
if plan.drop_mgmt_client {
self.mgmt_client.lock().await.take();
// Swap in a fresh cell instead of clearing the old one in place. The
// write lock is held only for the pointer swap, never across a build,
// so this never waits for a management-client build in flight (which,
// on the CBS failure path, runs on this very task).
*self.mgmt_client.write().await = Arc::new(OnceCell::new());
debug!(connection_id = %connection_id, "Recovery: dropped management client.");
}
}
Expand Down Expand Up @@ -1532,4 +1551,156 @@ mod tests {
assert!(RecoveryPlan::for_action(&ErrorRecoveryAction::RetryAction).is_none());
assert!(RecoveryPlan::for_action(&ErrorRecoveryAction::ReturnError).is_none());
}

// The management-client build must not hold any `mgmt_client` lock.
//
// This points the connection at a local TCP peer that accepts the socket and
// never sends the AMQP protocol header, so `create_connection` stays inside
// `ensure_amqp_management` for the whole test. The cache lock must still be
// free: it only guards the cell pointer, so recovery and `close_connection`
// can take it while a build is in flight.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn management_build_does_not_hold_mgmt_lock() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stalled AMQP peer");
let port = listener.local_addr().expect("listener address").port();
// Hold every accepted socket open and answer nothing. The first accept
// signals the test, which is the synchronization point that makes this
// test deterministic. A fixed sleep would not do: on a loaded runner the
// build task can still be pending, and the assertions below would then
// pass against the old implementation.
let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let mut accepted = Vec::new();
let mut accepted_tx = Some(accepted_tx);
while let Ok((stream, _)) = listener.accept() {
if let Some(tx) = accepted_tx.take() {
let _ = tx.send(());
}
accepted.push(stream);
}
});

let url = Url::parse(&format!("amqp://127.0.0.1:{port}")).expect("stalled peer URL");
let connection = RecoverableConnection::new(
url,
None,
None,
Arc::new(MockCredential),
Default::default(),
None,
);

let build = tokio::spawn({
let connection = connection.clone();
async move {
let _ = connection.ensure_amqp_management().await;
}
});

// The old code took the `mgmt_client` guard before it opened the
// connection, so a completed accept proves the build is past that point
// and inside the region that used to be locked.
tokio::time::timeout(std::time::Duration::from_secs(30), accepted_rx)
.await
.expect("the build did not connect to the stalled peer within 30s")
.expect("the listener thread dropped the accept signal");

let build_is_running = !build.is_finished();
let lock_is_free = connection.mgmt_client.try_write().is_some();
build.abort();

assert!(
build_is_running,
"The management-client build finished instead of blocking on the stalled peer."
);
assert!(
lock_is_free,
"`ensure_amqp_management` held the `mgmt_client` lock across the build. That is \
the self-deadlock: the build authorizes the management path, and a CBS failure \
there re-enters the same lock through `apply_recovery_plan` on the same task."
);
}

// Recovery must never wait for an in-flight management-client build.
//
// This test uses production entry points only: one task calls
// `ensure_amqp_management` against a TCP peer that accepts the socket and
// then answers nothing, so the build stays in flight. A second task then
// runs `recover_from_error` for `ReconnectLink`, the action a detached or
// stolen CBS link produces. That plan sets `drop_mgmt_client` and leaves the
// connection alone.
//
// While the management client lived behind a single `AsyncMutex` that
// `ensure_amqp_management` held across the whole build, the recovery task
// waited on that guard and never returned. The same wait happens on one
// task in production (build -> CBS authorize -> retry loop -> recovery
// hook), where it is a self-deadlock instead of contention.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn recovery_does_not_wait_for_in_flight_management_build() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stalled AMQP peer");
let port = listener.local_addr().expect("listener address").port();
// Hold every accepted socket open and answer nothing. The first accept
// signals the test, which is the synchronization point that makes this
// test deterministic. A fixed sleep would not do: on a loaded runner the
// build task can still be pending, and the assertions below would then
// pass against the old implementation.
let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let mut accepted = Vec::new();
let mut accepted_tx = Some(accepted_tx);
while let Ok((stream, _)) = listener.accept() {
if let Some(tx) = accepted_tx.take() {
let _ = tx.send(());
}
accepted.push(stream);
}
});

let url = Url::parse(&format!("amqp://127.0.0.1:{port}")).expect("stalled peer URL");
let connection = RecoverableConnection::new(
url,
None,
None,
Arc::new(MockCredential),
Default::default(),
None,
);

let build = tokio::spawn({
let connection = connection.clone();
async move {
let _ = connection.ensure_amqp_management().await;
}
});

// Wait for the build to reach the stalled peer. The old code took the
// `mgmt_client` guard before it opened the connection, so a completed
// accept proves the build holds whatever lock the implementation takes.
tokio::time::timeout(std::time::Duration::from_secs(30), accepted_rx)
.await
.expect("the build did not connect to the stalled peer within 30s")
.expect("the listener thread dropped the accept signal");

assert!(
!build.is_finished(),
"The management-client build finished instead of blocking on the stalled peer."
);

let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
RecoverableConnection::recover_from_error(
Arc::downgrade(&connection),
ErrorRecoveryAction::ReconnectLink,
),
)
.await;
build.abort();

assert!(
result.is_ok(),
"Recovery did not complete in 10s: it waited for the in-flight management-client \
build. On the production path the same wait happens on a single task and hangs \
forever."
);
}
}
Loading