durable-workflow 0.4.0__tar.gz → 0.4.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-0.4.0/src/durable_workflow.egg-info → durable_workflow-0.4.2}/PKG-INFO +1 -1
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/pyproject.toml +1 -1
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/client.py +42 -4
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/sync.py +4 -2
- durable_workflow-0.4.2/src/durable_workflow/testing.py +454 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_client.py +28 -0
- durable_workflow-0.4.2/tests/test_testing_harness.py +414 -0
- durable_workflow-0.4.0/src/durable_workflow/testing.py +0 -267
- durable_workflow-0.4.0/tests/test_testing_harness.py +0 -221
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/LICENSE +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/README.md +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/setup.cfg +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_activity_context.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_auth_composition.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_errors.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_external_storage.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_external_task_input.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_external_task_result.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_invocable.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_metrics.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_queries.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_replay.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_retry_policy.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_schedules.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_serializer.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_signals.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_sleep.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_sync.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_updates.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_wait_condition.py +0 -0
- {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_worker.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
22
|
import time
|
|
23
|
+
import warnings
|
|
23
24
|
from collections.abc import Callable
|
|
24
25
|
from dataclasses import dataclass
|
|
25
26
|
from importlib.metadata import PackageNotFoundError
|
|
@@ -87,6 +88,32 @@ def _route_for_metrics(path: str) -> str:
|
|
|
87
88
|
return "/" + "/".join(parts)
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
def _resolve_namespace_name(
|
|
92
|
+
name: str | None,
|
|
93
|
+
namespace_alias: str | None,
|
|
94
|
+
*,
|
|
95
|
+
method: str,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Resolve the namespace name accepted via ``name=`` or the deprecated ``namespace=`` alias."""
|
|
98
|
+
if namespace_alias is not None:
|
|
99
|
+
if name is not None:
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"{method}() received both 'name' and the deprecated alias "
|
|
102
|
+
"'namespace'; pass only 'name'."
|
|
103
|
+
)
|
|
104
|
+
warnings.warn(
|
|
105
|
+
f"{method}() argument 'namespace' is deprecated since 0.4.1; "
|
|
106
|
+
"use 'name' to match describe_namespace, create_namespace, and "
|
|
107
|
+
"update_namespace.",
|
|
108
|
+
DeprecationWarning,
|
|
109
|
+
stacklevel=3,
|
|
110
|
+
)
|
|
111
|
+
name = namespace_alias
|
|
112
|
+
if name is None:
|
|
113
|
+
raise TypeError(f"{method}() missing required argument: 'name'")
|
|
114
|
+
return name
|
|
115
|
+
|
|
116
|
+
|
|
90
117
|
@dataclass
|
|
91
118
|
class WorkflowExecution:
|
|
92
119
|
"""Current server view of one workflow execution."""
|
|
@@ -1308,14 +1335,25 @@ class Client:
|
|
|
1308
1335
|
|
|
1309
1336
|
async def set_namespace_external_storage(
|
|
1310
1337
|
self,
|
|
1311
|
-
|
|
1338
|
+
name: str | None = None,
|
|
1312
1339
|
*,
|
|
1313
1340
|
driver: str,
|
|
1314
1341
|
enabled: bool = True,
|
|
1315
1342
|
threshold_bytes: int | None = None,
|
|
1316
1343
|
config: dict[str, Any] | None = None,
|
|
1344
|
+
namespace: str | None = None,
|
|
1317
1345
|
) -> NamespaceDescription:
|
|
1318
|
-
"""Configure external payload storage for a namespace.
|
|
1346
|
+
"""Configure external payload storage for a namespace.
|
|
1347
|
+
|
|
1348
|
+
The first positional argument is the namespace ``name``, matching
|
|
1349
|
+
:meth:`describe_namespace`, :meth:`create_namespace`, and
|
|
1350
|
+
:meth:`update_namespace`. The ``namespace=`` keyword is accepted as a
|
|
1351
|
+
deprecated alias from the 0.4.0 release and emits a
|
|
1352
|
+
:class:`DeprecationWarning`; it will be removed in a future release.
|
|
1353
|
+
"""
|
|
1354
|
+
name = _resolve_namespace_name(
|
|
1355
|
+
name, namespace, method="set_namespace_external_storage"
|
|
1356
|
+
)
|
|
1319
1357
|
body: dict[str, Any] = {
|
|
1320
1358
|
"driver": driver,
|
|
1321
1359
|
"enabled": enabled,
|
|
@@ -1327,9 +1365,9 @@ class Client:
|
|
|
1327
1365
|
|
|
1328
1366
|
data = await self._request(
|
|
1329
1367
|
"PUT",
|
|
1330
|
-
f"/namespaces/{quote(
|
|
1368
|
+
f"/namespaces/{quote(name, safe='')}/external-storage",
|
|
1331
1369
|
json=body,
|
|
1332
|
-
context=
|
|
1370
|
+
context=name,
|
|
1333
1371
|
)
|
|
1334
1372
|
if not isinstance(data, dict):
|
|
1335
1373
|
raise ServerError(
|
|
@@ -354,20 +354,22 @@ class Client:
|
|
|
354
354
|
|
|
355
355
|
def set_namespace_external_storage(
|
|
356
356
|
self,
|
|
357
|
-
|
|
357
|
+
name: str | None = None,
|
|
358
358
|
*,
|
|
359
359
|
driver: str,
|
|
360
360
|
enabled: bool = True,
|
|
361
361
|
threshold_bytes: int | None = None,
|
|
362
362
|
config: dict[str, Any] | None = None,
|
|
363
|
+
namespace: str | None = None,
|
|
363
364
|
) -> NamespaceDescription:
|
|
364
365
|
result: NamespaceDescription = _run(
|
|
365
366
|
self._async.set_namespace_external_storage(
|
|
366
|
-
|
|
367
|
+
name,
|
|
367
368
|
driver=driver,
|
|
368
369
|
enabled=enabled,
|
|
369
370
|
threshold_bytes=threshold_bytes,
|
|
370
371
|
config=config,
|
|
372
|
+
namespace=namespace,
|
|
371
373
|
)
|
|
372
374
|
)
|
|
373
375
|
return result
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""In-process test harness for workflow authoring.
|
|
2
|
+
|
|
3
|
+
:class:`WorkflowEnvironment` drives a workflow to completion in a single
|
|
4
|
+
Python process, without a running server or worker. It reuses the same
|
|
5
|
+
:func:`durable_workflow.workflow.replay` machinery the worker uses, but
|
|
6
|
+
resolves yielded commands against user-registered activity mocks and
|
|
7
|
+
auto-fires timers / side-effects / search-attribute upserts so tests do
|
|
8
|
+
not need a real clock or Redis.
|
|
9
|
+
|
|
10
|
+
Typical use::
|
|
11
|
+
|
|
12
|
+
def test_my_workflow():
|
|
13
|
+
env = WorkflowEnvironment()
|
|
14
|
+
env.register_activity_result("charge_card", {"id": "ch_1"})
|
|
15
|
+
env.register_activity_result("send_receipt", None)
|
|
16
|
+
result = env.execute_workflow(OrderWorkflow, "order-1", {"amount": 42})
|
|
17
|
+
assert result == {"status": "complete", "charge_id": "ch_1"}
|
|
18
|
+
|
|
19
|
+
When a workflow yields :class:`~durable_workflow.workflow.ContinueAsNew`,
|
|
20
|
+
the harness records a ``WorkflowContinuedAsNew`` event on the completing
|
|
21
|
+
run, resets history, and starts a new run with the command's arguments.
|
|
22
|
+
:attr:`WorkflowEnvironment.runs` exposes the per-run record (input,
|
|
23
|
+
workflow type, history events, and terminal command) so tests can assert
|
|
24
|
+
on the whole chain. The chain length is bounded by
|
|
25
|
+
``continue_as_new_limit``; exceeding it raises :exc:`RuntimeError`.
|
|
26
|
+
|
|
27
|
+
For regression-testing workflow code against production histories, use
|
|
28
|
+
:func:`replay_history` — it hands the real durable history straight to
|
|
29
|
+
the worker's replayer and surfaces any non-determinism as a raised
|
|
30
|
+
exception.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from collections.abc import Callable, Iterable
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from . import serializer
|
|
42
|
+
from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
|
|
43
|
+
from .workflow import (
|
|
44
|
+
CompleteWorkflow,
|
|
45
|
+
ContinueAsNew,
|
|
46
|
+
FailWorkflow,
|
|
47
|
+
RecordSideEffect,
|
|
48
|
+
RecordVersionMarker,
|
|
49
|
+
ReplayOutcome,
|
|
50
|
+
ScheduleActivity,
|
|
51
|
+
StartChildWorkflow,
|
|
52
|
+
StartTimer,
|
|
53
|
+
UpsertSearchAttributes,
|
|
54
|
+
registry,
|
|
55
|
+
replay,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class WorkflowRunRecord:
|
|
61
|
+
"""One run within an ``execute_workflow`` chain.
|
|
62
|
+
|
|
63
|
+
A single-run workflow produces one record with :attr:`terminal` of
|
|
64
|
+
type :class:`~durable_workflow.workflow.CompleteWorkflow` or
|
|
65
|
+
:class:`~durable_workflow.workflow.FailWorkflow`. A workflow that
|
|
66
|
+
yields :class:`~durable_workflow.workflow.ContinueAsNew` produces
|
|
67
|
+
one record per link; the terminal of each non-final link is the
|
|
68
|
+
``ContinueAsNew`` command itself and the final link terminates as
|
|
69
|
+
usual.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
workflow_type: str
|
|
73
|
+
input: list[Any]
|
|
74
|
+
history: list[dict[str, Any]] = field(default_factory=list)
|
|
75
|
+
terminal: Any = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WorkflowEnvironment:
|
|
79
|
+
"""Drives a workflow to completion against user-registered activity mocks.
|
|
80
|
+
|
|
81
|
+
:param iteration_limit: upper bound on per-run replay iterations
|
|
82
|
+
(guard against workflows that never yield a terminal command).
|
|
83
|
+
:param continue_as_new_limit: upper bound on the number of
|
|
84
|
+
continue-as-new links in one ``execute_workflow`` chain. The
|
|
85
|
+
first run counts as link 1; a workflow that continues five
|
|
86
|
+
times runs six links. Exceeding the limit raises
|
|
87
|
+
:exc:`RuntimeError`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
iteration_limit: int = 1000,
|
|
94
|
+
continue_as_new_limit: int = 50,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._activity_results: dict[str, Any] = {}
|
|
97
|
+
self._activity_fns: dict[str, Callable[..., Any]] = {}
|
|
98
|
+
self._child_workflow_results: dict[str, Any] = {}
|
|
99
|
+
self._pending_signals: list[tuple[str, list[Any]]] = []
|
|
100
|
+
self._queued_signals_by_run: dict[int, list[tuple[str, list[Any]]]] = {}
|
|
101
|
+
self._iteration_limit = iteration_limit
|
|
102
|
+
self._continue_as_new_limit = continue_as_new_limit
|
|
103
|
+
self._registered_workflows: dict[str, type] = {}
|
|
104
|
+
self._runs: list[WorkflowRunRecord] = []
|
|
105
|
+
|
|
106
|
+
def register_activity_result(self, name: str, result: Any) -> None:
|
|
107
|
+
"""Canned response: every call to ``name`` returns ``result``."""
|
|
108
|
+
self._activity_results[name] = result
|
|
109
|
+
|
|
110
|
+
def register_activity(self, name: str, fn: Callable[..., Any]) -> None:
|
|
111
|
+
"""Callable mock: ``fn(*arguments)`` is invoked for each scheduled call.
|
|
112
|
+
|
|
113
|
+
Use this when the test needs the mock to vary with arguments (e.g.
|
|
114
|
+
look up by order id) or to capture invocations.
|
|
115
|
+
"""
|
|
116
|
+
self._activity_fns[name] = fn
|
|
117
|
+
|
|
118
|
+
def register_child_workflow_result(self, workflow_type: str, result: Any) -> None:
|
|
119
|
+
"""Canned response for child workflow completions."""
|
|
120
|
+
self._child_workflow_results[workflow_type] = result
|
|
121
|
+
|
|
122
|
+
def register_workflow(self, workflow_cls: type) -> None:
|
|
123
|
+
"""Make ``workflow_cls`` resolvable by name for continue-as-new.
|
|
124
|
+
|
|
125
|
+
``continue_as_new(workflow_type="other-name")`` looks up the
|
|
126
|
+
next workflow class by its ``@workflow.defn`` name. The class
|
|
127
|
+
passed to :meth:`execute_workflow` is registered automatically;
|
|
128
|
+
call this method for additional workflow classes the chain may
|
|
129
|
+
continue into.
|
|
130
|
+
"""
|
|
131
|
+
name = getattr(workflow_cls, "__workflow_name__", None)
|
|
132
|
+
if not isinstance(name, str) or not name:
|
|
133
|
+
raise TypeError(
|
|
134
|
+
f"{workflow_cls!r} is not a workflow class; "
|
|
135
|
+
"decorate it with @workflow.defn(name=...) first."
|
|
136
|
+
)
|
|
137
|
+
self._registered_workflows[name] = workflow_cls
|
|
138
|
+
|
|
139
|
+
def signal(
|
|
140
|
+
self,
|
|
141
|
+
name: str,
|
|
142
|
+
args: list[Any] | None = None,
|
|
143
|
+
*,
|
|
144
|
+
run: int | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Queue a signal to be delivered before the next iteration.
|
|
147
|
+
|
|
148
|
+
Signals are drained in the order they were queued and injected
|
|
149
|
+
into the workflow history as ``SignalReceived`` events; the
|
|
150
|
+
replayer dispatches each to its registered ``@workflow.signal``
|
|
151
|
+
handler.
|
|
152
|
+
|
|
153
|
+
Pass ``run`` to target a specific link in a continue-as-new
|
|
154
|
+
chain (``run=1`` is the first run, ``run=2`` the run that
|
|
155
|
+
starts after the first ``ContinueAsNew``, and so on). Without
|
|
156
|
+
``run`` the signal is consumed at the current front of the
|
|
157
|
+
chain — on the first run before the first iteration, and on
|
|
158
|
+
later iterations in whatever link happens to be running.
|
|
159
|
+
"""
|
|
160
|
+
normalized = (name, list(args) if args is not None else [])
|
|
161
|
+
if run is None:
|
|
162
|
+
self._pending_signals.append(normalized)
|
|
163
|
+
return
|
|
164
|
+
if run < 1:
|
|
165
|
+
raise ValueError("run number must be 1 or greater")
|
|
166
|
+
self._queued_signals_by_run.setdefault(run, []).append(normalized)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def runs(self) -> list[WorkflowRunRecord]:
|
|
170
|
+
"""Per-run records produced by the most recent ``execute_workflow`` call.
|
|
171
|
+
|
|
172
|
+
Empty before the first ``execute_workflow`` call. Reset at the
|
|
173
|
+
start of each call.
|
|
174
|
+
"""
|
|
175
|
+
return list(self._runs)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def run_count(self) -> int:
|
|
179
|
+
"""Number of runs in the most recent ``execute_workflow`` chain."""
|
|
180
|
+
return len(self._runs)
|
|
181
|
+
|
|
182
|
+
def execute_workflow(
|
|
183
|
+
self,
|
|
184
|
+
workflow_cls: type,
|
|
185
|
+
*args: Any,
|
|
186
|
+
run_id: str = "test-run",
|
|
187
|
+
) -> Any:
|
|
188
|
+
"""Drive ``workflow_cls`` to a terminal state and return its result.
|
|
189
|
+
|
|
190
|
+
Raises :class:`~durable_workflow.errors.WorkflowFailed` if the
|
|
191
|
+
workflow ended in the ``failed`` state. Activities that do not
|
|
192
|
+
have a registered mock raise :class:`KeyError` so tests fail
|
|
193
|
+
loudly on missing fixtures.
|
|
194
|
+
|
|
195
|
+
If the workflow yields
|
|
196
|
+
:class:`~durable_workflow.workflow.ContinueAsNew`, the harness
|
|
197
|
+
starts a new run with the new input and, if the command named a
|
|
198
|
+
``workflow_type``, the matching registered workflow class. The
|
|
199
|
+
chain is bounded by ``continue_as_new_limit`` and returns the
|
|
200
|
+
terminal result of the final run.
|
|
201
|
+
"""
|
|
202
|
+
self._runs = []
|
|
203
|
+
self.register_workflow(workflow_cls)
|
|
204
|
+
current_cls = workflow_cls
|
|
205
|
+
current_args: list[Any] = list(args)
|
|
206
|
+
current_run_id = run_id
|
|
207
|
+
|
|
208
|
+
for link_index in range(self._continue_as_new_limit):
|
|
209
|
+
history: list[dict[str, Any]] = []
|
|
210
|
+
link_number = link_index + 1
|
|
211
|
+
record = WorkflowRunRecord(
|
|
212
|
+
workflow_type=_workflow_name(current_cls),
|
|
213
|
+
input=list(current_args),
|
|
214
|
+
history=history,
|
|
215
|
+
)
|
|
216
|
+
self._runs.append(record)
|
|
217
|
+
|
|
218
|
+
if link_index == 0:
|
|
219
|
+
self._drain_pending_signals_into(history)
|
|
220
|
+
for queued in self._queued_signals_by_run.pop(link_number, []):
|
|
221
|
+
history.append(_signal_event(*queued))
|
|
222
|
+
|
|
223
|
+
continuation: ContinueAsNew | None = None
|
|
224
|
+
terminal_outcome: Any = _SENTINEL
|
|
225
|
+
for _ in range(self._iteration_limit):
|
|
226
|
+
self._drain_pending_signals_into(history)
|
|
227
|
+
outcome = replay(
|
|
228
|
+
current_cls, history, list(current_args), run_id=current_run_id
|
|
229
|
+
)
|
|
230
|
+
terminal_outcome, continuation = self._apply_commands(outcome, history)
|
|
231
|
+
if continuation is not None or terminal_outcome is not _SENTINEL:
|
|
232
|
+
break
|
|
233
|
+
else:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
f"workflow {record.workflow_type!r} did not terminate within "
|
|
236
|
+
f"{self._iteration_limit} iterations; check for missing "
|
|
237
|
+
"activity mocks or signals that never satisfy a wait."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if continuation is not None:
|
|
241
|
+
record.terminal = continuation
|
|
242
|
+
history.append(_continued_as_new_event(continuation))
|
|
243
|
+
current_cls = self._resolve_next_workflow(continuation, current_cls)
|
|
244
|
+
current_args = list(continuation.arguments)
|
|
245
|
+
current_run_id = f"{current_run_id}-continued"
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
record.terminal = terminal_outcome
|
|
249
|
+
if isinstance(terminal_outcome, WorkflowFailed):
|
|
250
|
+
raise terminal_outcome
|
|
251
|
+
if isinstance(terminal_outcome, CompleteWorkflow):
|
|
252
|
+
return terminal_outcome.result
|
|
253
|
+
return terminal_outcome
|
|
254
|
+
|
|
255
|
+
raise RuntimeError(
|
|
256
|
+
f"workflow chained continue-as-new past the {self._continue_as_new_limit}"
|
|
257
|
+
" link limit; raise continue_as_new_limit or tighten the workflow's "
|
|
258
|
+
"termination condition."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _resolve_next_workflow(
|
|
262
|
+
self, continuation: ContinueAsNew, current_cls: type
|
|
263
|
+
) -> type:
|
|
264
|
+
target_name = continuation.workflow_type
|
|
265
|
+
if not target_name:
|
|
266
|
+
return current_cls
|
|
267
|
+
current_name = getattr(current_cls, "__workflow_name__", None)
|
|
268
|
+
if target_name == current_name:
|
|
269
|
+
return current_cls
|
|
270
|
+
if target_name in self._registered_workflows:
|
|
271
|
+
return self._registered_workflows[target_name]
|
|
272
|
+
global_registry = registry()
|
|
273
|
+
if target_name in global_registry:
|
|
274
|
+
return global_registry[target_name]
|
|
275
|
+
raise KeyError(
|
|
276
|
+
f"continue_as_new targeted workflow type {target_name!r} "
|
|
277
|
+
"but no matching class is registered; call "
|
|
278
|
+
"env.register_workflow(YourWorkflow) before execute_workflow()."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _drain_pending_signals_into(self, history: list[dict[str, Any]]) -> None:
|
|
282
|
+
while self._pending_signals:
|
|
283
|
+
name, sig_args = self._pending_signals.pop(0)
|
|
284
|
+
history.append(_signal_event(name, sig_args))
|
|
285
|
+
|
|
286
|
+
def _apply_commands(
|
|
287
|
+
self, outcome: ReplayOutcome, history: list[dict[str, Any]]
|
|
288
|
+
) -> tuple[Any, ContinueAsNew | None]:
|
|
289
|
+
for cmd in outcome.commands:
|
|
290
|
+
if isinstance(cmd, CompleteWorkflow):
|
|
291
|
+
return cmd, None
|
|
292
|
+
if isinstance(cmd, FailWorkflow):
|
|
293
|
+
return WorkflowFailed(cmd.message, cmd.exception_type), None
|
|
294
|
+
if isinstance(cmd, ContinueAsNew):
|
|
295
|
+
return _SENTINEL, cmd
|
|
296
|
+
if isinstance(cmd, ScheduleActivity):
|
|
297
|
+
history.append(self._resolve_activity(cmd))
|
|
298
|
+
elif isinstance(cmd, StartTimer):
|
|
299
|
+
history.append({"event_type": "TimerFired", "payload": {}})
|
|
300
|
+
elif isinstance(cmd, StartChildWorkflow):
|
|
301
|
+
history.append(self._resolve_child_workflow(cmd))
|
|
302
|
+
elif isinstance(cmd, RecordSideEffect):
|
|
303
|
+
history.append(
|
|
304
|
+
{
|
|
305
|
+
"event_type": "SideEffectRecorded",
|
|
306
|
+
"payload": {
|
|
307
|
+
"result": serializer.envelope(cmd.result),
|
|
308
|
+
"payload_codec": serializer.AVRO_CODEC,
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
elif isinstance(cmd, UpsertSearchAttributes):
|
|
313
|
+
history.append(
|
|
314
|
+
{"event_type": "SearchAttributesUpserted", "payload": {}}
|
|
315
|
+
)
|
|
316
|
+
elif isinstance(cmd, RecordVersionMarker):
|
|
317
|
+
history.append(
|
|
318
|
+
{
|
|
319
|
+
"event_type": "VersionMarkerRecorded",
|
|
320
|
+
"payload": {"version": cmd.version},
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
raise TypeError(f"unsupported command in test harness: {cmd!r}")
|
|
325
|
+
return _SENTINEL, None
|
|
326
|
+
|
|
327
|
+
def _resolve_activity(self, cmd: ScheduleActivity) -> dict[str, Any]:
|
|
328
|
+
if cmd.activity_type in self._activity_fns:
|
|
329
|
+
result = self._activity_fns[cmd.activity_type](*cmd.arguments)
|
|
330
|
+
elif cmd.activity_type in self._activity_results:
|
|
331
|
+
result = self._activity_results[cmd.activity_type]
|
|
332
|
+
else:
|
|
333
|
+
raise KeyError(
|
|
334
|
+
f"no mock registered for activity {cmd.activity_type!r}; "
|
|
335
|
+
"call env.register_activity_result() or env.register_activity()."
|
|
336
|
+
)
|
|
337
|
+
return {
|
|
338
|
+
"event_type": "ActivityCompleted",
|
|
339
|
+
"payload": {
|
|
340
|
+
"result": serializer.envelope(result),
|
|
341
|
+
"payload_codec": serializer.AVRO_CODEC,
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
def _resolve_child_workflow(self, cmd: StartChildWorkflow) -> dict[str, Any]:
|
|
346
|
+
if cmd.workflow_type not in self._child_workflow_results:
|
|
347
|
+
raise KeyError(
|
|
348
|
+
f"no mock registered for child workflow {cmd.workflow_type!r}; "
|
|
349
|
+
"call env.register_child_workflow_result()."
|
|
350
|
+
)
|
|
351
|
+
return {
|
|
352
|
+
"event_type": "ChildRunCompleted",
|
|
353
|
+
"payload": {
|
|
354
|
+
"result": serializer.envelope(self._child_workflow_results[cmd.workflow_type]),
|
|
355
|
+
"payload_codec": serializer.AVRO_CODEC,
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _workflow_name(cls: type) -> str:
|
|
361
|
+
name = getattr(cls, "__workflow_name__", None)
|
|
362
|
+
return name if isinstance(name, str) and name else cls.__name__
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _signal_event(name: str, sig_args: list[Any]) -> dict[str, Any]:
|
|
366
|
+
return {
|
|
367
|
+
"event_type": "SignalReceived",
|
|
368
|
+
"payload": {
|
|
369
|
+
"signal_name": name,
|
|
370
|
+
"value": serializer.envelope(sig_args),
|
|
371
|
+
"payload_codec": serializer.AVRO_CODEC,
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _continued_as_new_event(cmd: ContinueAsNew) -> dict[str, Any]:
|
|
377
|
+
payload: dict[str, Any] = {
|
|
378
|
+
"arguments": serializer.envelope(cmd.arguments),
|
|
379
|
+
"payload_codec": serializer.AVRO_CODEC,
|
|
380
|
+
}
|
|
381
|
+
if cmd.workflow_type is not None:
|
|
382
|
+
payload["workflow_type"] = cmd.workflow_type
|
|
383
|
+
if cmd.task_queue is not None:
|
|
384
|
+
payload["queue"] = cmd.task_queue
|
|
385
|
+
return {"event_type": "WorkflowContinuedAsNew", "payload": payload}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Sentinel marking "no terminal command seen this iteration".
|
|
389
|
+
_SENTINEL = object()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def replay_history(
|
|
393
|
+
workflow_cls: type,
|
|
394
|
+
history_events: Iterable[dict[str, Any]],
|
|
395
|
+
start_input: list[Any] | None = None,
|
|
396
|
+
*,
|
|
397
|
+
run_id: str = "",
|
|
398
|
+
payload_codec: str | None = None,
|
|
399
|
+
) -> ReplayOutcome:
|
|
400
|
+
"""Replay a production history against current workflow code.
|
|
401
|
+
|
|
402
|
+
Hands the durable history directly to the worker's replayer. Raises any
|
|
403
|
+
exception the workflow would raise during replay — for example a
|
|
404
|
+
non-determinism failure when ``run`` yields a different command sequence
|
|
405
|
+
from the one recorded in history.
|
|
406
|
+
|
|
407
|
+
This is the supported way to regression-test a workflow change against
|
|
408
|
+
real production traffic: dump the history from ``Client.get_history``,
|
|
409
|
+
save the JSON, and replay it on every PR.
|
|
410
|
+
"""
|
|
411
|
+
return replay(
|
|
412
|
+
workflow_cls,
|
|
413
|
+
history_events,
|
|
414
|
+
list(start_input or []),
|
|
415
|
+
run_id=run_id,
|
|
416
|
+
payload_codec=payload_codec,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def replay_history_file(
|
|
421
|
+
workflow_cls: type,
|
|
422
|
+
path: str | Path,
|
|
423
|
+
start_input: list[Any] | None = None,
|
|
424
|
+
*,
|
|
425
|
+
run_id: str = "",
|
|
426
|
+
payload_codec: str | None = None,
|
|
427
|
+
) -> ReplayOutcome:
|
|
428
|
+
"""Convenience wrapper: load a JSON history file and replay it.
|
|
429
|
+
|
|
430
|
+
Accepts either a list of events at the top level or a dict with an
|
|
431
|
+
``events`` key (matching the shape of ``Client.get_history``).
|
|
432
|
+
"""
|
|
433
|
+
data = json.loads(Path(path).read_text())
|
|
434
|
+
events = data["events"] if isinstance(data, dict) else data
|
|
435
|
+
return replay_history(
|
|
436
|
+
workflow_cls,
|
|
437
|
+
events,
|
|
438
|
+
start_input,
|
|
439
|
+
run_id=run_id,
|
|
440
|
+
payload_codec=payload_codec,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
__all__ = [
|
|
445
|
+
"WorkflowEnvironment",
|
|
446
|
+
"WorkflowRunRecord",
|
|
447
|
+
"replay_history",
|
|
448
|
+
"replay_history_file",
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# Re-export terminal exceptions the harness may raise so tests can catch
|
|
453
|
+
# them without hunting for the right import path.
|
|
454
|
+
_TERMINAL_EXCEPTIONS = (WorkflowFailed, WorkflowCancelled, WorkflowTerminated)
|
|
@@ -931,6 +931,34 @@ class TestNamespaces:
|
|
|
931
931
|
assert result.external_payload_storage.config["bucket"] == fixture["semantic_body"]["bucket"]
|
|
932
932
|
assert result.external_payload_storage.prefix == fixture["semantic_body"]["prefix"]
|
|
933
933
|
|
|
934
|
+
@pytest.mark.asyncio
|
|
935
|
+
async def test_set_namespace_external_storage_accepts_deprecated_namespace_alias(
|
|
936
|
+
self, client: Client
|
|
937
|
+
) -> None:
|
|
938
|
+
resp = _mock_response(200, {"name": "billing"})
|
|
939
|
+
|
|
940
|
+
with (
|
|
941
|
+
patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock,
|
|
942
|
+
pytest.warns(DeprecationWarning, match="namespace.*deprecated"),
|
|
943
|
+
):
|
|
944
|
+
await client.set_namespace_external_storage(namespace="billing", driver="local")
|
|
945
|
+
|
|
946
|
+
assert mock.call_args.args[:2] == ("PUT", "/api/namespaces/billing/external-storage")
|
|
947
|
+
|
|
948
|
+
@pytest.mark.asyncio
|
|
949
|
+
async def test_set_namespace_external_storage_rejects_both_name_and_alias(
|
|
950
|
+
self, client: Client
|
|
951
|
+
) -> None:
|
|
952
|
+
with pytest.raises(TypeError, match="both 'name' and the deprecated alias"):
|
|
953
|
+
await client.set_namespace_external_storage(
|
|
954
|
+
"billing", namespace="other", driver="local"
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
@pytest.mark.asyncio
|
|
958
|
+
async def test_set_namespace_external_storage_requires_name(self, client: Client) -> None:
|
|
959
|
+
with pytest.raises(TypeError, match="missing required argument: 'name'"):
|
|
960
|
+
await client.set_namespace_external_storage(driver="local")
|
|
961
|
+
|
|
934
962
|
@pytest.mark.asyncio
|
|
935
963
|
async def test_namespace_name_is_url_encoded(self, client: Client) -> None:
|
|
936
964
|
resp = _mock_response(200, {"name": "billing reports"})
|