durable-workflow 2.5.2__tar.gz → 2.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.5.2/src/durable_workflow.egg-info → durable_workflow-2.5.4}/PKG-INFO +1 -1
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/pyproject.toml +3 -3
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/errors.py +24 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/testing.py +5 -1
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/worker.py +43 -16
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/workflow.py +46 -33
- {durable_workflow-2.5.2 → durable_workflow-2.5.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- durable_workflow-2.5.4/tests/test_condition_replay_drift.py +132 -0
- durable_workflow-2.5.4/tests/test_registration_ambiguity.py +193 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay_regression_corpus.py +13 -1
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_wait_condition.py +10 -15
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/LICENSE +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/README.md +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/setup.cfg +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_client.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_worker.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.2 → durable_workflow-2.5.4}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.5.
|
|
7
|
+
version = "2.5.4"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.5.
|
|
75
|
-
registry-version = "2.5.
|
|
74
|
+
product-train = "2.5.4"
|
|
75
|
+
registry-version = "2.5.4"
|
|
76
76
|
supported-server-versions = "2.5.16"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -70,6 +70,7 @@ from .errors import (
|
|
|
70
70
|
ChildWorkflowCancelled,
|
|
71
71
|
ChildWorkflowFailed,
|
|
72
72
|
ChildWorkflowTerminated,
|
|
73
|
+
DuplicateRegistrationError,
|
|
73
74
|
DurableOperationCancelled,
|
|
74
75
|
DurableWorkflowError,
|
|
75
76
|
ExternalPayloadError,
|
|
@@ -370,6 +371,7 @@ __all__ = [
|
|
|
370
371
|
"NexusOperationResult",
|
|
371
372
|
"NexusServiceCall",
|
|
372
373
|
"NonDeterministicReplayError",
|
|
374
|
+
"DuplicateRegistrationError",
|
|
373
375
|
"InvalidWorkflowDefinition",
|
|
374
376
|
"NoopMetrics",
|
|
375
377
|
"QueryFailed",
|
|
@@ -31,6 +31,30 @@ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
|
|
|
31
31
|
"""Workflow authoring uses an unsupported entry point or result."""
|
|
32
32
|
|
|
33
33
|
|
|
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
|
+
|
|
34
58
|
class DurableOperationCancelled(DurableWorkflowError):
|
|
35
59
|
"""A durable selection member was explicitly cancelled."""
|
|
36
60
|
|
|
@@ -39,7 +39,7 @@ from pathlib import Path
|
|
|
39
39
|
from typing import Any
|
|
40
40
|
|
|
41
41
|
from . import serializer
|
|
42
|
-
from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
|
|
42
|
+
from .errors import DuplicateRegistrationError, WorkflowCancelled, WorkflowFailed, WorkflowTerminated
|
|
43
43
|
from .workflow import (
|
|
44
44
|
CompleteWorkflow,
|
|
45
45
|
ContinueAsNew,
|
|
@@ -52,6 +52,7 @@ from .workflow import (
|
|
|
52
52
|
StartTimer,
|
|
53
53
|
UpsertSearchAttributes,
|
|
54
54
|
_validate_workflow_entrypoint,
|
|
55
|
+
_workflow_handler_registries,
|
|
55
56
|
registry,
|
|
56
57
|
replay,
|
|
57
58
|
)
|
|
@@ -136,6 +137,9 @@ class WorkflowEnvironment:
|
|
|
136
137
|
"decorate it with @workflow.defn(name=...) first."
|
|
137
138
|
)
|
|
138
139
|
_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)
|
|
139
143
|
self._registered_workflows[name] = workflow_cls
|
|
140
144
|
|
|
141
145
|
def signal(
|
|
@@ -66,9 +66,11 @@ from .errors import (
|
|
|
66
66
|
ActivityCancelled,
|
|
67
67
|
ActivityFailed,
|
|
68
68
|
AvroNotInstalledError,
|
|
69
|
+
DuplicateRegistrationError,
|
|
69
70
|
DurableWorkflowError,
|
|
70
71
|
ExternalPayloadError,
|
|
71
72
|
InvalidArgument,
|
|
73
|
+
NonDeterministicReplayError,
|
|
72
74
|
NonRetryableError,
|
|
73
75
|
QueryFailed,
|
|
74
76
|
ServerError,
|
|
@@ -105,6 +107,7 @@ from .workflow import (
|
|
|
105
107
|
StartChildWorkflow,
|
|
106
108
|
UpsertMemo,
|
|
107
109
|
_validate_workflow_entrypoint,
|
|
110
|
+
_workflow_handler_registries,
|
|
108
111
|
apply_update,
|
|
109
112
|
commands_to_server_commands,
|
|
110
113
|
query_state,
|
|
@@ -1027,7 +1030,11 @@ class Worker:
|
|
|
1027
1030
|
self.workflows: dict[str, type] = {}
|
|
1028
1031
|
for definition in workflows:
|
|
1029
1032
|
_validate_workflow_entrypoint(definition)
|
|
1030
|
-
|
|
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
|
|
1031
1038
|
self.workflow_definition_fingerprints = {
|
|
1032
1039
|
workflow_type: _workflow_definition_fingerprint(workflow_cls)
|
|
1033
1040
|
for workflow_type, workflow_cls in self.workflows.items()
|
|
@@ -1036,7 +1043,12 @@ class Worker:
|
|
|
1036
1043
|
workflow_type: _workflow_command_contract(workflow_cls)
|
|
1037
1044
|
for workflow_type, workflow_cls in self.workflows.items()
|
|
1038
1045
|
}
|
|
1039
|
-
self.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
|
|
1040
1052
|
self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
|
|
1041
1053
|
self._sticky_cache = StickyWorkflowCache(
|
|
1042
1054
|
sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
|
|
@@ -2502,20 +2514,35 @@ class Worker:
|
|
|
2502
2514
|
|
|
2503
2515
|
update_id = task.get("workflow_update_id")
|
|
2504
2516
|
if isinstance(update_id, str) and update_id:
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2517
|
+
try:
|
|
2518
|
+
update_command = apply_update(
|
|
2519
|
+
cls,
|
|
2520
|
+
history,
|
|
2521
|
+
start_input,
|
|
2522
|
+
update_id,
|
|
2523
|
+
workflow_id=task.get("workflow_id"),
|
|
2524
|
+
run_id=run_id,
|
|
2525
|
+
payload_codec=codec,
|
|
2526
|
+
external_storage=self.external_storage,
|
|
2527
|
+
external_storage_cache=self.external_storage_cache,
|
|
2528
|
+
prepare_local_activities=self._prepared_local_activities_supported,
|
|
2529
|
+
prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
|
|
2530
|
+
local_activity_cancellation_policies=self._local_activity_cancellation_policies,
|
|
2531
|
+
)
|
|
2532
|
+
except NonDeterministicReplayError as exc:
|
|
2533
|
+
log.exception("workflow update task %s replay failed for run %s", task_id, run_id)
|
|
2534
|
+
try:
|
|
2535
|
+
await self.client.fail_workflow_task(
|
|
2536
|
+
task_id=task_id,
|
|
2537
|
+
lease_owner=self.worker_id,
|
|
2538
|
+
workflow_task_attempt=attempt,
|
|
2539
|
+
message=f"workflow replay failed before update {update_id!r}: {exc}",
|
|
2540
|
+
failure_type=type(exc).__name__,
|
|
2541
|
+
stack_trace=traceback.format_exc(),
|
|
2542
|
+
)
|
|
2543
|
+
except Exception as failure_error:
|
|
2544
|
+
log.warning("failed to report update replay failure: %s", failure_error)
|
|
2545
|
+
return None
|
|
2519
2546
|
command = update_command.to_server_command(
|
|
2520
2547
|
self.task_queue,
|
|
2521
2548
|
payload_codec=command_codec,
|
|
@@ -58,6 +58,7 @@ from .errors import (
|
|
|
58
58
|
ChildWorkflowCancelled,
|
|
59
59
|
ChildWorkflowFailed,
|
|
60
60
|
ChildWorkflowTerminated,
|
|
61
|
+
DuplicateRegistrationError,
|
|
61
62
|
DurableOperationCancelled,
|
|
62
63
|
InvalidWorkflowDefinition,
|
|
63
64
|
NexusOperationFailed,
|
|
@@ -124,6 +125,29 @@ def _validate_workflow_result(value: Any, cls: type) -> None:
|
|
|
124
125
|
)
|
|
125
126
|
|
|
126
127
|
|
|
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
|
+
|
|
127
151
|
def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
|
|
128
152
|
"""Register a class as a workflow type under a language-neutral name.
|
|
129
153
|
|
|
@@ -136,28 +160,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
|
|
|
136
160
|
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
|
|
137
161
|
cls.__workflow_name__ = name # type: ignore[attr-defined]
|
|
138
162
|
_validate_workflow_entrypoint(cls)
|
|
139
|
-
signals
|
|
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)
|
|
161
164
|
cls.__workflow_signals__ = signals # type: ignore[attr-defined]
|
|
162
165
|
cls.__workflow_queries__ = queries # type: ignore[attr-defined]
|
|
163
166
|
cls.__workflow_updates__ = updates # type: ignore[attr-defined]
|
|
@@ -2578,9 +2581,12 @@ class Replayer:
|
|
|
2578
2581
|
def __init__(self, *, workflows: Iterable[type]) -> None:
|
|
2579
2582
|
self._workflows: dict[str, type] = {}
|
|
2580
2583
|
for workflow_cls in workflows:
|
|
2584
|
+
_workflow_handler_registries(workflow_cls)
|
|
2581
2585
|
workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
|
|
2582
2586
|
if workflow_type in self._workflows:
|
|
2583
|
-
raise
|
|
2587
|
+
raise DuplicateRegistrationError(
|
|
2588
|
+
"workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
|
|
2589
|
+
)
|
|
2584
2590
|
self._workflows[workflow_type] = workflow_cls
|
|
2585
2591
|
if not self._workflows:
|
|
2586
2592
|
raise ValueError("Replayer requires at least one workflow class")
|
|
@@ -3067,6 +3073,8 @@ def apply_update(
|
|
|
3067
3073
|
prepare_local_activity_groups=prepare_local_activity_groups,
|
|
3068
3074
|
local_activity_cancellation_policies=local_activity_cancellation_policies,
|
|
3069
3075
|
)
|
|
3076
|
+
except NonDeterministicReplayError:
|
|
3077
|
+
raise
|
|
3070
3078
|
except Exception as exc:
|
|
3071
3079
|
return _fail_update_from_exception(
|
|
3072
3080
|
update_id,
|
|
@@ -5144,16 +5152,20 @@ def _replay_state(
|
|
|
5144
5152
|
for _ in range(boundary):
|
|
5145
5153
|
_apply_receiver(pending_receivers.pop(0))
|
|
5146
5154
|
|
|
5147
|
-
def _condition_wait_mismatch(
|
|
5155
|
+
def _condition_wait_mismatch(
|
|
5156
|
+
opened: Mapping[str, Any], cmd: WaitCondition,
|
|
5157
|
+
) -> NonDeterministicReplayError | None:
|
|
5158
|
+
step = recorded_wait_steps[wait_yield_count]
|
|
5159
|
+
identity = f"condition wait {opened.get('condition_wait_id')!r}"
|
|
5148
5160
|
opened_key = opened.get("condition_key")
|
|
5149
5161
|
if isinstance(opened_key, str) and opened_key != (cmd.condition_key or ""):
|
|
5150
|
-
return
|
|
5151
|
-
|
|
5152
|
-
|
|
5162
|
+
return NonDeterministicReplayError(
|
|
5163
|
+
step.workflow_sequence, "condition wait", step.event_types,
|
|
5164
|
+
detail=(
|
|
5165
|
+
f"condition_key_changed: {identity} wait_condition key changed during replay: "
|
|
5153
5166
|
f"history has {opened_key!r}, workflow yielded "
|
|
5154
5167
|
f"{cmd.condition_key!r}"
|
|
5155
5168
|
),
|
|
5156
|
-
exception_type="NonDeterministicWaitCondition",
|
|
5157
5169
|
)
|
|
5158
5170
|
|
|
5159
5171
|
opened_fingerprint = opened.get("condition_definition_fingerprint")
|
|
@@ -5161,13 +5173,14 @@ def _replay_state(
|
|
|
5161
5173
|
isinstance(opened_fingerprint, str)
|
|
5162
5174
|
and cmd.condition_definition_fingerprint != opened_fingerprint
|
|
5163
5175
|
):
|
|
5164
|
-
return
|
|
5165
|
-
|
|
5176
|
+
return NonDeterministicReplayError(
|
|
5177
|
+
step.workflow_sequence, "condition wait", step.event_types,
|
|
5178
|
+
detail=(
|
|
5179
|
+
f"condition_fingerprint_changed: {identity} "
|
|
5166
5180
|
"wait_condition predicate fingerprint changed during replay: "
|
|
5167
5181
|
f"history has {opened_fingerprint!r}, workflow yielded "
|
|
5168
5182
|
f"{cmd.condition_definition_fingerprint!r}"
|
|
5169
5183
|
),
|
|
5170
|
-
exception_type="NonDeterministicWaitCondition",
|
|
5171
5184
|
)
|
|
5172
5185
|
|
|
5173
5186
|
return None
|
|
@@ -6655,7 +6668,7 @@ def _replay_state(
|
|
|
6655
6668
|
next_value = True
|
|
6656
6669
|
continue
|
|
6657
6670
|
if next_opened_mismatch is not None:
|
|
6658
|
-
|
|
6671
|
+
raise next_opened_mismatch
|
|
6659
6672
|
step = _next_unconsumed_recorded_step()
|
|
6660
6673
|
if step is not None:
|
|
6661
6674
|
_assert_step_matches(cmd, step)
|
|
@@ -6707,7 +6720,7 @@ def _replay_state(
|
|
|
6707
6720
|
opened = wait_opened[wait_yield_count]
|
|
6708
6721
|
mismatch = _condition_wait_mismatch(opened, cmd)
|
|
6709
6722
|
if mismatch is not None:
|
|
6710
|
-
|
|
6723
|
+
raise mismatch
|
|
6711
6724
|
opened_id = opened.get("condition_wait_id")
|
|
6712
6725
|
if isinstance(opened_id, str):
|
|
6713
6726
|
resolution = wait_resolutions.get(opened_id)
|
|
@@ -62,6 +62,7 @@ tests/test_committed_cancellation_scope_descendants.py
|
|
|
62
62
|
tests/test_committed_cancellation_scope_groups.py
|
|
63
63
|
tests/test_committed_cancellation_scope_history.py
|
|
64
64
|
tests/test_committed_cancellation_scope_replay.py
|
|
65
|
+
tests/test_condition_replay_drift.py
|
|
65
66
|
tests/test_control_plane_parity_fixtures.py
|
|
66
67
|
tests/test_cooperative_cancellation.py
|
|
67
68
|
tests/test_cooperative_cancellation_client.py
|
|
@@ -94,6 +95,7 @@ tests/test_python_conformance.py
|
|
|
94
95
|
tests/test_queries.py
|
|
95
96
|
tests/test_readme_quickstart.py
|
|
96
97
|
tests/test_redrive_replay.py
|
|
98
|
+
tests/test_registration_ambiguity.py
|
|
97
99
|
tests/test_release_metadata.py
|
|
98
100
|
tests/test_replay.py
|
|
99
101
|
tests/test_replay_conformance.py
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from durable_workflow import Worker, serializer, workflow
|
|
8
|
+
from durable_workflow.client import Client
|
|
9
|
+
from durable_workflow.errors import NonDeterministicReplayError, QueryFailed
|
|
10
|
+
from durable_workflow.workflow import WorkflowContext, apply_update, query_state, replay
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@workflow.defn(name="tests.replay.condition-identity")
|
|
14
|
+
class ConditionIdentityWorkflow:
|
|
15
|
+
@workflow.query("state")
|
|
16
|
+
def state(self) -> str:
|
|
17
|
+
return "waiting"
|
|
18
|
+
|
|
19
|
+
@workflow.update("approve")
|
|
20
|
+
def approve(self) -> str:
|
|
21
|
+
return "approved"
|
|
22
|
+
|
|
23
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
24
|
+
yield ctx.wait_condition(lambda: False, key="approved")
|
|
25
|
+
return "done"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def condition_history(field: str) -> list[dict]:
|
|
29
|
+
return [{
|
|
30
|
+
"event_type": "ConditionWaitOpened",
|
|
31
|
+
"payload": {
|
|
32
|
+
"sequence": 7,
|
|
33
|
+
"condition_wait_id": "wait-original",
|
|
34
|
+
"condition_key": "approved",
|
|
35
|
+
field: "old-key" if field == "condition_key" else "sha256:different",
|
|
36
|
+
},
|
|
37
|
+
}]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
|
|
41
|
+
@pytest.mark.parametrize("consumer", ["replay", "query", "update"])
|
|
42
|
+
def test_condition_drift_is_not_a_business_failure(field: str, consumer: str) -> None:
|
|
43
|
+
history = condition_history(field)
|
|
44
|
+
error = QueryFailed if consumer == "query" else NonDeterministicReplayError
|
|
45
|
+
with pytest.raises(error) as captured:
|
|
46
|
+
if consumer == "query":
|
|
47
|
+
query_state(ConditionIdentityWorkflow, history, [], "state")
|
|
48
|
+
elif consumer == "update":
|
|
49
|
+
apply_update(ConditionIdentityWorkflow, history, [], "update-original")
|
|
50
|
+
else:
|
|
51
|
+
replay(ConditionIdentityWorkflow, history, [])
|
|
52
|
+
|
|
53
|
+
mismatch = captured.value.__cause__ if consumer == "query" else captured.value
|
|
54
|
+
assert isinstance(mismatch, NonDeterministicReplayError)
|
|
55
|
+
assert mismatch.workflow_sequence == 7
|
|
56
|
+
assert "wait-original" in str(mismatch)
|
|
57
|
+
assert "changed" in str(mismatch)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
@pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
|
|
62
|
+
@pytest.mark.parametrize("consumer", ["workflow", "update"])
|
|
63
|
+
async def test_worker_reports_drift_once_without_a_completion(field: str, consumer: str) -> None:
|
|
64
|
+
client = AsyncMock(spec=Client)
|
|
65
|
+
worker = Worker(client, task_queue="condition-drift", workflows=[ConditionIdentityWorkflow])
|
|
66
|
+
task = {
|
|
67
|
+
"task_id": "task-original",
|
|
68
|
+
"workflow_id": "workflow-original",
|
|
69
|
+
"run_id": "run-original",
|
|
70
|
+
"workflow_type": "tests.replay.condition-identity",
|
|
71
|
+
"workflow_task_attempt": 2,
|
|
72
|
+
"history_events": condition_history(field),
|
|
73
|
+
"arguments": serializer.envelope([], codec="avro"),
|
|
74
|
+
"payload_codec": "avro",
|
|
75
|
+
}
|
|
76
|
+
if consumer == "update":
|
|
77
|
+
task["workflow_update_id"] = "update-original"
|
|
78
|
+
|
|
79
|
+
assert await worker._run_workflow_task_core(task) is None
|
|
80
|
+
|
|
81
|
+
client.complete_workflow_task.assert_not_awaited()
|
|
82
|
+
client.fail_workflow_task.assert_awaited_once()
|
|
83
|
+
report = client.fail_workflow_task.await_args.kwargs
|
|
84
|
+
assert report["task_id"] == "task-original"
|
|
85
|
+
assert report["workflow_task_attempt"] == 2
|
|
86
|
+
assert report["lease_owner"] == worker.worker_id
|
|
87
|
+
assert report["failure_type"] == "NonDeterministicReplayError"
|
|
88
|
+
assert "wait-original" in report["message"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_application_predicate_failure_remains_terminal() -> None:
|
|
92
|
+
@workflow.defn(name="tests.condition-predicate-failure")
|
|
93
|
+
class PredicateFailure:
|
|
94
|
+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
|
|
95
|
+
def predicate() -> bool:
|
|
96
|
+
raise ValueError("application failed")
|
|
97
|
+
|
|
98
|
+
yield ctx.wait_condition(predicate, key="approved")
|
|
99
|
+
|
|
100
|
+
outcome = replay(PredicateFailure, [], [])
|
|
101
|
+
assert len(outcome.commands) == 1
|
|
102
|
+
command = outcome.commands[0].to_server_command("condition-drift")
|
|
103
|
+
assert command["type"] == "fail_workflow"
|
|
104
|
+
assert command["exception_type"] == "ValueError"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
@pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
|
|
109
|
+
async def test_query_worker_reports_unavailable_state_without_changing_the_run(field: str) -> None:
|
|
110
|
+
client = AsyncMock(spec=Client)
|
|
111
|
+
worker = Worker(client, task_queue="condition-drift", workflows=[ConditionIdentityWorkflow])
|
|
112
|
+
outcome = await worker._run_query_task({
|
|
113
|
+
"query_task_id": "query-original",
|
|
114
|
+
"query_task_attempt": 2,
|
|
115
|
+
"workflow_id": "workflow-original",
|
|
116
|
+
"run_id": "run-original",
|
|
117
|
+
"workflow_type": "tests.replay.condition-identity",
|
|
118
|
+
"query_name": "state",
|
|
119
|
+
"history_events": condition_history(field),
|
|
120
|
+
"workflow_arguments": serializer.envelope([], codec="avro"),
|
|
121
|
+
"query_arguments": serializer.envelope([], codec="avro"),
|
|
122
|
+
"payload_codec": "avro",
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
assert outcome == "failed"
|
|
126
|
+
client.complete_query_task.assert_not_awaited()
|
|
127
|
+
client.complete_workflow_task.assert_not_awaited()
|
|
128
|
+
client.fail_workflow_task.assert_not_awaited()
|
|
129
|
+
client.fail_query_task.assert_awaited_once()
|
|
130
|
+
report = client.fail_query_task.await_args.kwargs
|
|
131
|
+
assert report["reason"] == "query_workflow_state_unavailable"
|
|
132
|
+
assert "wait-original" in report["message"]
|