|
9 | 9 | import os |
10 | 10 | import subprocess |
11 | 11 | import sys |
| 12 | +from collections.abc import AsyncIterator |
12 | 13 | from contextlib import asynccontextmanager |
13 | 14 | from contextvars import ContextVar |
14 | 15 | from dataclasses import dataclass |
|
20 | 21 |
|
21 | 22 | import pytest |
22 | 23 |
|
23 | | -from durabletask import task |
| 24 | +from durabletask import entities, task |
24 | 25 | from durabletask.internal import helpers |
25 | 26 | from durabletask.internal import orchestrator_service_pb2 as pb |
26 | 27 | from tests.durabletask.test_coroutine_orchestrators import ( |
@@ -1057,3 +1058,132 @@ async def branch(name: str, error: BaseException | None) -> None: |
1057 | 1058 | _execute(orchestrator, [], _start()) |
1058 | 1059 | assert caught.value is exit_error |
1059 | 1060 | assert cleaned == ["custom", "exit", "last"] |
| 1061 | + |
| 1062 | + |
| 1063 | +def _cleanup_signal(kind: str) -> BaseException: |
| 1064 | + if kind == "system_exit": |
| 1065 | + return SystemExit("stop") |
| 1066 | + if kind == "keyboard_interrupt": |
| 1067 | + return KeyboardInterrupt("interrupt") |
| 1068 | + if kind == "nested_group": |
| 1069 | + return BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [SystemExit("stop")])]) |
| 1070 | + if kind == "generator_exit": |
| 1071 | + return GeneratorExit("closed") |
| 1072 | + return BaseExceptionGroup("cancelled", [asyncio.CancelledError()]) |
| 1073 | + |
| 1074 | + |
| 1075 | +@pytest.mark.parametrize("control_first", [True, False]) |
| 1076 | +@pytest.mark.parametrize("other", ["generator_exit", "cancelled_group"]) |
| 1077 | +@pytest.mark.parametrize("control", ["system_exit", "keyboard_interrupt", "nested_group"]) |
| 1078 | +def test_cleanup_keeps_process_control_over_other_control_flow( |
| 1079 | + control: str, other: str, control_first: bool) -> None: |
| 1080 | + control_error = _cleanup_signal(control) |
| 1081 | + errors = [control_error, _cleanup_signal(other)] |
| 1082 | + if not control_first: |
| 1083 | + errors.reverse() |
| 1084 | + cleaned: list[int] = [] |
| 1085 | + |
| 1086 | + async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None: |
| 1087 | + async def branch(index: int) -> None: |
| 1088 | + try: |
| 1089 | + await ctx.wait_for_external_event(f"never-{index}") |
| 1090 | + finally: |
| 1091 | + cleaned.append(index) |
| 1092 | + raise errors[index] |
| 1093 | + |
| 1094 | + asyncio.create_task(branch(0)) |
| 1095 | + asyncio.create_task(branch(1)) |
| 1096 | + await ctx.call_activity("wait") |
| 1097 | + |
| 1098 | + with pytest.raises(type(control_error)) as caught: |
| 1099 | + _execute(orchestrator, [], _start()) |
| 1100 | + assert caught.value is control_error |
| 1101 | + assert cleaned == [0, 1] |
| 1102 | + |
| 1103 | + |
| 1104 | +def test_cleanup_closes_async_generators_started_during_generator_cleanup( |
| 1105 | + caplog: pytest.LogCaptureFixture) -> None: |
| 1106 | + closed: list[str] = [] |
| 1107 | + |
| 1108 | + async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None: |
| 1109 | + async def child() -> AsyncIterator[int]: |
| 1110 | + try: |
| 1111 | + yield 1 |
| 1112 | + finally: |
| 1113 | + await asyncio.sleep(0) |
| 1114 | + closed.append("child") |
| 1115 | + |
| 1116 | + async def outer() -> AsyncIterator[int]: |
| 1117 | + try: |
| 1118 | + yield 1 |
| 1119 | + finally: |
| 1120 | + await anext(child()) |
| 1121 | + closed.append("outer") |
| 1122 | + |
| 1123 | + await anext(outer()) |
| 1124 | + await ctx.call_activity("wait") |
| 1125 | + |
| 1126 | + actions = _execute(orchestrator, [], _start()).actions |
| 1127 | + assert [action.scheduleTask.name for action in actions] == ["wait"] |
| 1128 | + assert closed == ["outer", "child"] |
| 1129 | + assert "Async" not in caplog.text |
| 1130 | + |
| 1131 | + |
| 1132 | +def test_exhausted_async_generators_are_not_retained_until_dispatch_end() -> None: |
| 1133 | + references: list[ReferenceType[Any]] = [] |
| 1134 | + |
| 1135 | + async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> None: |
| 1136 | + async def values() -> AsyncIterator[int]: |
| 1137 | + yield 1 |
| 1138 | + |
| 1139 | + for _ in range(200): |
| 1140 | + generator = values() |
| 1141 | + references.append(ref(generator)) |
| 1142 | + async for _ in generator: |
| 1143 | + pass |
| 1144 | + del generator |
| 1145 | + gc.collect() |
| 1146 | + assert sum(reference() is not None for reference in references) < 100 |
| 1147 | + await ctx.call_activity("wait") |
| 1148 | + |
| 1149 | + _execute(orchestrator, [], _start()) |
| 1150 | + |
| 1151 | + |
| 1152 | +@pytest.mark.parametrize("suspend", ["activity", "entity"]) |
| 1153 | +def test_entity_lock_disposal_during_teardown_does_not_schedule_releases( |
| 1154 | + suspend: str, caplog: pytest.LogCaptureFixture) -> None: |
| 1155 | + first = entities.EntityInstanceId("counter", "a") |
| 1156 | + second = entities.EntityInstanceId("counter", "b") |
| 1157 | + |
| 1158 | + async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Any: |
| 1159 | + with await ctx.lock_entities([first, second]): |
| 1160 | + if suspend == "activity": |
| 1161 | + return await ctx.call_activity("inside-lock") |
| 1162 | + return await ctx.call_entity(first, "get") |
| 1163 | + |
| 1164 | + old = _start() |
| 1165 | + lock = _execute(orchestrator, [], old).actions[0].sendEntityMessage.entityLockRequested |
| 1166 | + old += [helpers.new_event_sent_event(1, str(first), json.dumps({"id": lock.criticalSectionId}))] |
| 1167 | + new = [helpers.new_event_raised_event(lock.criticalSectionId, None)] |
| 1168 | + actions = _execute(orchestrator, old, new).actions |
| 1169 | + assert len(actions) == 1 |
| 1170 | + if suspend == "activity": |
| 1171 | + assert actions[0].scheduleTask.name == "inside-lock" |
| 1172 | + old += new + [helpers.new_task_scheduled_event(2, "inside-lock")] |
| 1173 | + new = [helpers.new_task_completed_event(2, json.dumps(42))] |
| 1174 | + else: |
| 1175 | + call = actions[0].sendEntityMessage.entityOperationCalled |
| 1176 | + assert call.targetInstanceId.value == str(first) |
| 1177 | + old += new + [helpers.new_event_sent_event(2, str(first), json.dumps({"id": call.requestId}))] |
| 1178 | + new = [helpers.new_event_raised_event(call.requestId, json.dumps({"result": "42"}))] |
| 1179 | + assert "cleanup" not in caplog.text |
| 1180 | + |
| 1181 | + result = _execute(orchestrator, old, new) |
| 1182 | + actions = result.actions |
| 1183 | + releases = [action.sendEntityMessage.entityUnlockSent for action in actions[:-1]] |
| 1184 | + # Entity calls inside a critical section keep the legacy shared-list release order. |
| 1185 | + expected = [first, second] if suspend == "activity" else [second, first] |
| 1186 | + assert [release.targetInstanceId.value for release in releases] == [str(e) for e in expected] |
| 1187 | + assert [action.id for action in actions] == [3, 4, 5] |
| 1188 | + assert json.loads(_completion(result).result.value) == 42 |
| 1189 | + assert "cleanup" not in caplog.text |
0 commit comments