Performance [P1]: Single-flight async Azure token refresh - #198
Conversation
AsyncAccessTokenManager.get_access_token() checked expiry and awaited refresh_token() without any synchronization. Since the async DTS gRPC interceptor awaits it on every RPC, a burst of concurrent grpc.aio calls arriving while the token was missing or inside its renewal window would each independently call the credential, producing a thundering herd of Entra/credential requests, extra latency, and throttling pressure. Mirror the double-checked locking already used by the synchronous AccessTokenManager: re-check token validity after acquiring a refresh guard so only one coroutine contacts the credential per refresh window while the rest reuse the result. The guard is an asyncio.Lock created lazily per running event loop (tracked in a small map behind a threading.Lock), because an asyncio.Lock binds to the loop it is first awaited on and this manager may outlive a loop or be shared across loops. Locks for closed loops are pruned so the map cannot grow unbounded. The cached-token fast path is unchanged and never touches the lock, so there is no added per-RPC overhead. Public signatures, return values, the renewal margin behavior, exception propagation, and logging are all preserved. Adds unit tests covering concurrent cold start, concurrent refresh after expiry, the cached-token path, credential failure propagation without deadlock, and reuse across distinct event loops. Fixes #192 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bb08d0ed-484d-4015-b905-3e164f68678b
There was a problem hiding this comment.
Pull request overview
This PR improves the Azure Managed provider’s async token acquisition path by adding single-flight synchronization to AsyncAccessTokenManager, preventing concurrent grpc.aio RPC bursts from triggering duplicate credential refresh calls.
Changes:
- Added per-event-loop
asyncio.Lockguarding with double-checked refresh logic inAsyncAccessTokenManager.get_access_token(). - Added async concurrency/unit tests covering cold-start, refresh-window behavior, failure propagation, and cross-event-loop reuse.
- Documented the user-visible concurrency improvement in the provider changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py | Adds async single-flight and event-loop binding tests for AsyncAccessTokenManager. |
| durabletask-azuremanaged/durabletask/azuremanaged/internal/access_token_manager.py | Implements per-event-loop refresh locking with a double-check to avoid duplicate async refreshes. |
| durabletask-azuremanaged/CHANGELOG.md | Notes the improved async refresh concurrency behavior under ## Unreleased. |
…ght-async-token-refresh
|
Thanks for the review. Addressing the two automated comments here, since one of them rests on a claim I was able to check directly. Blank line after the copyright header — decliningThe suggestion was to add a blank line between the license header and the first import. I checked # Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
from datetime import datetime, timezoneThis PR adds Happy to fix it as a separate cleanup if you would prefer the convention applied retroactively across existing files, but it seems out of scope here. Comprehension readability — agreeing, with a correction to the reasonThe comment on the list comprehension in The underlying readability point is fair, though, so it is being applied on its own merits: the comprehension is hoisted into a named local and then iterated. No behaviour change. Also in this updateThis branch is being merged up to latest |
Address review feedback on the stale-lock cleanup in AsyncAccessTokenManager._get_refresh_lock(). The list comprehension was embedded directly in the `for` statement, which read awkwardly across the wrapped line. Hoist it into a named `stale_loops` local and iterate over that. This is purely a readability change with no behavioural difference: the snapshot is still materialized before mutating the mapping, which is what makes deleting during iteration safe. Note this was not a lint violation - flake8 passes on the file both before and after the change. Also align the async deferred-acquisition test with the naming and assertions that PR #199 introduced for the synchronous manager, and assert explicitly that constructing AsyncAccessTokenManager performs no credential call. The async manager has always deferred its first acquisition, so the cold-start burst flows through the same refresh guard as a later refresh; the test now states that intent directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bb08d0ed-484d-4015-b905-3e164f68678b
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
durabletask-azuremanaged/durabletask/azuremanaged/internal/access_token_manager.py:4
- The module-level copyright header is no longer followed by a blank line before imports, which is inconsistent with the standard header formatting used across the repo (e.g., durabletask_grpc_interceptor.py starts imports after a blank line).
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import asyncio
from datetime import datetime, timedelta, timezone
Resolves the single conflict in tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py introduced by PR #198 (single-flight async Azure token refresh), keeping both sides. Both sides widened an adjacent import into parenthesized form, so git tangled them around the shared closing parenthesis. Kept every import from both: - main's `access_token_manager` import widened to `AccessTokenManager` plus `AsyncAccessTokenManager`. - this branch's `durabletask_grpc_interceptor` module import and its `DTSAsyncDefaultClientInterceptorImpl` / `DTSDefaultClientInterceptorImpl` import. No test class conflicted: #198 appended its async single-flight classes after TestAccessTokenManagerThreadSafety, while TestSdkVersionCaching sits before it. Verified by AST inventory that the merged symbol set equals the union of both parents exactly - 38 symbols, 38 unique, 0 duplicates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7499a54-f262-49a6-bf91-274af28543ae
Summary
AsyncAccessTokenManager.get_access_token()checked expiry and awaitedrefresh_token()with no synchronization. BecauseDTSAsyncDefaultClientInterceptorImpl._intercept_call()awaits it on everyRPC, a burst of concurrent
grpc.aiocalls arriving while the token wasmissing or inside its renewal window would each independently hit the
credential — a thundering herd of Entra/credential requests, added latency, and
avoidable throttling pressure. The synchronous
AccessTokenManageralreadyguards this with double-checked locking; the async path did not.
This change mirrors that design for the async manager:
get_access_token()now re-checks token validity after acquiring arefresh guard, so only one coroutine calls the credential per refresh window
and the rest reuse the result.
asyncio.Lockcreated lazily per running event loop(kept in a small map guarded by a
threading.Lock). Anasyncio.Lockbindsitself to the loop it is first awaited on, so a single lock created in
__init__would raise if the manager outlived a loop or was shared acrossloops. Locks belonging to closed loops are pruned so the map cannot grow
without bound.
check short-circuits and no lock is touched, so there is no added per-RPC
overhead.
Failures propagate unchanged — the guard is released via
async with, so acredential error surfaces to the caller and a later attempt can still succeed.
Backwards compatibility
No public API changes. Method signatures, return values, the expiry/renewal
margin behavior, exception propagation, logging, and sync-path thread-safety
are all preserved. The only new members are private (
_get_refresh_lock,_refresh_locks,_refresh_locks_guard).Scope
Deliberately narrow, limited to this issue. It does not touch deferred sync
token acquisition (#187) or SDK version lookup caching (#195), which are being
addressed separately in these same files.
Verification
tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py,modeled on the existing sync
test_concurrent_refresh_performs_single_refresh:subsequent attempt succeeds.
binding the lock to a dead loop).
two single-flight tests fail with
1 != 16and2 != 17— exactly theduplicate acquisition described in the issue.
test_durabletask_grpc_interceptor.py+test_sandboxes_extension.py— 47 passed (42 at baseline, +5 new).tests/durabletaskexcluding*_e2e.py— 681 passed,7 skipped, identical to baseline.
on the changed source; pymarkdown introduces no new findings (the two
MD013 hits on the changelog are pre-existing lines).
test_dts_batch_actions.pyfails identically with and without this change(11 failures on a clean baseline); it requires a live Durable Task Scheduler
endpoint.
Fixes #192