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,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests for fault tolerance features.
|
|
3
|
+
|
|
4
|
+
Tests cover the full workflow recovery flow after simulated worker failures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pyworkflow import (
|
|
12
|
+
reset_config,
|
|
13
|
+
workflow,
|
|
14
|
+
)
|
|
15
|
+
from pyworkflow.engine.events import (
|
|
16
|
+
EventType,
|
|
17
|
+
create_step_completed_event,
|
|
18
|
+
create_workflow_interrupted_event,
|
|
19
|
+
create_workflow_started_event,
|
|
20
|
+
)
|
|
21
|
+
from pyworkflow.serialization.encoder import serialize, serialize_args, serialize_kwargs
|
|
22
|
+
from pyworkflow.storage.memory import InMemoryStorageBackend
|
|
23
|
+
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def storage():
|
|
28
|
+
"""Provide a clean in-memory storage backend for each test."""
|
|
29
|
+
return InMemoryStorageBackend()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def reset_pyworkflow_config():
|
|
34
|
+
"""Reset configuration before and after each test."""
|
|
35
|
+
reset_config()
|
|
36
|
+
yield
|
|
37
|
+
reset_config()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestRecoveryDetection:
|
|
41
|
+
"""Tests for detecting recovery scenarios."""
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_detect_running_workflow_as_recovery_scenario(self, storage):
|
|
45
|
+
"""A workflow in RUNNING status should be detected as recovery scenario."""
|
|
46
|
+
# Create a workflow run that's stuck in RUNNING (simulates worker crash)
|
|
47
|
+
run = WorkflowRun(
|
|
48
|
+
run_id="test_run",
|
|
49
|
+
workflow_name="test_workflow",
|
|
50
|
+
status=RunStatus.RUNNING,
|
|
51
|
+
created_at=datetime.now(UTC),
|
|
52
|
+
started_at=datetime.now(UTC),
|
|
53
|
+
input_args=serialize_args(),
|
|
54
|
+
input_kwargs=serialize_kwargs(),
|
|
55
|
+
recovery_attempts=0,
|
|
56
|
+
max_recovery_attempts=3,
|
|
57
|
+
recover_on_worker_loss=True,
|
|
58
|
+
)
|
|
59
|
+
await storage.create_run(run)
|
|
60
|
+
|
|
61
|
+
# Verify the run is in RUNNING status
|
|
62
|
+
retrieved_run = await storage.get_run("test_run")
|
|
63
|
+
assert retrieved_run.status == RunStatus.RUNNING
|
|
64
|
+
assert retrieved_run.recover_on_worker_loss is True
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_recovery_disabled_workflow(self, storage):
|
|
68
|
+
"""Workflow with recover_on_worker_loss=False should not auto-recover."""
|
|
69
|
+
run = WorkflowRun(
|
|
70
|
+
run_id="test_run",
|
|
71
|
+
workflow_name="test_workflow",
|
|
72
|
+
status=RunStatus.RUNNING,
|
|
73
|
+
created_at=datetime.now(UTC),
|
|
74
|
+
input_args=serialize_args(),
|
|
75
|
+
input_kwargs=serialize_kwargs(),
|
|
76
|
+
recovery_attempts=0,
|
|
77
|
+
max_recovery_attempts=3,
|
|
78
|
+
recover_on_worker_loss=False, # Disabled
|
|
79
|
+
)
|
|
80
|
+
await storage.create_run(run)
|
|
81
|
+
|
|
82
|
+
retrieved_run = await storage.get_run("test_run")
|
|
83
|
+
assert retrieved_run.recover_on_worker_loss is False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestInterruptedEventRecording:
|
|
87
|
+
"""Tests for recording WORKFLOW_INTERRUPTED events."""
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_record_interrupted_event(self, storage):
|
|
91
|
+
"""Should record WORKFLOW_INTERRUPTED event on recovery."""
|
|
92
|
+
# Create workflow run
|
|
93
|
+
run = WorkflowRun(
|
|
94
|
+
run_id="test_run",
|
|
95
|
+
workflow_name="test_workflow",
|
|
96
|
+
status=RunStatus.RUNNING,
|
|
97
|
+
created_at=datetime.now(UTC),
|
|
98
|
+
input_args=serialize_args(),
|
|
99
|
+
input_kwargs=serialize_kwargs(),
|
|
100
|
+
)
|
|
101
|
+
await storage.create_run(run)
|
|
102
|
+
|
|
103
|
+
# Record workflow started event
|
|
104
|
+
start_event = create_workflow_started_event(
|
|
105
|
+
run_id="test_run",
|
|
106
|
+
workflow_name="test_workflow",
|
|
107
|
+
args=serialize_args(),
|
|
108
|
+
kwargs=serialize_kwargs(),
|
|
109
|
+
)
|
|
110
|
+
await storage.record_event(start_event)
|
|
111
|
+
|
|
112
|
+
# Record step completed event
|
|
113
|
+
step_event = create_step_completed_event(
|
|
114
|
+
run_id="test_run",
|
|
115
|
+
step_id="step_1",
|
|
116
|
+
result=serialize(42),
|
|
117
|
+
step_name="test_step",
|
|
118
|
+
)
|
|
119
|
+
await storage.record_event(step_event)
|
|
120
|
+
|
|
121
|
+
# Simulate worker crash - record interrupted event
|
|
122
|
+
interrupted_event = create_workflow_interrupted_event(
|
|
123
|
+
run_id="test_run",
|
|
124
|
+
reason="worker_lost",
|
|
125
|
+
worker_id="worker_1",
|
|
126
|
+
last_event_sequence=2,
|
|
127
|
+
error="Worker process terminated",
|
|
128
|
+
recovery_attempt=1,
|
|
129
|
+
recoverable=True,
|
|
130
|
+
)
|
|
131
|
+
await storage.record_event(interrupted_event)
|
|
132
|
+
|
|
133
|
+
# Verify events
|
|
134
|
+
events = await storage.get_events("test_run")
|
|
135
|
+
assert len(events) == 3
|
|
136
|
+
|
|
137
|
+
# Check interrupted event
|
|
138
|
+
interrupted = [e for e in events if e.type == EventType.WORKFLOW_INTERRUPTED]
|
|
139
|
+
assert len(interrupted) == 1
|
|
140
|
+
assert interrupted[0].data["reason"] == "worker_lost"
|
|
141
|
+
assert interrupted[0].data["recovery_attempt"] == 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestRecoveryAttemptTracking:
|
|
145
|
+
"""Tests for tracking recovery attempts."""
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_increment_recovery_attempts(self, storage):
|
|
149
|
+
"""Should increment recovery_attempts on each recovery."""
|
|
150
|
+
run = WorkflowRun(
|
|
151
|
+
run_id="test_run",
|
|
152
|
+
workflow_name="test_workflow",
|
|
153
|
+
status=RunStatus.RUNNING,
|
|
154
|
+
created_at=datetime.now(UTC),
|
|
155
|
+
input_args=serialize_args(),
|
|
156
|
+
input_kwargs=serialize_kwargs(),
|
|
157
|
+
recovery_attempts=0,
|
|
158
|
+
max_recovery_attempts=3,
|
|
159
|
+
)
|
|
160
|
+
await storage.create_run(run)
|
|
161
|
+
|
|
162
|
+
# First recovery attempt
|
|
163
|
+
await storage.update_run_recovery_attempts("test_run", 1)
|
|
164
|
+
run1 = await storage.get_run("test_run")
|
|
165
|
+
assert run1.recovery_attempts == 1
|
|
166
|
+
|
|
167
|
+
# Second recovery attempt
|
|
168
|
+
await storage.update_run_recovery_attempts("test_run", 2)
|
|
169
|
+
run2 = await storage.get_run("test_run")
|
|
170
|
+
assert run2.recovery_attempts == 2
|
|
171
|
+
|
|
172
|
+
# Third recovery attempt
|
|
173
|
+
await storage.update_run_recovery_attempts("test_run", 3)
|
|
174
|
+
run3 = await storage.get_run("test_run")
|
|
175
|
+
assert run3.recovery_attempts == 3
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_max_recovery_attempts_exceeded(self, storage):
|
|
179
|
+
"""Should mark workflow as FAILED when max attempts exceeded."""
|
|
180
|
+
run = WorkflowRun(
|
|
181
|
+
run_id="test_run",
|
|
182
|
+
workflow_name="test_workflow",
|
|
183
|
+
status=RunStatus.RUNNING,
|
|
184
|
+
created_at=datetime.now(UTC),
|
|
185
|
+
input_args=serialize_args(),
|
|
186
|
+
input_kwargs=serialize_kwargs(),
|
|
187
|
+
recovery_attempts=3, # Already at max
|
|
188
|
+
max_recovery_attempts=3,
|
|
189
|
+
)
|
|
190
|
+
await storage.create_run(run)
|
|
191
|
+
|
|
192
|
+
# Simulating what would happen when max exceeded
|
|
193
|
+
await storage.update_run_status(
|
|
194
|
+
run_id="test_run",
|
|
195
|
+
status=RunStatus.FAILED,
|
|
196
|
+
error="Exceeded max recovery attempts (3)",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
run = await storage.get_run("test_run")
|
|
200
|
+
assert run.status == RunStatus.FAILED
|
|
201
|
+
assert "max recovery attempts" in run.error.lower()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestEventReplayWithInterruption:
|
|
205
|
+
"""Tests for event replay after interruption."""
|
|
206
|
+
|
|
207
|
+
@pytest.mark.asyncio
|
|
208
|
+
async def test_replay_preserves_step_results_after_interruption(self, storage):
|
|
209
|
+
"""Step results should be preserved and replayable after interruption."""
|
|
210
|
+
from pyworkflow.context import LocalContext
|
|
211
|
+
from pyworkflow.engine.replay import replay_events
|
|
212
|
+
|
|
213
|
+
# Create events simulating a workflow that was interrupted
|
|
214
|
+
events = [
|
|
215
|
+
create_workflow_started_event(
|
|
216
|
+
run_id="test_run",
|
|
217
|
+
workflow_name="test_workflow",
|
|
218
|
+
args=serialize_args("arg1"),
|
|
219
|
+
kwargs=serialize_kwargs(key="value"),
|
|
220
|
+
),
|
|
221
|
+
create_step_completed_event(
|
|
222
|
+
run_id="test_run",
|
|
223
|
+
step_id="step_1",
|
|
224
|
+
result=serialize({"processed": True}),
|
|
225
|
+
step_name="step_1",
|
|
226
|
+
),
|
|
227
|
+
create_step_completed_event(
|
|
228
|
+
run_id="test_run",
|
|
229
|
+
step_id="step_2",
|
|
230
|
+
result=serialize(100),
|
|
231
|
+
step_name="step_2",
|
|
232
|
+
),
|
|
233
|
+
create_workflow_interrupted_event(
|
|
234
|
+
run_id="test_run",
|
|
235
|
+
reason="worker_lost",
|
|
236
|
+
recovery_attempt=1,
|
|
237
|
+
),
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
# Assign sequence numbers
|
|
241
|
+
for i, event in enumerate(events):
|
|
242
|
+
event.sequence = i + 1
|
|
243
|
+
await storage.record_event(event)
|
|
244
|
+
|
|
245
|
+
# Load events and replay
|
|
246
|
+
loaded_events = await storage.get_events("test_run")
|
|
247
|
+
|
|
248
|
+
ctx = LocalContext(
|
|
249
|
+
run_id="test_run",
|
|
250
|
+
workflow_name="test_workflow",
|
|
251
|
+
storage=storage,
|
|
252
|
+
event_log=loaded_events,
|
|
253
|
+
durable=True,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
await replay_events(ctx, loaded_events)
|
|
257
|
+
|
|
258
|
+
# Verify step results are available
|
|
259
|
+
assert ctx.get_step_result("step_1") == {"processed": True}
|
|
260
|
+
assert ctx.get_step_result("step_2") == 100
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class TestStatusTransitions:
|
|
264
|
+
"""Tests for workflow status transitions during recovery."""
|
|
265
|
+
|
|
266
|
+
@pytest.mark.asyncio
|
|
267
|
+
async def test_running_to_interrupted(self, storage):
|
|
268
|
+
"""RUNNING -> INTERRUPTED transition."""
|
|
269
|
+
run = WorkflowRun(
|
|
270
|
+
run_id="test_run",
|
|
271
|
+
workflow_name="test_workflow",
|
|
272
|
+
status=RunStatus.RUNNING,
|
|
273
|
+
created_at=datetime.now(UTC),
|
|
274
|
+
input_args=serialize_args(),
|
|
275
|
+
input_kwargs=serialize_kwargs(),
|
|
276
|
+
)
|
|
277
|
+
await storage.create_run(run)
|
|
278
|
+
|
|
279
|
+
await storage.update_run_status(
|
|
280
|
+
run_id="test_run",
|
|
281
|
+
status=RunStatus.INTERRUPTED,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
run = await storage.get_run("test_run")
|
|
285
|
+
assert run.status == RunStatus.INTERRUPTED
|
|
286
|
+
|
|
287
|
+
@pytest.mark.asyncio
|
|
288
|
+
async def test_interrupted_to_running_on_recovery(self, storage):
|
|
289
|
+
"""INTERRUPTED -> RUNNING transition on recovery."""
|
|
290
|
+
run = WorkflowRun(
|
|
291
|
+
run_id="test_run",
|
|
292
|
+
workflow_name="test_workflow",
|
|
293
|
+
status=RunStatus.INTERRUPTED,
|
|
294
|
+
created_at=datetime.now(UTC),
|
|
295
|
+
input_args=serialize_args(),
|
|
296
|
+
input_kwargs=serialize_kwargs(),
|
|
297
|
+
recovery_attempts=1,
|
|
298
|
+
)
|
|
299
|
+
await storage.create_run(run)
|
|
300
|
+
|
|
301
|
+
# Recovery starts
|
|
302
|
+
await storage.update_run_status(
|
|
303
|
+
run_id="test_run",
|
|
304
|
+
status=RunStatus.RUNNING,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
run = await storage.get_run("test_run")
|
|
308
|
+
assert run.status == RunStatus.RUNNING
|
|
309
|
+
|
|
310
|
+
@pytest.mark.asyncio
|
|
311
|
+
async def test_interrupted_to_failed_on_max_attempts(self, storage):
|
|
312
|
+
"""INTERRUPTED -> FAILED when max attempts exceeded."""
|
|
313
|
+
run = WorkflowRun(
|
|
314
|
+
run_id="test_run",
|
|
315
|
+
workflow_name="test_workflow",
|
|
316
|
+
status=RunStatus.INTERRUPTED,
|
|
317
|
+
created_at=datetime.now(UTC),
|
|
318
|
+
input_args=serialize_args(),
|
|
319
|
+
input_kwargs=serialize_kwargs(),
|
|
320
|
+
recovery_attempts=3,
|
|
321
|
+
max_recovery_attempts=3,
|
|
322
|
+
)
|
|
323
|
+
await storage.create_run(run)
|
|
324
|
+
|
|
325
|
+
await storage.update_run_status(
|
|
326
|
+
run_id="test_run",
|
|
327
|
+
status=RunStatus.FAILED,
|
|
328
|
+
error="Exceeded max recovery attempts",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
run = await storage.get_run("test_run")
|
|
332
|
+
assert run.status == RunStatus.FAILED
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class TestWorkflowDecoratorRecoveryConfig:
|
|
336
|
+
"""Tests for workflow decorator recovery configuration."""
|
|
337
|
+
|
|
338
|
+
def test_workflow_decorator_stores_recovery_config(self):
|
|
339
|
+
"""@workflow decorator should store recovery config on wrapper."""
|
|
340
|
+
|
|
341
|
+
@workflow(
|
|
342
|
+
name="test_recovery_config_1",
|
|
343
|
+
recover_on_worker_loss=True,
|
|
344
|
+
max_recovery_attempts=5,
|
|
345
|
+
)
|
|
346
|
+
async def my_workflow():
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
assert my_workflow.__workflow_recover_on_worker_loss__ is True
|
|
350
|
+
assert my_workflow.__workflow_max_recovery_attempts__ == 5
|
|
351
|
+
|
|
352
|
+
def test_workflow_decorator_defaults_none(self):
|
|
353
|
+
"""@workflow decorator should default recovery config to None when called with ()."""
|
|
354
|
+
|
|
355
|
+
@workflow(name="test_recovery_config_2")
|
|
356
|
+
async def my_workflow():
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
assert my_workflow.__workflow_recover_on_worker_loss__ is None
|
|
360
|
+
assert my_workflow.__workflow_max_recovery_attempts__ is None
|
|
361
|
+
|
|
362
|
+
def test_workflow_decorator_disable_recovery(self):
|
|
363
|
+
"""@workflow decorator can disable recovery."""
|
|
364
|
+
|
|
365
|
+
@workflow(name="test_recovery_config_3", recover_on_worker_loss=False)
|
|
366
|
+
async def my_workflow():
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
assert my_workflow.__workflow_recover_on_worker_loss__ is False
|