pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.10__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.
- pyworkflow/__init__.py +10 -1
- pyworkflow/celery/tasks.py +272 -24
- pyworkflow/cli/__init__.py +4 -1
- pyworkflow/cli/commands/runs.py +4 -4
- pyworkflow/cli/commands/setup.py +203 -4
- pyworkflow/cli/utils/config_generator.py +76 -3
- pyworkflow/cli/utils/docker_manager.py +232 -0
- pyworkflow/config.py +94 -17
- pyworkflow/context/__init__.py +13 -0
- pyworkflow/context/base.py +26 -0
- pyworkflow/context/local.py +80 -0
- pyworkflow/context/step_context.py +295 -0
- pyworkflow/core/registry.py +6 -1
- pyworkflow/core/step.py +141 -0
- pyworkflow/core/workflow.py +56 -0
- pyworkflow/engine/events.py +30 -0
- pyworkflow/engine/replay.py +39 -0
- pyworkflow/primitives/child_workflow.py +1 -1
- pyworkflow/runtime/local.py +1 -1
- pyworkflow/storage/__init__.py +14 -0
- pyworkflow/storage/base.py +35 -0
- pyworkflow/storage/cassandra.py +1747 -0
- pyworkflow/storage/config.py +69 -0
- pyworkflow/storage/dynamodb.py +31 -2
- pyworkflow/storage/file.py +28 -0
- pyworkflow/storage/memory.py +18 -0
- pyworkflow/storage/mysql.py +1159 -0
- pyworkflow/storage/postgres.py +27 -2
- pyworkflow/storage/schemas.py +4 -3
- pyworkflow/storage/sqlite.py +25 -2
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/METADATA +7 -4
- pyworkflow_engine-0.1.10.dist-info/RECORD +91 -0
- pyworkflow_engine-0.1.10.dist-info/top_level.txt +1 -0
- dashboard/backend/app/__init__.py +0 -1
- dashboard/backend/app/config.py +0 -32
- dashboard/backend/app/controllers/__init__.py +0 -6
- dashboard/backend/app/controllers/run_controller.py +0 -86
- dashboard/backend/app/controllers/workflow_controller.py +0 -33
- dashboard/backend/app/dependencies/__init__.py +0 -5
- dashboard/backend/app/dependencies/storage.py +0 -50
- dashboard/backend/app/repositories/__init__.py +0 -6
- dashboard/backend/app/repositories/run_repository.py +0 -80
- dashboard/backend/app/repositories/workflow_repository.py +0 -27
- dashboard/backend/app/rest/__init__.py +0 -8
- dashboard/backend/app/rest/v1/__init__.py +0 -12
- dashboard/backend/app/rest/v1/health.py +0 -33
- dashboard/backend/app/rest/v1/runs.py +0 -133
- dashboard/backend/app/rest/v1/workflows.py +0 -41
- dashboard/backend/app/schemas/__init__.py +0 -23
- dashboard/backend/app/schemas/common.py +0 -16
- dashboard/backend/app/schemas/event.py +0 -24
- dashboard/backend/app/schemas/hook.py +0 -25
- dashboard/backend/app/schemas/run.py +0 -54
- dashboard/backend/app/schemas/step.py +0 -28
- dashboard/backend/app/schemas/workflow.py +0 -31
- dashboard/backend/app/server.py +0 -87
- dashboard/backend/app/services/__init__.py +0 -6
- dashboard/backend/app/services/run_service.py +0 -240
- dashboard/backend/app/services/workflow_service.py +0 -155
- dashboard/backend/main.py +0 -18
- docs/concepts/cancellation.mdx +0 -362
- docs/concepts/continue-as-new.mdx +0 -434
- docs/concepts/events.mdx +0 -266
- docs/concepts/fault-tolerance.mdx +0 -370
- docs/concepts/hooks.mdx +0 -552
- docs/concepts/limitations.mdx +0 -167
- docs/concepts/schedules.mdx +0 -775
- docs/concepts/sleep.mdx +0 -312
- docs/concepts/steps.mdx +0 -301
- docs/concepts/workflows.mdx +0 -255
- docs/guides/cli.mdx +0 -942
- docs/guides/configuration.mdx +0 -560
- docs/introduction.mdx +0 -155
- docs/quickstart.mdx +0 -279
- examples/__init__.py +0 -1
- examples/celery/__init__.py +0 -1
- examples/celery/durable/docker-compose.yml +0 -55
- examples/celery/durable/pyworkflow.config.yaml +0 -12
- examples/celery/durable/workflows/__init__.py +0 -122
- examples/celery/durable/workflows/basic.py +0 -87
- examples/celery/durable/workflows/batch_processing.py +0 -102
- examples/celery/durable/workflows/cancellation.py +0 -273
- examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
- examples/celery/durable/workflows/child_workflows.py +0 -202
- examples/celery/durable/workflows/continue_as_new.py +0 -260
- examples/celery/durable/workflows/fault_tolerance.py +0 -210
- examples/celery/durable/workflows/hooks.py +0 -211
- examples/celery/durable/workflows/idempotency.py +0 -112
- examples/celery/durable/workflows/long_running.py +0 -99
- examples/celery/durable/workflows/retries.py +0 -101
- examples/celery/durable/workflows/schedules.py +0 -209
- examples/celery/transient/01_basic_workflow.py +0 -91
- examples/celery/transient/02_fault_tolerance.py +0 -257
- examples/celery/transient/__init__.py +0 -20
- examples/celery/transient/pyworkflow.config.yaml +0 -25
- examples/local/__init__.py +0 -1
- examples/local/durable/01_basic_workflow.py +0 -94
- examples/local/durable/02_file_storage.py +0 -132
- examples/local/durable/03_retries.py +0 -169
- examples/local/durable/04_long_running.py +0 -119
- examples/local/durable/05_event_log.py +0 -145
- examples/local/durable/06_idempotency.py +0 -148
- examples/local/durable/07_hooks.py +0 -334
- examples/local/durable/08_cancellation.py +0 -233
- examples/local/durable/09_child_workflows.py +0 -198
- examples/local/durable/10_child_workflow_patterns.py +0 -265
- examples/local/durable/11_continue_as_new.py +0 -249
- examples/local/durable/12_schedules.py +0 -198
- examples/local/durable/__init__.py +0 -1
- examples/local/transient/01_quick_tasks.py +0 -87
- examples/local/transient/02_retries.py +0 -130
- examples/local/transient/03_sleep.py +0 -141
- examples/local/transient/__init__.py +0 -1
- pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +0 -330
- tests/integration/test_child_workflows.py +0 -439
- tests/integration/test_continue_as_new.py +0 -428
- tests/integration/test_dynamodb_storage.py +0 -1146
- tests/integration/test_fault_tolerance.py +0 -369
- tests/integration/test_schedule_storage.py +0 -484
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +0 -1
- tests/unit/backends/test_dynamodb_storage.py +0 -1554
- tests/unit/backends/test_postgres_storage.py +0 -1281
- tests/unit/backends/test_sqlite_storage.py +0 -1460
- tests/unit/conftest.py +0 -41
- tests/unit/test_cancellation.py +0 -364
- tests/unit/test_child_workflows.py +0 -680
- tests/unit/test_continue_as_new.py +0 -441
- tests/unit/test_event_limits.py +0 -316
- tests/unit/test_executor.py +0 -320
- tests/unit/test_fault_tolerance.py +0 -334
- tests/unit/test_hooks.py +0 -495
- tests/unit/test_registry.py +0 -261
- tests/unit/test_replay.py +0 -420
- tests/unit/test_schedule_schemas.py +0 -285
- tests/unit/test_schedule_utils.py +0 -286
- tests/unit/test_scheduled_workflow.py +0 -274
- tests/unit/test_step.py +0 -353
- tests/unit/test_workflow.py +0 -243
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/licenses/LICENSE +0 -0
tests/unit/test_registry.py
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for workflow and step registry.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from pyworkflow.core.registry import (
|
|
8
|
-
WorkflowRegistry,
|
|
9
|
-
get_workflow,
|
|
10
|
-
get_workflow_by_func,
|
|
11
|
-
register_step,
|
|
12
|
-
register_workflow,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class TestWorkflowRegistry:
|
|
17
|
-
"""Test the workflow registry functionality."""
|
|
18
|
-
|
|
19
|
-
def test_register_and_get_workflow(self):
|
|
20
|
-
"""Test registering and retrieving a workflow."""
|
|
21
|
-
registry = WorkflowRegistry()
|
|
22
|
-
|
|
23
|
-
# Define a simple workflow function
|
|
24
|
-
async def my_workflow():
|
|
25
|
-
pass
|
|
26
|
-
|
|
27
|
-
# Register workflow
|
|
28
|
-
registry.register_workflow(
|
|
29
|
-
name="test_workflow",
|
|
30
|
-
func=my_workflow,
|
|
31
|
-
original_func=my_workflow,
|
|
32
|
-
max_duration="1h",
|
|
33
|
-
tags=["backend", "critical"],
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
# Retrieve workflow
|
|
37
|
-
workflow_meta = registry.get_workflow("test_workflow")
|
|
38
|
-
|
|
39
|
-
assert workflow_meta is not None
|
|
40
|
-
assert workflow_meta.name == "test_workflow"
|
|
41
|
-
assert workflow_meta.func == my_workflow
|
|
42
|
-
assert workflow_meta.max_duration == "1h"
|
|
43
|
-
assert workflow_meta.tags == ["backend", "critical"]
|
|
44
|
-
|
|
45
|
-
def test_get_workflow_by_func(self):
|
|
46
|
-
"""Test retrieving a workflow by its function reference."""
|
|
47
|
-
registry = WorkflowRegistry()
|
|
48
|
-
|
|
49
|
-
async def my_workflow():
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
|
-
# Register workflow
|
|
53
|
-
registry.register_workflow(
|
|
54
|
-
name="test_workflow",
|
|
55
|
-
func=my_workflow,
|
|
56
|
-
original_func=my_workflow,
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
# Retrieve by function
|
|
60
|
-
workflow_meta = registry.get_workflow_by_func(my_workflow)
|
|
61
|
-
|
|
62
|
-
assert workflow_meta is not None
|
|
63
|
-
assert workflow_meta.name == "test_workflow"
|
|
64
|
-
|
|
65
|
-
def test_get_nonexistent_workflow(self):
|
|
66
|
-
"""Test retrieving a workflow that doesn't exist."""
|
|
67
|
-
registry = WorkflowRegistry()
|
|
68
|
-
workflow_meta = registry.get_workflow("nonexistent")
|
|
69
|
-
assert workflow_meta is None
|
|
70
|
-
|
|
71
|
-
def test_register_duplicate_workflow(self):
|
|
72
|
-
"""Test that registering the same workflow name raises an error."""
|
|
73
|
-
registry = WorkflowRegistry()
|
|
74
|
-
|
|
75
|
-
async def workflow1():
|
|
76
|
-
pass
|
|
77
|
-
|
|
78
|
-
async def workflow2():
|
|
79
|
-
pass
|
|
80
|
-
|
|
81
|
-
# Register first workflow
|
|
82
|
-
registry.register_workflow(
|
|
83
|
-
name="duplicate",
|
|
84
|
-
func=workflow1,
|
|
85
|
-
original_func=workflow1,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
# Registering again should raise ValueError
|
|
89
|
-
with pytest.raises(ValueError, match="already registered"):
|
|
90
|
-
registry.register_workflow(
|
|
91
|
-
name="duplicate",
|
|
92
|
-
func=workflow2,
|
|
93
|
-
original_func=workflow2,
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
def test_list_workflows(self):
|
|
97
|
-
"""Test listing all registered workflows."""
|
|
98
|
-
registry = WorkflowRegistry()
|
|
99
|
-
|
|
100
|
-
async def workflow1():
|
|
101
|
-
pass
|
|
102
|
-
|
|
103
|
-
async def workflow2():
|
|
104
|
-
pass
|
|
105
|
-
|
|
106
|
-
# Register multiple workflows
|
|
107
|
-
registry.register_workflow(
|
|
108
|
-
name="workflow_a",
|
|
109
|
-
func=workflow1,
|
|
110
|
-
original_func=workflow1,
|
|
111
|
-
)
|
|
112
|
-
registry.register_workflow(
|
|
113
|
-
name="workflow_b",
|
|
114
|
-
func=workflow2,
|
|
115
|
-
original_func=workflow2,
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
workflows = registry.list_workflows()
|
|
119
|
-
assert len(workflows) == 2
|
|
120
|
-
assert "workflow_a" in workflows
|
|
121
|
-
assert "workflow_b" in workflows
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
class TestStepRegistry:
|
|
125
|
-
"""Test the step registry functionality."""
|
|
126
|
-
|
|
127
|
-
def test_register_and_get_step(self):
|
|
128
|
-
"""Test registering and retrieving a step."""
|
|
129
|
-
registry = WorkflowRegistry()
|
|
130
|
-
|
|
131
|
-
async def my_step():
|
|
132
|
-
pass
|
|
133
|
-
|
|
134
|
-
# Register step
|
|
135
|
-
registry.register_step(
|
|
136
|
-
name="test_step",
|
|
137
|
-
func=my_step,
|
|
138
|
-
original_func=my_step,
|
|
139
|
-
max_retries=5,
|
|
140
|
-
retry_delay="10",
|
|
141
|
-
timeout=30,
|
|
142
|
-
metadata={"type": "api_call"},
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
# Retrieve step
|
|
146
|
-
step_meta = registry.get_step("test_step")
|
|
147
|
-
|
|
148
|
-
assert step_meta is not None
|
|
149
|
-
assert step_meta.name == "test_step"
|
|
150
|
-
assert step_meta.func == my_step
|
|
151
|
-
assert step_meta.max_retries == 5
|
|
152
|
-
assert step_meta.retry_delay == "10"
|
|
153
|
-
assert step_meta.timeout == 30
|
|
154
|
-
assert step_meta.metadata == {"type": "api_call"}
|
|
155
|
-
|
|
156
|
-
def test_get_nonexistent_step(self):
|
|
157
|
-
"""Test retrieving a step that doesn't exist."""
|
|
158
|
-
registry = WorkflowRegistry()
|
|
159
|
-
step_meta = registry.get_step("nonexistent")
|
|
160
|
-
assert step_meta is None
|
|
161
|
-
|
|
162
|
-
def test_register_duplicate_step(self):
|
|
163
|
-
"""Test that registering the same step name raises an error."""
|
|
164
|
-
registry = WorkflowRegistry()
|
|
165
|
-
|
|
166
|
-
async def step1():
|
|
167
|
-
pass
|
|
168
|
-
|
|
169
|
-
async def step2():
|
|
170
|
-
pass
|
|
171
|
-
|
|
172
|
-
# Register first step
|
|
173
|
-
registry.register_step(
|
|
174
|
-
name="duplicate",
|
|
175
|
-
func=step1,
|
|
176
|
-
original_func=step1,
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
# Registering again should raise ValueError
|
|
180
|
-
with pytest.raises(ValueError, match="already registered"):
|
|
181
|
-
registry.register_step(
|
|
182
|
-
name="duplicate",
|
|
183
|
-
func=step2,
|
|
184
|
-
original_func=step2,
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
def test_list_steps(self):
|
|
188
|
-
"""Test listing all registered steps."""
|
|
189
|
-
registry = WorkflowRegistry()
|
|
190
|
-
|
|
191
|
-
async def step1():
|
|
192
|
-
pass
|
|
193
|
-
|
|
194
|
-
async def step2():
|
|
195
|
-
pass
|
|
196
|
-
|
|
197
|
-
# Register multiple steps
|
|
198
|
-
registry.register_step(name="step_a", func=step1, original_func=step1)
|
|
199
|
-
registry.register_step(name="step_b", func=step2, original_func=step2)
|
|
200
|
-
|
|
201
|
-
steps = registry.list_steps()
|
|
202
|
-
assert len(steps) == 2
|
|
203
|
-
assert "step_a" in steps
|
|
204
|
-
assert "step_b" in steps
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
class TestGlobalRegistry:
|
|
208
|
-
"""Test the global registry functions."""
|
|
209
|
-
|
|
210
|
-
def test_register_workflow_globally(self):
|
|
211
|
-
"""Test the global register_workflow function."""
|
|
212
|
-
|
|
213
|
-
async def global_workflow():
|
|
214
|
-
pass
|
|
215
|
-
|
|
216
|
-
register_workflow(
|
|
217
|
-
name="global_test_workflow",
|
|
218
|
-
func=global_workflow,
|
|
219
|
-
original_func=global_workflow,
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
# Should be able to retrieve it
|
|
223
|
-
workflow_meta = get_workflow("global_test_workflow")
|
|
224
|
-
assert workflow_meta is not None
|
|
225
|
-
assert workflow_meta.name == "global_test_workflow"
|
|
226
|
-
|
|
227
|
-
def test_register_step_globally(self):
|
|
228
|
-
"""Test the global register_step function."""
|
|
229
|
-
|
|
230
|
-
async def global_step():
|
|
231
|
-
pass
|
|
232
|
-
|
|
233
|
-
register_step(
|
|
234
|
-
name="global_test_step",
|
|
235
|
-
func=global_step,
|
|
236
|
-
original_func=global_step,
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
# Should be able to retrieve it from global registry
|
|
240
|
-
from pyworkflow.core.registry import _registry
|
|
241
|
-
|
|
242
|
-
step_meta = _registry.get_step("global_test_step")
|
|
243
|
-
assert step_meta is not None
|
|
244
|
-
assert step_meta.name == "global_test_step"
|
|
245
|
-
|
|
246
|
-
def test_get_workflow_by_func_globally(self):
|
|
247
|
-
"""Test retrieving workflow by function from global registry."""
|
|
248
|
-
|
|
249
|
-
async def another_workflow():
|
|
250
|
-
pass
|
|
251
|
-
|
|
252
|
-
register_workflow(
|
|
253
|
-
name="func_lookup_test",
|
|
254
|
-
func=another_workflow,
|
|
255
|
-
original_func=another_workflow,
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
# Should be able to retrieve by function
|
|
259
|
-
workflow_meta = get_workflow_by_func(another_workflow)
|
|
260
|
-
assert workflow_meta is not None
|
|
261
|
-
assert workflow_meta.name == "func_lookup_test"
|
tests/unit/test_replay.py
DELETED
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for event replay engine.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from datetime import UTC, datetime
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from pyworkflow.context import LocalContext
|
|
10
|
-
from pyworkflow.engine.events import (
|
|
11
|
-
Event,
|
|
12
|
-
EventType,
|
|
13
|
-
create_hook_created_event,
|
|
14
|
-
create_hook_expired_event,
|
|
15
|
-
create_hook_received_event,
|
|
16
|
-
create_sleep_completed_event,
|
|
17
|
-
create_sleep_started_event,
|
|
18
|
-
create_step_completed_event,
|
|
19
|
-
create_step_started_event,
|
|
20
|
-
)
|
|
21
|
-
from pyworkflow.engine.replay import EventReplayer, replay_events
|
|
22
|
-
from pyworkflow.serialization.encoder import serialize
|
|
23
|
-
from pyworkflow.storage.file import FileStorageBackend
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class TestEventReplayer:
|
|
27
|
-
"""Test the EventReplayer class."""
|
|
28
|
-
|
|
29
|
-
@pytest.mark.asyncio
|
|
30
|
-
async def test_replay_empty_events(self, tmp_path):
|
|
31
|
-
"""Test replaying with no events."""
|
|
32
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
33
|
-
ctx = LocalContext(
|
|
34
|
-
run_id="test_run",
|
|
35
|
-
workflow_name="test_workflow",
|
|
36
|
-
storage=storage,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
replayer = EventReplayer()
|
|
40
|
-
await replayer.replay(ctx, [])
|
|
41
|
-
|
|
42
|
-
# Context should remain empty
|
|
43
|
-
assert len(ctx.step_results) == 0
|
|
44
|
-
assert len(ctx.completed_sleeps) == 0
|
|
45
|
-
assert len(ctx.hook_results) == 0
|
|
46
|
-
|
|
47
|
-
@pytest.mark.asyncio
|
|
48
|
-
async def test_replay_step_completed(self, tmp_path):
|
|
49
|
-
"""Test replaying step.completed events."""
|
|
50
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
51
|
-
ctx = LocalContext(
|
|
52
|
-
run_id="test_run",
|
|
53
|
-
workflow_name="test_workflow",
|
|
54
|
-
storage=storage,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
# Create step completed events
|
|
58
|
-
events = [
|
|
59
|
-
create_step_started_event(
|
|
60
|
-
run_id="test_run",
|
|
61
|
-
step_id="step_1",
|
|
62
|
-
step_name="test_step",
|
|
63
|
-
args="[]",
|
|
64
|
-
kwargs="{}",
|
|
65
|
-
attempt=1,
|
|
66
|
-
),
|
|
67
|
-
create_step_completed_event(
|
|
68
|
-
run_id="test_run",
|
|
69
|
-
step_id="step_1",
|
|
70
|
-
result=serialize("result_1"),
|
|
71
|
-
step_name="test_step",
|
|
72
|
-
),
|
|
73
|
-
]
|
|
74
|
-
|
|
75
|
-
# Assign sequence numbers
|
|
76
|
-
for i, event in enumerate(events, 1):
|
|
77
|
-
event.sequence = i
|
|
78
|
-
|
|
79
|
-
replayer = EventReplayer()
|
|
80
|
-
await replayer.replay(ctx, events)
|
|
81
|
-
|
|
82
|
-
# Step result should be cached
|
|
83
|
-
assert "step_1" in ctx.step_results
|
|
84
|
-
assert ctx.step_results["step_1"] == "result_1"
|
|
85
|
-
|
|
86
|
-
@pytest.mark.asyncio
|
|
87
|
-
async def test_replay_multiple_steps(self, tmp_path):
|
|
88
|
-
"""Test replaying multiple step events."""
|
|
89
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
90
|
-
ctx = LocalContext(
|
|
91
|
-
run_id="test_run",
|
|
92
|
-
workflow_name="test_workflow",
|
|
93
|
-
storage=storage,
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
events = [
|
|
97
|
-
create_step_completed_event(
|
|
98
|
-
run_id="test_run",
|
|
99
|
-
step_id="step_1",
|
|
100
|
-
result=serialize("result_1"),
|
|
101
|
-
step_name="test_step",
|
|
102
|
-
),
|
|
103
|
-
create_step_completed_event(
|
|
104
|
-
run_id="test_run",
|
|
105
|
-
step_id="step_2",
|
|
106
|
-
result=serialize("result_2"),
|
|
107
|
-
step_name="test_step",
|
|
108
|
-
),
|
|
109
|
-
create_step_completed_event(
|
|
110
|
-
run_id="test_run",
|
|
111
|
-
step_id="step_3",
|
|
112
|
-
result=serialize("result_3"),
|
|
113
|
-
step_name="test_step",
|
|
114
|
-
),
|
|
115
|
-
]
|
|
116
|
-
|
|
117
|
-
for i, event in enumerate(events, 1):
|
|
118
|
-
event.sequence = i
|
|
119
|
-
|
|
120
|
-
replayer = EventReplayer()
|
|
121
|
-
await replayer.replay(ctx, events)
|
|
122
|
-
|
|
123
|
-
# All results should be cached
|
|
124
|
-
assert len(ctx.step_results) == 3
|
|
125
|
-
assert ctx.step_results["step_1"] == "result_1"
|
|
126
|
-
assert ctx.step_results["step_2"] == "result_2"
|
|
127
|
-
assert ctx.step_results["step_3"] == "result_3"
|
|
128
|
-
|
|
129
|
-
@pytest.mark.asyncio
|
|
130
|
-
async def test_replay_sleep_events(self, tmp_path):
|
|
131
|
-
"""Test replaying sleep events."""
|
|
132
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
133
|
-
ctx = LocalContext(
|
|
134
|
-
run_id="test_run",
|
|
135
|
-
workflow_name="test_workflow",
|
|
136
|
-
storage=storage,
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
resume_at = datetime.now(UTC)
|
|
140
|
-
events = [
|
|
141
|
-
create_sleep_started_event(
|
|
142
|
-
run_id="test_run",
|
|
143
|
-
sleep_id="sleep_1",
|
|
144
|
-
duration_seconds=60,
|
|
145
|
-
resume_at=resume_at,
|
|
146
|
-
name="test_sleep",
|
|
147
|
-
),
|
|
148
|
-
create_sleep_completed_event(run_id="test_run", sleep_id="sleep_1"),
|
|
149
|
-
]
|
|
150
|
-
|
|
151
|
-
for i, event in enumerate(events, 1):
|
|
152
|
-
event.sequence = i
|
|
153
|
-
|
|
154
|
-
replayer = EventReplayer()
|
|
155
|
-
await replayer.replay(ctx, events)
|
|
156
|
-
|
|
157
|
-
# Sleep should be marked as completed
|
|
158
|
-
assert ctx.is_sleep_completed("sleep_1")
|
|
159
|
-
assert "sleep_1" in ctx.completed_sleeps
|
|
160
|
-
|
|
161
|
-
@pytest.mark.asyncio
|
|
162
|
-
async def test_replay_pending_sleep(self, tmp_path):
|
|
163
|
-
"""Test replaying sleep that hasn't completed."""
|
|
164
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
165
|
-
ctx = LocalContext(
|
|
166
|
-
run_id="test_run",
|
|
167
|
-
workflow_name="test_workflow",
|
|
168
|
-
storage=storage,
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
resume_at = datetime.now(UTC)
|
|
172
|
-
events = [
|
|
173
|
-
create_sleep_started_event(
|
|
174
|
-
run_id="test_run",
|
|
175
|
-
sleep_id="sleep_pending",
|
|
176
|
-
duration_seconds=60,
|
|
177
|
-
resume_at=resume_at,
|
|
178
|
-
name="pending_sleep",
|
|
179
|
-
),
|
|
180
|
-
]
|
|
181
|
-
|
|
182
|
-
for i, event in enumerate(events, 1):
|
|
183
|
-
event.sequence = i
|
|
184
|
-
|
|
185
|
-
replayer = EventReplayer()
|
|
186
|
-
await replayer.replay(ctx, events)
|
|
187
|
-
|
|
188
|
-
# Sleep should be pending (not completed)
|
|
189
|
-
assert not ctx.is_sleep_completed("sleep_pending")
|
|
190
|
-
assert "sleep_pending" in ctx.pending_sleeps
|
|
191
|
-
|
|
192
|
-
@pytest.mark.asyncio
|
|
193
|
-
async def test_replay_hook_events(self, tmp_path):
|
|
194
|
-
"""Test replaying hook events."""
|
|
195
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
196
|
-
ctx = LocalContext(
|
|
197
|
-
run_id="test_run",
|
|
198
|
-
workflow_name="test_workflow",
|
|
199
|
-
storage=storage,
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
events = [
|
|
203
|
-
create_hook_created_event(
|
|
204
|
-
run_id="test_run",
|
|
205
|
-
hook_id="hook_1",
|
|
206
|
-
url="https://example.com/hook",
|
|
207
|
-
token="secret_token",
|
|
208
|
-
name="test_hook",
|
|
209
|
-
),
|
|
210
|
-
create_hook_received_event(
|
|
211
|
-
run_id="test_run",
|
|
212
|
-
hook_id="hook_1",
|
|
213
|
-
payload={"data": "test_payload"},
|
|
214
|
-
),
|
|
215
|
-
]
|
|
216
|
-
|
|
217
|
-
for i, event in enumerate(events, 1):
|
|
218
|
-
event.sequence = i
|
|
219
|
-
|
|
220
|
-
replayer = EventReplayer()
|
|
221
|
-
await replayer.replay(ctx, events)
|
|
222
|
-
|
|
223
|
-
# Hook result should be cached
|
|
224
|
-
assert ctx.has_hook_result("hook_1")
|
|
225
|
-
assert ctx.get_hook_result("hook_1") == {"data": "test_payload"}
|
|
226
|
-
|
|
227
|
-
@pytest.mark.asyncio
|
|
228
|
-
async def test_replay_expired_hook(self, tmp_path):
|
|
229
|
-
"""Test replaying an expired hook."""
|
|
230
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
231
|
-
ctx = LocalContext(
|
|
232
|
-
run_id="test_run",
|
|
233
|
-
workflow_name="test_workflow",
|
|
234
|
-
storage=storage,
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
events = [
|
|
238
|
-
create_hook_created_event(
|
|
239
|
-
run_id="test_run",
|
|
240
|
-
hook_id="hook_expired",
|
|
241
|
-
url="https://example.com/hook",
|
|
242
|
-
token="token",
|
|
243
|
-
),
|
|
244
|
-
create_hook_expired_event(
|
|
245
|
-
run_id="test_run",
|
|
246
|
-
hook_id="hook_expired",
|
|
247
|
-
),
|
|
248
|
-
]
|
|
249
|
-
|
|
250
|
-
for i, event in enumerate(events, 1):
|
|
251
|
-
event.sequence = i
|
|
252
|
-
|
|
253
|
-
replayer = EventReplayer()
|
|
254
|
-
await replayer.replay(ctx, events)
|
|
255
|
-
|
|
256
|
-
# Hook should not be in pending hooks
|
|
257
|
-
assert "hook_expired" not in ctx.pending_hooks
|
|
258
|
-
# Hook should not have a result
|
|
259
|
-
assert not ctx.has_hook_result("hook_expired")
|
|
260
|
-
|
|
261
|
-
@pytest.mark.asyncio
|
|
262
|
-
async def test_replay_events_in_order(self, tmp_path):
|
|
263
|
-
"""Test that events are replayed in sequence order."""
|
|
264
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
265
|
-
ctx = LocalContext(
|
|
266
|
-
run_id="test_run",
|
|
267
|
-
workflow_name="test_workflow",
|
|
268
|
-
storage=storage,
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
# Create events out of order
|
|
272
|
-
event1 = create_step_completed_event(
|
|
273
|
-
run_id="test_run", step_id="step_1", result=serialize("result_1"), step_name="test_step"
|
|
274
|
-
)
|
|
275
|
-
event1.sequence = 2
|
|
276
|
-
|
|
277
|
-
event2 = create_step_completed_event(
|
|
278
|
-
run_id="test_run", step_id="step_2", result=serialize("result_2"), step_name="test_step"
|
|
279
|
-
)
|
|
280
|
-
event2.sequence = 1
|
|
281
|
-
|
|
282
|
-
# Provide in wrong order
|
|
283
|
-
events = [event1, event2]
|
|
284
|
-
|
|
285
|
-
replayer = EventReplayer()
|
|
286
|
-
await replayer.replay(ctx, events)
|
|
287
|
-
|
|
288
|
-
# Both should be replayed correctly regardless of input order
|
|
289
|
-
assert len(ctx.step_results) == 2
|
|
290
|
-
|
|
291
|
-
@pytest.mark.asyncio
|
|
292
|
-
async def test_replay_sets_replaying_flag(self, tmp_path):
|
|
293
|
-
"""Test that replay sets and clears is_replaying flag."""
|
|
294
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
295
|
-
ctx = LocalContext(
|
|
296
|
-
run_id="test_run",
|
|
297
|
-
workflow_name="test_workflow",
|
|
298
|
-
storage=storage,
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
events = [
|
|
302
|
-
create_step_completed_event(
|
|
303
|
-
run_id="test_run",
|
|
304
|
-
step_id="step_1",
|
|
305
|
-
result=serialize("result"),
|
|
306
|
-
step_name="test_step",
|
|
307
|
-
),
|
|
308
|
-
]
|
|
309
|
-
events[0].sequence = 1
|
|
310
|
-
|
|
311
|
-
assert ctx.is_replaying is False
|
|
312
|
-
|
|
313
|
-
replayer = EventReplayer()
|
|
314
|
-
await replayer.replay(ctx, events)
|
|
315
|
-
|
|
316
|
-
# After replay, flag should be cleared
|
|
317
|
-
assert ctx.is_replaying is False
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
class TestReplayPublicAPI:
|
|
321
|
-
"""Test the public replay API."""
|
|
322
|
-
|
|
323
|
-
@pytest.mark.asyncio
|
|
324
|
-
async def test_replay_events_function(self, tmp_path):
|
|
325
|
-
"""Test the replay_events public function."""
|
|
326
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
327
|
-
ctx = LocalContext(
|
|
328
|
-
run_id="test_run",
|
|
329
|
-
workflow_name="test_workflow",
|
|
330
|
-
storage=storage,
|
|
331
|
-
)
|
|
332
|
-
|
|
333
|
-
events = [
|
|
334
|
-
create_step_completed_event(
|
|
335
|
-
run_id="test_run",
|
|
336
|
-
step_id="step_1",
|
|
337
|
-
result=serialize("test_result"),
|
|
338
|
-
step_name="test_step",
|
|
339
|
-
),
|
|
340
|
-
]
|
|
341
|
-
events[0].sequence = 1
|
|
342
|
-
|
|
343
|
-
# Use public API
|
|
344
|
-
await replay_events(ctx, events)
|
|
345
|
-
|
|
346
|
-
# Verify replay worked
|
|
347
|
-
assert "step_1" in ctx.step_results
|
|
348
|
-
assert ctx.step_results["step_1"] == "test_result"
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
class TestReplayIntegration:
|
|
352
|
-
"""Integration tests for replay with full workflows."""
|
|
353
|
-
|
|
354
|
-
@pytest.mark.asyncio
|
|
355
|
-
async def test_replay_full_workflow(self, tmp_path):
|
|
356
|
-
"""Test replaying a complete workflow event log."""
|
|
357
|
-
storage = FileStorageBackend(base_path=str(tmp_path))
|
|
358
|
-
ctx = LocalContext(
|
|
359
|
-
run_id="test_run",
|
|
360
|
-
workflow_name="test_workflow",
|
|
361
|
-
storage=storage,
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
# Simulate a workflow with steps and sleep
|
|
365
|
-
resume_at = datetime.now(UTC)
|
|
366
|
-
events = [
|
|
367
|
-
Event(
|
|
368
|
-
run_id="test_run",
|
|
369
|
-
type=EventType.WORKFLOW_STARTED,
|
|
370
|
-
sequence=1,
|
|
371
|
-
data={"workflow_name": "test_workflow"},
|
|
372
|
-
),
|
|
373
|
-
create_step_started_event(
|
|
374
|
-
run_id="test_run",
|
|
375
|
-
step_id="step_1",
|
|
376
|
-
step_name="first_step",
|
|
377
|
-
args="[]",
|
|
378
|
-
kwargs="{}",
|
|
379
|
-
attempt=1,
|
|
380
|
-
),
|
|
381
|
-
create_step_completed_event(
|
|
382
|
-
run_id="test_run",
|
|
383
|
-
step_id="step_1",
|
|
384
|
-
result=serialize("step_1_result"),
|
|
385
|
-
step_name="first_step",
|
|
386
|
-
),
|
|
387
|
-
create_sleep_started_event(
|
|
388
|
-
run_id="test_run",
|
|
389
|
-
sleep_id="sleep_1",
|
|
390
|
-
duration_seconds=10,
|
|
391
|
-
resume_at=resume_at,
|
|
392
|
-
),
|
|
393
|
-
create_sleep_completed_event(run_id="test_run", sleep_id="sleep_1"),
|
|
394
|
-
create_step_started_event(
|
|
395
|
-
run_id="test_run",
|
|
396
|
-
step_id="step_2",
|
|
397
|
-
step_name="second_step",
|
|
398
|
-
args="[]",
|
|
399
|
-
kwargs="{}",
|
|
400
|
-
attempt=1,
|
|
401
|
-
),
|
|
402
|
-
create_step_completed_event(
|
|
403
|
-
run_id="test_run",
|
|
404
|
-
step_id="step_2",
|
|
405
|
-
result=serialize("step_2_result"),
|
|
406
|
-
step_name="second_step",
|
|
407
|
-
),
|
|
408
|
-
]
|
|
409
|
-
|
|
410
|
-
# Assign sequences
|
|
411
|
-
for i, event in enumerate(events, 1):
|
|
412
|
-
event.sequence = i
|
|
413
|
-
|
|
414
|
-
await replay_events(ctx, events)
|
|
415
|
-
|
|
416
|
-
# Verify all state was restored
|
|
417
|
-
assert len(ctx.step_results) == 2
|
|
418
|
-
assert ctx.step_results["step_1"] == "step_1_result"
|
|
419
|
-
assert ctx.step_results["step_2"] == "step_2_result"
|
|
420
|
-
assert ctx.is_sleep_completed("sleep_1")
|