Skip to content

Performance [P1]: Single-flight async Azure token refresh - #198

Merged
berndverst merged 3 commits into
mainfrom
berndverst-single-flight-async-token-refresh
Jul 27, 2026
Merged

Performance [P1]: Single-flight async Azure token refresh#198
berndverst merged 3 commits into
mainfrom
berndverst-single-flight-async-token-refresh

Conversation

@berndverst

Copy link
Copy Markdown
Member

Summary

AsyncAccessTokenManager.get_access_token() checked expiry and awaited
refresh_token() with no synchronization. Because
DTSAsyncDefaultClientInterceptorImpl._intercept_call() 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 hit the
credential — a thundering herd of Entra/credential requests, added latency, and
avoidable throttling pressure. The synchronous AccessTokenManager already
guards 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 a
    refresh guard, so only one coroutine calls the credential per refresh window
    and the rest reuse the result.
  • The guard is an asyncio.Lock created lazily per running event loop
    (kept in a small map guarded by a threading.Lock). An asyncio.Lock binds
    itself 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 across
    loops. Locks belonging to closed loops are pruned so the map cannot grow
    without bound.
  • The fast path is unchanged: when a cached token is still valid, the outer
    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 a
credential 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

  • New tests in tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py,
    modeled on the existing sync test_concurrent_refresh_performs_single_refresh:
    • 16 concurrent cold-start callers result in exactly 1 credential call.
    • 16 concurrent callers after expiry result in exactly 1 refresh.
    • A valid cached token is returned without contacting the credential.
    • A credential failure propagates to waiters and does not deadlock; a
      subsequent attempt succeeds.
    • The manager is reusable across two distinct event loops (guards against
      binding the lock to a dead loop).
  • Confirmed the tests catch the bug: with the source change reverted, the
    two single-flight tests fail with 1 != 16 and 2 != 17 — exactly the
    duplicate acquisition described in the issue.
  • Provider unit tests: test_durabletask_grpc_interceptor.py +
    test_sandboxes_extension.py — 47 passed (42 at baseline, +5 new).
  • Core unit tests: tests/durabletask excluding *_e2e.py — 681 passed,
    7 skipped, identical to baseline.
  • flake8 clean on both changed Python files; pyright reports 0 errors
    on the changed source; pymarkdown introduces no new findings (the two
    MD013 hits on the changelog are pre-existing lines).
  • test_dts_batch_actions.py fails identically with and without this change
    (11 failures on a clean baseline); it requires a live Durable Task Scheduler
    endpoint.

Fixes #192

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
Copilot AI review requested due to automatic review settings July 27, 2026 06:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Lock guarding with double-checked refresh logic in AsyncAccessTokenManager.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.

@berndverst

Copy link
Copy Markdown
Member Author

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 — declining

The suggestion was to add a blank line between the license header and the first import. I checked main before acting, and access_token_manager.py already has no blank line there:

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
from datetime import datetime, timezone

This PR adds import asyncio as the first import and removes nothing, so it preserves the file's existing style rather than introducing a deviation. The repo's "header, then blank line" convention applies to new files; reformatting this one would be an unrelated style change to a file that was already like this.

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 reason

The comment on the list comprehension in _get_refresh_lock said the formatting "violates flake8." That part is not accurate — I ran python -m flake8 against the changed files and it exits 0 with no output, so there is no lint violation to fix.

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 update

This branch is being merged up to latest main, which now includes #199 (0f316cc). That PR rewrote AccessTokenManager.__init__ to defer the initial token acquisition, touching the same constructor this PR modifies, so the merge keeps both: main's deferred first acquisition and this PR's single-flight async refresh. The interaction is worth a look on re-review, since the deferred first acquisition now also flows through the refresh lock that this PR made single-flight for the async path.

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
Copilot AI review requested due to automatic review settings July 27, 2026 16:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@berndverst
berndverst merged commit 30f6e24 into main Jul 27, 2026
25 checks passed
@berndverst
berndverst deleted the berndverst-single-flight-async-token-refresh branch July 27, 2026 17:41
berndverst pushed a commit that referenced this pull request Jul 27, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance [P1]: Single-flight async Azure token refresh

3 participants