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