pyworkflow-engine 0.1.7__py3-none-any.whl
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.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for continue-as-new feature.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- ContinueAsNewSignal exception
|
|
6
|
+
- continue_as_new() primitive function
|
|
7
|
+
- CONTINUED_AS_NEW status and event types
|
|
8
|
+
- Storage chain tracking methods
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from pyworkflow import (
|
|
14
|
+
CancellationError,
|
|
15
|
+
LocalContext,
|
|
16
|
+
MockContext,
|
|
17
|
+
RunStatus,
|
|
18
|
+
set_context,
|
|
19
|
+
)
|
|
20
|
+
from pyworkflow.core.exceptions import ContinueAsNewSignal
|
|
21
|
+
from pyworkflow.engine.events import (
|
|
22
|
+
EventType,
|
|
23
|
+
create_workflow_continued_as_new_event,
|
|
24
|
+
)
|
|
25
|
+
from pyworkflow.primitives.continue_as_new import continue_as_new
|
|
26
|
+
from pyworkflow.storage.memory import InMemoryStorageBackend
|
|
27
|
+
from pyworkflow.storage.schemas import WorkflowRun
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestContinueAsNewSignal:
|
|
31
|
+
"""Test ContinueAsNewSignal exception."""
|
|
32
|
+
|
|
33
|
+
def test_signal_default_message(self):
|
|
34
|
+
"""Test ContinueAsNewSignal has correct default message."""
|
|
35
|
+
signal = ContinueAsNewSignal()
|
|
36
|
+
assert str(signal) == "Workflow continuing as new execution"
|
|
37
|
+
|
|
38
|
+
def test_signal_stores_args(self):
|
|
39
|
+
"""Test ContinueAsNewSignal stores positional args."""
|
|
40
|
+
signal = ContinueAsNewSignal(workflow_args=("arg1", "arg2"))
|
|
41
|
+
assert signal.workflow_args == ("arg1", "arg2")
|
|
42
|
+
|
|
43
|
+
def test_signal_stores_kwargs(self):
|
|
44
|
+
"""Test ContinueAsNewSignal stores keyword args."""
|
|
45
|
+
signal = ContinueAsNewSignal(workflow_kwargs={"key": "value"})
|
|
46
|
+
assert signal.workflow_kwargs == {"key": "value"}
|
|
47
|
+
|
|
48
|
+
def test_signal_stores_both_args_and_kwargs(self):
|
|
49
|
+
"""Test ContinueAsNewSignal stores both args and kwargs."""
|
|
50
|
+
signal = ContinueAsNewSignal(workflow_args=("a", "b"), workflow_kwargs={"x": 1, "y": 2})
|
|
51
|
+
assert signal.workflow_args == ("a", "b")
|
|
52
|
+
assert signal.workflow_kwargs == {"x": 1, "y": 2}
|
|
53
|
+
|
|
54
|
+
def test_signal_defaults_to_empty(self):
|
|
55
|
+
"""Test ContinueAsNewSignal defaults to empty args/kwargs."""
|
|
56
|
+
signal = ContinueAsNewSignal()
|
|
57
|
+
assert signal.workflow_args == ()
|
|
58
|
+
assert signal.workflow_kwargs == {}
|
|
59
|
+
|
|
60
|
+
def test_signal_none_kwargs_becomes_empty_dict(self):
|
|
61
|
+
"""Test None kwargs becomes empty dict."""
|
|
62
|
+
signal = ContinueAsNewSignal(workflow_kwargs=None)
|
|
63
|
+
assert signal.workflow_kwargs == {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestContinueAsNewPrimitive:
|
|
67
|
+
"""Test continue_as_new() primitive function."""
|
|
68
|
+
|
|
69
|
+
def test_raises_runtime_error_outside_context(self):
|
|
70
|
+
"""Test continue_as_new raises RuntimeError outside context."""
|
|
71
|
+
set_context(None)
|
|
72
|
+
|
|
73
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
74
|
+
continue_as_new("arg1")
|
|
75
|
+
|
|
76
|
+
assert "must be called within a workflow context" in str(exc_info.value)
|
|
77
|
+
|
|
78
|
+
def test_raises_value_error_without_args(self):
|
|
79
|
+
"""Test continue_as_new raises ValueError without args."""
|
|
80
|
+
ctx = LocalContext(
|
|
81
|
+
run_id="test_run",
|
|
82
|
+
workflow_name="test_workflow",
|
|
83
|
+
storage=None,
|
|
84
|
+
durable=False,
|
|
85
|
+
)
|
|
86
|
+
set_context(ctx)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with pytest.raises(ValueError) as exc_info:
|
|
90
|
+
continue_as_new()
|
|
91
|
+
|
|
92
|
+
assert "requires at least one argument" in str(exc_info.value)
|
|
93
|
+
finally:
|
|
94
|
+
set_context(None)
|
|
95
|
+
|
|
96
|
+
def test_raises_signal_with_positional_args(self):
|
|
97
|
+
"""Test continue_as_new raises signal with positional args."""
|
|
98
|
+
ctx = LocalContext(
|
|
99
|
+
run_id="test_run",
|
|
100
|
+
workflow_name="test_workflow",
|
|
101
|
+
storage=None,
|
|
102
|
+
durable=False,
|
|
103
|
+
)
|
|
104
|
+
set_context(ctx)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
108
|
+
continue_as_new("arg1", "arg2")
|
|
109
|
+
|
|
110
|
+
assert exc_info.value.workflow_args == ("arg1", "arg2")
|
|
111
|
+
assert exc_info.value.workflow_kwargs == {}
|
|
112
|
+
finally:
|
|
113
|
+
set_context(None)
|
|
114
|
+
|
|
115
|
+
def test_raises_signal_with_keyword_args(self):
|
|
116
|
+
"""Test continue_as_new raises signal with keyword args."""
|
|
117
|
+
ctx = LocalContext(
|
|
118
|
+
run_id="test_run",
|
|
119
|
+
workflow_name="test_workflow",
|
|
120
|
+
storage=None,
|
|
121
|
+
durable=False,
|
|
122
|
+
)
|
|
123
|
+
set_context(ctx)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
127
|
+
continue_as_new(cursor="abc123")
|
|
128
|
+
|
|
129
|
+
assert exc_info.value.workflow_args == ()
|
|
130
|
+
assert exc_info.value.workflow_kwargs == {"cursor": "abc123"}
|
|
131
|
+
finally:
|
|
132
|
+
set_context(None)
|
|
133
|
+
|
|
134
|
+
def test_raises_signal_with_mixed_args(self):
|
|
135
|
+
"""Test continue_as_new raises signal with mixed args."""
|
|
136
|
+
ctx = LocalContext(
|
|
137
|
+
run_id="test_run",
|
|
138
|
+
workflow_name="test_workflow",
|
|
139
|
+
storage=None,
|
|
140
|
+
durable=False,
|
|
141
|
+
)
|
|
142
|
+
set_context(ctx)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
146
|
+
continue_as_new("pos_arg", key1="val1", key2="val2")
|
|
147
|
+
|
|
148
|
+
assert exc_info.value.workflow_args == ("pos_arg",)
|
|
149
|
+
assert exc_info.value.workflow_kwargs == {"key1": "val1", "key2": "val2"}
|
|
150
|
+
finally:
|
|
151
|
+
set_context(None)
|
|
152
|
+
|
|
153
|
+
def test_checks_cancellation_before_raising_signal(self):
|
|
154
|
+
"""Test continue_as_new checks cancellation first."""
|
|
155
|
+
ctx = LocalContext(
|
|
156
|
+
run_id="test_run",
|
|
157
|
+
workflow_name="test_workflow",
|
|
158
|
+
storage=None,
|
|
159
|
+
durable=False,
|
|
160
|
+
)
|
|
161
|
+
ctx.request_cancellation(reason="User cancelled")
|
|
162
|
+
set_context(ctx)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with pytest.raises(CancellationError):
|
|
166
|
+
continue_as_new("arg1")
|
|
167
|
+
finally:
|
|
168
|
+
set_context(None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestContinuedAsNewStatus:
|
|
172
|
+
"""Test CONTINUED_AS_NEW status."""
|
|
173
|
+
|
|
174
|
+
def test_status_exists_in_enum(self):
|
|
175
|
+
"""Test CONTINUED_AS_NEW exists in RunStatus enum."""
|
|
176
|
+
assert hasattr(RunStatus, "CONTINUED_AS_NEW")
|
|
177
|
+
assert RunStatus.CONTINUED_AS_NEW.value == "continued_as_new"
|
|
178
|
+
|
|
179
|
+
def test_status_is_distinct_from_completed(self):
|
|
180
|
+
"""Test CONTINUED_AS_NEW is distinct from COMPLETED."""
|
|
181
|
+
assert RunStatus.CONTINUED_AS_NEW != RunStatus.COMPLETED
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TestContinuedAsNewEvent:
|
|
185
|
+
"""Test WORKFLOW_CONTINUED_AS_NEW event type."""
|
|
186
|
+
|
|
187
|
+
def test_event_type_exists(self):
|
|
188
|
+
"""Test WORKFLOW_CONTINUED_AS_NEW exists in EventType enum."""
|
|
189
|
+
assert hasattr(EventType, "WORKFLOW_CONTINUED_AS_NEW")
|
|
190
|
+
assert EventType.WORKFLOW_CONTINUED_AS_NEW.value == "workflow.continued_as_new"
|
|
191
|
+
|
|
192
|
+
def test_create_event(self):
|
|
193
|
+
"""Test create_workflow_continued_as_new_event."""
|
|
194
|
+
event = create_workflow_continued_as_new_event(
|
|
195
|
+
run_id="run_123",
|
|
196
|
+
new_run_id="run_456",
|
|
197
|
+
args='["arg1", "arg2"]',
|
|
198
|
+
kwargs='{"key": "value"}',
|
|
199
|
+
reason="Event limit reached",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
assert event.run_id == "run_123"
|
|
203
|
+
assert event.type == EventType.WORKFLOW_CONTINUED_AS_NEW
|
|
204
|
+
assert event.data["new_run_id"] == "run_456"
|
|
205
|
+
assert event.data["args"] == '["arg1", "arg2"]'
|
|
206
|
+
assert event.data["kwargs"] == '{"key": "value"}'
|
|
207
|
+
assert event.data["reason"] == "Event limit reached"
|
|
208
|
+
|
|
209
|
+
def test_create_event_minimal(self):
|
|
210
|
+
"""Test create_workflow_continued_as_new_event with minimal params."""
|
|
211
|
+
event = create_workflow_continued_as_new_event(
|
|
212
|
+
run_id="run_123",
|
|
213
|
+
new_run_id="run_456",
|
|
214
|
+
args="[]",
|
|
215
|
+
kwargs="{}",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert event.run_id == "run_123"
|
|
219
|
+
assert event.type == EventType.WORKFLOW_CONTINUED_AS_NEW
|
|
220
|
+
assert event.data.get("reason") is None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestWorkflowRunContinuationFields:
|
|
224
|
+
"""Test WorkflowRun continuation tracking fields."""
|
|
225
|
+
|
|
226
|
+
def test_workflow_run_has_continuation_fields(self):
|
|
227
|
+
"""Test WorkflowRun has continued_from and continued_to fields."""
|
|
228
|
+
run = WorkflowRun(
|
|
229
|
+
run_id="run_123",
|
|
230
|
+
workflow_name="test_workflow",
|
|
231
|
+
status=RunStatus.PENDING,
|
|
232
|
+
continued_from_run_id="run_100",
|
|
233
|
+
continued_to_run_id="run_200",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
assert run.continued_from_run_id == "run_100"
|
|
237
|
+
assert run.continued_to_run_id == "run_200"
|
|
238
|
+
|
|
239
|
+
def test_workflow_run_defaults_to_none(self):
|
|
240
|
+
"""Test continuation fields default to None."""
|
|
241
|
+
run = WorkflowRun(
|
|
242
|
+
run_id="run_123",
|
|
243
|
+
workflow_name="test_workflow",
|
|
244
|
+
status=RunStatus.PENDING,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
assert run.continued_from_run_id is None
|
|
248
|
+
assert run.continued_to_run_id is None
|
|
249
|
+
|
|
250
|
+
def test_workflow_run_to_dict_includes_continuation_fields(self):
|
|
251
|
+
"""Test to_dict includes continuation fields."""
|
|
252
|
+
run = WorkflowRun(
|
|
253
|
+
run_id="run_123",
|
|
254
|
+
workflow_name="test_workflow",
|
|
255
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
256
|
+
continued_from_run_id="run_100",
|
|
257
|
+
continued_to_run_id="run_200",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
data = run.to_dict()
|
|
261
|
+
|
|
262
|
+
assert data["continued_from_run_id"] == "run_100"
|
|
263
|
+
assert data["continued_to_run_id"] == "run_200"
|
|
264
|
+
|
|
265
|
+
def test_workflow_run_from_dict_parses_continuation_fields(self):
|
|
266
|
+
"""Test from_dict parses continuation fields."""
|
|
267
|
+
from datetime import UTC, datetime
|
|
268
|
+
|
|
269
|
+
now = datetime.now(UTC)
|
|
270
|
+
data = {
|
|
271
|
+
"run_id": "run_123",
|
|
272
|
+
"workflow_name": "test_workflow",
|
|
273
|
+
"status": "continued_as_new",
|
|
274
|
+
"created_at": now.isoformat(),
|
|
275
|
+
"updated_at": now.isoformat(),
|
|
276
|
+
"continued_from_run_id": "run_100",
|
|
277
|
+
"continued_to_run_id": "run_200",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
run = WorkflowRun.from_dict(data)
|
|
281
|
+
|
|
282
|
+
assert run.continued_from_run_id == "run_100"
|
|
283
|
+
assert run.continued_to_run_id == "run_200"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestStorageChainMethods:
|
|
287
|
+
"""Test storage backend chain tracking methods."""
|
|
288
|
+
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_update_run_continuation(self):
|
|
291
|
+
"""Test update_run_continuation sets continued_to_run_id."""
|
|
292
|
+
storage = InMemoryStorageBackend()
|
|
293
|
+
|
|
294
|
+
run = WorkflowRun(
|
|
295
|
+
run_id="run_1",
|
|
296
|
+
workflow_name="test_workflow",
|
|
297
|
+
status=RunStatus.PENDING,
|
|
298
|
+
)
|
|
299
|
+
await storage.create_run(run)
|
|
300
|
+
|
|
301
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
302
|
+
|
|
303
|
+
updated_run = await storage.get_run("run_1")
|
|
304
|
+
assert updated_run.continued_to_run_id == "run_2"
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_update_run_continuation_nonexistent(self):
|
|
308
|
+
"""Test update_run_continuation for non-existent run does not raise."""
|
|
309
|
+
storage = InMemoryStorageBackend()
|
|
310
|
+
|
|
311
|
+
# Should not raise
|
|
312
|
+
await storage.update_run_continuation("nonexistent_run", "run_2")
|
|
313
|
+
|
|
314
|
+
@pytest.mark.asyncio
|
|
315
|
+
async def test_get_workflow_chain_single_run(self):
|
|
316
|
+
"""Test get_workflow_chain returns single run for no chain."""
|
|
317
|
+
storage = InMemoryStorageBackend()
|
|
318
|
+
|
|
319
|
+
run = WorkflowRun(
|
|
320
|
+
run_id="run_1",
|
|
321
|
+
workflow_name="test_workflow",
|
|
322
|
+
status=RunStatus.PENDING,
|
|
323
|
+
)
|
|
324
|
+
await storage.create_run(run)
|
|
325
|
+
|
|
326
|
+
chain = await storage.get_workflow_chain("run_1")
|
|
327
|
+
|
|
328
|
+
assert len(chain) == 1
|
|
329
|
+
assert chain[0].run_id == "run_1"
|
|
330
|
+
|
|
331
|
+
@pytest.mark.asyncio
|
|
332
|
+
async def test_get_workflow_chain_two_runs(self):
|
|
333
|
+
"""Test get_workflow_chain returns ordered chain of two runs."""
|
|
334
|
+
storage = InMemoryStorageBackend()
|
|
335
|
+
|
|
336
|
+
# Create first run
|
|
337
|
+
run1 = WorkflowRun(
|
|
338
|
+
run_id="run_1",
|
|
339
|
+
workflow_name="test_workflow",
|
|
340
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
341
|
+
)
|
|
342
|
+
await storage.create_run(run1)
|
|
343
|
+
|
|
344
|
+
# Create second run with continued_from
|
|
345
|
+
run2 = WorkflowRun(
|
|
346
|
+
run_id="run_2",
|
|
347
|
+
workflow_name="test_workflow",
|
|
348
|
+
status=RunStatus.RUNNING,
|
|
349
|
+
continued_from_run_id="run_1",
|
|
350
|
+
)
|
|
351
|
+
await storage.create_run(run2)
|
|
352
|
+
|
|
353
|
+
# Link first run to second
|
|
354
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
355
|
+
|
|
356
|
+
# Query from either run should give same chain
|
|
357
|
+
chain_from_1 = await storage.get_workflow_chain("run_1")
|
|
358
|
+
chain_from_2 = await storage.get_workflow_chain("run_2")
|
|
359
|
+
|
|
360
|
+
assert len(chain_from_1) == 2
|
|
361
|
+
assert len(chain_from_2) == 2
|
|
362
|
+
assert chain_from_1[0].run_id == "run_1"
|
|
363
|
+
assert chain_from_1[1].run_id == "run_2"
|
|
364
|
+
assert chain_from_2[0].run_id == "run_1"
|
|
365
|
+
assert chain_from_2[1].run_id == "run_2"
|
|
366
|
+
|
|
367
|
+
@pytest.mark.asyncio
|
|
368
|
+
async def test_get_workflow_chain_three_runs(self):
|
|
369
|
+
"""Test get_workflow_chain returns ordered chain of three runs."""
|
|
370
|
+
storage = InMemoryStorageBackend()
|
|
371
|
+
|
|
372
|
+
# Create chain: run_1 -> run_2 -> run_3
|
|
373
|
+
run1 = WorkflowRun(
|
|
374
|
+
run_id="run_1",
|
|
375
|
+
workflow_name="test_workflow",
|
|
376
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
377
|
+
)
|
|
378
|
+
await storage.create_run(run1)
|
|
379
|
+
|
|
380
|
+
run2 = WorkflowRun(
|
|
381
|
+
run_id="run_2",
|
|
382
|
+
workflow_name="test_workflow",
|
|
383
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
384
|
+
continued_from_run_id="run_1",
|
|
385
|
+
)
|
|
386
|
+
await storage.create_run(run2)
|
|
387
|
+
|
|
388
|
+
run3 = WorkflowRun(
|
|
389
|
+
run_id="run_3",
|
|
390
|
+
workflow_name="test_workflow",
|
|
391
|
+
status=RunStatus.RUNNING,
|
|
392
|
+
continued_from_run_id="run_2",
|
|
393
|
+
)
|
|
394
|
+
await storage.create_run(run3)
|
|
395
|
+
|
|
396
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
397
|
+
await storage.update_run_continuation("run_2", "run_3")
|
|
398
|
+
|
|
399
|
+
# Query from middle run
|
|
400
|
+
chain = await storage.get_workflow_chain("run_2")
|
|
401
|
+
|
|
402
|
+
assert len(chain) == 3
|
|
403
|
+
assert [r.run_id for r in chain] == ["run_1", "run_2", "run_3"]
|
|
404
|
+
|
|
405
|
+
@pytest.mark.asyncio
|
|
406
|
+
async def test_get_workflow_chain_nonexistent_run(self):
|
|
407
|
+
"""Test get_workflow_chain returns empty list for nonexistent run."""
|
|
408
|
+
storage = InMemoryStorageBackend()
|
|
409
|
+
|
|
410
|
+
chain = await storage.get_workflow_chain("nonexistent")
|
|
411
|
+
|
|
412
|
+
assert chain == []
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class TestMockContextContinueAsNew:
|
|
416
|
+
"""Test MockContext with continue_as_new."""
|
|
417
|
+
|
|
418
|
+
def test_mock_context_allows_continue_as_new(self):
|
|
419
|
+
"""Test continue_as_new works with MockContext."""
|
|
420
|
+
ctx = MockContext(run_id="test", workflow_name="test")
|
|
421
|
+
set_context(ctx)
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
425
|
+
continue_as_new("arg1")
|
|
426
|
+
|
|
427
|
+
assert exc_info.value.workflow_args == ("arg1",)
|
|
428
|
+
finally:
|
|
429
|
+
set_context(None)
|
|
430
|
+
|
|
431
|
+
def test_mock_context_cancellation_prevents_continue_as_new(self):
|
|
432
|
+
"""Test MockContext cancellation prevents continue_as_new."""
|
|
433
|
+
ctx = MockContext(run_id="test", workflow_name="test")
|
|
434
|
+
ctx.request_cancellation()
|
|
435
|
+
set_context(ctx)
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
with pytest.raises(CancellationError):
|
|
439
|
+
continue_as_new("arg1")
|
|
440
|
+
finally:
|
|
441
|
+
set_context(None)
|