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,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests for continue-as-new feature.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Workflow continues as new and new run executes
|
|
6
|
+
- Chain of multiple continuations tracked correctly
|
|
7
|
+
- Cancellation prevents continuation
|
|
8
|
+
- Error handling during continuation
|
|
9
|
+
- get_workflow_chain() function
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from pyworkflow import (
|
|
17
|
+
CancellationError,
|
|
18
|
+
RunStatus,
|
|
19
|
+
continue_as_new,
|
|
20
|
+
get_workflow_chain,
|
|
21
|
+
)
|
|
22
|
+
from pyworkflow.config import configure, reset_config
|
|
23
|
+
from pyworkflow.context import LocalContext, set_context
|
|
24
|
+
from pyworkflow.core.exceptions import ContinueAsNewSignal
|
|
25
|
+
from pyworkflow.engine.events import EventType
|
|
26
|
+
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
27
|
+
from pyworkflow.storage.memory import InMemoryStorageBackend
|
|
28
|
+
from pyworkflow.storage.schemas import WorkflowRun
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def storage():
|
|
33
|
+
"""Create in-memory storage for tests."""
|
|
34
|
+
return InMemoryStorageBackend()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture(autouse=True)
|
|
38
|
+
def setup_config(storage):
|
|
39
|
+
"""Configure pyworkflow with in-memory storage."""
|
|
40
|
+
configure(storage=storage, default_durable=True)
|
|
41
|
+
yield
|
|
42
|
+
reset_config()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestContinueAsNewExecution:
|
|
46
|
+
"""Test continue_as_new execution flow."""
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_continue_as_new_raises_signal(self, storage):
|
|
50
|
+
"""Test that continue_as_new raises ContinueAsNewSignal."""
|
|
51
|
+
# Execute workflow - it should raise ContinueAsNewSignal
|
|
52
|
+
ctx = LocalContext(
|
|
53
|
+
run_id="run_1",
|
|
54
|
+
workflow_name="counter_workflow",
|
|
55
|
+
storage=storage,
|
|
56
|
+
durable=True,
|
|
57
|
+
)
|
|
58
|
+
set_context(ctx)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
62
|
+
continue_as_new(count=2)
|
|
63
|
+
|
|
64
|
+
# Check the signal has correct args
|
|
65
|
+
assert exc_info.value.workflow_kwargs == {"count": 2}
|
|
66
|
+
finally:
|
|
67
|
+
set_context(None)
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_storage_links_runs_correctly(self, storage):
|
|
71
|
+
"""Test that storage properly links continuation runs."""
|
|
72
|
+
# Create initial run
|
|
73
|
+
run1 = WorkflowRun(
|
|
74
|
+
run_id="run_1",
|
|
75
|
+
workflow_name="my_workflow",
|
|
76
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
77
|
+
)
|
|
78
|
+
await storage.create_run(run1)
|
|
79
|
+
|
|
80
|
+
# Create continuation run with link
|
|
81
|
+
run2 = WorkflowRun(
|
|
82
|
+
run_id="run_2",
|
|
83
|
+
workflow_name="my_workflow",
|
|
84
|
+
status=RunStatus.RUNNING,
|
|
85
|
+
continued_from_run_id="run_1",
|
|
86
|
+
)
|
|
87
|
+
await storage.create_run(run2)
|
|
88
|
+
|
|
89
|
+
# Update old run to point to new
|
|
90
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
91
|
+
|
|
92
|
+
# Check old run is updated
|
|
93
|
+
old_run = await storage.get_run("run_1")
|
|
94
|
+
assert old_run.status == RunStatus.CONTINUED_AS_NEW
|
|
95
|
+
assert old_run.continued_to_run_id == "run_2"
|
|
96
|
+
|
|
97
|
+
# Check new run is linked back
|
|
98
|
+
new_run = await storage.get_run("run_2")
|
|
99
|
+
assert new_run is not None
|
|
100
|
+
assert new_run.continued_from_run_id == "run_1"
|
|
101
|
+
assert new_run.workflow_name == "my_workflow"
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_continuation_event_recorded(self, storage):
|
|
105
|
+
"""Test that WORKFLOW_CONTINUED_AS_NEW event is recorded."""
|
|
106
|
+
from pyworkflow.engine.events import create_workflow_continued_as_new_event
|
|
107
|
+
|
|
108
|
+
# Create initial run
|
|
109
|
+
run = WorkflowRun(
|
|
110
|
+
run_id="run_1",
|
|
111
|
+
workflow_name="my_workflow",
|
|
112
|
+
status=RunStatus.RUNNING,
|
|
113
|
+
)
|
|
114
|
+
await storage.create_run(run)
|
|
115
|
+
|
|
116
|
+
# Record continuation event manually (simulating what executor does)
|
|
117
|
+
continuation_event = create_workflow_continued_as_new_event(
|
|
118
|
+
run_id="run_1",
|
|
119
|
+
new_run_id="run_2",
|
|
120
|
+
args=serialize_args(42),
|
|
121
|
+
kwargs=serialize_kwargs(key="value"),
|
|
122
|
+
)
|
|
123
|
+
await storage.record_event(continuation_event)
|
|
124
|
+
|
|
125
|
+
# Check event was recorded
|
|
126
|
+
events = await storage.get_events("run_1")
|
|
127
|
+
continuation_events = [e for e in events if e.type == EventType.WORKFLOW_CONTINUED_AS_NEW]
|
|
128
|
+
assert len(continuation_events) == 1
|
|
129
|
+
|
|
130
|
+
event = continuation_events[0]
|
|
131
|
+
assert event.data["new_run_id"] == "run_2"
|
|
132
|
+
assert event.data["args"] == serialize_args(42)
|
|
133
|
+
assert event.data["kwargs"] == serialize_kwargs(key="value")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestWorkflowChain:
|
|
137
|
+
"""Test workflow chain tracking."""
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_get_workflow_chain_returns_ordered_list(self, storage):
|
|
141
|
+
"""Test get_workflow_chain returns runs in order."""
|
|
142
|
+
# Create a chain of runs: run_1 -> run_2 -> run_3
|
|
143
|
+
run1 = WorkflowRun(
|
|
144
|
+
run_id="run_1",
|
|
145
|
+
workflow_name="my_workflow",
|
|
146
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
147
|
+
created_at=datetime.now(UTC),
|
|
148
|
+
)
|
|
149
|
+
await storage.create_run(run1)
|
|
150
|
+
|
|
151
|
+
run2 = WorkflowRun(
|
|
152
|
+
run_id="run_2",
|
|
153
|
+
workflow_name="my_workflow",
|
|
154
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
155
|
+
continued_from_run_id="run_1",
|
|
156
|
+
created_at=datetime.now(UTC),
|
|
157
|
+
)
|
|
158
|
+
await storage.create_run(run2)
|
|
159
|
+
|
|
160
|
+
run3 = WorkflowRun(
|
|
161
|
+
run_id="run_3",
|
|
162
|
+
workflow_name="my_workflow",
|
|
163
|
+
status=RunStatus.COMPLETED,
|
|
164
|
+
continued_from_run_id="run_2",
|
|
165
|
+
created_at=datetime.now(UTC),
|
|
166
|
+
)
|
|
167
|
+
await storage.create_run(run3)
|
|
168
|
+
|
|
169
|
+
# Link runs
|
|
170
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
171
|
+
await storage.update_run_continuation("run_2", "run_3")
|
|
172
|
+
|
|
173
|
+
# Query chain from any run
|
|
174
|
+
chain = await get_workflow_chain("run_2", storage=storage)
|
|
175
|
+
|
|
176
|
+
assert len(chain) == 3
|
|
177
|
+
assert [r.run_id for r in chain] == ["run_1", "run_2", "run_3"]
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_chain_from_first_run(self, storage):
|
|
181
|
+
"""Test getting chain from first run returns full chain."""
|
|
182
|
+
# Create chain
|
|
183
|
+
run1 = WorkflowRun(
|
|
184
|
+
run_id="first",
|
|
185
|
+
workflow_name="my_workflow",
|
|
186
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
187
|
+
)
|
|
188
|
+
await storage.create_run(run1)
|
|
189
|
+
|
|
190
|
+
run2 = WorkflowRun(
|
|
191
|
+
run_id="second",
|
|
192
|
+
workflow_name="my_workflow",
|
|
193
|
+
status=RunStatus.RUNNING,
|
|
194
|
+
continued_from_run_id="first",
|
|
195
|
+
)
|
|
196
|
+
await storage.create_run(run2)
|
|
197
|
+
|
|
198
|
+
await storage.update_run_continuation("first", "second")
|
|
199
|
+
|
|
200
|
+
chain = await get_workflow_chain("first", storage=storage)
|
|
201
|
+
|
|
202
|
+
assert len(chain) == 2
|
|
203
|
+
assert chain[0].run_id == "first"
|
|
204
|
+
assert chain[1].run_id == "second"
|
|
205
|
+
|
|
206
|
+
@pytest.mark.asyncio
|
|
207
|
+
async def test_chain_from_last_run(self, storage):
|
|
208
|
+
"""Test getting chain from last run returns full chain."""
|
|
209
|
+
# Create chain
|
|
210
|
+
run1 = WorkflowRun(
|
|
211
|
+
run_id="first",
|
|
212
|
+
workflow_name="my_workflow",
|
|
213
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
214
|
+
)
|
|
215
|
+
await storage.create_run(run1)
|
|
216
|
+
|
|
217
|
+
run2 = WorkflowRun(
|
|
218
|
+
run_id="last",
|
|
219
|
+
workflow_name="my_workflow",
|
|
220
|
+
status=RunStatus.RUNNING,
|
|
221
|
+
continued_from_run_id="first",
|
|
222
|
+
)
|
|
223
|
+
await storage.create_run(run2)
|
|
224
|
+
|
|
225
|
+
await storage.update_run_continuation("first", "last")
|
|
226
|
+
|
|
227
|
+
chain = await get_workflow_chain("last", storage=storage)
|
|
228
|
+
|
|
229
|
+
assert len(chain) == 2
|
|
230
|
+
assert chain[0].run_id == "first"
|
|
231
|
+
assert chain[1].run_id == "last"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestCancellationPreventsContination:
|
|
235
|
+
"""Test that cancellation prevents continue_as_new."""
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_cancelled_workflow_cannot_continue_as_new(self):
|
|
239
|
+
"""Test that continue_as_new raises CancellationError when cancelled."""
|
|
240
|
+
ctx = LocalContext(
|
|
241
|
+
run_id="test_run",
|
|
242
|
+
workflow_name="test_workflow",
|
|
243
|
+
storage=None,
|
|
244
|
+
durable=False,
|
|
245
|
+
)
|
|
246
|
+
ctx.request_cancellation(reason="User cancelled")
|
|
247
|
+
set_context(ctx)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# Should raise CancellationError, not ContinueAsNewSignal
|
|
251
|
+
with pytest.raises(CancellationError):
|
|
252
|
+
continue_as_new("arg1")
|
|
253
|
+
finally:
|
|
254
|
+
set_context(None)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class TestContinueAsNewWithArgs:
|
|
258
|
+
"""Test continue_as_new with various argument patterns."""
|
|
259
|
+
|
|
260
|
+
@pytest.mark.asyncio
|
|
261
|
+
async def test_continue_with_positional_args(self):
|
|
262
|
+
"""Test continue_as_new with positional args."""
|
|
263
|
+
ctx = LocalContext(
|
|
264
|
+
run_id="test_run",
|
|
265
|
+
workflow_name="test_workflow",
|
|
266
|
+
storage=None,
|
|
267
|
+
durable=False,
|
|
268
|
+
)
|
|
269
|
+
set_context(ctx)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
273
|
+
continue_as_new("a", "b", "c")
|
|
274
|
+
|
|
275
|
+
assert exc_info.value.workflow_args == ("a", "b", "c")
|
|
276
|
+
assert exc_info.value.workflow_kwargs == {}
|
|
277
|
+
finally:
|
|
278
|
+
set_context(None)
|
|
279
|
+
|
|
280
|
+
@pytest.mark.asyncio
|
|
281
|
+
async def test_continue_with_keyword_args(self):
|
|
282
|
+
"""Test continue_as_new with keyword args."""
|
|
283
|
+
ctx = LocalContext(
|
|
284
|
+
run_id="test_run",
|
|
285
|
+
workflow_name="test_workflow",
|
|
286
|
+
storage=None,
|
|
287
|
+
durable=False,
|
|
288
|
+
)
|
|
289
|
+
set_context(ctx)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
293
|
+
continue_as_new(cursor="abc", limit=100)
|
|
294
|
+
|
|
295
|
+
assert exc_info.value.workflow_args == ()
|
|
296
|
+
assert exc_info.value.workflow_kwargs == {"cursor": "abc", "limit": 100}
|
|
297
|
+
finally:
|
|
298
|
+
set_context(None)
|
|
299
|
+
|
|
300
|
+
@pytest.mark.asyncio
|
|
301
|
+
async def test_continue_with_complex_args(self):
|
|
302
|
+
"""Test continue_as_new with complex types."""
|
|
303
|
+
ctx = LocalContext(
|
|
304
|
+
run_id="test_run",
|
|
305
|
+
workflow_name="test_workflow",
|
|
306
|
+
storage=None,
|
|
307
|
+
durable=False,
|
|
308
|
+
)
|
|
309
|
+
set_context(ctx)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
complex_data = {"items": [1, 2, 3], "metadata": {"key": "value"}}
|
|
313
|
+
|
|
314
|
+
with pytest.raises(ContinueAsNewSignal) as exc_info:
|
|
315
|
+
continue_as_new(data=complex_data)
|
|
316
|
+
|
|
317
|
+
assert exc_info.value.workflow_kwargs["data"] == complex_data
|
|
318
|
+
finally:
|
|
319
|
+
set_context(None)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class TestFileStorageChain:
|
|
323
|
+
"""Test chain methods with FileStorageBackend."""
|
|
324
|
+
|
|
325
|
+
@pytest.mark.asyncio
|
|
326
|
+
async def test_file_storage_workflow_chain(self, tmp_path):
|
|
327
|
+
"""Test get_workflow_chain with FileStorageBackend."""
|
|
328
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
329
|
+
|
|
330
|
+
storage = FileStorageBackend(base_path=str(tmp_path / "workflow_data"))
|
|
331
|
+
|
|
332
|
+
# Create chain
|
|
333
|
+
run1 = WorkflowRun(
|
|
334
|
+
run_id="run_1",
|
|
335
|
+
workflow_name="my_workflow",
|
|
336
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
337
|
+
)
|
|
338
|
+
await storage.create_run(run1)
|
|
339
|
+
|
|
340
|
+
run2 = WorkflowRun(
|
|
341
|
+
run_id="run_2",
|
|
342
|
+
workflow_name="my_workflow",
|
|
343
|
+
status=RunStatus.RUNNING,
|
|
344
|
+
continued_from_run_id="run_1",
|
|
345
|
+
)
|
|
346
|
+
await storage.create_run(run2)
|
|
347
|
+
|
|
348
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
349
|
+
|
|
350
|
+
# Get chain
|
|
351
|
+
chain = await storage.get_workflow_chain("run_2")
|
|
352
|
+
|
|
353
|
+
assert len(chain) == 2
|
|
354
|
+
assert chain[0].run_id == "run_1"
|
|
355
|
+
assert chain[1].run_id == "run_2"
|
|
356
|
+
|
|
357
|
+
@pytest.mark.asyncio
|
|
358
|
+
async def test_file_storage_update_continuation(self, tmp_path):
|
|
359
|
+
"""Test update_run_continuation with FileStorageBackend."""
|
|
360
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
361
|
+
|
|
362
|
+
storage = FileStorageBackend(base_path=str(tmp_path / "workflow_data"))
|
|
363
|
+
|
|
364
|
+
# Create run
|
|
365
|
+
run = WorkflowRun(
|
|
366
|
+
run_id="run_1",
|
|
367
|
+
workflow_name="my_workflow",
|
|
368
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
369
|
+
)
|
|
370
|
+
await storage.create_run(run)
|
|
371
|
+
|
|
372
|
+
# Update continuation
|
|
373
|
+
await storage.update_run_continuation("run_1", "run_2")
|
|
374
|
+
|
|
375
|
+
# Verify
|
|
376
|
+
updated_run = await storage.get_run("run_1")
|
|
377
|
+
assert updated_run.continued_to_run_id == "run_2"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class TestContinuedAsNewStatus:
|
|
381
|
+
"""Test CONTINUED_AS_NEW status handling."""
|
|
382
|
+
|
|
383
|
+
@pytest.mark.asyncio
|
|
384
|
+
async def test_continued_as_new_is_terminal(self, storage):
|
|
385
|
+
"""Test that CONTINUED_AS_NEW is treated as terminal status."""
|
|
386
|
+
from pyworkflow import cancel_workflow
|
|
387
|
+
|
|
388
|
+
run = WorkflowRun(
|
|
389
|
+
run_id="run_1",
|
|
390
|
+
workflow_name="my_workflow",
|
|
391
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
392
|
+
)
|
|
393
|
+
await storage.create_run(run)
|
|
394
|
+
|
|
395
|
+
# Trying to cancel should return False (terminal state)
|
|
396
|
+
result = await cancel_workflow("run_1", storage=storage)
|
|
397
|
+
assert result is False
|
|
398
|
+
|
|
399
|
+
@pytest.mark.asyncio
|
|
400
|
+
async def test_list_runs_includes_continued_as_new(self, storage):
|
|
401
|
+
"""Test that list_runs can filter by CONTINUED_AS_NEW status."""
|
|
402
|
+
# Create runs with different statuses
|
|
403
|
+
run1 = WorkflowRun(
|
|
404
|
+
run_id="run_1",
|
|
405
|
+
workflow_name="my_workflow",
|
|
406
|
+
status=RunStatus.COMPLETED,
|
|
407
|
+
)
|
|
408
|
+
await storage.create_run(run1)
|
|
409
|
+
|
|
410
|
+
run2 = WorkflowRun(
|
|
411
|
+
run_id="run_2",
|
|
412
|
+
workflow_name="my_workflow",
|
|
413
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
414
|
+
)
|
|
415
|
+
await storage.create_run(run2)
|
|
416
|
+
|
|
417
|
+
run3 = WorkflowRun(
|
|
418
|
+
run_id="run_3",
|
|
419
|
+
workflow_name="my_workflow",
|
|
420
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
421
|
+
)
|
|
422
|
+
await storage.create_run(run3)
|
|
423
|
+
|
|
424
|
+
# Filter by CONTINUED_AS_NEW
|
|
425
|
+
runs, _ = await storage.list_runs(status=RunStatus.CONTINUED_AS_NEW)
|
|
426
|
+
|
|
427
|
+
assert len(runs) == 2
|
|
428
|
+
assert all(r.status == RunStatus.CONTINUED_AS_NEW for r in runs)
|