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,545 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schedule primitives for workflow scheduling.
|
|
3
|
+
|
|
4
|
+
Provides functions to create, manage, and query schedules.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
# Create a cron-based schedule
|
|
8
|
+
schedule = await create_schedule(
|
|
9
|
+
"daily_report",
|
|
10
|
+
ScheduleSpec(cron="0 9 * * *"),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Create an interval-based schedule
|
|
14
|
+
schedule = await create_schedule(
|
|
15
|
+
"health_check",
|
|
16
|
+
ScheduleSpec(interval="5m"),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Pause a schedule
|
|
20
|
+
await pause_schedule(schedule.schedule_id)
|
|
21
|
+
|
|
22
|
+
# Resume a schedule
|
|
23
|
+
await resume_schedule(schedule.schedule_id)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import uuid
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
from pyworkflow.core.registry import get_workflow
|
|
33
|
+
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
34
|
+
from pyworkflow.storage.base import StorageBackend
|
|
35
|
+
from pyworkflow.storage.schemas import (
|
|
36
|
+
OverlapPolicy,
|
|
37
|
+
Schedule,
|
|
38
|
+
ScheduleSpec,
|
|
39
|
+
ScheduleStatus,
|
|
40
|
+
)
|
|
41
|
+
from pyworkflow.utils.schedule import calculate_next_run_time
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def create_schedule(
|
|
45
|
+
workflow_name: str,
|
|
46
|
+
spec: ScheduleSpec,
|
|
47
|
+
*args: Any,
|
|
48
|
+
overlap_policy: OverlapPolicy = OverlapPolicy.SKIP,
|
|
49
|
+
schedule_id: str | None = None,
|
|
50
|
+
storage: StorageBackend | None = None,
|
|
51
|
+
**kwargs: Any,
|
|
52
|
+
) -> Schedule:
|
|
53
|
+
"""
|
|
54
|
+
Create a new schedule for a workflow.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
workflow_name: Name of the workflow to schedule
|
|
58
|
+
spec: Schedule specification (cron, interval, or calendar)
|
|
59
|
+
*args: Positional arguments for the workflow
|
|
60
|
+
overlap_policy: How to handle overlapping runs
|
|
61
|
+
schedule_id: Optional custom schedule ID
|
|
62
|
+
storage: Storage backend (uses global config if not provided)
|
|
63
|
+
**kwargs: Keyword arguments for the workflow
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Created Schedule
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If workflow not found or invalid spec
|
|
70
|
+
|
|
71
|
+
Examples:
|
|
72
|
+
# Every day at 9 AM
|
|
73
|
+
schedule = await create_schedule(
|
|
74
|
+
"daily_report",
|
|
75
|
+
ScheduleSpec(cron="0 9 * * *"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Every 5 minutes with skip overlap
|
|
79
|
+
schedule = await create_schedule(
|
|
80
|
+
"health_check",
|
|
81
|
+
ScheduleSpec(interval="5m"),
|
|
82
|
+
overlap_policy=OverlapPolicy.SKIP,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Calendar-based: 1st of every month at midnight
|
|
86
|
+
from pyworkflow.storage.schemas import CalendarSpec
|
|
87
|
+
schedule = await create_schedule(
|
|
88
|
+
"monthly_billing",
|
|
89
|
+
ScheduleSpec(calendar=[CalendarSpec(day_of_month=1, hour=0, minute=0)]),
|
|
90
|
+
)
|
|
91
|
+
"""
|
|
92
|
+
if storage is None:
|
|
93
|
+
from pyworkflow.config import get_config
|
|
94
|
+
|
|
95
|
+
storage = get_config().storage
|
|
96
|
+
|
|
97
|
+
if storage is None:
|
|
98
|
+
raise ValueError("Storage backend required for schedules")
|
|
99
|
+
|
|
100
|
+
# Validate workflow exists
|
|
101
|
+
workflow_meta = get_workflow(workflow_name)
|
|
102
|
+
if not workflow_meta:
|
|
103
|
+
raise ValueError(f"Workflow '{workflow_name}' not found in registry")
|
|
104
|
+
|
|
105
|
+
# Validate spec
|
|
106
|
+
if not spec.cron and not spec.interval and not spec.calendar:
|
|
107
|
+
raise ValueError("Schedule spec must have cron, interval, or calendar")
|
|
108
|
+
|
|
109
|
+
# Validate cron expression if provided
|
|
110
|
+
if spec.cron:
|
|
111
|
+
from pyworkflow.utils.schedule import validate_cron_expression
|
|
112
|
+
|
|
113
|
+
if not validate_cron_expression(spec.cron):
|
|
114
|
+
raise ValueError(f"Invalid cron expression: {spec.cron}")
|
|
115
|
+
|
|
116
|
+
# Generate schedule_id
|
|
117
|
+
if schedule_id is None:
|
|
118
|
+
schedule_id = f"sched_{uuid.uuid4().hex[:12]}"
|
|
119
|
+
|
|
120
|
+
# Calculate first run time
|
|
121
|
+
next_run_time = calculate_next_run_time(spec)
|
|
122
|
+
|
|
123
|
+
schedule = Schedule(
|
|
124
|
+
schedule_id=schedule_id,
|
|
125
|
+
workflow_name=workflow_name,
|
|
126
|
+
spec=spec,
|
|
127
|
+
status=ScheduleStatus.ACTIVE,
|
|
128
|
+
args=serialize_args(*args),
|
|
129
|
+
kwargs=serialize_kwargs(**kwargs),
|
|
130
|
+
overlap_policy=overlap_policy,
|
|
131
|
+
created_at=datetime.now(UTC),
|
|
132
|
+
next_run_time=next_run_time,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await storage.create_schedule(schedule)
|
|
136
|
+
|
|
137
|
+
logger.info(
|
|
138
|
+
f"Created schedule: {schedule_id}",
|
|
139
|
+
workflow_name=workflow_name,
|
|
140
|
+
next_run_time=next_run_time.isoformat() if next_run_time else None,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return schedule
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def get_schedule(
|
|
147
|
+
schedule_id: str,
|
|
148
|
+
storage: StorageBackend | None = None,
|
|
149
|
+
) -> Schedule | None:
|
|
150
|
+
"""
|
|
151
|
+
Get a schedule by ID.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
schedule_id: Schedule identifier
|
|
155
|
+
storage: Storage backend
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Schedule if found, None otherwise
|
|
159
|
+
"""
|
|
160
|
+
if storage is None:
|
|
161
|
+
from pyworkflow.config import get_config
|
|
162
|
+
|
|
163
|
+
storage = get_config().storage
|
|
164
|
+
|
|
165
|
+
if storage is None:
|
|
166
|
+
raise ValueError("Storage backend required")
|
|
167
|
+
|
|
168
|
+
return await storage.get_schedule(schedule_id)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def list_schedules(
|
|
172
|
+
workflow_name: str | None = None,
|
|
173
|
+
status: ScheduleStatus | None = None,
|
|
174
|
+
limit: int = 100,
|
|
175
|
+
offset: int = 0,
|
|
176
|
+
storage: StorageBackend | None = None,
|
|
177
|
+
) -> list[Schedule]:
|
|
178
|
+
"""
|
|
179
|
+
List schedules with optional filtering.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
workflow_name: Filter by workflow name
|
|
183
|
+
status: Filter by status
|
|
184
|
+
limit: Maximum number of results
|
|
185
|
+
offset: Number of results to skip
|
|
186
|
+
storage: Storage backend
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
List of Schedule instances
|
|
190
|
+
"""
|
|
191
|
+
if storage is None:
|
|
192
|
+
from pyworkflow.config import get_config
|
|
193
|
+
|
|
194
|
+
storage = get_config().storage
|
|
195
|
+
|
|
196
|
+
if storage is None:
|
|
197
|
+
raise ValueError("Storage backend required")
|
|
198
|
+
|
|
199
|
+
return await storage.list_schedules(
|
|
200
|
+
workflow_name=workflow_name,
|
|
201
|
+
status=status,
|
|
202
|
+
limit=limit,
|
|
203
|
+
offset=offset,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def update_schedule(
|
|
208
|
+
schedule_id: str,
|
|
209
|
+
spec: ScheduleSpec | None = None,
|
|
210
|
+
overlap_policy: OverlapPolicy | None = None,
|
|
211
|
+
storage: StorageBackend | None = None,
|
|
212
|
+
) -> Schedule:
|
|
213
|
+
"""
|
|
214
|
+
Update an existing schedule.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
schedule_id: Schedule identifier
|
|
218
|
+
spec: New schedule specification (optional)
|
|
219
|
+
overlap_policy: New overlap policy (optional)
|
|
220
|
+
storage: Storage backend
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Updated Schedule
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: If schedule not found
|
|
227
|
+
"""
|
|
228
|
+
if storage is None:
|
|
229
|
+
from pyworkflow.config import get_config
|
|
230
|
+
|
|
231
|
+
storage = get_config().storage
|
|
232
|
+
|
|
233
|
+
if storage is None:
|
|
234
|
+
raise ValueError("Storage backend required")
|
|
235
|
+
|
|
236
|
+
schedule = await storage.get_schedule(schedule_id)
|
|
237
|
+
if not schedule:
|
|
238
|
+
raise ValueError(f"Schedule not found: {schedule_id}")
|
|
239
|
+
|
|
240
|
+
if spec is not None:
|
|
241
|
+
schedule.spec = spec
|
|
242
|
+
# Recalculate next run time
|
|
243
|
+
schedule.next_run_time = calculate_next_run_time(spec)
|
|
244
|
+
|
|
245
|
+
if overlap_policy is not None:
|
|
246
|
+
schedule.overlap_policy = overlap_policy
|
|
247
|
+
|
|
248
|
+
schedule.updated_at = datetime.now(UTC)
|
|
249
|
+
await storage.update_schedule(schedule)
|
|
250
|
+
|
|
251
|
+
logger.info(f"Updated schedule: {schedule_id}")
|
|
252
|
+
return schedule
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def pause_schedule(
|
|
256
|
+
schedule_id: str,
|
|
257
|
+
storage: StorageBackend | None = None,
|
|
258
|
+
) -> Schedule:
|
|
259
|
+
"""
|
|
260
|
+
Pause a schedule.
|
|
261
|
+
|
|
262
|
+
A paused schedule will not trigger any new workflow runs until resumed.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
schedule_id: Schedule identifier
|
|
266
|
+
storage: Storage backend
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Updated Schedule
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
ValueError: If schedule not found
|
|
273
|
+
"""
|
|
274
|
+
if storage is None:
|
|
275
|
+
from pyworkflow.config import get_config
|
|
276
|
+
|
|
277
|
+
storage = get_config().storage
|
|
278
|
+
|
|
279
|
+
if storage is None:
|
|
280
|
+
raise ValueError("Storage backend required")
|
|
281
|
+
|
|
282
|
+
schedule = await storage.get_schedule(schedule_id)
|
|
283
|
+
if not schedule:
|
|
284
|
+
raise ValueError(f"Schedule not found: {schedule_id}")
|
|
285
|
+
|
|
286
|
+
schedule.status = ScheduleStatus.PAUSED
|
|
287
|
+
schedule.updated_at = datetime.now(UTC)
|
|
288
|
+
await storage.update_schedule(schedule)
|
|
289
|
+
|
|
290
|
+
logger.info(f"Paused schedule: {schedule_id}")
|
|
291
|
+
return schedule
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def resume_schedule(
|
|
295
|
+
schedule_id: str,
|
|
296
|
+
storage: StorageBackend | None = None,
|
|
297
|
+
) -> Schedule:
|
|
298
|
+
"""
|
|
299
|
+
Resume a paused schedule.
|
|
300
|
+
|
|
301
|
+
Recalculates the next run time from now.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
schedule_id: Schedule identifier
|
|
305
|
+
storage: Storage backend
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Updated Schedule with new next_run_time
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
ValueError: If schedule not found
|
|
312
|
+
"""
|
|
313
|
+
if storage is None:
|
|
314
|
+
from pyworkflow.config import get_config
|
|
315
|
+
|
|
316
|
+
storage = get_config().storage
|
|
317
|
+
|
|
318
|
+
if storage is None:
|
|
319
|
+
raise ValueError("Storage backend required")
|
|
320
|
+
|
|
321
|
+
schedule = await storage.get_schedule(schedule_id)
|
|
322
|
+
if not schedule:
|
|
323
|
+
raise ValueError(f"Schedule not found: {schedule_id}")
|
|
324
|
+
|
|
325
|
+
schedule.status = ScheduleStatus.ACTIVE
|
|
326
|
+
schedule.updated_at = datetime.now(UTC)
|
|
327
|
+
schedule.next_run_time = calculate_next_run_time(schedule.spec)
|
|
328
|
+
await storage.update_schedule(schedule)
|
|
329
|
+
|
|
330
|
+
logger.info(
|
|
331
|
+
f"Resumed schedule: {schedule_id}",
|
|
332
|
+
next_run_time=schedule.next_run_time.isoformat() if schedule.next_run_time else None,
|
|
333
|
+
)
|
|
334
|
+
return schedule
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def delete_schedule(
|
|
338
|
+
schedule_id: str,
|
|
339
|
+
storage: StorageBackend | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""
|
|
342
|
+
Delete a schedule (soft delete).
|
|
343
|
+
|
|
344
|
+
The schedule record is preserved for audit purposes but marked as deleted.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
schedule_id: Schedule identifier
|
|
348
|
+
storage: Storage backend
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If schedule not found
|
|
352
|
+
"""
|
|
353
|
+
if storage is None:
|
|
354
|
+
from pyworkflow.config import get_config
|
|
355
|
+
|
|
356
|
+
storage = get_config().storage
|
|
357
|
+
|
|
358
|
+
if storage is None:
|
|
359
|
+
raise ValueError("Storage backend required")
|
|
360
|
+
|
|
361
|
+
await storage.delete_schedule(schedule_id)
|
|
362
|
+
logger.info(f"Deleted schedule: {schedule_id}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def trigger_schedule(
|
|
366
|
+
schedule_id: str,
|
|
367
|
+
storage: StorageBackend | None = None,
|
|
368
|
+
) -> str:
|
|
369
|
+
"""
|
|
370
|
+
Manually trigger a schedule immediately.
|
|
371
|
+
|
|
372
|
+
This bypasses the normal scheduling and executes the workflow immediately.
|
|
373
|
+
Does not affect the regular schedule timing.
|
|
374
|
+
|
|
375
|
+
Uses the configured runtime (local or celery) to execute the workflow.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
schedule_id: Schedule identifier
|
|
379
|
+
storage: Storage backend
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The workflow run ID
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ValueError: If schedule not found
|
|
386
|
+
"""
|
|
387
|
+
if storage is None:
|
|
388
|
+
from pyworkflow.config import get_config
|
|
389
|
+
|
|
390
|
+
storage = get_config().storage
|
|
391
|
+
|
|
392
|
+
if storage is None:
|
|
393
|
+
raise ValueError("Storage backend required")
|
|
394
|
+
|
|
395
|
+
schedule = await storage.get_schedule(schedule_id)
|
|
396
|
+
if not schedule:
|
|
397
|
+
raise ValueError(f"Schedule not found: {schedule_id}")
|
|
398
|
+
|
|
399
|
+
# Get workflow function
|
|
400
|
+
workflow_meta = get_workflow(schedule.workflow_name)
|
|
401
|
+
if not workflow_meta:
|
|
402
|
+
raise ValueError(f"Workflow '{schedule.workflow_name}' not found in registry")
|
|
403
|
+
|
|
404
|
+
# Deserialize args and kwargs
|
|
405
|
+
from pyworkflow.serialization.decoder import deserialize_args, deserialize_kwargs
|
|
406
|
+
|
|
407
|
+
args = deserialize_args(schedule.args)
|
|
408
|
+
kwargs = deserialize_kwargs(schedule.kwargs)
|
|
409
|
+
|
|
410
|
+
# Use runtime-agnostic start() which delegates to configured runtime
|
|
411
|
+
from pyworkflow.engine.executor import start
|
|
412
|
+
|
|
413
|
+
run_id = await start(
|
|
414
|
+
workflow_meta.func,
|
|
415
|
+
*args,
|
|
416
|
+
storage=storage,
|
|
417
|
+
durable=True,
|
|
418
|
+
**kwargs,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Update schedule stats
|
|
422
|
+
now = datetime.now(UTC)
|
|
423
|
+
schedule.last_run_at = now
|
|
424
|
+
schedule.total_runs += 1
|
|
425
|
+
schedule.next_run_time = calculate_next_run_time(schedule.spec, last_run=now, now=now)
|
|
426
|
+
await storage.update_schedule(schedule)
|
|
427
|
+
|
|
428
|
+
logger.info(f"Manually triggered schedule: {schedule_id}", run_id=run_id)
|
|
429
|
+
return run_id
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def backfill_schedule(
|
|
433
|
+
schedule_id: str,
|
|
434
|
+
start_time: datetime,
|
|
435
|
+
end_time: datetime,
|
|
436
|
+
storage: StorageBackend | None = None,
|
|
437
|
+
) -> list[str]:
|
|
438
|
+
"""
|
|
439
|
+
Backfill missed runs for a schedule.
|
|
440
|
+
|
|
441
|
+
Creates workflow runs for all scheduled times between start_time and end_time.
|
|
442
|
+
Useful for catching up after scheduler downtime.
|
|
443
|
+
|
|
444
|
+
Uses the configured runtime (local or celery) to execute the workflows.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
schedule_id: Schedule to backfill
|
|
448
|
+
start_time: Start of backfill period
|
|
449
|
+
end_time: End of backfill period
|
|
450
|
+
storage: Storage backend
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
List of created run IDs
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
ValueError: If schedule not found
|
|
457
|
+
"""
|
|
458
|
+
if storage is None:
|
|
459
|
+
from pyworkflow.config import get_config
|
|
460
|
+
|
|
461
|
+
storage = get_config().storage
|
|
462
|
+
|
|
463
|
+
if storage is None:
|
|
464
|
+
raise ValueError("Storage backend required")
|
|
465
|
+
|
|
466
|
+
schedule = await storage.get_schedule(schedule_id)
|
|
467
|
+
if not schedule:
|
|
468
|
+
raise ValueError(f"Schedule not found: {schedule_id}")
|
|
469
|
+
|
|
470
|
+
from pyworkflow.engine.events import (
|
|
471
|
+
create_schedule_backfill_completed_event,
|
|
472
|
+
create_schedule_backfill_started_event,
|
|
473
|
+
)
|
|
474
|
+
from pyworkflow.serialization.decoder import deserialize_args, deserialize_kwargs
|
|
475
|
+
from pyworkflow.utils.schedule import calculate_backfill_times
|
|
476
|
+
|
|
477
|
+
backfill_times = calculate_backfill_times(schedule.spec, start_time, end_time)
|
|
478
|
+
|
|
479
|
+
if not backfill_times:
|
|
480
|
+
logger.info(f"No backfill times found for schedule: {schedule_id}")
|
|
481
|
+
return []
|
|
482
|
+
|
|
483
|
+
# Record backfill started event
|
|
484
|
+
started_event = create_schedule_backfill_started_event(
|
|
485
|
+
run_id=schedule_id,
|
|
486
|
+
schedule_id=schedule_id,
|
|
487
|
+
start_time=start_time,
|
|
488
|
+
end_time=end_time,
|
|
489
|
+
expected_runs=len(backfill_times),
|
|
490
|
+
)
|
|
491
|
+
await storage.record_event(started_event)
|
|
492
|
+
|
|
493
|
+
# Get workflow function
|
|
494
|
+
workflow_meta = get_workflow(schedule.workflow_name)
|
|
495
|
+
if not workflow_meta:
|
|
496
|
+
raise ValueError(f"Workflow '{schedule.workflow_name}' not found in registry")
|
|
497
|
+
|
|
498
|
+
# Deserialize args and kwargs
|
|
499
|
+
args = deserialize_args(schedule.args)
|
|
500
|
+
kwargs = deserialize_kwargs(schedule.kwargs)
|
|
501
|
+
|
|
502
|
+
# Use runtime-agnostic start() which delegates to configured runtime
|
|
503
|
+
from pyworkflow.engine.executor import start
|
|
504
|
+
|
|
505
|
+
run_ids: list[str] = []
|
|
506
|
+
|
|
507
|
+
for scheduled_time in backfill_times:
|
|
508
|
+
try:
|
|
509
|
+
run_id = await start(
|
|
510
|
+
workflow_meta.func,
|
|
511
|
+
*args,
|
|
512
|
+
storage=storage,
|
|
513
|
+
durable=True,
|
|
514
|
+
**kwargs,
|
|
515
|
+
)
|
|
516
|
+
run_ids.append(run_id)
|
|
517
|
+
logger.debug(
|
|
518
|
+
f"Backfill run started for {schedule_id}",
|
|
519
|
+
run_id=run_id,
|
|
520
|
+
scheduled_time=scheduled_time.isoformat(),
|
|
521
|
+
)
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.error(
|
|
524
|
+
f"Failed to start backfill run for {schedule_id}",
|
|
525
|
+
scheduled_time=scheduled_time.isoformat(),
|
|
526
|
+
error=str(e),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Record backfill completed event
|
|
530
|
+
completed_event = create_schedule_backfill_completed_event(
|
|
531
|
+
run_id=schedule_id,
|
|
532
|
+
schedule_id=schedule_id,
|
|
533
|
+
runs_created=len(run_ids),
|
|
534
|
+
run_ids=run_ids,
|
|
535
|
+
)
|
|
536
|
+
await storage.record_event(completed_event)
|
|
537
|
+
|
|
538
|
+
logger.info(
|
|
539
|
+
f"Completed backfill for schedule: {schedule_id}",
|
|
540
|
+
count=len(run_ids),
|
|
541
|
+
start_time=start_time.isoformat(),
|
|
542
|
+
end_time=end_time.isoformat(),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
return run_ids
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shield - Protection from cancellation.
|
|
3
|
+
|
|
4
|
+
The shield() context manager allows critical sections of code to run
|
|
5
|
+
to completion even when cancellation has been requested. Use it for
|
|
6
|
+
cleanup operations, compensating transactions, or any code that must
|
|
7
|
+
complete to maintain consistency.
|
|
8
|
+
|
|
9
|
+
Note:
|
|
10
|
+
Cancellation in PyWorkflow is checkpoint-based. It is checked:
|
|
11
|
+
- Before each step execution
|
|
12
|
+
- Before sleep suspension
|
|
13
|
+
- Before hook suspension
|
|
14
|
+
|
|
15
|
+
Cancellation does NOT interrupt a step mid-execution. If a step takes
|
|
16
|
+
a long time, cancellation will only be detected after it completes.
|
|
17
|
+
For cooperative cancellation within long-running steps, call
|
|
18
|
+
``ctx.check_cancellation()`` periodically.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
@workflow
|
|
22
|
+
async def order_workflow(order_id: str):
|
|
23
|
+
try:
|
|
24
|
+
await reserve_inventory()
|
|
25
|
+
await charge_payment()
|
|
26
|
+
await ship_order()
|
|
27
|
+
except CancellationError:
|
|
28
|
+
# Critical cleanup - must complete even if cancelled
|
|
29
|
+
async with shield():
|
|
30
|
+
await release_inventory()
|
|
31
|
+
await refund_payment()
|
|
32
|
+
raise # Re-raise after cleanup
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from collections.abc import AsyncIterator
|
|
36
|
+
from contextlib import asynccontextmanager
|
|
37
|
+
|
|
38
|
+
from loguru import logger
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def shield() -> AsyncIterator[None]:
|
|
43
|
+
"""
|
|
44
|
+
Context manager that prevents cancellation within its scope.
|
|
45
|
+
|
|
46
|
+
While inside a shield() block, cancellation checks will not raise
|
|
47
|
+
CancellationError. The cancellation request is preserved and will
|
|
48
|
+
take effect after exiting the shield scope.
|
|
49
|
+
|
|
50
|
+
Use for:
|
|
51
|
+
- Critical cleanup operations
|
|
52
|
+
- Compensating transactions
|
|
53
|
+
- Database commits
|
|
54
|
+
- Any code that must complete for consistency
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
async with shield():
|
|
58
|
+
# This code will complete even if cancellation was requested
|
|
59
|
+
await critical_cleanup()
|
|
60
|
+
|
|
61
|
+
Warning:
|
|
62
|
+
Don't use shield for long-running operations as it defeats
|
|
63
|
+
the purpose of graceful cancellation.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
None - the shield scope
|
|
67
|
+
"""
|
|
68
|
+
from pyworkflow.context import get_context, has_context
|
|
69
|
+
|
|
70
|
+
if not has_context():
|
|
71
|
+
# No workflow context - shield has no effect
|
|
72
|
+
yield
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
ctx = get_context()
|
|
76
|
+
|
|
77
|
+
# Save previous state and block cancellation
|
|
78
|
+
previous_blocked = ctx._cancellation_blocked # type: ignore[attr-defined]
|
|
79
|
+
ctx._cancellation_blocked = True # type: ignore[attr-defined]
|
|
80
|
+
|
|
81
|
+
logger.debug(
|
|
82
|
+
"Entered shield scope - cancellation blocked",
|
|
83
|
+
run_id=ctx.run_id,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
# Restore previous state
|
|
90
|
+
ctx._cancellation_blocked = previous_blocked # type: ignore[attr-defined]
|
|
91
|
+
|
|
92
|
+
logger.debug(
|
|
93
|
+
"Exited shield scope - cancellation restored",
|
|
94
|
+
run_id=ctx.run_id,
|
|
95
|
+
cancellation_requested=ctx.is_cancellation_requested(),
|
|
96
|
+
)
|