pyworkflow-engine 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DynamoDB storage backend using aiobotocore.
|
|
3
|
+
|
|
4
|
+
This backend stores workflow data in AWS DynamoDB, suitable for:
|
|
5
|
+
- Serverless deployments
|
|
6
|
+
- Multi-region high availability
|
|
7
|
+
- Automatically scaled workloads
|
|
8
|
+
|
|
9
|
+
Uses single-table design with Global Secondary Indexes for efficient querying.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from aiobotocore.session import get_session
|
|
18
|
+
from botocore.exceptions import ClientError
|
|
19
|
+
|
|
20
|
+
from pyworkflow.engine.events import Event, EventType
|
|
21
|
+
from pyworkflow.storage.base import StorageBackend
|
|
22
|
+
from pyworkflow.storage.schemas import (
|
|
23
|
+
Hook,
|
|
24
|
+
HookStatus,
|
|
25
|
+
OverlapPolicy,
|
|
26
|
+
RunStatus,
|
|
27
|
+
Schedule,
|
|
28
|
+
ScheduleSpec,
|
|
29
|
+
ScheduleStatus,
|
|
30
|
+
StepExecution,
|
|
31
|
+
StepStatus,
|
|
32
|
+
WorkflowRun,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DynamoDBStorageBackend(StorageBackend):
|
|
37
|
+
"""
|
|
38
|
+
DynamoDB storage backend using aiobotocore for async operations.
|
|
39
|
+
|
|
40
|
+
Uses single-table design with the following key patterns:
|
|
41
|
+
- PK: Entity type + ID (e.g., RUN#run_abc123)
|
|
42
|
+
- SK: Sub-key for ordering (e.g., #METADATA, EVENT#00001)
|
|
43
|
+
|
|
44
|
+
Global Secondary Indexes:
|
|
45
|
+
- GSI1: Status-based queries (GSI1PK: entity type, GSI1SK: status#created_at)
|
|
46
|
+
- GSI2: Workflow name queries (GSI2PK: WORKFLOW#name, GSI2SK: created_at)
|
|
47
|
+
- GSI3: Idempotency key lookup (GSI3PK: IDEMPOTENCY#key)
|
|
48
|
+
- GSI4: Parent-child relationships (GSI4PK: PARENT#run_id, GSI4SK: CHILD#run_id)
|
|
49
|
+
- GSI5: Schedule due time (GSI5PK: ACTIVE_SCHEDULES, GSI5SK: next_run_time)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
table_name: str = "pyworkflow",
|
|
55
|
+
region: str = "us-east-1",
|
|
56
|
+
endpoint_url: str | None = None,
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Initialize DynamoDB storage backend.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
table_name: DynamoDB table name
|
|
63
|
+
region: AWS region
|
|
64
|
+
endpoint_url: Optional endpoint URL for local testing (e.g., http://localhost:8000)
|
|
65
|
+
"""
|
|
66
|
+
self.table_name = table_name
|
|
67
|
+
self.region = region
|
|
68
|
+
self.endpoint_url = endpoint_url
|
|
69
|
+
self._session = get_session()
|
|
70
|
+
self._initialized = False
|
|
71
|
+
|
|
72
|
+
@asynccontextmanager
|
|
73
|
+
async def _get_client(self):
|
|
74
|
+
"""Get DynamoDB client context manager."""
|
|
75
|
+
async with self._session.create_client(
|
|
76
|
+
"dynamodb",
|
|
77
|
+
region_name=self.region,
|
|
78
|
+
endpoint_url=self.endpoint_url,
|
|
79
|
+
) as client:
|
|
80
|
+
yield client
|
|
81
|
+
|
|
82
|
+
async def connect(self) -> None:
|
|
83
|
+
"""Initialize connection and create table if needed."""
|
|
84
|
+
if not self._initialized:
|
|
85
|
+
await self._ensure_table_exists()
|
|
86
|
+
self._initialized = True
|
|
87
|
+
|
|
88
|
+
async def disconnect(self) -> None:
|
|
89
|
+
"""Close connection (no-op for DynamoDB, connection is per-request)."""
|
|
90
|
+
self._initialized = False
|
|
91
|
+
|
|
92
|
+
async def _ensure_table_exists(self) -> None:
|
|
93
|
+
"""Create table with GSIs if it doesn't exist."""
|
|
94
|
+
async with self._get_client() as client:
|
|
95
|
+
try:
|
|
96
|
+
await client.describe_table(TableName=self.table_name)
|
|
97
|
+
return # Table exists
|
|
98
|
+
except ClientError as e:
|
|
99
|
+
if e.response["Error"]["Code"] != "ResourceNotFoundException":
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
# Create table with single-table design
|
|
103
|
+
await client.create_table(
|
|
104
|
+
TableName=self.table_name,
|
|
105
|
+
KeySchema=[
|
|
106
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
107
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
108
|
+
],
|
|
109
|
+
AttributeDefinitions=[
|
|
110
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
111
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
112
|
+
{"AttributeName": "GSI1PK", "AttributeType": "S"},
|
|
113
|
+
{"AttributeName": "GSI1SK", "AttributeType": "S"},
|
|
114
|
+
{"AttributeName": "GSI2PK", "AttributeType": "S"},
|
|
115
|
+
{"AttributeName": "GSI2SK", "AttributeType": "S"},
|
|
116
|
+
{"AttributeName": "GSI3PK", "AttributeType": "S"},
|
|
117
|
+
{"AttributeName": "GSI4PK", "AttributeType": "S"},
|
|
118
|
+
{"AttributeName": "GSI4SK", "AttributeType": "S"},
|
|
119
|
+
{"AttributeName": "GSI5PK", "AttributeType": "S"},
|
|
120
|
+
{"AttributeName": "GSI5SK", "AttributeType": "S"},
|
|
121
|
+
],
|
|
122
|
+
GlobalSecondaryIndexes=[
|
|
123
|
+
{
|
|
124
|
+
"IndexName": "GSI1",
|
|
125
|
+
"KeySchema": [
|
|
126
|
+
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
|
|
127
|
+
{"AttributeName": "GSI1SK", "KeyType": "RANGE"},
|
|
128
|
+
],
|
|
129
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"IndexName": "GSI2",
|
|
133
|
+
"KeySchema": [
|
|
134
|
+
{"AttributeName": "GSI2PK", "KeyType": "HASH"},
|
|
135
|
+
{"AttributeName": "GSI2SK", "KeyType": "RANGE"},
|
|
136
|
+
],
|
|
137
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"IndexName": "GSI3",
|
|
141
|
+
"KeySchema": [
|
|
142
|
+
{"AttributeName": "GSI3PK", "KeyType": "HASH"},
|
|
143
|
+
],
|
|
144
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"IndexName": "GSI4",
|
|
148
|
+
"KeySchema": [
|
|
149
|
+
{"AttributeName": "GSI4PK", "KeyType": "HASH"},
|
|
150
|
+
{"AttributeName": "GSI4SK", "KeyType": "RANGE"},
|
|
151
|
+
],
|
|
152
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"IndexName": "GSI5",
|
|
156
|
+
"KeySchema": [
|
|
157
|
+
{"AttributeName": "GSI5PK", "KeyType": "HASH"},
|
|
158
|
+
{"AttributeName": "GSI5SK", "KeyType": "RANGE"},
|
|
159
|
+
],
|
|
160
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
BillingMode="PAY_PER_REQUEST", # On-demand capacity
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Wait for table to be active
|
|
167
|
+
waiter = client.get_waiter("table_exists")
|
|
168
|
+
await waiter.wait(TableName=self.table_name)
|
|
169
|
+
|
|
170
|
+
# Helper methods for DynamoDB item conversion
|
|
171
|
+
|
|
172
|
+
def _serialize_value(self, value: Any) -> dict[str, Any]:
|
|
173
|
+
"""Convert Python value to DynamoDB attribute value."""
|
|
174
|
+
if value is None:
|
|
175
|
+
return {"NULL": True}
|
|
176
|
+
elif isinstance(value, bool):
|
|
177
|
+
return {"BOOL": value}
|
|
178
|
+
elif isinstance(value, (int, float)):
|
|
179
|
+
return {"N": str(value)}
|
|
180
|
+
elif isinstance(value, str):
|
|
181
|
+
return {"S": value}
|
|
182
|
+
elif isinstance(value, list):
|
|
183
|
+
return {"L": [self._serialize_value(v) for v in value]}
|
|
184
|
+
elif isinstance(value, dict):
|
|
185
|
+
return {"M": {k: self._serialize_value(v) for k, v in value.items()}}
|
|
186
|
+
else:
|
|
187
|
+
return {"S": str(value)}
|
|
188
|
+
|
|
189
|
+
def _deserialize_value(self, attr: dict[str, Any]) -> Any:
|
|
190
|
+
"""Convert DynamoDB attribute value to Python value."""
|
|
191
|
+
if "NULL" in attr:
|
|
192
|
+
return None
|
|
193
|
+
elif "BOOL" in attr:
|
|
194
|
+
return attr["BOOL"]
|
|
195
|
+
elif "N" in attr:
|
|
196
|
+
n = attr["N"]
|
|
197
|
+
return int(n) if "." not in n else float(n)
|
|
198
|
+
elif "S" in attr:
|
|
199
|
+
return attr["S"]
|
|
200
|
+
elif "L" in attr:
|
|
201
|
+
return [self._deserialize_value(v) for v in attr["L"]]
|
|
202
|
+
elif "M" in attr:
|
|
203
|
+
return {k: self._deserialize_value(v) for k, v in attr["M"].items()}
|
|
204
|
+
else:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def _item_to_dict(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
208
|
+
"""Convert DynamoDB item to Python dict."""
|
|
209
|
+
return {k: self._deserialize_value(v) for k, v in item.items()}
|
|
210
|
+
|
|
211
|
+
def _dict_to_item(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
"""Convert Python dict to DynamoDB item."""
|
|
213
|
+
return {k: self._serialize_value(v) for k, v in data.items() if v is not None}
|
|
214
|
+
|
|
215
|
+
# Workflow Run Operations
|
|
216
|
+
|
|
217
|
+
async def create_run(self, run: WorkflowRun) -> None:
|
|
218
|
+
"""Create a new workflow run record."""
|
|
219
|
+
async with self._get_client() as client:
|
|
220
|
+
item = {
|
|
221
|
+
"PK": f"RUN#{run.run_id}",
|
|
222
|
+
"SK": "#METADATA",
|
|
223
|
+
"entity_type": "run",
|
|
224
|
+
"run_id": run.run_id,
|
|
225
|
+
"workflow_name": run.workflow_name,
|
|
226
|
+
"status": run.status.value,
|
|
227
|
+
"created_at": run.created_at.isoformat(),
|
|
228
|
+
"updated_at": run.updated_at.isoformat(),
|
|
229
|
+
"started_at": run.started_at.isoformat() if run.started_at else None,
|
|
230
|
+
"completed_at": run.completed_at.isoformat() if run.completed_at else None,
|
|
231
|
+
"input_args": run.input_args,
|
|
232
|
+
"input_kwargs": run.input_kwargs,
|
|
233
|
+
"result": run.result,
|
|
234
|
+
"error": run.error,
|
|
235
|
+
"idempotency_key": run.idempotency_key,
|
|
236
|
+
"max_duration": run.max_duration,
|
|
237
|
+
"metadata": json.dumps(run.metadata),
|
|
238
|
+
"recovery_attempts": run.recovery_attempts,
|
|
239
|
+
"max_recovery_attempts": run.max_recovery_attempts,
|
|
240
|
+
"recover_on_worker_loss": run.recover_on_worker_loss,
|
|
241
|
+
"parent_run_id": run.parent_run_id,
|
|
242
|
+
"nesting_depth": run.nesting_depth,
|
|
243
|
+
"continued_from_run_id": run.continued_from_run_id,
|
|
244
|
+
"continued_to_run_id": run.continued_to_run_id,
|
|
245
|
+
# GSI keys
|
|
246
|
+
"GSI1PK": "RUNS",
|
|
247
|
+
"GSI1SK": f"{run.status.value}#{run.created_at.isoformat()}",
|
|
248
|
+
"GSI2PK": f"WORKFLOW#{run.workflow_name}",
|
|
249
|
+
"GSI2SK": run.created_at.isoformat(),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Add idempotency GSI if key exists
|
|
253
|
+
if run.idempotency_key:
|
|
254
|
+
item["GSI3PK"] = f"IDEMPOTENCY#{run.idempotency_key}"
|
|
255
|
+
|
|
256
|
+
# Add parent-child GSI if parent exists
|
|
257
|
+
if run.parent_run_id:
|
|
258
|
+
item["GSI4PK"] = f"PARENT#{run.parent_run_id}"
|
|
259
|
+
item["GSI4SK"] = f"CHILD#{run.run_id}"
|
|
260
|
+
|
|
261
|
+
await client.put_item(
|
|
262
|
+
TableName=self.table_name,
|
|
263
|
+
Item=self._dict_to_item(item),
|
|
264
|
+
ConditionExpression="attribute_not_exists(PK)",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def get_run(self, run_id: str) -> WorkflowRun | None:
|
|
268
|
+
"""Retrieve a workflow run by ID."""
|
|
269
|
+
async with self._get_client() as client:
|
|
270
|
+
response = await client.get_item(
|
|
271
|
+
TableName=self.table_name,
|
|
272
|
+
Key={
|
|
273
|
+
"PK": {"S": f"RUN#{run_id}"},
|
|
274
|
+
"SK": {"S": "#METADATA"},
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
item = response.get("Item")
|
|
279
|
+
if not item:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
return self._item_to_workflow_run(self._item_to_dict(item))
|
|
283
|
+
|
|
284
|
+
async def get_run_by_idempotency_key(self, key: str) -> WorkflowRun | None:
|
|
285
|
+
"""Retrieve a workflow run by idempotency key."""
|
|
286
|
+
async with self._get_client() as client:
|
|
287
|
+
response = await client.query(
|
|
288
|
+
TableName=self.table_name,
|
|
289
|
+
IndexName="GSI3",
|
|
290
|
+
KeyConditionExpression="GSI3PK = :pk",
|
|
291
|
+
ExpressionAttributeValues={":pk": {"S": f"IDEMPOTENCY#{key}"}},
|
|
292
|
+
Limit=1,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
items = response.get("Items", [])
|
|
296
|
+
if not items:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
return self._item_to_workflow_run(self._item_to_dict(items[0]))
|
|
300
|
+
|
|
301
|
+
async def update_run_status(
|
|
302
|
+
self,
|
|
303
|
+
run_id: str,
|
|
304
|
+
status: RunStatus,
|
|
305
|
+
result: str | None = None,
|
|
306
|
+
error: str | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Update workflow run status."""
|
|
309
|
+
async with self._get_client() as client:
|
|
310
|
+
now = datetime.now(UTC).isoformat()
|
|
311
|
+
|
|
312
|
+
update_expr = "SET #status = :status, updated_at = :updated_at, GSI1SK = :gsi1sk"
|
|
313
|
+
expr_names = {"#status": "status"}
|
|
314
|
+
expr_values: dict[str, Any] = {
|
|
315
|
+
":status": {"S": status.value},
|
|
316
|
+
":updated_at": {"S": now},
|
|
317
|
+
":gsi1sk": {"S": f"{status.value}#{now}"},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if result is not None:
|
|
321
|
+
update_expr += ", #result = :result"
|
|
322
|
+
expr_names["#result"] = "result"
|
|
323
|
+
expr_values[":result"] = {"S": result}
|
|
324
|
+
|
|
325
|
+
if error is not None:
|
|
326
|
+
update_expr += ", #error = :error"
|
|
327
|
+
expr_names["#error"] = "error"
|
|
328
|
+
expr_values[":error"] = {"S": error}
|
|
329
|
+
|
|
330
|
+
if status == RunStatus.COMPLETED:
|
|
331
|
+
update_expr += ", completed_at = :completed_at"
|
|
332
|
+
expr_values[":completed_at"] = {"S": now}
|
|
333
|
+
|
|
334
|
+
await client.update_item(
|
|
335
|
+
TableName=self.table_name,
|
|
336
|
+
Key={
|
|
337
|
+
"PK": {"S": f"RUN#{run_id}"},
|
|
338
|
+
"SK": {"S": "#METADATA"},
|
|
339
|
+
},
|
|
340
|
+
UpdateExpression=update_expr,
|
|
341
|
+
ExpressionAttributeNames=expr_names,
|
|
342
|
+
ExpressionAttributeValues=expr_values,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
async def update_run_recovery_attempts(
|
|
346
|
+
self,
|
|
347
|
+
run_id: str,
|
|
348
|
+
recovery_attempts: int,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Update the recovery attempts counter for a workflow run."""
|
|
351
|
+
async with self._get_client() as client:
|
|
352
|
+
await client.update_item(
|
|
353
|
+
TableName=self.table_name,
|
|
354
|
+
Key={
|
|
355
|
+
"PK": {"S": f"RUN#{run_id}"},
|
|
356
|
+
"SK": {"S": "#METADATA"},
|
|
357
|
+
},
|
|
358
|
+
UpdateExpression="SET recovery_attempts = :ra, updated_at = :now",
|
|
359
|
+
ExpressionAttributeValues={
|
|
360
|
+
":ra": {"N": str(recovery_attempts)},
|
|
361
|
+
":now": {"S": datetime.now(UTC).isoformat()},
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
async def list_runs(
|
|
366
|
+
self,
|
|
367
|
+
query: str | None = None,
|
|
368
|
+
status: RunStatus | None = None,
|
|
369
|
+
start_time: datetime | None = None,
|
|
370
|
+
end_time: datetime | None = None,
|
|
371
|
+
limit: int = 100,
|
|
372
|
+
cursor: str | None = None,
|
|
373
|
+
) -> tuple[list[WorkflowRun], str | None]:
|
|
374
|
+
"""List workflow runs with optional filtering and pagination."""
|
|
375
|
+
async with self._get_client() as client:
|
|
376
|
+
# Use GSI1 for status-based queries, otherwise scan
|
|
377
|
+
if status:
|
|
378
|
+
key_condition = "GSI1PK = :pk AND begins_with(GSI1SK, :status)"
|
|
379
|
+
expr_values: dict[str, Any] = {
|
|
380
|
+
":pk": {"S": "RUNS"},
|
|
381
|
+
":status": {"S": f"{status.value}#"},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
params: dict[str, Any] = {
|
|
385
|
+
"TableName": self.table_name,
|
|
386
|
+
"IndexName": "GSI1",
|
|
387
|
+
"KeyConditionExpression": key_condition,
|
|
388
|
+
"ExpressionAttributeValues": expr_values,
|
|
389
|
+
"Limit": limit + 1,
|
|
390
|
+
"ScanIndexForward": False, # Descending order
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if cursor:
|
|
394
|
+
# Decode cursor (run_id)
|
|
395
|
+
run = await self.get_run(cursor)
|
|
396
|
+
if run:
|
|
397
|
+
params["ExclusiveStartKey"] = {
|
|
398
|
+
"GSI1PK": {"S": "RUNS"},
|
|
399
|
+
"GSI1SK": {"S": f"{run.status.value}#{run.created_at.isoformat()}"},
|
|
400
|
+
"PK": {"S": f"RUN#{cursor}"},
|
|
401
|
+
"SK": {"S": "#METADATA"},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
response = await client.query(**params)
|
|
405
|
+
else:
|
|
406
|
+
# Scan with filter for non-status queries
|
|
407
|
+
params = {
|
|
408
|
+
"TableName": self.table_name,
|
|
409
|
+
"IndexName": "GSI1",
|
|
410
|
+
"KeyConditionExpression": "GSI1PK = :pk",
|
|
411
|
+
"ExpressionAttributeValues": {":pk": {"S": "RUNS"}},
|
|
412
|
+
"Limit": limit + 1,
|
|
413
|
+
"ScanIndexForward": False,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
filter_exprs = []
|
|
417
|
+
expr_values = {}
|
|
418
|
+
|
|
419
|
+
if query:
|
|
420
|
+
filter_exprs.append(
|
|
421
|
+
"(contains(workflow_name, :query) OR contains(input_kwargs, :query))"
|
|
422
|
+
)
|
|
423
|
+
expr_values[":query"] = {"S": query}
|
|
424
|
+
|
|
425
|
+
if start_time:
|
|
426
|
+
filter_exprs.append("created_at >= :start_time")
|
|
427
|
+
expr_values[":start_time"] = {"S": start_time.isoformat()}
|
|
428
|
+
|
|
429
|
+
if end_time:
|
|
430
|
+
filter_exprs.append("created_at < :end_time")
|
|
431
|
+
expr_values[":end_time"] = {"S": end_time.isoformat()}
|
|
432
|
+
|
|
433
|
+
if filter_exprs:
|
|
434
|
+
params["FilterExpression"] = " AND ".join(filter_exprs)
|
|
435
|
+
params["ExpressionAttributeValues"].update(expr_values)
|
|
436
|
+
|
|
437
|
+
if cursor:
|
|
438
|
+
run = await self.get_run(cursor)
|
|
439
|
+
if run:
|
|
440
|
+
params["ExclusiveStartKey"] = {
|
|
441
|
+
"GSI1PK": {"S": "RUNS"},
|
|
442
|
+
"GSI1SK": {"S": f"{run.status.value}#{run.created_at.isoformat()}"},
|
|
443
|
+
"PK": {"S": f"RUN#{cursor}"},
|
|
444
|
+
"SK": {"S": "#METADATA"},
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
response = await client.query(**params)
|
|
448
|
+
|
|
449
|
+
items = response.get("Items", [])
|
|
450
|
+
has_more = len(items) > limit
|
|
451
|
+
|
|
452
|
+
if has_more:
|
|
453
|
+
items = items[:limit]
|
|
454
|
+
|
|
455
|
+
runs = [self._item_to_workflow_run(self._item_to_dict(item)) for item in items]
|
|
456
|
+
next_cursor = runs[-1].run_id if runs and has_more else None
|
|
457
|
+
|
|
458
|
+
return runs, next_cursor
|
|
459
|
+
|
|
460
|
+
# Event Log Operations
|
|
461
|
+
|
|
462
|
+
async def record_event(self, event: Event) -> None:
|
|
463
|
+
"""Record an event to the append-only event log."""
|
|
464
|
+
async with self._get_client() as client:
|
|
465
|
+
# Get next sequence number using atomic counter
|
|
466
|
+
response = await client.update_item(
|
|
467
|
+
TableName=self.table_name,
|
|
468
|
+
Key={
|
|
469
|
+
"PK": {"S": f"RUN#{event.run_id}"},
|
|
470
|
+
"SK": {"S": "#EVENT_COUNTER"},
|
|
471
|
+
},
|
|
472
|
+
UpdateExpression="ADD seq :inc",
|
|
473
|
+
ExpressionAttributeValues={":inc": {"N": "1"}},
|
|
474
|
+
ReturnValues="UPDATED_NEW",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
sequence = int(response["Attributes"]["seq"]["N"]) - 1
|
|
478
|
+
|
|
479
|
+
item = {
|
|
480
|
+
"PK": f"RUN#{event.run_id}",
|
|
481
|
+
"SK": f"EVENT#{sequence:05d}",
|
|
482
|
+
"entity_type": "event",
|
|
483
|
+
"event_id": event.event_id,
|
|
484
|
+
"run_id": event.run_id,
|
|
485
|
+
"sequence": sequence,
|
|
486
|
+
"type": event.type.value,
|
|
487
|
+
"timestamp": event.timestamp.isoformat(),
|
|
488
|
+
"data": json.dumps(event.data),
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
await client.put_item(
|
|
492
|
+
TableName=self.table_name,
|
|
493
|
+
Item=self._dict_to_item(item),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
async def get_events(
|
|
497
|
+
self,
|
|
498
|
+
run_id: str,
|
|
499
|
+
event_types: list[str] | None = None,
|
|
500
|
+
) -> list[Event]:
|
|
501
|
+
"""Retrieve all events for a workflow run, ordered by sequence."""
|
|
502
|
+
async with self._get_client() as client:
|
|
503
|
+
params: dict[str, Any] = {
|
|
504
|
+
"TableName": self.table_name,
|
|
505
|
+
"KeyConditionExpression": "PK = :pk AND begins_with(SK, :sk_prefix)",
|
|
506
|
+
"ExpressionAttributeValues": {
|
|
507
|
+
":pk": {"S": f"RUN#{run_id}"},
|
|
508
|
+
":sk_prefix": {"S": "EVENT#"},
|
|
509
|
+
},
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if event_types:
|
|
513
|
+
placeholders = [f":type{i}" for i in range(len(event_types))]
|
|
514
|
+
params["FilterExpression"] = f"#type IN ({', '.join(placeholders)})"
|
|
515
|
+
params["ExpressionAttributeNames"] = {"#type": "type"}
|
|
516
|
+
for i, et in enumerate(event_types):
|
|
517
|
+
params["ExpressionAttributeValues"][f":type{i}"] = {"S": et}
|
|
518
|
+
|
|
519
|
+
# Handle pagination for large event logs
|
|
520
|
+
events = []
|
|
521
|
+
while True:
|
|
522
|
+
response = await client.query(**params)
|
|
523
|
+
items = response.get("Items", [])
|
|
524
|
+
events.extend([self._item_to_event(self._item_to_dict(item)) for item in items])
|
|
525
|
+
|
|
526
|
+
if "LastEvaluatedKey" not in response:
|
|
527
|
+
break
|
|
528
|
+
params["ExclusiveStartKey"] = response["LastEvaluatedKey"]
|
|
529
|
+
|
|
530
|
+
return events
|
|
531
|
+
|
|
532
|
+
async def get_latest_event(
|
|
533
|
+
self,
|
|
534
|
+
run_id: str,
|
|
535
|
+
event_type: str | None = None,
|
|
536
|
+
) -> Event | None:
|
|
537
|
+
"""Get the latest event for a run, optionally filtered by type."""
|
|
538
|
+
async with self._get_client() as client:
|
|
539
|
+
params: dict[str, Any] = {
|
|
540
|
+
"TableName": self.table_name,
|
|
541
|
+
"KeyConditionExpression": "PK = :pk AND begins_with(SK, :sk_prefix)",
|
|
542
|
+
"ExpressionAttributeValues": {
|
|
543
|
+
":pk": {"S": f"RUN#{run_id}"},
|
|
544
|
+
":sk_prefix": {"S": "EVENT#"},
|
|
545
|
+
},
|
|
546
|
+
"ScanIndexForward": False, # Descending order
|
|
547
|
+
"Limit": 10 if event_type else 1, # Get more if filtering
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if event_type:
|
|
551
|
+
params["FilterExpression"] = "#type = :event_type"
|
|
552
|
+
params["ExpressionAttributeNames"] = {"#type": "type"}
|
|
553
|
+
params["ExpressionAttributeValues"][":event_type"] = {"S": event_type}
|
|
554
|
+
|
|
555
|
+
response = await client.query(**params)
|
|
556
|
+
items = response.get("Items", [])
|
|
557
|
+
|
|
558
|
+
if not items:
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
return self._item_to_event(self._item_to_dict(items[0]))
|
|
562
|
+
|
|
563
|
+
# Step Operations
|
|
564
|
+
|
|
565
|
+
async def create_step(self, step: StepExecution) -> None:
|
|
566
|
+
"""Create a step execution record."""
|
|
567
|
+
async with self._get_client() as client:
|
|
568
|
+
retry_count = step.attempt - 1 if step.attempt > 0 else 0
|
|
569
|
+
|
|
570
|
+
item = {
|
|
571
|
+
"PK": f"RUN#{step.run_id}",
|
|
572
|
+
"SK": f"STEP#{step.step_id}",
|
|
573
|
+
"entity_type": "step",
|
|
574
|
+
"step_id": step.step_id,
|
|
575
|
+
"run_id": step.run_id,
|
|
576
|
+
"step_name": step.step_name,
|
|
577
|
+
"status": step.status.value,
|
|
578
|
+
"created_at": step.created_at.isoformat(),
|
|
579
|
+
"started_at": step.started_at.isoformat() if step.started_at else None,
|
|
580
|
+
"completed_at": step.completed_at.isoformat() if step.completed_at else None,
|
|
581
|
+
"input_args": step.input_args,
|
|
582
|
+
"input_kwargs": step.input_kwargs,
|
|
583
|
+
"result": step.result,
|
|
584
|
+
"error": step.error,
|
|
585
|
+
"retry_count": retry_count,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await client.put_item(
|
|
589
|
+
TableName=self.table_name,
|
|
590
|
+
Item=self._dict_to_item(item),
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
async def get_step(self, step_id: str) -> StepExecution | None:
|
|
594
|
+
"""Retrieve a step execution by ID."""
|
|
595
|
+
# Steps are stored under their run, so we need to scan
|
|
596
|
+
# Note: For high-volume production use, consider adding a GSI on step_id
|
|
597
|
+
async with self._get_client() as client:
|
|
598
|
+
items = []
|
|
599
|
+
|
|
600
|
+
# Scan with pagination to find the step
|
|
601
|
+
response = await client.scan(
|
|
602
|
+
TableName=self.table_name,
|
|
603
|
+
FilterExpression="entity_type = :et AND step_id = :sid",
|
|
604
|
+
ExpressionAttributeValues={
|
|
605
|
+
":et": {"S": "step"},
|
|
606
|
+
":sid": {"S": step_id},
|
|
607
|
+
},
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
items.extend(response.get("Items", []))
|
|
611
|
+
|
|
612
|
+
# Continue scanning if there are more pages and we haven't found it
|
|
613
|
+
while "LastEvaluatedKey" in response and not items:
|
|
614
|
+
response = await client.scan(
|
|
615
|
+
TableName=self.table_name,
|
|
616
|
+
FilterExpression="entity_type = :et AND step_id = :sid",
|
|
617
|
+
ExpressionAttributeValues={
|
|
618
|
+
":et": {"S": "step"},
|
|
619
|
+
":sid": {"S": step_id},
|
|
620
|
+
},
|
|
621
|
+
ExclusiveStartKey=response["LastEvaluatedKey"],
|
|
622
|
+
)
|
|
623
|
+
items.extend(response.get("Items", []))
|
|
624
|
+
|
|
625
|
+
if not items:
|
|
626
|
+
return None
|
|
627
|
+
|
|
628
|
+
return self._item_to_step_execution(self._item_to_dict(items[0]))
|
|
629
|
+
|
|
630
|
+
async def update_step_status(
|
|
631
|
+
self,
|
|
632
|
+
step_id: str,
|
|
633
|
+
status: str,
|
|
634
|
+
result: str | None = None,
|
|
635
|
+
error: str | None = None,
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Update step execution status."""
|
|
638
|
+
# First find the step to get its run_id
|
|
639
|
+
step = await self.get_step(step_id)
|
|
640
|
+
if not step:
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
async with self._get_client() as client:
|
|
644
|
+
update_expr = "SET #status = :status"
|
|
645
|
+
expr_names = {"#status": "status"}
|
|
646
|
+
expr_values: dict[str, Any] = {":status": {"S": status}}
|
|
647
|
+
|
|
648
|
+
if result is not None:
|
|
649
|
+
update_expr += ", #result = :result"
|
|
650
|
+
expr_names["#result"] = "result"
|
|
651
|
+
expr_values[":result"] = {"S": result}
|
|
652
|
+
|
|
653
|
+
if error is not None:
|
|
654
|
+
update_expr += ", #error = :error"
|
|
655
|
+
expr_names["#error"] = "error"
|
|
656
|
+
expr_values[":error"] = {"S": error}
|
|
657
|
+
|
|
658
|
+
if status == "completed":
|
|
659
|
+
update_expr += ", completed_at = :completed_at"
|
|
660
|
+
expr_values[":completed_at"] = {"S": datetime.now(UTC).isoformat()}
|
|
661
|
+
|
|
662
|
+
await client.update_item(
|
|
663
|
+
TableName=self.table_name,
|
|
664
|
+
Key={
|
|
665
|
+
"PK": {"S": f"RUN#{step.run_id}"},
|
|
666
|
+
"SK": {"S": f"STEP#{step_id}"},
|
|
667
|
+
},
|
|
668
|
+
UpdateExpression=update_expr,
|
|
669
|
+
ExpressionAttributeNames=expr_names,
|
|
670
|
+
ExpressionAttributeValues=expr_values,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
async def list_steps(self, run_id: str) -> list[StepExecution]:
|
|
674
|
+
"""List all steps for a workflow run."""
|
|
675
|
+
async with self._get_client() as client:
|
|
676
|
+
response = await client.query(
|
|
677
|
+
TableName=self.table_name,
|
|
678
|
+
KeyConditionExpression="PK = :pk AND begins_with(SK, :sk_prefix)",
|
|
679
|
+
ExpressionAttributeValues={
|
|
680
|
+
":pk": {"S": f"RUN#{run_id}"},
|
|
681
|
+
":sk_prefix": {"S": "STEP#"},
|
|
682
|
+
},
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
items = response.get("Items", [])
|
|
686
|
+
steps = [self._item_to_step_execution(self._item_to_dict(item)) for item in items]
|
|
687
|
+
|
|
688
|
+
# Sort by created_at
|
|
689
|
+
steps.sort(key=lambda s: s.created_at)
|
|
690
|
+
return steps
|
|
691
|
+
|
|
692
|
+
# Hook Operations
|
|
693
|
+
|
|
694
|
+
async def create_hook(self, hook: Hook) -> None:
|
|
695
|
+
"""Create a hook record."""
|
|
696
|
+
async with self._get_client() as client:
|
|
697
|
+
# Main hook item
|
|
698
|
+
item = {
|
|
699
|
+
"PK": f"HOOK#{hook.hook_id}",
|
|
700
|
+
"SK": "#METADATA",
|
|
701
|
+
"entity_type": "hook",
|
|
702
|
+
"hook_id": hook.hook_id,
|
|
703
|
+
"run_id": hook.run_id,
|
|
704
|
+
"token": hook.token,
|
|
705
|
+
"created_at": hook.created_at.isoformat(),
|
|
706
|
+
"received_at": hook.received_at.isoformat() if hook.received_at else None,
|
|
707
|
+
"expires_at": hook.expires_at.isoformat() if hook.expires_at else None,
|
|
708
|
+
"status": hook.status.value,
|
|
709
|
+
"payload": hook.payload,
|
|
710
|
+
"metadata": json.dumps(hook.metadata),
|
|
711
|
+
# GSI for run_id lookup
|
|
712
|
+
"GSI1PK": f"RUN_HOOKS#{hook.run_id}",
|
|
713
|
+
"GSI1SK": f"{hook.status.value}#{hook.created_at.isoformat()}",
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
# Token lookup item
|
|
717
|
+
token_item = {
|
|
718
|
+
"PK": f"TOKEN#{hook.token}",
|
|
719
|
+
"SK": f"HOOK#{hook.hook_id}",
|
|
720
|
+
"entity_type": "hook_token",
|
|
721
|
+
"hook_id": hook.hook_id,
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
# Write both items
|
|
725
|
+
await client.put_item(
|
|
726
|
+
TableName=self.table_name,
|
|
727
|
+
Item=self._dict_to_item(item),
|
|
728
|
+
)
|
|
729
|
+
await client.put_item(
|
|
730
|
+
TableName=self.table_name,
|
|
731
|
+
Item=self._dict_to_item(token_item),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
async def get_hook(self, hook_id: str) -> Hook | None:
|
|
735
|
+
"""Retrieve a hook by ID."""
|
|
736
|
+
async with self._get_client() as client:
|
|
737
|
+
response = await client.get_item(
|
|
738
|
+
TableName=self.table_name,
|
|
739
|
+
Key={
|
|
740
|
+
"PK": {"S": f"HOOK#{hook_id}"},
|
|
741
|
+
"SK": {"S": "#METADATA"},
|
|
742
|
+
},
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
item = response.get("Item")
|
|
746
|
+
if not item:
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
return self._item_to_hook(self._item_to_dict(item))
|
|
750
|
+
|
|
751
|
+
async def get_hook_by_token(self, token: str) -> Hook | None:
|
|
752
|
+
"""Retrieve a hook by its token."""
|
|
753
|
+
async with self._get_client() as client:
|
|
754
|
+
# First get the hook_id from the token lookup item
|
|
755
|
+
response = await client.query(
|
|
756
|
+
TableName=self.table_name,
|
|
757
|
+
KeyConditionExpression="PK = :pk",
|
|
758
|
+
ExpressionAttributeValues={":pk": {"S": f"TOKEN#{token}"}},
|
|
759
|
+
Limit=1,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
items = response.get("Items", [])
|
|
763
|
+
if not items:
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
hook_id = self._deserialize_value(items[0]["hook_id"])
|
|
767
|
+
return await self.get_hook(hook_id)
|
|
768
|
+
|
|
769
|
+
async def update_hook_status(
|
|
770
|
+
self,
|
|
771
|
+
hook_id: str,
|
|
772
|
+
status: HookStatus,
|
|
773
|
+
payload: str | None = None,
|
|
774
|
+
) -> None:
|
|
775
|
+
"""Update hook status and optionally payload."""
|
|
776
|
+
async with self._get_client() as client:
|
|
777
|
+
update_expr = "SET #status = :status"
|
|
778
|
+
expr_names = {"#status": "status"}
|
|
779
|
+
expr_values: dict[str, Any] = {":status": {"S": status.value}}
|
|
780
|
+
|
|
781
|
+
if payload is not None:
|
|
782
|
+
update_expr += ", payload = :payload"
|
|
783
|
+
expr_values[":payload"] = {"S": payload}
|
|
784
|
+
|
|
785
|
+
if status == HookStatus.RECEIVED:
|
|
786
|
+
update_expr += ", received_at = :received_at"
|
|
787
|
+
expr_values[":received_at"] = {"S": datetime.now(UTC).isoformat()}
|
|
788
|
+
|
|
789
|
+
await client.update_item(
|
|
790
|
+
TableName=self.table_name,
|
|
791
|
+
Key={
|
|
792
|
+
"PK": {"S": f"HOOK#{hook_id}"},
|
|
793
|
+
"SK": {"S": "#METADATA"},
|
|
794
|
+
},
|
|
795
|
+
UpdateExpression=update_expr,
|
|
796
|
+
ExpressionAttributeNames=expr_names,
|
|
797
|
+
ExpressionAttributeValues=expr_values,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
async def list_hooks(
|
|
801
|
+
self,
|
|
802
|
+
run_id: str | None = None,
|
|
803
|
+
status: HookStatus | None = None,
|
|
804
|
+
limit: int = 100,
|
|
805
|
+
offset: int = 0,
|
|
806
|
+
) -> list[Hook]:
|
|
807
|
+
"""List hooks with optional filtering."""
|
|
808
|
+
async with self._get_client() as client:
|
|
809
|
+
if run_id:
|
|
810
|
+
# Use GSI1 for run_id-based queries
|
|
811
|
+
params: dict[str, Any] = {
|
|
812
|
+
"TableName": self.table_name,
|
|
813
|
+
"IndexName": "GSI1",
|
|
814
|
+
"KeyConditionExpression": "GSI1PK = :pk",
|
|
815
|
+
"ExpressionAttributeValues": {":pk": {"S": f"RUN_HOOKS#{run_id}"}},
|
|
816
|
+
"Limit": limit + offset,
|
|
817
|
+
"ScanIndexForward": False,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if status:
|
|
821
|
+
params["KeyConditionExpression"] += " AND begins_with(GSI1SK, :status)"
|
|
822
|
+
params["ExpressionAttributeValues"][":status"] = {"S": f"{status.value}#"}
|
|
823
|
+
|
|
824
|
+
response = await client.query(**params)
|
|
825
|
+
else:
|
|
826
|
+
# Scan for all hooks
|
|
827
|
+
params = {
|
|
828
|
+
"TableName": self.table_name,
|
|
829
|
+
"FilterExpression": "entity_type = :et",
|
|
830
|
+
"ExpressionAttributeValues": {":et": {"S": "hook"}},
|
|
831
|
+
"Limit": limit + offset,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if status:
|
|
835
|
+
params["FilterExpression"] += " AND #status = :status"
|
|
836
|
+
params["ExpressionAttributeNames"] = {"#status": "status"}
|
|
837
|
+
params["ExpressionAttributeValues"][":status"] = {"S": status.value}
|
|
838
|
+
|
|
839
|
+
response = await client.scan(**params)
|
|
840
|
+
|
|
841
|
+
items = response.get("Items", [])
|
|
842
|
+
|
|
843
|
+
# Apply offset
|
|
844
|
+
items = items[offset : offset + limit]
|
|
845
|
+
|
|
846
|
+
return [self._item_to_hook(self._item_to_dict(item)) for item in items]
|
|
847
|
+
|
|
848
|
+
# Cancellation Flag Operations
|
|
849
|
+
|
|
850
|
+
async def set_cancellation_flag(self, run_id: str) -> None:
|
|
851
|
+
"""Set a cancellation flag for a workflow run."""
|
|
852
|
+
async with self._get_client() as client:
|
|
853
|
+
await client.put_item(
|
|
854
|
+
TableName=self.table_name,
|
|
855
|
+
Item=self._dict_to_item(
|
|
856
|
+
{
|
|
857
|
+
"PK": f"CANCEL#{run_id}",
|
|
858
|
+
"SK": "#FLAG",
|
|
859
|
+
"entity_type": "cancellation",
|
|
860
|
+
"run_id": run_id,
|
|
861
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
862
|
+
}
|
|
863
|
+
),
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
async def check_cancellation_flag(self, run_id: str) -> bool:
|
|
867
|
+
"""Check if a cancellation flag is set for a workflow run."""
|
|
868
|
+
async with self._get_client() as client:
|
|
869
|
+
response = await client.get_item(
|
|
870
|
+
TableName=self.table_name,
|
|
871
|
+
Key={
|
|
872
|
+
"PK": {"S": f"CANCEL#{run_id}"},
|
|
873
|
+
"SK": {"S": "#FLAG"},
|
|
874
|
+
},
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
return "Item" in response
|
|
878
|
+
|
|
879
|
+
async def clear_cancellation_flag(self, run_id: str) -> None:
|
|
880
|
+
"""Clear the cancellation flag for a workflow run."""
|
|
881
|
+
async with self._get_client() as client:
|
|
882
|
+
await client.delete_item(
|
|
883
|
+
TableName=self.table_name,
|
|
884
|
+
Key={
|
|
885
|
+
"PK": {"S": f"CANCEL#{run_id}"},
|
|
886
|
+
"SK": {"S": "#FLAG"},
|
|
887
|
+
},
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Continue-As-New Chain Operations
|
|
891
|
+
|
|
892
|
+
async def update_run_continuation(
|
|
893
|
+
self,
|
|
894
|
+
run_id: str,
|
|
895
|
+
continued_to_run_id: str,
|
|
896
|
+
) -> None:
|
|
897
|
+
"""Update the continuation link for a workflow run."""
|
|
898
|
+
async with self._get_client() as client:
|
|
899
|
+
await client.update_item(
|
|
900
|
+
TableName=self.table_name,
|
|
901
|
+
Key={
|
|
902
|
+
"PK": {"S": f"RUN#{run_id}"},
|
|
903
|
+
"SK": {"S": "#METADATA"},
|
|
904
|
+
},
|
|
905
|
+
UpdateExpression="SET continued_to_run_id = :ctr, updated_at = :now",
|
|
906
|
+
ExpressionAttributeValues={
|
|
907
|
+
":ctr": {"S": continued_to_run_id},
|
|
908
|
+
":now": {"S": datetime.now(UTC).isoformat()},
|
|
909
|
+
},
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
async def get_workflow_chain(
|
|
913
|
+
self,
|
|
914
|
+
run_id: str,
|
|
915
|
+
) -> list[WorkflowRun]:
|
|
916
|
+
"""Get all runs in a continue-as-new chain."""
|
|
917
|
+
# Find the first run in the chain
|
|
918
|
+
current_id: str | None = run_id
|
|
919
|
+
while current_id:
|
|
920
|
+
run = await self.get_run(current_id)
|
|
921
|
+
if not run or not run.continued_from_run_id:
|
|
922
|
+
break
|
|
923
|
+
current_id = run.continued_from_run_id
|
|
924
|
+
|
|
925
|
+
# Now collect all runs in the chain from first to last
|
|
926
|
+
runs = []
|
|
927
|
+
while current_id:
|
|
928
|
+
run = await self.get_run(current_id)
|
|
929
|
+
if not run:
|
|
930
|
+
break
|
|
931
|
+
runs.append(run)
|
|
932
|
+
current_id = run.continued_to_run_id
|
|
933
|
+
|
|
934
|
+
return runs
|
|
935
|
+
|
|
936
|
+
# Child Workflow Operations
|
|
937
|
+
|
|
938
|
+
async def get_children(
|
|
939
|
+
self,
|
|
940
|
+
parent_run_id: str,
|
|
941
|
+
status: RunStatus | None = None,
|
|
942
|
+
) -> list[WorkflowRun]:
|
|
943
|
+
"""Get all child workflow runs for a parent workflow."""
|
|
944
|
+
async with self._get_client() as client:
|
|
945
|
+
params: dict[str, Any] = {
|
|
946
|
+
"TableName": self.table_name,
|
|
947
|
+
"IndexName": "GSI4",
|
|
948
|
+
"KeyConditionExpression": "GSI4PK = :pk",
|
|
949
|
+
"ExpressionAttributeValues": {":pk": {"S": f"PARENT#{parent_run_id}"}},
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if status:
|
|
953
|
+
params["FilterExpression"] = "#status = :status"
|
|
954
|
+
params["ExpressionAttributeNames"] = {"#status": "status"}
|
|
955
|
+
params["ExpressionAttributeValues"][":status"] = {"S": status.value}
|
|
956
|
+
|
|
957
|
+
response = await client.query(**params)
|
|
958
|
+
items = response.get("Items", [])
|
|
959
|
+
|
|
960
|
+
runs = [self._item_to_workflow_run(self._item_to_dict(item)) for item in items]
|
|
961
|
+
runs.sort(key=lambda r: r.created_at)
|
|
962
|
+
return runs
|
|
963
|
+
|
|
964
|
+
async def get_parent(self, run_id: str) -> WorkflowRun | None:
|
|
965
|
+
"""Get the parent workflow run for a child workflow."""
|
|
966
|
+
run = await self.get_run(run_id)
|
|
967
|
+
if not run or not run.parent_run_id:
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
return await self.get_run(run.parent_run_id)
|
|
971
|
+
|
|
972
|
+
async def get_nesting_depth(self, run_id: str) -> int:
|
|
973
|
+
"""Get the nesting depth for a workflow."""
|
|
974
|
+
run = await self.get_run(run_id)
|
|
975
|
+
return run.nesting_depth if run else 0
|
|
976
|
+
|
|
977
|
+
# Schedule Operations
|
|
978
|
+
|
|
979
|
+
async def create_schedule(self, schedule: Schedule) -> None:
|
|
980
|
+
"""Create a new schedule record."""
|
|
981
|
+
async with self._get_client() as client:
|
|
982
|
+
spec_value = schedule.spec.cron or schedule.spec.interval or ""
|
|
983
|
+
spec_type = "cron" if schedule.spec.cron else "interval"
|
|
984
|
+
timezone = schedule.spec.timezone
|
|
985
|
+
|
|
986
|
+
item = {
|
|
987
|
+
"PK": f"SCHEDULE#{schedule.schedule_id}",
|
|
988
|
+
"SK": "#METADATA",
|
|
989
|
+
"entity_type": "schedule",
|
|
990
|
+
"schedule_id": schedule.schedule_id,
|
|
991
|
+
"workflow_name": schedule.workflow_name,
|
|
992
|
+
"spec": spec_value,
|
|
993
|
+
"spec_type": spec_type,
|
|
994
|
+
"timezone": timezone,
|
|
995
|
+
"input_args": schedule.args,
|
|
996
|
+
"input_kwargs": schedule.kwargs,
|
|
997
|
+
"status": schedule.status.value,
|
|
998
|
+
"overlap_policy": schedule.overlap_policy.value,
|
|
999
|
+
"next_run_time": schedule.next_run_time.isoformat()
|
|
1000
|
+
if schedule.next_run_time
|
|
1001
|
+
else None,
|
|
1002
|
+
"last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
|
|
1003
|
+
"running_run_ids": json.dumps(schedule.running_run_ids),
|
|
1004
|
+
"created_at": schedule.created_at.isoformat(),
|
|
1005
|
+
"updated_at": schedule.updated_at.isoformat()
|
|
1006
|
+
if schedule.updated_at
|
|
1007
|
+
else datetime.now(UTC).isoformat(),
|
|
1008
|
+
# GSI keys
|
|
1009
|
+
"GSI1PK": "SCHEDULES",
|
|
1010
|
+
"GSI1SK": f"{schedule.status.value}#{schedule.created_at.isoformat()}",
|
|
1011
|
+
"GSI2PK": f"WORKFLOW#{schedule.workflow_name}",
|
|
1012
|
+
"GSI2SK": schedule.created_at.isoformat(),
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
# Add active schedules GSI for due schedule queries
|
|
1016
|
+
if schedule.status == ScheduleStatus.ACTIVE and schedule.next_run_time:
|
|
1017
|
+
item["GSI5PK"] = "ACTIVE_SCHEDULES"
|
|
1018
|
+
item["GSI5SK"] = schedule.next_run_time.isoformat()
|
|
1019
|
+
|
|
1020
|
+
await client.put_item(
|
|
1021
|
+
TableName=self.table_name,
|
|
1022
|
+
Item=self._dict_to_item(item),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
async def get_schedule(self, schedule_id: str) -> Schedule | None:
|
|
1026
|
+
"""Retrieve a schedule by ID."""
|
|
1027
|
+
async with self._get_client() as client:
|
|
1028
|
+
response = await client.get_item(
|
|
1029
|
+
TableName=self.table_name,
|
|
1030
|
+
Key={
|
|
1031
|
+
"PK": {"S": f"SCHEDULE#{schedule_id}"},
|
|
1032
|
+
"SK": {"S": "#METADATA"},
|
|
1033
|
+
},
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
item = response.get("Item")
|
|
1037
|
+
if not item:
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
return self._item_to_schedule(self._item_to_dict(item))
|
|
1041
|
+
|
|
1042
|
+
async def update_schedule(self, schedule: Schedule) -> None:
|
|
1043
|
+
"""Update an existing schedule."""
|
|
1044
|
+
async with self._get_client() as client:
|
|
1045
|
+
spec_value = schedule.spec.cron or schedule.spec.interval or ""
|
|
1046
|
+
spec_type = "cron" if schedule.spec.cron else "interval"
|
|
1047
|
+
timezone = schedule.spec.timezone
|
|
1048
|
+
now = datetime.now(UTC)
|
|
1049
|
+
|
|
1050
|
+
item = {
|
|
1051
|
+
"PK": f"SCHEDULE#{schedule.schedule_id}",
|
|
1052
|
+
"SK": "#METADATA",
|
|
1053
|
+
"entity_type": "schedule",
|
|
1054
|
+
"schedule_id": schedule.schedule_id,
|
|
1055
|
+
"workflow_name": schedule.workflow_name,
|
|
1056
|
+
"spec": spec_value,
|
|
1057
|
+
"spec_type": spec_type,
|
|
1058
|
+
"timezone": timezone,
|
|
1059
|
+
"input_args": schedule.args,
|
|
1060
|
+
"input_kwargs": schedule.kwargs,
|
|
1061
|
+
"status": schedule.status.value,
|
|
1062
|
+
"overlap_policy": schedule.overlap_policy.value,
|
|
1063
|
+
"next_run_time": schedule.next_run_time.isoformat()
|
|
1064
|
+
if schedule.next_run_time
|
|
1065
|
+
else None,
|
|
1066
|
+
"last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
|
|
1067
|
+
"running_run_ids": json.dumps(schedule.running_run_ids),
|
|
1068
|
+
"created_at": schedule.created_at.isoformat(),
|
|
1069
|
+
"updated_at": schedule.updated_at.isoformat()
|
|
1070
|
+
if schedule.updated_at
|
|
1071
|
+
else now.isoformat(),
|
|
1072
|
+
# GSI keys
|
|
1073
|
+
"GSI1PK": "SCHEDULES",
|
|
1074
|
+
"GSI1SK": f"{schedule.status.value}#{schedule.created_at.isoformat()}",
|
|
1075
|
+
"GSI2PK": f"WORKFLOW#{schedule.workflow_name}",
|
|
1076
|
+
"GSI2SK": schedule.created_at.isoformat(),
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
# Add active schedules GSI for due schedule queries
|
|
1080
|
+
if schedule.status == ScheduleStatus.ACTIVE and schedule.next_run_time:
|
|
1081
|
+
item["GSI5PK"] = "ACTIVE_SCHEDULES"
|
|
1082
|
+
item["GSI5SK"] = schedule.next_run_time.isoformat()
|
|
1083
|
+
|
|
1084
|
+
await client.put_item(
|
|
1085
|
+
TableName=self.table_name,
|
|
1086
|
+
Item=self._dict_to_item(item),
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
async def delete_schedule(self, schedule_id: str) -> None:
|
|
1090
|
+
"""Mark a schedule as deleted (soft delete)."""
|
|
1091
|
+
schedule = await self.get_schedule(schedule_id)
|
|
1092
|
+
if not schedule:
|
|
1093
|
+
return
|
|
1094
|
+
|
|
1095
|
+
schedule.status = ScheduleStatus.DELETED
|
|
1096
|
+
schedule.updated_at = datetime.now(UTC)
|
|
1097
|
+
await self.update_schedule(schedule)
|
|
1098
|
+
|
|
1099
|
+
async def list_schedules(
|
|
1100
|
+
self,
|
|
1101
|
+
workflow_name: str | None = None,
|
|
1102
|
+
status: ScheduleStatus | None = None,
|
|
1103
|
+
limit: int = 100,
|
|
1104
|
+
offset: int = 0,
|
|
1105
|
+
) -> list[Schedule]:
|
|
1106
|
+
"""List schedules with optional filtering."""
|
|
1107
|
+
async with self._get_client() as client:
|
|
1108
|
+
if workflow_name:
|
|
1109
|
+
# Use GSI2 for workflow_name queries
|
|
1110
|
+
params: dict[str, Any] = {
|
|
1111
|
+
"TableName": self.table_name,
|
|
1112
|
+
"IndexName": "GSI2",
|
|
1113
|
+
"KeyConditionExpression": "GSI2PK = :pk",
|
|
1114
|
+
"ExpressionAttributeValues": {":pk": {"S": f"WORKFLOW#{workflow_name}"}},
|
|
1115
|
+
"Limit": limit + offset,
|
|
1116
|
+
"ScanIndexForward": False,
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if status:
|
|
1120
|
+
params["FilterExpression"] = "#status = :status"
|
|
1121
|
+
params["ExpressionAttributeNames"] = {"#status": "status"}
|
|
1122
|
+
params["ExpressionAttributeValues"][":status"] = {"S": status.value}
|
|
1123
|
+
|
|
1124
|
+
response = await client.query(**params)
|
|
1125
|
+
elif status:
|
|
1126
|
+
# Use GSI1 for status queries
|
|
1127
|
+
params = {
|
|
1128
|
+
"TableName": self.table_name,
|
|
1129
|
+
"IndexName": "GSI1",
|
|
1130
|
+
"KeyConditionExpression": "GSI1PK = :pk AND begins_with(GSI1SK, :status)",
|
|
1131
|
+
"ExpressionAttributeValues": {
|
|
1132
|
+
":pk": {"S": "SCHEDULES"},
|
|
1133
|
+
":status": {"S": f"{status.value}#"},
|
|
1134
|
+
},
|
|
1135
|
+
"Limit": limit + offset,
|
|
1136
|
+
"ScanIndexForward": False,
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
response = await client.query(**params)
|
|
1140
|
+
else:
|
|
1141
|
+
# Query all schedules
|
|
1142
|
+
params = {
|
|
1143
|
+
"TableName": self.table_name,
|
|
1144
|
+
"IndexName": "GSI1",
|
|
1145
|
+
"KeyConditionExpression": "GSI1PK = :pk",
|
|
1146
|
+
"ExpressionAttributeValues": {":pk": {"S": "SCHEDULES"}},
|
|
1147
|
+
"Limit": limit + offset,
|
|
1148
|
+
"ScanIndexForward": False,
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
response = await client.query(**params)
|
|
1152
|
+
|
|
1153
|
+
items = response.get("Items", [])
|
|
1154
|
+
|
|
1155
|
+
# Apply offset
|
|
1156
|
+
items = items[offset : offset + limit]
|
|
1157
|
+
|
|
1158
|
+
return [self._item_to_schedule(self._item_to_dict(item)) for item in items]
|
|
1159
|
+
|
|
1160
|
+
async def get_due_schedules(self, now: datetime) -> list[Schedule]:
|
|
1161
|
+
"""Get all schedules that are due to run."""
|
|
1162
|
+
async with self._get_client() as client:
|
|
1163
|
+
response = await client.query(
|
|
1164
|
+
TableName=self.table_name,
|
|
1165
|
+
IndexName="GSI5",
|
|
1166
|
+
KeyConditionExpression="GSI5PK = :pk AND GSI5SK <= :now",
|
|
1167
|
+
ExpressionAttributeValues={
|
|
1168
|
+
":pk": {"S": "ACTIVE_SCHEDULES"},
|
|
1169
|
+
":now": {"S": now.isoformat()},
|
|
1170
|
+
},
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
items = response.get("Items", [])
|
|
1174
|
+
schedules = [self._item_to_schedule(self._item_to_dict(item)) for item in items]
|
|
1175
|
+
|
|
1176
|
+
# Sort by next_run_time
|
|
1177
|
+
schedules.sort(key=lambda s: s.next_run_time or datetime.min.replace(tzinfo=UTC))
|
|
1178
|
+
return schedules
|
|
1179
|
+
|
|
1180
|
+
async def add_running_run(self, schedule_id: str, run_id: str) -> None:
|
|
1181
|
+
"""Add a run_id to the schedule's running_run_ids list."""
|
|
1182
|
+
schedule = await self.get_schedule(schedule_id)
|
|
1183
|
+
if not schedule:
|
|
1184
|
+
raise ValueError(f"Schedule {schedule_id} not found")
|
|
1185
|
+
|
|
1186
|
+
if run_id not in schedule.running_run_ids:
|
|
1187
|
+
schedule.running_run_ids.append(run_id)
|
|
1188
|
+
schedule.updated_at = datetime.now(UTC)
|
|
1189
|
+
await self.update_schedule(schedule)
|
|
1190
|
+
|
|
1191
|
+
async def remove_running_run(self, schedule_id: str, run_id: str) -> None:
|
|
1192
|
+
"""Remove a run_id from the schedule's running_run_ids list."""
|
|
1193
|
+
schedule = await self.get_schedule(schedule_id)
|
|
1194
|
+
if not schedule:
|
|
1195
|
+
raise ValueError(f"Schedule {schedule_id} not found")
|
|
1196
|
+
|
|
1197
|
+
if run_id in schedule.running_run_ids:
|
|
1198
|
+
schedule.running_run_ids.remove(run_id)
|
|
1199
|
+
schedule.updated_at = datetime.now(UTC)
|
|
1200
|
+
await self.update_schedule(schedule)
|
|
1201
|
+
|
|
1202
|
+
# Helper methods for converting DynamoDB items to domain objects
|
|
1203
|
+
|
|
1204
|
+
def _item_to_workflow_run(self, item: dict[str, Any]) -> WorkflowRun:
|
|
1205
|
+
"""Convert DynamoDB item to WorkflowRun object."""
|
|
1206
|
+
return WorkflowRun(
|
|
1207
|
+
run_id=item["run_id"],
|
|
1208
|
+
workflow_name=item["workflow_name"],
|
|
1209
|
+
status=RunStatus(item["status"]),
|
|
1210
|
+
created_at=datetime.fromisoformat(item["created_at"]),
|
|
1211
|
+
updated_at=datetime.fromisoformat(item["updated_at"]),
|
|
1212
|
+
started_at=datetime.fromisoformat(item["started_at"])
|
|
1213
|
+
if item.get("started_at")
|
|
1214
|
+
else None,
|
|
1215
|
+
completed_at=datetime.fromisoformat(item["completed_at"])
|
|
1216
|
+
if item.get("completed_at")
|
|
1217
|
+
else None,
|
|
1218
|
+
input_args=item.get("input_args", "[]"),
|
|
1219
|
+
input_kwargs=item.get("input_kwargs", "{}"),
|
|
1220
|
+
result=item.get("result"),
|
|
1221
|
+
error=item.get("error"),
|
|
1222
|
+
idempotency_key=item.get("idempotency_key"),
|
|
1223
|
+
max_duration=item.get("max_duration"),
|
|
1224
|
+
metadata=json.loads(item.get("metadata", "{}")),
|
|
1225
|
+
recovery_attempts=item.get("recovery_attempts", 0),
|
|
1226
|
+
max_recovery_attempts=item.get("max_recovery_attempts", 3),
|
|
1227
|
+
recover_on_worker_loss=item.get("recover_on_worker_loss", True),
|
|
1228
|
+
parent_run_id=item.get("parent_run_id"),
|
|
1229
|
+
nesting_depth=item.get("nesting_depth", 0),
|
|
1230
|
+
continued_from_run_id=item.get("continued_from_run_id"),
|
|
1231
|
+
continued_to_run_id=item.get("continued_to_run_id"),
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
def _item_to_event(self, item: dict[str, Any]) -> Event:
|
|
1235
|
+
"""Convert DynamoDB item to Event object."""
|
|
1236
|
+
return Event(
|
|
1237
|
+
event_id=item["event_id"],
|
|
1238
|
+
run_id=item["run_id"],
|
|
1239
|
+
sequence=item.get("sequence", 0),
|
|
1240
|
+
type=EventType(item["type"]),
|
|
1241
|
+
timestamp=datetime.fromisoformat(item["timestamp"]),
|
|
1242
|
+
data=json.loads(item.get("data", "{}")),
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
def _item_to_step_execution(self, item: dict[str, Any]) -> StepExecution:
|
|
1246
|
+
"""Convert DynamoDB item to StepExecution object."""
|
|
1247
|
+
retry_count = item.get("retry_count", 0)
|
|
1248
|
+
return StepExecution(
|
|
1249
|
+
step_id=item["step_id"],
|
|
1250
|
+
run_id=item["run_id"],
|
|
1251
|
+
step_name=item["step_name"],
|
|
1252
|
+
status=StepStatus(item["status"]),
|
|
1253
|
+
created_at=datetime.fromisoformat(item["created_at"]),
|
|
1254
|
+
started_at=datetime.fromisoformat(item["started_at"])
|
|
1255
|
+
if item.get("started_at")
|
|
1256
|
+
else None,
|
|
1257
|
+
completed_at=datetime.fromisoformat(item["completed_at"])
|
|
1258
|
+
if item.get("completed_at")
|
|
1259
|
+
else None,
|
|
1260
|
+
input_args=item.get("input_args", "[]"),
|
|
1261
|
+
input_kwargs=item.get("input_kwargs", "{}"),
|
|
1262
|
+
result=item.get("result"),
|
|
1263
|
+
error=item.get("error"),
|
|
1264
|
+
attempt=retry_count + 1,
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
def _item_to_hook(self, item: dict[str, Any]) -> Hook:
|
|
1268
|
+
"""Convert DynamoDB item to Hook object."""
|
|
1269
|
+
return Hook(
|
|
1270
|
+
hook_id=item["hook_id"],
|
|
1271
|
+
run_id=item["run_id"],
|
|
1272
|
+
token=item["token"],
|
|
1273
|
+
created_at=datetime.fromisoformat(item["created_at"]),
|
|
1274
|
+
received_at=datetime.fromisoformat(item["received_at"])
|
|
1275
|
+
if item.get("received_at")
|
|
1276
|
+
else None,
|
|
1277
|
+
expires_at=datetime.fromisoformat(item["expires_at"])
|
|
1278
|
+
if item.get("expires_at")
|
|
1279
|
+
else None,
|
|
1280
|
+
status=HookStatus(item["status"]),
|
|
1281
|
+
payload=item.get("payload"),
|
|
1282
|
+
metadata=json.loads(item.get("metadata", "{}")),
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
def _item_to_schedule(self, item: dict[str, Any]) -> Schedule:
|
|
1286
|
+
"""Convert DynamoDB item to Schedule object."""
|
|
1287
|
+
spec_value = item.get("spec", "")
|
|
1288
|
+
spec_type = item.get("spec_type", "interval")
|
|
1289
|
+
timezone = item.get("timezone", "UTC")
|
|
1290
|
+
|
|
1291
|
+
if spec_type == "cron":
|
|
1292
|
+
spec = ScheduleSpec(cron=spec_value, timezone=timezone)
|
|
1293
|
+
else:
|
|
1294
|
+
spec = ScheduleSpec(interval=spec_value, timezone=timezone)
|
|
1295
|
+
|
|
1296
|
+
return Schedule(
|
|
1297
|
+
schedule_id=item["schedule_id"],
|
|
1298
|
+
workflow_name=item["workflow_name"],
|
|
1299
|
+
spec=spec,
|
|
1300
|
+
status=ScheduleStatus(item["status"]),
|
|
1301
|
+
args=item.get("input_args", "[]"),
|
|
1302
|
+
kwargs=item.get("input_kwargs", "{}"),
|
|
1303
|
+
overlap_policy=OverlapPolicy(item.get("overlap_policy", "skip")),
|
|
1304
|
+
created_at=datetime.fromisoformat(item["created_at"]),
|
|
1305
|
+
updated_at=datetime.fromisoformat(item["updated_at"])
|
|
1306
|
+
if item.get("updated_at")
|
|
1307
|
+
else None,
|
|
1308
|
+
last_run_at=datetime.fromisoformat(item["last_run_at"])
|
|
1309
|
+
if item.get("last_run_at")
|
|
1310
|
+
else None,
|
|
1311
|
+
next_run_time=datetime.fromisoformat(item["next_run_time"])
|
|
1312
|
+
if item.get("next_run_time")
|
|
1313
|
+
else None,
|
|
1314
|
+
running_run_ids=json.loads(item.get("running_run_ids", "[]")),
|
|
1315
|
+
)
|