Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,22 @@ entry points are rejected with `InvalidWorkflowDefinition` during definition or
worker registration. Async activity handlers remain supported. Direct replay
also rejects async entry points and awaitable results.

Workers reject duplicate workflow or activity wire names with
`DuplicateRegistrationError` before contacting Server or polling work. Each
Worker and offline Replayer has its own registration scope. Distinct names
remain supported, and separate workers may use different implementations of
the same name. The error identifies the kind, wire name and both definitions.

Within a workflow class, signal, query, update and validator names are checked
independently. One name may be shared across these kinds. Python's normal
method resolution applies to inheritance: overriding the same Python attribute
is allowed, while two resolved attributes with the same handler kind and wire
name are rejected at decoration time and at Worker, Replayer and test-environment
admission, including programmatically defined classes. The test environment rejects a different
workflow class replacing an existing name, while repeated registration of the
same class remains idempotent for automatic execution. Explicit activity mock
and canned-result setters continue to support replacement.

## Workflow signals, queries, and updates

Signals mutate workflow state during replay:
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "durable-workflow"
version = "2.5.3"
version = "2.5.4"
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"

[tool.durable-workflow]
product-train = "2.5.3"
registry-version = "2.5.3"
product-train = "2.5.4"
registry-version = "2.5.4"
supported-server-versions = "2.5.16"
worker-protocol-version = "1.19"
control-plane-version = "2"
Expand Down
2 changes: 2 additions & 0 deletions src/durable_workflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
ChildWorkflowCancelled,
ChildWorkflowFailed,
ChildWorkflowTerminated,
DuplicateRegistrationError,
DurableOperationCancelled,
DurableWorkflowError,
ExternalPayloadError,
Expand Down Expand Up @@ -370,6 +371,7 @@
"NexusOperationResult",
"NexusServiceCall",
"NonDeterministicReplayError",
"DuplicateRegistrationError",
"InvalidWorkflowDefinition",
"NoopMetrics",
"QueryFailed",
Expand Down
24 changes: 24 additions & 0 deletions src/durable_workflow/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
"""Workflow authoring uses an unsupported entry point or result."""


class DuplicateRegistrationError(DurableWorkflowError, ValueError):
"""One registration scope assigns a wire name to multiple definitions."""

def __init__(
self, registration_kind: str, wire_name: str,
first_definition: object, second_definition: object,
) -> None:
def label(definition: object) -> str:
if isinstance(definition, str):
return definition
module = getattr(definition, "__module__", type(definition).__module__)
name = getattr(definition, "__qualname__", type(definition).__qualname__)
return f"{module}.{name}"

self.registration_kind = registration_kind
self.wire_name = wire_name
self.first_definition = label(first_definition)
self.second_definition = label(second_definition)
super().__init__(
f"duplicate {registration_kind} name {wire_name!r}: "
f"{self.first_definition} conflicts with {self.second_definition}"
)


class DurableOperationCancelled(DurableWorkflowError):
"""A durable selection member was explicitly cancelled."""

Expand Down
6 changes: 5 additions & 1 deletion src/durable_workflow/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_my_workflow():
from typing import Any

from . import serializer
from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
from .errors import DuplicateRegistrationError, WorkflowCancelled, WorkflowFailed, WorkflowTerminated
from .workflow import (
CompleteWorkflow,
ContinueAsNew,
Expand All @@ -52,6 +52,7 @@ def test_my_workflow():
StartTimer,
UpsertSearchAttributes,
_validate_workflow_entrypoint,
_workflow_handler_registries,
registry,
replay,
)
Expand Down Expand Up @@ -136,6 +137,9 @@ def register_workflow(self, workflow_cls: type) -> None:
"decorate it with @workflow.defn(name=...) first."
)
_validate_workflow_entrypoint(workflow_cls)
_workflow_handler_registries(workflow_cls)
if name in self._registered_workflows and self._registered_workflows[name] is not workflow_cls:
raise DuplicateRegistrationError("workflow", name, self._registered_workflows[name], workflow_cls)
self._registered_workflows[name] = workflow_cls

def signal(
Expand Down
15 changes: 13 additions & 2 deletions src/durable_workflow/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
ActivityCancelled,
ActivityFailed,
AvroNotInstalledError,
DuplicateRegistrationError,
DurableWorkflowError,
ExternalPayloadError,
InvalidArgument,
Expand Down Expand Up @@ -106,6 +107,7 @@
StartChildWorkflow,
UpsertMemo,
_validate_workflow_entrypoint,
_workflow_handler_registries,
apply_update,
commands_to_server_commands,
query_state,
Expand Down Expand Up @@ -1028,7 +1030,11 @@ def __init__(
self.workflows: dict[str, type] = {}
for definition in workflows:
_validate_workflow_entrypoint(definition)
self.workflows[_workflow_name(definition)] = definition
_workflow_handler_registries(definition)
name = _workflow_name(definition)
if name in self.workflows:
raise DuplicateRegistrationError("workflow", name, self.workflows[name], definition)
self.workflows[name] = definition
self.workflow_definition_fingerprints = {
workflow_type: _workflow_definition_fingerprint(workflow_cls)
for workflow_type, workflow_cls in self.workflows.items()
Expand All @@ -1037,7 +1043,12 @@ def __init__(
workflow_type: _workflow_command_contract(workflow_cls)
for workflow_type, workflow_cls in self.workflows.items()
}
self.activities = {_activity_name(a): a for a in activities}
self.activities: dict[str, Callable[..., Any]] = {}
for activity_definition in activities:
name = _activity_name(activity_definition)
if name in self.activities:
raise DuplicateRegistrationError("activity", name, self.activities[name], activity_definition)
self.activities[name] = activity_definition
self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
self._sticky_cache = StickyWorkflowCache(
sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
Expand Down
52 changes: 29 additions & 23 deletions src/durable_workflow/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
ChildWorkflowCancelled,
ChildWorkflowFailed,
ChildWorkflowTerminated,
DuplicateRegistrationError,
DurableOperationCancelled,
InvalidWorkflowDefinition,
NexusOperationFailed,
Expand Down Expand Up @@ -124,6 +125,29 @@ def _validate_workflow_result(value: Any, cls: type) -> None:
)


def _workflow_handler_registries(
cls: type,
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
handlers: dict[str, dict[str, str]] = {kind: {} for kind in ("signal", "query", "update", "update_validator")}
for attr in dir(cls):
if attr.startswith("_"):
continue
member = getattr(cls, attr, None)
for kind, registry in handlers.items():
wire_name = getattr(member, f"__{kind}_name__", None)
if not isinstance(wire_name, str) or not wire_name:
continue
if kind == "signal" and wire_name == MESSAGE_STREAM_SIGNAL:
raise ValueError(f"signal name {wire_name!r} is reserved by the workflow runtime")
if wire_name in registry:
owner = f"{cls.__module__}.{cls.__qualname__}"
raise DuplicateRegistrationError(
kind, wire_name, f"{owner}.{registry[wire_name]}", f"{owner}.{attr}",
)
registry[wire_name] = attr
return handlers["signal"], handlers["query"], handlers["update"], handlers["update_validator"]


def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
"""Register a class as a workflow type under a language-neutral name.

Expand All @@ -136,28 +160,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
cls.__workflow_name__ = name # type: ignore[attr-defined]
_validate_workflow_entrypoint(cls)
signals: dict[str, str] = {}
queries: dict[str, str] = {}
updates: dict[str, str] = {}
update_validators: dict[str, str] = {}
for attr in dir(cls):
if attr.startswith("_"):
continue
member = getattr(cls, attr, None)
signal_name = getattr(member, "__signal_name__", None)
if isinstance(signal_name, str) and signal_name:
if signal_name == MESSAGE_STREAM_SIGNAL:
raise ValueError(f"signal name {signal_name!r} is reserved by the workflow runtime")
signals[signal_name] = attr
query_name = getattr(member, "__query_name__", None)
if isinstance(query_name, str) and query_name:
queries[query_name] = attr
update_name = getattr(member, "__update_name__", None)
if isinstance(update_name, str) and update_name:
updates[update_name] = attr
update_validator_name = getattr(member, "__update_validator_name__", None)
if isinstance(update_validator_name, str) and update_validator_name:
update_validators[update_validator_name] = attr
signals, queries, updates, update_validators = _workflow_handler_registries(cls)
cls.__workflow_signals__ = signals # type: ignore[attr-defined]
cls.__workflow_queries__ = queries # type: ignore[attr-defined]
cls.__workflow_updates__ = updates # type: ignore[attr-defined]
Expand Down Expand Up @@ -2578,9 +2581,12 @@ class Replayer:
def __init__(self, *, workflows: Iterable[type]) -> None:
self._workflows: dict[str, type] = {}
for workflow_cls in workflows:
_workflow_handler_registries(workflow_cls)
workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
if workflow_type in self._workflows:
raise ValueError(f"duplicate workflow type registered with Replayer: {workflow_type!r}")
raise DuplicateRegistrationError(
"workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
)
self._workflows[workflow_type] = workflow_cls
if not self._workflows:
raise ValueError("Replayer requires at least one workflow class")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
"fixture_schema": "durable-workflow.replay-regression/v1",
"id": "python-ambiguous-worker-registration-rejected",
"protocol_version": "1.19",
"bindings": ["python"],
"consumers": ["workflow-executor"],
"workflow": {
"type": "golden.single-activity",
"input": ["Ada"],
"payload_codec": "avro",
"worker_registrations": ["golden.single-activity", "golden.single-activity"]
},
"history": [
{"event_type": "WorkflowStarted", "payload": {"payload_codec": "avro"}},
{"event_type": "ActivityScheduled", "payload": {"sequence": 1, "activity_type": "golden.greet"}},
{"event_type": "ActivityCompleted", "payload": {"sequence": 1, "result": "wwHioz3/VYAiNwoSaGVsbG8gQWRh"}}
],
"expected": {"error": "duplicate workflow name 'golden.single-activity'"}
}
Loading
Loading