Skip to content

Commit 86cfced

Browse files
Reject ambiguous registrations before polling (#110)
* Reject ambiguous Python handler registrations before polling * Validate programmatic workflow handlers at every admission surface --------- Co-authored-by: Durable Workflow <support@durable-workflow.com>
1 parent e88642d commit 86cfced

10 files changed

Lines changed: 316 additions & 30 deletions

File tree

‎docs/sdk-reference.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,22 @@ entry points are rejected with `InvalidWorkflowDefinition` during definition or
411411
worker registration. Async activity handlers remain supported. Direct replay
412412
also rejects async entry points and awaitable results.
413413

414+
Workers reject duplicate workflow or activity wire names with
415+
`DuplicateRegistrationError` before contacting Server or polling work. Each
416+
Worker and offline Replayer has its own registration scope. Distinct names
417+
remain supported, and separate workers may use different implementations of
418+
the same name. The error identifies the kind, wire name and both definitions.
419+
420+
Within a workflow class, signal, query, update and validator names are checked
421+
independently. One name may be shared across these kinds. Python's normal
422+
method resolution applies to inheritance: overriding the same Python attribute
423+
is allowed, while two resolved attributes with the same handler kind and wire
424+
name are rejected at decoration time and at Worker, Replayer and test-environment
425+
admission, including programmatically defined classes. The test environment rejects a different
426+
workflow class replacing an existing name, while repeated registration of the
427+
same class remains idempotent for automatic execution. Explicit activity mock
428+
and canned-result setters continue to support replacement.
429+
414430
## Workflow signals, queries, and updates
415431

416432
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.3"
7+
version = "2.5.4"
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.3"
75-
registry-version = "2.5.3"
74+
product-train = "2.5.4"
75+
registry-version = "2.5.4"
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
@@ -70,6 +70,7 @@
7070
ChildWorkflowCancelled,
7171
ChildWorkflowFailed,
7272
ChildWorkflowTerminated,
73+
DuplicateRegistrationError,
7374
DurableOperationCancelled,
7475
DurableWorkflowError,
7576
ExternalPayloadError,
@@ -370,6 +371,7 @@
370371
"NexusOperationResult",
371372
"NexusServiceCall",
372373
"NonDeterministicReplayError",
374+
"DuplicateRegistrationError",
373375
"InvalidWorkflowDefinition",
374376
"NoopMetrics",
375377
"QueryFailed",

‎src/durable_workflow/errors.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,30 @@ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
3131
"""Workflow authoring uses an unsupported entry point or result."""
3232

3333

34+
class DuplicateRegistrationError(DurableWorkflowError, ValueError):
35+
"""One registration scope assigns a wire name to multiple definitions."""
36+
37+
def __init__(
38+
self, registration_kind: str, wire_name: str,
39+
first_definition: object, second_definition: object,
40+
) -> None:
41+
def label(definition: object) -> str:
42+
if isinstance(definition, str):
43+
return definition
44+
module = getattr(definition, "__module__", type(definition).__module__)
45+
name = getattr(definition, "__qualname__", type(definition).__qualname__)
46+
return f"{module}.{name}"
47+
48+
self.registration_kind = registration_kind
49+
self.wire_name = wire_name
50+
self.first_definition = label(first_definition)
51+
self.second_definition = label(second_definition)
52+
super().__init__(
53+
f"duplicate {registration_kind} name {wire_name!r}: "
54+
f"{self.first_definition} conflicts with {self.second_definition}"
55+
)
56+
57+
3458
class DurableOperationCancelled(DurableWorkflowError):
3559
"""A durable selection member was explicitly cancelled."""
3660

‎src/durable_workflow/testing.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_my_workflow():
3939
from typing import Any
4040

4141
from . import serializer
42-
from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
42+
from .errors import DuplicateRegistrationError, WorkflowCancelled, WorkflowFailed, WorkflowTerminated
4343
from .workflow import (
4444
CompleteWorkflow,
4545
ContinueAsNew,
@@ -52,6 +52,7 @@ def test_my_workflow():
5252
StartTimer,
5353
UpsertSearchAttributes,
5454
_validate_workflow_entrypoint,
55+
_workflow_handler_registries,
5556
registry,
5657
replay,
5758
)
@@ -136,6 +137,9 @@ def register_workflow(self, workflow_cls: type) -> None:
136137
"decorate it with @workflow.defn(name=...) first."
137138
)
138139
_validate_workflow_entrypoint(workflow_cls)
140+
_workflow_handler_registries(workflow_cls)
141+
if name in self._registered_workflows and self._registered_workflows[name] is not workflow_cls:
142+
raise DuplicateRegistrationError("workflow", name, self._registered_workflows[name], workflow_cls)
139143
self._registered_workflows[name] = workflow_cls
140144

141145
def signal(

‎src/durable_workflow/worker.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
ActivityCancelled,
6767
ActivityFailed,
6868
AvroNotInstalledError,
69+
DuplicateRegistrationError,
6970
DurableWorkflowError,
7071
ExternalPayloadError,
7172
InvalidArgument,
@@ -106,6 +107,7 @@
106107
StartChildWorkflow,
107108
UpsertMemo,
108109
_validate_workflow_entrypoint,
110+
_workflow_handler_registries,
109111
apply_update,
110112
commands_to_server_commands,
111113
query_state,
@@ -1028,7 +1030,11 @@ def __init__(
10281030
self.workflows: dict[str, type] = {}
10291031
for definition in workflows:
10301032
_validate_workflow_entrypoint(definition)
1031-
self.workflows[_workflow_name(definition)] = definition
1033+
_workflow_handler_registries(definition)
1034+
name = _workflow_name(definition)
1035+
if name in self.workflows:
1036+
raise DuplicateRegistrationError("workflow", name, self.workflows[name], definition)
1037+
self.workflows[name] = definition
10321038
self.workflow_definition_fingerprints = {
10331039
workflow_type: _workflow_definition_fingerprint(workflow_cls)
10341040
for workflow_type, workflow_cls in self.workflows.items()
@@ -1037,7 +1043,12 @@ def __init__(
10371043
workflow_type: _workflow_command_contract(workflow_cls)
10381044
for workflow_type, workflow_cls in self.workflows.items()
10391045
}
1040-
self.activities = {_activity_name(a): a for a in activities}
1046+
self.activities: dict[str, Callable[..., Any]] = {}
1047+
for activity_definition in activities:
1048+
name = _activity_name(activity_definition)
1049+
if name in self.activities:
1050+
raise DuplicateRegistrationError("activity", name, self.activities[name], activity_definition)
1051+
self.activities[name] = activity_definition
10411052
self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
10421053
self._sticky_cache = StickyWorkflowCache(
10431054
sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,

‎src/durable_workflow/workflow.py‎

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
ChildWorkflowCancelled,
5959
ChildWorkflowFailed,
6060
ChildWorkflowTerminated,
61+
DuplicateRegistrationError,
6162
DurableOperationCancelled,
6263
InvalidWorkflowDefinition,
6364
NexusOperationFailed,
@@ -124,6 +125,29 @@ def _validate_workflow_result(value: Any, cls: type) -> None:
124125
)
125126

126127

128+
def _workflow_handler_registries(
129+
cls: type,
130+
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
131+
handlers: dict[str, dict[str, str]] = {kind: {} for kind in ("signal", "query", "update", "update_validator")}
132+
for attr in dir(cls):
133+
if attr.startswith("_"):
134+
continue
135+
member = getattr(cls, attr, None)
136+
for kind, registry in handlers.items():
137+
wire_name = getattr(member, f"__{kind}_name__", None)
138+
if not isinstance(wire_name, str) or not wire_name:
139+
continue
140+
if kind == "signal" and wire_name == MESSAGE_STREAM_SIGNAL:
141+
raise ValueError(f"signal name {wire_name!r} is reserved by the workflow runtime")
142+
if wire_name in registry:
143+
owner = f"{cls.__module__}.{cls.__qualname__}"
144+
raise DuplicateRegistrationError(
145+
kind, wire_name, f"{owner}.{registry[wire_name]}", f"{owner}.{attr}",
146+
)
147+
registry[wire_name] = attr
148+
return handlers["signal"], handlers["query"], handlers["update"], handlers["update_validator"]
149+
150+
127151
def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
128152
"""Register a class as a workflow type under a language-neutral name.
129153
@@ -136,28 +160,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
136160
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
137161
cls.__workflow_name__ = name # type: ignore[attr-defined]
138162
_validate_workflow_entrypoint(cls)
139-
signals: dict[str, str] = {}
140-
queries: dict[str, str] = {}
141-
updates: dict[str, str] = {}
142-
update_validators: dict[str, str] = {}
143-
for attr in dir(cls):
144-
if attr.startswith("_"):
145-
continue
146-
member = getattr(cls, attr, None)
147-
signal_name = getattr(member, "__signal_name__", None)
148-
if isinstance(signal_name, str) and signal_name:
149-
if signal_name == MESSAGE_STREAM_SIGNAL:
150-
raise ValueError(f"signal name {signal_name!r} is reserved by the workflow runtime")
151-
signals[signal_name] = attr
152-
query_name = getattr(member, "__query_name__", None)
153-
if isinstance(query_name, str) and query_name:
154-
queries[query_name] = attr
155-
update_name = getattr(member, "__update_name__", None)
156-
if isinstance(update_name, str) and update_name:
157-
updates[update_name] = attr
158-
update_validator_name = getattr(member, "__update_validator_name__", None)
159-
if isinstance(update_validator_name, str) and update_validator_name:
160-
update_validators[update_validator_name] = attr
163+
signals, queries, updates, update_validators = _workflow_handler_registries(cls)
161164
cls.__workflow_signals__ = signals # type: ignore[attr-defined]
162165
cls.__workflow_queries__ = queries # type: ignore[attr-defined]
163166
cls.__workflow_updates__ = updates # type: ignore[attr-defined]
@@ -2578,9 +2581,12 @@ class Replayer:
25782581
def __init__(self, *, workflows: Iterable[type]) -> None:
25792582
self._workflows: dict[str, type] = {}
25802583
for workflow_cls in workflows:
2584+
_workflow_handler_registries(workflow_cls)
25812585
workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
25822586
if workflow_type in self._workflows:
2583-
raise ValueError(f"duplicate workflow type registered with Replayer: {workflow_type!r}")
2587+
raise DuplicateRegistrationError(
2588+
"workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
2589+
)
25842590
self._workflows[workflow_type] = workflow_cls
25852591
if not self._workflows:
25862592
raise ValueError("Replayer requires at least one workflow class")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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": "python-ambiguous-worker-registration-rejected",
5+
"protocol_version": "1.19",
6+
"bindings": ["python"],
7+
"consumers": ["workflow-executor"],
8+
"workflow": {
9+
"type": "golden.single-activity",
10+
"input": ["Ada"],
11+
"payload_codec": "avro",
12+
"worker_registrations": ["golden.single-activity", "golden.single-activity"]
13+
},
14+
"history": [
15+
{"event_type": "WorkflowStarted", "payload": {"payload_codec": "avro"}},
16+
{"event_type": "ActivityScheduled", "payload": {"sequence": 1, "activity_type": "golden.greet"}},
17+
{"event_type": "ActivityCompleted", "payload": {"sequence": 1, "result": "wwHioz3/VYAiNwoSaGVsbG8gQWRh"}}
18+
],
19+
"expected": {"error": "duplicate workflow name 'golden.single-activity'"}
20+
}

0 commit comments

Comments
 (0)