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,552 @@
1
+ ---
2
+ title: 'Hooks'
3
+ description: 'Pause workflows and wait for external events like approvals, webhooks, or user actions'
4
+ ---
5
+
6
+ ## What are Hooks?
7
+
8
+ Hooks allow workflows to **suspend execution and wait for external events**. Unlike `sleep()` which resumes after a time duration, hooks wait for an external system or user to provide data before continuing.
9
+
10
+ ```python
11
+ from pydantic import BaseModel
12
+ from pyworkflow import define_hook, workflow
13
+
14
+ class ApprovalPayload(BaseModel):
15
+ approved: bool
16
+ reviewer: str
17
+ comments: str | None = None
18
+
19
+ # Define a typed hook with Pydantic validation
20
+ approval_hook = define_hook("manager_approval", ApprovalPayload)
21
+
22
+ @workflow()
23
+ async def approval_workflow(order_id: str):
24
+ order = await prepare_order(order_id)
25
+
26
+ # Workflow suspends here - waits for external approval
27
+ approval: ApprovalPayload = await approval_hook(timeout="7d")
28
+
29
+ if approval.approved:
30
+ return await fulfill_order(order)
31
+ else:
32
+ return await cancel_order(order, approval.comments)
33
+ ```
34
+
35
+ ## How It Works
36
+
37
+ ```
38
+ Workflow Execution
39
+
40
+
41
+ ┌───────────────┐
42
+ │ Execute Steps │
43
+ └───────┬───────┘
44
+
45
+
46
+ ┌───────────────────┐
47
+ │ await hook(...) │
48
+ └───────┬───────────┘
49
+
50
+ ├─── 1. Generate token (run_id:hook_id)
51
+
52
+ ├─── 2. Record hook_created event
53
+
54
+ ├─── 3. Store hook with schema (for CLI)
55
+
56
+ ├─── 4. Call on_created callback with token
57
+
58
+ ├─── 5. Raise SuspensionSignal
59
+
60
+ └─── 6. Worker is freed
61
+
62
+ │ ... waiting for external event ...
63
+
64
+
65
+ ┌─────────────────────┐
66
+ │ External system │
67
+ │ calls resume_hook() │
68
+ │ with token + payload│
69
+ └────────┬────────────┘
70
+
71
+ ├─── 7. Validate payload (if typed)
72
+
73
+ ├─── 8. Record hook_received event
74
+
75
+ └─── 9. Schedule workflow resumption
76
+
77
+
78
+ ┌───────────────┐
79
+ │ Replay events │
80
+ │ Resume work │
81
+ └───────────────┘
82
+ ```
83
+
84
+ ## Choosing a Hook Type
85
+
86
+ PyWorkflow offers two ways to define hooks:
87
+
88
+ | Feature | TypedHook (Recommended) | Simple Hook |
89
+ |---------|-------------------------|-------------|
90
+ | Type safety | Full Pydantic validation | Dict with any keys |
91
+ | CLI prompts | Interactive field-by-field | JSON payload only |
92
+ | IDE support | Autocomplete, type hints | None |
93
+ | Schema stored | Yes (enables CLI features) | No |
94
+ | Best for | Production workflows | Quick prototypes |
95
+
96
+ <Note>
97
+ We recommend **TypedHook** for all production workflows. The Pydantic schema enables CLI interactive prompts, payload validation, and better IDE support.
98
+ </Note>
99
+
100
+ ## TypedHook with Pydantic (Recommended)
101
+
102
+ TypedHook combines hook suspension with Pydantic validation for type-safe payloads.
103
+
104
+ ### Defining a Typed Hook
105
+
106
+ ```python
107
+ from pydantic import BaseModel
108
+ from typing import Optional
109
+ from pyworkflow import define_hook
110
+
111
+ # 1. Define the payload schema
112
+ class ApprovalPayload(BaseModel):
113
+ approved: bool
114
+ reviewer: str
115
+ comments: Optional[str] = None
116
+
117
+ # 2. Create the typed hook
118
+ approval_hook = define_hook("manager_approval", ApprovalPayload)
119
+ ```
120
+
121
+ ### Using in a Workflow
122
+
123
+ ```python
124
+ from pyworkflow import workflow
125
+
126
+ @workflow()
127
+ async def order_approval(order_id: str):
128
+ order = await prepare_order(order_id)
129
+
130
+ async def on_hook_created(token: str):
131
+ print(f"Approval needed! Token: {token}")
132
+ # Send token to external system, email, Slack, etc.
133
+
134
+ # Wait for approval - returns validated ApprovalPayload
135
+ approval: ApprovalPayload = await approval_hook(
136
+ timeout="7d",
137
+ on_created=on_hook_created,
138
+ )
139
+
140
+ # Type-safe access to payload fields
141
+ if approval.approved:
142
+ print(f"Approved by {approval.reviewer}")
143
+ return await fulfill_order(order)
144
+ else:
145
+ return await cancel_order(order, approval.comments or "Rejected")
146
+ ```
147
+
148
+ ### CLI Interactive Resume
149
+
150
+ When you run `pyworkflow hooks resume`, the CLI uses the stored Pydantic schema to prompt for each field:
151
+
152
+ ```bash
153
+ $ pyworkflow hooks resume
154
+
155
+ ? Select a pending hook to resume:
156
+ > manager_approval (run_abc123:hook_manager_approval_1)
157
+
158
+ ? approved (bool): yes
159
+ ? reviewer (str): admin@example.com
160
+ ? comments (str, optional): Looks good!
161
+
162
+ Hook resumed successfully.
163
+ ```
164
+
165
+ ## Simple Hook
166
+
167
+ For quick prototyping or when you don't need typed payloads, use the `hook()` function directly.
168
+
169
+ ```python
170
+ from pyworkflow import hook, workflow
171
+
172
+ @workflow()
173
+ async def simple_approval(order_id: str):
174
+ order = await prepare_order(order_id)
175
+
176
+ async def on_hook_created(token: str):
177
+ print(f"Resume with: pyworkflow hooks resume {token}")
178
+
179
+ # Wait for any payload (untyped dict)
180
+ approval = await hook(
181
+ "approval",
182
+ timeout="24h",
183
+ on_created=on_hook_created,
184
+ )
185
+
186
+ # Payload is a dict - no type safety
187
+ if approval.get("approved"):
188
+ return await fulfill_order(order)
189
+ else:
190
+ return await cancel_order(order, approval.get("reason", "Rejected"))
191
+ ```
192
+
193
+ <Warning>
194
+ Simple hooks don't enable CLI interactive prompts. You must provide the full JSON payload when resuming.
195
+ </Warning>
196
+
197
+ ## Resuming Hooks
198
+
199
+ ### Using the CLI
200
+
201
+ ```bash
202
+ # List all pending hooks
203
+ pyworkflow hooks list --status pending
204
+
205
+ # View hook details
206
+ pyworkflow hooks info <token>
207
+
208
+ # Resume interactively (TypedHook only - prompts for fields)
209
+ pyworkflow hooks resume
210
+
211
+ # Resume with explicit payload
212
+ pyworkflow hooks resume <token> --payload '{"approved": true, "reviewer": "admin"}'
213
+
214
+ # Resume with payload from file
215
+ pyworkflow hooks resume <token> --payload-file approval.json
216
+ ```
217
+
218
+ ### Programmatically
219
+
220
+ ```python
221
+ from pyworkflow import resume_hook
222
+
223
+ # Resume a hook with payload
224
+ result = await resume_hook(
225
+ token="run_abc123:hook_manager_approval_1",
226
+ payload={"approved": True, "reviewer": "admin@example.com"},
227
+ )
228
+
229
+ if result.success:
230
+ print(f"Hook resumed, workflow continuing")
231
+ else:
232
+ print(f"Failed: {result.error}")
233
+ ```
234
+
235
+ ### Token Format
236
+
237
+ Tokens are composite identifiers in the format `run_id:hook_id`:
238
+
239
+ ```
240
+ run_abc123:hook_manager_approval_1
241
+ └────┬────┘ └─────────┬──────────┘
242
+ run_id hook_id
243
+ ```
244
+
245
+ This self-describing format allows the system to route the resumption to the correct workflow run.
246
+
247
+ ## Configuration Options
248
+
249
+ | Option | Type | Description |
250
+ |--------|------|-------------|
251
+ | `timeout` | `str \| int` | Maximum wait time before hook expires. String format (`"24h"`, `"7d"`) or seconds. |
252
+ | `on_created` | `Callable[[str], Awaitable[None]]` | Async callback invoked with token when hook is created. |
253
+ | `payload_schema` | `Type[BaseModel]` | Pydantic model for payload validation (used by simple `hook()` only). |
254
+
255
+ ### Timeout Examples
256
+
257
+ ```python
258
+ # String format (recommended)
259
+ await approval_hook(timeout="24h") # 24 hours
260
+ await approval_hook(timeout="7d") # 7 days
261
+ await approval_hook(timeout="1h30m") # 1 hour 30 minutes
262
+
263
+ # Integer (seconds)
264
+ await approval_hook(timeout=3600) # 1 hour
265
+
266
+ # No timeout (waits indefinitely)
267
+ await approval_hook()
268
+ ```
269
+
270
+ ### on_created Callback
271
+
272
+ The `on_created` callback is called with the hook token when the hook is created. Use it to notify external systems:
273
+
274
+ ```python
275
+ async def notify_approver(token: str):
276
+ await send_slack_message(
277
+ channel="#approvals",
278
+ text=f"Approval needed! Resume: `pyworkflow hooks resume {token}`"
279
+ )
280
+ await send_email(
281
+ to="manager@example.com",
282
+ subject="Approval Required",
283
+ body=f"Click to approve: https://app.example.com/approve?token={token}"
284
+ )
285
+
286
+ approval = await approval_hook(
287
+ timeout="7d",
288
+ on_created=notify_approver,
289
+ )
290
+ ```
291
+
292
+ ## Use Cases
293
+
294
+ ### Human Approval Workflows
295
+
296
+ ```python
297
+ @workflow()
298
+ async def expense_approval(expense_id: str, amount: float):
299
+ expense = await prepare_expense(expense_id)
300
+
301
+ if amount > 1000:
302
+ # High-value expenses need manager approval
303
+ approval = await manager_approval_hook(timeout="48h")
304
+ if not approval.approved:
305
+ return await reject_expense(expense, approval.reason)
306
+
307
+ return await process_expense(expense)
308
+ ```
309
+
310
+ ### Multi-Level Approval
311
+
312
+ ```python
313
+ @workflow()
314
+ async def contract_approval(contract_id: str):
315
+ contract = await prepare_contract(contract_id)
316
+
317
+ # Level 1: Manager approval
318
+ manager = await manager_hook(timeout="24h")
319
+ if not manager.approved:
320
+ return await reject_contract(contract, "Manager rejected")
321
+
322
+ # Level 2: Legal review
323
+ legal = await legal_hook(timeout="72h")
324
+ if not legal.approved:
325
+ return await reject_contract(contract, "Legal rejected")
326
+
327
+ # Level 3: Executive sign-off
328
+ executive = await executive_hook(timeout="7d")
329
+ if not executive.approved:
330
+ return await reject_contract(contract, "Executive rejected")
331
+
332
+ return await execute_contract(contract)
333
+ ```
334
+
335
+ ### Webhook Integration
336
+
337
+ ```python
338
+ @workflow()
339
+ async def payment_processing(order_id: str):
340
+ order = await create_order(order_id)
341
+
342
+ async def setup_webhook(token: str):
343
+ # Register token with payment provider
344
+ await payment_provider.register_webhook(
345
+ callback_url=f"https://api.example.com/hooks/{token}",
346
+ )
347
+
348
+ # Wait for payment confirmation from external provider
349
+ payment = await payment_confirmation_hook(
350
+ timeout="1h",
351
+ on_created=setup_webhook,
352
+ )
353
+
354
+ if payment.status == "completed":
355
+ return await fulfill_order(order)
356
+ else:
357
+ return await cancel_order(order, payment.error)
358
+ ```
359
+
360
+ ### User Confirmation
361
+
362
+ ```python
363
+ @workflow()
364
+ async def account_deletion(user_id: str):
365
+ user = await get_user(user_id)
366
+
367
+ async def send_confirmation_email(token: str):
368
+ await send_email(
369
+ to=user.email,
370
+ subject="Confirm Account Deletion",
371
+ body=f"Click to confirm: https://app.example.com/confirm?token={token}"
372
+ )
373
+
374
+ # Wait for user to confirm deletion
375
+ confirmation = await confirmation_hook(
376
+ timeout="24h",
377
+ on_created=send_confirmation_email,
378
+ )
379
+
380
+ if confirmation.confirmed:
381
+ return await delete_user(user_id)
382
+ else:
383
+ return {"status": "cancelled", "user_id": user_id}
384
+ ```
385
+
386
+ ## Best Practices
387
+
388
+ <AccordionGroup>
389
+ <Accordion title="Use TypedHook for production workflows">
390
+ TypedHook provides validation, IDE support, and CLI interactive prompts:
391
+
392
+ ```python
393
+ # Good: Typed hook with Pydantic
394
+ class ApprovalPayload(BaseModel):
395
+ approved: bool
396
+ reviewer: str
397
+
398
+ approval_hook = define_hook("approval", ApprovalPayload)
399
+
400
+ # Avoid in production: Untyped hook
401
+ approval = await hook("approval")
402
+ ```
403
+ </Accordion>
404
+
405
+ <Accordion title="Set appropriate timeouts">
406
+ Always set timeouts to prevent workflows from waiting indefinitely:
407
+
408
+ ```python
409
+ # Good: Set reasonable timeout
410
+ await approval_hook(timeout="7d")
411
+
412
+ # Avoid: No timeout (waits forever)
413
+ await approval_hook()
414
+ ```
415
+
416
+ Handle expiration gracefully in your workflow logic.
417
+ </Accordion>
418
+
419
+ <Accordion title="Use descriptive hook names">
420
+ Hook names should clearly indicate their purpose:
421
+
422
+ ```python
423
+ # Good: Clear purpose
424
+ manager_approval_hook = define_hook("manager_approval", ApprovalPayload)
425
+ payment_confirmation_hook = define_hook("payment_confirmation", PaymentPayload)
426
+
427
+ # Avoid: Vague names
428
+ hook1 = define_hook("hook1", Payload)
429
+ ```
430
+ </Accordion>
431
+
432
+ <Accordion title="Notify external systems via on_created">
433
+ Use the `on_created` callback to trigger notifications:
434
+
435
+ ```python
436
+ async def notify_systems(token: str):
437
+ await send_slack_notification(token)
438
+ await send_email_notification(token)
439
+ await register_webhook(token)
440
+
441
+ approval = await approval_hook(
442
+ timeout="24h",
443
+ on_created=notify_systems,
444
+ )
445
+ ```
446
+ </Accordion>
447
+
448
+ <Accordion title="Handle hook errors gracefully">
449
+ Catch and handle hook-related exceptions:
450
+
451
+ ```python
452
+ from pyworkflow import HookExpiredError, HookNotFoundError
453
+
454
+ try:
455
+ result = await resume_hook(token, payload)
456
+ except HookExpiredError:
457
+ print("Hook has expired")
458
+ except HookNotFoundError:
459
+ print("Hook not found")
460
+ ```
461
+ </Accordion>
462
+ </AccordionGroup>
463
+
464
+ ## Testing Hooks
465
+
466
+ Use `MockContext` to test workflows with hooks without actual suspension:
467
+
468
+ ```python
469
+ import asyncio
470
+ from pyworkflow import MockContext, set_context
471
+
472
+ def test_approval_workflow():
473
+ # Create mock context with predefined hook responses
474
+ ctx = MockContext(
475
+ run_id="test_run",
476
+ workflow_name="approval_workflow",
477
+ mock_hooks={
478
+ "manager_approval": {
479
+ "approved": True,
480
+ "reviewer": "test@example.com",
481
+ "comments": "Approved in test",
482
+ }
483
+ }
484
+ )
485
+ set_context(ctx)
486
+
487
+ try:
488
+ # Run workflow - hook returns mock response immediately
489
+ result = asyncio.run(approval_workflow("order-123"))
490
+
491
+ # Verify workflow completed correctly
492
+ assert result["status"] == "fulfilled"
493
+ assert ctx.hook_count == 1
494
+ finally:
495
+ set_context(None)
496
+ ```
497
+
498
+ ### Testing Multiple Hooks
499
+
500
+ ```python
501
+ def test_multi_approval_workflow():
502
+ ctx = MockContext(
503
+ run_id="test_run",
504
+ workflow_name="multi_approval",
505
+ mock_hooks={
506
+ "manager_approval": {"approved": True, "approver": "manager"},
507
+ "finance_approval": {"approved": True, "approver": "finance"},
508
+ }
509
+ )
510
+ set_context(ctx)
511
+
512
+ try:
513
+ result = asyncio.run(multi_approval_workflow("order-123"))
514
+ assert result["status"] == "fulfilled"
515
+ assert ctx.hook_count == 2
516
+ finally:
517
+ set_context(None)
518
+ ```
519
+
520
+ ## Hook Events
521
+
522
+ Hooks generate events that are stored in the event log:
523
+
524
+ | Event Type | When | Data |
525
+ |------------|------|------|
526
+ | `hook.created` | Hook is awaited | hook_id, token, name, expires_at, schema |
527
+ | `hook.received` | Hook is resumed | hook_id, payload |
528
+ | `hook.expired` | Timeout reached | hook_id |
529
+ | `hook.disposed` | Hook cleaned up | hook_id |
530
+
531
+ View hook events for a run:
532
+
533
+ ```bash
534
+ pyworkflow runs logs <run_id> --type hook
535
+ ```
536
+
537
+ ## Next Steps
538
+
539
+ <CardGroup cols={2}>
540
+ <Card title="Sleep" icon="clock" href="/concepts/sleep">
541
+ Pause workflows for time durations.
542
+ </Card>
543
+ <Card title="Workflows" icon="diagram-project" href="/concepts/workflows">
544
+ Learn about workflow orchestration.
545
+ </Card>
546
+ <Card title="CLI Guide" icon="terminal" href="/guides/cli">
547
+ Manage hooks with CLI commands.
548
+ </Card>
549
+ <Card title="Fault Tolerance" icon="shield" href="/concepts/fault-tolerance">
550
+ Auto-recovery from worker crashes.
551
+ </Card>
552
+ </CardGroup>
@@ -0,0 +1,167 @@
1
+ ---
2
+ title: 'Limitations'
3
+ description: 'Built-in safeguards and limits to ensure stable workflow execution'
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ PyWorkflow includes built-in safeguards to prevent runaway workflows from consuming excessive resources. These limits help ensure system stability and predictable behavior.
9
+
10
+ ## Event History Limits
11
+
12
+ Since PyWorkflow uses event sourcing, every workflow action is recorded as an event. To prevent unbounded growth and memory issues, there are limits on the number of events a workflow can generate.
13
+
14
+ ### Soft Limit (Warning)
15
+
16
+ | Setting | Default | Description |
17
+ |---------|---------|-------------|
18
+ | `event_soft_limit` | 10,000 | Logs a warning when reached |
19
+ | `event_warning_interval` | 100 | Logs warning every N events after soft limit |
20
+
21
+ When a workflow reaches 10,000 events, PyWorkflow logs a warning:
22
+
23
+ ```
24
+ WARNING - Workflow approaching event limit: 10000/50000
25
+ ```
26
+
27
+ After the soft limit, warnings continue every 100 events (10,100, 10,200, etc.) to alert you that the workflow is growing large.
28
+
29
+ ### Hard Limit (Failure)
30
+
31
+ | Setting | Default | Description |
32
+ |---------|---------|-------------|
33
+ | `event_hard_limit` | 50,000 | Terminates workflow with failure |
34
+
35
+ When a workflow reaches 50,000 events, it is terminated with an `EventLimitExceededError`:
36
+
37
+ ```python
38
+ from pyworkflow.core.exceptions import EventLimitExceededError
39
+
40
+ # This error is raised when hard limit is reached
41
+ # EventLimitExceededError: Workflow run_abc123 exceeded maximum event limit: 50000 >= 50000
42
+ ```
43
+
44
+ <Warning>
45
+ The hard limit is a safety mechanism. If your workflow is hitting this limit, it likely indicates a design issue such as an infinite loop or processing too many items in a single workflow run.
46
+ </Warning>
47
+
48
+ ## Why These Limits Exist
49
+
50
+ 1. **Memory Protection**: Each event consumes memory. Unbounded event growth can exhaust system resources.
51
+
52
+ 2. **Replay Performance**: When workflows resume, all events are replayed. Large event logs slow down resumption.
53
+
54
+ 3. **Storage Costs**: Events are persisted to storage. Excessive events increase storage requirements.
55
+
56
+ 4. **Bug Detection**: Hitting limits often indicates bugs like infinite loops or improper workflow design.
57
+
58
+ ## Best Practices
59
+
60
+ <Tip>
61
+ **Design for bounded event counts:**
62
+
63
+ ```python
64
+ # BAD: Processing millions of items in one workflow
65
+ @workflow()
66
+ async def process_all_orders():
67
+ orders = await get_all_orders() # Could be millions!
68
+ for order in orders:
69
+ await process_order(order) # Each creates events
70
+
71
+ # GOOD: Process in batches with separate workflow runs
72
+ @workflow()
73
+ async def process_order_batch(batch_ids: list[str]):
74
+ for order_id in batch_ids[:100]: # Bounded batch size
75
+ await process_order(order_id)
76
+
77
+ # Orchestrate batches externally
78
+ for batch in chunk_list(all_order_ids, 100):
79
+ await start(process_order_batch, batch)
80
+ ```
81
+ </Tip>
82
+
83
+ ## Configuring Limits
84
+
85
+ <Warning>
86
+ Modifying event limits is **not recommended**. The defaults are carefully chosen to balance flexibility with safety. Only change these if you fully understand the implications.
87
+ </Warning>
88
+
89
+ If you must change the limits:
90
+
91
+ ```python
92
+ import pyworkflow
93
+
94
+ # This will emit a UserWarning - proceed with caution
95
+ pyworkflow.configure(
96
+ event_soft_limit=20_000, # Warning at 20K events
97
+ event_hard_limit=100_000, # Fail at 100K events
98
+ event_warning_interval=200, # Warn every 200 events after soft limit
99
+ )
100
+ ```
101
+
102
+ ### Configuration Options
103
+
104
+ | Option | Type | Default | Description |
105
+ |--------|------|---------|-------------|
106
+ | `event_soft_limit` | `int` | 10,000 | Event count to start logging warnings |
107
+ | `event_hard_limit` | `int` | 50,000 | Event count to terminate workflow |
108
+ | `event_warning_interval` | `int` | 100 | Events between warnings after soft limit |
109
+
110
+ ## Transient Mode
111
+
112
+ Event limits only apply to **durable** workflows. Transient workflows (with `durable=False`) do not record events and are not subject to these limits.
113
+
114
+ ```python
115
+ @workflow(durable=False)
116
+ async def quick_task():
117
+ # No event limits - events aren't recorded
118
+ for i in range(100_000):
119
+ await some_step(i)
120
+ ```
121
+
122
+ <Note>
123
+ Transient workflows sacrifice durability for performance. They cannot be resumed after crashes or restarts.
124
+ </Note>
125
+
126
+ ## Monitoring Event Counts
127
+
128
+ You can monitor event counts using the storage backend:
129
+
130
+ ```python
131
+ from pyworkflow import get_storage
132
+
133
+ storage = get_storage()
134
+ events = await storage.get_events(run_id)
135
+
136
+ print(f"Event count: {len(events)}")
137
+
138
+ # Check if approaching limits
139
+ if len(events) > 8000:
140
+ print("Warning: Workflow has many events, consider redesigning")
141
+ ```
142
+
143
+ ## Handling Limit Errors
144
+
145
+ When the hard limit is reached, an `EventLimitExceededError` is raised. This error inherits from `FatalError`, meaning it will **not** be retried.
146
+
147
+ ```python
148
+ from pyworkflow.core.exceptions import EventLimitExceededError, FatalError
149
+
150
+ try:
151
+ await start(my_workflow, args)
152
+ except EventLimitExceededError as e:
153
+ print(f"Workflow {e.run_id} exceeded {e.limit} events")
154
+ print(f"Actual count: {e.event_count}")
155
+ # Consider splitting the work into smaller workflows
156
+ ```
157
+
158
+ ## Next Steps
159
+
160
+ <CardGroup cols={2}>
161
+ <Card title="Events" icon="scroll" href="/concepts/events">
162
+ Learn how event sourcing works in PyWorkflow.
163
+ </Card>
164
+ <Card title="Configuration" icon="gear" href="/guides/configuration">
165
+ See all available configuration options.
166
+ </Card>
167
+ </CardGroup>