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,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event replay engine for deterministic workflow state reconstruction.
|
|
3
|
+
|
|
4
|
+
The replay engine processes the event log to rebuild workflow state,
|
|
5
|
+
enabling fault tolerance and resumption after crashes or suspensions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from pyworkflow.context import LocalContext
|
|
11
|
+
from pyworkflow.engine.events import Event, EventType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventReplayer:
|
|
15
|
+
"""
|
|
16
|
+
Replays events to reconstruct workflow state.
|
|
17
|
+
|
|
18
|
+
The replayer processes events in sequence order to restore:
|
|
19
|
+
- Completed step results
|
|
20
|
+
- Hook payloads
|
|
21
|
+
- Sleep completion status
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def replay(self, ctx: LocalContext, events: list[Event]) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Replay events to restore workflow state.
|
|
27
|
+
|
|
28
|
+
This enables deterministic execution - same events always produce
|
|
29
|
+
same state.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
ctx: Workflow context to populate
|
|
33
|
+
events: List of events ordered by sequence
|
|
34
|
+
"""
|
|
35
|
+
if not events:
|
|
36
|
+
logger.debug(f"No events to replay for run {ctx.run_id}")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
logger.debug(
|
|
40
|
+
f"Replaying {len(events)} events for run {ctx.run_id}",
|
|
41
|
+
run_id=ctx.run_id,
|
|
42
|
+
workflow_name=ctx.workflow_name,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
ctx.is_replaying = True
|
|
46
|
+
ctx.event_log = events
|
|
47
|
+
|
|
48
|
+
for event in sorted(events, key=lambda e: e.sequence or 0):
|
|
49
|
+
await self._apply_event(ctx, event)
|
|
50
|
+
|
|
51
|
+
ctx.is_replaying = False
|
|
52
|
+
|
|
53
|
+
logger.debug(
|
|
54
|
+
f"Replay complete: {len(ctx.step_results)} steps, "
|
|
55
|
+
f"{len(ctx.hook_results)} hooks, "
|
|
56
|
+
f"{len(ctx.pending_sleeps)} pending sleeps, "
|
|
57
|
+
f"{len(ctx.retry_state)} pending retries",
|
|
58
|
+
run_id=ctx.run_id,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _apply_event(self, ctx: LocalContext, event: Event) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Apply a single event to the context.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
ctx: Workflow context
|
|
67
|
+
event: Event to apply
|
|
68
|
+
"""
|
|
69
|
+
if event.type == EventType.STEP_COMPLETED:
|
|
70
|
+
await self._apply_step_completed(ctx, event)
|
|
71
|
+
|
|
72
|
+
elif event.type == EventType.SLEEP_STARTED:
|
|
73
|
+
await self._apply_sleep_started(ctx, event)
|
|
74
|
+
|
|
75
|
+
elif event.type == EventType.SLEEP_COMPLETED:
|
|
76
|
+
await self._apply_sleep_completed(ctx, event)
|
|
77
|
+
|
|
78
|
+
elif event.type == EventType.HOOK_CREATED:
|
|
79
|
+
await self._apply_hook_created(ctx, event)
|
|
80
|
+
|
|
81
|
+
elif event.type == EventType.HOOK_RECEIVED:
|
|
82
|
+
await self._apply_hook_received(ctx, event)
|
|
83
|
+
|
|
84
|
+
elif event.type == EventType.HOOK_EXPIRED:
|
|
85
|
+
await self._apply_hook_expired(ctx, event)
|
|
86
|
+
|
|
87
|
+
elif event.type == EventType.STEP_RETRYING:
|
|
88
|
+
await self._apply_step_retrying(ctx, event)
|
|
89
|
+
|
|
90
|
+
elif event.type == EventType.WORKFLOW_INTERRUPTED:
|
|
91
|
+
await self._apply_workflow_interrupted(ctx, event)
|
|
92
|
+
|
|
93
|
+
elif event.type == EventType.CANCELLATION_REQUESTED:
|
|
94
|
+
await self._apply_cancellation_requested(ctx, event)
|
|
95
|
+
|
|
96
|
+
# Other event types don't affect replay state
|
|
97
|
+
# (workflow_started, step_started, step_failed, etc. are informational)
|
|
98
|
+
|
|
99
|
+
async def _apply_step_completed(self, ctx: LocalContext, event: Event) -> None:
|
|
100
|
+
"""Apply step_completed event - cache the result."""
|
|
101
|
+
from pyworkflow.serialization.decoder import deserialize
|
|
102
|
+
|
|
103
|
+
step_id = event.data.get("step_id")
|
|
104
|
+
result_json = event.data.get("result")
|
|
105
|
+
|
|
106
|
+
if step_id and result_json:
|
|
107
|
+
# Deserialize the result before caching
|
|
108
|
+
result = deserialize(result_json)
|
|
109
|
+
ctx.cache_step_result(step_id, result)
|
|
110
|
+
logger.debug(
|
|
111
|
+
f"Cached step result: {step_id}",
|
|
112
|
+
run_id=ctx.run_id,
|
|
113
|
+
step_id=step_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def _apply_sleep_started(self, ctx: LocalContext, event: Event) -> None:
|
|
117
|
+
"""Apply sleep_started event - mark sleep as pending."""
|
|
118
|
+
from datetime import datetime
|
|
119
|
+
|
|
120
|
+
sleep_id = event.data.get("sleep_id")
|
|
121
|
+
resume_at_str = event.data.get("resume_at")
|
|
122
|
+
|
|
123
|
+
if sleep_id and resume_at_str:
|
|
124
|
+
# Parse resume_at from ISO format
|
|
125
|
+
resume_at = datetime.fromisoformat(resume_at_str)
|
|
126
|
+
ctx.add_pending_sleep(sleep_id, resume_at)
|
|
127
|
+
logger.debug(
|
|
128
|
+
f"Sleep pending: {sleep_id}",
|
|
129
|
+
run_id=ctx.run_id,
|
|
130
|
+
sleep_id=sleep_id,
|
|
131
|
+
resume_at=resume_at_str,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def _apply_sleep_completed(self, ctx: LocalContext, event: Event) -> None:
|
|
135
|
+
"""Apply sleep_completed event - mark sleep as done."""
|
|
136
|
+
sleep_id = event.data.get("sleep_id")
|
|
137
|
+
|
|
138
|
+
if sleep_id:
|
|
139
|
+
ctx.mark_sleep_completed(sleep_id)
|
|
140
|
+
logger.debug(
|
|
141
|
+
f"Sleep completed: {sleep_id}",
|
|
142
|
+
run_id=ctx.run_id,
|
|
143
|
+
sleep_id=sleep_id,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def _apply_hook_created(self, ctx: LocalContext, event: Event) -> None:
|
|
147
|
+
"""Apply hook_created event - mark hook as pending."""
|
|
148
|
+
hook_id = event.data.get("hook_id")
|
|
149
|
+
|
|
150
|
+
if hook_id:
|
|
151
|
+
ctx.add_pending_hook(hook_id, event.data)
|
|
152
|
+
logger.debug(
|
|
153
|
+
f"Hook pending: {hook_id}",
|
|
154
|
+
run_id=ctx.run_id,
|
|
155
|
+
hook_id=hook_id,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _apply_hook_received(self, ctx: LocalContext, event: Event) -> None:
|
|
159
|
+
"""Apply hook_received event - cache the payload."""
|
|
160
|
+
hook_id = event.data.get("hook_id")
|
|
161
|
+
payload = event.data.get("payload")
|
|
162
|
+
|
|
163
|
+
if hook_id:
|
|
164
|
+
ctx.cache_hook_result(hook_id, payload)
|
|
165
|
+
logger.debug(
|
|
166
|
+
f"Cached hook result: {hook_id}",
|
|
167
|
+
run_id=ctx.run_id,
|
|
168
|
+
hook_id=hook_id,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def _apply_hook_expired(self, ctx: LocalContext, event: Event) -> None:
|
|
172
|
+
"""Apply hook_expired event - remove from pending."""
|
|
173
|
+
hook_id = event.data.get("hook_id")
|
|
174
|
+
|
|
175
|
+
if hook_id:
|
|
176
|
+
ctx.pending_hooks.pop(hook_id, None)
|
|
177
|
+
logger.debug(
|
|
178
|
+
f"Hook expired: {hook_id}",
|
|
179
|
+
run_id=ctx.run_id,
|
|
180
|
+
hook_id=hook_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def _apply_step_retrying(self, ctx: LocalContext, event: Event) -> None:
|
|
184
|
+
"""Apply step_retrying event - restore retry state for resumption."""
|
|
185
|
+
from datetime import datetime
|
|
186
|
+
|
|
187
|
+
step_id = event.data.get("step_id")
|
|
188
|
+
next_attempt = event.data.get("attempt")
|
|
189
|
+
resume_at_str = event.data.get("resume_at")
|
|
190
|
+
event.data.get("retry_after")
|
|
191
|
+
max_retries = event.data.get("max_retries", 3)
|
|
192
|
+
retry_delay = event.data.get("retry_strategy", "exponential")
|
|
193
|
+
last_error = event.data.get("error", "")
|
|
194
|
+
|
|
195
|
+
if step_id and next_attempt:
|
|
196
|
+
# Parse resume_at from ISO format
|
|
197
|
+
resume_at = datetime.fromisoformat(resume_at_str) if resume_at_str else None
|
|
198
|
+
|
|
199
|
+
# Restore retry state to context
|
|
200
|
+
ctx.set_retry_state(
|
|
201
|
+
step_id=step_id,
|
|
202
|
+
attempt=next_attempt,
|
|
203
|
+
resume_at=resume_at,
|
|
204
|
+
max_retries=max_retries,
|
|
205
|
+
retry_delay=retry_delay,
|
|
206
|
+
last_error=last_error,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
logger.debug(
|
|
210
|
+
f"Retry pending: {step_id}",
|
|
211
|
+
run_id=ctx.run_id,
|
|
212
|
+
step_id=step_id,
|
|
213
|
+
next_attempt=next_attempt,
|
|
214
|
+
resume_at=resume_at_str,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def _apply_workflow_interrupted(self, ctx: LocalContext, event: Event) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Apply workflow_interrupted event - log the interruption.
|
|
220
|
+
|
|
221
|
+
This event is informational for the replay - it doesn't change state
|
|
222
|
+
since the workflow will continue from the last completed step.
|
|
223
|
+
The event records that an interruption occurred for auditing purposes.
|
|
224
|
+
"""
|
|
225
|
+
reason = event.data.get("reason", "unknown")
|
|
226
|
+
recovery_attempt = event.data.get("recovery_attempt", 0)
|
|
227
|
+
last_event_sequence = event.data.get("last_event_sequence")
|
|
228
|
+
|
|
229
|
+
logger.info(
|
|
230
|
+
f"Workflow was interrupted: {reason}",
|
|
231
|
+
run_id=ctx.run_id,
|
|
232
|
+
reason=reason,
|
|
233
|
+
recovery_attempt=recovery_attempt,
|
|
234
|
+
last_event_sequence=last_event_sequence,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def _apply_cancellation_requested(self, ctx: LocalContext, event: Event) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Apply cancellation_requested event - mark workflow for cancellation.
|
|
240
|
+
|
|
241
|
+
This event signals that cancellation was requested. During replay,
|
|
242
|
+
we set the cancellation flag so the workflow will raise CancellationError
|
|
243
|
+
at the next check point.
|
|
244
|
+
"""
|
|
245
|
+
reason = event.data.get("reason")
|
|
246
|
+
requested_by = event.data.get("requested_by")
|
|
247
|
+
|
|
248
|
+
# Set cancellation flag in context
|
|
249
|
+
ctx.request_cancellation(reason=reason)
|
|
250
|
+
|
|
251
|
+
logger.info(
|
|
252
|
+
"Cancellation requested for workflow",
|
|
253
|
+
run_id=ctx.run_id,
|
|
254
|
+
reason=reason,
|
|
255
|
+
requested_by=requested_by,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Singleton instance
|
|
260
|
+
_replayer = EventReplayer()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def replay_events(ctx: LocalContext, events: list[Event]) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Replay events to restore workflow state.
|
|
266
|
+
|
|
267
|
+
Public API for event replay.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
ctx: Workflow context to populate
|
|
271
|
+
events: List of events to replay
|
|
272
|
+
"""
|
|
273
|
+
await _replayer.replay(ctx, events)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observability and logging for PyWorkflow.
|
|
3
|
+
|
|
4
|
+
Provides structured logging, metrics, and tracing capabilities for workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pyworkflow.observability.logging import (
|
|
8
|
+
bind_step_context,
|
|
9
|
+
bind_workflow_context,
|
|
10
|
+
configure_logging,
|
|
11
|
+
get_logger,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"configure_logging",
|
|
16
|
+
"get_logger",
|
|
17
|
+
"bind_workflow_context",
|
|
18
|
+
"bind_step_context",
|
|
19
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loguru logging configuration for PyWorkflow.
|
|
3
|
+
|
|
4
|
+
Provides structured logging with context-aware formatting for workflows, steps,
|
|
5
|
+
and events. Integrates with loguru for powerful logging capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def configure_logging(
|
|
16
|
+
level: str = "INFO",
|
|
17
|
+
log_file: str | None = None,
|
|
18
|
+
json_logs: bool = False,
|
|
19
|
+
show_context: bool = True,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Configure PyWorkflow logging with loguru.
|
|
23
|
+
|
|
24
|
+
This sets up structured logging with workflow context (run_id, step_id, etc.)
|
|
25
|
+
and flexible output formats.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
29
|
+
log_file: Optional file path for log output
|
|
30
|
+
json_logs: If True, output logs in JSON format (useful for production)
|
|
31
|
+
show_context: If True, include workflow context in log messages
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
# Basic configuration (console output only)
|
|
35
|
+
configure_logging()
|
|
36
|
+
|
|
37
|
+
# Debug mode with file output
|
|
38
|
+
configure_logging(level="DEBUG", log_file="workflow.log")
|
|
39
|
+
|
|
40
|
+
# Production mode with JSON logs
|
|
41
|
+
configure_logging(
|
|
42
|
+
level="INFO",
|
|
43
|
+
log_file="production.log",
|
|
44
|
+
json_logs=True
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Minimal logs without context
|
|
48
|
+
configure_logging(level="WARNING", show_context=False)
|
|
49
|
+
"""
|
|
50
|
+
# Remove default logger
|
|
51
|
+
logger.remove()
|
|
52
|
+
|
|
53
|
+
# Console format
|
|
54
|
+
if json_logs:
|
|
55
|
+
# JSON format for structured logging
|
|
56
|
+
console_format = _get_json_format()
|
|
57
|
+
else:
|
|
58
|
+
# Human-readable format
|
|
59
|
+
if show_context:
|
|
60
|
+
# Include extra context when available (run_id, step_id, etc.)
|
|
61
|
+
console_format = (
|
|
62
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
63
|
+
"<level>{level: <8}</level> | "
|
|
64
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
|
65
|
+
"<level>{message}</level>"
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
console_format = (
|
|
69
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
70
|
+
"<level>{level: <8}</level> | "
|
|
71
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
|
72
|
+
"<level>{message}</level>"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Add console handler with filter to inject context
|
|
76
|
+
def format_with_context(record: dict[str, Any]) -> bool:
|
|
77
|
+
"""Add context fields to the format string dynamically."""
|
|
78
|
+
extra_str = ""
|
|
79
|
+
if show_context and record["extra"]:
|
|
80
|
+
# Build context string from extra fields
|
|
81
|
+
context_parts = []
|
|
82
|
+
if "run_id" in record["extra"]:
|
|
83
|
+
context_parts.append(f"run_id={record['extra']['run_id']}")
|
|
84
|
+
if "step_id" in record["extra"]:
|
|
85
|
+
context_parts.append(f"step_id={record['extra']['step_id']}")
|
|
86
|
+
if "workflow_name" in record["extra"]:
|
|
87
|
+
context_parts.append(f"workflow={record['extra']['workflow_name']}")
|
|
88
|
+
if context_parts:
|
|
89
|
+
extra_str = " | " + " ".join(context_parts)
|
|
90
|
+
record["extra"]["_context"] = extra_str
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
logger.add(
|
|
94
|
+
sys.stderr,
|
|
95
|
+
format=console_format + "{extra[_context]}",
|
|
96
|
+
level=level,
|
|
97
|
+
colorize=not json_logs,
|
|
98
|
+
serialize=json_logs,
|
|
99
|
+
filter=format_with_context, # type: ignore[arg-type]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Add file handler if requested
|
|
103
|
+
if log_file:
|
|
104
|
+
log_path = Path(log_file)
|
|
105
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
if json_logs:
|
|
108
|
+
# JSON format for file
|
|
109
|
+
logger.add(
|
|
110
|
+
log_file,
|
|
111
|
+
format=_get_json_format(),
|
|
112
|
+
level=level,
|
|
113
|
+
rotation="100 MB",
|
|
114
|
+
retention="30 days",
|
|
115
|
+
compression="gz",
|
|
116
|
+
serialize=True,
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
# Human-readable format for file
|
|
120
|
+
logger.add(
|
|
121
|
+
log_file,
|
|
122
|
+
format=(
|
|
123
|
+
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
|
|
124
|
+
"{level: <8} | "
|
|
125
|
+
"{name}:{function}:{line} | "
|
|
126
|
+
"{message} | "
|
|
127
|
+
"{extra}"
|
|
128
|
+
),
|
|
129
|
+
level=level,
|
|
130
|
+
rotation="100 MB",
|
|
131
|
+
retention="30 days",
|
|
132
|
+
compression="gz",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
logger.info(f"PyWorkflow logging configured at level {level}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get_json_format() -> str:
|
|
139
|
+
"""
|
|
140
|
+
Get JSON log format string.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Format string for JSON structured logging
|
|
144
|
+
"""
|
|
145
|
+
return (
|
|
146
|
+
'{{"timestamp":"{time:YYYY-MM-DD HH:mm:ss.SSS}",'
|
|
147
|
+
'"level":"{level}",'
|
|
148
|
+
'"logger":"{name}",'
|
|
149
|
+
'"function":"{function}",'
|
|
150
|
+
'"line":{line},'
|
|
151
|
+
'"message":"{message}",'
|
|
152
|
+
'"extra":{extra}}}'
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_logger(name: str | None = None) -> Any:
|
|
157
|
+
"""
|
|
158
|
+
Get a logger instance.
|
|
159
|
+
|
|
160
|
+
This is a convenience function that returns the configured loguru logger
|
|
161
|
+
with optional context binding.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: Optional logger name (for filtering)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Configured logger instance
|
|
168
|
+
|
|
169
|
+
Examples:
|
|
170
|
+
# Get logger for a module
|
|
171
|
+
log = get_logger(__name__)
|
|
172
|
+
log.info("Processing workflow")
|
|
173
|
+
|
|
174
|
+
# Use with context
|
|
175
|
+
log = get_logger().bind(run_id="run_123")
|
|
176
|
+
log.info("Step started")
|
|
177
|
+
"""
|
|
178
|
+
if name:
|
|
179
|
+
return logger.bind(module=name)
|
|
180
|
+
return logger
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def bind_workflow_context(run_id: str, workflow_name: str) -> Any:
|
|
184
|
+
"""
|
|
185
|
+
Bind workflow context to logger.
|
|
186
|
+
|
|
187
|
+
This adds run_id and workflow_name to all subsequent log messages.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
run_id: Workflow run identifier
|
|
191
|
+
workflow_name: Workflow name
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Logger with bound context
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
log = bind_workflow_context("run_123", "process_order")
|
|
198
|
+
log.info("Workflow started")
|
|
199
|
+
# Output includes run_id and workflow_name
|
|
200
|
+
"""
|
|
201
|
+
return logger.bind(run_id=run_id, workflow_name=workflow_name)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def bind_step_context(run_id: str, step_id: str, step_name: str) -> Any:
|
|
205
|
+
"""
|
|
206
|
+
Bind step context to logger.
|
|
207
|
+
|
|
208
|
+
This adds run_id, step_id, and step_name to all subsequent log messages.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
run_id: Workflow run identifier
|
|
212
|
+
step_id: Step identifier
|
|
213
|
+
step_name: Step name
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Logger with bound context
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
log = bind_step_context("run_123", "step_abc", "validate_order")
|
|
220
|
+
log.info("Step executing")
|
|
221
|
+
# Output includes run_id, step_id, and step_name
|
|
222
|
+
"""
|
|
223
|
+
return logger.bind(run_id=run_id, step_id=step_id, step_name=step_name)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Default configuration on import
|
|
227
|
+
# Users can override by calling configure_logging()
|
|
228
|
+
try:
|
|
229
|
+
# Only configure if logger doesn't have handlers
|
|
230
|
+
if len(logger._core.handlers) == 0: # type: ignore[attr-defined]
|
|
231
|
+
configure_logging(level="INFO", show_context=False)
|
|
232
|
+
except Exception:
|
|
233
|
+
# If configuration fails, just use default loguru
|
|
234
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow primitives for durable execution.
|
|
3
|
+
|
|
4
|
+
Primitives provide building blocks for workflow orchestration:
|
|
5
|
+
- sleep: Durable delays without holding resources
|
|
6
|
+
- hook: Wait for external events (webhooks, approvals, callbacks)
|
|
7
|
+
- define_hook: Create typed hooks with Pydantic validation
|
|
8
|
+
- resume_hook: Resume suspended workflows from external systems
|
|
9
|
+
- shield: Protection from cancellation for critical sections
|
|
10
|
+
- continue_as_new: Continue workflow with fresh event history
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pyworkflow.primitives.continue_as_new import continue_as_new
|
|
14
|
+
from pyworkflow.primitives.define_hook import TypedHook, define_hook
|
|
15
|
+
from pyworkflow.primitives.hooks import hook
|
|
16
|
+
from pyworkflow.primitives.resume_hook import ResumeResult, resume_hook
|
|
17
|
+
from pyworkflow.primitives.shield import shield
|
|
18
|
+
from pyworkflow.primitives.sleep import sleep
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
# Sleep
|
|
22
|
+
"sleep",
|
|
23
|
+
# Hooks
|
|
24
|
+
"hook",
|
|
25
|
+
"define_hook",
|
|
26
|
+
"TypedHook",
|
|
27
|
+
"resume_hook",
|
|
28
|
+
"ResumeResult",
|
|
29
|
+
# Cancellation
|
|
30
|
+
"shield",
|
|
31
|
+
# Continue-as-new
|
|
32
|
+
"continue_as_new",
|
|
33
|
+
]
|