Skip to content

Commit 39ae905

Browse files
Reject unsupported asynchronous workflow definitions and results (#108)
* Reject unsupported asynchronous workflow definitions and results * Validate optional and callable workflow entry points safely * Use the established error-only replay fixture outcome * Prepare Python 2.5.2 and cover callable async entry points --------- Co-authored-by: Durable Workflow <support@durable-workflow.com>
1 parent c5508bc commit 39ae905

10 files changed

Lines changed: 189 additions & 6 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:

‎pyproject.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "durable-workflow"
7-
version = "2.5.1"
7+
version = "2.5.2"
88
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
7171
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
7272

7373
[tool.durable-workflow]
74-
product-train = "2.5.1"
75-
registry-version = "2.5.1"
74+
product-train = "2.5.2"
75+
registry-version = "2.5.2"
7676
supported-server-versions = "2.5.16"
7777
worker-protocol-version = "1.19"
7878
control-plane-version = "2"

‎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: 33 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,31 @@ 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 = getattr(cls, "run", None)
104+
if not callable(entrypoint):
105+
return
106+
targets = [inspect.unwrap(entrypoint)]
107+
if not inspect.isroutine(targets[0]):
108+
targets.append(inspect.unwrap(targets[0].__call__))
109+
if any(inspect.iscoroutinefunction(target) or inspect.isasyncgenfunction(target) for target in targets):
110+
name = getattr(cls, "__workflow_name__", cls.__name__)
111+
raise InvalidWorkflowDefinition(
112+
f"unsupported_workflow_entrypoint: workflow {name!r} ({cls.__qualname__}.run) "
113+
"must use a synchronous function or generator; yield durable commands instead of async def."
114+
)
115+
116+
117+
def _validate_workflow_result(value: Any, cls: type) -> None:
118+
if inspect.isawaitable(value) or inspect.isasyncgen(value):
119+
if inspect.iscoroutine(value):
120+
value.close()
121+
raise InvalidWorkflowDefinition(
122+
f"unsupported_workflow_result: {cls.__qualname__}.run returned an async value; "
123+
"use a synchronous result or yield durable commands."
124+
)
125+
126+
100127
def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
101128
"""Register a class as a workflow type under a language-neutral name.
102129
@@ -108,6 +135,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
108135

109136
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
110137
cls.__workflow_name__ = name # type: ignore[attr-defined]
138+
_validate_workflow_entrypoint(cls)
111139
signals: dict[str, str] = {}
112140
queries: dict[str, str] = {}
113141
updates: dict[str, str] = {}
@@ -4037,6 +4065,7 @@ def _replay_state(
40374065
allow_cancellation_scope_authoring: bool = False,
40384066
allow_cancellation_scope_delivery: bool = False,
40394067
) -> _ReplayState:
4068+
_validate_workflow_entrypoint(workflow_cls)
40404069
if allow_cancellation_scope_delivery and not allow_cancellation_scope_authoring:
40414070
raise LocalActivityExecutionAborted("scope delivery requires canonical scope authoring")
40424071
if prepare_local_activity_groups and not prepare_local_activities:
@@ -5172,6 +5201,7 @@ def _same_logical_condition_wait(opened: Mapping[str, Any], cmd: WaitCondition)
51725201
except Exception as exc:
51735202
return _state([_fail_workflow_from_exception(exc)])
51745203
if not hasattr(gen, "__next__"):
5204+
_validate_workflow_result(gen, workflow_cls)
51755205
if isinstance(gen, ContinueAsNew):
51765206
_assert_no_unconsumed_history("continue as new")
51775207
return _state([gen])
@@ -6032,6 +6062,7 @@ def _scope_call_membership(command: Any) -> str:
60326062
return next(iter(memberships))
60336063

60346064
def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
6065+
_validate_workflow_result(value, workflow_cls)
60356066
_apply_due_receivers()
60366067
_consume_terminal_condition_reopens()
60376068
commands = list(pending) if include_pending else []
@@ -6784,10 +6815,10 @@ def _advance_selection_clock(base: int, size: int, failure: BaseException | None
67846815
try:
67856816
return _terminal_state(stop.value, include_pending=True)
67866817
except Exception as exc:
6787-
if isinstance(exc, NonDeterministicReplayError):
6818+
if isinstance(exc, (NonDeterministicReplayError, InvalidWorkflowDefinition)):
67886819
raise
67896820
return _state(pending + [_fail_workflow_from_exception(exc)])
6790-
except NonDeterministicReplayError:
6821+
except (NonDeterministicReplayError, InvalidWorkflowDefinition):
67916822
raise
67926823
except LocalActivityExecutionAborted:
67936824
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"}
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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterator, Generator
4+
from functools import partial, 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+
class AsyncCallable:
24+
async def __call__(self, ctx: Any) -> str:
25+
return "unsupported"
26+
27+
28+
class CallableWorkflow:
29+
run = AsyncCallable()
30+
31+
32+
class PartialWorkflow:
33+
run = staticmethod(partial(CoroutineWorkflow.run, object()))
34+
35+
36+
ASYNC_DEFINITIONS = [CoroutineWorkflow, AsyncGeneratorWorkflow, CallableWorkflow, PartialWorkflow]
37+
38+
39+
@pytest.mark.parametrize("definition", ASYNC_DEFINITIONS)
40+
def test_definition_rejects_async_entrypoints(definition: type) -> None:
41+
with pytest.raises(ValueError, match="unsupported_workflow_entrypoint.*synchronous"):
42+
workflow.defn(name="invalid-async")(definition)
43+
44+
45+
@pytest.mark.parametrize("definition", ASYNC_DEFINITIONS)
46+
def test_worker_rejects_undecorated_async_entrypoints_before_network(definition: type) -> None:
47+
with pytest.raises(ValueError, match=definition.__name__):
48+
Worker(Client("http://127.0.0.1:1"), task_queue="unused", workflows=[definition])
49+
50+
51+
@pytest.mark.parametrize("definition", ASYNC_DEFINITIONS)
52+
def test_direct_replay_rejects_async_without_creating_a_coroutine(definition: type) -> None:
53+
with pytest.raises(ValueError, match="unsupported_workflow_entrypoint"):
54+
workflow.replay(definition, [], [])
55+
56+
57+
def test_test_environment_revalidates_entrypoint_after_decoration() -> None:
58+
@workflow.defn(name="changed-entrypoint")
59+
class ChangedWorkflow:
60+
def run(self, ctx: Any) -> str:
61+
return "valid"
62+
63+
ChangedWorkflow.run = CoroutineWorkflow.run # type: ignore[assignment]
64+
with pytest.raises(ValueError, match="changed-entrypoint"):
65+
WorkflowEnvironment().register_workflow(ChangedWorkflow)
66+
67+
68+
def test_wrapped_and_inherited_async_entrypoints_are_rejected() -> None:
69+
class InheritedWorkflow(CoroutineWorkflow):
70+
pass
71+
72+
class WrappedWorkflow:
73+
@wraps(CoroutineWorkflow.run)
74+
def run(self, ctx: Any) -> Any:
75+
return CoroutineWorkflow.run(self, ctx)
76+
77+
for definition in [InheritedWorkflow, WrappedWorkflow]:
78+
with pytest.raises(ValueError, match="unsupported_workflow_entrypoint"):
79+
workflow.replay(definition, [], [])
80+
81+
82+
@pytest.mark.parametrize("generator", [False, True])
83+
def test_runtime_awaitable_result_is_not_completed(generator: bool) -> None:
84+
class AwaitableResultWorkflow:
85+
def run(self, ctx: Any) -> Any:
86+
return CoroutineWorkflow.run(self, ctx)
87+
88+
class GeneratorAwaitableResultWorkflow:
89+
def run(self, ctx: Any) -> Generator[Any, Any, Any]:
90+
if False:
91+
yield None
92+
return CoroutineWorkflow.run(self, ctx)
93+
94+
definition = GeneratorAwaitableResultWorkflow if generator else AwaitableResultWorkflow
95+
with pytest.raises(ValueError, match="unsupported_workflow_result"):
96+
workflow.replay(definition, [], [])
97+
98+
99+
def test_synchronous_and_generator_workflows_remain_supported() -> None:
100+
@workflow.defn(name="valid-sync")
101+
class SynchronousWorkflow:
102+
def run(self, ctx: Any) -> str:
103+
return "done"
104+
105+
@workflow.defn(name="valid-generator")
106+
class GeneratorWorkflow:
107+
def run(self, ctx: Any) -> Generator[Any, Any, str]:
108+
if False:
109+
yield None
110+
return "done"
111+
112+
Worker(Client("http://127.0.0.1:1"), task_queue="unused", workflows=[SynchronousWorkflow, GeneratorWorkflow])
113+
environment = WorkflowEnvironment()
114+
for definition in [SynchronousWorkflow, GeneratorWorkflow]:
115+
assert environment.execute_workflow(definition) == "done"
116+
assert workflow.replay(definition, [], []).commands[0].result == "done" # type: ignore[union-attr]

0 commit comments

Comments
 (0)