pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.9__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.
Files changed (145) hide show
  1. pyworkflow/__init__.py +10 -1
  2. pyworkflow/celery/tasks.py +272 -24
  3. pyworkflow/cli/__init__.py +4 -1
  4. pyworkflow/cli/commands/runs.py +4 -4
  5. pyworkflow/cli/commands/setup.py +203 -4
  6. pyworkflow/cli/utils/config_generator.py +76 -3
  7. pyworkflow/cli/utils/docker_manager.py +232 -0
  8. pyworkflow/context/__init__.py +13 -0
  9. pyworkflow/context/base.py +26 -0
  10. pyworkflow/context/local.py +80 -0
  11. pyworkflow/context/step_context.py +295 -0
  12. pyworkflow/core/registry.py +6 -1
  13. pyworkflow/core/step.py +141 -0
  14. pyworkflow/core/workflow.py +56 -0
  15. pyworkflow/engine/events.py +30 -0
  16. pyworkflow/engine/replay.py +39 -0
  17. pyworkflow/primitives/child_workflow.py +1 -1
  18. pyworkflow/runtime/local.py +1 -1
  19. pyworkflow/storage/__init__.py +14 -0
  20. pyworkflow/storage/base.py +35 -0
  21. pyworkflow/storage/cassandra.py +1747 -0
  22. pyworkflow/storage/config.py +69 -0
  23. pyworkflow/storage/dynamodb.py +31 -2
  24. pyworkflow/storage/file.py +28 -0
  25. pyworkflow/storage/memory.py +18 -0
  26. pyworkflow/storage/mysql.py +1159 -0
  27. pyworkflow/storage/postgres.py +27 -2
  28. pyworkflow/storage/schemas.py +4 -3
  29. pyworkflow/storage/sqlite.py +25 -2
  30. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/METADATA +7 -4
  31. pyworkflow_engine-0.1.9.dist-info/RECORD +91 -0
  32. pyworkflow_engine-0.1.9.dist-info/top_level.txt +1 -0
  33. dashboard/backend/app/__init__.py +0 -1
  34. dashboard/backend/app/config.py +0 -32
  35. dashboard/backend/app/controllers/__init__.py +0 -6
  36. dashboard/backend/app/controllers/run_controller.py +0 -86
  37. dashboard/backend/app/controllers/workflow_controller.py +0 -33
  38. dashboard/backend/app/dependencies/__init__.py +0 -5
  39. dashboard/backend/app/dependencies/storage.py +0 -50
  40. dashboard/backend/app/repositories/__init__.py +0 -6
  41. dashboard/backend/app/repositories/run_repository.py +0 -80
  42. dashboard/backend/app/repositories/workflow_repository.py +0 -27
  43. dashboard/backend/app/rest/__init__.py +0 -8
  44. dashboard/backend/app/rest/v1/__init__.py +0 -12
  45. dashboard/backend/app/rest/v1/health.py +0 -33
  46. dashboard/backend/app/rest/v1/runs.py +0 -133
  47. dashboard/backend/app/rest/v1/workflows.py +0 -41
  48. dashboard/backend/app/schemas/__init__.py +0 -23
  49. dashboard/backend/app/schemas/common.py +0 -16
  50. dashboard/backend/app/schemas/event.py +0 -24
  51. dashboard/backend/app/schemas/hook.py +0 -25
  52. dashboard/backend/app/schemas/run.py +0 -54
  53. dashboard/backend/app/schemas/step.py +0 -28
  54. dashboard/backend/app/schemas/workflow.py +0 -31
  55. dashboard/backend/app/server.py +0 -87
  56. dashboard/backend/app/services/__init__.py +0 -6
  57. dashboard/backend/app/services/run_service.py +0 -240
  58. dashboard/backend/app/services/workflow_service.py +0 -155
  59. dashboard/backend/main.py +0 -18
  60. docs/concepts/cancellation.mdx +0 -362
  61. docs/concepts/continue-as-new.mdx +0 -434
  62. docs/concepts/events.mdx +0 -266
  63. docs/concepts/fault-tolerance.mdx +0 -370
  64. docs/concepts/hooks.mdx +0 -552
  65. docs/concepts/limitations.mdx +0 -167
  66. docs/concepts/schedules.mdx +0 -775
  67. docs/concepts/sleep.mdx +0 -312
  68. docs/concepts/steps.mdx +0 -301
  69. docs/concepts/workflows.mdx +0 -255
  70. docs/guides/cli.mdx +0 -942
  71. docs/guides/configuration.mdx +0 -560
  72. docs/introduction.mdx +0 -155
  73. docs/quickstart.mdx +0 -279
  74. examples/__init__.py +0 -1
  75. examples/celery/__init__.py +0 -1
  76. examples/celery/durable/docker-compose.yml +0 -55
  77. examples/celery/durable/pyworkflow.config.yaml +0 -12
  78. examples/celery/durable/workflows/__init__.py +0 -122
  79. examples/celery/durable/workflows/basic.py +0 -87
  80. examples/celery/durable/workflows/batch_processing.py +0 -102
  81. examples/celery/durable/workflows/cancellation.py +0 -273
  82. examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
  83. examples/celery/durable/workflows/child_workflows.py +0 -202
  84. examples/celery/durable/workflows/continue_as_new.py +0 -260
  85. examples/celery/durable/workflows/fault_tolerance.py +0 -210
  86. examples/celery/durable/workflows/hooks.py +0 -211
  87. examples/celery/durable/workflows/idempotency.py +0 -112
  88. examples/celery/durable/workflows/long_running.py +0 -99
  89. examples/celery/durable/workflows/retries.py +0 -101
  90. examples/celery/durable/workflows/schedules.py +0 -209
  91. examples/celery/transient/01_basic_workflow.py +0 -91
  92. examples/celery/transient/02_fault_tolerance.py +0 -257
  93. examples/celery/transient/__init__.py +0 -20
  94. examples/celery/transient/pyworkflow.config.yaml +0 -25
  95. examples/local/__init__.py +0 -1
  96. examples/local/durable/01_basic_workflow.py +0 -94
  97. examples/local/durable/02_file_storage.py +0 -132
  98. examples/local/durable/03_retries.py +0 -169
  99. examples/local/durable/04_long_running.py +0 -119
  100. examples/local/durable/05_event_log.py +0 -145
  101. examples/local/durable/06_idempotency.py +0 -148
  102. examples/local/durable/07_hooks.py +0 -334
  103. examples/local/durable/08_cancellation.py +0 -233
  104. examples/local/durable/09_child_workflows.py +0 -198
  105. examples/local/durable/10_child_workflow_patterns.py +0 -265
  106. examples/local/durable/11_continue_as_new.py +0 -249
  107. examples/local/durable/12_schedules.py +0 -198
  108. examples/local/durable/__init__.py +0 -1
  109. examples/local/transient/01_quick_tasks.py +0 -87
  110. examples/local/transient/02_retries.py +0 -130
  111. examples/local/transient/03_sleep.py +0 -141
  112. examples/local/transient/__init__.py +0 -1
  113. pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
  114. pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
  115. tests/examples/__init__.py +0 -0
  116. tests/integration/__init__.py +0 -0
  117. tests/integration/test_cancellation.py +0 -330
  118. tests/integration/test_child_workflows.py +0 -439
  119. tests/integration/test_continue_as_new.py +0 -428
  120. tests/integration/test_dynamodb_storage.py +0 -1146
  121. tests/integration/test_fault_tolerance.py +0 -369
  122. tests/integration/test_schedule_storage.py +0 -484
  123. tests/unit/__init__.py +0 -0
  124. tests/unit/backends/__init__.py +0 -1
  125. tests/unit/backends/test_dynamodb_storage.py +0 -1554
  126. tests/unit/backends/test_postgres_storage.py +0 -1281
  127. tests/unit/backends/test_sqlite_storage.py +0 -1460
  128. tests/unit/conftest.py +0 -41
  129. tests/unit/test_cancellation.py +0 -364
  130. tests/unit/test_child_workflows.py +0 -680
  131. tests/unit/test_continue_as_new.py +0 -441
  132. tests/unit/test_event_limits.py +0 -316
  133. tests/unit/test_executor.py +0 -320
  134. tests/unit/test_fault_tolerance.py +0 -334
  135. tests/unit/test_hooks.py +0 -495
  136. tests/unit/test_registry.py +0 -261
  137. tests/unit/test_replay.py +0 -420
  138. tests/unit/test_schedule_schemas.py +0 -285
  139. tests/unit/test_schedule_utils.py +0 -286
  140. tests/unit/test_scheduled_workflow.py +0 -274
  141. tests/unit/test_step.py +0 -353
  142. tests/unit/test_workflow.py +0 -243
  143. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/WHEEL +0 -0
  144. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/entry_points.txt +0 -0
  145. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/licenses/LICENSE +0 -0
@@ -1,369 +0,0 @@
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