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.
- pyworkflow/__init__.py +10 -1
- pyworkflow/celery/tasks.py +272 -24
- pyworkflow/cli/__init__.py +4 -1
- pyworkflow/cli/commands/runs.py +4 -4
- pyworkflow/cli/commands/setup.py +203 -4
- pyworkflow/cli/utils/config_generator.py +76 -3
- pyworkflow/cli/utils/docker_manager.py +232 -0
- pyworkflow/config.py +94 -17
- pyworkflow/context/__init__.py +13 -0
- pyworkflow/context/base.py +26 -0
- pyworkflow/context/local.py +80 -0
- pyworkflow/context/step_context.py +295 -0
- pyworkflow/core/registry.py +6 -1
- pyworkflow/core/step.py +141 -0
- pyworkflow/core/workflow.py +56 -0
- pyworkflow/engine/events.py +30 -0
- pyworkflow/engine/replay.py +39 -0
- pyworkflow/primitives/child_workflow.py +1 -1
- pyworkflow/runtime/local.py +1 -1
- pyworkflow/storage/__init__.py +14 -0
- pyworkflow/storage/base.py +35 -0
- pyworkflow/storage/cassandra.py +1747 -0
- pyworkflow/storage/config.py +69 -0
- pyworkflow/storage/dynamodb.py +31 -2
- pyworkflow/storage/file.py +28 -0
- pyworkflow/storage/memory.py +18 -0
- pyworkflow/storage/mysql.py +1159 -0
- pyworkflow/storage/postgres.py +27 -2
- pyworkflow/storage/schemas.py +4 -3
- pyworkflow/storage/sqlite.py +25 -2
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/METADATA +7 -4
- pyworkflow_engine-0.1.10.dist-info/RECORD +91 -0
- pyworkflow_engine-0.1.10.dist-info/top_level.txt +1 -0
- dashboard/backend/app/__init__.py +0 -1
- dashboard/backend/app/config.py +0 -32
- dashboard/backend/app/controllers/__init__.py +0 -6
- dashboard/backend/app/controllers/run_controller.py +0 -86
- dashboard/backend/app/controllers/workflow_controller.py +0 -33
- dashboard/backend/app/dependencies/__init__.py +0 -5
- dashboard/backend/app/dependencies/storage.py +0 -50
- dashboard/backend/app/repositories/__init__.py +0 -6
- dashboard/backend/app/repositories/run_repository.py +0 -80
- dashboard/backend/app/repositories/workflow_repository.py +0 -27
- dashboard/backend/app/rest/__init__.py +0 -8
- dashboard/backend/app/rest/v1/__init__.py +0 -12
- dashboard/backend/app/rest/v1/health.py +0 -33
- dashboard/backend/app/rest/v1/runs.py +0 -133
- dashboard/backend/app/rest/v1/workflows.py +0 -41
- dashboard/backend/app/schemas/__init__.py +0 -23
- dashboard/backend/app/schemas/common.py +0 -16
- dashboard/backend/app/schemas/event.py +0 -24
- dashboard/backend/app/schemas/hook.py +0 -25
- dashboard/backend/app/schemas/run.py +0 -54
- dashboard/backend/app/schemas/step.py +0 -28
- dashboard/backend/app/schemas/workflow.py +0 -31
- dashboard/backend/app/server.py +0 -87
- dashboard/backend/app/services/__init__.py +0 -6
- dashboard/backend/app/services/run_service.py +0 -240
- dashboard/backend/app/services/workflow_service.py +0 -155
- dashboard/backend/main.py +0 -18
- docs/concepts/cancellation.mdx +0 -362
- docs/concepts/continue-as-new.mdx +0 -434
- docs/concepts/events.mdx +0 -266
- docs/concepts/fault-tolerance.mdx +0 -370
- docs/concepts/hooks.mdx +0 -552
- docs/concepts/limitations.mdx +0 -167
- docs/concepts/schedules.mdx +0 -775
- docs/concepts/sleep.mdx +0 -312
- docs/concepts/steps.mdx +0 -301
- docs/concepts/workflows.mdx +0 -255
- docs/guides/cli.mdx +0 -942
- docs/guides/configuration.mdx +0 -560
- docs/introduction.mdx +0 -155
- docs/quickstart.mdx +0 -279
- examples/__init__.py +0 -1
- examples/celery/__init__.py +0 -1
- examples/celery/durable/docker-compose.yml +0 -55
- examples/celery/durable/pyworkflow.config.yaml +0 -12
- examples/celery/durable/workflows/__init__.py +0 -122
- examples/celery/durable/workflows/basic.py +0 -87
- examples/celery/durable/workflows/batch_processing.py +0 -102
- examples/celery/durable/workflows/cancellation.py +0 -273
- examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
- examples/celery/durable/workflows/child_workflows.py +0 -202
- examples/celery/durable/workflows/continue_as_new.py +0 -260
- examples/celery/durable/workflows/fault_tolerance.py +0 -210
- examples/celery/durable/workflows/hooks.py +0 -211
- examples/celery/durable/workflows/idempotency.py +0 -112
- examples/celery/durable/workflows/long_running.py +0 -99
- examples/celery/durable/workflows/retries.py +0 -101
- examples/celery/durable/workflows/schedules.py +0 -209
- examples/celery/transient/01_basic_workflow.py +0 -91
- examples/celery/transient/02_fault_tolerance.py +0 -257
- examples/celery/transient/__init__.py +0 -20
- examples/celery/transient/pyworkflow.config.yaml +0 -25
- examples/local/__init__.py +0 -1
- examples/local/durable/01_basic_workflow.py +0 -94
- examples/local/durable/02_file_storage.py +0 -132
- examples/local/durable/03_retries.py +0 -169
- examples/local/durable/04_long_running.py +0 -119
- examples/local/durable/05_event_log.py +0 -145
- examples/local/durable/06_idempotency.py +0 -148
- examples/local/durable/07_hooks.py +0 -334
- examples/local/durable/08_cancellation.py +0 -233
- examples/local/durable/09_child_workflows.py +0 -198
- examples/local/durable/10_child_workflow_patterns.py +0 -265
- examples/local/durable/11_continue_as_new.py +0 -249
- examples/local/durable/12_schedules.py +0 -198
- examples/local/durable/__init__.py +0 -1
- examples/local/transient/01_quick_tasks.py +0 -87
- examples/local/transient/02_retries.py +0 -130
- examples/local/transient/03_sleep.py +0 -141
- examples/local/transient/__init__.py +0 -1
- pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +0 -330
- tests/integration/test_child_workflows.py +0 -439
- tests/integration/test_continue_as_new.py +0 -428
- tests/integration/test_dynamodb_storage.py +0 -1146
- tests/integration/test_fault_tolerance.py +0 -369
- tests/integration/test_schedule_storage.py +0 -484
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +0 -1
- tests/unit/backends/test_dynamodb_storage.py +0 -1554
- tests/unit/backends/test_postgres_storage.py +0 -1281
- tests/unit/backends/test_sqlite_storage.py +0 -1460
- tests/unit/conftest.py +0 -41
- tests/unit/test_cancellation.py +0 -364
- tests/unit/test_child_workflows.py +0 -680
- tests/unit/test_continue_as_new.py +0 -441
- tests/unit/test_event_limits.py +0 -316
- tests/unit/test_executor.py +0 -320
- tests/unit/test_fault_tolerance.py +0 -334
- tests/unit/test_hooks.py +0 -495
- tests/unit/test_registry.py +0 -261
- tests/unit/test_replay.py +0 -420
- tests/unit/test_schedule_schemas.py +0 -285
- tests/unit/test_schedule_utils.py +0 -286
- tests/unit/test_scheduled_workflow.py +0 -274
- tests/unit/test_step.py +0 -353
- tests/unit/test_workflow.py +0 -243
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.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
|
+
)
|