Skip to content

Commit c81ea3f

Browse files
Reject unsupported asynchronous workflow definitions and results
1 parent c5508bc commit c81ea3f

9 files changed

Lines changed: 167 additions & 3 deletions

File tree

‎docs/sdk-reference.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,12 @@ language, target service language, operation name, request payload,
405405
service-call id, response or failure surface, and optional artifact metadata
406406
to the recorded result.
407407

408+
Workflow entry points use synchronous `def run(...)` methods, either returning
409+
a result directly or yielding durable commands. `async def` and async-generator
410+
entry points are rejected with `InvalidWorkflowDefinition` during definition or
411+
worker registration. Async activity handlers remain supported. Direct replay
412+
also rejects async entry points and awaitable results.
413+
408414
## Workflow signals, queries, and updates
409415

410416
Signals mutate workflow state during replay:

‎src/durable_workflow/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
ExternalPayloadUnavailable,
8282
ExternalPayloadUnsupported,
8383
InvalidArgument,
84+
InvalidWorkflowDefinition,
8485
NamespaceNotFound,
8586
NexusOperationFailed,
8687
NonDeterministicReplayError,
@@ -369,6 +370,7 @@
369370
"NexusOperationResult",
370371
"NexusServiceCall",
371372
"NonDeterministicReplayError",
373+
"InvalidWorkflowDefinition",
372374
"NoopMetrics",
373375
"QueryFailed",
374376
"RuntimeCapabilityUnsupported",

‎src/durable_workflow/errors.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class DurableWorkflowError(Exception):
2727
"""Base class for every exception raised by the SDK."""
2828

2929

30+
class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
31+
"""Workflow authoring uses an unsupported entry point or result."""
32+
33+
3034
class DurableOperationCancelled(DurableWorkflowError):
3135
"""A durable selection member was explicitly cancelled."""
3236

‎src/durable_workflow/testing.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def test_my_workflow():
5151
StartChildWorkflow,
5252
StartTimer,
5353
UpsertSearchAttributes,
54+
_validate_workflow_entrypoint,
5455
registry,
5556
replay,
5657
)
@@ -134,6 +135,7 @@ def register_workflow(self, workflow_cls: type) -> None:
134135
f"{workflow_cls!r} is not a workflow class; "
135136
"decorate it with @workflow.defn(name=...) first."
136137
)
138+
_validate_workflow_entrypoint(workflow_cls)
137139
self._registered_workflows[name] = workflow_cls
138140

139141
def signal(

‎src/durable_workflow/worker.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
ScheduleActivity,
105105
StartChildWorkflow,
106106
UpsertMemo,
107+
_validate_workflow_entrypoint,
107108
apply_update,
108109
commands_to_server_commands,
109110
query_state,
@@ -1023,7 +1024,10 @@ def __init__(
10231024
) -> None:
10241025
self.client = client
10251026
self.task_queue = task_queue
1026-
self.workflows = {_workflow_name(w): w for w in workflows}
1027+
self.workflows: dict[str, type] = {}
1028+
for definition in workflows:
1029+
_validate_workflow_entrypoint(definition)
1030+
self.workflows[_workflow_name(definition)] = definition
10271031
self.workflow_definition_fingerprints = {
10281032
workflow_type: _workflow_definition_fingerprint(workflow_cls)
10291033
for workflow_type, workflow_cls in self.workflows.items()

‎src/durable_workflow/workflow.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import base64
2222
import contextlib
2323
import hashlib
24+
import inspect
2425
import logging
2526
import math
2627
import random
@@ -58,6 +59,7 @@
5859
ChildWorkflowFailed,
5960
ChildWorkflowTerminated,
6061
DurableOperationCancelled,
62+
InvalidWorkflowDefinition,
6163
NexusOperationFailed,
6264
NonDeterministicReplayError,
6365
QueryFailed,
@@ -97,6 +99,28 @@ def _authored_scope_cleanup() -> dict[str, str] | None:
9799
MESSAGE_STREAM_MAX_BATCH = 100
98100

99101

102+
def _validate_workflow_entrypoint(cls: type) -> None:
103+
entrypoint = inspect.unwrap(getattr(cls, "run", None))
104+
if callable(entrypoint) and not inspect.isroutine(entrypoint):
105+
entrypoint = inspect.unwrap(entrypoint.__call__)
106+
if inspect.iscoroutinefunction(entrypoint) or inspect.isasyncgenfunction(entrypoint):
107+
name = getattr(cls, "__workflow_name__", cls.__name__)
108+
raise InvalidWorkflowDefinition(
109+
f"unsupported_workflow_entrypoint: workflow {name!r} ({cls.__qualname__}.run) "
110+
"must use a synchronous function or generator; yield durable commands instead of async def."
111+
)
112+
113+
114+
def _validate_workflow_result(value: Any, cls: type) -> None:
115+
if inspect.isawaitable(value) or inspect.isasyncgen(value):
116+
if inspect.iscoroutine(value):
117+
value.close()
118+
raise InvalidWorkflowDefinition(
119+
f"unsupported_workflow_result: {cls.__qualname__}.run returned an async value; "
120+
"use a synchronous result or yield durable commands."
121+
)
122+
123+
100124
def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
101125
"""Register a class as a workflow type under a language-neutral name.
102126
@@ -108,6 +132,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
108132

109133
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
110134
cls.__workflow_name__ = name # type: ignore[attr-defined]
135+
_validate_workflow_entrypoint(cls)
111136
signals: dict[str, str] = {}
112137
queries: dict[str, str] = {}
113138
updates: dict[str, str] = {}
@@ -4037,6 +4062,7 @@ def _replay_state(
40374062
allow_cancellation_scope_authoring: bool = False,
40384063
allow_cancellation_scope_delivery: bool = False,
40394064
) -> _ReplayState:
4065+
_validate_workflow_entrypoint(workflow_cls)
40404066
if allow_cancellation_scope_delivery and not allow_cancellation_scope_authoring:
40414067
raise LocalActivityExecutionAborted("scope delivery requires canonical scope authoring")
40424068
if prepare_local_activity_groups and not prepare_local_activities:
@@ -5172,6 +5198,7 @@ def _same_logical_condition_wait(opened: Mapping[str, Any], cmd: WaitCondition)
51725198
except Exception as exc:
51735199
return _state([_fail_workflow_from_exception(exc)])
51745200
if not hasattr(gen, "__next__"):
5201+
_validate_workflow_result(gen, workflow_cls)
51755202
if isinstance(gen, ContinueAsNew):
51765203
_assert_no_unconsumed_history("continue as new")
51775204
return _state([gen])
@@ -6032,6 +6059,7 @@ def _scope_call_membership(command: Any) -> str:
60326059
return next(iter(memberships))
60336060

60346061
def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
6062+
_validate_workflow_result(value, workflow_cls)
60356063
_apply_due_receivers()
60366064
_consume_terminal_condition_reopens()
60376065
commands = list(pending) if include_pending else []
@@ -6784,10 +6812,10 @@ def _advance_selection_clock(base: int, size: int, failure: BaseException | None
67846812
try:
67856813
return _terminal_state(stop.value, include_pending=True)
67866814
except Exception as exc:
6787-
if isinstance(exc, NonDeterministicReplayError):
6815+
if isinstance(exc, (NonDeterministicReplayError, InvalidWorkflowDefinition)):
67886816
raise
67896817
return _state(pending + [_fail_workflow_from_exception(exc)])
6790-
except NonDeterministicReplayError:
6818+
except (NonDeterministicReplayError, InvalidWorkflowDefinition):
67916819
raise
67926820
except LocalActivityExecutionAborted:
67936821
raise
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
3+
"fixture_schema": "durable-workflow.replay-regression/v1",
4+
"id": "unsupported-async-workflow-entrypoint",
5+
"protocol_version": "1.19",
6+
"bindings": ["python"],
7+
"workflow": {"type": "tests.replay.unsupported-async", "input": [], "payload_codec": "avro"},
8+
"history": [{"event_type": "WorkflowStarted", "payload": {}}],
9+
"expected": {"error": "unsupported_workflow_entrypoint", "command_sequence": []}
10+
}

‎tests/test_replay_regression_corpus.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,15 @@ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
251251
return (yield ctx.start_child_workflow("child", []))
252252

253253

254+
class UnsupportedAsyncWorkflow:
255+
__workflow_name__ = "tests.replay.unsupported-async"
256+
257+
async def run(self, ctx: WorkflowContext) -> str:
258+
return "body was never executed"
259+
260+
254261
WORKFLOWS = [
262+
UnsupportedAsyncWorkflow,
255263
ChildPolicyAuthorWorkflow,
256264
ColdReplacementSatisfiedConditionWorkflow,
257265
CooperativeReopenedConditionCleanupWorkflow,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterator, Generator
4+
from functools import wraps
5+
from typing import Any
6+
7+
import pytest
8+
9+
from durable_workflow import Client, Worker, workflow
10+
from durable_workflow.testing import WorkflowEnvironment
11+
12+
13+
class CoroutineWorkflow:
14+
async def run(self, ctx: Any) -> str:
15+
return "never executed"
16+
17+
18+
class AsyncGeneratorWorkflow:
19+
async def run(self, ctx: Any) -> AsyncIterator[str]:
20+
yield "unsupported"
21+
22+
23+
@pytest.mark.parametrize("definition", [CoroutineWorkflow, AsyncGeneratorWorkflow])
24+
def test_definition_rejects_async_entrypoints(definition: type) -> None:
25+
with pytest.raises(ValueError, match="unsupported_workflow_entrypoint.*synchronous"):
26+
workflow.defn(name="invalid-async")(definition)
27+
28+
29+
@pytest.mark.parametrize("definition", [CoroutineWorkflow, AsyncGeneratorWorkflow])
30+
def test_worker_rejects_undecorated_async_entrypoints_before_network(definition: type) -> None:
31+
with pytest.raises(ValueError, match=definition.__name__):
32+
Worker(Client("http://127.0.0.1:1"), task_queue="unused", workflows=[definition])
33+
34+
35+
@pytest.mark.parametrize("definition", [CoroutineWorkflow, AsyncGeneratorWorkflow])
36+
def test_direct_replay_rejects_async_without_creating_a_coroutine(definition: type) -> None:
37+
with pytest.raises(ValueError, match="unsupported_workflow_entrypoint"):
38+
workflow.replay(definition, [], [])
39+
40+
41+
def test_test_environment_revalidates_entrypoint_after_decoration() -> None:
42+
@workflow.defn(name="changed-entrypoint")
43+
class ChangedWorkflow:
44+
def run(self, ctx: Any) -> str:
45+
return "valid"
46+
47+
ChangedWorkflow.run = CoroutineWorkflow.run # type: ignore[assignment]
48+
with pytest.raises(ValueError, match="changed-entrypoint"):
49+
WorkflowEnvironment().register_workflow(ChangedWorkflow)
50+
51+
52+
def test_wrapped_and_inherited_async_entrypoints_are_rejected() -> None:
53+
class InheritedWorkflow(CoroutineWorkflow):
54+
pass
55+
56+
class WrappedWorkflow:
57+
@wraps(CoroutineWorkflow.run)
58+
def run(self, ctx: Any) -> Any:
59+
return CoroutineWorkflow.run(self, ctx)
60+
61+
for definition in [InheritedWorkflow, WrappedWorkflow]:
62+
with pytest.raises(ValueError, match="unsupported_workflow_entrypoint"):
63+
workflow.replay(definition, [], [])
64+
65+
66+
@pytest.mark.parametrize("generator", [False, True])
67+
def test_runtime_awaitable_result_is_not_completed(generator: bool) -> None:
68+
class AwaitableResultWorkflow:
69+
def run(self, ctx: Any) -> Any:
70+
return CoroutineWorkflow.run(self, ctx)
71+
72+
class GeneratorAwaitableResultWorkflow:
73+
def run(self, ctx: Any) -> Generator[Any, Any, Any]:
74+
if False:
75+
yield None
76+
return CoroutineWorkflow.run(self, ctx)
77+
78+
definition = GeneratorAwaitableResultWorkflow if generator else AwaitableResultWorkflow
79+
with pytest.raises(ValueError, match="unsupported_workflow_result"):
80+
workflow.replay(definition, [], [])
81+
82+
83+
def test_synchronous_and_generator_workflows_remain_supported() -> None:
84+
@workflow.defn(name="valid-sync")
85+
class SynchronousWorkflow:
86+
def run(self, ctx: Any) -> str:
87+
return "done"
88+
89+
@workflow.defn(name="valid-generator")
90+
class GeneratorWorkflow:
91+
def run(self, ctx: Any) -> Generator[Any, Any, str]:
92+
if False:
93+
yield None
94+
return "done"
95+
96+
Worker(Client("http://127.0.0.1:1"), task_queue="unused", workflows=[SynchronousWorkflow, GeneratorWorkflow])
97+
environment = WorkflowEnvironment()
98+
for definition in [SynchronousWorkflow, GeneratorWorkflow]:
99+
assert environment.execute_workflow(definition) == "done"
100+
assert workflow.replay(definition, [], []).commands[0].result == "done" # type: ignore[union-attr]

0 commit comments

Comments
 (0)