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
@@ -0,0 +1,1747 @@
1
+ """
2
+ Apache Cassandra storage backend using cassandra-driver.
3
+
4
+ This backend stores workflow data in Cassandra, suitable for:
5
+ - Massive horizontal scalability (petabyte-scale)
6
+ - High availability with no single point of failure
7
+ - Multi-datacenter replication
8
+ - High write throughput (optimized for event sourcing)
9
+
10
+ Uses a multi-table design with denormalized data for efficient queries
11
+ without ALLOW FILTERING.
12
+ """
13
+
14
+ import json
15
+ import uuid
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Any
18
+
19
+ from cassandra import ConsistencyLevel
20
+ from cassandra.auth import PlainTextAuthProvider
21
+ from cassandra.cluster import Cluster, Session
22
+ from cassandra.query import BatchStatement, SimpleStatement
23
+
24
+ from pyworkflow.engine.events import Event, EventType
25
+ from pyworkflow.storage.base import StorageBackend
26
+ from pyworkflow.storage.schemas import (
27
+ Hook,
28
+ HookStatus,
29
+ OverlapPolicy,
30
+ RunStatus,
31
+ Schedule,
32
+ ScheduleSpec,
33
+ ScheduleStatus,
34
+ StepExecution,
35
+ StepStatus,
36
+ WorkflowRun,
37
+ )
38
+
39
+
40
+ class CassandraStorageBackend(StorageBackend):
41
+ """
42
+ Apache Cassandra storage backend.
43
+
44
+ Uses a multi-table design with denormalized data for efficient queries.
45
+ Each table is optimized for specific query patterns, avoiding the need
46
+ for ALLOW FILTERING.
47
+
48
+ Tables:
49
+ - Primary tables: workflow_runs, events, steps, hooks, schedules, cancellation_flags
50
+ - Lookup tables: steps_by_id, hooks_by_id, hooks_by_token, runs_by_idempotency_key
51
+ - Query tables: runs_by_status, runs_by_workflow, runs_by_parent, schedules_by_workflow,
52
+ schedules_by_status, due_schedules
53
+
54
+ Events use TIMEUUID for natural time-based ordering.
55
+ List queries use time-bucketed partitions to avoid hot partitions.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ contact_points: list[str] | None = None,
61
+ port: int = 9042,
62
+ keyspace: str = "pyworkflow",
63
+ username: str | None = None,
64
+ password: str | None = None,
65
+ read_consistency: str = "LOCAL_QUORUM",
66
+ write_consistency: str = "LOCAL_QUORUM",
67
+ replication_strategy: str = "SimpleStrategy",
68
+ replication_factor: int = 3,
69
+ datacenter: str | None = None,
70
+ protocol_version: int = 4,
71
+ connect_timeout: float = 10.0,
72
+ ):
73
+ """
74
+ Initialize Cassandra storage backend.
75
+
76
+ Args:
77
+ contact_points: List of Cassandra node addresses (default: ["localhost"])
78
+ port: Cassandra native transport port (default: 9042)
79
+ keyspace: Keyspace name (default: "pyworkflow")
80
+ username: Optional authentication username
81
+ password: Optional authentication password
82
+ read_consistency: Read consistency level (default: "LOCAL_QUORUM")
83
+ write_consistency: Write consistency level (default: "LOCAL_QUORUM")
84
+ replication_strategy: Keyspace replication strategy (default: "SimpleStrategy")
85
+ replication_factor: Replication factor for SimpleStrategy (default: 3)
86
+ datacenter: Datacenter name for NetworkTopologyStrategy
87
+ protocol_version: CQL protocol version (default: 4)
88
+ connect_timeout: Connection timeout in seconds (default: 10.0)
89
+ """
90
+ self.contact_points = contact_points or ["localhost"]
91
+ self.port = port
92
+ self.keyspace = keyspace
93
+ self.username = username
94
+ self.password = password
95
+ self.read_consistency = getattr(ConsistencyLevel, read_consistency)
96
+ self.write_consistency = getattr(ConsistencyLevel, write_consistency)
97
+ self.replication_strategy = replication_strategy
98
+ self.replication_factor = replication_factor
99
+ self.datacenter = datacenter
100
+ self.protocol_version = protocol_version
101
+ self.connect_timeout = connect_timeout
102
+
103
+ self._cluster: Cluster | None = None
104
+ self._session: Session | None = None
105
+ self._initialized = False
106
+
107
+ async def connect(self) -> None:
108
+ """Initialize connection and create keyspace/tables if needed."""
109
+ if self._session is None:
110
+ auth_provider = None
111
+ if self.username and self.password:
112
+ auth_provider = PlainTextAuthProvider(
113
+ username=self.username, password=self.password
114
+ )
115
+
116
+ self._cluster = Cluster(
117
+ contact_points=self.contact_points,
118
+ port=self.port,
119
+ auth_provider=auth_provider,
120
+ protocol_version=self.protocol_version,
121
+ connect_timeout=self.connect_timeout,
122
+ )
123
+ self._session = self._cluster.connect()
124
+
125
+ if not self._initialized:
126
+ await self._initialize_schema()
127
+ self._initialized = True
128
+
129
+ async def disconnect(self) -> None:
130
+ """Close connection to Cassandra cluster."""
131
+ if self._session:
132
+ self._session.shutdown()
133
+ self._session = None
134
+ if self._cluster:
135
+ self._cluster.shutdown()
136
+ self._cluster = None
137
+ self._initialized = False
138
+
139
+ async def health_check(self) -> bool:
140
+ """Check if Cassandra cluster is healthy and accessible."""
141
+ try:
142
+ if not self._session:
143
+ return False
144
+ self._session.execute("SELECT now() FROM system.local")
145
+ return True
146
+ except Exception:
147
+ return False
148
+
149
+ def _ensure_connected(self) -> Session:
150
+ """Ensure Cassandra session is connected."""
151
+ if not self._session:
152
+ raise RuntimeError("Cassandra not connected. Call connect() first.")
153
+ return self._session
154
+
155
+ async def _initialize_schema(self) -> None:
156
+ """Create keyspace and tables if they don't exist."""
157
+ session = self._ensure_connected()
158
+
159
+ # Create keyspace
160
+ if self.replication_strategy == "NetworkTopologyStrategy" and self.datacenter:
161
+ replication = (
162
+ f"{{'class': 'NetworkTopologyStrategy', "
163
+ f"'{self.datacenter}': {self.replication_factor}}}"
164
+ )
165
+ else:
166
+ replication = (
167
+ f"{{'class': 'SimpleStrategy', 'replication_factor': {self.replication_factor}}}"
168
+ )
169
+
170
+ session.execute(
171
+ f"CREATE KEYSPACE IF NOT EXISTS {self.keyspace} WITH replication = {replication}"
172
+ )
173
+ session.set_keyspace(self.keyspace)
174
+
175
+ # Create primary tables
176
+ await self._create_workflow_runs_table(session)
177
+ await self._create_events_table(session)
178
+ await self._create_steps_tables(session)
179
+ await self._create_hooks_tables(session)
180
+ await self._create_cancellation_flags_table(session)
181
+ await self._create_schedules_tables(session)
182
+
183
+ # Create query pattern tables
184
+ await self._create_runs_query_tables(session)
185
+
186
+ async def _create_workflow_runs_table(self, session: Session) -> None:
187
+ """Create workflow_runs primary table and lookup tables."""
188
+ session.execute("""
189
+ CREATE TABLE IF NOT EXISTS workflow_runs (
190
+ run_id TEXT PRIMARY KEY,
191
+ workflow_name TEXT,
192
+ status TEXT,
193
+ created_at TIMESTAMP,
194
+ updated_at TIMESTAMP,
195
+ started_at TIMESTAMP,
196
+ completed_at TIMESTAMP,
197
+ input_args TEXT,
198
+ input_kwargs TEXT,
199
+ result TEXT,
200
+ error TEXT,
201
+ idempotency_key TEXT,
202
+ max_duration TEXT,
203
+ context TEXT,
204
+ recovery_attempts INT,
205
+ max_recovery_attempts INT,
206
+ recover_on_worker_loss BOOLEAN,
207
+ parent_run_id TEXT,
208
+ nesting_depth INT,
209
+ continued_from_run_id TEXT,
210
+ continued_to_run_id TEXT
211
+ )
212
+ """)
213
+
214
+ # Idempotency key lookup table
215
+ session.execute("""
216
+ CREATE TABLE IF NOT EXISTS runs_by_idempotency_key (
217
+ idempotency_key TEXT PRIMARY KEY,
218
+ run_id TEXT
219
+ )
220
+ """)
221
+
222
+ async def _create_events_table(self, session: Session) -> None:
223
+ """Create events table with TIMEUUID ordering."""
224
+ session.execute("""
225
+ CREATE TABLE IF NOT EXISTS events (
226
+ run_id TEXT,
227
+ event_time TIMEUUID,
228
+ event_id TEXT,
229
+ type TEXT,
230
+ timestamp TIMESTAMP,
231
+ data TEXT,
232
+ PRIMARY KEY (run_id, event_time)
233
+ ) WITH CLUSTERING ORDER BY (event_time ASC)
234
+ """)
235
+
236
+ async def _create_steps_tables(self, session: Session) -> None:
237
+ """Create steps tables."""
238
+ # Steps by run
239
+ session.execute("""
240
+ CREATE TABLE IF NOT EXISTS steps (
241
+ run_id TEXT,
242
+ step_id TEXT,
243
+ step_name TEXT,
244
+ status TEXT,
245
+ created_at TIMESTAMP,
246
+ updated_at TIMESTAMP,
247
+ started_at TIMESTAMP,
248
+ completed_at TIMESTAMP,
249
+ input_args TEXT,
250
+ input_kwargs TEXT,
251
+ result TEXT,
252
+ error TEXT,
253
+ attempt INT,
254
+ max_retries INT,
255
+ retry_after TIMESTAMP,
256
+ retry_delay TEXT,
257
+ PRIMARY KEY (run_id, step_id)
258
+ )
259
+ """)
260
+
261
+ # Step lookup by ID
262
+ session.execute("""
263
+ CREATE TABLE IF NOT EXISTS steps_by_id (
264
+ step_id TEXT PRIMARY KEY,
265
+ run_id TEXT
266
+ )
267
+ """)
268
+
269
+ async def _create_hooks_tables(self, session: Session) -> None:
270
+ """Create hooks tables."""
271
+ # Hooks by run
272
+ session.execute("""
273
+ CREATE TABLE IF NOT EXISTS hooks (
274
+ run_id TEXT,
275
+ hook_id TEXT,
276
+ token TEXT,
277
+ url TEXT,
278
+ status TEXT,
279
+ created_at TIMESTAMP,
280
+ received_at TIMESTAMP,
281
+ expires_at TIMESTAMP,
282
+ payload TEXT,
283
+ name TEXT,
284
+ payload_schema TEXT,
285
+ metadata TEXT,
286
+ PRIMARY KEY (run_id, hook_id)
287
+ )
288
+ """)
289
+
290
+ # Hook lookup by ID
291
+ session.execute("""
292
+ CREATE TABLE IF NOT EXISTS hooks_by_id (
293
+ hook_id TEXT PRIMARY KEY,
294
+ run_id TEXT
295
+ )
296
+ """)
297
+
298
+ # Hook lookup by token
299
+ session.execute("""
300
+ CREATE TABLE IF NOT EXISTS hooks_by_token (
301
+ token TEXT PRIMARY KEY,
302
+ run_id TEXT,
303
+ hook_id TEXT
304
+ )
305
+ """)
306
+
307
+ async def _create_cancellation_flags_table(self, session: Session) -> None:
308
+ """Create cancellation flags table."""
309
+ session.execute("""
310
+ CREATE TABLE IF NOT EXISTS cancellation_flags (
311
+ run_id TEXT PRIMARY KEY,
312
+ created_at TIMESTAMP
313
+ )
314
+ """)
315
+
316
+ async def _create_schedules_tables(self, session: Session) -> None:
317
+ """Create schedules tables."""
318
+ # Main schedules table
319
+ session.execute("""
320
+ CREATE TABLE IF NOT EXISTS schedules (
321
+ schedule_id TEXT PRIMARY KEY,
322
+ workflow_name TEXT,
323
+ spec TEXT,
324
+ spec_type TEXT,
325
+ timezone TEXT,
326
+ args TEXT,
327
+ kwargs TEXT,
328
+ status TEXT,
329
+ overlap_policy TEXT,
330
+ created_at TIMESTAMP,
331
+ updated_at TIMESTAMP,
332
+ last_run_at TIMESTAMP,
333
+ next_run_time TIMESTAMP,
334
+ last_run_id TEXT,
335
+ running_run_ids TEXT,
336
+ buffered_count INT
337
+ )
338
+ """)
339
+
340
+ # Schedules by workflow
341
+ session.execute("""
342
+ CREATE TABLE IF NOT EXISTS schedules_by_workflow (
343
+ workflow_name TEXT,
344
+ schedule_id TEXT,
345
+ status TEXT,
346
+ PRIMARY KEY (workflow_name, schedule_id)
347
+ )
348
+ """)
349
+
350
+ # Schedules by status
351
+ session.execute("""
352
+ CREATE TABLE IF NOT EXISTS schedules_by_status (
353
+ status TEXT,
354
+ schedule_id TEXT,
355
+ workflow_name TEXT,
356
+ PRIMARY KEY (status, schedule_id)
357
+ )
358
+ """)
359
+
360
+ # Due schedules with hourly buckets
361
+ session.execute("""
362
+ CREATE TABLE IF NOT EXISTS due_schedules (
363
+ hour_bucket TEXT,
364
+ next_run_time TIMESTAMP,
365
+ schedule_id TEXT,
366
+ status TEXT,
367
+ PRIMARY KEY (hour_bucket, next_run_time, schedule_id)
368
+ ) WITH CLUSTERING ORDER BY (next_run_time ASC, schedule_id ASC)
369
+ """)
370
+
371
+ async def _create_runs_query_tables(self, session: Session) -> None:
372
+ """Create query pattern tables for runs."""
373
+ # Runs by status with daily buckets
374
+ session.execute("""
375
+ CREATE TABLE IF NOT EXISTS runs_by_status (
376
+ status TEXT,
377
+ date_bucket TEXT,
378
+ created_at TIMESTAMP,
379
+ run_id TEXT,
380
+ workflow_name TEXT,
381
+ PRIMARY KEY ((status, date_bucket), created_at, run_id)
382
+ ) WITH CLUSTERING ORDER BY (created_at DESC, run_id DESC)
383
+ """)
384
+
385
+ # Runs by workflow name with daily buckets
386
+ session.execute("""
387
+ CREATE TABLE IF NOT EXISTS runs_by_workflow (
388
+ workflow_name TEXT,
389
+ date_bucket TEXT,
390
+ created_at TIMESTAMP,
391
+ run_id TEXT,
392
+ status TEXT,
393
+ PRIMARY KEY ((workflow_name, date_bucket), created_at, run_id)
394
+ ) WITH CLUSTERING ORDER BY (created_at DESC, run_id DESC)
395
+ """)
396
+
397
+ # Child workflows
398
+ session.execute("""
399
+ CREATE TABLE IF NOT EXISTS runs_by_parent (
400
+ parent_run_id TEXT,
401
+ created_at TIMESTAMP,
402
+ run_id TEXT,
403
+ status TEXT,
404
+ PRIMARY KEY (parent_run_id, created_at, run_id)
405
+ )
406
+ """)
407
+
408
+ # Helper methods
409
+
410
+ def _get_date_bucket(self, dt: datetime) -> str:
411
+ """Get date bucket string (YYYY-MM-DD) for time-based partitioning."""
412
+ return dt.strftime("%Y-%m-%d")
413
+
414
+ def _get_hour_bucket(self, dt: datetime) -> str:
415
+ """Get hour bucket string (YYYY-MM-DD-HH) for schedule partitioning."""
416
+ return dt.strftime("%Y-%m-%d-%H")
417
+
418
+ def _get_date_buckets(
419
+ self,
420
+ start_time: datetime | None = None,
421
+ end_time: datetime | None = None,
422
+ max_buckets: int = 30,
423
+ ) -> list[str]:
424
+ """Generate list of date buckets to query, from newest to oldest."""
425
+ end = end_time or datetime.now(UTC)
426
+ start = start_time or (end - timedelta(days=max_buckets))
427
+
428
+ buckets: list[str] = []
429
+ current = end
430
+ while current >= start and len(buckets) < max_buckets:
431
+ buckets.append(self._get_date_bucket(current))
432
+ current -= timedelta(days=1)
433
+
434
+ return buckets
435
+
436
+ def _generate_timeuuid(self) -> uuid.UUID:
437
+ """Generate a time-based UUID (v1) for event ordering."""
438
+ return uuid.uuid1()
439
+
440
+ # Workflow Run Operations
441
+
442
+ async def create_run(self, run: WorkflowRun) -> None:
443
+ """Create a new workflow run record with denormalized writes."""
444
+ session = self._ensure_connected()
445
+
446
+ batch = BatchStatement(consistency_level=self.write_consistency)
447
+
448
+ # Main workflow_runs table
449
+ batch.add(
450
+ SimpleStatement("""
451
+ INSERT INTO workflow_runs (
452
+ run_id, workflow_name, status, created_at, updated_at,
453
+ started_at, completed_at, input_args, input_kwargs,
454
+ result, error, idempotency_key, max_duration, context,
455
+ recovery_attempts, max_recovery_attempts, recover_on_worker_loss,
456
+ parent_run_id, nesting_depth, continued_from_run_id, continued_to_run_id
457
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
458
+ """),
459
+ (
460
+ run.run_id,
461
+ run.workflow_name,
462
+ run.status.value,
463
+ run.created_at,
464
+ run.updated_at,
465
+ run.started_at,
466
+ run.completed_at,
467
+ run.input_args,
468
+ run.input_kwargs,
469
+ run.result,
470
+ run.error,
471
+ run.idempotency_key,
472
+ run.max_duration,
473
+ json.dumps(run.context),
474
+ run.recovery_attempts,
475
+ run.max_recovery_attempts,
476
+ run.recover_on_worker_loss,
477
+ run.parent_run_id,
478
+ run.nesting_depth,
479
+ run.continued_from_run_id,
480
+ run.continued_to_run_id,
481
+ ),
482
+ )
483
+
484
+ # Idempotency key lookup (if key provided)
485
+ if run.idempotency_key:
486
+ batch.add(
487
+ SimpleStatement(
488
+ "INSERT INTO runs_by_idempotency_key (idempotency_key, run_id) VALUES (%s, %s)"
489
+ ),
490
+ (run.idempotency_key, run.run_id),
491
+ )
492
+
493
+ # Runs by status with date bucket
494
+ date_bucket = self._get_date_bucket(run.created_at)
495
+ batch.add(
496
+ SimpleStatement("""
497
+ INSERT INTO runs_by_status (status, date_bucket, created_at, run_id, workflow_name)
498
+ VALUES (%s, %s, %s, %s, %s)
499
+ """),
500
+ (run.status.value, date_bucket, run.created_at, run.run_id, run.workflow_name),
501
+ )
502
+
503
+ # Runs by workflow name with date bucket
504
+ batch.add(
505
+ SimpleStatement("""
506
+ INSERT INTO runs_by_workflow (workflow_name, date_bucket, created_at, run_id, status)
507
+ VALUES (%s, %s, %s, %s, %s)
508
+ """),
509
+ (run.workflow_name, date_bucket, run.created_at, run.run_id, run.status.value),
510
+ )
511
+
512
+ # Parent-child relationship (if has parent)
513
+ if run.parent_run_id:
514
+ batch.add(
515
+ SimpleStatement("""
516
+ INSERT INTO runs_by_parent (parent_run_id, created_at, run_id, status)
517
+ VALUES (%s, %s, %s, %s)
518
+ """),
519
+ (run.parent_run_id, run.created_at, run.run_id, run.status.value),
520
+ )
521
+
522
+ session.execute(batch)
523
+
524
+ async def get_run(self, run_id: str) -> WorkflowRun | None:
525
+ """Retrieve a workflow run by ID."""
526
+ session = self._ensure_connected()
527
+
528
+ row = session.execute(
529
+ SimpleStatement(
530
+ "SELECT * FROM workflow_runs WHERE run_id = %s",
531
+ consistency_level=self.read_consistency,
532
+ ),
533
+ (run_id,),
534
+ ).one()
535
+
536
+ if not row:
537
+ return None
538
+
539
+ return self._row_to_workflow_run(row)
540
+
541
+ async def get_run_by_idempotency_key(self, key: str) -> WorkflowRun | None:
542
+ """Retrieve a workflow run by idempotency key."""
543
+ session = self._ensure_connected()
544
+
545
+ # First lookup run_id from idempotency key table
546
+ row = session.execute(
547
+ SimpleStatement(
548
+ "SELECT run_id FROM runs_by_idempotency_key WHERE idempotency_key = %s",
549
+ consistency_level=self.read_consistency,
550
+ ),
551
+ (key,),
552
+ ).one()
553
+
554
+ if not row:
555
+ return None
556
+
557
+ return await self.get_run(row.run_id)
558
+
559
+ async def update_run_status(
560
+ self,
561
+ run_id: str,
562
+ status: RunStatus,
563
+ result: str | None = None,
564
+ error: str | None = None,
565
+ ) -> None:
566
+ """Update workflow run status."""
567
+ session = self._ensure_connected()
568
+
569
+ # Get current run to know date bucket and old status
570
+ run = await self.get_run(run_id)
571
+ if not run:
572
+ return
573
+
574
+ now = datetime.now(UTC)
575
+ completed_at = now if status == RunStatus.COMPLETED else run.completed_at
576
+ date_bucket = self._get_date_bucket(run.created_at)
577
+ old_status = run.status.value
578
+ new_status = status.value
579
+
580
+ batch = BatchStatement(consistency_level=self.write_consistency)
581
+
582
+ # Update main table
583
+ batch.add(
584
+ SimpleStatement("""
585
+ UPDATE workflow_runs
586
+ SET status = %s, updated_at = %s, result = %s, error = %s, completed_at = %s
587
+ WHERE run_id = %s
588
+ """),
589
+ (new_status, now, result, error, completed_at, run_id),
590
+ )
591
+
592
+ # Update runs_by_status - delete old, insert new
593
+ if old_status != new_status:
594
+ batch.add(
595
+ SimpleStatement("""
596
+ DELETE FROM runs_by_status
597
+ WHERE status = %s AND date_bucket = %s AND created_at = %s AND run_id = %s
598
+ """),
599
+ (old_status, date_bucket, run.created_at, run_id),
600
+ )
601
+ batch.add(
602
+ SimpleStatement("""
603
+ INSERT INTO runs_by_status (status, date_bucket, created_at, run_id, workflow_name)
604
+ VALUES (%s, %s, %s, %s, %s)
605
+ """),
606
+ (new_status, date_bucket, run.created_at, run_id, run.workflow_name),
607
+ )
608
+
609
+ # Update runs_by_workflow status
610
+ batch.add(
611
+ SimpleStatement("""
612
+ UPDATE runs_by_workflow
613
+ SET status = %s
614
+ WHERE workflow_name = %s AND date_bucket = %s AND created_at = %s AND run_id = %s
615
+ """),
616
+ (new_status, run.workflow_name, date_bucket, run.created_at, run_id),
617
+ )
618
+
619
+ # Update runs_by_parent status if has parent
620
+ if run.parent_run_id:
621
+ batch.add(
622
+ SimpleStatement("""
623
+ UPDATE runs_by_parent
624
+ SET status = %s
625
+ WHERE parent_run_id = %s AND created_at = %s AND run_id = %s
626
+ """),
627
+ (new_status, run.parent_run_id, run.created_at, run_id),
628
+ )
629
+
630
+ session.execute(batch)
631
+
632
+ async def update_run_recovery_attempts(
633
+ self,
634
+ run_id: str,
635
+ recovery_attempts: int,
636
+ ) -> None:
637
+ """Update the recovery attempts counter for a workflow run."""
638
+ session = self._ensure_connected()
639
+
640
+ session.execute(
641
+ SimpleStatement(
642
+ """
643
+ UPDATE workflow_runs
644
+ SET recovery_attempts = %s, updated_at = %s
645
+ WHERE run_id = %s
646
+ """,
647
+ consistency_level=self.write_consistency,
648
+ ),
649
+ (recovery_attempts, datetime.now(UTC), run_id),
650
+ )
651
+
652
+ async def update_run_context(
653
+ self,
654
+ run_id: str,
655
+ context: dict,
656
+ ) -> None:
657
+ """Update the step context for a workflow run."""
658
+ session = self._ensure_connected()
659
+
660
+ session.execute(
661
+ SimpleStatement(
662
+ """
663
+ UPDATE workflow_runs
664
+ SET context = %s, updated_at = %s
665
+ WHERE run_id = %s
666
+ """,
667
+ consistency_level=self.write_consistency,
668
+ ),
669
+ (json.dumps(context), datetime.now(UTC), run_id),
670
+ )
671
+
672
+ async def get_run_context(self, run_id: str) -> dict:
673
+ """Get the current step context for a workflow run."""
674
+ run = await self.get_run(run_id)
675
+ return run.context if run else {}
676
+
677
+ async def list_runs(
678
+ self,
679
+ query: str | None = None,
680
+ status: RunStatus | None = None,
681
+ start_time: datetime | None = None,
682
+ end_time: datetime | None = None,
683
+ limit: int = 100,
684
+ cursor: str | None = None,
685
+ ) -> tuple[list[WorkflowRun], str | None]:
686
+ """List workflow runs with bucket walking pagination."""
687
+ session = self._ensure_connected()
688
+
689
+ # Get date buckets to query
690
+ buckets = self._get_date_buckets(start_time, end_time)
691
+ runs: list[WorkflowRun] = []
692
+
693
+ # Decode cursor if provided
694
+ cursor_created_at: datetime | None = None
695
+ if cursor:
696
+ cursor_run = await self.get_run(cursor)
697
+ if cursor_run:
698
+ cursor_created_at = cursor_run.created_at
699
+
700
+ for bucket in buckets:
701
+ if len(runs) >= limit:
702
+ break
703
+
704
+ if status:
705
+ # Query runs_by_status table
706
+ if cursor_created_at:
707
+ rows = session.execute(
708
+ SimpleStatement(
709
+ """
710
+ SELECT run_id FROM runs_by_status
711
+ WHERE status = %s AND date_bucket = %s AND created_at < %s
712
+ ORDER BY created_at DESC
713
+ LIMIT %s
714
+ """,
715
+ consistency_level=self.read_consistency,
716
+ ),
717
+ (status.value, bucket, cursor_created_at, limit - len(runs) + 1),
718
+ )
719
+ else:
720
+ rows = session.execute(
721
+ SimpleStatement(
722
+ """
723
+ SELECT run_id FROM runs_by_status
724
+ WHERE status = %s AND date_bucket = %s
725
+ ORDER BY created_at DESC
726
+ LIMIT %s
727
+ """,
728
+ consistency_level=self.read_consistency,
729
+ ),
730
+ (status.value, bucket, limit - len(runs) + 1),
731
+ )
732
+ else:
733
+ # Without status filter, we need to scan multiple tables or use a different approach
734
+ # For now, query all statuses in this bucket (less efficient but avoids ALLOW FILTERING)
735
+ if cursor_created_at:
736
+ rows = session.execute(
737
+ SimpleStatement(
738
+ """
739
+ SELECT run_id FROM runs_by_workflow
740
+ WHERE workflow_name = %s AND date_bucket = %s AND created_at < %s
741
+ ORDER BY created_at DESC
742
+ LIMIT %s
743
+ """,
744
+ consistency_level=self.read_consistency,
745
+ ),
746
+ # This doesn't work without workflow_name, fall back to direct table scan
747
+ ("", bucket, cursor_created_at, limit - len(runs) + 1),
748
+ )
749
+ # Fall back: query workflow_runs directly with token range (less efficient)
750
+ rows = session.execute(
751
+ SimpleStatement(
752
+ """
753
+ SELECT * FROM workflow_runs
754
+ LIMIT %s
755
+ """,
756
+ consistency_level=self.read_consistency,
757
+ ),
758
+ (limit * 2,), # Fetch more to filter
759
+ )
760
+ else:
761
+ # Without filters, query workflow_runs directly
762
+ rows = session.execute(
763
+ SimpleStatement(
764
+ "SELECT * FROM workflow_runs LIMIT %s",
765
+ consistency_level=self.read_consistency,
766
+ ),
767
+ (limit * 2,),
768
+ )
769
+
770
+ for row in rows:
771
+ if len(runs) >= limit + 1:
772
+ break
773
+ run = (
774
+ await self.get_run(row.run_id)
775
+ if hasattr(row, "run_id")
776
+ else self._row_to_workflow_run(row)
777
+ )
778
+ if run:
779
+ # Apply query filter if provided (case-insensitive substring)
780
+ if query:
781
+ if (
782
+ query.lower() not in run.workflow_name.lower()
783
+ and query.lower() not in run.input_kwargs.lower()
784
+ ):
785
+ continue
786
+ # Apply time filters
787
+ if start_time and run.created_at < start_time:
788
+ continue
789
+ if end_time and run.created_at >= end_time:
790
+ continue
791
+ runs.append(run)
792
+
793
+ # Reset cursor after first bucket
794
+ cursor_created_at = None
795
+
796
+ # Sort by created_at descending
797
+ runs.sort(key=lambda r: r.created_at, reverse=True)
798
+
799
+ # Handle pagination
800
+ has_more = len(runs) > limit
801
+ if has_more:
802
+ runs = runs[:limit]
803
+
804
+ next_cursor = runs[-1].run_id if runs and has_more else None
805
+ return runs, next_cursor
806
+
807
+ # Event Log Operations
808
+
809
+ async def record_event(self, event: Event) -> None:
810
+ """Record an event with TIMEUUID for ordering."""
811
+ session = self._ensure_connected()
812
+
813
+ # Generate TIMEUUID for natural time ordering
814
+ event_time = self._generate_timeuuid()
815
+
816
+ session.execute(
817
+ SimpleStatement(
818
+ """
819
+ INSERT INTO events (run_id, event_time, event_id, type, timestamp, data)
820
+ VALUES (%s, %s, %s, %s, %s, %s)
821
+ """,
822
+ consistency_level=self.write_consistency,
823
+ ),
824
+ (
825
+ event.run_id,
826
+ event_time,
827
+ event.event_id,
828
+ event.type.value,
829
+ event.timestamp,
830
+ json.dumps(event.data),
831
+ ),
832
+ )
833
+
834
+ async def get_events(
835
+ self,
836
+ run_id: str,
837
+ event_types: list[str] | None = None,
838
+ ) -> list[Event]:
839
+ """Retrieve all events for a workflow run, ordered by time."""
840
+ session = self._ensure_connected()
841
+
842
+ rows = session.execute(
843
+ SimpleStatement(
844
+ "SELECT * FROM events WHERE run_id = %s ORDER BY event_time ASC",
845
+ consistency_level=self.read_consistency,
846
+ ),
847
+ (run_id,),
848
+ )
849
+
850
+ events = []
851
+ for idx, row in enumerate(rows):
852
+ # Filter by event types if specified (application-side filtering)
853
+ if event_types and row.type not in event_types:
854
+ continue
855
+ events.append(self._row_to_event(row, sequence=idx))
856
+
857
+ return events
858
+
859
+ async def get_latest_event(
860
+ self,
861
+ run_id: str,
862
+ event_type: str | None = None,
863
+ ) -> Event | None:
864
+ """Get the latest event for a run, optionally filtered by type."""
865
+ session = self._ensure_connected()
866
+
867
+ rows = session.execute(
868
+ SimpleStatement(
869
+ """
870
+ SELECT * FROM events
871
+ WHERE run_id = %s
872
+ ORDER BY event_time DESC
873
+ LIMIT %s
874
+ """,
875
+ consistency_level=self.read_consistency,
876
+ ),
877
+ (run_id, 10 if event_type else 1),
878
+ )
879
+
880
+ # Get total count for sequence number
881
+ all_events = list(
882
+ session.execute(
883
+ SimpleStatement(
884
+ "SELECT event_time FROM events WHERE run_id = %s",
885
+ consistency_level=self.read_consistency,
886
+ ),
887
+ (run_id,),
888
+ )
889
+ )
890
+ total_count = len(all_events)
891
+
892
+ for row in rows:
893
+ if event_type and row.type != event_type:
894
+ continue
895
+ return self._row_to_event(row, sequence=total_count - 1)
896
+
897
+ return None
898
+
899
+ # Step Operations
900
+
901
+ async def create_step(self, step: StepExecution) -> None:
902
+ """Create a step execution record."""
903
+ session = self._ensure_connected()
904
+
905
+ batch = BatchStatement(consistency_level=self.write_consistency)
906
+
907
+ # Steps table
908
+ batch.add(
909
+ SimpleStatement("""
910
+ INSERT INTO steps (
911
+ run_id, step_id, step_name, status, created_at, updated_at,
912
+ started_at, completed_at, input_args, input_kwargs,
913
+ result, error, attempt, max_retries, retry_after, retry_delay
914
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
915
+ """),
916
+ (
917
+ step.run_id,
918
+ step.step_id,
919
+ step.step_name,
920
+ step.status.value,
921
+ step.created_at,
922
+ step.updated_at,
923
+ step.started_at,
924
+ step.completed_at,
925
+ step.input_args,
926
+ step.input_kwargs,
927
+ step.result,
928
+ step.error,
929
+ step.attempt,
930
+ step.max_retries,
931
+ step.retry_after,
932
+ step.retry_delay,
933
+ ),
934
+ )
935
+
936
+ # Steps lookup by ID
937
+ batch.add(
938
+ SimpleStatement("INSERT INTO steps_by_id (step_id, run_id) VALUES (%s, %s)"),
939
+ (step.step_id, step.run_id),
940
+ )
941
+
942
+ session.execute(batch)
943
+
944
+ async def get_step(self, step_id: str) -> StepExecution | None:
945
+ """Retrieve a step execution by ID."""
946
+ session = self._ensure_connected()
947
+
948
+ # First lookup run_id
949
+ lookup = session.execute(
950
+ SimpleStatement(
951
+ "SELECT run_id FROM steps_by_id WHERE step_id = %s",
952
+ consistency_level=self.read_consistency,
953
+ ),
954
+ (step_id,),
955
+ ).one()
956
+
957
+ if not lookup:
958
+ return None
959
+
960
+ # Then get full step
961
+ row = session.execute(
962
+ SimpleStatement(
963
+ "SELECT * FROM steps WHERE run_id = %s AND step_id = %s",
964
+ consistency_level=self.read_consistency,
965
+ ),
966
+ (lookup.run_id, step_id),
967
+ ).one()
968
+
969
+ if not row:
970
+ return None
971
+
972
+ return self._row_to_step_execution(row)
973
+
974
+ async def update_step_status(
975
+ self,
976
+ step_id: str,
977
+ status: str,
978
+ result: str | None = None,
979
+ error: str | None = None,
980
+ ) -> None:
981
+ """Update step execution status."""
982
+ session = self._ensure_connected()
983
+
984
+ # First lookup run_id
985
+ lookup = session.execute(
986
+ SimpleStatement(
987
+ "SELECT run_id FROM steps_by_id WHERE step_id = %s",
988
+ consistency_level=self.read_consistency,
989
+ ),
990
+ (step_id,),
991
+ ).one()
992
+
993
+ if not lookup:
994
+ return
995
+
996
+ now = datetime.now(UTC)
997
+ completed_at = now if status == "completed" else None
998
+
999
+ session.execute(
1000
+ SimpleStatement(
1001
+ """
1002
+ UPDATE steps
1003
+ SET status = %s, updated_at = %s, result = %s, error = %s, completed_at = %s
1004
+ WHERE run_id = %s AND step_id = %s
1005
+ """,
1006
+ consistency_level=self.write_consistency,
1007
+ ),
1008
+ (status, now, result, error, completed_at, lookup.run_id, step_id),
1009
+ )
1010
+
1011
+ async def list_steps(self, run_id: str) -> list[StepExecution]:
1012
+ """List all steps for a workflow run."""
1013
+ session = self._ensure_connected()
1014
+
1015
+ rows = session.execute(
1016
+ SimpleStatement(
1017
+ "SELECT * FROM steps WHERE run_id = %s",
1018
+ consistency_level=self.read_consistency,
1019
+ ),
1020
+ (run_id,),
1021
+ )
1022
+
1023
+ steps = [self._row_to_step_execution(row) for row in rows]
1024
+ steps.sort(key=lambda s: s.created_at)
1025
+ return steps
1026
+
1027
+ # Hook Operations
1028
+
1029
+ async def create_hook(self, hook: Hook) -> None:
1030
+ """Create a hook record."""
1031
+ session = self._ensure_connected()
1032
+
1033
+ batch = BatchStatement(consistency_level=self.write_consistency)
1034
+
1035
+ # Hooks table
1036
+ batch.add(
1037
+ SimpleStatement("""
1038
+ INSERT INTO hooks (
1039
+ run_id, hook_id, token, url, status, created_at,
1040
+ received_at, expires_at, payload, name, payload_schema, metadata
1041
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
1042
+ """),
1043
+ (
1044
+ hook.run_id,
1045
+ hook.hook_id,
1046
+ hook.token,
1047
+ hook.url,
1048
+ hook.status.value,
1049
+ hook.created_at,
1050
+ hook.received_at,
1051
+ hook.expires_at,
1052
+ hook.payload,
1053
+ hook.name,
1054
+ hook.payload_schema,
1055
+ json.dumps(hook.metadata),
1056
+ ),
1057
+ )
1058
+
1059
+ # Hooks lookup by ID
1060
+ batch.add(
1061
+ SimpleStatement("INSERT INTO hooks_by_id (hook_id, run_id) VALUES (%s, %s)"),
1062
+ (hook.hook_id, hook.run_id),
1063
+ )
1064
+
1065
+ # Hooks lookup by token
1066
+ batch.add(
1067
+ SimpleStatement(
1068
+ "INSERT INTO hooks_by_token (token, run_id, hook_id) VALUES (%s, %s, %s)"
1069
+ ),
1070
+ (hook.token, hook.run_id, hook.hook_id),
1071
+ )
1072
+
1073
+ session.execute(batch)
1074
+
1075
+ async def get_hook(self, hook_id: str) -> Hook | None:
1076
+ """Retrieve a hook by ID."""
1077
+ session = self._ensure_connected()
1078
+
1079
+ # First lookup run_id
1080
+ lookup = session.execute(
1081
+ SimpleStatement(
1082
+ "SELECT run_id FROM hooks_by_id WHERE hook_id = %s",
1083
+ consistency_level=self.read_consistency,
1084
+ ),
1085
+ (hook_id,),
1086
+ ).one()
1087
+
1088
+ if not lookup:
1089
+ return None
1090
+
1091
+ # Then get full hook
1092
+ row = session.execute(
1093
+ SimpleStatement(
1094
+ "SELECT * FROM hooks WHERE run_id = %s AND hook_id = %s",
1095
+ consistency_level=self.read_consistency,
1096
+ ),
1097
+ (lookup.run_id, hook_id),
1098
+ ).one()
1099
+
1100
+ if not row:
1101
+ return None
1102
+
1103
+ return self._row_to_hook(row)
1104
+
1105
+ async def get_hook_by_token(self, token: str) -> Hook | None:
1106
+ """Retrieve a hook by its token."""
1107
+ session = self._ensure_connected()
1108
+
1109
+ # Lookup by token
1110
+ lookup = session.execute(
1111
+ SimpleStatement(
1112
+ "SELECT run_id, hook_id FROM hooks_by_token WHERE token = %s",
1113
+ consistency_level=self.read_consistency,
1114
+ ),
1115
+ (token,),
1116
+ ).one()
1117
+
1118
+ if not lookup:
1119
+ return None
1120
+
1121
+ # Get full hook
1122
+ row = session.execute(
1123
+ SimpleStatement(
1124
+ "SELECT * FROM hooks WHERE run_id = %s AND hook_id = %s",
1125
+ consistency_level=self.read_consistency,
1126
+ ),
1127
+ (lookup.run_id, lookup.hook_id),
1128
+ ).one()
1129
+
1130
+ if not row:
1131
+ return None
1132
+
1133
+ return self._row_to_hook(row)
1134
+
1135
+ async def update_hook_status(
1136
+ self,
1137
+ hook_id: str,
1138
+ status: HookStatus,
1139
+ payload: str | None = None,
1140
+ ) -> None:
1141
+ """Update hook status and optionally payload."""
1142
+ session = self._ensure_connected()
1143
+
1144
+ # First lookup run_id
1145
+ lookup = session.execute(
1146
+ SimpleStatement(
1147
+ "SELECT run_id FROM hooks_by_id WHERE hook_id = %s",
1148
+ consistency_level=self.read_consistency,
1149
+ ),
1150
+ (hook_id,),
1151
+ ).one()
1152
+
1153
+ if not lookup:
1154
+ return
1155
+
1156
+ received_at = datetime.now(UTC) if status == HookStatus.RECEIVED else None
1157
+
1158
+ session.execute(
1159
+ SimpleStatement(
1160
+ """
1161
+ UPDATE hooks
1162
+ SET status = %s, payload = %s, received_at = %s
1163
+ WHERE run_id = %s AND hook_id = %s
1164
+ """,
1165
+ consistency_level=self.write_consistency,
1166
+ ),
1167
+ (status.value, payload, received_at, lookup.run_id, hook_id),
1168
+ )
1169
+
1170
+ async def list_hooks(
1171
+ self,
1172
+ run_id: str | None = None,
1173
+ status: HookStatus | None = None,
1174
+ limit: int = 100,
1175
+ offset: int = 0,
1176
+ ) -> list[Hook]:
1177
+ """List hooks with optional filtering."""
1178
+ session = self._ensure_connected()
1179
+
1180
+ if run_id:
1181
+ rows = session.execute(
1182
+ SimpleStatement(
1183
+ "SELECT * FROM hooks WHERE run_id = %s",
1184
+ consistency_level=self.read_consistency,
1185
+ ),
1186
+ (run_id,),
1187
+ )
1188
+ else:
1189
+ # Without run_id, we'd need to scan all partitions
1190
+ # Return empty list - caller should provide run_id
1191
+ return []
1192
+
1193
+ hooks = []
1194
+ for row in rows:
1195
+ hook = self._row_to_hook(row)
1196
+ # Apply status filter (application-side)
1197
+ if status and hook.status != status:
1198
+ continue
1199
+ hooks.append(hook)
1200
+
1201
+ # Sort by created_at descending
1202
+ hooks.sort(key=lambda h: h.created_at, reverse=True)
1203
+
1204
+ # Apply offset and limit
1205
+ return hooks[offset : offset + limit]
1206
+
1207
+ # Cancellation Flag Operations
1208
+
1209
+ async def set_cancellation_flag(self, run_id: str) -> None:
1210
+ """Set a cancellation flag for a workflow run."""
1211
+ session = self._ensure_connected()
1212
+
1213
+ session.execute(
1214
+ SimpleStatement(
1215
+ "INSERT INTO cancellation_flags (run_id, created_at) VALUES (%s, %s)",
1216
+ consistency_level=self.write_consistency,
1217
+ ),
1218
+ (run_id, datetime.now(UTC)),
1219
+ )
1220
+
1221
+ async def check_cancellation_flag(self, run_id: str) -> bool:
1222
+ """Check if a cancellation flag is set for a workflow run."""
1223
+ session = self._ensure_connected()
1224
+
1225
+ row = session.execute(
1226
+ SimpleStatement(
1227
+ "SELECT run_id FROM cancellation_flags WHERE run_id = %s",
1228
+ consistency_level=self.read_consistency,
1229
+ ),
1230
+ (run_id,),
1231
+ ).one()
1232
+
1233
+ return row is not None
1234
+
1235
+ async def clear_cancellation_flag(self, run_id: str) -> None:
1236
+ """Clear the cancellation flag for a workflow run."""
1237
+ session = self._ensure_connected()
1238
+
1239
+ session.execute(
1240
+ SimpleStatement(
1241
+ "DELETE FROM cancellation_flags WHERE run_id = %s",
1242
+ consistency_level=self.write_consistency,
1243
+ ),
1244
+ (run_id,),
1245
+ )
1246
+
1247
+ # Continue-As-New Chain Operations
1248
+
1249
+ async def update_run_continuation(
1250
+ self,
1251
+ run_id: str,
1252
+ continued_to_run_id: str,
1253
+ ) -> None:
1254
+ """Update the continuation link for a workflow run."""
1255
+ session = self._ensure_connected()
1256
+
1257
+ session.execute(
1258
+ SimpleStatement(
1259
+ """
1260
+ UPDATE workflow_runs
1261
+ SET continued_to_run_id = %s, updated_at = %s
1262
+ WHERE run_id = %s
1263
+ """,
1264
+ consistency_level=self.write_consistency,
1265
+ ),
1266
+ (continued_to_run_id, datetime.now(UTC), run_id),
1267
+ )
1268
+
1269
+ async def get_workflow_chain(
1270
+ self,
1271
+ run_id: str,
1272
+ ) -> list[WorkflowRun]:
1273
+ """Get all runs in a continue-as-new chain."""
1274
+ # Find the first run in the chain
1275
+ current_id: str | None = run_id
1276
+ while current_id:
1277
+ run = await self.get_run(current_id)
1278
+ if not run or not run.continued_from_run_id:
1279
+ break
1280
+ current_id = run.continued_from_run_id
1281
+
1282
+ # Collect all runs from first to last
1283
+ runs = []
1284
+ while current_id:
1285
+ run = await self.get_run(current_id)
1286
+ if not run:
1287
+ break
1288
+ runs.append(run)
1289
+ current_id = run.continued_to_run_id
1290
+
1291
+ return runs
1292
+
1293
+ # Child Workflow Operations
1294
+
1295
+ async def get_children(
1296
+ self,
1297
+ parent_run_id: str,
1298
+ status: RunStatus | None = None,
1299
+ ) -> list[WorkflowRun]:
1300
+ """Get all child workflow runs for a parent workflow."""
1301
+ session = self._ensure_connected()
1302
+
1303
+ rows = session.execute(
1304
+ SimpleStatement(
1305
+ "SELECT run_id, status FROM runs_by_parent WHERE parent_run_id = %s",
1306
+ consistency_level=self.read_consistency,
1307
+ ),
1308
+ (parent_run_id,),
1309
+ )
1310
+
1311
+ children = []
1312
+ for row in rows:
1313
+ # Filter by status if specified
1314
+ if status and row.status != status.value:
1315
+ continue
1316
+ run = await self.get_run(row.run_id)
1317
+ if run:
1318
+ children.append(run)
1319
+
1320
+ children.sort(key=lambda r: r.created_at)
1321
+ return children
1322
+
1323
+ async def get_parent(self, run_id: str) -> WorkflowRun | None:
1324
+ """Get the parent workflow run for a child workflow."""
1325
+ run = await self.get_run(run_id)
1326
+ if not run or not run.parent_run_id:
1327
+ return None
1328
+
1329
+ return await self.get_run(run.parent_run_id)
1330
+
1331
+ async def get_nesting_depth(self, run_id: str) -> int:
1332
+ """Get the nesting depth for a workflow."""
1333
+ run = await self.get_run(run_id)
1334
+ return run.nesting_depth if run else 0
1335
+
1336
+ # Schedule Operations
1337
+
1338
+ async def create_schedule(self, schedule: Schedule) -> None:
1339
+ """Create a new schedule record."""
1340
+ session = self._ensure_connected()
1341
+
1342
+ # Derive spec_type from ScheduleSpec
1343
+ spec_type = (
1344
+ "cron" if schedule.spec.cron else ("interval" if schedule.spec.interval else "calendar")
1345
+ )
1346
+
1347
+ batch = BatchStatement(consistency_level=self.write_consistency)
1348
+
1349
+ # Main schedules table
1350
+ batch.add(
1351
+ SimpleStatement("""
1352
+ INSERT INTO schedules (
1353
+ schedule_id, workflow_name, spec, spec_type, timezone,
1354
+ args, kwargs, status, overlap_policy, created_at, updated_at,
1355
+ last_run_at, next_run_time, last_run_id, running_run_ids, buffered_count
1356
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
1357
+ """),
1358
+ (
1359
+ schedule.schedule_id,
1360
+ schedule.workflow_name,
1361
+ json.dumps(schedule.spec.to_dict()),
1362
+ spec_type,
1363
+ schedule.spec.timezone,
1364
+ schedule.args,
1365
+ schedule.kwargs,
1366
+ schedule.status.value,
1367
+ schedule.overlap_policy.value,
1368
+ schedule.created_at,
1369
+ schedule.updated_at,
1370
+ schedule.last_run_at,
1371
+ schedule.next_run_time,
1372
+ schedule.last_run_id,
1373
+ json.dumps(schedule.running_run_ids),
1374
+ schedule.buffered_count,
1375
+ ),
1376
+ )
1377
+
1378
+ # Schedules by workflow
1379
+ batch.add(
1380
+ SimpleStatement("""
1381
+ INSERT INTO schedules_by_workflow (workflow_name, schedule_id, status)
1382
+ VALUES (%s, %s, %s)
1383
+ """),
1384
+ (schedule.workflow_name, schedule.schedule_id, schedule.status.value),
1385
+ )
1386
+
1387
+ # Schedules by status
1388
+ batch.add(
1389
+ SimpleStatement("""
1390
+ INSERT INTO schedules_by_status (status, schedule_id, workflow_name)
1391
+ VALUES (%s, %s, %s)
1392
+ """),
1393
+ (schedule.status.value, schedule.schedule_id, schedule.workflow_name),
1394
+ )
1395
+
1396
+ # Due schedules (if active and has next_run_time)
1397
+ if schedule.status == ScheduleStatus.ACTIVE and schedule.next_run_time:
1398
+ hour_bucket = self._get_hour_bucket(schedule.next_run_time)
1399
+ batch.add(
1400
+ SimpleStatement("""
1401
+ INSERT INTO due_schedules (hour_bucket, next_run_time, schedule_id, status)
1402
+ VALUES (%s, %s, %s, %s)
1403
+ """),
1404
+ (hour_bucket, schedule.next_run_time, schedule.schedule_id, schedule.status.value),
1405
+ )
1406
+
1407
+ session.execute(batch)
1408
+
1409
+ async def get_schedule(self, schedule_id: str) -> Schedule | None:
1410
+ """Retrieve a schedule by ID."""
1411
+ session = self._ensure_connected()
1412
+
1413
+ row = session.execute(
1414
+ SimpleStatement(
1415
+ "SELECT * FROM schedules WHERE schedule_id = %s",
1416
+ consistency_level=self.read_consistency,
1417
+ ),
1418
+ (schedule_id,),
1419
+ ).one()
1420
+
1421
+ if not row:
1422
+ return None
1423
+
1424
+ return self._row_to_schedule(row)
1425
+
1426
+ async def update_schedule(self, schedule: Schedule) -> None:
1427
+ """Update an existing schedule."""
1428
+ session = self._ensure_connected()
1429
+
1430
+ # Get old schedule to clean up denormalized tables
1431
+ old_schedule = await self.get_schedule(schedule.schedule_id)
1432
+ if not old_schedule:
1433
+ raise ValueError(f"Schedule {schedule.schedule_id} not found")
1434
+
1435
+ # Derive spec_type from ScheduleSpec
1436
+ spec_type = (
1437
+ "cron" if schedule.spec.cron else ("interval" if schedule.spec.interval else "calendar")
1438
+ )
1439
+
1440
+ batch = BatchStatement(consistency_level=self.write_consistency)
1441
+
1442
+ # Update main table
1443
+ batch.add(
1444
+ SimpleStatement("""
1445
+ UPDATE schedules SET
1446
+ workflow_name = %s, spec = %s, spec_type = %s, timezone = %s,
1447
+ args = %s, kwargs = %s, status = %s, overlap_policy = %s,
1448
+ updated_at = %s, last_run_at = %s, next_run_time = %s,
1449
+ last_run_id = %s, running_run_ids = %s, buffered_count = %s
1450
+ WHERE schedule_id = %s
1451
+ """),
1452
+ (
1453
+ schedule.workflow_name,
1454
+ json.dumps(schedule.spec.to_dict()),
1455
+ spec_type,
1456
+ schedule.spec.timezone,
1457
+ schedule.args,
1458
+ schedule.kwargs,
1459
+ schedule.status.value,
1460
+ schedule.overlap_policy.value,
1461
+ schedule.updated_at or datetime.now(UTC),
1462
+ schedule.last_run_at,
1463
+ schedule.next_run_time,
1464
+ schedule.last_run_id,
1465
+ json.dumps(schedule.running_run_ids),
1466
+ schedule.buffered_count,
1467
+ schedule.schedule_id,
1468
+ ),
1469
+ )
1470
+
1471
+ # Update schedules_by_workflow if status changed
1472
+ if old_schedule.status != schedule.status:
1473
+ batch.add(
1474
+ SimpleStatement("""
1475
+ UPDATE schedules_by_workflow SET status = %s
1476
+ WHERE workflow_name = %s AND schedule_id = %s
1477
+ """),
1478
+ (schedule.status.value, schedule.workflow_name, schedule.schedule_id),
1479
+ )
1480
+
1481
+ # Delete from old status, insert into new status
1482
+ batch.add(
1483
+ SimpleStatement("""
1484
+ DELETE FROM schedules_by_status
1485
+ WHERE status = %s AND schedule_id = %s
1486
+ """),
1487
+ (old_schedule.status.value, schedule.schedule_id),
1488
+ )
1489
+ batch.add(
1490
+ SimpleStatement("""
1491
+ INSERT INTO schedules_by_status (status, schedule_id, workflow_name)
1492
+ VALUES (%s, %s, %s)
1493
+ """),
1494
+ (schedule.status.value, schedule.schedule_id, schedule.workflow_name),
1495
+ )
1496
+
1497
+ # Update due_schedules if next_run_time changed
1498
+ if old_schedule.next_run_time != schedule.next_run_time:
1499
+ # Delete old entry
1500
+ if old_schedule.next_run_time:
1501
+ old_hour_bucket = self._get_hour_bucket(old_schedule.next_run_time)
1502
+ batch.add(
1503
+ SimpleStatement("""
1504
+ DELETE FROM due_schedules
1505
+ WHERE hour_bucket = %s AND next_run_time = %s AND schedule_id = %s
1506
+ """),
1507
+ (old_hour_bucket, old_schedule.next_run_time, schedule.schedule_id),
1508
+ )
1509
+ # Insert new entry
1510
+ if schedule.status == ScheduleStatus.ACTIVE and schedule.next_run_time:
1511
+ new_hour_bucket = self._get_hour_bucket(schedule.next_run_time)
1512
+ batch.add(
1513
+ SimpleStatement("""
1514
+ INSERT INTO due_schedules (hour_bucket, next_run_time, schedule_id, status)
1515
+ VALUES (%s, %s, %s, %s)
1516
+ """),
1517
+ (
1518
+ new_hour_bucket,
1519
+ schedule.next_run_time,
1520
+ schedule.schedule_id,
1521
+ schedule.status.value,
1522
+ ),
1523
+ )
1524
+
1525
+ session.execute(batch)
1526
+
1527
+ async def delete_schedule(self, schedule_id: str) -> None:
1528
+ """Mark a schedule as deleted (soft delete)."""
1529
+ schedule = await self.get_schedule(schedule_id)
1530
+ if not schedule:
1531
+ return
1532
+
1533
+ schedule.status = ScheduleStatus.DELETED
1534
+ schedule.updated_at = datetime.now(UTC)
1535
+ await self.update_schedule(schedule)
1536
+
1537
+ async def list_schedules(
1538
+ self,
1539
+ workflow_name: str | None = None,
1540
+ status: ScheduleStatus | None = None,
1541
+ limit: int = 100,
1542
+ offset: int = 0,
1543
+ ) -> list[Schedule]:
1544
+ """List schedules with optional filtering."""
1545
+ session = self._ensure_connected()
1546
+
1547
+ if workflow_name:
1548
+ rows = session.execute(
1549
+ SimpleStatement(
1550
+ "SELECT schedule_id, status FROM schedules_by_workflow WHERE workflow_name = %s",
1551
+ consistency_level=self.read_consistency,
1552
+ ),
1553
+ (workflow_name,),
1554
+ )
1555
+ elif status:
1556
+ rows = session.execute(
1557
+ SimpleStatement(
1558
+ "SELECT schedule_id FROM schedules_by_status WHERE status = %s",
1559
+ consistency_level=self.read_consistency,
1560
+ ),
1561
+ (status.value,),
1562
+ )
1563
+ else:
1564
+ # Without filters, get from main table (less efficient)
1565
+ rows = session.execute(
1566
+ SimpleStatement(
1567
+ "SELECT schedule_id FROM schedules LIMIT %s",
1568
+ consistency_level=self.read_consistency,
1569
+ ),
1570
+ (limit * 2,),
1571
+ )
1572
+
1573
+ schedules = []
1574
+ for row in rows:
1575
+ schedule = await self.get_schedule(row.schedule_id)
1576
+ if schedule:
1577
+ # Apply status filter if querying by workflow_name
1578
+ if workflow_name and status and schedule.status != status:
1579
+ continue
1580
+ schedules.append(schedule)
1581
+
1582
+ # Sort by created_at descending
1583
+ schedules.sort(key=lambda s: s.created_at, reverse=True)
1584
+
1585
+ # Apply offset and limit
1586
+ return schedules[offset : offset + limit]
1587
+
1588
+ async def get_due_schedules(self, now: datetime) -> list[Schedule]:
1589
+ """Get all schedules that are due to run."""
1590
+ session = self._ensure_connected()
1591
+
1592
+ due_schedules = []
1593
+
1594
+ # Query current hour and previous 24 hours (in case of delays)
1595
+ for hours_ago in range(25):
1596
+ bucket_time = now - timedelta(hours=hours_ago)
1597
+ hour_bucket = self._get_hour_bucket(bucket_time)
1598
+
1599
+ rows = session.execute(
1600
+ SimpleStatement(
1601
+ """
1602
+ SELECT schedule_id FROM due_schedules
1603
+ WHERE hour_bucket = %s AND next_run_time <= %s
1604
+ """,
1605
+ consistency_level=self.read_consistency,
1606
+ ),
1607
+ (hour_bucket, now),
1608
+ )
1609
+
1610
+ for row in rows:
1611
+ schedule = await self.get_schedule(row.schedule_id)
1612
+ if schedule and schedule.status == ScheduleStatus.ACTIVE:
1613
+ due_schedules.append(schedule)
1614
+
1615
+ # Remove duplicates and sort by next_run_time
1616
+ seen = set()
1617
+ unique_schedules = []
1618
+ for s in due_schedules:
1619
+ if s.schedule_id not in seen:
1620
+ seen.add(s.schedule_id)
1621
+ unique_schedules.append(s)
1622
+
1623
+ unique_schedules.sort(key=lambda s: s.next_run_time or datetime.min.replace(tzinfo=UTC))
1624
+ return unique_schedules
1625
+
1626
+ async def add_running_run(self, schedule_id: str, run_id: str) -> None:
1627
+ """Add a run_id to the schedule's running_run_ids list."""
1628
+ schedule = await self.get_schedule(schedule_id)
1629
+ if not schedule:
1630
+ raise ValueError(f"Schedule {schedule_id} not found")
1631
+
1632
+ if run_id not in schedule.running_run_ids:
1633
+ schedule.running_run_ids.append(run_id)
1634
+ schedule.updated_at = datetime.now(UTC)
1635
+ await self.update_schedule(schedule)
1636
+
1637
+ async def remove_running_run(self, schedule_id: str, run_id: str) -> None:
1638
+ """Remove a run_id from the schedule's running_run_ids list."""
1639
+ schedule = await self.get_schedule(schedule_id)
1640
+ if not schedule:
1641
+ raise ValueError(f"Schedule {schedule_id} not found")
1642
+
1643
+ if run_id in schedule.running_run_ids:
1644
+ schedule.running_run_ids.remove(run_id)
1645
+ schedule.updated_at = datetime.now(UTC)
1646
+ await self.update_schedule(schedule)
1647
+
1648
+ # Helper methods for converting Cassandra rows to domain objects
1649
+
1650
+ def _row_to_workflow_run(self, row: Any) -> WorkflowRun:
1651
+ """Convert Cassandra row to WorkflowRun object."""
1652
+ return WorkflowRun(
1653
+ run_id=row.run_id,
1654
+ workflow_name=row.workflow_name,
1655
+ status=RunStatus(row.status),
1656
+ created_at=row.created_at,
1657
+ updated_at=row.updated_at,
1658
+ started_at=row.started_at,
1659
+ completed_at=row.completed_at,
1660
+ input_args=row.input_args or "[]",
1661
+ input_kwargs=row.input_kwargs or "{}",
1662
+ result=row.result,
1663
+ error=row.error,
1664
+ idempotency_key=row.idempotency_key,
1665
+ max_duration=row.max_duration,
1666
+ context=json.loads(row.context) if row.context else {},
1667
+ recovery_attempts=row.recovery_attempts or 0,
1668
+ max_recovery_attempts=row.max_recovery_attempts or 3,
1669
+ recover_on_worker_loss=row.recover_on_worker_loss
1670
+ if row.recover_on_worker_loss is not None
1671
+ else True,
1672
+ parent_run_id=row.parent_run_id,
1673
+ nesting_depth=row.nesting_depth or 0,
1674
+ continued_from_run_id=row.continued_from_run_id,
1675
+ continued_to_run_id=row.continued_to_run_id,
1676
+ )
1677
+
1678
+ def _row_to_event(self, row: Any, sequence: int = 0) -> Event:
1679
+ """Convert Cassandra row to Event object."""
1680
+ return Event(
1681
+ event_id=row.event_id,
1682
+ run_id=row.run_id,
1683
+ sequence=sequence,
1684
+ type=EventType(row.type),
1685
+ timestamp=row.timestamp,
1686
+ data=json.loads(row.data) if row.data else {},
1687
+ )
1688
+
1689
+ def _row_to_step_execution(self, row: Any) -> StepExecution:
1690
+ """Convert Cassandra row to StepExecution object."""
1691
+ return StepExecution(
1692
+ step_id=row.step_id,
1693
+ run_id=row.run_id,
1694
+ step_name=row.step_name,
1695
+ status=StepStatus(row.status),
1696
+ created_at=row.created_at,
1697
+ updated_at=row.updated_at,
1698
+ started_at=row.started_at,
1699
+ completed_at=row.completed_at,
1700
+ input_args=row.input_args or "[]",
1701
+ input_kwargs=row.input_kwargs or "{}",
1702
+ result=row.result,
1703
+ error=row.error,
1704
+ attempt=row.attempt or 1,
1705
+ max_retries=row.max_retries or 3,
1706
+ retry_after=row.retry_after,
1707
+ retry_delay=row.retry_delay,
1708
+ )
1709
+
1710
+ def _row_to_hook(self, row: Any) -> Hook:
1711
+ """Convert Cassandra row to Hook object."""
1712
+ return Hook(
1713
+ hook_id=row.hook_id,
1714
+ run_id=row.run_id,
1715
+ token=row.token,
1716
+ url=row.url or "",
1717
+ status=HookStatus(row.status),
1718
+ created_at=row.created_at,
1719
+ received_at=row.received_at,
1720
+ expires_at=row.expires_at,
1721
+ payload=row.payload,
1722
+ name=row.name,
1723
+ payload_schema=row.payload_schema,
1724
+ metadata=json.loads(row.metadata) if row.metadata else {},
1725
+ )
1726
+
1727
+ def _row_to_schedule(self, row: Any) -> Schedule:
1728
+ """Convert Cassandra row to Schedule object."""
1729
+ spec_data = json.loads(row.spec) if row.spec else {}
1730
+ spec = ScheduleSpec.from_dict(spec_data)
1731
+
1732
+ return Schedule(
1733
+ schedule_id=row.schedule_id,
1734
+ workflow_name=row.workflow_name,
1735
+ spec=spec,
1736
+ status=ScheduleStatus(row.status),
1737
+ args=row.args or "[]",
1738
+ kwargs=row.kwargs or "{}",
1739
+ overlap_policy=OverlapPolicy(row.overlap_policy),
1740
+ created_at=row.created_at,
1741
+ updated_at=row.updated_at,
1742
+ last_run_at=row.last_run_at,
1743
+ next_run_time=row.next_run_time,
1744
+ last_run_id=row.last_run_id,
1745
+ running_run_ids=json.loads(row.running_run_ids) if row.running_run_ids else [],
1746
+ buffered_count=row.buffered_count or 0,
1747
+ )