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,930 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LocalContext - In-process workflow execution with optional event sourcing.
|
|
3
|
+
|
|
4
|
+
This context runs workflows locally with support for:
|
|
5
|
+
- Durable mode: Event sourcing, checkpointing, suspend/resume
|
|
6
|
+
- Transient mode: Simple execution without persistence
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from loguru import logger
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from pyworkflow.context.base import StepFunction, WorkflowContext
|
|
22
|
+
from pyworkflow.core.exceptions import SuspensionSignal
|
|
23
|
+
from pyworkflow.utils.duration import parse_duration
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocalContext(WorkflowContext):
|
|
27
|
+
"""
|
|
28
|
+
Local execution context with optional event sourcing.
|
|
29
|
+
|
|
30
|
+
In durable mode:
|
|
31
|
+
- Steps are checkpointed to storage
|
|
32
|
+
- Sleeps suspend the workflow (can be resumed later)
|
|
33
|
+
- Hooks wait for external events
|
|
34
|
+
|
|
35
|
+
In transient mode:
|
|
36
|
+
- Steps execute directly
|
|
37
|
+
- Sleeps use asyncio.sleep
|
|
38
|
+
- No persistence
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
# Create durable context
|
|
42
|
+
ctx = LocalContext(
|
|
43
|
+
run_id="run_123",
|
|
44
|
+
workflow_name="order_workflow",
|
|
45
|
+
storage=FileStorageBackend("./data"),
|
|
46
|
+
durable=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Or transient context
|
|
50
|
+
ctx = LocalContext(
|
|
51
|
+
run_id="run_456",
|
|
52
|
+
workflow_name="quick_task",
|
|
53
|
+
durable=False,
|
|
54
|
+
)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
run_id: str = "local_run",
|
|
60
|
+
workflow_name: str = "local_workflow",
|
|
61
|
+
storage: Any | None = None,
|
|
62
|
+
durable: bool = True,
|
|
63
|
+
event_log: list[Any] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Initialize local context.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
run_id: Unique identifier for this workflow run
|
|
70
|
+
workflow_name: Name of the workflow
|
|
71
|
+
storage: Storage backend for event sourcing (required for durable)
|
|
72
|
+
durable: Whether to use durable execution mode
|
|
73
|
+
event_log: Existing events for replay (when resuming)
|
|
74
|
+
"""
|
|
75
|
+
super().__init__(run_id=run_id, workflow_name=workflow_name)
|
|
76
|
+
self._storage = storage
|
|
77
|
+
self._durable = durable and storage is not None
|
|
78
|
+
self._event_log = event_log or []
|
|
79
|
+
|
|
80
|
+
# Execution state
|
|
81
|
+
self._step_results: dict[str, Any] = {}
|
|
82
|
+
self._completed_sleeps: set[str] = set()
|
|
83
|
+
self._pending_sleeps: dict[str, Any] = {}
|
|
84
|
+
self._hook_results: dict[str, Any] = {}
|
|
85
|
+
self._pending_hooks: dict[str, Any] = {}
|
|
86
|
+
self._step_counter = 0
|
|
87
|
+
self._retry_states: dict[str, dict[str, Any]] = {}
|
|
88
|
+
self._is_replaying = False
|
|
89
|
+
self._last_warning_count: int = 0 # Track last event count for warning interval
|
|
90
|
+
|
|
91
|
+
# Cancellation state
|
|
92
|
+
self._cancellation_requested: bool = False
|
|
93
|
+
self._cancellation_blocked: bool = False
|
|
94
|
+
self._cancellation_reason: str | None = None
|
|
95
|
+
|
|
96
|
+
# Child workflow state
|
|
97
|
+
self._child_results: dict[str, dict[str, Any]] = {}
|
|
98
|
+
self._pending_children: dict[str, str] = {} # child_id -> child_run_id
|
|
99
|
+
|
|
100
|
+
# Replay state if resuming
|
|
101
|
+
if event_log:
|
|
102
|
+
self._is_replaying = True
|
|
103
|
+
self._replay_events(event_log)
|
|
104
|
+
self._is_replaying = False
|
|
105
|
+
|
|
106
|
+
def _replay_events(self, events: list[Any]) -> None:
|
|
107
|
+
"""Replay events to restore state."""
|
|
108
|
+
from pyworkflow.engine.events import EventType
|
|
109
|
+
from pyworkflow.serialization.decoder import deserialize
|
|
110
|
+
|
|
111
|
+
for event in events:
|
|
112
|
+
if event.type == EventType.STEP_COMPLETED:
|
|
113
|
+
step_id = event.data.get("step_id")
|
|
114
|
+
result = deserialize(event.data.get("result"))
|
|
115
|
+
self._step_results[step_id] = result
|
|
116
|
+
|
|
117
|
+
elif event.type == EventType.SLEEP_COMPLETED:
|
|
118
|
+
sleep_id = event.data.get("sleep_id")
|
|
119
|
+
self._completed_sleeps.add(sleep_id)
|
|
120
|
+
|
|
121
|
+
elif event.type == EventType.HOOK_RECEIVED:
|
|
122
|
+
hook_id = event.data.get("hook_id")
|
|
123
|
+
payload = deserialize(event.data.get("payload"))
|
|
124
|
+
self._hook_results[hook_id] = payload
|
|
125
|
+
|
|
126
|
+
elif event.type == EventType.STEP_RETRYING:
|
|
127
|
+
step_id = event.data.get("step_id")
|
|
128
|
+
self._retry_states[step_id] = {
|
|
129
|
+
"step_id": step_id,
|
|
130
|
+
"current_attempt": event.data.get("attempt", 1),
|
|
131
|
+
"resume_at": event.data.get("resume_at"),
|
|
132
|
+
"max_retries": event.data.get("max_retries", 3),
|
|
133
|
+
"retry_delay": event.data.get("retry_strategy", "exponential"),
|
|
134
|
+
"last_error": event.data.get("error", ""),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
elif event.type == EventType.CANCELLATION_REQUESTED:
|
|
138
|
+
self._cancellation_requested = True
|
|
139
|
+
self._cancellation_reason = event.data.get("reason")
|
|
140
|
+
|
|
141
|
+
# Child workflow events
|
|
142
|
+
elif event.type == EventType.CHILD_WORKFLOW_STARTED:
|
|
143
|
+
child_id = event.data.get("child_id")
|
|
144
|
+
child_run_id = event.data.get("child_run_id")
|
|
145
|
+
if child_id and child_run_id:
|
|
146
|
+
self._pending_children[child_id] = child_run_id
|
|
147
|
+
|
|
148
|
+
elif event.type == EventType.CHILD_WORKFLOW_COMPLETED:
|
|
149
|
+
child_id = event.data.get("child_id")
|
|
150
|
+
child_run_id = event.data.get("child_run_id")
|
|
151
|
+
result = deserialize(event.data.get("result"))
|
|
152
|
+
if child_id:
|
|
153
|
+
self._child_results[child_id] = {
|
|
154
|
+
"child_run_id": child_run_id,
|
|
155
|
+
"result": result,
|
|
156
|
+
"__failed__": False,
|
|
157
|
+
}
|
|
158
|
+
self._pending_children.pop(child_id, None)
|
|
159
|
+
|
|
160
|
+
elif event.type == EventType.CHILD_WORKFLOW_FAILED:
|
|
161
|
+
child_id = event.data.get("child_id")
|
|
162
|
+
child_run_id = event.data.get("child_run_id")
|
|
163
|
+
error = event.data.get("error")
|
|
164
|
+
error_type = event.data.get("error_type")
|
|
165
|
+
if child_id:
|
|
166
|
+
self._child_results[child_id] = {
|
|
167
|
+
"child_run_id": child_run_id,
|
|
168
|
+
"error": error,
|
|
169
|
+
"error_type": error_type,
|
|
170
|
+
"__failed__": True,
|
|
171
|
+
}
|
|
172
|
+
self._pending_children.pop(child_id, None)
|
|
173
|
+
|
|
174
|
+
elif event.type == EventType.CHILD_WORKFLOW_CANCELLED:
|
|
175
|
+
child_id = event.data.get("child_id")
|
|
176
|
+
child_run_id = event.data.get("child_run_id")
|
|
177
|
+
reason = event.data.get("reason")
|
|
178
|
+
if child_id:
|
|
179
|
+
self._child_results[child_id] = {
|
|
180
|
+
"child_run_id": child_run_id,
|
|
181
|
+
"error": f"Cancelled: {reason}",
|
|
182
|
+
"error_type": "CancellationError",
|
|
183
|
+
"__failed__": True,
|
|
184
|
+
}
|
|
185
|
+
self._pending_children.pop(child_id, None)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def is_durable(self) -> bool:
|
|
189
|
+
return self._durable
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def storage(self) -> Any | None:
|
|
193
|
+
"""Get the storage backend."""
|
|
194
|
+
return self._storage
|
|
195
|
+
|
|
196
|
+
def _get_storage(self) -> Any:
|
|
197
|
+
"""Get storage backend, asserting it's not None (for durable mode)."""
|
|
198
|
+
assert self._storage is not None, "Storage not available in transient mode"
|
|
199
|
+
return self._storage
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def is_replaying(self) -> bool:
|
|
203
|
+
"""Check if currently replaying events."""
|
|
204
|
+
return self._is_replaying
|
|
205
|
+
|
|
206
|
+
@is_replaying.setter
|
|
207
|
+
def is_replaying(self, value: bool) -> None:
|
|
208
|
+
"""Set replay mode."""
|
|
209
|
+
self._is_replaying = value
|
|
210
|
+
|
|
211
|
+
# =========================================================================
|
|
212
|
+
# Step result caching (for @step decorator compatibility)
|
|
213
|
+
# =========================================================================
|
|
214
|
+
|
|
215
|
+
def should_execute_step(self, step_id: str) -> bool:
|
|
216
|
+
"""Check if a step should be executed (not already cached)."""
|
|
217
|
+
return step_id not in self._step_results
|
|
218
|
+
|
|
219
|
+
def get_step_result(self, step_id: str) -> Any:
|
|
220
|
+
"""Get cached step result."""
|
|
221
|
+
return self._step_results.get(step_id)
|
|
222
|
+
|
|
223
|
+
def cache_step_result(self, step_id: str, result: Any) -> None:
|
|
224
|
+
"""Cache a step result."""
|
|
225
|
+
self._step_results[step_id] = result
|
|
226
|
+
|
|
227
|
+
# =========================================================================
|
|
228
|
+
# Retry state management (for @step decorator compatibility)
|
|
229
|
+
# =========================================================================
|
|
230
|
+
|
|
231
|
+
def get_retry_state(self, step_id: str) -> dict[str, Any] | None:
|
|
232
|
+
"""Get retry state for a step."""
|
|
233
|
+
return self._retry_states.get(step_id)
|
|
234
|
+
|
|
235
|
+
def set_retry_state(
|
|
236
|
+
self,
|
|
237
|
+
step_id: str,
|
|
238
|
+
attempt: int,
|
|
239
|
+
resume_at: Any,
|
|
240
|
+
max_retries: int,
|
|
241
|
+
retry_delay: Any,
|
|
242
|
+
last_error: str,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Set retry state for a step."""
|
|
245
|
+
self._retry_states[step_id] = {
|
|
246
|
+
"step_id": step_id,
|
|
247
|
+
"current_attempt": attempt,
|
|
248
|
+
"resume_at": resume_at,
|
|
249
|
+
"max_retries": max_retries,
|
|
250
|
+
"retry_delay": retry_delay,
|
|
251
|
+
"last_error": last_error,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def clear_retry_state(self, step_id: str) -> None:
|
|
255
|
+
"""Clear retry state for a step."""
|
|
256
|
+
self._retry_states.pop(step_id, None)
|
|
257
|
+
|
|
258
|
+
# =========================================================================
|
|
259
|
+
# Sleep state management (for @step decorator and EventReplayer compatibility)
|
|
260
|
+
# =========================================================================
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def pending_sleeps(self) -> dict[str, Any]:
|
|
264
|
+
"""Get pending sleeps (sleep_id -> resume_at)."""
|
|
265
|
+
return self._pending_sleeps
|
|
266
|
+
|
|
267
|
+
def add_pending_sleep(self, sleep_id: str, resume_at: Any) -> None:
|
|
268
|
+
"""Add a pending sleep."""
|
|
269
|
+
self._pending_sleeps[sleep_id] = resume_at
|
|
270
|
+
|
|
271
|
+
def mark_sleep_completed(self, sleep_id: str) -> None:
|
|
272
|
+
"""Mark a sleep as completed."""
|
|
273
|
+
self._completed_sleeps.add(sleep_id)
|
|
274
|
+
|
|
275
|
+
def should_execute_sleep(self, sleep_id: str) -> bool:
|
|
276
|
+
"""Check if a sleep should be executed (not already completed)."""
|
|
277
|
+
return sleep_id not in self._completed_sleeps
|
|
278
|
+
|
|
279
|
+
def is_sleep_completed(self, sleep_id: str) -> bool:
|
|
280
|
+
"""Check if a sleep has been completed."""
|
|
281
|
+
return sleep_id in self._completed_sleeps
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def completed_sleeps(self) -> set[str]:
|
|
285
|
+
"""Get the set of completed sleep IDs."""
|
|
286
|
+
return self._completed_sleeps
|
|
287
|
+
|
|
288
|
+
# =========================================================================
|
|
289
|
+
# Hook state management (for EventReplayer compatibility)
|
|
290
|
+
# =========================================================================
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def pending_hooks(self) -> dict[str, Any]:
|
|
294
|
+
"""Get pending hooks."""
|
|
295
|
+
return self._pending_hooks
|
|
296
|
+
|
|
297
|
+
def add_pending_hook(self, hook_id: str, data: Any) -> None:
|
|
298
|
+
"""Add a pending hook."""
|
|
299
|
+
self._pending_hooks[hook_id] = data
|
|
300
|
+
|
|
301
|
+
def cache_hook_result(self, hook_id: str, payload: Any) -> None:
|
|
302
|
+
"""Cache a hook result."""
|
|
303
|
+
self._hook_results[hook_id] = payload
|
|
304
|
+
|
|
305
|
+
def has_hook_result(self, hook_id: str) -> bool:
|
|
306
|
+
"""Check if a hook result exists."""
|
|
307
|
+
return hook_id in self._hook_results
|
|
308
|
+
|
|
309
|
+
def get_hook_result(self, hook_id: str) -> Any:
|
|
310
|
+
"""Get a cached hook result."""
|
|
311
|
+
return self._hook_results.get(hook_id)
|
|
312
|
+
|
|
313
|
+
# =========================================================================
|
|
314
|
+
# Child workflow state management
|
|
315
|
+
# =========================================================================
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def pending_children(self) -> dict[str, str]:
|
|
319
|
+
"""Get pending child workflows (child_id -> child_run_id)."""
|
|
320
|
+
return self._pending_children
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def child_results(self) -> dict[str, dict[str, Any]]:
|
|
324
|
+
"""Get child workflow results."""
|
|
325
|
+
return self._child_results
|
|
326
|
+
|
|
327
|
+
def has_child_result(self, child_id: str) -> bool:
|
|
328
|
+
"""Check if a child workflow result exists."""
|
|
329
|
+
return child_id in self._child_results
|
|
330
|
+
|
|
331
|
+
def get_child_result(self, child_id: str) -> dict[str, Any]:
|
|
332
|
+
"""Get cached child workflow result."""
|
|
333
|
+
return self._child_results.get(child_id, {})
|
|
334
|
+
|
|
335
|
+
def cache_child_result(
|
|
336
|
+
self,
|
|
337
|
+
child_id: str,
|
|
338
|
+
child_run_id: str,
|
|
339
|
+
result: Any,
|
|
340
|
+
failed: bool = False,
|
|
341
|
+
error: str | None = None,
|
|
342
|
+
error_type: str | None = None,
|
|
343
|
+
) -> None:
|
|
344
|
+
"""
|
|
345
|
+
Cache a child workflow result.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
child_id: Deterministic child identifier
|
|
349
|
+
child_run_id: The child workflow's run ID
|
|
350
|
+
result: The result (if successful)
|
|
351
|
+
failed: Whether the child failed
|
|
352
|
+
error: Error message (if failed)
|
|
353
|
+
error_type: Exception type (if failed)
|
|
354
|
+
"""
|
|
355
|
+
if failed:
|
|
356
|
+
self._child_results[child_id] = {
|
|
357
|
+
"child_run_id": child_run_id,
|
|
358
|
+
"error": error,
|
|
359
|
+
"error_type": error_type,
|
|
360
|
+
"__failed__": True,
|
|
361
|
+
}
|
|
362
|
+
else:
|
|
363
|
+
self._child_results[child_id] = {
|
|
364
|
+
"child_run_id": child_run_id,
|
|
365
|
+
"result": result,
|
|
366
|
+
"__failed__": False,
|
|
367
|
+
}
|
|
368
|
+
self._pending_children.pop(child_id, None)
|
|
369
|
+
|
|
370
|
+
def add_pending_child(self, child_id: str, child_run_id: str) -> None:
|
|
371
|
+
"""Add a pending child workflow."""
|
|
372
|
+
self._pending_children[child_id] = child_run_id
|
|
373
|
+
|
|
374
|
+
# =========================================================================
|
|
375
|
+
# Event log access (for EventReplayer compatibility)
|
|
376
|
+
# =========================================================================
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def event_log(self) -> list[Any]:
|
|
380
|
+
"""Get the event log."""
|
|
381
|
+
return self._event_log
|
|
382
|
+
|
|
383
|
+
@event_log.setter
|
|
384
|
+
def event_log(self, events: list[Any]) -> None:
|
|
385
|
+
"""Set the event log."""
|
|
386
|
+
self._event_log = events
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def step_results(self) -> dict[str, Any]:
|
|
390
|
+
"""Get step results."""
|
|
391
|
+
return self._step_results
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def hook_results(self) -> dict[str, Any]:
|
|
395
|
+
"""Get hook results."""
|
|
396
|
+
return self._hook_results
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def retry_state(self) -> dict[str, dict[str, Any]]:
|
|
400
|
+
"""Get retry states."""
|
|
401
|
+
return self._retry_states
|
|
402
|
+
|
|
403
|
+
# =========================================================================
|
|
404
|
+
# Event limit validation
|
|
405
|
+
# =========================================================================
|
|
406
|
+
|
|
407
|
+
async def validate_event_limits(self) -> None:
|
|
408
|
+
"""
|
|
409
|
+
Validate event count against configured soft/hard limits.
|
|
410
|
+
|
|
411
|
+
- Soft limit: Log warning, then every N events after
|
|
412
|
+
- Hard limit: Raise EventLimitExceededError
|
|
413
|
+
|
|
414
|
+
Called before recording new events to prevent runaway workflows.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
EventLimitExceededError: If event count exceeds hard limit
|
|
418
|
+
"""
|
|
419
|
+
if not self._durable or self._storage is None:
|
|
420
|
+
return # Skip validation for transient mode
|
|
421
|
+
|
|
422
|
+
from pyworkflow.config import get_config
|
|
423
|
+
from pyworkflow.core.exceptions import EventLimitExceededError
|
|
424
|
+
|
|
425
|
+
config = get_config()
|
|
426
|
+
|
|
427
|
+
# Get current event count from storage
|
|
428
|
+
events = await self._get_storage().get_events(self._run_id)
|
|
429
|
+
event_count = len(events)
|
|
430
|
+
|
|
431
|
+
# Hard limit check - fail immediately
|
|
432
|
+
if event_count >= config.event_hard_limit:
|
|
433
|
+
raise EventLimitExceededError(self._run_id, event_count, config.event_hard_limit)
|
|
434
|
+
|
|
435
|
+
# Soft limit check with interval warnings
|
|
436
|
+
if event_count >= config.event_soft_limit:
|
|
437
|
+
# Calculate if we should log a warning
|
|
438
|
+
should_warn = (
|
|
439
|
+
self._last_warning_count == 0 # First warning
|
|
440
|
+
or event_count >= self._last_warning_count + config.event_warning_interval
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if should_warn:
|
|
444
|
+
logger.warning(
|
|
445
|
+
f"Workflow approaching event limit: {event_count}/{config.event_hard_limit}",
|
|
446
|
+
run_id=self._run_id,
|
|
447
|
+
event_count=event_count,
|
|
448
|
+
soft_limit=config.event_soft_limit,
|
|
449
|
+
hard_limit=config.event_hard_limit,
|
|
450
|
+
)
|
|
451
|
+
self._last_warning_count = event_count
|
|
452
|
+
|
|
453
|
+
# =========================================================================
|
|
454
|
+
# Step execution
|
|
455
|
+
# =========================================================================
|
|
456
|
+
|
|
457
|
+
async def run(
|
|
458
|
+
self,
|
|
459
|
+
func: StepFunction,
|
|
460
|
+
*args: Any,
|
|
461
|
+
name: str | None = None,
|
|
462
|
+
**kwargs: Any,
|
|
463
|
+
) -> Any:
|
|
464
|
+
"""
|
|
465
|
+
Execute a step function.
|
|
466
|
+
|
|
467
|
+
In durable mode:
|
|
468
|
+
- Generates deterministic step ID
|
|
469
|
+
- Checks for cached result (replay)
|
|
470
|
+
- Records events to storage
|
|
471
|
+
|
|
472
|
+
In transient mode:
|
|
473
|
+
- Executes function directly
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
func: Step function to execute
|
|
477
|
+
*args: Arguments for the function
|
|
478
|
+
name: Optional step name
|
|
479
|
+
**kwargs: Keyword arguments for the function
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Result of the step function
|
|
483
|
+
"""
|
|
484
|
+
step_name: str = name or getattr(func, "__name__", None) or "step"
|
|
485
|
+
|
|
486
|
+
if not self._durable:
|
|
487
|
+
# Transient mode - execute directly
|
|
488
|
+
logger.debug(f"[transient] Running step: {step_name}")
|
|
489
|
+
return await self._execute_func(func, *args, **kwargs)
|
|
490
|
+
|
|
491
|
+
# Durable mode - use event sourcing
|
|
492
|
+
step_id = self._generate_step_id(step_name, args, kwargs)
|
|
493
|
+
|
|
494
|
+
# Check if already completed (replay)
|
|
495
|
+
if step_id in self._step_results:
|
|
496
|
+
logger.debug(f"[replay] Step {step_name} already completed, using cached result")
|
|
497
|
+
return self._step_results[step_id]
|
|
498
|
+
|
|
499
|
+
# Record step start
|
|
500
|
+
await self._record_step_start(step_id, step_name, args, kwargs)
|
|
501
|
+
|
|
502
|
+
logger.info(f"Running step: {step_name}", run_id=self._run_id, step_id=step_id)
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
# Execute the function
|
|
506
|
+
result = await self._execute_func(func, *args, **kwargs)
|
|
507
|
+
|
|
508
|
+
# Record completion
|
|
509
|
+
await self._record_step_complete(step_id, step_name, result)
|
|
510
|
+
|
|
511
|
+
# Cache result
|
|
512
|
+
self._step_results[step_id] = result
|
|
513
|
+
|
|
514
|
+
logger.info(f"Step completed: {step_name}", run_id=self._run_id, step_id=step_id)
|
|
515
|
+
return result
|
|
516
|
+
|
|
517
|
+
except Exception as e:
|
|
518
|
+
await self._record_step_failed(step_id, e)
|
|
519
|
+
raise
|
|
520
|
+
|
|
521
|
+
async def _execute_func(self, func: Callable, *args: Any, **kwargs: Any) -> Any:
|
|
522
|
+
"""Execute a function, handling both sync and async."""
|
|
523
|
+
if asyncio.iscoroutinefunction(func):
|
|
524
|
+
return await func(*args, **kwargs)
|
|
525
|
+
return func(*args, **kwargs)
|
|
526
|
+
|
|
527
|
+
def _generate_step_id(self, step_name: str, args: tuple, kwargs: dict) -> str:
|
|
528
|
+
"""Generate deterministic step ID."""
|
|
529
|
+
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
530
|
+
|
|
531
|
+
args_str = serialize_args(*args)
|
|
532
|
+
kwargs_str = serialize_kwargs(**kwargs)
|
|
533
|
+
content = f"{step_name}:{args_str}:{kwargs_str}"
|
|
534
|
+
hash_hex = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
535
|
+
return f"step_{step_name}_{hash_hex}"
|
|
536
|
+
|
|
537
|
+
async def _record_step_start(
|
|
538
|
+
self, step_id: str, step_name: str, args: tuple, kwargs: dict
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Record step started event."""
|
|
541
|
+
from pyworkflow.engine.events import create_step_started_event
|
|
542
|
+
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
543
|
+
|
|
544
|
+
event = create_step_started_event(
|
|
545
|
+
run_id=self._run_id,
|
|
546
|
+
step_id=step_id,
|
|
547
|
+
step_name=step_name,
|
|
548
|
+
args=serialize_args(*args),
|
|
549
|
+
kwargs=serialize_kwargs(**kwargs),
|
|
550
|
+
attempt=1,
|
|
551
|
+
)
|
|
552
|
+
await self._get_storage().record_event(event)
|
|
553
|
+
|
|
554
|
+
async def _record_step_complete(self, step_id: str, step_name: str, result: Any) -> None:
|
|
555
|
+
"""Record step completed event."""
|
|
556
|
+
from pyworkflow.engine.events import create_step_completed_event
|
|
557
|
+
from pyworkflow.serialization.encoder import serialize
|
|
558
|
+
|
|
559
|
+
event = create_step_completed_event(
|
|
560
|
+
run_id=self._run_id,
|
|
561
|
+
step_id=step_id,
|
|
562
|
+
result=serialize(result),
|
|
563
|
+
step_name=step_name,
|
|
564
|
+
)
|
|
565
|
+
await self._get_storage().record_event(event)
|
|
566
|
+
|
|
567
|
+
async def _record_step_failed(self, step_id: str, error: Exception) -> None:
|
|
568
|
+
"""Record step failed event."""
|
|
569
|
+
from pyworkflow.engine.events import create_step_failed_event
|
|
570
|
+
|
|
571
|
+
event = create_step_failed_event(
|
|
572
|
+
run_id=self._run_id,
|
|
573
|
+
step_id=step_id,
|
|
574
|
+
error=str(error),
|
|
575
|
+
error_type=type(error).__name__,
|
|
576
|
+
is_retryable=True,
|
|
577
|
+
attempt=1,
|
|
578
|
+
)
|
|
579
|
+
await self._get_storage().record_event(event)
|
|
580
|
+
|
|
581
|
+
# =========================================================================
|
|
582
|
+
# Sleep
|
|
583
|
+
# =========================================================================
|
|
584
|
+
|
|
585
|
+
async def sleep(self, duration: str | int | float) -> None:
|
|
586
|
+
"""
|
|
587
|
+
Sleep for the specified duration.
|
|
588
|
+
|
|
589
|
+
In durable mode:
|
|
590
|
+
- Records sleep event
|
|
591
|
+
- Raises SuspensionSignal to pause workflow
|
|
592
|
+
- Workflow can be resumed later
|
|
593
|
+
|
|
594
|
+
In transient mode:
|
|
595
|
+
- Uses asyncio.sleep
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
duration: Sleep duration (string like "5m" or seconds)
|
|
599
|
+
"""
|
|
600
|
+
# Parse duration
|
|
601
|
+
duration_seconds = parse_duration(duration) if isinstance(duration, str) else int(duration)
|
|
602
|
+
|
|
603
|
+
if not self._durable:
|
|
604
|
+
# Transient mode - just sleep
|
|
605
|
+
logger.debug(f"[transient] Sleeping {duration_seconds}s")
|
|
606
|
+
await asyncio.sleep(duration_seconds)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
# Check for cancellation before sleeping
|
|
610
|
+
self.check_cancellation()
|
|
611
|
+
|
|
612
|
+
# Durable mode - suspend workflow
|
|
613
|
+
sleep_id = self._generate_sleep_id(duration_seconds)
|
|
614
|
+
|
|
615
|
+
# Check if already completed (replay)
|
|
616
|
+
if sleep_id in self._completed_sleeps:
|
|
617
|
+
logger.debug(f"[replay] Sleep {sleep_id} already completed, skipping")
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
# Calculate resume time
|
|
621
|
+
resume_at = datetime.now(UTC).timestamp() + duration_seconds
|
|
622
|
+
|
|
623
|
+
# Check if we should resume now
|
|
624
|
+
if datetime.now(UTC).timestamp() >= resume_at:
|
|
625
|
+
logger.debug(f"Sleep {sleep_id} time elapsed, continuing")
|
|
626
|
+
self._completed_sleeps.add(sleep_id)
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
# Validate event limits before recording sleep event
|
|
630
|
+
await self.validate_event_limits()
|
|
631
|
+
|
|
632
|
+
# Record sleep started and suspend
|
|
633
|
+
await self._record_sleep_start(sleep_id, duration_seconds, resume_at)
|
|
634
|
+
|
|
635
|
+
logger.info(
|
|
636
|
+
f"Suspending workflow for {duration_seconds}s",
|
|
637
|
+
run_id=self._run_id,
|
|
638
|
+
sleep_id=sleep_id,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
raise SuspensionSignal(
|
|
642
|
+
reason=f"sleep:{sleep_id}",
|
|
643
|
+
resume_at=datetime.fromtimestamp(resume_at, tz=UTC),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
def _generate_sleep_id(self, duration_seconds: int) -> str:
|
|
647
|
+
"""Generate deterministic sleep ID."""
|
|
648
|
+
self._step_counter += 1
|
|
649
|
+
return f"sleep_{self._step_counter}_{duration_seconds}s"
|
|
650
|
+
|
|
651
|
+
async def _record_sleep_start(
|
|
652
|
+
self, sleep_id: str, duration_seconds: int, resume_at: float
|
|
653
|
+
) -> None:
|
|
654
|
+
"""Record sleep started event."""
|
|
655
|
+
from pyworkflow.engine.events import create_sleep_started_event
|
|
656
|
+
|
|
657
|
+
event = create_sleep_started_event(
|
|
658
|
+
run_id=self._run_id,
|
|
659
|
+
sleep_id=sleep_id,
|
|
660
|
+
duration_seconds=duration_seconds,
|
|
661
|
+
resume_at=datetime.fromtimestamp(resume_at, tz=UTC),
|
|
662
|
+
)
|
|
663
|
+
await self._get_storage().record_event(event)
|
|
664
|
+
|
|
665
|
+
# =========================================================================
|
|
666
|
+
# Parallel execution
|
|
667
|
+
# =========================================================================
|
|
668
|
+
|
|
669
|
+
async def parallel(self, *tasks: Any) -> list[Any]:
|
|
670
|
+
"""Execute multiple tasks in parallel."""
|
|
671
|
+
return list(await asyncio.gather(*tasks))
|
|
672
|
+
|
|
673
|
+
# =========================================================================
|
|
674
|
+
# External events (hooks)
|
|
675
|
+
# =========================================================================
|
|
676
|
+
|
|
677
|
+
async def wait_for_event(
|
|
678
|
+
self,
|
|
679
|
+
event_name: str,
|
|
680
|
+
timeout: str | int | None = None,
|
|
681
|
+
) -> Any:
|
|
682
|
+
"""
|
|
683
|
+
Wait for an external event.
|
|
684
|
+
|
|
685
|
+
In durable mode:
|
|
686
|
+
- Creates a hook
|
|
687
|
+
- Suspends workflow waiting for webhook
|
|
688
|
+
- Returns payload when webhook received
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
event_name: Name for the event/hook
|
|
692
|
+
timeout: Optional timeout
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Event payload
|
|
696
|
+
"""
|
|
697
|
+
if not self._durable:
|
|
698
|
+
raise NotImplementedError("wait_for_event requires durable mode with storage")
|
|
699
|
+
|
|
700
|
+
hook_id = f"hook_{event_name}_{self._step_counter}"
|
|
701
|
+
self._step_counter += 1
|
|
702
|
+
|
|
703
|
+
# Check if already received (replay)
|
|
704
|
+
if hook_id in self._hook_results:
|
|
705
|
+
logger.debug(f"[replay] Hook {hook_id} already received")
|
|
706
|
+
return self._hook_results[hook_id]
|
|
707
|
+
|
|
708
|
+
# Record hook created and suspend
|
|
709
|
+
await self._record_hook_created(hook_id, event_name, timeout)
|
|
710
|
+
|
|
711
|
+
logger.info(
|
|
712
|
+
f"Waiting for event: {event_name}",
|
|
713
|
+
run_id=self._run_id,
|
|
714
|
+
hook_id=hook_id,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
raise SuspensionSignal(
|
|
718
|
+
reason=f"hook:{hook_id}",
|
|
719
|
+
hook_id=hook_id,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
async def _record_hook_created(
|
|
723
|
+
self, hook_id: str, event_name: str, timeout: str | int | None
|
|
724
|
+
) -> None:
|
|
725
|
+
"""Record hook created event."""
|
|
726
|
+
from pyworkflow.engine.events import create_hook_created_event
|
|
727
|
+
|
|
728
|
+
timeout_seconds = None
|
|
729
|
+
if timeout:
|
|
730
|
+
timeout_seconds = parse_duration(timeout) if isinstance(timeout, str) else int(timeout)
|
|
731
|
+
|
|
732
|
+
event = create_hook_created_event(
|
|
733
|
+
run_id=self._run_id,
|
|
734
|
+
hook_id=hook_id,
|
|
735
|
+
hook_name=event_name,
|
|
736
|
+
timeout_seconds=timeout_seconds,
|
|
737
|
+
)
|
|
738
|
+
await self._get_storage().record_event(event)
|
|
739
|
+
|
|
740
|
+
async def hook(
|
|
741
|
+
self,
|
|
742
|
+
name: str,
|
|
743
|
+
timeout: int | None = None,
|
|
744
|
+
on_created: Callable[[str], Awaitable[None]] | None = None,
|
|
745
|
+
payload_schema: type[BaseModel] | None = None,
|
|
746
|
+
) -> Any:
|
|
747
|
+
"""
|
|
748
|
+
Wait for an external event (webhook, approval, callback).
|
|
749
|
+
|
|
750
|
+
In durable mode:
|
|
751
|
+
- Generates hook_id and composite token (run_id:hook_id)
|
|
752
|
+
- Checks if already received (replay mode)
|
|
753
|
+
- Records HOOK_CREATED event (idempotency checked via events)
|
|
754
|
+
- Calls on_created callback with token (if provided)
|
|
755
|
+
- Raises SuspensionSignal to pause workflow
|
|
756
|
+
|
|
757
|
+
In transient mode:
|
|
758
|
+
- Raises NotImplementedError (hooks require durability)
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
name: Human-readable name for the hook
|
|
762
|
+
timeout: Optional timeout in seconds
|
|
763
|
+
on_created: Optional async callback called with token when hook is created
|
|
764
|
+
payload_schema: Optional Pydantic model class for payload validation
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
Payload from resume_hook()
|
|
768
|
+
|
|
769
|
+
Raises:
|
|
770
|
+
NotImplementedError: If not in durable mode
|
|
771
|
+
"""
|
|
772
|
+
if not self._durable:
|
|
773
|
+
raise NotImplementedError(
|
|
774
|
+
"hook() requires durable mode with storage. "
|
|
775
|
+
"Initialize LocalContext with durable=True and a storage backend."
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# Check for cancellation before waiting for hook
|
|
779
|
+
self.check_cancellation()
|
|
780
|
+
|
|
781
|
+
# Generate deterministic hook_id
|
|
782
|
+
self._step_counter += 1
|
|
783
|
+
hook_id = f"hook_{name}_{self._step_counter}"
|
|
784
|
+
|
|
785
|
+
# Check if already received (replay mode)
|
|
786
|
+
if hook_id in self._hook_results:
|
|
787
|
+
logger.debug(f"[replay] Hook {hook_id} already received")
|
|
788
|
+
return self._hook_results[hook_id]
|
|
789
|
+
|
|
790
|
+
# Generate composite token: run_id:hook_id
|
|
791
|
+
from pyworkflow.primitives.resume_hook import create_hook_token
|
|
792
|
+
|
|
793
|
+
actual_token = create_hook_token(self._run_id, hook_id)
|
|
794
|
+
|
|
795
|
+
# Calculate expiration time
|
|
796
|
+
expires_at = None
|
|
797
|
+
if timeout:
|
|
798
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=timeout)
|
|
799
|
+
|
|
800
|
+
# Validate event limits before recording hook event
|
|
801
|
+
await self.validate_event_limits()
|
|
802
|
+
|
|
803
|
+
# Record HOOK_CREATED event (this is the source of truth for hook existence)
|
|
804
|
+
from pyworkflow.engine.events import create_hook_created_event
|
|
805
|
+
|
|
806
|
+
event = create_hook_created_event(
|
|
807
|
+
run_id=self._run_id,
|
|
808
|
+
hook_id=hook_id,
|
|
809
|
+
hook_name=name,
|
|
810
|
+
token=actual_token,
|
|
811
|
+
timeout_seconds=timeout,
|
|
812
|
+
expires_at=expires_at,
|
|
813
|
+
)
|
|
814
|
+
await self._get_storage().record_event(event)
|
|
815
|
+
|
|
816
|
+
# Convert Pydantic model to JSON schema if provided
|
|
817
|
+
schema_json = None
|
|
818
|
+
if payload_schema is not None:
|
|
819
|
+
schema_json = json.dumps(payload_schema.model_json_schema())
|
|
820
|
+
|
|
821
|
+
# Create Hook record in storage for querying
|
|
822
|
+
from pyworkflow.storage.schemas import Hook
|
|
823
|
+
|
|
824
|
+
hook_record = Hook(
|
|
825
|
+
hook_id=hook_id,
|
|
826
|
+
run_id=self._run_id,
|
|
827
|
+
token=actual_token,
|
|
828
|
+
name=name,
|
|
829
|
+
expires_at=expires_at,
|
|
830
|
+
payload_schema=schema_json,
|
|
831
|
+
)
|
|
832
|
+
await self._get_storage().create_hook(hook_record)
|
|
833
|
+
|
|
834
|
+
# Track pending hook locally
|
|
835
|
+
self._pending_hooks[hook_id] = {
|
|
836
|
+
"token": actual_token,
|
|
837
|
+
"name": name,
|
|
838
|
+
"expires_at": expires_at.isoformat() if expires_at else None,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
# Call on_created callback if provided (before suspension)
|
|
842
|
+
if on_created is not None:
|
|
843
|
+
await on_created(actual_token)
|
|
844
|
+
|
|
845
|
+
logger.info(
|
|
846
|
+
f"Waiting for hook: {name}",
|
|
847
|
+
run_id=self._run_id,
|
|
848
|
+
hook_id=hook_id,
|
|
849
|
+
token=actual_token,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
raise SuspensionSignal(
|
|
853
|
+
reason=f"hook:{hook_id}",
|
|
854
|
+
hook_id=hook_id,
|
|
855
|
+
token=actual_token,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# =========================================================================
|
|
859
|
+
# Cancellation support
|
|
860
|
+
# =========================================================================
|
|
861
|
+
|
|
862
|
+
def is_cancellation_requested(self) -> bool:
|
|
863
|
+
"""
|
|
864
|
+
Check if cancellation has been requested for this workflow.
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
True if cancellation was requested, False otherwise
|
|
868
|
+
"""
|
|
869
|
+
return self._cancellation_requested
|
|
870
|
+
|
|
871
|
+
def request_cancellation(self, reason: str | None = None) -> None:
|
|
872
|
+
"""
|
|
873
|
+
Mark this workflow as cancelled.
|
|
874
|
+
|
|
875
|
+
This sets the cancellation flag. The workflow will raise
|
|
876
|
+
CancellationError at the next cancellation check point.
|
|
877
|
+
|
|
878
|
+
Args:
|
|
879
|
+
reason: Optional reason for cancellation
|
|
880
|
+
"""
|
|
881
|
+
self._cancellation_requested = True
|
|
882
|
+
self._cancellation_reason = reason
|
|
883
|
+
logger.info(
|
|
884
|
+
"Cancellation requested for workflow",
|
|
885
|
+
run_id=self._run_id,
|
|
886
|
+
reason=reason,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
def check_cancellation(self) -> None:
|
|
890
|
+
"""
|
|
891
|
+
Check for cancellation and raise if requested.
|
|
892
|
+
|
|
893
|
+
This should be called at interruptible points (before steps,
|
|
894
|
+
during sleeps, etc.) to allow graceful cancellation.
|
|
895
|
+
|
|
896
|
+
Raises:
|
|
897
|
+
CancellationError: If cancellation was requested and not blocked
|
|
898
|
+
"""
|
|
899
|
+
if self._cancellation_requested and not self._cancellation_blocked:
|
|
900
|
+
from pyworkflow.core.exceptions import CancellationError
|
|
901
|
+
|
|
902
|
+
logger.info(
|
|
903
|
+
"Cancellation check triggered - raising CancellationError",
|
|
904
|
+
run_id=self._run_id,
|
|
905
|
+
reason=self._cancellation_reason,
|
|
906
|
+
)
|
|
907
|
+
raise CancellationError(
|
|
908
|
+
message=f"Workflow was cancelled: {self._cancellation_reason or 'no reason provided'}",
|
|
909
|
+
reason=self._cancellation_reason,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
@property
|
|
913
|
+
def cancellation_blocked(self) -> bool:
|
|
914
|
+
"""
|
|
915
|
+
Check if cancellation is currently blocked (within a shield scope).
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
True if cancellation is blocked, False otherwise
|
|
919
|
+
"""
|
|
920
|
+
return self._cancellation_blocked
|
|
921
|
+
|
|
922
|
+
@property
|
|
923
|
+
def cancellation_reason(self) -> str | None:
|
|
924
|
+
"""
|
|
925
|
+
Get the reason for cancellation, if any.
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
The cancellation reason or None if not cancelled
|
|
929
|
+
"""
|
|
930
|
+
return self._cancellation_reason
|