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 +6 -21
- edda/integrations/graph/__init__.py +58 -0
- edda/integrations/graph/context.py +81 -0
- edda/integrations/graph/exceptions.py +9 -0
- edda/integrations/graph/graph.py +385 -0
- edda/integrations/graph/nodes.py +144 -0
- edda/integrations/llamaindex/__init__.py +51 -0
- edda/integrations/llamaindex/events.py +160 -0
- edda/integrations/llamaindex/exceptions.py +15 -0
- edda/integrations/llamaindex/workflow.py +306 -0
- edda/locking.py +12 -37
- {edda_framework-0.14.0.dist-info → edda_framework-0.15.0.dist-info}/METADATA +13 -1
- {edda_framework-0.14.0.dist-info → edda_framework-0.15.0.dist-info}/RECORD +16 -7
- {edda_framework-0.14.0.dist-info → edda_framework-0.15.0.dist-info}/WHEEL +0 -0
- {edda_framework-0.14.0.dist-info → edda_framework-0.15.0.dist-info}/entry_points.txt +0 -0
- {edda_framework-0.14.0.dist-info → edda_framework-0.15.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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"])
|