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/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()