Skip to content

Commit d45812a

Browse files
committed
Defer async client channel creation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7bc5f597-1596-460d-bfe6-738676344ef0
1 parent b229e69 commit d45812a

4 files changed

Lines changed: 91 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import paths, `__all__`, `dir()`, and star-imports behave exactly as before.
3333

3434
FIXED
3535

36+
- Fixed `AsyncTaskHubGrpcClient` failing during construction when no current
37+
event loop was set. SDK-owned async gRPC channels are now created on first use,
38+
binding them to the event loop that performs the RPC.
3639
- Fixed the worker allocating one `asyncio` task per queued work item before applying the concurrency limit, which made memory use and event-loop scheduling overhead grow with the queue backlog during bursts. In-flight work item tasks are now bounded by the configured `ConcurrencyOptions` limits.
3740
- Fixed input/output type discovery raising `TypeError: unhashable type` for handlers that are unhashable callables. A callable object registered through `add_named_activity()`, `add_named_orchestrator()`, or `add_named_entity()` is unhashable whenever its class defines `__eq__` without `__hash__` — most commonly a `@dataclass` with a `__call__` method, since dataclasses default to `eq=True`. Annotations on such handlers are now discovered normally instead of failing.
3841

azure-functions-durable/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ CHANGED
2222
- Reused host-driven orchestration and entity workers across invocations,
2323
avoiding repeated allocation of unused worker resources.
2424

25+
FIXED
26+
27+
- Fixed asynchronous durable-client construction failing after an application
28+
event loop had been closed or cleared.
29+
2530
## 2.0.0b1
2631

2732
First preview (beta) release of `azure-functions-durable` 2.x — a ground-up

durabletask/client.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -988,20 +988,17 @@ def __init__(self, *,
988988
else None
989989
)
990990
self._interceptors = self._compose_interceptors(user_interceptors)
991-
if channel is None:
992-
channel = shared.get_async_grpc_channel(
993-
host_address=self._host_address,
994-
secure_channel=secure_channel,
995-
interceptors=self._interceptors,
996-
channel_options=channel_options,
997-
)
998991
# Caller-owned channels cannot be retroactively wrapped with our
999992
# interceptor (``grpc.aio`` exposes no public equivalent of
1000993
# ``grpc.intercept_channel``). We document this in :meth:`__init__` and
1001994
# leave the failure-tracking opt-out implicit: callers wanting full
1002995
# resiliency should let us create the channel.
1003996
self._channel = channel
1004-
self._stub = cast(_AsyncTaskHubSidecarServiceStub, stubs.TaskHubSidecarServiceStub(channel))
997+
self._stub = (
998+
cast(_AsyncTaskHubSidecarServiceStub, stubs.TaskHubSidecarServiceStub(channel))
999+
if channel is not None
1000+
else None
1001+
)
10051002
self._logger = shared.get_logger("async_client", log_handler, log_formatter)
10061003
self.default_version = default_version
10071004
self._payload_store = payload_store
@@ -1016,6 +1013,25 @@ def _compose_interceptors(
10161013
composed.extend(user_interceptors)
10171014
return composed
10181015

1016+
def _get_stub(self) -> _AsyncTaskHubSidecarServiceStub:
1017+
"""Create the SDK-owned channel on the event loop that first uses it."""
1018+
if self._closing:
1019+
raise RuntimeError("The client is closed")
1020+
if self._stub is None:
1021+
channel = shared.get_async_grpc_channel(
1022+
host_address=self._host_address,
1023+
secure_channel=self._secure_channel,
1024+
interceptors=self._interceptors,
1025+
channel_options=self._channel_options,
1026+
)
1027+
stub = cast(
1028+
_AsyncTaskHubSidecarServiceStub,
1029+
stubs.TaskHubSidecarServiceStub(channel),
1030+
)
1031+
self._channel = channel
1032+
self._stub = stub
1033+
return self._stub
1034+
10191035
async def close(self) -> None:
10201036
"""Closes the underlying gRPC channel.
10211037
@@ -1047,7 +1063,9 @@ async def close(self) -> None:
10471063
await asyncio.gather(*close_tasks, return_exceptions=True)
10481064
for retired_channel in retired_channels:
10491065
await retired_channel.close()
1050-
await self._channel.close()
1066+
channel = self._channel
1067+
if channel is not None:
1068+
await channel.close()
10511069

10521070
async def __aenter__(self) -> "AsyncTaskHubGrpcClient":
10531071
return self
@@ -1093,6 +1111,8 @@ async def _maybe_recreate_channel(self) -> None:
10931111
if now - self._last_recreate_time < self._resiliency_options.min_recreate_interval_seconds:
10941112
return
10951113
old_channel = self._channel
1114+
if old_channel is None:
1115+
return
10961116
self._channel = shared.get_async_grpc_channel(
10971117
host_address=self._host_address,
10981118
secure_channel=self._secure_channel,
@@ -1147,13 +1167,13 @@ async def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInpu
11471167
await payload_helpers.externalize_payloads_async(
11481168
req, self._payload_store, instance_id=req.instanceId,
11491169
)
1150-
res: pb.CreateInstanceResponse = await self._stub.StartInstance(req)
1170+
res: pb.CreateInstanceResponse = await self._get_stub().StartInstance(req)
11511171
return res.instanceId
11521172

11531173
async def get_orchestration_state(self, instance_id: str, *,
11541174
fetch_payloads: bool = True) -> OrchestrationState | None:
11551175
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
1156-
res: pb.GetInstanceResponse = await self._stub.GetInstance(req)
1176+
res: pb.GetInstanceResponse = await self._get_stub().GetInstance(req)
11571177
if self._payload_store is not None and res.exists:
11581178
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
11591179
return new_orchestration_state(req.instanceId, res, self._data_converter)
@@ -1168,7 +1188,7 @@ async def get_orchestration_history(self,
11681188
forWorkItemProcessing=for_work_item_processing,
11691189
)
11701190
self._logger.info(f"Retrieving history for instance '{instance_id}'.")
1171-
stream = self._stub.StreamInstanceHistory(req)
1191+
stream = self._get_stub().StreamInstanceHistory(req)
11721192
return await history_helpers.collect_history_events_async(stream, self._payload_store)
11731193

11741194
async def list_instance_ids(self,
@@ -1192,7 +1212,7 @@ async def list_instance_ids(self,
11921212
f"page_size={page_size}, "
11931213
f"continuation_token={continuation_token}"
11941214
)
1195-
resp: pb.ListInstanceIdsResponse = await self._stub.ListInstanceIds(req)
1215+
resp: pb.ListInstanceIdsResponse = await self._get_stub().ListInstanceIds(req)
11961216
next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
11971217
return Page(items=list(resp.instanceIds), continuation_token=next_token)
11981218

@@ -1209,7 +1229,7 @@ async def get_all_orchestration_states(self,
12091229

12101230
while True:
12111231
req = build_query_instances_req(orchestration_query, _continuation_token)
1212-
resp: pb.QueryInstancesResponse = await self._stub.QueryInstances(req)
1232+
resp: pb.QueryInstancesResponse = await self._get_stub().QueryInstances(req)
12131233
if self._payload_store is not None:
12141234
await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
12151235
states += [parse_orchestration_state(res, self._data_converter) for res in resp.orchestrationState]
@@ -1226,7 +1246,7 @@ async def wait_for_orchestration_start(self, instance_id: str, *,
12261246
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
12271247
try:
12281248
self._logger.info(f"Waiting up to {timeout}s for instance '{instance_id}' to start.")
1229-
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceStart(
1249+
res: pb.GetInstanceResponse = await self._get_stub().WaitForInstanceStart(
12301250
req,
12311251
timeout=timeout,
12321252
)
@@ -1245,7 +1265,7 @@ async def wait_for_orchestration_completion(self, instance_id: str, *,
12451265
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
12461266
try:
12471267
self._logger.info(f"Waiting {timeout}s for instance '{instance_id}' to complete.")
1248-
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceCompletion(
1268+
res: pb.GetInstanceResponse = await self._get_stub().WaitForInstanceCompletion(
12491269
req,
12501270
timeout=timeout,
12511271
)
@@ -1269,7 +1289,7 @@ async def raise_orchestration_event(self, instance_id: str, event_name: str, *,
12691289
await payload_helpers.externalize_payloads_async(
12701290
req, self._payload_store, instance_id=instance_id,
12711291
)
1272-
await self._stub.RaiseEvent(req)
1292+
await self._get_stub().RaiseEvent(req)
12731293

12741294
async def terminate_orchestration(self, instance_id: str, *,
12751295
output: Any | None = None,
@@ -1281,17 +1301,17 @@ async def terminate_orchestration(self, instance_id: str, *,
12811301
await payload_helpers.externalize_payloads_async(
12821302
req, self._payload_store, instance_id=instance_id,
12831303
)
1284-
await self._stub.TerminateInstance(req)
1304+
await self._get_stub().TerminateInstance(req)
12851305

12861306
async def suspend_orchestration(self, instance_id: str) -> None:
12871307
req = pb.SuspendRequest(instanceId=instance_id)
12881308
self._logger.info(f"Suspending instance '{instance_id}'.")
1289-
await self._stub.SuspendInstance(req)
1309+
await self._get_stub().SuspendInstance(req)
12901310

12911311
async def resume_orchestration(self, instance_id: str) -> None:
12921312
req = pb.ResumeRequest(instanceId=instance_id)
12931313
self._logger.info(f"Resuming instance '{instance_id}'.")
1294-
await self._stub.ResumeInstance(req)
1314+
await self._get_stub().ResumeInstance(req)
12951315

12961316
async def rewind_orchestration(self, instance_id: str, *,
12971317
reason: str | None = None) -> None:
@@ -1311,7 +1331,7 @@ async def rewind_orchestration(self, instance_id: str, *,
13111331
reason=helpers.get_string_value(reason))
13121332

13131333
self._logger.info(f"Rewinding instance '{instance_id}'.")
1314-
await self._stub.RewindInstance(req)
1334+
await self._get_stub().RewindInstance(req)
13151335

13161336
async def restart_orchestration(self, instance_id: str, *,
13171337
restart_with_new_instance_id: bool = False) -> str:
@@ -1330,13 +1350,13 @@ async def restart_orchestration(self, instance_id: str, *,
13301350
restartWithNewInstanceId=restart_with_new_instance_id)
13311351

13321352
self._logger.info(f"Restarting instance '{instance_id}'.")
1333-
res: pb.RestartInstanceResponse = await self._stub.RestartInstance(req)
1353+
res: pb.RestartInstanceResponse = await self._get_stub().RestartInstance(req)
13341354
return res.instanceId
13351355

13361356
async def purge_orchestration(self, instance_id: str, recursive: bool = True) -> PurgeInstancesResult:
13371357
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive)
13381358
self._logger.info(f"Purging instance '{instance_id}'.")
1339-
resp: pb.PurgeInstancesResponse = await self._stub.PurgeInstances(req)
1359+
resp: pb.PurgeInstancesResponse = await self._get_stub().PurgeInstances(req)
13401360
return PurgeInstancesResult(resp.deletedInstanceCount, resp.isComplete.value)
13411361

13421362
async def purge_orchestrations_by(self,
@@ -1350,7 +1370,7 @@ async def purge_orchestrations_by(self,
13501370
f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
13511371
f"recursive={recursive}")
13521372
req = build_purge_by_filter_req(created_time_from, created_time_to, runtime_status, recursive)
1353-
resp: pb.PurgeInstancesResponse = await self._stub.PurgeInstances(req)
1373+
resp: pb.PurgeInstancesResponse = await self._get_stub().PurgeInstances(req)
13541374
return PurgeInstancesResult(resp.deletedInstanceCount, resp.isComplete.value)
13551375

13561376
async def signal_entity(self,
@@ -1365,15 +1385,15 @@ async def signal_entity(self,
13651385
await payload_helpers.externalize_payloads_async(
13661386
req, self._payload_store, instance_id=str(entity_instance_id),
13671387
)
1368-
await self._stub.SignalEntity(req)
1388+
await self._get_stub().SignalEntity(req)
13691389

13701390
async def get_entity(self,
13711391
entity_instance_id: EntityInstanceId,
13721392
include_state: bool = True
13731393
) -> EntityMetadata | None:
13741394
req = pb.GetEntityRequest(instanceId=str(entity_instance_id), includeState=include_state)
13751395
self._logger.info(f"Getting entity '{entity_instance_id}'.")
1376-
res: pb.GetEntityResponse = await self._stub.GetEntity(req)
1396+
res: pb.GetEntityResponse = await self._get_stub().GetEntity(req)
13771397
if not res.exists:
13781398
return None
13791399
if self._payload_store is not None:
@@ -1392,7 +1412,7 @@ async def get_all_entities(self,
13921412

13931413
while True:
13941414
query_request = build_query_entities_req(entity_query, _continuation_token)
1395-
resp: pb.QueryEntitiesResponse = await self._stub.QueryEntities(query_request)
1415+
resp: pb.QueryEntitiesResponse = await self._get_stub().QueryEntities(query_request)
13961416
if self._payload_store is not None:
13971417
await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
13981418
entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState, self._data_converter) for entity in resp.entities]
@@ -1418,7 +1438,7 @@ async def clean_entity_storage(self,
14181438
releaseOrphanedLocks=release_orphaned_locks,
14191439
continuationToken=_continuation_token
14201440
)
1421-
resp: pb.CleanEntityStorageResponse = await self._stub.CleanEntityStorage(req)
1441+
resp: pb.CleanEntityStorageResponse = await self._get_stub().CleanEntityStorage(req)
14221442
empty_entities_removed += resp.emptyEntitiesRemoved
14231443
orphaned_locks_released += resp.orphanedLocksReleased
14241444

tests/durabletask/test_client.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ def install_resilient_test_stubs(client):
223223
wrapper_cls = _ResilientAsyncTestStub if is_async else _ResilientSyncTestStub
224224

225225
def wrap_if_needed():
226+
if is_async:
227+
client._get_stub()
226228
if not isinstance(client._stub, wrapper_cls):
227229
client._stub = wrapper_cls(client._stub, client._resiliency_interceptor)
228230

@@ -490,20 +492,24 @@ def test_async_grpc_channel_protocol_stripping():
490492
# ==== Async client construction tests ====
491493

492494

493-
def test_async_client_creates_with_defaults():
495+
@pytest.mark.asyncio
496+
async def test_async_client_creates_with_defaults():
494497
with patch('grpc.aio.insecure_channel') as mock_channel:
495498
mock_channel.return_value = MagicMock()
496499
client = AsyncTaskHubGrpcClient()
500+
client._get_stub()
497501
mock_channel.assert_called_once_with(
498502
get_default_host_address(),
499503
interceptors=[client._resiliency_interceptor])
500504

501505

502-
def test_async_client_creates_with_metadata():
506+
@pytest.mark.asyncio
507+
async def test_async_client_creates_with_metadata():
503508
with patch('grpc.aio.insecure_channel') as mock_channel:
504509
mock_channel.return_value = MagicMock()
505510
client = AsyncTaskHubGrpcClient(
506511
host_address=HOST_ADDRESS, metadata=METADATA)
512+
client._get_stub()
507513
mock_channel.assert_called_once()
508514
args, kwargs = mock_channel.call_args
509515
assert args[0] == HOST_ADDRESS
@@ -515,6 +521,33 @@ def test_async_client_creates_with_metadata():
515521
assert isinstance(interceptors[1], DefaultAsyncClientInterceptorImpl)
516522

517523

524+
def test_async_client_defers_channel_until_async_use():
525+
asyncio.run(asyncio.sleep(0))
526+
channel = MagicMock()
527+
channel.close = AsyncMock()
528+
stub = MagicMock()
529+
stub.GetInstance = AsyncMock(return_value=MagicMock(exists=False))
530+
531+
with patch(
532+
'durabletask.client.shared.get_async_grpc_channel',
533+
return_value=channel,
534+
) as mock_get_channel, patch(
535+
'durabletask.client.stubs.TaskHubSidecarServiceStub',
536+
return_value=stub,
537+
):
538+
client = AsyncTaskHubGrpcClient()
539+
mock_get_channel.assert_not_called()
540+
541+
async def use_client() -> None:
542+
assert await client.get_orchestration_state("abc") is None
543+
await client.close()
544+
545+
asyncio.run(use_client())
546+
547+
mock_get_channel.assert_called_once()
548+
channel.close.assert_awaited_once()
549+
550+
518551
def test_client_uses_provided_channel_directly():
519552
provided_channel = MagicMock()
520553
with patch('durabletask.internal.shared.get_grpc_channel') as mock_get_channel:

0 commit comments

Comments
 (0)