Skip to content

Commit 814e60a

Browse files
Bernd VerstCopilot
andcommitted
Merge origin/main into berndverst-cache-azure-managed-sdk-version
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
2 parents 0a852f0 + 7223d24 commit 814e60a

5 files changed

Lines changed: 344 additions & 23 deletions

File tree

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1414
`DefaultAzureCredential`) now surface from the first request rather than from the constructor.
1515
The exception type and message are unchanged; only the timing differs.
1616
- `FailureDetails.error_type` now carries the fully-qualified type name (e.g. `durabletask.task.TaskFailedError`) instead of the bare class name, and the new `FailureDetails.is_caused_by()` helper is available (both inherited from durabletask). See the core `durabletask` changelog for details, including the breaking-change notes.
17+
- Improved async access token refresh concurrency handling to avoid duplicate
18+
refresh operations under concurrent access, matching the existing sync
19+
behavior.
1720

1821
## v1.8.0
1922

durabletask-azuremanaged/durabletask/azuremanaged/internal/access_token_manager.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
3+
import asyncio
34
from datetime import datetime, timedelta, timezone
45
from threading import Lock
56

@@ -71,9 +72,37 @@ def __init__(self, token_credential: AsyncTokenCredential | None,
7172
self._token = None
7273
self.expiry_time = None
7374

75+
# An asyncio.Lock binds itself to the event loop it is first used on, and this
76+
# manager may outlive a loop or be shared across loops. Locks are therefore
77+
# created lazily per running loop, guarded by a plain threading lock because
78+
# different loops may run on different threads.
79+
self._refresh_locks: dict[asyncio.AbstractEventLoop, asyncio.Lock] = {}
80+
self._refresh_locks_guard = Lock()
81+
82+
def _get_refresh_lock(self) -> asyncio.Lock:
83+
loop = asyncio.get_running_loop()
84+
with self._refresh_locks_guard:
85+
lock = self._refresh_locks.get(loop)
86+
if lock is None:
87+
# Discard locks belonging to loops that are no longer usable so the
88+
# mapping does not grow without bound.
89+
stale_loops = [
90+
existing for existing in self._refresh_locks if existing.is_closed()
91+
]
92+
for stale_loop in stale_loops:
93+
del self._refresh_locks[stale_loop]
94+
lock = asyncio.Lock()
95+
self._refresh_locks[loop] = lock
96+
return lock
97+
7498
async def get_access_token(self) -> AccessToken | None:
7599
if self._token is None or self.is_token_expired():
76-
await self.refresh_token()
100+
async with self._get_refresh_lock():
101+
# Re-check under the lock: a concurrent caller may have already
102+
# refreshed the token while this one was waiting, so only a single
103+
# credential request is made per refresh window.
104+
if self._token is None or self.is_token_expired():
105+
await self.refresh_token()
77106
return self._token
78107

79108
def is_token_expired(self) -> bool:

durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/worker.py

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -130,30 +130,50 @@ def _stop_sandbox_registration(self) -> None:
130130

131131
def _run_sandbox_registration_loop(self) -> None:
132132
retry_delay = 1.0
133-
while not self._sandbox_registration_stop.is_set():
134-
try:
135-
client = SandboxActivitiesGrpcTransport(
136-
host_address=self._sandbox_host_address,
137-
taskhub=self._sandbox_taskhub,
138-
token_credential=self._sandbox_token_credential,
139-
secure_channel=self._sandbox_secure_channel)
133+
client: Optional[SandboxActivitiesGrpcTransport] = None
134+
try:
135+
while not self._sandbox_registration_stop.is_set():
140136
try:
137+
if client is None:
138+
client = SandboxActivitiesGrpcTransport(
139+
host_address=self._sandbox_host_address,
140+
taskhub=self._sandbox_taskhub,
141+
token_credential=self._sandbox_token_credential,
142+
secure_channel=self._sandbox_secure_channel)
141143
client.connect_sandbox_activity_worker(self._registration_messages())
142144
retry_delay = 1.0
143-
finally:
144-
client.close()
145-
except Exception as ex:
146-
if self._sandbox_registration_stop.is_set():
147-
break
148-
if not _is_retriable_registration_failure(ex):
149-
self._sandbox_logger.error(
150-
"Sandbox activity worker registration failed permanently: %s", ex)
151-
self._sandbox_registration_stop.set()
152-
break
153-
self._sandbox_logger.warning("Sandbox activity worker registration failed: %s", ex)
154-
delay = random.uniform(0, retry_delay)
155-
self._sandbox_registration_stop.wait(delay)
156-
retry_delay = min(retry_delay * 2, 30.0)
145+
except Exception as ex:
146+
# gRPC channels reconnect on their own after transient stream
147+
# failures, so the transport (and its channel, stub, and cached
148+
# access token) is reused instead of being rebuilt per attempt.
149+
if _requires_new_registration_transport(ex):
150+
self._close_sandbox_registration_transport(client)
151+
client = None
152+
if self._sandbox_registration_stop.is_set():
153+
break
154+
if not _is_retriable_registration_failure(ex):
155+
self._sandbox_logger.error(
156+
"Sandbox activity worker registration failed permanently: %s", ex)
157+
self._sandbox_registration_stop.set()
158+
break
159+
self._sandbox_logger.warning(
160+
"Sandbox activity worker registration failed: %s", ex)
161+
delay = random.uniform(0, retry_delay)
162+
self._sandbox_registration_stop.wait(delay)
163+
retry_delay = min(retry_delay * 2, 30.0)
164+
finally:
165+
self._close_sandbox_registration_transport(client)
166+
167+
def _close_sandbox_registration_transport(
168+
self,
169+
client: Optional[SandboxActivitiesGrpcTransport]) -> None:
170+
if client is None:
171+
return
172+
try:
173+
client.close()
174+
except Exception as ex:
175+
self._sandbox_logger.debug(
176+
"Sandbox activity worker registration transport failed to close: %s", ex)
157177

158178
def _registration_messages(self) -> Iterator[pb.SandboxActivityWorkerMessage]:
159179
yield build_sandbox_worker_start(
@@ -264,6 +284,26 @@ def _is_retriable_registration_failure(ex: Exception) -> bool:
264284
return isinstance(ex, OSError)
265285

266286

287+
def _requires_new_registration_transport(ex: Exception) -> bool:
288+
"""Returns whether a failure leaves the registration transport unusable.
289+
290+
A gRPC channel transparently reconnects after a stream fails, so the same
291+
transport can serve the next registration attempt. A channel that has been
292+
shut down rejects every later RPC, and a failure raised outside of gRPC (for
293+
example while the channel itself is being created) can leave the transport
294+
only partially initialized, so both cases need a replacement transport.
295+
"""
296+
if not isinstance(ex, grpc.RpcError):
297+
return True
298+
299+
if ex.code() != grpc.StatusCode.CANCELLED:
300+
return False
301+
302+
details = getattr(ex, "details", None)
303+
resolved_details = details() if callable(details) else None
304+
return isinstance(resolved_details, str) and "channel closed" in resolved_details.lower()
305+
306+
267307
def _resolve_max_concurrent_activities() -> int:
268308
value = os.getenv("DTS_SANDBOX_MAX_ACTIVITIES")
269309
if value is None:

tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
import asyncio
45
import unittest
56
from concurrent import futures
67
from datetime import datetime, timedelta, timezone
@@ -15,7 +16,10 @@
1516

1617
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
1718
from durabletask.azuremanaged.internal import durabletask_grpc_interceptor
18-
from durabletask.azuremanaged.internal.access_token_manager import AccessTokenManager
19+
from durabletask.azuremanaged.internal.access_token_manager import (
20+
AccessTokenManager,
21+
AsyncAccessTokenManager,
22+
)
1923
from durabletask.azuremanaged.internal.durabletask_grpc_interceptor import (
2024
DTSAsyncDefaultClientInterceptorImpl,
2125
DTSDefaultClientInterceptorImpl,
@@ -288,5 +292,108 @@ def test_concurrent_refresh_performs_single_refresh(self):
288292
self.assertEqual(2, credential.calls)
289293

290294

295+
class _CredentialError(Exception):
296+
pass
297+
298+
299+
class _TestAsyncTokenCredential:
300+
def __init__(self, delay: float = 0.02):
301+
self.calls = 0
302+
self._delay = delay
303+
304+
async def get_token(self, _scope):
305+
# Counting before the await means every coroutine that reaches the credential
306+
# is recorded, which is what makes a thundering herd observable.
307+
self.calls += 1
308+
call_number = self.calls
309+
await asyncio.sleep(self._delay)
310+
return AccessToken(f"token-{call_number}", int(time.time()) + 3600)
311+
312+
313+
class _FlakyAsyncTokenCredential:
314+
def __init__(self):
315+
self.calls = 0
316+
self.fail = True
317+
318+
async def get_token(self, _scope):
319+
self.calls += 1
320+
await asyncio.sleep(0)
321+
if self.fail:
322+
raise _CredentialError("credential unavailable")
323+
return AccessToken("token-recovered", int(time.time()) + 3600)
324+
325+
326+
class TestAsyncAccessTokenManagerSingleFlight(unittest.IsolatedAsyncioTestCase):
327+
async def test_deferred_first_acquisition_performs_single_acquisition(self):
328+
credential = _TestAsyncTokenCredential()
329+
manager = AsyncAccessTokenManager(credential)
330+
331+
self.assertEqual(0, credential.calls, "Constructing the manager must not acquire a token")
332+
333+
# The async manager has always deferred the first acquisition (a constructor
334+
# cannot await), so the cold-start burst goes through the same refresh guard
335+
# as a later refresh and must likewise be single-flight.
336+
tokens = await asyncio.gather(*(manager.get_access_token() for _ in range(16)))
337+
338+
self.assertEqual(1, credential.calls)
339+
self.assertEqual({"token-1"}, {token.token for token in tokens})
340+
341+
async def test_concurrent_refresh_performs_single_refresh(self):
342+
credential = _TestAsyncTokenCredential()
343+
manager = AsyncAccessTokenManager(credential)
344+
await manager.get_access_token()
345+
self.assertEqual(1, credential.calls)
346+
347+
manager.expiry_time = datetime.now(timezone.utc) - timedelta(seconds=1)
348+
349+
tokens = await asyncio.gather(*(manager.get_access_token() for _ in range(16)))
350+
351+
self.assertEqual(2, credential.calls)
352+
self.assertEqual({"token-2"}, {token.token for token in tokens})
353+
354+
async def test_valid_token_is_returned_without_contacting_credential(self):
355+
credential = _TestAsyncTokenCredential()
356+
manager = AsyncAccessTokenManager(credential)
357+
358+
first = await manager.get_access_token()
359+
second = await manager.get_access_token()
360+
361+
self.assertEqual(1, credential.calls)
362+
self.assertIs(first, second)
363+
364+
async def test_credential_failure_propagates_and_does_not_deadlock(self):
365+
credential = _FlakyAsyncTokenCredential()
366+
manager = AsyncAccessTokenManager(credential)
367+
368+
results = await asyncio.gather(
369+
*(manager.get_access_token() for _ in range(4)), return_exceptions=True)
370+
371+
self.assertTrue(all(isinstance(result, _CredentialError) for result in results))
372+
373+
# The guard must have been released on failure so a later attempt can succeed.
374+
credential.fail = False
375+
token = await manager.get_access_token()
376+
self.assertIsNotNone(token)
377+
self.assertEqual("token-recovered", token.token)
378+
379+
380+
class TestAsyncAccessTokenManagerEventLoopBinding(unittest.TestCase):
381+
def test_manager_is_reusable_across_event_loops(self):
382+
credential = _TestAsyncTokenCredential(delay=0)
383+
manager = AsyncAccessTokenManager(credential)
384+
385+
first = asyncio.run(manager.get_access_token())
386+
387+
manager.expiry_time = datetime.now(timezone.utc) - timedelta(seconds=1)
388+
389+
# The second run uses a distinct event loop; the refresh guard must not stay
390+
# bound to the first, now-closed loop.
391+
second = asyncio.run(manager.get_access_token())
392+
393+
self.assertIsNotNone(first)
394+
self.assertIsNotNone(second)
395+
self.assertEqual(2, credential.calls)
396+
397+
291398
if __name__ == "__main__":
292399
unittest.main()

0 commit comments

Comments
 (0)