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,1460 +0,0 @@
1
- """
2
- Unit tests for SQLite storage backend.
3
-
4
- These tests verify the SQLiteStorageBackend implementation.
5
- For integration tests with a real SQLite database, see tests/integration/.
6
- """
7
-
8
- from contextlib import asynccontextmanager
9
- from datetime import UTC, datetime
10
- from pathlib import Path
11
- from unittest.mock import AsyncMock, MagicMock, patch
12
-
13
- import pytest
14
-
15
- from pyworkflow.engine.events import Event, EventType
16
- from pyworkflow.storage.schemas import (
17
- Hook,
18
- HookStatus,
19
- OverlapPolicy,
20
- RunStatus,
21
- Schedule,
22
- ScheduleSpec,
23
- ScheduleStatus,
24
- StepExecution,
25
- StepStatus,
26
- WorkflowRun,
27
- )
28
- from pyworkflow.storage.sqlite import SQLiteStorageBackend
29
-
30
-
31
- @pytest.fixture
32
- def mock_backend(tmp_path):
33
- """Create a backend with mocked connection for testing."""
34
- backend = SQLiteStorageBackend(db_path=str(tmp_path / "test.db"))
35
- mock_conn = MagicMock()
36
- backend._db = mock_conn
37
- backend._initialized = True
38
- return backend, mock_conn
39
-
40
-
41
- def create_mock_cursor(fetchone_result=None, fetchall_result=None):
42
- """Helper to create a mock cursor that works as async context manager."""
43
- mock_cursor = AsyncMock()
44
- mock_cursor.fetchone = AsyncMock(return_value=fetchone_result)
45
- mock_cursor.fetchall = AsyncMock(return_value=fetchall_result or [])
46
- return mock_cursor
47
-
48
-
49
- class TestSQLiteStorageBackendInit:
50
- """Test SQLiteStorageBackend initialization."""
51
-
52
- def test_init_with_default_path(self):
53
- """Test initialization with default database path."""
54
- backend = SQLiteStorageBackend()
55
-
56
- assert backend.db_path == Path("./pyworkflow_data/pyworkflow.db")
57
- assert backend._db is None
58
- assert backend._initialized is False
59
-
60
- def test_init_with_custom_path(self, tmp_path):
61
- """Test initialization with custom database path."""
62
- custom_path = tmp_path / "custom" / "path" / "db.sqlite"
63
- backend = SQLiteStorageBackend(db_path=str(custom_path))
64
-
65
- assert backend.db_path == custom_path
66
- assert backend._db is None
67
- assert backend._initialized is False
68
-
69
- def test_init_creates_parent_directory(self, tmp_path):
70
- """Test that initialization creates parent directories."""
71
- nested_path = tmp_path / "nested" / "dir" / "test.db"
72
- _backend = SQLiteStorageBackend(db_path=str(nested_path)) # noqa: F841
73
-
74
- # Parent should be created during __init__
75
- assert nested_path.parent.exists()
76
-
77
-
78
- class TestSQLiteStorageBackendConnection:
79
- """Test connection management."""
80
-
81
- @pytest.mark.asyncio
82
- async def test_ensure_connected_raises_when_not_connected(self):
83
- """Test that _ensure_connected raises when connection is None."""
84
- backend = SQLiteStorageBackend()
85
-
86
- with pytest.raises(RuntimeError, match="Database not connected"):
87
- backend._ensure_connected()
88
-
89
- @pytest.mark.asyncio
90
- async def test_connect_creates_connection(self, tmp_path):
91
- """Test that connect creates a database connection."""
92
- backend = SQLiteStorageBackend(db_path=str(tmp_path / "test.db"))
93
-
94
- mock_conn = AsyncMock()
95
-
96
- async def mock_connect(*args, **kwargs):
97
- return mock_conn
98
-
99
- with patch("aiosqlite.connect", side_effect=mock_connect) as mock_aiosqlite:
100
- # Mock the schema initialization
101
- backend._initialize_schema = AsyncMock()
102
-
103
- await backend.connect()
104
-
105
- mock_aiosqlite.assert_called_once()
106
- assert backend._db is not None
107
- assert backend._initialized is True
108
-
109
- @pytest.mark.asyncio
110
- async def test_disconnect_closes_connection(self):
111
- """Test that disconnect closes the connection."""
112
- backend = SQLiteStorageBackend()
113
- mock_conn = AsyncMock()
114
- backend._db = mock_conn
115
- backend._initialized = True
116
-
117
- await backend.disconnect()
118
-
119
- mock_conn.close.assert_called_once()
120
- assert backend._db is None
121
- assert backend._initialized is False
122
-
123
-
124
- class TestRowConversion:
125
- """Test row to object conversion methods."""
126
-
127
- def test_row_to_workflow_run(self):
128
- """Test converting database row to WorkflowRun."""
129
- backend = SQLiteStorageBackend()
130
-
131
- # SQLite returns tuples in column order
132
- row = (
133
- "run_123", # 0: run_id
134
- "test_workflow", # 1: workflow_name
135
- "running", # 2: status
136
- "2024-01-01T12:00:00+00:00", # 3: created_at
137
- "2024-01-01T12:00:01+00:00", # 4: updated_at
138
- "2024-01-01T12:00:00+00:00", # 5: started_at
139
- None, # 6: completed_at
140
- "[]", # 7: input_args
141
- '{"key": "value"}', # 8: input_kwargs
142
- None, # 9: result
143
- None, # 10: error
144
- "idem_123", # 11: idempotency_key
145
- "1h", # 12: max_duration
146
- '{"foo": "bar"}', # 13: metadata
147
- 0, # 14: recovery_attempts
148
- 3, # 15: max_recovery_attempts
149
- 1, # 16: recover_on_worker_loss (SQLite stores as int)
150
- None, # 17: parent_run_id
151
- 0, # 18: nesting_depth
152
- None, # 19: continued_from_run_id
153
- None, # 20: continued_to_run_id
154
- )
155
-
156
- run = backend._row_to_workflow_run(row)
157
-
158
- assert run.run_id == "run_123"
159
- assert run.workflow_name == "test_workflow"
160
- assert run.status == RunStatus.RUNNING
161
- assert run.idempotency_key == "idem_123"
162
- assert run.metadata == {"foo": "bar"}
163
- assert run.recover_on_worker_loss is True
164
-
165
- def test_row_to_event(self):
166
- """Test converting database row to Event."""
167
- backend = SQLiteStorageBackend()
168
-
169
- row = (
170
- "event_123", # 0: event_id
171
- "run_123", # 1: run_id
172
- 5, # 2: sequence
173
- "step.completed", # 3: type
174
- "2024-01-01T12:00:00+00:00", # 4: timestamp
175
- '{"step_id": "step_1"}', # 5: data
176
- )
177
-
178
- event = backend._row_to_event(row)
179
-
180
- assert event.event_id == "event_123"
181
- assert event.run_id == "run_123"
182
- assert event.sequence == 5
183
- assert event.type == EventType.STEP_COMPLETED
184
- assert event.data == {"step_id": "step_1"}
185
-
186
- def test_row_to_step_execution(self):
187
- """Test converting database row to StepExecution."""
188
- backend = SQLiteStorageBackend()
189
-
190
- row = (
191
- "step_123", # 0: step_id
192
- "run_123", # 1: run_id
193
- "process_data", # 2: step_name
194
- "completed", # 3: status
195
- "2024-01-01T12:00:00+00:00", # 4: created_at
196
- "2024-01-01T12:00:01+00:00", # 5: started_at
197
- "2024-01-01T12:00:05+00:00", # 6: completed_at
198
- "[]", # 7: input_args
199
- "{}", # 8: input_kwargs
200
- '"success"', # 9: result
201
- None, # 10: error
202
- 2, # 11: retry_count (0-based in DB)
203
- )
204
-
205
- step = backend._row_to_step_execution(row)
206
-
207
- assert step.step_id == "step_123"
208
- assert step.step_name == "process_data"
209
- assert step.status == StepStatus.COMPLETED
210
- # retry_count 2 -> attempt 3 (1-based)
211
- assert step.attempt == 3
212
-
213
- def test_row_to_hook(self):
214
- """Test converting database row to Hook."""
215
- backend = SQLiteStorageBackend()
216
-
217
- row = (
218
- "hook_123", # 0: hook_id
219
- "run_123", # 1: run_id
220
- "token_abc", # 2: token
221
- "2024-01-01T12:00:00+00:00", # 3: created_at
222
- None, # 4: received_at
223
- "2024-01-02T12:00:00+00:00", # 5: expires_at
224
- "pending", # 6: status
225
- None, # 7: payload
226
- '{"webhook": true}', # 8: metadata
227
- )
228
-
229
- hook = backend._row_to_hook(row)
230
-
231
- assert hook.hook_id == "hook_123"
232
- assert hook.token == "token_abc"
233
- assert hook.status == HookStatus.PENDING
234
- assert hook.metadata == {"webhook": True}
235
-
236
- def test_row_to_schedule(self):
237
- """Test converting database row to Schedule."""
238
- backend = SQLiteStorageBackend()
239
-
240
- row = (
241
- "sched_123", # 0: schedule_id
242
- "daily_report", # 1: workflow_name
243
- "0 9 * * *", # 2: spec (cron expression)
244
- "cron", # 3: spec_type
245
- "UTC", # 4: timezone
246
- "[]", # 5: input_args
247
- "{}", # 6: input_kwargs
248
- "active", # 7: status
249
- "skip", # 8: overlap_policy
250
- "2024-01-02T09:00:00+00:00", # 9: next_run_time
251
- "2024-01-01T09:00:00+00:00", # 10: last_run_time
252
- '["run_1", "run_2"]', # 11: running_run_ids
253
- "{}", # 12: metadata
254
- "2024-01-01T00:00:00+00:00", # 13: created_at
255
- "2024-01-01T09:00:00+00:00", # 14: updated_at
256
- None, # 15: paused_at
257
- None, # 16: deleted_at
258
- )
259
-
260
- schedule = backend._row_to_schedule(row)
261
-
262
- assert schedule.schedule_id == "sched_123"
263
- assert schedule.workflow_name == "daily_report"
264
- assert schedule.spec.cron == "0 9 * * *"
265
- assert schedule.spec.timezone == "UTC"
266
- assert schedule.status == ScheduleStatus.ACTIVE
267
- assert schedule.overlap_policy == OverlapPolicy.SKIP
268
- assert schedule.running_run_ids == ["run_1", "run_2"]
269
-
270
-
271
- class TestSQLiteStorageBackendConfig:
272
- """Test storage configuration integration."""
273
-
274
- def test_storage_to_config(self, tmp_path):
275
- """Test serializing backend to config."""
276
- from pyworkflow.storage.config import storage_to_config
277
-
278
- db_path = tmp_path / "db.sqlite"
279
- backend = SQLiteStorageBackend(db_path=str(db_path))
280
- config = storage_to_config(backend)
281
-
282
- assert config["type"] == "sqlite"
283
- # Config module serializes db_path as base_path
284
- assert config["base_path"] == str(db_path)
285
-
286
- def test_config_to_storage(self, tmp_path):
287
- """Test creating backend from config."""
288
- from pyworkflow.storage.config import config_to_storage
289
-
290
- db_path = tmp_path / "db.sqlite"
291
- # Config module uses base_path key for SQLite
292
- config = {"type": "sqlite", "base_path": str(db_path)}
293
- backend = config_to_storage(config)
294
-
295
- assert isinstance(backend, SQLiteStorageBackend)
296
- assert str(backend.db_path) == str(db_path)
297
-
298
- def test_storage_to_config_with_default_path(self, tmp_path):
299
- """Test serializing backend with default path to config."""
300
- from pyworkflow.storage.config import storage_to_config
301
-
302
- db_path = tmp_path / "default.db"
303
- backend = SQLiteStorageBackend(db_path=str(db_path))
304
- config = storage_to_config(backend)
305
-
306
- assert config["type"] == "sqlite"
307
- # Config module uses base_path key
308
- assert "base_path" in config
309
-
310
- def test_config_to_storage_with_default(self):
311
- """Test creating backend from minimal config."""
312
- from pyworkflow.storage.config import config_to_storage
313
-
314
- config = {"type": "sqlite"}
315
- backend = config_to_storage(config)
316
-
317
- assert isinstance(backend, SQLiteStorageBackend)
318
-
319
-
320
- class TestWorkflowRunOperations:
321
- """Test workflow run CRUD operations."""
322
-
323
- @pytest.mark.asyncio
324
- async def test_create_run(self, mock_backend):
325
- """Test creating a workflow run."""
326
- backend, mock_conn = mock_backend
327
- mock_conn.execute = AsyncMock()
328
- mock_conn.commit = AsyncMock()
329
-
330
- run = WorkflowRun(
331
- run_id="run_123",
332
- workflow_name="test_workflow",
333
- status=RunStatus.PENDING,
334
- )
335
-
336
- await backend.create_run(run)
337
-
338
- mock_conn.execute.assert_called_once()
339
- call_args = mock_conn.execute.call_args
340
- assert "INSERT INTO workflow_runs" in call_args[0][0]
341
- assert call_args[0][1][0] == "run_123"
342
- mock_conn.commit.assert_called_once()
343
-
344
- @pytest.mark.asyncio
345
- async def test_get_run_found(self, mock_backend):
346
- """Test retrieving an existing workflow run."""
347
- backend, mock_conn = mock_backend
348
-
349
- row = (
350
- "run_123", # run_id
351
- "test_workflow", # workflow_name
352
- "running", # status
353
- "2024-01-01T12:00:00+00:00", # created_at
354
- "2024-01-01T12:00:01+00:00", # updated_at
355
- None, # started_at
356
- None, # completed_at
357
- "[]", # input_args
358
- "{}", # input_kwargs
359
- None, # result
360
- None, # error
361
- None, # idempotency_key
362
- None, # max_duration
363
- "{}", # metadata
364
- 0, # recovery_attempts
365
- 3, # max_recovery_attempts
366
- 1, # recover_on_worker_loss
367
- None, # parent_run_id
368
- 0, # nesting_depth
369
- None, # continued_from_run_id
370
- None, # continued_to_run_id
371
- )
372
-
373
- mock_cursor = create_mock_cursor(fetchone_result=row)
374
-
375
- @asynccontextmanager
376
- async def mock_execute(*args, **kwargs):
377
- yield mock_cursor
378
-
379
- mock_conn.execute = mock_execute
380
-
381
- run = await backend.get_run("run_123")
382
-
383
- assert run is not None
384
- assert run.run_id == "run_123"
385
- assert run.status == RunStatus.RUNNING
386
- mock_cursor.fetchone.assert_called_once()
387
-
388
- @pytest.mark.asyncio
389
- async def test_get_run_not_found(self, mock_backend):
390
- """Test retrieving a non-existent workflow run."""
391
- backend, mock_conn = mock_backend
392
-
393
- mock_cursor = create_mock_cursor(fetchone_result=None)
394
-
395
- @asynccontextmanager
396
- async def mock_execute(*args, **kwargs):
397
- yield mock_cursor
398
-
399
- mock_conn.execute = mock_execute
400
-
401
- run = await backend.get_run("nonexistent")
402
-
403
- assert run is None
404
-
405
- @pytest.mark.asyncio
406
- async def test_get_run_by_idempotency_key(self, mock_backend):
407
- """Test retrieving workflow run by idempotency key."""
408
- backend, mock_conn = mock_backend
409
-
410
- row = (
411
- "run_123",
412
- "test_workflow",
413
- "completed",
414
- "2024-01-01T12:00:00+00:00",
415
- "2024-01-01T12:00:01+00:00",
416
- None,
417
- None,
418
- "[]",
419
- "{}",
420
- None,
421
- None,
422
- "idem_key_123",
423
- None,
424
- "{}",
425
- 0,
426
- 3,
427
- 1,
428
- None,
429
- 0,
430
- None,
431
- None,
432
- )
433
-
434
- mock_cursor = create_mock_cursor(fetchone_result=row)
435
-
436
- @asynccontextmanager
437
- async def mock_execute(*args, **kwargs):
438
- yield mock_cursor
439
-
440
- mock_conn.execute = mock_execute
441
-
442
- run = await backend.get_run_by_idempotency_key("idem_key_123")
443
-
444
- assert run is not None
445
- assert run.idempotency_key == "idem_key_123"
446
-
447
- @pytest.mark.asyncio
448
- async def test_update_run_status(self, mock_backend):
449
- """Test updating workflow run status."""
450
- backend, mock_conn = mock_backend
451
- mock_conn.execute = AsyncMock()
452
- mock_conn.commit = AsyncMock()
453
-
454
- await backend.update_run_status("run_123", RunStatus.COMPLETED, result='"done"', error=None)
455
-
456
- mock_conn.execute.assert_called_once()
457
- call_args = mock_conn.execute.call_args
458
- assert "UPDATE workflow_runs" in call_args[0][0]
459
- assert "status" in call_args[0][0]
460
- mock_conn.commit.assert_called_once()
461
-
462
- @pytest.mark.asyncio
463
- async def test_update_run_recovery_attempts(self, mock_backend):
464
- """Test updating recovery attempts counter."""
465
- backend, mock_conn = mock_backend
466
- mock_conn.execute = AsyncMock()
467
- mock_conn.commit = AsyncMock()
468
-
469
- await backend.update_run_recovery_attempts("run_123", 2)
470
-
471
- mock_conn.execute.assert_called_once()
472
- call_args = mock_conn.execute.call_args
473
- assert "recovery_attempts" in call_args[0][0]
474
- mock_conn.commit.assert_called_once()
475
-
476
- @pytest.mark.asyncio
477
- async def test_list_runs(self, mock_backend):
478
- """Test listing workflow runs."""
479
- backend, mock_conn = mock_backend
480
-
481
- row = (
482
- "run_1",
483
- "test_workflow",
484
- "completed",
485
- "2024-01-01T12:00:00+00:00",
486
- "2024-01-01T12:00:01+00:00",
487
- None,
488
- None,
489
- "[]",
490
- "{}",
491
- None,
492
- None,
493
- None,
494
- None,
495
- "{}",
496
- 0,
497
- 3,
498
- 1,
499
- None,
500
- 0,
501
- None,
502
- None,
503
- )
504
-
505
- mock_cursor = create_mock_cursor(fetchall_result=[row])
506
-
507
- @asynccontextmanager
508
- async def mock_execute(*args, **kwargs):
509
- yield mock_cursor
510
-
511
- mock_conn.execute = mock_execute
512
-
513
- runs, cursor = await backend.list_runs(limit=10)
514
-
515
- assert len(runs) == 1
516
- assert runs[0].run_id == "run_1"
517
-
518
-
519
- class TestEventOperations:
520
- """Test event log operations."""
521
-
522
- @pytest.mark.asyncio
523
- async def test_record_event(self, mock_backend):
524
- """Test recording an event."""
525
- backend, mock_conn = mock_backend
526
-
527
- # Mock for sequence fetch - this is used with async with
528
- mock_cursor_seq = create_mock_cursor(fetchone_result=(0,))
529
-
530
- # Track which call is being made
531
- call_count = [0]
532
- execute_calls = []
533
-
534
- # Create a mock that works both as context manager and awaitable
535
- class MockExecuteResult:
536
- def __init__(self, cursor):
537
- self._cursor = cursor
538
-
539
- async def __aenter__(self):
540
- return self._cursor
541
-
542
- async def __aexit__(self, *args):
543
- pass
544
-
545
- def __await__(self):
546
- async def _noop():
547
- return None
548
-
549
- return _noop().__await__()
550
-
551
- def mock_execute(sql, params=None):
552
- call_count[0] += 1
553
- execute_calls.append((sql, params))
554
- if "SELECT" in sql:
555
- return MockExecuteResult(mock_cursor_seq)
556
- else:
557
- return MockExecuteResult(AsyncMock())
558
-
559
- mock_conn.execute = mock_execute
560
- mock_conn.commit = AsyncMock()
561
-
562
- event = Event(
563
- event_id="event_123",
564
- run_id="run_123",
565
- type=EventType.WORKFLOW_STARTED,
566
- timestamp=datetime.now(UTC),
567
- data={"key": "value"},
568
- )
569
-
570
- await backend.record_event(event)
571
-
572
- mock_conn.commit.assert_called_once()
573
- # Should have called execute twice: once for SELECT, once for INSERT
574
- assert len(execute_calls) == 2
575
- assert "INSERT INTO events" in execute_calls[1][0]
576
-
577
- @pytest.mark.asyncio
578
- async def test_get_events(self, mock_backend):
579
- """Test retrieving events for a workflow run."""
580
- backend, mock_conn = mock_backend
581
-
582
- rows = [
583
- (
584
- "event_1",
585
- "run_123",
586
- 0,
587
- "workflow.started",
588
- "2024-01-01T12:00:00+00:00",
589
- "{}",
590
- ),
591
- (
592
- "event_2",
593
- "run_123",
594
- 1,
595
- "step.completed",
596
- "2024-01-01T12:00:01+00:00",
597
- '{"step_id": "step_1"}',
598
- ),
599
- ]
600
-
601
- mock_cursor = create_mock_cursor(fetchall_result=rows)
602
-
603
- @asynccontextmanager
604
- async def mock_execute(*args, **kwargs):
605
- yield mock_cursor
606
-
607
- mock_conn.execute = mock_execute
608
-
609
- events = await backend.get_events("run_123")
610
-
611
- assert len(events) == 2
612
- assert events[0].type == EventType.WORKFLOW_STARTED
613
- assert events[1].type == EventType.STEP_COMPLETED
614
-
615
- @pytest.mark.asyncio
616
- async def test_get_latest_event(self, mock_backend):
617
- """Test retrieving the latest event."""
618
- backend, mock_conn = mock_backend
619
-
620
- row = (
621
- "event_5",
622
- "run_123",
623
- 5,
624
- "step.completed",
625
- "2024-01-01T12:00:05+00:00",
626
- "{}",
627
- )
628
-
629
- mock_cursor = create_mock_cursor(fetchone_result=row)
630
-
631
- @asynccontextmanager
632
- async def mock_execute(*args, **kwargs):
633
- yield mock_cursor
634
-
635
- mock_conn.execute = mock_execute
636
-
637
- event = await backend.get_latest_event("run_123")
638
-
639
- assert event is not None
640
- assert event.sequence == 5
641
-
642
-
643
- class TestStepOperations:
644
- """Test step execution operations."""
645
-
646
- @pytest.mark.asyncio
647
- async def test_create_step(self, mock_backend):
648
- """Test creating a step execution record."""
649
- backend, mock_conn = mock_backend
650
- mock_conn.execute = AsyncMock()
651
- mock_conn.commit = AsyncMock()
652
-
653
- step = StepExecution(
654
- step_id="step_123",
655
- run_id="run_123",
656
- step_name="process_data",
657
- status=StepStatus.PENDING,
658
- )
659
-
660
- await backend.create_step(step)
661
-
662
- mock_conn.execute.assert_called_once()
663
- call_args = mock_conn.execute.call_args
664
- assert "INSERT INTO steps" in call_args[0][0]
665
- mock_conn.commit.assert_called_once()
666
-
667
- @pytest.mark.asyncio
668
- async def test_get_step_found(self, mock_backend):
669
- """Test retrieving an existing step."""
670
- backend, mock_conn = mock_backend
671
-
672
- row = (
673
- "step_123",
674
- "run_123",
675
- "process_data",
676
- "completed",
677
- "2024-01-01T12:00:00+00:00",
678
- "2024-01-01T12:00:01+00:00",
679
- "2024-01-01T12:00:05+00:00",
680
- "[]",
681
- "{}",
682
- '"success"',
683
- None,
684
- 1, # retry_count (0-based)
685
- )
686
-
687
- mock_cursor = create_mock_cursor(fetchone_result=row)
688
-
689
- @asynccontextmanager
690
- async def mock_execute(*args, **kwargs):
691
- yield mock_cursor
692
-
693
- mock_conn.execute = mock_execute
694
-
695
- step = await backend.get_step("step_123")
696
-
697
- assert step is not None
698
- assert step.step_id == "step_123"
699
- assert step.status == StepStatus.COMPLETED
700
-
701
- @pytest.mark.asyncio
702
- async def test_get_step_not_found(self, mock_backend):
703
- """Test retrieving a non-existent step."""
704
- backend, mock_conn = mock_backend
705
-
706
- mock_cursor = create_mock_cursor(fetchone_result=None)
707
-
708
- @asynccontextmanager
709
- async def mock_execute(*args, **kwargs):
710
- yield mock_cursor
711
-
712
- mock_conn.execute = mock_execute
713
-
714
- step = await backend.get_step("nonexistent")
715
-
716
- assert step is None
717
-
718
- @pytest.mark.asyncio
719
- async def test_update_step_status(self, mock_backend):
720
- """Test updating step execution status."""
721
- backend, mock_conn = mock_backend
722
- mock_conn.execute = AsyncMock()
723
- mock_conn.commit = AsyncMock()
724
-
725
- await backend.update_step_status("step_123", StepStatus.COMPLETED, result='"done"')
726
-
727
- mock_conn.execute.assert_called_once()
728
- call_args = mock_conn.execute.call_args
729
- assert "UPDATE steps" in call_args[0][0]
730
- mock_conn.commit.assert_called_once()
731
-
732
- @pytest.mark.asyncio
733
- async def test_list_steps(self, mock_backend):
734
- """Test listing steps for a workflow run."""
735
- backend, mock_conn = mock_backend
736
-
737
- row = (
738
- "step_1",
739
- "run_123",
740
- "step_one",
741
- "completed",
742
- "2024-01-01T12:00:00+00:00",
743
- None,
744
- None,
745
- "[]",
746
- "{}",
747
- None,
748
- None,
749
- 1,
750
- )
751
-
752
- mock_cursor = create_mock_cursor(fetchall_result=[row])
753
-
754
- @asynccontextmanager
755
- async def mock_execute(*args, **kwargs):
756
- yield mock_cursor
757
-
758
- mock_conn.execute = mock_execute
759
-
760
- steps = await backend.list_steps("run_123")
761
-
762
- assert len(steps) == 1
763
- assert steps[0].step_id == "step_1"
764
-
765
-
766
- class TestHookOperations:
767
- """Test hook operations."""
768
-
769
- @pytest.mark.asyncio
770
- async def test_create_hook(self, mock_backend):
771
- """Test creating a hook record."""
772
- backend, mock_conn = mock_backend
773
- mock_conn.execute = AsyncMock()
774
- mock_conn.commit = AsyncMock()
775
-
776
- hook = Hook(
777
- hook_id="hook_123",
778
- run_id="run_123",
779
- token="token_abc",
780
- )
781
-
782
- await backend.create_hook(hook)
783
-
784
- mock_conn.execute.assert_called_once()
785
- call_args = mock_conn.execute.call_args
786
- assert "INSERT INTO hooks" in call_args[0][0]
787
- mock_conn.commit.assert_called_once()
788
-
789
- @pytest.mark.asyncio
790
- async def test_get_hook_found(self, mock_backend):
791
- """Test retrieving an existing hook."""
792
- backend, mock_conn = mock_backend
793
-
794
- row = (
795
- "hook_123",
796
- "run_123",
797
- "token_abc",
798
- "2024-01-01T12:00:00+00:00",
799
- None,
800
- None,
801
- "pending",
802
- None,
803
- "{}",
804
- )
805
-
806
- mock_cursor = create_mock_cursor(fetchone_result=row)
807
-
808
- @asynccontextmanager
809
- async def mock_execute(*args, **kwargs):
810
- yield mock_cursor
811
-
812
- mock_conn.execute = mock_execute
813
-
814
- hook = await backend.get_hook("hook_123")
815
-
816
- assert hook is not None
817
- assert hook.hook_id == "hook_123"
818
- assert hook.status == HookStatus.PENDING
819
-
820
- @pytest.mark.asyncio
821
- async def test_get_hook_not_found(self, mock_backend):
822
- """Test retrieving a non-existent hook."""
823
- backend, mock_conn = mock_backend
824
-
825
- mock_cursor = create_mock_cursor(fetchone_result=None)
826
-
827
- @asynccontextmanager
828
- async def mock_execute(*args, **kwargs):
829
- yield mock_cursor
830
-
831
- mock_conn.execute = mock_execute
832
-
833
- hook = await backend.get_hook("nonexistent")
834
-
835
- assert hook is None
836
-
837
- @pytest.mark.asyncio
838
- async def test_get_hook_by_token(self, mock_backend):
839
- """Test retrieving a hook by token."""
840
- backend, mock_conn = mock_backend
841
-
842
- row = (
843
- "hook_123",
844
- "run_123",
845
- "token_abc",
846
- "2024-01-01T12:00:00+00:00",
847
- None,
848
- None,
849
- "pending",
850
- None,
851
- "{}",
852
- )
853
-
854
- mock_cursor = create_mock_cursor(fetchone_result=row)
855
-
856
- @asynccontextmanager
857
- async def mock_execute(*args, **kwargs):
858
- yield mock_cursor
859
-
860
- mock_conn.execute = mock_execute
861
-
862
- hook = await backend.get_hook_by_token("token_abc")
863
-
864
- assert hook is not None
865
- assert hook.token == "token_abc"
866
-
867
- @pytest.mark.asyncio
868
- async def test_update_hook_status(self, mock_backend):
869
- """Test updating hook status."""
870
- backend, mock_conn = mock_backend
871
- mock_conn.execute = AsyncMock()
872
- mock_conn.commit = AsyncMock()
873
-
874
- await backend.update_hook_status(
875
- "hook_123", HookStatus.RECEIVED, payload='{"data": "test"}'
876
- )
877
-
878
- mock_conn.execute.assert_called_once()
879
- call_args = mock_conn.execute.call_args
880
- assert "UPDATE hooks" in call_args[0][0]
881
- mock_conn.commit.assert_called_once()
882
-
883
- @pytest.mark.asyncio
884
- async def test_list_hooks(self, mock_backend):
885
- """Test listing hooks."""
886
- backend, mock_conn = mock_backend
887
-
888
- row = (
889
- "hook_1",
890
- "run_123",
891
- "token_1",
892
- "2024-01-01T12:00:00+00:00",
893
- None,
894
- None,
895
- "pending",
896
- None,
897
- "{}",
898
- )
899
-
900
- mock_cursor = create_mock_cursor(fetchall_result=[row])
901
-
902
- @asynccontextmanager
903
- async def mock_execute(*args, **kwargs):
904
- yield mock_cursor
905
-
906
- mock_conn.execute = mock_execute
907
-
908
- hooks = await backend.list_hooks(run_id="run_123")
909
-
910
- assert len(hooks) == 1
911
- assert hooks[0].hook_id == "hook_1"
912
-
913
-
914
- class TestCancellationOperations:
915
- """Test cancellation flag operations."""
916
-
917
- @pytest.mark.asyncio
918
- async def test_set_cancellation_flag(self, mock_backend):
919
- """Test setting a cancellation flag."""
920
- backend, mock_conn = mock_backend
921
- mock_conn.execute = AsyncMock()
922
- mock_conn.commit = AsyncMock()
923
-
924
- await backend.set_cancellation_flag("run_123")
925
-
926
- mock_conn.execute.assert_called_once()
927
- call_args = mock_conn.execute.call_args
928
- assert "INSERT OR IGNORE INTO cancellation_flags" in call_args[0][0]
929
- mock_conn.commit.assert_called_once()
930
-
931
- @pytest.mark.asyncio
932
- async def test_check_cancellation_flag_set(self, mock_backend):
933
- """Test checking a set cancellation flag."""
934
- backend, mock_conn = mock_backend
935
-
936
- mock_cursor = create_mock_cursor(fetchone_result=(1,))
937
-
938
- @asynccontextmanager
939
- async def mock_execute(*args, **kwargs):
940
- yield mock_cursor
941
-
942
- mock_conn.execute = mock_execute
943
-
944
- result = await backend.check_cancellation_flag("run_123")
945
-
946
- assert result is True
947
-
948
- @pytest.mark.asyncio
949
- async def test_check_cancellation_flag_not_set(self, mock_backend):
950
- """Test checking when cancellation flag is not set."""
951
- backend, mock_conn = mock_backend
952
-
953
- mock_cursor = create_mock_cursor(fetchone_result=None)
954
-
955
- @asynccontextmanager
956
- async def mock_execute(*args, **kwargs):
957
- yield mock_cursor
958
-
959
- mock_conn.execute = mock_execute
960
-
961
- result = await backend.check_cancellation_flag("run_123")
962
-
963
- assert result is False
964
-
965
- @pytest.mark.asyncio
966
- async def test_clear_cancellation_flag(self, mock_backend):
967
- """Test clearing a cancellation flag."""
968
- backend, mock_conn = mock_backend
969
- mock_conn.execute = AsyncMock()
970
- mock_conn.commit = AsyncMock()
971
-
972
- await backend.clear_cancellation_flag("run_123")
973
-
974
- mock_conn.execute.assert_called_once()
975
- call_args = mock_conn.execute.call_args
976
- assert "DELETE FROM cancellation_flags" in call_args[0][0]
977
- mock_conn.commit.assert_called_once()
978
-
979
-
980
- class TestContinueAsNewOperations:
981
- """Test continue-as-new chain operations."""
982
-
983
- @pytest.mark.asyncio
984
- async def test_update_run_continuation(self, mock_backend):
985
- """Test updating continuation link."""
986
- backend, mock_conn = mock_backend
987
- mock_conn.execute = AsyncMock()
988
- mock_conn.commit = AsyncMock()
989
-
990
- await backend.update_run_continuation("run_1", "run_2")
991
-
992
- mock_conn.execute.assert_called_once()
993
- call_args = mock_conn.execute.call_args
994
- assert "continued_to_run_id" in call_args[0][0]
995
- mock_conn.commit.assert_called_once()
996
-
997
- @pytest.mark.asyncio
998
- async def test_get_workflow_chain(self, mock_backend):
999
- """Test retrieving workflow chain."""
1000
- backend, mock_conn = mock_backend
1001
-
1002
- # Mock get_run for chain traversal
1003
- with patch.object(
1004
- backend,
1005
- "get_run",
1006
- return_value=WorkflowRun(
1007
- run_id="run_1",
1008
- workflow_name="test_workflow",
1009
- status=RunStatus.COMPLETED,
1010
- continued_to_run_id=None,
1011
- ),
1012
- ):
1013
- # Mock the chain traversal query (finding first run)
1014
- mock_cursor = create_mock_cursor(fetchone_result=None)
1015
-
1016
- @asynccontextmanager
1017
- async def mock_execute(*args, **kwargs):
1018
- yield mock_cursor
1019
-
1020
- mock_conn.execute = mock_execute
1021
-
1022
- runs = await backend.get_workflow_chain("run_1")
1023
-
1024
- assert len(runs) == 1
1025
-
1026
-
1027
- class TestChildWorkflowOperations:
1028
- """Test child workflow operations."""
1029
-
1030
- @pytest.mark.asyncio
1031
- async def test_get_children(self, mock_backend):
1032
- """Test retrieving child workflows."""
1033
- backend, mock_conn = mock_backend
1034
-
1035
- row = (
1036
- "child_1",
1037
- "child_workflow",
1038
- "completed",
1039
- "2024-01-01T12:00:00+00:00",
1040
- "2024-01-01T12:00:01+00:00",
1041
- None,
1042
- None,
1043
- "[]",
1044
- "{}",
1045
- None,
1046
- None,
1047
- None,
1048
- None,
1049
- "{}",
1050
- 0,
1051
- 3,
1052
- 1,
1053
- "parent_123",
1054
- 1,
1055
- None,
1056
- None,
1057
- )
1058
-
1059
- mock_cursor = create_mock_cursor(fetchall_result=[row])
1060
-
1061
- @asynccontextmanager
1062
- async def mock_execute(*args, **kwargs):
1063
- yield mock_cursor
1064
-
1065
- mock_conn.execute = mock_execute
1066
-
1067
- children = await backend.get_children("parent_123")
1068
-
1069
- assert len(children) == 1
1070
- assert children[0].parent_run_id == "parent_123"
1071
-
1072
- @pytest.mark.asyncio
1073
- async def test_get_parent_found(self, mock_backend):
1074
- """Test retrieving parent workflow."""
1075
- backend, mock_conn = mock_backend
1076
-
1077
- child_row = (
1078
- "child_1",
1079
- "child_workflow",
1080
- "running",
1081
- "2024-01-01T12:00:00+00:00",
1082
- "2024-01-01T12:00:01+00:00",
1083
- None,
1084
- None,
1085
- "[]",
1086
- "{}",
1087
- None,
1088
- None,
1089
- None,
1090
- None,
1091
- "{}",
1092
- 0,
1093
- 3,
1094
- 1,
1095
- "parent_123",
1096
- 1,
1097
- None,
1098
- None,
1099
- )
1100
-
1101
- parent_row = (
1102
- "parent_123",
1103
- "parent_workflow",
1104
- "running",
1105
- "2024-01-01T12:00:00+00:00",
1106
- "2024-01-01T12:00:01+00:00",
1107
- None,
1108
- None,
1109
- "[]",
1110
- "{}",
1111
- None,
1112
- None,
1113
- None,
1114
- None,
1115
- "{}",
1116
- 0,
1117
- 3,
1118
- 1,
1119
- None,
1120
- 0,
1121
- None,
1122
- None,
1123
- )
1124
-
1125
- call_count = [0]
1126
- rows = [child_row, parent_row]
1127
-
1128
- def create_cursor_for_call():
1129
- mock_cursor = create_mock_cursor(fetchone_result=rows[call_count[0]])
1130
- call_count[0] += 1
1131
- return mock_cursor
1132
-
1133
- @asynccontextmanager
1134
- async def mock_execute(*args, **kwargs):
1135
- yield create_cursor_for_call()
1136
-
1137
- mock_conn.execute = mock_execute
1138
-
1139
- parent = await backend.get_parent("child_1")
1140
-
1141
- assert parent is not None
1142
- assert parent.run_id == "parent_123"
1143
-
1144
- @pytest.mark.asyncio
1145
- async def test_get_parent_not_found(self, mock_backend):
1146
- """Test get_parent when run has no parent."""
1147
- backend, mock_conn = mock_backend
1148
-
1149
- row = (
1150
- "run_1",
1151
- "test_workflow",
1152
- "running",
1153
- "2024-01-01T12:00:00+00:00",
1154
- "2024-01-01T12:00:01+00:00",
1155
- None,
1156
- None,
1157
- "[]",
1158
- "{}",
1159
- None,
1160
- None,
1161
- None,
1162
- None,
1163
- "{}",
1164
- 0,
1165
- 3,
1166
- 1,
1167
- None, # No parent_run_id
1168
- 0,
1169
- None,
1170
- None,
1171
- )
1172
-
1173
- mock_cursor = create_mock_cursor(fetchone_result=row)
1174
-
1175
- @asynccontextmanager
1176
- async def mock_execute(*args, **kwargs):
1177
- yield mock_cursor
1178
-
1179
- mock_conn.execute = mock_execute
1180
-
1181
- parent = await backend.get_parent("run_1")
1182
-
1183
- assert parent is None
1184
-
1185
- @pytest.mark.asyncio
1186
- async def test_get_nesting_depth(self, mock_backend):
1187
- """Test getting nesting depth."""
1188
- backend, mock_conn = mock_backend
1189
-
1190
- row = (
1191
- "run_1",
1192
- "test_workflow",
1193
- "running",
1194
- "2024-01-01T12:00:00+00:00",
1195
- "2024-01-01T12:00:01+00:00",
1196
- None,
1197
- None,
1198
- "[]",
1199
- "{}",
1200
- None,
1201
- None,
1202
- None,
1203
- None,
1204
- "{}",
1205
- 0,
1206
- 3,
1207
- 1,
1208
- None,
1209
- 2, # nesting_depth
1210
- None,
1211
- None,
1212
- )
1213
-
1214
- mock_cursor = create_mock_cursor(fetchone_result=row)
1215
-
1216
- @asynccontextmanager
1217
- async def mock_execute(*args, **kwargs):
1218
- yield mock_cursor
1219
-
1220
- mock_conn.execute = mock_execute
1221
-
1222
- depth = await backend.get_nesting_depth("run_1")
1223
-
1224
- assert depth == 2
1225
-
1226
-
1227
- class TestScheduleOperations:
1228
- """Test schedule operations."""
1229
-
1230
- @pytest.mark.asyncio
1231
- async def test_create_schedule(self, mock_backend):
1232
- """Test creating a schedule."""
1233
- backend, mock_conn = mock_backend
1234
- mock_conn.execute = AsyncMock()
1235
- mock_conn.commit = AsyncMock()
1236
-
1237
- schedule = Schedule(
1238
- schedule_id="sched_123",
1239
- workflow_name="daily_report",
1240
- spec=ScheduleSpec(cron="0 9 * * *"),
1241
- )
1242
-
1243
- await backend.create_schedule(schedule)
1244
-
1245
- mock_conn.execute.assert_called_once()
1246
- call_args = mock_conn.execute.call_args
1247
- assert "INSERT INTO schedules" in call_args[0][0]
1248
- mock_conn.commit.assert_called_once()
1249
-
1250
- @pytest.mark.asyncio
1251
- async def test_get_schedule_found(self, mock_backend):
1252
- """Test retrieving an existing schedule."""
1253
- backend, mock_conn = mock_backend
1254
-
1255
- row = (
1256
- "sched_123",
1257
- "daily_report",
1258
- "0 9 * * *",
1259
- "cron",
1260
- "UTC",
1261
- "[]",
1262
- "{}",
1263
- "active",
1264
- "skip",
1265
- "2024-01-02T09:00:00+00:00",
1266
- None,
1267
- "[]",
1268
- "{}",
1269
- "2024-01-01T00:00:00+00:00",
1270
- None,
1271
- None,
1272
- None,
1273
- )
1274
-
1275
- mock_cursor = create_mock_cursor(fetchone_result=row)
1276
-
1277
- @asynccontextmanager
1278
- async def mock_execute(*args, **kwargs):
1279
- yield mock_cursor
1280
-
1281
- mock_conn.execute = mock_execute
1282
-
1283
- schedule = await backend.get_schedule("sched_123")
1284
-
1285
- assert schedule is not None
1286
- assert schedule.schedule_id == "sched_123"
1287
- assert schedule.spec.cron == "0 9 * * *"
1288
-
1289
- @pytest.mark.asyncio
1290
- async def test_get_schedule_not_found(self, mock_backend):
1291
- """Test retrieving a non-existent schedule."""
1292
- backend, mock_conn = mock_backend
1293
-
1294
- mock_cursor = create_mock_cursor(fetchone_result=None)
1295
-
1296
- @asynccontextmanager
1297
- async def mock_execute(*args, **kwargs):
1298
- yield mock_cursor
1299
-
1300
- mock_conn.execute = mock_execute
1301
-
1302
- schedule = await backend.get_schedule("nonexistent")
1303
-
1304
- assert schedule is None
1305
-
1306
- @pytest.mark.asyncio
1307
- async def test_update_schedule(self, mock_backend):
1308
- """Test updating a schedule."""
1309
- backend, mock_conn = mock_backend
1310
- mock_conn.execute = AsyncMock()
1311
- mock_conn.commit = AsyncMock()
1312
-
1313
- schedule = Schedule(
1314
- schedule_id="sched_123",
1315
- workflow_name="daily_report",
1316
- spec=ScheduleSpec(cron="0 10 * * *"),
1317
- )
1318
-
1319
- await backend.update_schedule(schedule)
1320
-
1321
- mock_conn.execute.assert_called_once()
1322
- call_args = mock_conn.execute.call_args
1323
- assert "UPDATE schedules" in call_args[0][0]
1324
- mock_conn.commit.assert_called_once()
1325
-
1326
- @pytest.mark.asyncio
1327
- async def test_delete_schedule(self, mock_backend):
1328
- """Test deleting (soft delete) a schedule."""
1329
- backend, mock_conn = mock_backend
1330
- mock_conn.execute = AsyncMock()
1331
- mock_conn.commit = AsyncMock()
1332
-
1333
- await backend.delete_schedule("sched_123")
1334
-
1335
- mock_conn.execute.assert_called_once()
1336
- call_args = mock_conn.execute.call_args
1337
- assert "UPDATE schedules" in call_args[0][0]
1338
- assert "deleted_at" in call_args[0][0]
1339
- mock_conn.commit.assert_called_once()
1340
-
1341
- @pytest.mark.asyncio
1342
- async def test_list_schedules(self, mock_backend):
1343
- """Test listing schedules."""
1344
- backend, mock_conn = mock_backend
1345
-
1346
- row = (
1347
- "sched_1",
1348
- "daily_report",
1349
- "0 9 * * *",
1350
- "cron",
1351
- "UTC",
1352
- "[]",
1353
- "{}",
1354
- "active",
1355
- "skip",
1356
- "2024-01-02T09:00:00+00:00",
1357
- None,
1358
- "[]",
1359
- "{}",
1360
- "2024-01-01T00:00:00+00:00",
1361
- None,
1362
- None,
1363
- None,
1364
- )
1365
-
1366
- mock_cursor = create_mock_cursor(fetchall_result=[row])
1367
-
1368
- @asynccontextmanager
1369
- async def mock_execute(*args, **kwargs):
1370
- yield mock_cursor
1371
-
1372
- mock_conn.execute = mock_execute
1373
-
1374
- schedules = await backend.list_schedules()
1375
-
1376
- assert len(schedules) == 1
1377
- assert schedules[0].schedule_id == "sched_1"
1378
-
1379
- @pytest.mark.asyncio
1380
- async def test_get_due_schedules(self, mock_backend):
1381
- """Test getting schedules that are due to run."""
1382
- backend, mock_conn = mock_backend
1383
-
1384
- row = (
1385
- "sched_1",
1386
- "daily_report",
1387
- "0 9 * * *",
1388
- "cron",
1389
- "UTC",
1390
- "[]",
1391
- "{}",
1392
- "active",
1393
- "skip",
1394
- "2024-01-01T09:00:00+00:00",
1395
- None,
1396
- "[]",
1397
- "{}",
1398
- "2024-01-01T00:00:00+00:00",
1399
- None,
1400
- None,
1401
- None,
1402
- )
1403
-
1404
- mock_cursor = create_mock_cursor(fetchall_result=[row])
1405
-
1406
- @asynccontextmanager
1407
- async def mock_execute(*args, **kwargs):
1408
- yield mock_cursor
1409
-
1410
- mock_conn.execute = mock_execute
1411
-
1412
- now = datetime(2024, 1, 1, 9, 1, 0, tzinfo=UTC)
1413
- schedules = await backend.get_due_schedules(now)
1414
-
1415
- assert len(schedules) == 1
1416
-
1417
- @pytest.mark.asyncio
1418
- async def test_add_running_run(self, mock_backend):
1419
- """Test adding a run_id to schedule's running_run_ids."""
1420
- backend, mock_conn = mock_backend
1421
-
1422
- schedule = Schedule(
1423
- schedule_id="sched_123",
1424
- workflow_name="daily_report",
1425
- spec=ScheduleSpec(cron="0 9 * * *"),
1426
- running_run_ids=["run_1"],
1427
- )
1428
-
1429
- with (
1430
- patch.object(backend, "get_schedule", return_value=schedule),
1431
- patch.object(backend, "update_schedule") as mock_update,
1432
- ):
1433
- await backend.add_running_run("sched_123", "run_2")
1434
-
1435
- mock_update.assert_called_once()
1436
- updated_schedule = mock_update.call_args[0][0]
1437
- assert "run_2" in updated_schedule.running_run_ids
1438
-
1439
- @pytest.mark.asyncio
1440
- async def test_remove_running_run(self, mock_backend):
1441
- """Test removing a run_id from schedule's running_run_ids."""
1442
- backend, mock_conn = mock_backend
1443
-
1444
- schedule = Schedule(
1445
- schedule_id="sched_123",
1446
- workflow_name="daily_report",
1447
- spec=ScheduleSpec(cron="0 9 * * *"),
1448
- running_run_ids=["run_1", "run_2"],
1449
- )
1450
-
1451
- with (
1452
- patch.object(backend, "get_schedule", return_value=schedule),
1453
- patch.object(backend, "update_schedule") as mock_update,
1454
- ):
1455
- await backend.remove_running_run("sched_123", "run_1")
1456
-
1457
- mock_update.assert_called_once()
1458
- updated_schedule = mock_update.call_args[0][0]
1459
- assert "run_1" not in updated_schedule.running_run_ids
1460
- assert "run_2" in updated_schedule.running_run_ids