durable-workflow 2.5.0__tar.gz → 2.5.2__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.0/src/durable_workflow.egg-info → durable_workflow-2.5.2}/PKG-INFO +1 -1
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/pyproject.toml +4 -4
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/errors.py +4 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/testing.py +2 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/worker.py +5 -1
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/workflow.py +127 -14
- {durable_workflow-2.5.0 → durable_workflow-2.5.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_remote_worker.py +10 -3
- durable_workflow-2.5.2/tests/test_patch_history.py +145 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay_regression_corpus.py +12 -0
- durable_workflow-2.5.2/tests/test_workflow_entrypoint_validation.py +116 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/LICENSE +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/README.md +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/setup.cfg +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_client.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_worker.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.0 → durable_workflow-2.5.2}/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.2"
|
|
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,9 +71,9 @@ 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.
|
|
76
|
-
supported-server-versions = "2.5.
|
|
74
|
+
product-train = "2.5.2"
|
|
75
|
+
registry-version = "2.5.2"
|
|
76
|
+
supported-server-versions = "2.5.16"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -81,6 +81,7 @@ from .errors import (
|
|
|
81
81
|
ExternalPayloadUnavailable,
|
|
82
82
|
ExternalPayloadUnsupported,
|
|
83
83
|
InvalidArgument,
|
|
84
|
+
InvalidWorkflowDefinition,
|
|
84
85
|
NamespaceNotFound,
|
|
85
86
|
NexusOperationFailed,
|
|
86
87
|
NonDeterministicReplayError,
|
|
@@ -369,6 +370,7 @@ __all__ = [
|
|
|
369
370
|
"NexusOperationResult",
|
|
370
371
|
"NexusServiceCall",
|
|
371
372
|
"NonDeterministicReplayError",
|
|
373
|
+
"InvalidWorkflowDefinition",
|
|
372
374
|
"NoopMetrics",
|
|
373
375
|
"QueryFailed",
|
|
374
376
|
"RuntimeCapabilityUnsupported",
|
|
@@ -27,6 +27,10 @@ class DurableWorkflowError(Exception):
|
|
|
27
27
|
"""Base class for every exception raised by the SDK."""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
|
|
31
|
+
"""Workflow authoring uses an unsupported entry point or result."""
|
|
32
|
+
|
|
33
|
+
|
|
30
34
|
class DurableOperationCancelled(DurableWorkflowError):
|
|
31
35
|
"""A durable selection member was explicitly cancelled."""
|
|
32
36
|
|
|
@@ -51,6 +51,7 @@ from .workflow import (
|
|
|
51
51
|
StartChildWorkflow,
|
|
52
52
|
StartTimer,
|
|
53
53
|
UpsertSearchAttributes,
|
|
54
|
+
_validate_workflow_entrypoint,
|
|
54
55
|
registry,
|
|
55
56
|
replay,
|
|
56
57
|
)
|
|
@@ -134,6 +135,7 @@ class WorkflowEnvironment:
|
|
|
134
135
|
f"{workflow_cls!r} is not a workflow class; "
|
|
135
136
|
"decorate it with @workflow.defn(name=...) first."
|
|
136
137
|
)
|
|
138
|
+
_validate_workflow_entrypoint(workflow_cls)
|
|
137
139
|
self._registered_workflows[name] = workflow_cls
|
|
138
140
|
|
|
139
141
|
def signal(
|
|
@@ -104,6 +104,7 @@ from .workflow import (
|
|
|
104
104
|
ScheduleActivity,
|
|
105
105
|
StartChildWorkflow,
|
|
106
106
|
UpsertMemo,
|
|
107
|
+
_validate_workflow_entrypoint,
|
|
107
108
|
apply_update,
|
|
108
109
|
commands_to_server_commands,
|
|
109
110
|
query_state,
|
|
@@ -1023,7 +1024,10 @@ class Worker:
|
|
|
1023
1024
|
) -> None:
|
|
1024
1025
|
self.client = client
|
|
1025
1026
|
self.task_queue = task_queue
|
|
1026
|
-
self.workflows
|
|
1027
|
+
self.workflows: dict[str, type] = {}
|
|
1028
|
+
for definition in workflows:
|
|
1029
|
+
_validate_workflow_entrypoint(definition)
|
|
1030
|
+
self.workflows[_workflow_name(definition)] = definition
|
|
1027
1031
|
self.workflow_definition_fingerprints = {
|
|
1028
1032
|
workflow_type: _workflow_definition_fingerprint(workflow_cls)
|
|
1029
1033
|
for workflow_type, workflow_cls in self.workflows.items()
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import base64
|
|
22
22
|
import contextlib
|
|
23
23
|
import hashlib
|
|
24
|
+
import inspect
|
|
24
25
|
import logging
|
|
25
26
|
import math
|
|
26
27
|
import random
|
|
@@ -58,6 +59,7 @@ from .errors import (
|
|
|
58
59
|
ChildWorkflowFailed,
|
|
59
60
|
ChildWorkflowTerminated,
|
|
60
61
|
DurableOperationCancelled,
|
|
62
|
+
InvalidWorkflowDefinition,
|
|
61
63
|
NexusOperationFailed,
|
|
62
64
|
NonDeterministicReplayError,
|
|
63
65
|
QueryFailed,
|
|
@@ -97,6 +99,31 @@ MESSAGE_STREAM_CURSOR_SCHEMA = "durable-workflow.v2.message-stream.cursor"
|
|
|
97
99
|
MESSAGE_STREAM_MAX_BATCH = 100
|
|
98
100
|
|
|
99
101
|
|
|
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
|
+
|
|
100
127
|
def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
|
|
101
128
|
"""Register a class as a workflow type under a language-neutral name.
|
|
102
129
|
|
|
@@ -108,6 +135,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
|
|
|
108
135
|
|
|
109
136
|
def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
|
|
110
137
|
cls.__workflow_name__ = name # type: ignore[attr-defined]
|
|
138
|
+
_validate_workflow_entrypoint(cls)
|
|
111
139
|
signals: dict[str, str] = {}
|
|
112
140
|
queries: dict[str, str] = {}
|
|
113
141
|
updates: dict[str, str] = {}
|
|
@@ -4037,6 +4065,7 @@ def _replay_state(
|
|
|
4037
4065
|
allow_cancellation_scope_authoring: bool = False,
|
|
4038
4066
|
allow_cancellation_scope_delivery: bool = False,
|
|
4039
4067
|
) -> _ReplayState:
|
|
4068
|
+
_validate_workflow_entrypoint(workflow_cls)
|
|
4040
4069
|
if allow_cancellation_scope_delivery and not allow_cancellation_scope_authoring:
|
|
4041
4070
|
raise LocalActivityExecutionAborted("scope delivery requires canonical scope authoring")
|
|
4042
4071
|
if prepare_local_activity_groups and not prepare_local_activities:
|
|
@@ -5172,6 +5201,7 @@ def _replay_state(
|
|
|
5172
5201
|
except Exception as exc:
|
|
5173
5202
|
return _state([_fail_workflow_from_exception(exc)])
|
|
5174
5203
|
if not hasattr(gen, "__next__"):
|
|
5204
|
+
_validate_workflow_result(gen, workflow_cls)
|
|
5175
5205
|
if isinstance(gen, ContinueAsNew):
|
|
5176
5206
|
_assert_no_unconsumed_history("continue as new")
|
|
5177
5207
|
return _state([gen])
|
|
@@ -5185,6 +5215,96 @@ def _replay_state(
|
|
|
5185
5215
|
pending: list[Command] = []
|
|
5186
5216
|
advanced_cmd: Any = None
|
|
5187
5217
|
terminal_condition_reopen_cmd: WaitCondition | None = None
|
|
5218
|
+
version_decisions: dict[str, tuple[int, str]] = {}
|
|
5219
|
+
|
|
5220
|
+
def _version_error(detail: str, step: _RecordedStep | None = None) -> NonDeterministicReplayError:
|
|
5221
|
+
return NonDeterministicReplayError(
|
|
5222
|
+
step.workflow_sequence if step is not None else authored_sequence,
|
|
5223
|
+
"compatible version marker",
|
|
5224
|
+
step.event_types if step is not None else [],
|
|
5225
|
+
detail=detail,
|
|
5226
|
+
)
|
|
5227
|
+
|
|
5228
|
+
def _recorded_version(step: _RecordedStep) -> int:
|
|
5229
|
+
if step.history_index is None:
|
|
5230
|
+
raise _version_error("Version marker is missing its history position.", step)
|
|
5231
|
+
payload = events[step.history_index].get("payload") or {}
|
|
5232
|
+
version = payload.get("version")
|
|
5233
|
+
if type(version) is not int:
|
|
5234
|
+
raise _version_error("Version marker must contain an integer version.", step)
|
|
5235
|
+
change_id = payload.get("change_id")
|
|
5236
|
+
if "change_id" in payload and (not isinstance(change_id, str) or not change_id.strip()):
|
|
5237
|
+
raise _version_error("Version marker must retain a non-empty change ID.", step)
|
|
5238
|
+
if "min_supported" in payload or "max_supported" in payload:
|
|
5239
|
+
minimum, maximum = payload.get("min_supported"), payload.get("max_supported")
|
|
5240
|
+
if (type(minimum) is not int or type(maximum) is not int
|
|
5241
|
+
or not minimum <= version <= maximum):
|
|
5242
|
+
raise _version_error("Recorded version marker has an invalid supported range.", step)
|
|
5243
|
+
return version
|
|
5244
|
+
|
|
5245
|
+
def _consume_duplicate_version_markers() -> None:
|
|
5246
|
+
nonlocal result_cursor, authored_sequence
|
|
5247
|
+
if not version_decisions:
|
|
5248
|
+
return
|
|
5249
|
+
while result_cursor < len(recorded_steps):
|
|
5250
|
+
step = recorded_steps[result_cursor]
|
|
5251
|
+
change_id = step.details.get("change_id")
|
|
5252
|
+
if step.shape != "version marker" or change_id not in version_decisions:
|
|
5253
|
+
return
|
|
5254
|
+
if _next_unconsumed_recorded_step() is not step:
|
|
5255
|
+
return
|
|
5256
|
+
version = _recorded_version(step)
|
|
5257
|
+
if version != version_decisions[change_id][0]:
|
|
5258
|
+
raise _version_error(f"Conflicting recorded decisions for change ID {change_id!r}.", step)
|
|
5259
|
+
# Earlier SDKs emitted a marker for every invocation. Consume these
|
|
5260
|
+
# aliases while retaining their physical positions for cancellation
|
|
5261
|
+
# and prepared operations. The stored history itself is unchanged.
|
|
5262
|
+
result_cursor += 1
|
|
5263
|
+
authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
|
|
5264
|
+
|
|
5265
|
+
def _resolve_version(command: RecordVersionMarker) -> Any:
|
|
5266
|
+
nonlocal result_cursor, authored_sequence
|
|
5267
|
+
change_id = command.change_id
|
|
5268
|
+
if not isinstance(change_id, str) or not change_id.strip():
|
|
5269
|
+
raise _version_error("Version markers require a stable non-empty change ID.")
|
|
5270
|
+
if (type(command.min_supported) is not int or type(command.max_supported) is not int
|
|
5271
|
+
or command.min_supported > command.max_supported):
|
|
5272
|
+
raise _version_error(f"Invalid supported range for change ID {change_id!r}.")
|
|
5273
|
+
family = "version" if command.result_kind == "version" else "patch"
|
|
5274
|
+
decision = version_decisions.get(change_id)
|
|
5275
|
+
step = _next_unconsumed_recorded_step()
|
|
5276
|
+
if decision is not None:
|
|
5277
|
+
version, original_family = decision
|
|
5278
|
+
if original_family != family:
|
|
5279
|
+
raise _version_error(f"Change ID {change_id!r} cannot switch between version and patch helpers.", step)
|
|
5280
|
+
elif step is not None and step.shape == "version marker":
|
|
5281
|
+
_assert_step_matches(command, step)
|
|
5282
|
+
version = _recorded_version(step)
|
|
5283
|
+
result_cursor += 1
|
|
5284
|
+
authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
|
|
5285
|
+
elif step is not None or (cancellation.delivery is not None and not cancellation_consumed) or any(
|
|
5286
|
+
sequence not in consumed_scope_deliveries for sequence in committed_scopes.deliveries
|
|
5287
|
+
) or any(_history_event_type(event) in {
|
|
5288
|
+
"WorkflowCompleted", "WorkflowFailed", "WorkflowCancelled", "WorkflowTerminated",
|
|
5289
|
+
"WorkflowContinuedAsNew", "WorkflowTimedOut",
|
|
5290
|
+
} for event in events):
|
|
5291
|
+
# An unmarked recorded command proves this history passed the new
|
|
5292
|
+
# patch site. Leave that command available for its original call.
|
|
5293
|
+
version = -1
|
|
5294
|
+
else:
|
|
5295
|
+
version = command.version
|
|
5296
|
+
if type(version) is not int:
|
|
5297
|
+
raise _version_error(f"Invalid version for change ID {change_id!r}.")
|
|
5298
|
+
ctx.logger._set_replaying(False)
|
|
5299
|
+
pending.append(command)
|
|
5300
|
+
authored_sequence += 1
|
|
5301
|
+
if not command.min_supported <= version <= command.max_supported:
|
|
5302
|
+
raise _version_error(
|
|
5303
|
+
f"Recorded version {version} for change ID {change_id!r} is outside "
|
|
5304
|
+
f"the supported range {command.min_supported}..{command.max_supported}.", step,
|
|
5305
|
+
)
|
|
5306
|
+
version_decisions.setdefault(change_id, (version, family))
|
|
5307
|
+
return _version_marker_result(command, version)
|
|
5188
5308
|
|
|
5189
5309
|
def _parallel_leaf_kind(command: Any) -> str:
|
|
5190
5310
|
if isinstance(command, ScheduleActivity | RecordLocalActivity):
|
|
@@ -5942,6 +6062,7 @@ def _replay_state(
|
|
|
5942
6062
|
return next(iter(memberships))
|
|
5943
6063
|
|
|
5944
6064
|
def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
|
|
6065
|
+
_validate_workflow_result(value, workflow_cls)
|
|
5945
6066
|
_apply_due_receivers()
|
|
5946
6067
|
_consume_terminal_condition_reopens()
|
|
5947
6068
|
commands = list(pending) if include_pending else []
|
|
@@ -6064,6 +6185,7 @@ def _replay_state(
|
|
|
6064
6185
|
replay_token = _ACTIVE_WORKFLOW_REPLAY.set(ctx)
|
|
6065
6186
|
try:
|
|
6066
6187
|
while True:
|
|
6188
|
+
_consume_duplicate_version_markers()
|
|
6067
6189
|
# Cursor-0 receivers are start-boundary events. Enter run() once
|
|
6068
6190
|
# before applying them so workflow initialization observes the
|
|
6069
6191
|
# same WorkflowStarted-before-Signal/Update ordering the server
|
|
@@ -6081,6 +6203,9 @@ def _replay_state(
|
|
|
6081
6203
|
first = False
|
|
6082
6204
|
_apply_due_receivers()
|
|
6083
6205
|
_validate_local_activity_policies(cmd)
|
|
6206
|
+
if isinstance(cmd, RecordVersionMarker):
|
|
6207
|
+
next_value = _resolve_version(cmd)
|
|
6208
|
+
continue
|
|
6084
6209
|
if prepare_local_activities and isinstance(cmd, list | SelectGroup) and _contains_local_activity(cmd) and (
|
|
6085
6210
|
not prepare_local_activity_groups or isinstance(cmd, SelectGroup)
|
|
6086
6211
|
):
|
|
@@ -6497,18 +6622,6 @@ def _replay_state(
|
|
|
6497
6622
|
pending.append(cmd)
|
|
6498
6623
|
next_value = None
|
|
6499
6624
|
continue
|
|
6500
|
-
if isinstance(cmd, RecordVersionMarker):
|
|
6501
|
-
if result_cursor < len(resolved_results):
|
|
6502
|
-
_assert_next_step_matches(cmd)
|
|
6503
|
-
val = resolved_results[result_cursor]
|
|
6504
|
-
result_cursor += 1
|
|
6505
|
-
next_value = _version_marker_result(cmd, val)
|
|
6506
|
-
continue
|
|
6507
|
-
ctx.logger._set_replaying(False)
|
|
6508
|
-
_assert_pending_step_matches(cmd)
|
|
6509
|
-
pending.append(cmd)
|
|
6510
|
-
next_value = _version_marker_result(cmd, cmd.version)
|
|
6511
|
-
continue
|
|
6512
6625
|
if isinstance(cmd, WaitCondition):
|
|
6513
6626
|
# A condition that is already true is not persisted. Apply
|
|
6514
6627
|
# receivers at the yield boundary before deciding whether the
|
|
@@ -6702,10 +6815,10 @@ def _replay_state(
|
|
|
6702
6815
|
try:
|
|
6703
6816
|
return _terminal_state(stop.value, include_pending=True)
|
|
6704
6817
|
except Exception as exc:
|
|
6705
|
-
if isinstance(exc, NonDeterministicReplayError):
|
|
6818
|
+
if isinstance(exc, (NonDeterministicReplayError, InvalidWorkflowDefinition)):
|
|
6706
6819
|
raise
|
|
6707
6820
|
return _state(pending + [_fail_workflow_from_exception(exc)])
|
|
6708
|
-
except NonDeterministicReplayError:
|
|
6821
|
+
except (NonDeterministicReplayError, InvalidWorkflowDefinition):
|
|
6709
6822
|
raise
|
|
6710
6823
|
except LocalActivityExecutionAborted:
|
|
6711
6824
|
raise
|
|
@@ -82,6 +82,7 @@ tests/test_metrics.py
|
|
|
82
82
|
tests/test_order_processing_example.py
|
|
83
83
|
tests/test_parallel_completion_order_replay.py
|
|
84
84
|
tests/test_parallel_saga_authoring.py
|
|
85
|
+
tests/test_patch_history.py
|
|
85
86
|
tests/test_pending_operation_replay.py
|
|
86
87
|
tests/test_prepared_local_activity_client.py
|
|
87
88
|
tests/test_prepared_local_activity_groups.py
|
|
@@ -116,6 +117,7 @@ tests/test_updates.py
|
|
|
116
117
|
tests/test_wait_condition.py
|
|
117
118
|
tests/test_worker.py
|
|
118
119
|
tests/test_worker_session.py
|
|
120
|
+
tests/test_workflow_entrypoint_validation.py
|
|
119
121
|
tests/test_workflow_result_timeout.py
|
|
120
122
|
tests/test_workflow_streams.py
|
|
121
123
|
tests/test_workflow_updates_conformance.py
|
|
@@ -47,22 +47,29 @@ def stop_receipt() -> dict[str, Any]:
|
|
|
47
47
|
"heartbeat_recorded": False, "history_event_id": "receipt-event"}
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def write_pid_marker(marker: str) -> None:
|
|
51
|
+
target = Path(marker)
|
|
52
|
+
pending = target.with_name(target.name + ".pending")
|
|
53
|
+
pending.write_text(str(os.getpid()))
|
|
54
|
+
pending.replace(target)
|
|
55
|
+
|
|
56
|
+
|
|
50
57
|
async def blocked_async(marker: str, heartbeat: bool = False) -> None:
|
|
51
58
|
if heartbeat:
|
|
52
59
|
await activity.context().heartbeat({"authored": True})
|
|
53
|
-
|
|
60
|
+
write_pid_marker(marker)
|
|
54
61
|
await asyncio.Event().wait()
|
|
55
62
|
|
|
56
63
|
|
|
57
64
|
def blocked_sync(marker: str, heartbeat: bool = False) -> None:
|
|
58
65
|
if heartbeat:
|
|
59
66
|
asyncio.run(activity.context().heartbeat({"authored": True}))
|
|
60
|
-
|
|
67
|
+
write_pid_marker(marker)
|
|
61
68
|
time.sleep(60)
|
|
62
69
|
|
|
63
70
|
|
|
64
71
|
def released_sync(marker: str) -> bytes:
|
|
65
|
-
|
|
72
|
+
write_pid_marker(marker)
|
|
66
73
|
while not Path(marker + ".release").exists():
|
|
67
74
|
time.sleep(0.02)
|
|
68
75
|
return b"late result"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from durable_workflow import serializer, workflow
|
|
8
|
+
from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled
|
|
9
|
+
from durable_workflow.workflow import CompleteWorkflow, RecordVersionMarker, ScheduleActivity, replay
|
|
10
|
+
from tests.test_cooperative_cancellation import marker as cancellation_marker
|
|
11
|
+
from tests.test_cooperative_cancellation import request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def version_marker(sequence: int, change_id: str = "same", version: int = 1) -> dict[str, Any]:
|
|
15
|
+
return {"event_type": "VersionMarkerRecorded", "payload": {
|
|
16
|
+
"sequence": sequence, "change_id": change_id, "version": version,
|
|
17
|
+
"min_supported": -1, "max_supported": 1,
|
|
18
|
+
}}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def old_activity(sequence: int, completed: bool = True) -> list[dict[str, Any]]:
|
|
22
|
+
history = [{"event_type": "ActivityScheduled", "payload": {
|
|
23
|
+
"sequence": sequence, "activity_type": "old",
|
|
24
|
+
}}]
|
|
25
|
+
if completed:
|
|
26
|
+
history.append({"event_type": "ActivityCompleted", "payload": {
|
|
27
|
+
"sequence": sequence, "activity_type": "old", "result": serializer.encode("recorded"),
|
|
28
|
+
"payload_codec": "avro",
|
|
29
|
+
}})
|
|
30
|
+
return history
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@workflow.defn(name="tests.patch.insertion")
|
|
34
|
+
class PatchInsertionWorkflow:
|
|
35
|
+
def run(self, ctx): # type: ignore[no-untyped-def]
|
|
36
|
+
if (yield ctx.patched("same")):
|
|
37
|
+
yield ctx.schedule_activity("new", [])
|
|
38
|
+
return (yield ctx.schedule_activity("old", []))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@workflow.defn(name="tests.patch.repeated")
|
|
42
|
+
class RepeatedPatchWorkflow:
|
|
43
|
+
def run(self, ctx, repeat=True): # type: ignore[no-untyped-def]
|
|
44
|
+
first = yield ctx.patched("same")
|
|
45
|
+
second = (yield ctx.patched("same")) if repeat else first
|
|
46
|
+
result = yield ctx.schedule_activity("old", [])
|
|
47
|
+
return [first, second, result]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@workflow.defn(name="tests.patch.interleaved")
|
|
51
|
+
class InterleavedPatchWorkflow:
|
|
52
|
+
def run(self, ctx, repeat=True): # type: ignore[no-untyped-def]
|
|
53
|
+
first = yield ctx.patched("same")
|
|
54
|
+
result = yield ctx.schedule_activity("old", [])
|
|
55
|
+
second = (yield ctx.patched("same")) if repeat else first
|
|
56
|
+
return [first, second, result]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@workflow.defn(name="tests.patch.ranges")
|
|
60
|
+
class VersionRangeWorkflow:
|
|
61
|
+
def run(self, ctx, minimum=1, mixed=False): # type: ignore[no-untyped-def]
|
|
62
|
+
first = yield ctx.get_version("same", 1, 2)
|
|
63
|
+
second = (yield ctx.patched("same")) if mixed else (yield ctx.get_version("same", minimum, 3))
|
|
64
|
+
return [first, second]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@workflow.defn(name="tests.patch.cancellation")
|
|
68
|
+
class PatchCancellationWorkflow:
|
|
69
|
+
def run(self, ctx): # type: ignore[no-untyped-def]
|
|
70
|
+
decisions = [(yield ctx.patched("same")), (yield ctx.patched("same"))]
|
|
71
|
+
try:
|
|
72
|
+
yield ctx.schedule_activity("old", [])
|
|
73
|
+
except WorkflowCancelled:
|
|
74
|
+
return [decisions, "cancelled"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize("completed", [False, True])
|
|
78
|
+
def test_inserted_patch_preserves_unmarked_activity(completed: bool) -> None:
|
|
79
|
+
outcome = replay(PatchInsertionWorkflow, old_activity(1, completed), [])
|
|
80
|
+
if completed:
|
|
81
|
+
assert len(outcome.commands) == 1
|
|
82
|
+
command = outcome.commands[0]
|
|
83
|
+
assert isinstance(command, CompleteWorkflow)
|
|
84
|
+
assert command.result == "recorded"
|
|
85
|
+
else:
|
|
86
|
+
# The old activity is already scheduled. Await it without duplicating
|
|
87
|
+
# that command or introducing the new patched activity.
|
|
88
|
+
assert outcome.commands == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_repeated_patch_emits_one_marker_on_new_history() -> None:
|
|
92
|
+
commands = replay(RepeatedPatchWorkflow, [], []).commands
|
|
93
|
+
assert [type(command) for command in commands] == [RecordVersionMarker, ScheduleActivity]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize("repeat", [False, True])
|
|
97
|
+
@pytest.mark.parametrize("duplicates", [False, True])
|
|
98
|
+
def test_old_consistent_duplicate_markers_remain_replayable(repeat: bool, duplicates: bool) -> None:
|
|
99
|
+
history = [version_marker(1)]
|
|
100
|
+
if duplicates:
|
|
101
|
+
history.append(version_marker(2))
|
|
102
|
+
history += old_activity(3 if duplicates else 2)
|
|
103
|
+
commands = replay(RepeatedPatchWorkflow, history, [repeat]).commands
|
|
104
|
+
assert len(commands) == 1
|
|
105
|
+
assert isinstance(commands[0], CompleteWorkflow)
|
|
106
|
+
assert commands[0].result == [True, True, "recorded"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize("repeat", [False, True])
|
|
110
|
+
def test_duplicate_markers_after_an_activity_remain_replayable(repeat: bool) -> None:
|
|
111
|
+
history = [version_marker(1)] + old_activity(2) + [version_marker(3)]
|
|
112
|
+
commands = replay(InterleavedPatchWorkflow, history, [repeat]).commands
|
|
113
|
+
assert len(commands) == 1
|
|
114
|
+
assert isinstance(commands[0], CompleteWorkflow)
|
|
115
|
+
assert commands[0].result == [True, True, "recorded"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_repeated_version_reuses_first_decision() -> None:
|
|
119
|
+
commands = replay(VersionRangeWorkflow, [], []).commands
|
|
120
|
+
assert [type(command) for command in commands] == [RecordVersionMarker, CompleteWorkflow]
|
|
121
|
+
assert commands[-1].result == [2, 2]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.parametrize("arguments", [[3], [1, True]])
|
|
125
|
+
def test_repeated_version_rejects_incompatible_range_or_family(arguments: list[Any]) -> None:
|
|
126
|
+
with pytest.raises(NonDeterministicReplayError):
|
|
127
|
+
replay(VersionRangeWorkflow, [], arguments)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize("version", [-1, 2, True])
|
|
131
|
+
def test_conflicting_duplicate_marker_is_rejected(version: Any) -> None:
|
|
132
|
+
history = [version_marker(1), version_marker(2, version=version)] + old_activity(3)
|
|
133
|
+
with pytest.raises(NonDeterministicReplayError):
|
|
134
|
+
replay(RepeatedPatchWorkflow, history, [])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.parametrize("marker_count", [0, 1, 2])
|
|
138
|
+
def test_patch_decisions_preserve_committed_cancellation_sequence(marker_count: int) -> None:
|
|
139
|
+
history = [version_marker(sequence) for sequence in range(1, marker_count + 1)]
|
|
140
|
+
history += old_activity(marker_count + 1, False)
|
|
141
|
+
history += [request(), cancellation_marker(marker_count + 1)]
|
|
142
|
+
commands = replay(PatchCancellationWorkflow, history, [], run_id="run-1").commands
|
|
143
|
+
assert len(commands) == 1
|
|
144
|
+
assert isinstance(commands[0], CompleteWorkflow)
|
|
145
|
+
assert commands[0].result == [[marker_count > 0, marker_count > 0], "cancelled"]
|
|
@@ -24,6 +24,7 @@ from tests.test_golden_history_replay import (
|
|
|
24
24
|
GoldenTimeoutWaitWorkflow,
|
|
25
25
|
GoldenVersionMarkerWorkflow,
|
|
26
26
|
)
|
|
27
|
+
from tests.test_patch_history import PatchCancellationWorkflow, PatchInsertionWorkflow, RepeatedPatchWorkflow
|
|
27
28
|
from tests.test_pending_operation_replay import PendingOperation
|
|
28
29
|
from tests.test_update_signal_condition_replay import (
|
|
29
30
|
PostConditionReceiversWorkflow,
|
|
@@ -250,7 +251,15 @@ class ChildPolicyAuthorWorkflow:
|
|
|
250
251
|
return (yield ctx.start_child_workflow("child", []))
|
|
251
252
|
|
|
252
253
|
|
|
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
|
+
|
|
253
261
|
WORKFLOWS = [
|
|
262
|
+
UnsupportedAsyncWorkflow,
|
|
254
263
|
ChildPolicyAuthorWorkflow,
|
|
255
264
|
ColdReplacementSatisfiedConditionWorkflow,
|
|
256
265
|
CooperativeReopenedConditionCleanupWorkflow,
|
|
@@ -266,6 +275,9 @@ WORKFLOWS = [
|
|
|
266
275
|
NestedParallelPathWorkflow,
|
|
267
276
|
ParallelMetadataProducerWorkflow,
|
|
268
277
|
ParallelResultBindingWorkflow,
|
|
278
|
+
PatchCancellationWorkflow,
|
|
279
|
+
PatchInsertionWorkflow,
|
|
280
|
+
RepeatedPatchWorkflow,
|
|
269
281
|
PendingOperation,
|
|
270
282
|
PagedRecordedSideEffectsWorkflow,
|
|
271
283
|
PostConditionReceiversWorkflow,
|
|
@@ -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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cancellation_scope.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_cooperative_cancellation.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_prepared_local_activity.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/_sticky_workflow_cache.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_activity_cancellation_policies.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_admission.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cancellation_scope_authoring.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_history.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_committed_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_cooperative_cancellation_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_policies.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_prepared_local_activity_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_run_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_scoped_run_cancellation_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.5.0 → durable_workflow-2.5.2}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|