|
| 1 | +# Copyright (c) Microsoft Corporation. |
| 2 | +# Licensed under the MIT License. |
| 3 | + |
| 4 | +import json |
| 5 | +from concurrent.futures import ThreadPoolExecutor |
| 6 | +from datetime import datetime, timezone |
| 7 | +from unittest.mock import AsyncMock, Mock, patch |
| 8 | + |
| 9 | +import grpc |
| 10 | +import pytest |
| 11 | +from google.protobuf.wrappers_pb2 import StringValue |
| 12 | + |
| 13 | +from azure.durable_functions import DurableFunctionsClient, SyncDurableFunctionsClient |
| 14 | +from durabletask.client import AsyncTaskHubGrpcClient, OrchestrationQuery, OrchestrationStatus, TaskHubGrpcClient |
| 15 | +from durabletask.internal import orchestrator_service_pb2 as pb |
| 16 | +from durabletask.internal import orchestrator_service_pb2_grpc as rpc |
| 17 | + |
| 18 | + |
| 19 | +def _config(address: str) -> str: |
| 20 | + return json.dumps({ |
| 21 | + "rpcBaseUrl": address, |
| 22 | + "taskHubName": "TestHub", |
| 23 | + "connectionName": "TestStorage", |
| 24 | + "requiredQueryStringParameters": "code=test-key", |
| 25 | + "maxGrpcMessageSizeInBytes": 8388608, |
| 26 | + }) |
| 27 | + |
| 28 | + |
| 29 | +@pytest.mark.parametrize("use_async", [False, True]) |
| 30 | +@pytest.mark.parametrize("address, expected", [ |
| 31 | + ("http://127.0.0.1:8080/durabletask/", "http://127.0.0.1:8080"), |
| 32 | + ("https://host:443/durabletask/?code=key#fragment", "https://host:443"), |
| 33 | + ("http://[::1]:8080/durabletask/", "http://[::1]:8080"), |
| 34 | + ("HTTP://localhost:8080/", "http://localhost:8080"), |
| 35 | + ("localhost:8080", "localhost:8080"), |
| 36 | + ("dns:///localhost:8080", "dns:///localhost:8080"), |
| 37 | + ("unix:///tmp/durabletask.sock", "unix:///tmp/durabletask.sock"), |
| 38 | + ("grpc://localhost:8080", "grpc://localhost:8080"), |
| 39 | + ("", ""), |
| 40 | +]) |
| 41 | +def test_functions_client_normalizes_only_http_urls(use_async, address, expected): |
| 42 | + base = AsyncTaskHubGrpcClient if use_async else TaskHubGrpcClient |
| 43 | + client_type = DurableFunctionsClient if use_async else SyncDurableFunctionsClient |
| 44 | + with patch.object(base, "__init__", return_value=None) as init: |
| 45 | + client = client_type(_config(address)) |
| 46 | + |
| 47 | + assert client.rpcBaseUrl == address |
| 48 | + assert client.connectionName == "TestStorage" |
| 49 | + kwargs = init.call_args.kwargs |
| 50 | + assert kwargs["host_address"] == expected |
| 51 | + assert kwargs["metadata"] is None |
| 52 | + assert kwargs["channel_options"].max_receive_message_length == 8388608 |
| 53 | + assert kwargs["channel_options"].max_send_message_length == 8388608 |
| 54 | + interceptor, = kwargs["interceptors"] |
| 55 | + assert ("taskhub", "TestHub") in interceptor._metadata |
| 56 | + assert interceptor.required_query_string_parameters == "code=test-key" |
| 57 | + |
| 58 | + |
| 59 | +@pytest.fixture |
| 60 | +def local_grpc_server(): |
| 61 | + metadata = [] |
| 62 | + |
| 63 | + class Service(rpc.TaskHubSidecarServiceServicer): |
| 64 | + def GetInstance(self, request, context): |
| 65 | + metadata.append(dict(context.invocation_metadata())) |
| 66 | + return pb.GetInstanceResponse(exists=False) |
| 67 | + |
| 68 | + with ThreadPoolExecutor(max_workers=1) as executor: |
| 69 | + server = grpc.server(executor) |
| 70 | + rpc.add_TaskHubSidecarServiceServicer_to_server(Service(), server) |
| 71 | + port = server.add_insecure_port("127.0.0.1:0") |
| 72 | + server.start() |
| 73 | + try: |
| 74 | + yield f"http://127.0.0.1:{port}/durabletask/", metadata |
| 75 | + finally: |
| 76 | + server.stop(0).wait() |
| 77 | + |
| 78 | + |
| 79 | +@pytest.mark.asyncio |
| 80 | +@pytest.mark.parametrize("use_async", [False, True]) |
| 81 | +async def test_functions_client_reaches_grpc_server_with_url_path(local_grpc_server, use_async): |
| 82 | + address, metadata = local_grpc_server |
| 83 | + if use_async: |
| 84 | + client = DurableFunctionsClient(_config(address)) |
| 85 | + try: |
| 86 | + response = await client._get_stub().GetInstance( |
| 87 | + pb.GetInstanceRequest(instanceId="instance"), timeout=2) |
| 88 | + finally: |
| 89 | + await client.close() |
| 90 | + else: |
| 91 | + client = SyncDurableFunctionsClient(_config(address)) |
| 92 | + try: |
| 93 | + response = client._stub.GetInstance( |
| 94 | + pb.GetInstanceRequest(instanceId="instance"), timeout=2) |
| 95 | + finally: |
| 96 | + client.close() |
| 97 | + assert not response.exists |
| 98 | + assert metadata[0]["taskhub"] == "TestHub" |
| 99 | + assert metadata[0]["x-user-agent"].startswith("durabletask-python/") |
| 100 | + |
| 101 | + |
| 102 | +@pytest.mark.asyncio |
| 103 | +@pytest.mark.parametrize("use_async", [False, True]) |
| 104 | +@pytest.mark.parametrize("page_size, expected", [ |
| 105 | + ((1 << 31) - 1, 1000), (None, 1000), (1001, 1000), |
| 106 | + (1000, 1000), (25, 25), (0, 0), |
| 107 | +]) |
| 108 | +async def test_functions_query_limits_page_size_without_changing_query(use_async, page_size, expected): |
| 109 | + query = OrchestrationQuery( |
| 110 | + created_time_from=datetime(2026, 1, 1, tzinfo=timezone.utc), |
| 111 | + created_time_to=datetime(2026, 2, 1, tzinfo=timezone.utc), |
| 112 | + runtime_status=[OrchestrationStatus.RUNNING], |
| 113 | + max_instance_count=page_size, |
| 114 | + fetch_inputs_and_outputs=True, |
| 115 | + instance_id_prefix="prefix", |
| 116 | + task_hub_names=["TestHub"], |
| 117 | + ) |
| 118 | + stub = Mock() |
| 119 | + client = (DurableFunctionsClient(_config("localhost:8080")) if use_async |
| 120 | + else SyncDurableFunctionsClient(_config("localhost:8080"))) |
| 121 | + response = pb.QueryInstancesResponse() |
| 122 | + try: |
| 123 | + if use_async: |
| 124 | + stub.QueryInstances = AsyncMock(return_value=response) |
| 125 | + with patch.object(client, "_get_stub", return_value=stub): |
| 126 | + assert await client.get_all_orchestration_states(query) == [] |
| 127 | + else: |
| 128 | + stub.QueryInstances.return_value = response |
| 129 | + with patch.object(client, "_stub", stub): |
| 130 | + assert client.get_all_orchestration_states(query) == [] |
| 131 | + request = stub.QueryInstances.call_args.args[0].query |
| 132 | + assert request.maxInstanceCount == expected |
| 133 | + assert request.fetchInputsAndOutputs |
| 134 | + assert request.instanceIdPrefix.value == "prefix" |
| 135 | + assert list(request.runtimeStatus) == [pb.ORCHESTRATION_STATUS_RUNNING] |
| 136 | + assert [hub.value for hub in request.taskHubNames] == ["TestHub"] |
| 137 | + assert request.createdTimeFrom.ToDatetime(timezone.utc) == query.created_time_from |
| 138 | + assert request.createdTimeTo.ToDatetime(timezone.utc) == query.created_time_to |
| 139 | + assert query.max_instance_count == page_size |
| 140 | + finally: |
| 141 | + if use_async: |
| 142 | + await client.close() |
| 143 | + else: |
| 144 | + client.close() |
| 145 | + |
| 146 | + |
| 147 | +@pytest.mark.asyncio |
| 148 | +@pytest.mark.parametrize("method, page_size", [ |
| 149 | + ("async", None), ("sync", None), ("get_status_all", None), |
| 150 | + ("async", 25), ("sync", 25), |
| 151 | +]) |
| 152 | +async def test_functions_query_reads_all_pages_including_empty_pages(method, page_size): |
| 153 | + first_page_count = page_size or 1000 |
| 154 | + query = OrchestrationQuery(max_instance_count=page_size) if page_size else None |
| 155 | + responses = iter([ |
| 156 | + pb.QueryInstancesResponse( |
| 157 | + orchestrationState=[ |
| 158 | + pb.OrchestrationState(instanceId=str(i), name="test", |
| 159 | + orchestrationStatus=pb.ORCHESTRATION_STATUS_RUNNING) |
| 160 | + for i in range(first_page_count) |
| 161 | + ], |
| 162 | + continuationToken=StringValue(value="next")), |
| 163 | + pb.QueryInstancesResponse(continuationToken=StringValue(value="after-empty")), |
| 164 | + pb.QueryInstancesResponse( |
| 165 | + orchestrationState=[ |
| 166 | + pb.OrchestrationState(instanceId=str(i), name="test", |
| 167 | + orchestrationStatus=pb.ORCHESTRATION_STATUS_RUNNING) |
| 168 | + for i in range(first_page_count, first_page_count + 2) |
| 169 | + ]), |
| 170 | + ]) |
| 171 | + tokens = [] |
| 172 | + |
| 173 | + def query_instances(request): |
| 174 | + assert request.query.maxInstanceCount == first_page_count |
| 175 | + tokens.append(request.query.continuationToken.value) |
| 176 | + assert request.query.fetchInputsAndOutputs == (method == "get_status_all") |
| 177 | + return next(responses) |
| 178 | + |
| 179 | + stub = Mock() |
| 180 | + if method == "sync": |
| 181 | + client = SyncDurableFunctionsClient(_config("localhost:8080")) |
| 182 | + stub.QueryInstances.side_effect = query_instances |
| 183 | + try: |
| 184 | + with patch.object(client, "_stub", stub): |
| 185 | + states = client.get_all_orchestration_states(query) |
| 186 | + finally: |
| 187 | + client.close() |
| 188 | + else: |
| 189 | + client = DurableFunctionsClient(_config("localhost:8080")) |
| 190 | + stub.QueryInstances = AsyncMock(side_effect=query_instances) |
| 191 | + try: |
| 192 | + with patch.object(client, "_get_stub", return_value=stub): |
| 193 | + if method == "get_status_all": |
| 194 | + with pytest.warns(DeprecationWarning): |
| 195 | + states = await client.get_status_all() |
| 196 | + else: |
| 197 | + states = await client.get_all_orchestration_states(query) |
| 198 | + finally: |
| 199 | + await client.close() |
| 200 | + |
| 201 | + assert [state.instance_id for state in states] == [str(i) for i in range(first_page_count + 2)] |
| 202 | + assert tokens == ["", "next", "after-empty"] |
0 commit comments