edda-framework 0.1.0__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.
- edda/__init__.py +56 -0
- edda/activity.py +505 -0
- edda/app.py +996 -0
- edda/compensation.py +326 -0
- edda/context.py +489 -0
- edda/events.py +505 -0
- edda/exceptions.py +64 -0
- edda/hooks.py +284 -0
- edda/locking.py +322 -0
- edda/outbox/__init__.py +15 -0
- edda/outbox/relayer.py +274 -0
- edda/outbox/transactional.py +112 -0
- edda/pydantic_utils.py +316 -0
- edda/replay.py +799 -0
- edda/retry.py +207 -0
- edda/serialization/__init__.py +9 -0
- edda/serialization/base.py +83 -0
- edda/serialization/json.py +102 -0
- edda/storage/__init__.py +9 -0
- edda/storage/models.py +194 -0
- edda/storage/protocol.py +737 -0
- edda/storage/sqlalchemy_storage.py +1809 -0
- edda/viewer_ui/__init__.py +20 -0
- edda/viewer_ui/app.py +1399 -0
- edda/viewer_ui/components.py +1105 -0
- edda/viewer_ui/data_service.py +880 -0
- edda/visualizer/__init__.py +11 -0
- edda/visualizer/ast_analyzer.py +383 -0
- edda/visualizer/mermaid_generator.py +355 -0
- edda/workflow.py +218 -0
- edda_framework-0.1.0.dist-info/METADATA +748 -0
- edda_framework-0.1.0.dist-info/RECORD +35 -0
- edda_framework-0.1.0.dist-info/WHEEL +4 -0
- edda_framework-0.1.0.dist-info/entry_points.txt +2 -0
- edda_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
edda/compensation.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compensation (Saga compensation) module for Edda framework.
|
|
3
|
+
|
|
4
|
+
This module provides compensation transaction support for implementing
|
|
5
|
+
the Saga pattern with automatic rollback on failure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from edda.context import WorkflowContext
|
|
13
|
+
|
|
14
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
15
|
+
|
|
16
|
+
# Global registry for compensation functions
|
|
17
|
+
_COMPENSATION_REGISTRY: dict[str, Callable[..., Any]] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CompensationAction:
|
|
21
|
+
"""
|
|
22
|
+
Represents a compensation action that should be executed on rollback.
|
|
23
|
+
|
|
24
|
+
Compensation actions are stored in LIFO order (stack) and executed
|
|
25
|
+
in reverse order when a workflow fails.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
func: Callable[..., Any],
|
|
31
|
+
args: tuple[Any, ...],
|
|
32
|
+
kwargs: dict[str, Any],
|
|
33
|
+
name: str,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize a compensation action.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
func: The compensation function to execute
|
|
40
|
+
args: Positional arguments for the function
|
|
41
|
+
kwargs: Keyword arguments for the function
|
|
42
|
+
name: Human-readable name for this compensation
|
|
43
|
+
"""
|
|
44
|
+
self.func = func
|
|
45
|
+
self.args = args
|
|
46
|
+
self.kwargs = kwargs
|
|
47
|
+
self.name = name
|
|
48
|
+
|
|
49
|
+
async def execute(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Execute the compensation action.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
Exception: Any exception raised by the compensation function
|
|
55
|
+
"""
|
|
56
|
+
await self.func(*self.args, **self.kwargs)
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
"""String representation of the compensation action."""
|
|
60
|
+
return f"CompensationAction(name={self.name!r})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def register_compensation(
|
|
64
|
+
ctx: "WorkflowContext",
|
|
65
|
+
compensation_func: Callable[..., Any],
|
|
66
|
+
*args: Any,
|
|
67
|
+
activity_id: str | None = None,
|
|
68
|
+
**kwargs: Any,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Register a compensation action to be executed if the workflow fails.
|
|
72
|
+
|
|
73
|
+
Compensation actions are stored in LIFO order (like a stack) and will be
|
|
74
|
+
executed in reverse order during rollback.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
ctx: Workflow context
|
|
78
|
+
compensation_func: The async function to call for compensation
|
|
79
|
+
*args: Positional arguments to pass to the compensation function
|
|
80
|
+
activity_id: Activity ID to associate with this compensation (optional)
|
|
81
|
+
**kwargs: Keyword arguments to pass to the compensation function
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> @saga
|
|
85
|
+
... async def order_workflow(ctx: WorkflowContext, order_id: str) -> dict:
|
|
86
|
+
... # Execute activity
|
|
87
|
+
... result = await reserve_inventory(ctx, order_id, activity_id="reserve:1")
|
|
88
|
+
...
|
|
89
|
+
... # Register compensation AFTER activity execution
|
|
90
|
+
... await register_compensation(
|
|
91
|
+
... ctx,
|
|
92
|
+
... release_inventory,
|
|
93
|
+
... activity_id="reserve:1",
|
|
94
|
+
... reservation_id=result["reservation_id"]
|
|
95
|
+
... )
|
|
96
|
+
...
|
|
97
|
+
... return result
|
|
98
|
+
"""
|
|
99
|
+
# Get function name for logging
|
|
100
|
+
func_name = getattr(compensation_func, "__name__", str(compensation_func))
|
|
101
|
+
|
|
102
|
+
# Register function in global registry for later execution
|
|
103
|
+
_COMPENSATION_REGISTRY[func_name] = compensation_func
|
|
104
|
+
|
|
105
|
+
action = CompensationAction(
|
|
106
|
+
func=compensation_func,
|
|
107
|
+
args=args,
|
|
108
|
+
kwargs=kwargs,
|
|
109
|
+
name=func_name,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Generate activity_id if not provided
|
|
113
|
+
if activity_id is None:
|
|
114
|
+
activity_id = ctx._generate_activity_id(func_name)
|
|
115
|
+
|
|
116
|
+
# Store in workflow's compensation stack
|
|
117
|
+
await ctx._push_compensation(action, activity_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compensation(func: F) -> F:
|
|
121
|
+
"""
|
|
122
|
+
Decorator to register a compensation function in the global registry.
|
|
123
|
+
|
|
124
|
+
This automatically registers the function when the module is imported,
|
|
125
|
+
making it available for execution across all worker processes in a
|
|
126
|
+
multi-process environment (e.g., tsuno, gunicorn).
|
|
127
|
+
|
|
128
|
+
Usage:
|
|
129
|
+
>>> @compensation
|
|
130
|
+
... async def cancel_reservation(ctx: WorkflowContext, reservation_id: str):
|
|
131
|
+
... await cancel_api(reservation_id)
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
func: The compensation function to register
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The same function (unmodified)
|
|
138
|
+
"""
|
|
139
|
+
func_name = getattr(func, "__name__", str(func))
|
|
140
|
+
_COMPENSATION_REGISTRY[func_name] = func
|
|
141
|
+
return func
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def on_failure(compensation_func: Callable[..., Any]) -> Callable[[F], F]:
|
|
145
|
+
"""
|
|
146
|
+
Decorator to automatically register a compensation function.
|
|
147
|
+
|
|
148
|
+
This decorator wraps an activity and automatically registers a compensation
|
|
149
|
+
action when the activity completes successfully.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
compensation_func: The compensation function to register
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Decorator function
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> @activity
|
|
159
|
+
... @on_failure(release_inventory)
|
|
160
|
+
... async def reserve_inventory(ctx: WorkflowContext, order_id: str) -> dict:
|
|
161
|
+
... reservation_id = await make_reservation(order_id)
|
|
162
|
+
... return {"reservation_id": reservation_id}
|
|
163
|
+
...
|
|
164
|
+
... @activity
|
|
165
|
+
... async def release_inventory(ctx: WorkflowContext, reservation_id: str) -> None:
|
|
166
|
+
... await cancel_reservation(reservation_id)
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def decorator(func: F) -> F:
|
|
170
|
+
# Mark the function to indicate it has compensation
|
|
171
|
+
func._compensation_func = compensation_func # type: ignore[attr-defined]
|
|
172
|
+
func._has_compensation = True # type: ignore[attr-defined]
|
|
173
|
+
return func
|
|
174
|
+
|
|
175
|
+
return decorator
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def execute_compensations(ctx: "WorkflowContext") -> None:
|
|
179
|
+
"""
|
|
180
|
+
Execute all registered compensation actions in LIFO order.
|
|
181
|
+
|
|
182
|
+
This is called automatically when a workflow fails and needs to rollback.
|
|
183
|
+
Compensations are executed in reverse order (LIFO/stack semantics).
|
|
184
|
+
|
|
185
|
+
This function sets status to "compensating" before execution to enable
|
|
186
|
+
crash recovery. The caller is responsible for setting the final status
|
|
187
|
+
(failed/cancelled) after compensations complete.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
ctx: Workflow context
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
Exception: If any compensation fails (logged but not propagated)
|
|
194
|
+
"""
|
|
195
|
+
# Get all compensations from storage
|
|
196
|
+
compensations = await ctx._get_compensations()
|
|
197
|
+
|
|
198
|
+
# If no compensations, nothing to do
|
|
199
|
+
if not compensations:
|
|
200
|
+
print(f"[Compensation] No compensations to execute for {ctx.instance_id}")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Mark as compensating BEFORE execution for crash recovery
|
|
204
|
+
# This allows auto-resume to detect and restart incomplete compensation
|
|
205
|
+
print(f"[Compensation] Starting compensation execution for {ctx.instance_id}")
|
|
206
|
+
await ctx._update_status("compensating", {"started_at": None})
|
|
207
|
+
|
|
208
|
+
# Get already executed compensations to avoid duplicate execution
|
|
209
|
+
history = await ctx.storage.get_history(ctx.instance_id)
|
|
210
|
+
executed_compensation_ids = {
|
|
211
|
+
event.get("event_data", {}).get("compensation_id")
|
|
212
|
+
for event in history
|
|
213
|
+
if event.get("event_type") == "CompensationExecuted"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Execute in reverse order (LIFO - most recent first)
|
|
217
|
+
for compensation_data in compensations: # Already reversed by get_compensations()
|
|
218
|
+
compensation_id = compensation_data.get("id")
|
|
219
|
+
activity_name = compensation_data.get("activity_name")
|
|
220
|
+
args_data = compensation_data.get("args", {})
|
|
221
|
+
|
|
222
|
+
# Skip if already executed (idempotency)
|
|
223
|
+
if compensation_id in executed_compensation_ids:
|
|
224
|
+
print(
|
|
225
|
+
f"[Compensation] Skipping already executed: {activity_name} (id={compensation_id})"
|
|
226
|
+
)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Extract args and kwargs
|
|
230
|
+
comp_args = args_data.get("args", [])
|
|
231
|
+
comp_kwargs = args_data.get("kwargs", {})
|
|
232
|
+
|
|
233
|
+
# Skip if activity_name is None or not a string
|
|
234
|
+
if not isinstance(activity_name, str):
|
|
235
|
+
print(f"[Compensation] Warning: Invalid activity_name: {activity_name}. Skipping.")
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Log compensation execution
|
|
239
|
+
print(f"[Compensation] Executing: {activity_name} (id={compensation_id})")
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
# Look up compensation function from registry
|
|
243
|
+
compensation_func = _COMPENSATION_REGISTRY.get(activity_name)
|
|
244
|
+
|
|
245
|
+
if compensation_func is None:
|
|
246
|
+
print(
|
|
247
|
+
f"[Compensation] Warning: Function '{activity_name}' not found in registry. Skipping."
|
|
248
|
+
)
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Execute the compensation function directly
|
|
252
|
+
# Compensation functions should NOT have @activity decorator
|
|
253
|
+
await compensation_func(ctx, *comp_args, **comp_kwargs)
|
|
254
|
+
|
|
255
|
+
# Record compensation execution in history (with idempotency)
|
|
256
|
+
try:
|
|
257
|
+
compensation_activity_id = f"compensation_{compensation_id}"
|
|
258
|
+
await ctx.storage.append_history(
|
|
259
|
+
instance_id=ctx.instance_id,
|
|
260
|
+
activity_id=compensation_activity_id,
|
|
261
|
+
event_type="CompensationExecuted",
|
|
262
|
+
event_data={
|
|
263
|
+
"activity_name": activity_name,
|
|
264
|
+
"compensation_id": compensation_id,
|
|
265
|
+
"args": comp_args,
|
|
266
|
+
"kwargs": comp_kwargs,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
except Exception as record_error:
|
|
270
|
+
# UNIQUE constraint error means another process already recorded this compensation
|
|
271
|
+
# This is expected in concurrent cancellation scenarios - silently ignore
|
|
272
|
+
error_msg = str(record_error)
|
|
273
|
+
if "UNIQUE constraint" in error_msg or "UNIQUE" in error_msg:
|
|
274
|
+
print(
|
|
275
|
+
f"[Compensation] {activity_name} already recorded by another process, skipping duplicate record"
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
# Other errors should be logged but not break the compensation flow
|
|
279
|
+
print(
|
|
280
|
+
f"[Compensation] Warning: Failed to record {activity_name} execution: {record_error}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
print(f"[Compensation] Successfully executed: {activity_name}")
|
|
284
|
+
|
|
285
|
+
except Exception as error:
|
|
286
|
+
# Log but don't fail the rollback
|
|
287
|
+
print(f"[Compensation] Failed to execute {activity_name}: {error}")
|
|
288
|
+
|
|
289
|
+
# Record compensation failure in history
|
|
290
|
+
try:
|
|
291
|
+
compensation_activity_id = f"compensation_{compensation_id}"
|
|
292
|
+
await ctx.storage.append_history(
|
|
293
|
+
instance_id=ctx.instance_id,
|
|
294
|
+
activity_id=compensation_activity_id,
|
|
295
|
+
event_type="CompensationFailed",
|
|
296
|
+
event_data={
|
|
297
|
+
"activity_name": activity_name,
|
|
298
|
+
"compensation_id": compensation_id,
|
|
299
|
+
"error_type": type(error).__name__,
|
|
300
|
+
"error_message": str(error),
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
except Exception as record_error:
|
|
304
|
+
# UNIQUE constraint error means another process already recorded this failure
|
|
305
|
+
error_msg = str(record_error)
|
|
306
|
+
if "UNIQUE constraint" in error_msg or "UNIQUE" in error_msg:
|
|
307
|
+
print(
|
|
308
|
+
f"[Compensation] {activity_name} failure already recorded by another process"
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
print(
|
|
312
|
+
f"[Compensation] Warning: Failed to record compensation failure: {record_error}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def clear_compensations(ctx: "WorkflowContext") -> None:
|
|
317
|
+
"""
|
|
318
|
+
Clear all registered compensation actions.
|
|
319
|
+
|
|
320
|
+
This is called when a workflow completes successfully and no longer
|
|
321
|
+
needs the registered compensations.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
ctx: Workflow context
|
|
325
|
+
"""
|
|
326
|
+
await ctx._clear_compensations()
|