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,549 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory storage backend for testing and transient workflows.
|
|
3
|
+
|
|
4
|
+
This backend stores all data in memory and is ideal for:
|
|
5
|
+
- Unit testing
|
|
6
|
+
- Transient workflows that don't need persistence
|
|
7
|
+
- Development and prototyping
|
|
8
|
+
- Ephemeral containers
|
|
9
|
+
|
|
10
|
+
Note: All data is lost when the process exits.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
from pyworkflow.engine.events import Event
|
|
17
|
+
from pyworkflow.storage.base import StorageBackend
|
|
18
|
+
from pyworkflow.storage.schemas import (
|
|
19
|
+
Hook,
|
|
20
|
+
HookStatus,
|
|
21
|
+
RunStatus,
|
|
22
|
+
Schedule,
|
|
23
|
+
ScheduleStatus,
|
|
24
|
+
StepExecution,
|
|
25
|
+
WorkflowRun,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InMemoryStorageBackend(StorageBackend):
|
|
30
|
+
"""
|
|
31
|
+
Thread-safe in-memory storage backend.
|
|
32
|
+
|
|
33
|
+
All data is stored in dictionaries and protected by a reentrant lock
|
|
34
|
+
for thread safety.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> storage = InMemoryStorageBackend()
|
|
38
|
+
>>> pyworkflow.configure(storage=storage)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
"""Initialize empty storage."""
|
|
43
|
+
self._runs: dict[str, WorkflowRun] = {}
|
|
44
|
+
self._events: dict[str, list[Event]] = {}
|
|
45
|
+
self._steps: dict[str, StepExecution] = {}
|
|
46
|
+
self._hooks: dict[str, Hook] = {}
|
|
47
|
+
self._schedules: dict[str, Schedule] = {}
|
|
48
|
+
self._idempotency_index: dict[str, str] = {} # key -> run_id
|
|
49
|
+
self._token_index: dict[str, str] = {} # token -> hook_id
|
|
50
|
+
self._cancellation_flags: dict[str, bool] = {} # run_id -> cancelled
|
|
51
|
+
self._lock = threading.RLock()
|
|
52
|
+
self._event_sequences: dict[str, int] = {} # run_id -> next sequence
|
|
53
|
+
|
|
54
|
+
# Workflow Run Operations
|
|
55
|
+
|
|
56
|
+
async def create_run(self, run: WorkflowRun) -> None:
|
|
57
|
+
"""Create a new workflow run record."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
if run.run_id in self._runs:
|
|
60
|
+
raise ValueError(f"Run {run.run_id} already exists")
|
|
61
|
+
self._runs[run.run_id] = run
|
|
62
|
+
self._events[run.run_id] = []
|
|
63
|
+
self._event_sequences[run.run_id] = 0
|
|
64
|
+
if run.idempotency_key:
|
|
65
|
+
self._idempotency_index[run.idempotency_key] = run.run_id
|
|
66
|
+
|
|
67
|
+
async def get_run(self, run_id: str) -> WorkflowRun | None:
|
|
68
|
+
"""Retrieve a workflow run by ID."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
return self._runs.get(run_id)
|
|
71
|
+
|
|
72
|
+
async def get_run_by_idempotency_key(self, key: str) -> WorkflowRun | None:
|
|
73
|
+
"""Retrieve a workflow run by idempotency key."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
run_id = self._idempotency_index.get(key)
|
|
76
|
+
if run_id:
|
|
77
|
+
return self._runs.get(run_id)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
async def update_run_status(
|
|
81
|
+
self,
|
|
82
|
+
run_id: str,
|
|
83
|
+
status: RunStatus,
|
|
84
|
+
result: str | None = None,
|
|
85
|
+
error: str | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Update workflow run status and optionally result/error."""
|
|
88
|
+
with self._lock:
|
|
89
|
+
run = self._runs.get(run_id)
|
|
90
|
+
if run:
|
|
91
|
+
run.status = status
|
|
92
|
+
run.updated_at = datetime.now(UTC)
|
|
93
|
+
if result is not None:
|
|
94
|
+
run.result = result
|
|
95
|
+
if error is not None:
|
|
96
|
+
run.error = error
|
|
97
|
+
if status == RunStatus.COMPLETED or status == RunStatus.FAILED:
|
|
98
|
+
run.completed_at = datetime.now(UTC)
|
|
99
|
+
|
|
100
|
+
async def update_run_recovery_attempts(
|
|
101
|
+
self,
|
|
102
|
+
run_id: str,
|
|
103
|
+
recovery_attempts: int,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Update the recovery attempts counter for a workflow run."""
|
|
106
|
+
with self._lock:
|
|
107
|
+
run = self._runs.get(run_id)
|
|
108
|
+
if run:
|
|
109
|
+
run.recovery_attempts = recovery_attempts
|
|
110
|
+
run.updated_at = datetime.now(UTC)
|
|
111
|
+
|
|
112
|
+
async def list_runs(
|
|
113
|
+
self,
|
|
114
|
+
query: str | None = None,
|
|
115
|
+
status: RunStatus | None = None,
|
|
116
|
+
start_time: datetime | None = None,
|
|
117
|
+
end_time: datetime | None = None,
|
|
118
|
+
limit: int = 100,
|
|
119
|
+
cursor: str | None = None,
|
|
120
|
+
) -> tuple[list[WorkflowRun], str | None]:
|
|
121
|
+
"""List workflow runs with optional filtering and cursor-based pagination."""
|
|
122
|
+
import json
|
|
123
|
+
|
|
124
|
+
with self._lock:
|
|
125
|
+
runs = list(self._runs.values())
|
|
126
|
+
|
|
127
|
+
# Filter by query (case-insensitive substring in workflow_name or input_kwargs)
|
|
128
|
+
if query:
|
|
129
|
+
query_lower = query.lower()
|
|
130
|
+
filtered_runs = []
|
|
131
|
+
for r in runs:
|
|
132
|
+
workflow_name_match = query_lower in r.workflow_name.lower()
|
|
133
|
+
input_kwargs_str = json.dumps(r.input_kwargs or {}).lower()
|
|
134
|
+
input_kwargs_match = query_lower in input_kwargs_str
|
|
135
|
+
if workflow_name_match or input_kwargs_match:
|
|
136
|
+
filtered_runs.append(r)
|
|
137
|
+
runs = filtered_runs
|
|
138
|
+
|
|
139
|
+
# Filter by status
|
|
140
|
+
if status:
|
|
141
|
+
runs = [r for r in runs if r.status == status]
|
|
142
|
+
|
|
143
|
+
# Filter by time range (based on started_at)
|
|
144
|
+
if start_time or end_time:
|
|
145
|
+
filtered_runs = []
|
|
146
|
+
for r in runs:
|
|
147
|
+
if r.started_at is None:
|
|
148
|
+
continue # Skip runs that haven't started
|
|
149
|
+
if start_time and r.started_at < start_time:
|
|
150
|
+
continue
|
|
151
|
+
if end_time and r.started_at >= end_time:
|
|
152
|
+
continue
|
|
153
|
+
filtered_runs.append(r)
|
|
154
|
+
runs = filtered_runs
|
|
155
|
+
|
|
156
|
+
# Sort by (created_at DESC, run_id DESC) for deterministic ordering
|
|
157
|
+
runs.sort(key=lambda r: (r.created_at, r.run_id), reverse=True)
|
|
158
|
+
|
|
159
|
+
# Apply cursor-based pagination
|
|
160
|
+
if cursor:
|
|
161
|
+
cursor_found = False
|
|
162
|
+
filtered_runs = []
|
|
163
|
+
for run in runs:
|
|
164
|
+
if cursor_found:
|
|
165
|
+
filtered_runs.append(run)
|
|
166
|
+
elif run.run_id == cursor:
|
|
167
|
+
cursor_found = True
|
|
168
|
+
runs = filtered_runs
|
|
169
|
+
|
|
170
|
+
# Apply limit and determine next_cursor
|
|
171
|
+
if len(runs) > limit:
|
|
172
|
+
result_runs = runs[:limit]
|
|
173
|
+
next_cursor = result_runs[-1].run_id if result_runs else None
|
|
174
|
+
else:
|
|
175
|
+
result_runs = runs[:limit]
|
|
176
|
+
next_cursor = None
|
|
177
|
+
|
|
178
|
+
return result_runs, next_cursor
|
|
179
|
+
|
|
180
|
+
# Event Log Operations
|
|
181
|
+
|
|
182
|
+
async def record_event(self, event: Event) -> None:
|
|
183
|
+
"""Record an event to the append-only event log."""
|
|
184
|
+
with self._lock:
|
|
185
|
+
run_id = event.run_id
|
|
186
|
+
if run_id not in self._events:
|
|
187
|
+
self._events[run_id] = []
|
|
188
|
+
self._event_sequences[run_id] = 0
|
|
189
|
+
|
|
190
|
+
# Assign sequence number
|
|
191
|
+
event.sequence = self._event_sequences[run_id]
|
|
192
|
+
self._event_sequences[run_id] += 1
|
|
193
|
+
|
|
194
|
+
self._events[run_id].append(event)
|
|
195
|
+
|
|
196
|
+
async def get_events(
|
|
197
|
+
self,
|
|
198
|
+
run_id: str,
|
|
199
|
+
event_types: list[str] | None = None,
|
|
200
|
+
) -> list[Event]:
|
|
201
|
+
"""Retrieve all events for a workflow run, ordered by sequence."""
|
|
202
|
+
with self._lock:
|
|
203
|
+
events = list(self._events.get(run_id, []))
|
|
204
|
+
|
|
205
|
+
# Filter by event types
|
|
206
|
+
if event_types:
|
|
207
|
+
events = [e for e in events if e.type in event_types]
|
|
208
|
+
|
|
209
|
+
# Sort by sequence
|
|
210
|
+
events.sort(key=lambda e: e.sequence or 0)
|
|
211
|
+
|
|
212
|
+
return events
|
|
213
|
+
|
|
214
|
+
async def get_latest_event(
|
|
215
|
+
self,
|
|
216
|
+
run_id: str,
|
|
217
|
+
event_type: str | None = None,
|
|
218
|
+
) -> Event | None:
|
|
219
|
+
"""Get the latest event for a run, optionally filtered by type."""
|
|
220
|
+
with self._lock:
|
|
221
|
+
events = self._events.get(run_id, [])
|
|
222
|
+
if not events:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
# Filter by event type
|
|
226
|
+
if event_type:
|
|
227
|
+
events = [e for e in events if e.type.value == event_type]
|
|
228
|
+
|
|
229
|
+
if not events:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
# Return event with highest sequence
|
|
233
|
+
return max(events, key=lambda e: e.sequence or 0)
|
|
234
|
+
|
|
235
|
+
# Step Operations
|
|
236
|
+
|
|
237
|
+
async def create_step(self, step: StepExecution) -> None:
|
|
238
|
+
"""Create a step execution record."""
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._steps[step.step_id] = step
|
|
241
|
+
|
|
242
|
+
async def get_step(self, step_id: str) -> StepExecution | None:
|
|
243
|
+
"""Retrieve a step execution by ID."""
|
|
244
|
+
with self._lock:
|
|
245
|
+
return self._steps.get(step_id)
|
|
246
|
+
|
|
247
|
+
async def update_step_status(
|
|
248
|
+
self,
|
|
249
|
+
step_id: str,
|
|
250
|
+
status: str,
|
|
251
|
+
result: str | None = None,
|
|
252
|
+
error: str | None = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Update step execution status."""
|
|
255
|
+
with self._lock:
|
|
256
|
+
step = self._steps.get(step_id)
|
|
257
|
+
if step:
|
|
258
|
+
from pyworkflow.storage.schemas import StepStatus
|
|
259
|
+
|
|
260
|
+
step.status = StepStatus(status)
|
|
261
|
+
step.updated_at = datetime.now(UTC)
|
|
262
|
+
if result is not None:
|
|
263
|
+
step.result = result
|
|
264
|
+
if error is not None:
|
|
265
|
+
step.error = error
|
|
266
|
+
|
|
267
|
+
async def list_steps(self, run_id: str) -> list[StepExecution]:
|
|
268
|
+
"""List all steps for a workflow run."""
|
|
269
|
+
with self._lock:
|
|
270
|
+
return [s for s in self._steps.values() if s.run_id == run_id]
|
|
271
|
+
|
|
272
|
+
# Hook Operations
|
|
273
|
+
|
|
274
|
+
async def create_hook(self, hook: Hook) -> None:
|
|
275
|
+
"""Create a hook record."""
|
|
276
|
+
with self._lock:
|
|
277
|
+
self._hooks[hook.hook_id] = hook
|
|
278
|
+
self._token_index[hook.token] = hook.hook_id
|
|
279
|
+
|
|
280
|
+
async def get_hook(self, hook_id: str) -> Hook | None:
|
|
281
|
+
"""Retrieve a hook by ID."""
|
|
282
|
+
with self._lock:
|
|
283
|
+
return self._hooks.get(hook_id)
|
|
284
|
+
|
|
285
|
+
async def get_hook_by_token(self, token: str) -> Hook | None:
|
|
286
|
+
"""Retrieve a hook by its token."""
|
|
287
|
+
with self._lock:
|
|
288
|
+
hook_id = self._token_index.get(token)
|
|
289
|
+
if hook_id:
|
|
290
|
+
return self._hooks.get(hook_id)
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
async def update_hook_status(
|
|
294
|
+
self,
|
|
295
|
+
hook_id: str,
|
|
296
|
+
status: HookStatus,
|
|
297
|
+
payload: str | None = None,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Update hook status and optionally payload."""
|
|
300
|
+
with self._lock:
|
|
301
|
+
hook = self._hooks.get(hook_id)
|
|
302
|
+
if hook:
|
|
303
|
+
hook.status = status
|
|
304
|
+
if payload is not None:
|
|
305
|
+
hook.payload = payload
|
|
306
|
+
if status == HookStatus.RECEIVED:
|
|
307
|
+
hook.received_at = datetime.now(UTC)
|
|
308
|
+
|
|
309
|
+
async def list_hooks(
|
|
310
|
+
self,
|
|
311
|
+
run_id: str | None = None,
|
|
312
|
+
status: HookStatus | None = None,
|
|
313
|
+
limit: int = 100,
|
|
314
|
+
offset: int = 0,
|
|
315
|
+
) -> list[Hook]:
|
|
316
|
+
"""List hooks with optional filtering."""
|
|
317
|
+
with self._lock:
|
|
318
|
+
hooks = list(self._hooks.values())
|
|
319
|
+
|
|
320
|
+
# Filter by run_id
|
|
321
|
+
if run_id:
|
|
322
|
+
hooks = [h for h in hooks if h.run_id == run_id]
|
|
323
|
+
|
|
324
|
+
# Filter by status
|
|
325
|
+
if status:
|
|
326
|
+
hooks = [h for h in hooks if h.status == status]
|
|
327
|
+
|
|
328
|
+
# Sort by created_at descending
|
|
329
|
+
hooks.sort(key=lambda h: h.created_at, reverse=True)
|
|
330
|
+
|
|
331
|
+
# Apply pagination
|
|
332
|
+
return hooks[offset : offset + limit]
|
|
333
|
+
|
|
334
|
+
# Cancellation Flag Operations
|
|
335
|
+
|
|
336
|
+
async def set_cancellation_flag(self, run_id: str) -> None:
|
|
337
|
+
"""Set a cancellation flag for a workflow run."""
|
|
338
|
+
with self._lock:
|
|
339
|
+
self._cancellation_flags[run_id] = True
|
|
340
|
+
|
|
341
|
+
async def check_cancellation_flag(self, run_id: str) -> bool:
|
|
342
|
+
"""Check if a cancellation flag is set for a workflow run."""
|
|
343
|
+
with self._lock:
|
|
344
|
+
return self._cancellation_flags.get(run_id, False)
|
|
345
|
+
|
|
346
|
+
async def clear_cancellation_flag(self, run_id: str) -> None:
|
|
347
|
+
"""Clear the cancellation flag for a workflow run."""
|
|
348
|
+
with self._lock:
|
|
349
|
+
self._cancellation_flags.pop(run_id, None)
|
|
350
|
+
|
|
351
|
+
# Continue-As-New Chain Operations
|
|
352
|
+
|
|
353
|
+
async def update_run_continuation(
|
|
354
|
+
self,
|
|
355
|
+
run_id: str,
|
|
356
|
+
continued_to_run_id: str,
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Update the continuation link for a workflow run."""
|
|
359
|
+
with self._lock:
|
|
360
|
+
run = self._runs.get(run_id)
|
|
361
|
+
if run:
|
|
362
|
+
run.continued_to_run_id = continued_to_run_id
|
|
363
|
+
run.updated_at = datetime.now(UTC)
|
|
364
|
+
|
|
365
|
+
async def get_workflow_chain(
|
|
366
|
+
self,
|
|
367
|
+
run_id: str,
|
|
368
|
+
) -> list[WorkflowRun]:
|
|
369
|
+
"""Get all runs in a continue-as-new chain."""
|
|
370
|
+
with self._lock:
|
|
371
|
+
run = self._runs.get(run_id)
|
|
372
|
+
if not run:
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
# Walk backwards to find the start of the chain
|
|
376
|
+
current = run
|
|
377
|
+
while current.continued_from_run_id:
|
|
378
|
+
prev = self._runs.get(current.continued_from_run_id)
|
|
379
|
+
if not prev:
|
|
380
|
+
break
|
|
381
|
+
current = prev
|
|
382
|
+
|
|
383
|
+
# Build chain from start to end
|
|
384
|
+
chain = [current]
|
|
385
|
+
while current.continued_to_run_id:
|
|
386
|
+
next_run = self._runs.get(current.continued_to_run_id)
|
|
387
|
+
if not next_run:
|
|
388
|
+
break
|
|
389
|
+
chain.append(next_run)
|
|
390
|
+
current = next_run
|
|
391
|
+
|
|
392
|
+
return chain
|
|
393
|
+
|
|
394
|
+
# Child Workflow Operations
|
|
395
|
+
|
|
396
|
+
async def get_children(
|
|
397
|
+
self,
|
|
398
|
+
parent_run_id: str,
|
|
399
|
+
status: RunStatus | None = None,
|
|
400
|
+
) -> list[WorkflowRun]:
|
|
401
|
+
"""Get all child workflow runs for a parent workflow."""
|
|
402
|
+
with self._lock:
|
|
403
|
+
children = [run for run in self._runs.values() if run.parent_run_id == parent_run_id]
|
|
404
|
+
|
|
405
|
+
if status:
|
|
406
|
+
children = [c for c in children if c.status == status]
|
|
407
|
+
|
|
408
|
+
# Sort by created_at
|
|
409
|
+
children.sort(key=lambda r: r.created_at)
|
|
410
|
+
|
|
411
|
+
return children
|
|
412
|
+
|
|
413
|
+
async def get_parent(self, run_id: str) -> WorkflowRun | None:
|
|
414
|
+
"""Get the parent workflow run for a child workflow."""
|
|
415
|
+
with self._lock:
|
|
416
|
+
run = self._runs.get(run_id)
|
|
417
|
+
if run and run.parent_run_id:
|
|
418
|
+
return self._runs.get(run.parent_run_id)
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
async def get_nesting_depth(self, run_id: str) -> int:
|
|
422
|
+
"""Get the nesting depth for a workflow."""
|
|
423
|
+
with self._lock:
|
|
424
|
+
run = self._runs.get(run_id)
|
|
425
|
+
return run.nesting_depth if run else 0
|
|
426
|
+
|
|
427
|
+
# Schedule Operations
|
|
428
|
+
|
|
429
|
+
async def create_schedule(self, schedule: Schedule) -> None:
|
|
430
|
+
"""Create a new schedule record."""
|
|
431
|
+
with self._lock:
|
|
432
|
+
if schedule.schedule_id in self._schedules:
|
|
433
|
+
raise ValueError(f"Schedule {schedule.schedule_id} already exists")
|
|
434
|
+
self._schedules[schedule.schedule_id] = schedule
|
|
435
|
+
|
|
436
|
+
async def get_schedule(self, schedule_id: str) -> Schedule | None:
|
|
437
|
+
"""Retrieve a schedule by ID."""
|
|
438
|
+
with self._lock:
|
|
439
|
+
return self._schedules.get(schedule_id)
|
|
440
|
+
|
|
441
|
+
async def update_schedule(self, schedule: Schedule) -> None:
|
|
442
|
+
"""Update an existing schedule."""
|
|
443
|
+
with self._lock:
|
|
444
|
+
if schedule.schedule_id not in self._schedules:
|
|
445
|
+
raise ValueError(f"Schedule {schedule.schedule_id} does not exist")
|
|
446
|
+
self._schedules[schedule.schedule_id] = schedule
|
|
447
|
+
|
|
448
|
+
async def delete_schedule(self, schedule_id: str) -> None:
|
|
449
|
+
"""Mark a schedule as deleted (soft delete)."""
|
|
450
|
+
with self._lock:
|
|
451
|
+
if schedule_id not in self._schedules:
|
|
452
|
+
raise ValueError(f"Schedule {schedule_id} does not exist")
|
|
453
|
+
schedule = self._schedules[schedule_id]
|
|
454
|
+
schedule.status = ScheduleStatus.DELETED
|
|
455
|
+
schedule.updated_at = datetime.now(UTC)
|
|
456
|
+
|
|
457
|
+
async def list_schedules(
|
|
458
|
+
self,
|
|
459
|
+
workflow_name: str | None = None,
|
|
460
|
+
status: ScheduleStatus | None = None,
|
|
461
|
+
limit: int = 100,
|
|
462
|
+
offset: int = 0,
|
|
463
|
+
) -> list[Schedule]:
|
|
464
|
+
"""List schedules with optional filtering."""
|
|
465
|
+
with self._lock:
|
|
466
|
+
schedules = list(self._schedules.values())
|
|
467
|
+
|
|
468
|
+
# Apply filters
|
|
469
|
+
if workflow_name:
|
|
470
|
+
schedules = [s for s in schedules if s.workflow_name == workflow_name]
|
|
471
|
+
if status:
|
|
472
|
+
schedules = [s for s in schedules if s.status == status]
|
|
473
|
+
|
|
474
|
+
# Sort by created_at descending
|
|
475
|
+
schedules.sort(key=lambda s: s.created_at, reverse=True)
|
|
476
|
+
|
|
477
|
+
# Apply pagination
|
|
478
|
+
return schedules[offset : offset + limit]
|
|
479
|
+
|
|
480
|
+
async def get_due_schedules(self, now: datetime) -> list[Schedule]:
|
|
481
|
+
"""Get all schedules that are due to run."""
|
|
482
|
+
with self._lock:
|
|
483
|
+
due_schedules = [
|
|
484
|
+
s
|
|
485
|
+
for s in self._schedules.values()
|
|
486
|
+
if s.status == ScheduleStatus.ACTIVE
|
|
487
|
+
and s.next_run_time is not None
|
|
488
|
+
and s.next_run_time <= now
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
# Sort by next_run_time ascending
|
|
492
|
+
due_schedules.sort(key=lambda s: s.next_run_time) # type: ignore
|
|
493
|
+
return due_schedules
|
|
494
|
+
|
|
495
|
+
async def add_running_run(self, schedule_id: str, run_id: str) -> None:
|
|
496
|
+
"""Add a run_id to the schedule's running_run_ids list."""
|
|
497
|
+
with self._lock:
|
|
498
|
+
if schedule_id not in self._schedules:
|
|
499
|
+
raise ValueError(f"Schedule {schedule_id} does not exist")
|
|
500
|
+
schedule = self._schedules[schedule_id]
|
|
501
|
+
if run_id not in schedule.running_run_ids:
|
|
502
|
+
schedule.running_run_ids.append(run_id)
|
|
503
|
+
schedule.updated_at = datetime.now(UTC)
|
|
504
|
+
|
|
505
|
+
async def remove_running_run(self, schedule_id: str, run_id: str) -> None:
|
|
506
|
+
"""Remove a run_id from the schedule's running_run_ids list."""
|
|
507
|
+
with self._lock:
|
|
508
|
+
if schedule_id not in self._schedules:
|
|
509
|
+
raise ValueError(f"Schedule {schedule_id} does not exist")
|
|
510
|
+
schedule = self._schedules[schedule_id]
|
|
511
|
+
if run_id in schedule.running_run_ids:
|
|
512
|
+
schedule.running_run_ids.remove(run_id)
|
|
513
|
+
schedule.updated_at = datetime.now(UTC)
|
|
514
|
+
|
|
515
|
+
# Utility methods
|
|
516
|
+
|
|
517
|
+
def clear(self) -> None:
|
|
518
|
+
"""
|
|
519
|
+
Clear all data from storage.
|
|
520
|
+
|
|
521
|
+
Useful for testing to reset state between tests.
|
|
522
|
+
"""
|
|
523
|
+
with self._lock:
|
|
524
|
+
self._runs.clear()
|
|
525
|
+
self._events.clear()
|
|
526
|
+
self._steps.clear()
|
|
527
|
+
self._hooks.clear()
|
|
528
|
+
self._schedules.clear()
|
|
529
|
+
self._idempotency_index.clear()
|
|
530
|
+
self._token_index.clear()
|
|
531
|
+
self._cancellation_flags.clear()
|
|
532
|
+
self._event_sequences.clear()
|
|
533
|
+
|
|
534
|
+
def __len__(self) -> int:
|
|
535
|
+
"""Return total number of workflow runs."""
|
|
536
|
+
with self._lock:
|
|
537
|
+
return len(self._runs)
|
|
538
|
+
|
|
539
|
+
def __repr__(self) -> str:
|
|
540
|
+
"""Return string representation."""
|
|
541
|
+
with self._lock:
|
|
542
|
+
return (
|
|
543
|
+
f"InMemoryStorageBackend("
|
|
544
|
+
f"runs={len(self._runs)}, "
|
|
545
|
+
f"events={sum(len(e) for e in self._events.values())}, "
|
|
546
|
+
f"steps={len(self._steps)}, "
|
|
547
|
+
f"hooks={len(self._hooks)}, "
|
|
548
|
+
f"schedules={len(self._schedules)})"
|
|
549
|
+
)
|