|
2 | 2 | # Licensed under the MIT License. |
3 | 3 |
|
4 | 4 | import asyncio |
| 5 | +import atexit |
5 | 6 | import json |
| 7 | +import threading |
6 | 8 |
|
7 | 9 | from datetime import datetime, timedelta |
8 | 10 | from typing import Any, Optional, Union |
|
14 | 16 | AsyncTaskHubGrpcClient, |
15 | 17 | OrchestrationQuery, |
16 | 18 | OrchestrationStatus, |
| 19 | + TaskHubGrpcClient, |
17 | 20 | ) |
18 | 21 | from durabletask.entities import EntityInstanceId |
19 | 22 | from durabletask.grpc_options import GrpcChannelOptions |
20 | | -from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl |
| 23 | +from .internal.azurefunctions_grpc_interceptor import ( |
| 24 | + AzureFunctionsAsyncDefaultClientInterceptorImpl, |
| 25 | + AzureFunctionsDefaultClientInterceptorImpl, |
| 26 | +) |
21 | 27 | from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER |
22 | 28 | from .http import HttpManagementPayload |
23 | 29 | from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus |
|
26 | 32 | from .internal.compat.purge_history_result import PurgeHistoryResult |
27 | 33 |
|
28 | 34 |
|
| 35 | +_sync_client_cache: dict[str, "SyncDurableFunctionsClient"] = {} |
| 36 | +_sync_client_cache_lock = threading.Lock() |
| 37 | + |
| 38 | + |
29 | 39 | # Client class used for Durable Functions |
30 | 40 | class DurableFunctionsClient(AsyncTaskHubGrpcClient): |
31 | 41 | """A gRPC client passed to Durable Functions durable client bindings. |
@@ -490,3 +500,121 @@ def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpR |
490 | 500 | body=body_as_json, |
491 | 501 | mimetype="application/json", |
492 | 502 | headers={"Content-Type": "application/json"}) |
| 503 | + |
| 504 | + |
| 505 | +class SyncDurableFunctionsClient(TaskHubGrpcClient): |
| 506 | + """Synchronous durable client supplied by a Functions durable-client binding.""" |
| 507 | + |
| 508 | + taskHubName: str |
| 509 | + connectionName: str |
| 510 | + creationUrls: dict[str, str] |
| 511 | + managementUrls: dict[str, str] |
| 512 | + baseUrl: str |
| 513 | + requiredQueryStringParameters: str |
| 514 | + rpcBaseUrl: str |
| 515 | + httpBaseUrl: str |
| 516 | + maxGrpcMessageSizeInBytes: int |
| 517 | + grpcHttpClientTimeout: timedelta | str |
| 518 | + |
| 519 | + def __init__(self, client_as_string: str): |
| 520 | + self._parse_client_configuration(client_as_string) |
| 521 | + interceptors = [AzureFunctionsDefaultClientInterceptorImpl( |
| 522 | + self.taskHubName, self.requiredQueryStringParameters)] |
| 523 | + channel_options: GrpcChannelOptions | None = None |
| 524 | + if self.maxGrpcMessageSizeInBytes > 0: |
| 525 | + channel_options = GrpcChannelOptions( |
| 526 | + max_receive_message_length=self.maxGrpcMessageSizeInBytes, |
| 527 | + max_send_message_length=self.maxGrpcMessageSizeInBytes) |
| 528 | + super().__init__( |
| 529 | + host_address=self.rpcBaseUrl, |
| 530 | + secure_channel=False, |
| 531 | + metadata=None, |
| 532 | + interceptors=interceptors, |
| 533 | + channel_options=channel_options, |
| 534 | + data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) |
| 535 | + |
| 536 | + @classmethod |
| 537 | + def get_cached(cls, client_as_string: str) -> "SyncDurableFunctionsClient": |
| 538 | + """Get the process-wide client for a durable-client binding configuration. |
| 539 | +
|
| 540 | + Synchronous Functions bindings can be invoked frequently by history |
| 541 | + export fan-out activities. Reusing the gRPC channel avoids creating and |
| 542 | + tearing down a channel for every activity invocation. |
| 543 | + """ |
| 544 | + with _sync_client_cache_lock: |
| 545 | + cached = _sync_client_cache.get(client_as_string) |
| 546 | + if cached is None: |
| 547 | + cached = cls(client_as_string) |
| 548 | + _sync_client_cache[client_as_string] = cached |
| 549 | + return cached |
| 550 | + |
| 551 | + def _parse_client_configuration(self, client_as_string: str) -> None: |
| 552 | + client = json.loads(client_as_string) |
| 553 | + self.taskHubName = client.get("taskHubName") or "" |
| 554 | + self.connectionName = client.get("connectionName") or "" |
| 555 | + self.creationUrls = client.get("creationUrls") or {} |
| 556 | + self.managementUrls = client.get("managementUrls") or {} |
| 557 | + self.baseUrl = client.get("baseUrl") or "" |
| 558 | + self.requiredQueryStringParameters = client.get( |
| 559 | + "requiredQueryStringParameters") or "" |
| 560 | + self.rpcBaseUrl = client.get("rpcBaseUrl") or "" |
| 561 | + self.httpBaseUrl = client.get("httpBaseUrl") or "" |
| 562 | + self.maxGrpcMessageSizeInBytes = client.get( |
| 563 | + "maxGrpcMessageSizeInBytes") or 0 |
| 564 | + self.grpcHttpClientTimeout = client.get( |
| 565 | + "grpcHttpClientTimeout") or timedelta(seconds=30) |
| 566 | + |
| 567 | + def create_check_status_response( |
| 568 | + self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: |
| 569 | + payload = self._get_client_response_links(request, instance_id) |
| 570 | + return func.HttpResponse( |
| 571 | + body=str(payload), |
| 572 | + status_code=202, |
| 573 | + headers={ |
| 574 | + "content-type": "application/json", |
| 575 | + "Location": payload["statusQueryGetUri"], |
| 576 | + }, |
| 577 | + ) |
| 578 | + |
| 579 | + def create_http_management_payload( |
| 580 | + self, |
| 581 | + request: func.HttpRequest | str | None = None, |
| 582 | + instance_id: str | None = None) -> HttpManagementPayload: |
| 583 | + if instance_id is None and isinstance(request, str): |
| 584 | + instance_id = request |
| 585 | + request = None |
| 586 | + if instance_id is None: |
| 587 | + raise TypeError("instance_id is required") |
| 588 | + resolved_request = request if isinstance(request, func.HttpRequest) else None |
| 589 | + return self._get_client_response_links(resolved_request, instance_id) |
| 590 | + |
| 591 | + def _get_client_response_links( |
| 592 | + self, request: func.HttpRequest | None, |
| 593 | + instance_id: str) -> HttpManagementPayload: |
| 594 | + return HttpManagementPayload( |
| 595 | + instance_id, |
| 596 | + self._get_instance_status_url(request, instance_id), |
| 597 | + self.requiredQueryStringParameters) |
| 598 | + |
| 599 | + def _get_instance_status_url( |
| 600 | + self, request: func.HttpRequest | None, instance_id: str) -> str: |
| 601 | + encoded_instance_id = quote(instance_id) |
| 602 | + if request is not None: |
| 603 | + request_url = urlparse(request.url) |
| 604 | + return ( |
| 605 | + f"{request_url.scheme}://{request_url.netloc}" |
| 606 | + f"/runtime/webhooks/durabletask/instances/{encoded_instance_id}") |
| 607 | + base_url = self.baseUrl.rstrip("/") if self.baseUrl else "" |
| 608 | + return f"{base_url}/instances/{encoded_instance_id}" |
| 609 | + |
| 610 | + |
| 611 | +def _close_cached_sync_clients() -> None: |
| 612 | + """Release process-wide synchronous durable-client channels on shutdown.""" |
| 613 | + with _sync_client_cache_lock: |
| 614 | + clients = list(_sync_client_cache.values()) |
| 615 | + _sync_client_cache.clear() |
| 616 | + for client in clients: |
| 617 | + client.close() |
| 618 | + |
| 619 | + |
| 620 | +atexit.register(_close_cached_sync_clients) |
0 commit comments