Skip to content

Commit e73a705

Browse files
andystaplesCopilot
andcommitted
Fix async callable wrapper traversal
Track identities across wrapped, partial, and callable nodes; recognize intermediate coroutine functions before following their wrapped targets. Cover bounded worker failures and nested decorated dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 7d5f31d commit e73a705

3 files changed

Lines changed: 132 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

1010
FIXED
1111

12+
- Async orchestrators wrapped with `functools.wraps` are now recognized even
13+
when an intermediate async wrapper decorates a synchronous function, including
14+
callable instances and partials. Cyclic wrapper chains fail instead of hanging
15+
worker dispatch.
1216
- Activity and sub-orchestration `retry_timeout` decisions now use replay-safe
1317
orchestration time, preventing retries from expiring solely because history is
1418
replayed later. Retry calculations also accept timezone-aware timestamps.

‎durabletask/internal/shared.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,27 @@ def from_json(json_str: str | bytes | bytearray, expected_type: type | None = No
7676

7777
def is_async_callable(fn: Any) -> bool:
7878
"""Recognize async functions, wrapped functions, partials and callable objects."""
79+
# Keep nodes alive so temporary bound-method IDs cannot be reused.
80+
seen: dict[int, Any] = {}
7981
while True:
82+
identity = id(fn)
83+
if identity in seen:
84+
raise ValueError("wrapper loop when classifying async callable")
85+
seen[identity] = fn
8086
if inspect.iscoroutinefunction(fn):
8187
return True
82-
fn = inspect.unwrap(fn)
88+
if hasattr(fn, "__wrapped__"):
89+
fn = fn.__wrapped__
90+
continue
8391
if isinstance(fn, partial):
8492
fn = fn.func
8593
continue
86-
if inspect.iscoroutinefunction(fn):
87-
return True
88-
if inspect.isfunction(fn) or inspect.ismethod(fn) or inspect.isbuiltin(fn):
94+
if (inspect.isfunction(fn) or inspect.ismethod(fn)
95+
or inspect.isbuiltin(fn) or inspect.ismethodwrapper(fn)):
96+
return False
97+
fn = getattr(fn, "__call__", None)
98+
if fn is None:
8999
return False
90-
call = getattr(fn, "__call__", None)
91-
return call is not None and inspect.iscoroutinefunction(inspect.unwrap(call))
92100

93101

94102
def _get_legacy_logging_warning_stacklevel() -> int:

‎tests/durabletask/test_coroutine_orchestrators.py‎

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
import inspect
88
import json
99
import logging
10+
import subprocess
11+
import sys
1012
from collections.abc import AsyncGenerator, Generator
1113
from datetime import datetime, timedelta
12-
from functools import partial, wraps
14+
from functools import partial, update_wrapper, wraps
1315
from typing import Any
1416

1517
import pytest
1618

1719
from durabletask import entities, task, worker
18-
from durabletask.internal import helpers
20+
from durabletask.internal import helpers, shared
1921
from durabletask.internal import orchestrator_service_pb2 as pb
2022
from durabletask.serialization import JsonDataConverter
2123

@@ -92,6 +94,75 @@ async def __call__(
9294
assert json.loads(complete.result.value) == 4
9395

9496

97+
@pytest.mark.parametrize("shape", [
98+
"function", "partial", "bound-method", "callable", "partial-callable",
99+
"async-callable", "nested-callable",
100+
])
101+
def test_async_orchestrator_is_recognized_before_its_synchronous_wrapped_function(shape: str) -> None:
102+
def base(ctx: task.OrchestrationContext, value: int) -> int:
103+
return value + 1
104+
105+
@wraps(base)
106+
async def asynchronous(ctx: task.OrchestrationContext, value: int) -> int:
107+
return base(ctx, value)
108+
109+
@wraps(asynchronous)
110+
def traced(ctx: task.OrchestrationContext, value: int) -> Any:
111+
return asynchronous(ctx, value)
112+
113+
class DecoratedCallable:
114+
def base(self, ctx: task.OrchestrationContext, value: int) -> int:
115+
return value + 1
116+
117+
async def asynchronous(self, ctx: task.OrchestrationContext, value: int) -> int:
118+
return self.base(ctx, value)
119+
120+
def __call__(self, ctx: task.OrchestrationContext, value: int) -> Any:
121+
return self.asynchronous(ctx, value)
122+
123+
update_wrapper(DecoratedCallable.asynchronous, DecoratedCallable.base)
124+
update_wrapper(DecoratedCallable.__call__, DecoratedCallable.asynchronous)
125+
126+
class AsyncCallable(DecoratedCallable):
127+
async def __call__(self, ctx: task.OrchestrationContext, value: int) -> int:
128+
return self.base(ctx, value)
129+
130+
update_wrapper(AsyncCallable.__call__, AsyncCallable.base)
131+
132+
def wrap_callable(target: task.Orchestrator[Any, Any]) -> task.Orchestrator[Any, Any]:
133+
class Wrapper:
134+
def __call__(self, ctx: task.OrchestrationContext, value: int) -> Any:
135+
return target(ctx, value)
136+
137+
update_wrapper(Wrapper.__call__, target)
138+
return Wrapper()
139+
140+
decorated = DecoratedCallable()
141+
nested: task.Orchestrator[Any, Any] = traced
142+
# Each __call__ lookup produces a temporary bound method.
143+
for _ in range(32):
144+
nested = wrap_callable(nested)
145+
variants = {
146+
"function": traced,
147+
"partial": partial(traced),
148+
"bound-method": decorated.__call__,
149+
"callable": decorated,
150+
"partial-callable": partial(decorated),
151+
"async-callable": AsyncCallable(),
152+
"nested-callable": nested,
153+
}
154+
fn = variants[shape]
155+
assert shared.is_async_callable(fn)
156+
complete = _completion(_execute(fn, [], _start()))
157+
assert complete.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
158+
assert json.loads(complete.result.value) == 4
159+
160+
161+
@pytest.mark.parametrize("fn", [int, len, list[int].append, object().__str__])
162+
def test_synchronous_builtin_callables_are_not_classified_as_async(fn: Any) -> None:
163+
assert not shared.is_async_callable(fn)
164+
165+
95166
def test_unmarked_synchronous_wrapper_returning_coroutine_is_rejected_and_closed() -> None:
96167
created: list[Any] = []
97168

@@ -150,6 +221,47 @@ def wrapper(ctx: task.OrchestrationContext, value: int) -> int:
150221
assert "wrapper loop" in complete.failureDetails.errorMessage
151222

152223

224+
def _assert_mixed_wrapper_cycle_fails(shape: str) -> None:
225+
def wrapper(ctx: task.OrchestrationContext, value: int) -> int:
226+
return value
227+
228+
if shape == "partial":
229+
wrapper.__wrapped__ = partial(wrapper) # type: ignore[attr-defined]
230+
else:
231+
class CallableWrapper:
232+
def __call__(self, ctx: task.OrchestrationContext, value: int) -> int:
233+
return value
234+
235+
wrapper.__wrapped__ = CallableWrapper() # type: ignore[attr-defined]
236+
CallableWrapper.__call__.__wrapped__ = partial(wrapper) # type: ignore[attr-defined]
237+
238+
# No input: dispatch must detect the cycle without relying on type discovery.
239+
history = [
240+
helpers.new_orchestrator_started_event(NOW),
241+
helpers.new_execution_started_event("test", INSTANCE_ID),
242+
]
243+
complete = _completion(_execute(wrapper, [], history))
244+
assert complete.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
245+
assert complete.failureDetails.errorType == "builtins.ValueError"
246+
assert "wrapper loop" in complete.failureDetails.errorMessage
247+
with pytest.raises(ValueError, match="wrapper loop"):
248+
shared.is_async_callable(wrapper)
249+
250+
251+
@pytest.mark.parametrize("shape", ["partial", "callable-partial"])
252+
def test_mixed_wrapper_cycle_fails_within_bounded_worker_dispatch(shape: str) -> None:
253+
result = subprocess.run(
254+
[
255+
sys.executable, "-c",
256+
"import runpy, sys; "
257+
"runpy.run_path(sys.argv[1])[sys.argv[2]](sys.argv[3])",
258+
__file__, _assert_mixed_wrapper_cycle_fails.__name__, shape,
259+
],
260+
capture_output=True, text=True, timeout=10,
261+
)
262+
assert result.returncode == 0, result.stdout + result.stderr
263+
264+
153265
def test_await_is_concrete_and_completed_tasks_do_not_suspend() -> None:
154266
assert "__await__" not in task.Task.__abstractmethods__
155267

0 commit comments

Comments
 (0)