|
13 | 13 | import base64 |
14 | 14 | import json |
15 | 15 | from concurrent.futures import ThreadPoolExecutor |
| 16 | +from datetime import datetime |
16 | 17 | from types import SimpleNamespace |
17 | 18 |
|
18 | 19 | import pytest |
19 | 20 |
|
20 | 21 | import durabletask.internal.helpers as helpers |
21 | 22 | import durabletask.internal.orchestrator_service_pb2 as pb |
22 | 23 |
|
| 24 | +import azure.durable_functions as df |
23 | 25 | from azure.durable_functions.worker import DurableFunctionsWorker |
24 | 26 |
|
25 | 27 | TEST_INSTANCE_ID = "inst-123" |
@@ -132,6 +134,86 @@ def orchestrator(context): |
132 | 134 | assert "boom" in completion.failureDetails.errorMessage |
133 | 135 |
|
134 | 136 |
|
| 137 | +def test_activity_retry_then_fan_out_uses_distinct_task_ids(): |
| 138 | + """Regression test for Azure/azure-functions-durable-python#603.""" |
| 139 | + def orchestrator(context): |
| 140 | + options = df.RetryOptions( |
| 141 | + first_retry_interval_in_milliseconds=100, |
| 142 | + max_number_of_attempts=3, |
| 143 | + ) |
| 144 | + yield context.call_activity_with_retry("flaky", options) |
| 145 | + tasks = [context.call_activity("square", value) for value in range(13)] |
| 146 | + return (yield context.task_all(tasks)) |
| 147 | + |
| 148 | + worker = DurableFunctionsWorker() |
| 149 | + name = "retry-then-fan-out" |
| 150 | + started_at = datetime(2026, 1, 1) |
| 151 | + |
| 152 | + def execute(past_events, new_events): |
| 153 | + request = pb.OrchestratorRequest(instanceId=TEST_INSTANCE_ID) |
| 154 | + request.pastEvents.extend(past_events) |
| 155 | + request.newEvents.extend(new_events) |
| 156 | + encoded = base64.b64encode(request.SerializeToString()).decode("utf-8") |
| 157 | + return _decode_orchestrator_response( |
| 158 | + worker.execute_orchestration_request(orchestrator, encoded)) |
| 159 | + |
| 160 | + initial_events = [ |
| 161 | + helpers.new_orchestrator_started_event(started_at), |
| 162 | + helpers.new_execution_started_event(name, TEST_INSTANCE_ID), |
| 163 | + ] |
| 164 | + response = execute([], initial_events) |
| 165 | + assert len(response.actions) == 1 |
| 166 | + assert response.actions[0].id == 1 |
| 167 | + assert response.actions[0].scheduleTask.name == "flaky" |
| 168 | + |
| 169 | + past_events = initial_events + [ |
| 170 | + helpers.new_task_scheduled_event(1, "flaky"), |
| 171 | + ] |
| 172 | + failure_events = [ |
| 173 | + helpers.new_orchestrator_started_event(started_at), |
| 174 | + helpers.new_task_failed_event(1, ValueError("transient failure")), |
| 175 | + ] |
| 176 | + response = execute(past_events, failure_events) |
| 177 | + assert len(response.actions) == 1 |
| 178 | + retry_timer = response.actions[0] |
| 179 | + assert retry_timer.id == 2 |
| 180 | + assert retry_timer.HasField("createTimer") |
| 181 | + |
| 182 | + retry_at = retry_timer.createTimer.fireAt.ToDatetime() |
| 183 | + timer_events = [ |
| 184 | + helpers.new_timer_created_event(2, retry_at), |
| 185 | + helpers.new_orchestrator_started_event(retry_at), |
| 186 | + helpers.new_timer_fired_event(2, retry_at), |
| 187 | + ] |
| 188 | + past_events += failure_events |
| 189 | + response = execute(past_events, timer_events) |
| 190 | + assert len(response.actions) == 1 |
| 191 | + assert response.actions[0].id == 1 |
| 192 | + assert response.actions[0].scheduleTask.name == "flaky" |
| 193 | + |
| 194 | + retry_completed_events = [ |
| 195 | + helpers.new_orchestrator_started_event(retry_at), |
| 196 | + helpers.new_task_scheduled_event(1, "flaky"), |
| 197 | + helpers.new_task_completed_event(1, json.dumps("recovered")), |
| 198 | + ] |
| 199 | + past_events += timer_events |
| 200 | + response = execute(past_events, retry_completed_events) |
| 201 | + assert [action.id for action in response.actions] == list(range(3, 16)) |
| 202 | + assert all(action.scheduleTask.name == "square" for action in response.actions) |
| 203 | + |
| 204 | + fan_out_events = [helpers.new_orchestrator_started_event(retry_at)] |
| 205 | + for task_id in range(3, 16): |
| 206 | + fan_out_events.append(helpers.new_task_scheduled_event(task_id, "square")) |
| 207 | + fan_out_events.append( |
| 208 | + helpers.new_task_completed_event(task_id, json.dumps(task_id - 3))) |
| 209 | + |
| 210 | + past_events += retry_completed_events |
| 211 | + response = execute(past_events, fan_out_events) |
| 212 | + completion = _get_completion_action(response) |
| 213 | + assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED |
| 214 | + assert json.loads(completion.result.value) == list(range(13)) |
| 215 | + |
| 216 | + |
135 | 217 | def test_execute_orchestration_request_supports_concurrent_reinvocation(): |
136 | 218 | def orchestrator(context): |
137 | 219 | return context.instance_id |
|
0 commit comments