pyworkflow-engine 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,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
+ )