feat(core-amqp): refresh CBS tokens before they expire - #7308
Draft
Johnathan W (j7nw4r) wants to merge 6 commits into
Draft
feat(core-amqp): refresh CBS tokens before they expire#7308Johnathan W (j7nw4r) wants to merge 6 commits into
Johnathan W (j7nw4r) wants to merge 6 commits into
Conversation
|
Azure Pipelines: Successfully started running 3 pipeline(s). 7 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
The connection cached an access token for each audience and never looked at it again. On a cache hit it returned the stored token with no expiry test, and it stored tokens with emplace, which does not overwrite. There was no refresh timer. So a client that outlived one token lifetime got an unauthorized detach and could not recover without a process restart. The cache now refuses a token that is about to die and authenticates the audience again. A refresh thread also replaces each cached token seven minutes before the token expires, which follows the .NET client. The refresh thread is specific to uAMQP. It releases the token mutex for the credential call and for the CBS operation, because both go to the network. Holding that mutex there would block every caller that opens a link, and it would stop a shutdown from cancelling the work. The thread keeps a weak pointer to the session, so it never extends the life of a session, and it releases that pointer before it takes the mutex again. It drops the cache entry on any failure, so the next link open authenticates again. A result is discarded when a caller stored a newer token while the mutex was free. Shutdown cancels the refresh context before it takes the mutex, so a cancel can stop an operation that is in flight. The connection stops the thread from Close and from the destructor. The expiry rules live in a new header as pure functions, so tests can drive them without a service. They compare in the Azure::DateTime domain, because the cast to a system clock time point throws for a value outside the range of that clock, and a credential can return any value. Refs: #7254
The refresh thread releases the token mutex for its CBS operation, so a caller and the refresh thread could run two claims based security objects on one connection at the same time. uAMQP names the CBS links after the node, so both objects attach a link called "$cbs-sender" and one called "$cbs-receiver". AMQP 1.0 section 2.6.1 requires a link name to be unique for one direction between two containers, so the broker detaches one of them. A caller that authenticates in that window can get an authentication error for a token that is good. Add a mutex that covers the CBS operation alone, and take it on both paths. A caller takes the token mutex and then this mutex. The refresh thread takes this mutex only while it does not hold the token mutex, so the lock order stays acyclic and a shutdown still cancels the operation. Also wait for the refresh to reach the service in the live test before it checks the token count, because the count rises when the refresh asks for the token, which is before the put. Refs: #7254
The CI task "Validate Clang Format" runs clang-format-11 across the sdk folder and fails when a file differs. The new tests were formatted with a newer clang-format, which wraps some calls differently, so the task failed on three pipelines. Format the file with clang-format 11. No code changes.
…ation Event Hubs classifies amqp:unauthorized-access as not transient, so a send that meets that condition stops at the first attempt. Say so in the changelog entry, because it shows why the refresh matters.
Johnathan W (j7nw4r)
force-pushed
the
feat/cbs-token-refresh
branch
from
August 12, 2026 18:46
9373ea2 to
76202ae
Compare
The token refresh thread promotes a weak session pointer for the length of one refresh. A session holds its connection, so that promoted pointer can be the last reference to both. When the thread releases it, the session destructor and then the connection destructor run on the refresh thread. That gave two failures. First, the connection destructor calls StopTokenRefresh, which sees that the thread is joining itself and detaches it. The destructor then destroyed the token mutex, the condition variable, the stop flag, and the token maps, because all four were connection members. The detached thread came back to a destroyed mutex on the next line. The destructor asserts do not stop this, because Event Hubs on uAMQP never calls Connection::Open or Connection::Close. Second, the refresh takes an early return when the token for the audience is gone from the cache. On that path the session pointer died under the token mutex, so the connection destructor took a mutex that this thread already held. That mutex is not recursive, so the thread locked itself. The mutex, the condition variable, the stop flag, and the two token maps now live in a TokenRefreshState block that the connection and the thread both own. The thread body is a static function that takes the connection as a raw pointer, so the thread never keeps the connection alive. When a release can have destroyed the connection, RefreshTokenForAudience returns true, and the thread leaves without touching the connection again. The state block stays alive until the thread returns. The promoted session goes in a ReleaseOutsideLock hold. The hold gives up the token mutex for the release and takes it again after, on every path out of the function, and also on the early return and on an exception. So the connection destructor never runs on this thread while this thread holds the token mutex.
The refresh thread body needs a service, so no unit test reaches it. The two pieces that the lifetime fix added do not need one. TestReleaseOutsideLock puts an object in the hold that records the state of the lock in its destructor. The tests show that the release drops the pointer with the lock free, that it puts the lock back afterwards, and that the destructor does the same on the early return path and on an exception. Those are the properties that keep the connection destructor off the token mutex. TestTokenRefresh gets two more tests for the shared state block: the block starts empty, and it stays alive and usable after the owner that made it is gone.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The AMQP connection cached a CBS access token for each audience and never looked at it again. A cache hit returned the stored token with no expiry test, and the store used
emplace, which does not overwrite. A client that outlived one token lifetime, about 60 to 90 minutes for Microsoft Entra ID, received anamqp:unauthorized-accessdetach and could not recover without a process restart.This change is the token part of #7254. The link rebuild, the receiver rebuild, and the retry classification stay out of it.
Motivation
ConnectionImpl::AuthenticateAudiencereturned a cached token with no test ofExpiresOn, so authentication was a one time event for the life of the connection. The .NET client refreshes with a seven minute buffer (AmqpConnectionScope.cs:73,736); the C++ client had no equivalent.The failure needed no fault on the network, and it is terminal on the first attempt.
EventHubsExceptionFactory::IsErrorTransientdoes not listamqp:unauthorized-access, so the producer treats an expired token as not transient and stops instead of retrying.Changes
Three layers, all in
azure-core-amqp.The cache now refuses a token with less than 30 seconds of life left and authenticates again. The store assigns instead of
emplace. Both transports compile this part.A refresh thread replaces each token seven minutes before it expires, behind
#if ENABLE_UAMQP. It releases the token mutex for the credential call and the CBS operation, since both go to the network. It keeps a weak pointer to the session and promotes it only for one refresh. It discards its result when a caller stored a newer token, drops the cache entry on failure, and keeps a minimum interval of 20 seconds between passes so a short lived token cannot make it spin. A second narrow mutex serializes the CBS operation itself, because uAMQP names CBS links after the node and two attaches with one name violate AMQP 1.0 section 2.6.1. Callers take the token mutex then that one; the thread takes that one only while it holds no token mutex, so the order stays acyclic.An ownership rule makes the thread safe to shut down. A session holds its connection, so the promoted pointer can be the last reference to both, and releasing it ran both destructors on the refresh thread. That gave two failures: on the normal path the destructor detached the thread and then destroyed the token mutex, which the detached thread locked on the next line; on the early return the pointer died under the token mutex, which the destructor then locked from the thread already holding it. The destructor asserts catch neither, because Event Hubs over uAMQP never calls
Connection::OpenorConnection::Close, both being inside#if ENABLE_RUST_AMQP.The fix moves the token mutex, the condition variable, the stop flag, and the two token maps into a
TokenRefreshStateblock that the connection and the thread co-own through ashared_ptr, so the state outlives a connection that dies under the thread. AReleaseOutsideLockholder releases the promoted session with the lock free on every exit path, including the early return and an exception. The thread body is static and takes a raw connection pointer, and the refresh returns true when a release can have destroyed the connection, so the thread leaves without touching it again.Three behaviours are not visible in the diff. The destructor is the only shutdown path on the Event Hubs uAMQP configuration, so there is no
Closepath to review. A caller authenticating a new audience can wait behind an in flight refresh, bounded at 60 seconds by the refresh context. The thread does not restart after an unexpected error; the near expiry test on the cache is the fallback.One scaling note:
ProducerClientcreates one connection for each partition ID, so a 32 partition producer runs 33 connections and 33 refresh threads. Each carries its own mutexes, so partitions never queue behind each other.Test plan
Unit tests in
connection_tests.cppcover the expiry rules, including a default constructedExpiresOnand a year 9999 value, which must not throw. Nine more cover the ownership rule: seven drive theReleaseOutsideLockholder across release, restore, destructor, exception, repeat release, and shared ownership; two cover the state block.Five
_LIVEONLY_tests inproducer_client_test.cpprun against a real namespace, with a credential decorator that reports a short life for a real token so a refresh happens in seconds. They cover a refresh that reaches the service, continuity past two token lifetimes, a clean shutdown, no repeat requests for a normal token, and eight threads calling across a refresh. Together they add about eight minutes to a live run.Both ownership failures were reproduced before the fix with a throwaway harness that forces the interleaving without a network. Each hung for 15 seconds and each passes after the fix. AddressSanitizer reports nothing, because the faulting call sits inside an uninstrumented libc function.
Validation
azure-core-amqpunit tests, uAMQP configuration: 129 of 129 pass.clang-format11 is clean on every file touched.Follow up
The cleanup close in
PutTokenForAudienceuses the caller's context. A cancelled context makesMessageSenderImpl::Closethrow before it clears the management client's open flag, and that client stops the process when destroyed. Only direct users ofConnection::Closeover uAMQP reach it, which excludes Event Hubs. A fresh context with a short deadline fixes it; a bare uncancellable one does not, becauseMessageSenderImplnever completes its close queue when a sender enters the error state. That missing completion can hang any sender close.The concurrent call test drives about 40 management operations each second through one client, against a documented limit of 50 for each consumer group, and a throttle arrives as
amqp:not-allowed, which is classified as not transient. A slower cadence, aStatusCodecheck, and a progress watchdog would keep its purpose without the flake.Mock server tests for the refresh are absent.
mock_amqp_server.hpprejects a second attach on a link name it holds and does not restart its loop after the last detach, so it cannot serve a CBS re-attach. It is shared infrastructure and excluded on macOS, so that belongs in a change validated where those tests run.