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.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,334 @@
1
+ """
2
+ Unit tests for fault tolerance features.
3
+
4
+ Tests cover:
5
+ - WORKFLOW_INTERRUPTED event type
6
+ - RunStatus.INTERRUPTED status
7
+ - WorkflowRun recovery tracking fields
8
+ - Replay mechanism handling of WORKFLOW_INTERRUPTED
9
+ - Recovery config options
10
+ """
11
+
12
+ from datetime import UTC, datetime
13
+
14
+ import pytest
15
+
16
+ from pyworkflow.config import PyWorkflowConfig
17
+ from pyworkflow.engine.events import (
18
+ Event,
19
+ EventType,
20
+ create_workflow_interrupted_event,
21
+ )
22
+ from pyworkflow.storage.schemas import RunStatus, WorkflowRun
23
+
24
+
25
+ class TestWorkflowInterruptedEvent:
26
+ """Tests for WORKFLOW_INTERRUPTED event type."""
27
+
28
+ def test_event_type_exists(self):
29
+ """WORKFLOW_INTERRUPTED should be defined in EventType."""
30
+ assert hasattr(EventType, "WORKFLOW_INTERRUPTED")
31
+ assert EventType.WORKFLOW_INTERRUPTED.value == "workflow.interrupted"
32
+
33
+ def test_create_workflow_interrupted_event(self):
34
+ """Should create a valid WORKFLOW_INTERRUPTED event."""
35
+ event = create_workflow_interrupted_event(
36
+ run_id="test_run_123",
37
+ reason="worker_lost",
38
+ worker_id="worker_1",
39
+ last_event_sequence=5,
40
+ error="Worker process terminated unexpectedly",
41
+ recovery_attempt=1,
42
+ recoverable=True,
43
+ )
44
+
45
+ assert event.run_id == "test_run_123"
46
+ assert event.type == EventType.WORKFLOW_INTERRUPTED
47
+ assert event.data["reason"] == "worker_lost"
48
+ assert event.data["worker_id"] == "worker_1"
49
+ assert event.data["last_event_sequence"] == 5
50
+ assert event.data["error"] == "Worker process terminated unexpectedly"
51
+ assert event.data["recovery_attempt"] == 1
52
+ assert event.data["recoverable"] is True
53
+
54
+ def test_create_workflow_interrupted_event_minimal(self):
55
+ """Should create event with minimal required fields."""
56
+ event = create_workflow_interrupted_event(
57
+ run_id="test_run_456",
58
+ reason="timeout",
59
+ )
60
+
61
+ assert event.run_id == "test_run_456"
62
+ assert event.type == EventType.WORKFLOW_INTERRUPTED
63
+ assert event.data["reason"] == "timeout"
64
+ assert event.data["worker_id"] is None
65
+ assert event.data["last_event_sequence"] is None
66
+ assert event.data["error"] is None
67
+ assert event.data["recovery_attempt"] == 1
68
+ assert event.data["recoverable"] is True
69
+
70
+ def test_event_has_event_id(self):
71
+ """Should generate a unique event_id."""
72
+ event = create_workflow_interrupted_event(
73
+ run_id="test_run",
74
+ reason="signal",
75
+ )
76
+
77
+ assert event.event_id is not None
78
+ assert event.event_id.startswith("evt_")
79
+
80
+ def test_event_has_timestamp(self):
81
+ """Should have a timestamp."""
82
+ before = datetime.now(UTC)
83
+ event = create_workflow_interrupted_event(
84
+ run_id="test_run",
85
+ reason="worker_lost",
86
+ )
87
+ after = datetime.now(UTC)
88
+
89
+ assert event.timestamp is not None
90
+ assert before <= event.timestamp <= after
91
+
92
+
93
+ class TestRunStatusInterrupted:
94
+ """Tests for RunStatus.INTERRUPTED."""
95
+
96
+ def test_status_exists(self):
97
+ """INTERRUPTED should be defined in RunStatus."""
98
+ assert hasattr(RunStatus, "INTERRUPTED")
99
+ assert RunStatus.INTERRUPTED.value == "interrupted"
100
+
101
+ def test_status_serialization(self):
102
+ """Status should serialize and deserialize correctly."""
103
+ status = RunStatus.INTERRUPTED
104
+ serialized = status.value
105
+
106
+ assert serialized == "interrupted"
107
+ assert RunStatus(serialized) == RunStatus.INTERRUPTED
108
+
109
+
110
+ class TestWorkflowRunRecoveryFields:
111
+ """Tests for WorkflowRun recovery tracking fields."""
112
+
113
+ def test_default_values(self):
114
+ """Should have correct default values for recovery fields."""
115
+ run = WorkflowRun(
116
+ run_id="test_run",
117
+ workflow_name="test_workflow",
118
+ status=RunStatus.PENDING,
119
+ )
120
+
121
+ assert run.recovery_attempts == 0
122
+ assert run.max_recovery_attempts == 3
123
+ assert run.recover_on_worker_loss is True
124
+
125
+ def test_custom_values(self):
126
+ """Should accept custom recovery field values."""
127
+ run = WorkflowRun(
128
+ run_id="test_run",
129
+ workflow_name="test_workflow",
130
+ status=RunStatus.PENDING,
131
+ recovery_attempts=2,
132
+ max_recovery_attempts=5,
133
+ recover_on_worker_loss=False,
134
+ )
135
+
136
+ assert run.recovery_attempts == 2
137
+ assert run.max_recovery_attempts == 5
138
+ assert run.recover_on_worker_loss is False
139
+
140
+ def test_to_dict_includes_recovery_fields(self):
141
+ """to_dict() should include recovery fields."""
142
+ run = WorkflowRun(
143
+ run_id="test_run",
144
+ workflow_name="test_workflow",
145
+ status=RunStatus.RUNNING,
146
+ recovery_attempts=1,
147
+ max_recovery_attempts=3,
148
+ recover_on_worker_loss=True,
149
+ )
150
+
151
+ data = run.to_dict()
152
+
153
+ assert data["recovery_attempts"] == 1
154
+ assert data["max_recovery_attempts"] == 3
155
+ assert data["recover_on_worker_loss"] is True
156
+
157
+ def test_from_dict_reads_recovery_fields(self):
158
+ """from_dict() should read recovery fields."""
159
+ data = {
160
+ "run_id": "test_run",
161
+ "workflow_name": "test_workflow",
162
+ "status": "running",
163
+ "created_at": datetime.now(UTC).isoformat(),
164
+ "updated_at": datetime.now(UTC).isoformat(),
165
+ "recovery_attempts": 2,
166
+ "max_recovery_attempts": 4,
167
+ "recover_on_worker_loss": False,
168
+ }
169
+
170
+ run = WorkflowRun.from_dict(data)
171
+
172
+ assert run.recovery_attempts == 2
173
+ assert run.max_recovery_attempts == 4
174
+ assert run.recover_on_worker_loss is False
175
+
176
+ def test_from_dict_defaults_missing_recovery_fields(self):
177
+ """from_dict() should use defaults for missing recovery fields."""
178
+ data = {
179
+ "run_id": "test_run",
180
+ "workflow_name": "test_workflow",
181
+ "status": "running",
182
+ "created_at": datetime.now(UTC).isoformat(),
183
+ "updated_at": datetime.now(UTC).isoformat(),
184
+ # No recovery fields
185
+ }
186
+
187
+ run = WorkflowRun.from_dict(data)
188
+
189
+ assert run.recovery_attempts == 0
190
+ assert run.max_recovery_attempts == 3
191
+ assert run.recover_on_worker_loss is True
192
+
193
+
194
+ class TestRecoveryConfig:
195
+ """Tests for recovery configuration options."""
196
+
197
+ def test_config_defaults(self):
198
+ """Config should have correct default values."""
199
+ config = PyWorkflowConfig()
200
+
201
+ assert (
202
+ config.default_recover_on_worker_loss is None
203
+ ) # None = True for durable, False for transient
204
+ assert config.default_max_recovery_attempts == 3
205
+
206
+ def test_config_custom_values(self):
207
+ """Config should accept custom values."""
208
+ config = PyWorkflowConfig(
209
+ default_recover_on_worker_loss=False,
210
+ default_max_recovery_attempts=5,
211
+ )
212
+
213
+ assert config.default_recover_on_worker_loss is False
214
+ assert config.default_max_recovery_attempts == 5
215
+
216
+
217
+ class TestReplayWorkflowInterrupted:
218
+ """Tests for replay mechanism handling WORKFLOW_INTERRUPTED."""
219
+
220
+ @pytest.mark.asyncio
221
+ async def test_replay_workflow_interrupted_event(self):
222
+ """Replayer should handle WORKFLOW_INTERRUPTED event without error."""
223
+ from pyworkflow.context import LocalContext
224
+ from pyworkflow.engine.replay import EventReplayer
225
+
226
+ ctx = LocalContext(
227
+ run_id="test_run",
228
+ workflow_name="test_workflow",
229
+ storage=None,
230
+ event_log=[],
231
+ durable=False,
232
+ )
233
+
234
+ event = Event(
235
+ run_id="test_run",
236
+ type=EventType.WORKFLOW_INTERRUPTED,
237
+ data={
238
+ "reason": "worker_lost",
239
+ "recovery_attempt": 1,
240
+ "last_event_sequence": 3,
241
+ },
242
+ )
243
+
244
+ replayer = EventReplayer()
245
+ await replayer._apply_event(ctx, event)
246
+
247
+ # WORKFLOW_INTERRUPTED is informational, doesn't change state
248
+ # Just verify it doesn't raise an exception
249
+
250
+ @pytest.mark.asyncio
251
+ async def test_replay_with_interrupted_event_in_sequence(self):
252
+ """Replayer should handle WORKFLOW_INTERRUPTED in a sequence of events."""
253
+ from pyworkflow.context import LocalContext
254
+ from pyworkflow.engine.replay import EventReplayer
255
+ from pyworkflow.serialization.encoder import serialize
256
+
257
+ ctx = LocalContext(
258
+ run_id="test_run",
259
+ workflow_name="test_workflow",
260
+ storage=None,
261
+ event_log=[],
262
+ durable=True,
263
+ )
264
+
265
+ events = [
266
+ Event(
267
+ run_id="test_run",
268
+ type=EventType.WORKFLOW_STARTED,
269
+ data={"workflow_name": "test_workflow", "args": "[]", "kwargs": "{}"},
270
+ sequence=1,
271
+ ),
272
+ Event(
273
+ run_id="test_run",
274
+ type=EventType.STEP_COMPLETED,
275
+ data={"step_id": "step_1", "result": serialize(42)},
276
+ sequence=2,
277
+ ),
278
+ Event(
279
+ run_id="test_run",
280
+ type=EventType.WORKFLOW_INTERRUPTED,
281
+ data={"reason": "worker_lost", "recovery_attempt": 1},
282
+ sequence=3,
283
+ ),
284
+ ]
285
+
286
+ replayer = EventReplayer()
287
+ await replayer.replay(ctx, events)
288
+
289
+ # Step result should be cached
290
+ assert ctx.get_step_result("step_1") == 42
291
+
292
+
293
+ class TestStorageUpdateRecoveryAttempts:
294
+ """Tests for storage backend update_run_recovery_attempts method."""
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_memory_storage_update_recovery_attempts(self):
298
+ """InMemoryStorageBackend should update recovery_attempts."""
299
+ from pyworkflow.storage.memory import InMemoryStorageBackend
300
+
301
+ storage = InMemoryStorageBackend()
302
+
303
+ run = WorkflowRun(
304
+ run_id="test_run",
305
+ workflow_name="test_workflow",
306
+ status=RunStatus.RUNNING,
307
+ recovery_attempts=0,
308
+ )
309
+ await storage.create_run(run)
310
+
311
+ await storage.update_run_recovery_attempts("test_run", 2)
312
+
313
+ updated_run = await storage.get_run("test_run")
314
+ assert updated_run.recovery_attempts == 2
315
+
316
+ @pytest.mark.asyncio
317
+ async def test_file_storage_update_recovery_attempts(self, tmp_path):
318
+ """FileStorageBackend should update recovery_attempts."""
319
+ from pyworkflow.storage.file import FileStorageBackend
320
+
321
+ storage = FileStorageBackend(base_path=str(tmp_path))
322
+
323
+ run = WorkflowRun(
324
+ run_id="test_run",
325
+ workflow_name="test_workflow",
326
+ status=RunStatus.RUNNING,
327
+ recovery_attempts=0,
328
+ )
329
+ await storage.create_run(run)
330
+
331
+ await storage.update_run_recovery_attempts("test_run", 3)
332
+
333
+ updated_run = await storage.get_run("test_run")
334
+ assert updated_run.recovery_attempts == 3