pyworkflow-engine 0.1.7__py3-none-any.whl

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