edda-framework 0.14.0__py3-none-any.whl → 0.15.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/app.py CHANGED
@@ -583,7 +583,6 @@ class EddaApp:
583
583
  auto_resume_stale_workflows_periodically(
584
584
  self.storage,
585
585
  self.replay_engine,
586
- self.worker_id,
587
586
  interval=60,
588
587
  ),
589
588
  name="leader_stale_workflow_resume",
@@ -628,7 +627,6 @@ class EddaApp:
628
627
  auto_resume_stale_workflows_periodically(
629
628
  self.storage,
630
629
  self.replay_engine,
631
- self.worker_id,
632
630
  interval=60,
633
631
  ),
634
632
  name="leader_stale_workflow_resume",
@@ -1411,7 +1409,8 @@ class EddaApp:
1411
1409
  from growing indefinitely with orphaned messages (messages that were
1412
1410
  published but never received by any subscriber).
1413
1411
 
1414
- Uses system-level locking to ensure only one pod executes cleanup at a time.
1412
+ Important: This task should only be run by a single worker (e.g., via leader
1413
+ election). It does not perform its own distributed coordination.
1415
1414
 
1416
1415
  Args:
1417
1416
  interval: Cleanup interval in seconds (default: 3600 = 1 hour)
@@ -1422,27 +1421,13 @@ class EddaApp:
1422
1421
  """
1423
1422
  while True:
1424
1423
  try:
1425
- # Add jitter to prevent thundering herd in multi-pod deployments
1424
+ # Add jitter to prevent thundering herd
1426
1425
  jitter = random.uniform(0, interval * 0.3)
1427
1426
  await asyncio.sleep(interval + jitter)
1428
1427
 
1429
- # Try to acquire global lock for this task
1430
- lock_acquired = await self.storage.try_acquire_system_lock(
1431
- lock_name="cleanup_old_messages",
1432
- worker_id=self.worker_id,
1433
- timeout_seconds=interval,
1434
- )
1435
-
1436
- if not lock_acquired:
1437
- # Another pod is handling this task
1438
- continue
1439
-
1440
- try:
1441
- deleted_count = await self.storage.cleanup_old_channel_messages(retention_days)
1442
- if deleted_count > 0:
1443
- logger.info("Cleaned up %d old channel messages", deleted_count)
1444
- finally:
1445
- await self.storage.release_system_lock("cleanup_old_messages", self.worker_id)
1428
+ deleted_count = await self.storage.cleanup_old_channel_messages(retention_days)
1429
+ if deleted_count > 0:
1430
+ logger.info("Cleaned up %d old channel messages", deleted_count)
1446
1431
  except Exception as e:
1447
1432
  logger.error("Error cleaning up old messages: %s", e, exc_info=True)
1448
1433
 
@@ -0,0 +1,58 @@
1
+ """
2
+ Durable Graph Integration for Edda.
3
+
4
+ This module provides integration between pydantic-graph and Edda's durable
5
+ execution framework, making pydantic-graph execution crash-recoverable and
6
+ supporting durable wait operations.
7
+
8
+ Example:
9
+ from dataclasses import dataclass
10
+ from pydantic_graph import BaseNode, Graph, End
11
+ from edda import workflow, WorkflowContext
12
+ from edda.integrations.graph import DurableGraph, DurableGraphContext
13
+
14
+ @dataclass
15
+ class MyState:
16
+ counter: int = 0
17
+
18
+ @dataclass
19
+ class IncrementNode(BaseNode[MyState, None, int]):
20
+ async def run(self, ctx: DurableGraphContext) -> "CheckNode":
21
+ ctx.state.counter += 1
22
+ return CheckNode()
23
+
24
+ @dataclass
25
+ class CheckNode(BaseNode[MyState, None, int]):
26
+ async def run(self, ctx: DurableGraphContext) -> IncrementNode | End[int]:
27
+ if ctx.state.counter >= 5:
28
+ return End(ctx.state.counter)
29
+ return IncrementNode()
30
+
31
+ graph = Graph(nodes=[IncrementNode, CheckNode])
32
+ durable = DurableGraph(graph)
33
+
34
+ @workflow
35
+ async def counter_workflow(ctx: WorkflowContext) -> int:
36
+ return await durable.run(
37
+ ctx,
38
+ start_node=IncrementNode(),
39
+ state=MyState(),
40
+ )
41
+
42
+ Installation:
43
+ pip install 'edda-framework[graph]'
44
+ """
45
+
46
+ from .context import DurableGraphContext
47
+ from .exceptions import GraphExecutionError
48
+ from .graph import DurableGraph
49
+ from .nodes import ReceivedEvent, Sleep, WaitForEvent
50
+
51
+ __all__ = [
52
+ "DurableGraph",
53
+ "DurableGraphContext",
54
+ "GraphExecutionError",
55
+ "ReceivedEvent",
56
+ "Sleep",
57
+ "WaitForEvent",
58
+ ]
@@ -0,0 +1,81 @@
1
+ """DurableGraphContext - bridges pydantic-graph and Edda contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Generic, TypeVar
7
+
8
+ if TYPE_CHECKING:
9
+ from edda.context import WorkflowContext
10
+
11
+ from .nodes import ReceivedEvent
12
+
13
+ StateT = TypeVar("StateT")
14
+ DepsT = TypeVar("DepsT")
15
+
16
+
17
+ @dataclass
18
+ class DurableGraphContext(Generic[StateT, DepsT]):
19
+ """
20
+ Context that bridges pydantic-graph and Edda.
21
+
22
+ Provides access to:
23
+ - pydantic-graph's state and deps via properties
24
+ - last_event: The most recent event received via WaitForEvent
25
+
26
+ This context is passed to node's run() method when executing
27
+ via DurableGraph.
28
+
29
+ For durable wait operations (wait_event, sleep), use the WaitForEvent
30
+ and Sleep marker nodes instead of calling methods directly:
31
+
32
+ from edda.integrations.graph import WaitForEvent, Sleep
33
+
34
+ @dataclass
35
+ class MyNode(BaseNode[MyState, None, str]):
36
+ async def run(self, ctx: DurableGraphContext) -> WaitForEvent[NextNode]:
37
+ # Return a marker to wait for an event
38
+ return WaitForEvent(
39
+ event_type="payment.completed",
40
+ next_node=NextNode(),
41
+ timeout_seconds=3600,
42
+ )
43
+
44
+ @dataclass
45
+ class NextNode(BaseNode[MyState, None, str]):
46
+ async def run(self, ctx: DurableGraphContext) -> End[str]:
47
+ # Access the received event
48
+ event = ctx.last_event
49
+ return End(event.data.get("status", "unknown"))
50
+
51
+ Attributes:
52
+ state: The graph state object (mutable, shared across nodes)
53
+ deps: The dependencies object (immutable)
54
+ last_event: The most recent event received via WaitForEvent (or None)
55
+ workflow_ctx: The Edda WorkflowContext
56
+ """
57
+
58
+ _state: StateT
59
+ _deps: DepsT
60
+ workflow_ctx: WorkflowContext
61
+ last_event: ReceivedEvent | None = field(default=None)
62
+
63
+ @property
64
+ def state(self) -> StateT:
65
+ """Get the graph state object."""
66
+ return self._state
67
+
68
+ @property
69
+ def deps(self) -> DepsT:
70
+ """Get the dependencies object."""
71
+ return self._deps
72
+
73
+ @property
74
+ def instance_id(self) -> str:
75
+ """Get the workflow instance ID."""
76
+ return self.workflow_ctx.instance_id
77
+
78
+ @property
79
+ def is_replaying(self) -> bool:
80
+ """Check if the workflow is currently replaying."""
81
+ return self.workflow_ctx.is_replaying
@@ -0,0 +1,9 @@
1
+ """Exceptions for durable graph integration."""
2
+
3
+
4
+ class GraphExecutionError(Exception):
5
+ """Raised when a graph node execution fails."""
6
+
7
+ def __init__(self, message: str, node_name: str | None = None) -> None:
8
+ self.node_name = node_name
9
+ super().__init__(message)
@@ -0,0 +1,385 @@
1
+ """DurableGraph - makes pydantic-graph execution durable via Edda."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import importlib
7
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
8
+
9
+ from edda.activity import activity
10
+ from edda.pydantic_utils import to_json_dict
11
+
12
+ from .context import DurableGraphContext
13
+ from .exceptions import GraphExecutionError
14
+ from .nodes import ReceivedEvent, Sleep, WaitForEvent
15
+
16
+ if TYPE_CHECKING:
17
+ from edda.context import WorkflowContext
18
+
19
+ StateT = TypeVar("StateT")
20
+ DepsT = TypeVar("DepsT")
21
+ RunEndT = TypeVar("RunEndT")
22
+
23
+
24
+ def _import_pydantic_graph() -> Any:
25
+ """Import pydantic_graph with helpful error message."""
26
+ try:
27
+ import pydantic_graph
28
+
29
+ return pydantic_graph
30
+ except ImportError as e:
31
+ msg = (
32
+ "pydantic-graph is not installed. Install with:\n"
33
+ " pip install pydantic-graph\n"
34
+ "or\n"
35
+ " pip install 'edda-framework[graph]'"
36
+ )
37
+ raise ImportError(msg) from e
38
+
39
+
40
+ def _get_class_path(cls: type) -> str:
41
+ """Get fully qualified class path for serialization."""
42
+ return f"{cls.__module__}:{cls.__qualname__}"
43
+
44
+
45
+ def _import_class(path: str) -> type:
46
+ """Import a class from its fully qualified path."""
47
+ module_path, class_name = path.rsplit(":", 1)
48
+ module = importlib.import_module(module_path)
49
+ return getattr(module, class_name) # type: ignore[no-any-return]
50
+
51
+
52
+ def _serialize_node(node: Any) -> dict[str, Any]:
53
+ """Serialize a node to a dict."""
54
+ if dataclasses.is_dataclass(node) and not isinstance(node, type):
55
+ return {
56
+ "_class_path": _get_class_path(node.__class__),
57
+ "_data": dataclasses.asdict(node),
58
+ }
59
+ return {
60
+ "_class_path": _get_class_path(node.__class__),
61
+ "_data": {},
62
+ }
63
+
64
+
65
+ def _deserialize_node(data: dict[str, Any]) -> Any:
66
+ """Deserialize a node from a dict."""
67
+ cls = _import_class(data["_class_path"])
68
+ return cls(**data.get("_data", {}))
69
+
70
+
71
+ def _serialize_state(state: Any) -> dict[str, Any]:
72
+ """Serialize state to a dict."""
73
+ if state is None:
74
+ return {"_none": True}
75
+ if dataclasses.is_dataclass(state) and not isinstance(state, type):
76
+ return {
77
+ "_class_path": _get_class_path(state.__class__),
78
+ "_data": dataclasses.asdict(state),
79
+ }
80
+ if hasattr(state, "model_dump"):
81
+ return {
82
+ "_class_path": _get_class_path(state.__class__),
83
+ "_data": state.model_dump(),
84
+ }
85
+ return {"_raw": str(state)}
86
+
87
+
88
+ def _serialize_deps(deps: Any) -> dict[str, Any] | None:
89
+ """Serialize deps to a dict."""
90
+ if deps is None:
91
+ return None
92
+ if dataclasses.is_dataclass(deps) and not isinstance(deps, type):
93
+ return {
94
+ "_class_path": _get_class_path(deps.__class__),
95
+ "_data": dataclasses.asdict(deps),
96
+ }
97
+ if hasattr(deps, "model_dump"):
98
+ return {
99
+ "_class_path": _get_class_path(deps.__class__),
100
+ "_data": deps.model_dump(),
101
+ }
102
+ # For simple types (int, str, etc.), return as-is wrapped
103
+ return {"_value": deps}
104
+
105
+
106
+ def _deserialize_deps(data: dict[str, Any] | None) -> Any:
107
+ """Deserialize deps from a dict."""
108
+ if data is None:
109
+ return None
110
+ if "_value" in data:
111
+ return data["_value"]
112
+ cls = _import_class(data["_class_path"])
113
+ if dataclasses.is_dataclass(cls):
114
+ return cls(**data["_data"])
115
+ if hasattr(cls, "model_validate"):
116
+ return cls.model_validate(data["_data"])
117
+ return cls(**data["_data"])
118
+
119
+
120
+ def _deserialize_state(data: dict[str, Any]) -> Any:
121
+ """Deserialize state from a dict."""
122
+ if data.get("_none"):
123
+ return None
124
+ if "_raw" in data:
125
+ raise ValueError(f"Cannot deserialize state from raw: {data['_raw']}")
126
+ cls = _import_class(data["_class_path"])
127
+ if dataclasses.is_dataclass(cls):
128
+ return cls(**data["_data"])
129
+ if hasattr(cls, "model_validate"):
130
+ return cls.model_validate(data["_data"])
131
+ return cls(**data["_data"])
132
+
133
+
134
+ def _restore_state(source: Any, target: Any) -> None:
135
+ """Copy state from source to target object."""
136
+ if dataclasses.is_dataclass(source) and not isinstance(source, type):
137
+ for field in dataclasses.fields(source):
138
+ setattr(target, field.name, getattr(source, field.name))
139
+ elif hasattr(source, "__dict__"):
140
+ for key, value in source.__dict__.items():
141
+ if not key.startswith("_"):
142
+ setattr(target, key, value)
143
+
144
+
145
+ @activity
146
+ async def _run_graph_node(
147
+ ctx: WorkflowContext,
148
+ node_data: dict[str, Any],
149
+ state_data: dict[str, Any],
150
+ deps_data: dict[str, Any] | None,
151
+ last_event_data: dict[str, Any] | None = None,
152
+ ) -> dict[str, Any]:
153
+ """
154
+ Execute a single graph node as a durable activity.
155
+
156
+ This activity is the core of DurableGraph - it runs one node and returns
157
+ the serialized result (next node, End, WaitForEvent, or Sleep) along
158
+ with the updated state.
159
+ """
160
+ pg = _import_pydantic_graph()
161
+
162
+ # Deserialize node, state, and deps
163
+ node = _deserialize_node(node_data)
164
+ state = _deserialize_state(state_data)
165
+ deps = _deserialize_deps(deps_data)
166
+
167
+ # Reconstruct last_event if provided
168
+ last_event: ReceivedEvent | None = None
169
+ if last_event_data:
170
+ last_event = ReceivedEvent(
171
+ event_type=last_event_data.get("event_type", ""),
172
+ data=last_event_data.get("data", {}),
173
+ metadata=last_event_data.get("metadata", {}),
174
+ )
175
+
176
+ # Create durable context
177
+ durable_ctx = DurableGraphContext(
178
+ _state=state,
179
+ _deps=deps,
180
+ workflow_ctx=ctx,
181
+ last_event=last_event,
182
+ )
183
+
184
+ try:
185
+ # Execute the node
186
+ result = await node.run(durable_ctx)
187
+
188
+ # Serialize result based on type
189
+ if isinstance(result, pg.End):
190
+ return {
191
+ "_type": "End",
192
+ "_data": to_json_dict(result.data),
193
+ "_state": _serialize_state(state),
194
+ }
195
+ elif isinstance(result, WaitForEvent):
196
+ return {
197
+ "_type": "WaitForEvent",
198
+ "_event_type": result.event_type,
199
+ "_timeout_seconds": result.timeout_seconds,
200
+ "_next_node": _serialize_node(result.next_node),
201
+ "_state": _serialize_state(state),
202
+ }
203
+ elif isinstance(result, Sleep):
204
+ return {
205
+ "_type": "Sleep",
206
+ "_seconds": result.seconds,
207
+ "_next_node": _serialize_node(result.next_node),
208
+ "_state": _serialize_state(state),
209
+ }
210
+ else:
211
+ # Regular node transition
212
+ return {
213
+ "_type": "Node",
214
+ "_node": _serialize_node(result),
215
+ "_state": _serialize_state(state),
216
+ }
217
+
218
+ except Exception as e:
219
+ raise GraphExecutionError(
220
+ f"Node {node.__class__.__name__} failed: {e}",
221
+ node.__class__.__name__,
222
+ ) from e
223
+
224
+
225
+ class DurableGraph(Generic[StateT, DepsT, RunEndT]):
226
+ """
227
+ Wrapper that makes pydantic-graph execution durable.
228
+
229
+ DurableGraph wraps a pydantic-graph Graph and executes it with Edda's
230
+ durability guarantees:
231
+
232
+ - Each node execution is recorded as an Edda Activity
233
+ - On replay, completed nodes return cached results (no re-execution)
234
+ - Crash recovery: workflows resume from the last completed node
235
+ - WaitForEvent/Sleep markers enable durable wait operations
236
+
237
+ Example:
238
+ from dataclasses import dataclass
239
+ from pydantic_graph import BaseNode, Graph, End
240
+ from edda import workflow, WorkflowContext
241
+ from edda.integrations.graph import (
242
+ DurableGraph,
243
+ DurableGraphContext,
244
+ WaitForEvent,
245
+ )
246
+
247
+ @dataclass
248
+ class OrderState:
249
+ order_id: str | None = None
250
+
251
+ @dataclass
252
+ class ProcessOrder(BaseNode[OrderState, None, str]):
253
+ order_id: str
254
+
255
+ async def run(self, ctx: DurableGraphContext) -> WaitForEvent[WaitPayment]:
256
+ ctx.state.order_id = self.order_id
257
+ return WaitForEvent(
258
+ event_type="payment.completed",
259
+ next_node=WaitPayment(),
260
+ )
261
+
262
+ @dataclass
263
+ class WaitPayment(BaseNode[OrderState, None, str]):
264
+ async def run(self, ctx: DurableGraphContext) -> End[str]:
265
+ # Access the received event
266
+ event = ctx.last_event
267
+ if event and event.data.get("status") == "success":
268
+ return End("completed")
269
+ return End("failed")
270
+
271
+ graph = Graph(nodes=[ProcessOrder, WaitPayment])
272
+ durable = DurableGraph(graph)
273
+
274
+ @workflow
275
+ async def order_workflow(ctx: WorkflowContext, order_id: str) -> str:
276
+ return await durable.run(
277
+ ctx,
278
+ start_node=ProcessOrder(order_id=order_id),
279
+ state=OrderState(),
280
+ )
281
+ """
282
+
283
+ def __init__(self, graph: Any) -> None:
284
+ """
285
+ Initialize DurableGraph wrapper.
286
+
287
+ Args:
288
+ graph: A pydantic-graph Graph instance
289
+
290
+ Raises:
291
+ TypeError: If graph is not a pydantic-graph Graph instance
292
+ """
293
+ pg = _import_pydantic_graph()
294
+ if not isinstance(graph, pg.Graph):
295
+ raise TypeError(f"Expected pydantic_graph.Graph, got {type(graph).__name__}")
296
+ self._graph = graph
297
+
298
+ @property
299
+ def graph(self) -> Any:
300
+ """Get the underlying pydantic-graph Graph instance."""
301
+ return self._graph
302
+
303
+ async def run(
304
+ self,
305
+ ctx: WorkflowContext,
306
+ start_node: Any,
307
+ *,
308
+ state: StateT,
309
+ deps: DepsT = None, # type: ignore[assignment]
310
+ ) -> RunEndT:
311
+ """
312
+ Execute the graph durably with Edda crash recovery.
313
+
314
+ Args:
315
+ ctx: Edda WorkflowContext
316
+ start_node: The initial node to start execution from
317
+ state: Initial graph state (will be mutated during execution)
318
+ deps: Optional dependencies accessible via ctx.deps
319
+
320
+ Returns:
321
+ The final result (End.data value)
322
+
323
+ Raises:
324
+ GraphExecutionError: If graph execution fails
325
+ """
326
+ from edda.channels import sleep as edda_sleep
327
+ from edda.channels import wait_event as edda_wait_event
328
+
329
+ current_node = start_node
330
+ last_event_data: dict[str, Any] | None = None
331
+
332
+ # Execute nodes until End is reached
333
+ while True:
334
+ # Serialize inputs
335
+ node_data = _serialize_node(current_node)
336
+ state_data = _serialize_state(state)
337
+ deps_data = _serialize_deps(deps)
338
+
339
+ # Run node as activity (handles replay/caching automatically)
340
+ # The @activity decorator transforms the function signature
341
+ result = await _run_graph_node( # type: ignore[misc,call-arg]
342
+ ctx, # type: ignore[arg-type]
343
+ node_data,
344
+ state_data,
345
+ deps_data,
346
+ last_event_data,
347
+ )
348
+
349
+ # Restore state from result
350
+ restored_state = _deserialize_state(result["_state"])
351
+ _restore_state(restored_state, state)
352
+
353
+ # Handle result based on type
354
+ if result["_type"] == "End":
355
+ return result["_data"] # type: ignore[no-any-return]
356
+
357
+ elif result["_type"] == "WaitForEvent":
358
+ # Wait for event at workflow level (outside activity)
359
+ event = await edda_wait_event(
360
+ ctx,
361
+ result["_event_type"],
362
+ timeout_seconds=result.get("_timeout_seconds"),
363
+ )
364
+ # Store event data for next node
365
+ # Note: edda.channels.ReceivedEvent uses 'type' not 'event_type'
366
+ last_event_data = {
367
+ "event_type": getattr(event, "type", result["_event_type"]),
368
+ "data": event.data if isinstance(event.data, dict) else {},
369
+ "metadata": getattr(event, "extensions", {}),
370
+ }
371
+ # Move to next node
372
+ current_node = _deserialize_node(result["_next_node"])
373
+
374
+ elif result["_type"] == "Sleep":
375
+ # Sleep at workflow level (outside activity)
376
+ await edda_sleep(ctx, result["_seconds"])
377
+ # Clear last_event since this wasn't an event wait
378
+ last_event_data = None
379
+ # Move to next node
380
+ current_node = _deserialize_node(result["_next_node"])
381
+
382
+ else:
383
+ # Regular node transition
384
+ last_event_data = None # Clear last_event for regular transitions
385
+ current_node = _deserialize_node(result["_node"])