edda-framework 0.6.0__py3-none-any.whl → 0.8.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 +39 -5
- edda/app.py +383 -223
- edda/channels.py +992 -0
- edda/compensation.py +22 -22
- edda/context.py +77 -51
- edda/integrations/opentelemetry/hooks.py +7 -2
- edda/locking.py +130 -67
- edda/replay.py +312 -82
- edda/storage/models.py +165 -24
- edda/storage/protocol.py +575 -122
- edda/storage/sqlalchemy_storage.py +2073 -319
- edda/viewer_ui/app.py +558 -127
- edda/viewer_ui/components.py +81 -68
- edda/viewer_ui/data_service.py +61 -25
- edda/viewer_ui/theme.py +200 -0
- edda/workflow.py +43 -0
- {edda_framework-0.6.0.dist-info → edda_framework-0.8.0.dist-info}/METADATA +167 -9
- {edda_framework-0.6.0.dist-info → edda_framework-0.8.0.dist-info}/RECORD +21 -20
- {edda_framework-0.6.0.dist-info → edda_framework-0.8.0.dist-info}/WHEEL +1 -1
- edda/events.py +0 -505
- {edda_framework-0.6.0.dist-info → edda_framework-0.8.0.dist-info}/entry_points.txt +0 -0
- {edda_framework-0.6.0.dist-info → edda_framework-0.8.0.dist-info}/licenses/LICENSE +0 -0
edda/viewer_ui/theme.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Theme configuration for Edda Workflow Viewer.
|
|
3
|
+
|
|
4
|
+
Provides centralized color palette definitions and helper functions
|
|
5
|
+
for light and dark mode support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
# Tailwind CSS compatible color palette
|
|
11
|
+
COLORS: dict[str, dict[str, Any]] = {
|
|
12
|
+
"light": {
|
|
13
|
+
# Background colors
|
|
14
|
+
"bg_page": "#FFFFFF",
|
|
15
|
+
"bg_surface": "#F8FAFC", # Slate 50
|
|
16
|
+
"bg_elevated": "#FFFFFF",
|
|
17
|
+
"border": "#E2E8F0", # Slate 200
|
|
18
|
+
# Text colors
|
|
19
|
+
"text_primary": "#0F172A", # Slate 900
|
|
20
|
+
"text_secondary": "#64748B", # Slate 500
|
|
21
|
+
"text_muted": "#94A3B8", # Slate 400
|
|
22
|
+
# Status colors (bg, stroke, text)
|
|
23
|
+
"completed": {"bg": "#ECFDF5", "stroke": "#10B981", "text": "#065F46"},
|
|
24
|
+
"running": {"bg": "#FEF3C7", "stroke": "#F59E0B", "text": "#92400E"},
|
|
25
|
+
"failed": {"bg": "#FEE2E2", "stroke": "#EF4444", "text": "#991B1B"},
|
|
26
|
+
"waiting_event": {"bg": "#DBEAFE", "stroke": "#3B82F6", "text": "#1E40AF"},
|
|
27
|
+
"waiting_timer": {"bg": "#E0F2FE", "stroke": "#0EA5E9", "text": "#075985"},
|
|
28
|
+
"cancelled": {"bg": "#FFEDD5", "stroke": "#F97316", "text": "#9A3412"},
|
|
29
|
+
"compensating": {"bg": "#F3E8FF", "stroke": "#A855F7", "text": "#6B21A8"},
|
|
30
|
+
"not_executed": {"bg": "#F1F5F9", "stroke": "#CBD5E1", "text": "#64748B"},
|
|
31
|
+
"event_received": {"bg": "#CFFAFE", "stroke": "#06B6D4", "text": "#0E7490"},
|
|
32
|
+
"compensation_failed": {"bg": "#FEE2E2", "stroke": "#B91C1C", "text": "#7F1D1D"},
|
|
33
|
+
# Mermaid diagram specific
|
|
34
|
+
"condition": {"bg": "#FEF3C7", "stroke": "#F59E0B"},
|
|
35
|
+
"loop": {"bg": "#FDF4FF", "stroke": "#D946EF"},
|
|
36
|
+
"match": {"bg": "#ECFDF5", "stroke": "#10B981"},
|
|
37
|
+
"merge": {"bg": "#FFFFFF", "stroke": "#E2E8F0"},
|
|
38
|
+
"edge_executed": "#10B981",
|
|
39
|
+
"edge_not_executed": "#CBD5E1",
|
|
40
|
+
"edge_compensation": "#A855F7",
|
|
41
|
+
},
|
|
42
|
+
"dark": {
|
|
43
|
+
# Background colors
|
|
44
|
+
"bg_page": "#0F172A", # Slate 900
|
|
45
|
+
"bg_surface": "#1E293B", # Slate 800
|
|
46
|
+
"bg_elevated": "#334155", # Slate 700
|
|
47
|
+
"border": "#475569", # Slate 600
|
|
48
|
+
# Text colors
|
|
49
|
+
"text_primary": "#F1F5F9", # Slate 100
|
|
50
|
+
"text_secondary": "#94A3B8", # Slate 400
|
|
51
|
+
"text_muted": "#64748B", # Slate 500
|
|
52
|
+
# Status colors (bg, stroke, text)
|
|
53
|
+
"completed": {"bg": "#064E3B", "stroke": "#34D399", "text": "#A7F3D0"},
|
|
54
|
+
"running": {"bg": "#78350F", "stroke": "#FBBF24", "text": "#FDE68A"},
|
|
55
|
+
"failed": {"bg": "#7F1D1D", "stroke": "#F87171", "text": "#FECACA"},
|
|
56
|
+
"waiting_event": {"bg": "#1E3A8A", "stroke": "#60A5FA", "text": "#BFDBFE"},
|
|
57
|
+
"waiting_timer": {"bg": "#0C4A6E", "stroke": "#38BDF8", "text": "#BAE6FD"},
|
|
58
|
+
"cancelled": {"bg": "#7C2D12", "stroke": "#FB923C", "text": "#FED7AA"},
|
|
59
|
+
"compensating": {"bg": "#581C87", "stroke": "#C084FC", "text": "#E9D5FF"},
|
|
60
|
+
"not_executed": {"bg": "#334155", "stroke": "#64748B", "text": "#94A3B8"},
|
|
61
|
+
"event_received": {"bg": "#164E63", "stroke": "#22D3EE", "text": "#A5F3FC"},
|
|
62
|
+
"compensation_failed": {"bg": "#7F1D1D", "stroke": "#FCA5A5", "text": "#FECACA"},
|
|
63
|
+
# Mermaid diagram specific
|
|
64
|
+
"condition": {"bg": "#78350F", "stroke": "#FBBF24"},
|
|
65
|
+
"loop": {"bg": "#4C1D95", "stroke": "#E879F9"},
|
|
66
|
+
"match": {"bg": "#064E3B", "stroke": "#34D399"},
|
|
67
|
+
"merge": {"bg": "#1E293B", "stroke": "#475569"},
|
|
68
|
+
"edge_executed": "#34D399",
|
|
69
|
+
"edge_not_executed": "#64748B",
|
|
70
|
+
"edge_compensation": "#C084FC",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_status_color(status: str, is_dark: bool) -> dict[str, str]:
|
|
76
|
+
"""
|
|
77
|
+
Get color configuration for a workflow status.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
status: Status name (e.g., "completed", "running", "failed")
|
|
81
|
+
is_dark: Whether dark mode is enabled
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Dictionary with 'bg', 'stroke', and 'text' colors
|
|
85
|
+
"""
|
|
86
|
+
theme = "dark" if is_dark else "light"
|
|
87
|
+
status_key = status.lower().replace(" ", "_").replace("-", "_")
|
|
88
|
+
|
|
89
|
+
# Handle special status mappings
|
|
90
|
+
status_mapping = {
|
|
91
|
+
"waiting": "waiting_event",
|
|
92
|
+
"waiting_for_event": "waiting_event",
|
|
93
|
+
"waiting_for_timer": "waiting_timer",
|
|
94
|
+
"compensated": "compensating",
|
|
95
|
+
}
|
|
96
|
+
status_key = status_mapping.get(status_key, status_key)
|
|
97
|
+
|
|
98
|
+
return cast(dict[str, str], COLORS[theme].get(status_key, COLORS[theme]["not_executed"]))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_mermaid_style(status: str, is_dark: bool) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Get Mermaid style string for a status.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
status: Status name
|
|
107
|
+
is_dark: Whether dark mode is enabled
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Mermaid style string (e.g., "fill:#ECFDF5,stroke:#10B981,stroke-width:2px")
|
|
111
|
+
"""
|
|
112
|
+
colors = get_status_color(status, is_dark)
|
|
113
|
+
return f"fill:{colors['bg']},stroke:{colors['stroke']},stroke-width:2px"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_mermaid_node_style(node_type: str, is_dark: bool) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Get Mermaid style for structural nodes (condition, loop, merge).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
node_type: Node type (e.g., "condition", "loop", "merge")
|
|
122
|
+
is_dark: Whether dark mode is enabled
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Mermaid style string
|
|
126
|
+
"""
|
|
127
|
+
theme = "dark" if is_dark else "light"
|
|
128
|
+
colors = COLORS[theme].get(node_type, COLORS[theme]["merge"])
|
|
129
|
+
stroke_width = "1px" if node_type == "merge" else "2px"
|
|
130
|
+
return f"fill:{colors['bg']},stroke:{colors['stroke']},stroke-width:{stroke_width}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_edge_color(edge_type: str, is_dark: bool) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Get edge (arrow) color for Mermaid diagrams.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
edge_type: Edge type ("executed", "not_executed", "compensation")
|
|
139
|
+
is_dark: Whether dark mode is enabled
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Color hex code
|
|
143
|
+
"""
|
|
144
|
+
theme = "dark" if is_dark else "light"
|
|
145
|
+
key = f"edge_{edge_type}"
|
|
146
|
+
return cast(str, COLORS[theme].get(key, COLORS[theme]["edge_not_executed"]))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Tailwind CSS class mappings for UI components
|
|
150
|
+
# Note: Background colors are controlled via CSS in app.py for proper dark mode support
|
|
151
|
+
# NiceGUI adds 'dark' class to body, but Tailwind's dark: prefix expects it on html
|
|
152
|
+
TAILWIND_CLASSES = {
|
|
153
|
+
"card": "border", # Background handled by CSS
|
|
154
|
+
"card_hover": "", # Hover handled by CSS
|
|
155
|
+
"surface": "", # Background handled by CSS
|
|
156
|
+
"text_primary": "text-slate-900 dark:text-slate-100",
|
|
157
|
+
"text_secondary": "text-slate-500 dark:text-slate-400",
|
|
158
|
+
"text_muted": "text-slate-400 dark:text-slate-500",
|
|
159
|
+
"border": "border-slate-200 dark:border-slate-700",
|
|
160
|
+
"input": "border-slate-300 dark:border-slate-600", # Background handled by CSS
|
|
161
|
+
"code_block": "border", # Background handled by CSS
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Status badge Tailwind classes
|
|
165
|
+
STATUS_BADGE_CLASSES = {
|
|
166
|
+
"completed": ("bg-emerald-50 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"),
|
|
167
|
+
"running": "bg-amber-50 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
|
168
|
+
"failed": "bg-red-50 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
|
169
|
+
"waiting_event": "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
|
170
|
+
"waiting_timer": "bg-sky-50 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300",
|
|
171
|
+
"cancelled": ("bg-orange-50 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"),
|
|
172
|
+
"compensating": ("bg-purple-50 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"),
|
|
173
|
+
"not_executed": ("bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400"),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_status_badge_classes(status: str) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Get Tailwind CSS classes for a status badge.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
status: Status name
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Tailwind CSS class string
|
|
186
|
+
"""
|
|
187
|
+
status_key = status.lower().replace(" ", "_").replace("-", "_")
|
|
188
|
+
|
|
189
|
+
# Handle special status mappings
|
|
190
|
+
status_mapping = {
|
|
191
|
+
"waiting": "waiting_event",
|
|
192
|
+
"waiting_for_event": "waiting_event",
|
|
193
|
+
"waiting_for_timer": "waiting_timer",
|
|
194
|
+
"compensated": "compensating",
|
|
195
|
+
}
|
|
196
|
+
status_key = status_mapping.get(status_key, status_key)
|
|
197
|
+
|
|
198
|
+
base_classes = "px-3 py-1 rounded-full text-sm font-medium"
|
|
199
|
+
status_classes = STATUS_BADGE_CLASSES.get(status_key, STATUS_BADGE_CLASSES["not_executed"])
|
|
200
|
+
return f"{base_classes} {status_classes}"
|
edda/workflow.py
CHANGED
|
@@ -14,6 +14,49 @@ from edda.pydantic_utils import to_json_dict
|
|
|
14
14
|
|
|
15
15
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
class RecurException(Exception):
|
|
19
|
+
"""
|
|
20
|
+
Exception raised to signal that a workflow should recur (restart with fresh history).
|
|
21
|
+
|
|
22
|
+
This is similar to Erlang's tail recursion pattern - it prevents unbounded history
|
|
23
|
+
growth in long-running loops by completing the current workflow instance and
|
|
24
|
+
starting a new one with the provided arguments.
|
|
25
|
+
|
|
26
|
+
The workflow's history is archived (not deleted) and a new instance is created
|
|
27
|
+
with a reference to the previous instance (continued_from).
|
|
28
|
+
|
|
29
|
+
Note:
|
|
30
|
+
This exception should not be caught by user code. It is handled internally
|
|
31
|
+
by the ReplayEngine.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> @workflow
|
|
35
|
+
... async def notification_service(ctx: WorkflowContext, processed_count: int = 0):
|
|
36
|
+
... await join_group(ctx, group="order_watchers")
|
|
37
|
+
...
|
|
38
|
+
... count = 0
|
|
39
|
+
... while True:
|
|
40
|
+
... msg = await wait_message(ctx, channel="order.completed")
|
|
41
|
+
... await send_notification(ctx, msg.data, activity_id=f"notify:{msg.id}")
|
|
42
|
+
...
|
|
43
|
+
... count += 1
|
|
44
|
+
... if count >= 1000:
|
|
45
|
+
... # Reset history by recurring with new state
|
|
46
|
+
... await ctx.recur(processed_count=processed_count + count)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, kwargs: dict[str, Any]):
|
|
50
|
+
"""
|
|
51
|
+
Initialize RecurException.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
kwargs: Keyword arguments to pass to the new workflow instance
|
|
55
|
+
"""
|
|
56
|
+
self.kwargs = kwargs
|
|
57
|
+
super().__init__("Workflow recur requested")
|
|
58
|
+
|
|
59
|
+
|
|
17
60
|
# Global registry of workflow instances (will be set by EddaApp)
|
|
18
61
|
_replay_engine: Any = None
|
|
19
62
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edda-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Lightweight Durable Execution Framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2y/edda
|
|
6
6
|
Project-URL: Documentation, https://github.com/i2y/edda#readme
|
|
@@ -30,11 +30,13 @@ Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
|
30
30
|
Requires-Dist: uvloop>=0.22.1
|
|
31
31
|
Provides-Extra: dev
|
|
32
32
|
Requires-Dist: black>=25.9.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: mcp>=1.22.0; extra == 'dev'
|
|
33
34
|
Requires-Dist: mypy>=1.18.2; extra == 'dev'
|
|
34
35
|
Requires-Dist: pytest-asyncio>=1.2.0; extra == 'dev'
|
|
35
36
|
Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
|
|
36
37
|
Requires-Dist: pytest>=8.4.2; extra == 'dev'
|
|
37
38
|
Requires-Dist: ruff>=0.14.2; extra == 'dev'
|
|
39
|
+
Requires-Dist: starlette>=0.40.0; extra == 'dev'
|
|
38
40
|
Requires-Dist: testcontainers[mysql]>=4.0.0; extra == 'dev'
|
|
39
41
|
Requires-Dist: testcontainers[postgres]>=4.0.0; extra == 'dev'
|
|
40
42
|
Requires-Dist: tsuno>=0.1.3; extra == 'dev'
|
|
@@ -83,6 +85,7 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
|
|
|
83
85
|
- 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
|
|
84
86
|
- ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
|
|
85
87
|
- ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
|
|
88
|
+
- 📬 **Channel-based Messaging**: Actor-model style communication with competing (job queue) and broadcast (fan-out) modes
|
|
86
89
|
- 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
|
|
87
90
|
- 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
|
|
88
91
|
|
|
@@ -105,14 +108,14 @@ Edda's waiting functions make it ideal for time-based and event-driven business
|
|
|
105
108
|
- **📦 Scheduled Notifications**: Shipping updates, subscription renewals, appointment reminders
|
|
106
109
|
|
|
107
110
|
**Waiting functions**:
|
|
108
|
-
- `
|
|
109
|
-
- `
|
|
111
|
+
- `sleep(seconds)`: Wait for a relative duration
|
|
112
|
+
- `sleep_until(target_time)`: Wait until an absolute datetime (e.g., campaign end date)
|
|
110
113
|
- `wait_event(event_type)`: Wait for external events (near real-time response)
|
|
111
114
|
|
|
112
115
|
```python
|
|
113
116
|
@workflow
|
|
114
117
|
async def onboarding_reminder(ctx: WorkflowContext, user_id: str):
|
|
115
|
-
await
|
|
118
|
+
await sleep(ctx, seconds=3*24*60*60) # Wait 3 days
|
|
116
119
|
if not await check_completed(ctx, user_id):
|
|
117
120
|
await send_reminder(ctx, user_id)
|
|
118
121
|
```
|
|
@@ -164,7 +167,7 @@ graph TB
|
|
|
164
167
|
|
|
165
168
|
- Multiple workers can run simultaneously across different pods/servers
|
|
166
169
|
- Each workflow instance runs on only one worker at a time (automatic coordination)
|
|
167
|
-
- `wait_event()` and `
|
|
170
|
+
- `wait_event()` and `sleep()` free up worker resources while waiting, resume on any worker when event arrives or timer expires
|
|
168
171
|
- Automatic crash recovery with stale lock cleanup and workflow auto-resume
|
|
169
172
|
|
|
170
173
|
## Quick Start
|
|
@@ -484,7 +487,10 @@ Multiple workers can safely process workflows using database-based exclusive con
|
|
|
484
487
|
|
|
485
488
|
app = EddaApp(
|
|
486
489
|
db_url="postgresql://localhost/workflows", # Shared database for coordination
|
|
487
|
-
service_name="order-service"
|
|
490
|
+
service_name="order-service",
|
|
491
|
+
# Connection pool settings (optional)
|
|
492
|
+
pool_size=5, # Concurrent connections
|
|
493
|
+
max_overflow=10, # Additional burst capacity
|
|
488
494
|
)
|
|
489
495
|
```
|
|
490
496
|
|
|
@@ -612,10 +618,32 @@ async def payment_workflow(ctx: WorkflowContext, order_id: str):
|
|
|
612
618
|
return payment_event.data
|
|
613
619
|
```
|
|
614
620
|
|
|
615
|
-
**
|
|
621
|
+
**ReceivedEvent attributes**: The `wait_event()` function returns a `ReceivedEvent` object:
|
|
622
|
+
|
|
623
|
+
```python
|
|
624
|
+
event = await wait_event(ctx, "payment.completed")
|
|
625
|
+
amount = event.data["amount"] # Event payload (dict or bytes)
|
|
626
|
+
source = event.metadata.source # CloudEvents source
|
|
627
|
+
event_type = event.metadata.type # CloudEvents type
|
|
628
|
+
extensions = event.extensions # CloudEvents extensions
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
**Timeout handling with EventTimeoutError**:
|
|
616
632
|
|
|
617
633
|
```python
|
|
618
|
-
from edda import
|
|
634
|
+
from edda import wait_event, EventTimeoutError
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
event = await wait_event(ctx, "payment.completed", timeout_seconds=60)
|
|
638
|
+
except EventTimeoutError:
|
|
639
|
+
# Handle timeout (e.g., cancel order, send reminder)
|
|
640
|
+
await cancel_order(ctx, order_id)
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
**sleep() for time-based waiting**:
|
|
644
|
+
|
|
645
|
+
```python
|
|
646
|
+
from edda import sleep
|
|
619
647
|
|
|
620
648
|
@workflow
|
|
621
649
|
async def order_with_timeout(ctx: WorkflowContext, order_id: str):
|
|
@@ -623,7 +651,7 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
|
|
|
623
651
|
await create_order(ctx, order_id)
|
|
624
652
|
|
|
625
653
|
# Wait 60 seconds for payment
|
|
626
|
-
await
|
|
654
|
+
await sleep(ctx, seconds=60)
|
|
627
655
|
|
|
628
656
|
# Check payment status
|
|
629
657
|
return await check_payment(ctx, order_id)
|
|
@@ -637,6 +665,136 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
|
|
|
637
665
|
|
|
638
666
|
**For technical details**, see [Multi-Worker Continuations](local-docs/distributed-coroutines.md).
|
|
639
667
|
|
|
668
|
+
### Message Passing (Workflow-to-Workflow)
|
|
669
|
+
|
|
670
|
+
Edda provides actor-model style message passing for direct workflow-to-workflow communication:
|
|
671
|
+
|
|
672
|
+
```python
|
|
673
|
+
from edda import workflow, wait_message, send_message_to, WorkflowContext
|
|
674
|
+
|
|
675
|
+
# Receiver workflow - waits for approval message
|
|
676
|
+
@workflow
|
|
677
|
+
async def approval_workflow(ctx: WorkflowContext, request_id: str):
|
|
678
|
+
# Wait for message on "approval" channel
|
|
679
|
+
msg = await wait_message(ctx, channel="approval")
|
|
680
|
+
|
|
681
|
+
if msg.data["approved"]:
|
|
682
|
+
return {"status": "approved", "approver": msg.data["approver"]}
|
|
683
|
+
return {"status": "rejected"}
|
|
684
|
+
|
|
685
|
+
# Sender workflow - sends approval decision
|
|
686
|
+
@workflow
|
|
687
|
+
async def manager_workflow(ctx: WorkflowContext, request_id: str):
|
|
688
|
+
# Review and make decision
|
|
689
|
+
decision = await review_request(ctx, request_id)
|
|
690
|
+
|
|
691
|
+
# Send message to waiting workflow
|
|
692
|
+
await send_message_to(
|
|
693
|
+
ctx,
|
|
694
|
+
target_instance_id=request_id,
|
|
695
|
+
channel="approval",
|
|
696
|
+
data={"approved": decision, "approver": "manager-123"},
|
|
697
|
+
)
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**Group Communication (Erlang pg style)** - for fan-out messaging without knowing receiver instance IDs:
|
|
701
|
+
|
|
702
|
+
```python
|
|
703
|
+
from edda import workflow, join_group, wait_message, publish_to_group
|
|
704
|
+
|
|
705
|
+
# Receiver workflow - joins a group and listens
|
|
706
|
+
@workflow
|
|
707
|
+
async def notification_service(ctx: WorkflowContext, service_id: str):
|
|
708
|
+
# Join group at startup (loose coupling - sender doesn't need to know us)
|
|
709
|
+
await join_group(ctx, group="order_watchers")
|
|
710
|
+
|
|
711
|
+
while True:
|
|
712
|
+
msg = await wait_message(ctx, channel="order.created")
|
|
713
|
+
await send_notification(ctx, msg.data)
|
|
714
|
+
|
|
715
|
+
# Sender workflow - publishes to all group members
|
|
716
|
+
@workflow
|
|
717
|
+
async def order_processor(ctx: WorkflowContext, order_id: str):
|
|
718
|
+
result = await process_order(ctx, order_id)
|
|
719
|
+
|
|
720
|
+
# Broadcast to all watchers (doesn't need to know instance IDs)
|
|
721
|
+
count = await publish_to_group(
|
|
722
|
+
ctx,
|
|
723
|
+
group="order_watchers",
|
|
724
|
+
channel="order.created",
|
|
725
|
+
data={"order_id": order_id, "status": "completed"},
|
|
726
|
+
)
|
|
727
|
+
print(f"Notified {count} watchers")
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
**Channel API with Delivery Modes** - subscribe to channels with explicit delivery semantics:
|
|
731
|
+
|
|
732
|
+
```python
|
|
733
|
+
from edda import workflow, subscribe, receive, publish, WorkflowContext
|
|
734
|
+
|
|
735
|
+
# Job Worker - processes jobs exclusively (competing mode)
|
|
736
|
+
@workflow
|
|
737
|
+
async def job_worker(ctx: WorkflowContext, worker_id: str):
|
|
738
|
+
# Subscribe with competing mode - each job goes to ONE worker only
|
|
739
|
+
await subscribe(ctx, channel="jobs", mode="competing")
|
|
740
|
+
|
|
741
|
+
while True:
|
|
742
|
+
job = await receive(ctx, channel="jobs") # Get next job
|
|
743
|
+
await process_job(ctx, job.data)
|
|
744
|
+
await ctx.recur(worker_id) # Continue processing
|
|
745
|
+
|
|
746
|
+
# Notification Handler - receives ALL messages (broadcast mode)
|
|
747
|
+
@workflow
|
|
748
|
+
async def notification_handler(ctx: WorkflowContext, handler_id: str):
|
|
749
|
+
# Subscribe with broadcast mode - ALL handlers receive each message
|
|
750
|
+
await subscribe(ctx, channel="notifications", mode="broadcast")
|
|
751
|
+
|
|
752
|
+
while True:
|
|
753
|
+
msg = await receive(ctx, channel="notifications")
|
|
754
|
+
await send_notification(ctx, msg.data)
|
|
755
|
+
await ctx.recur(handler_id)
|
|
756
|
+
|
|
757
|
+
# Publisher - send messages to channel
|
|
758
|
+
await publish(ctx, channel="jobs", data={"task": "send_report"})
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**Delivery modes**:
|
|
762
|
+
- **`competing`**: Each message goes to exactly ONE subscriber (job queue/task distribution)
|
|
763
|
+
- **`broadcast`**: Each message goes to ALL subscribers (notifications/fan-out)
|
|
764
|
+
|
|
765
|
+
**Key features**:
|
|
766
|
+
- **Channel-based messaging**: Messages are delivered to workflows waiting on specific channels
|
|
767
|
+
- **Competing vs Broadcast**: Choose semantics per subscription
|
|
768
|
+
- **Group communication**: Erlang pg-style groups for loose coupling and fan-out
|
|
769
|
+
- **Database-backed**: All messages are persisted for durability
|
|
770
|
+
- **Lock-first delivery**: Safe for multi-worker environments
|
|
771
|
+
|
|
772
|
+
### Workflow Recurrence
|
|
773
|
+
|
|
774
|
+
Long-running workflows can use `ctx.recur()` to restart with fresh history while maintaining the same instance ID. This is essential for workflows that run indefinitely (job workers, notification handlers, etc.):
|
|
775
|
+
|
|
776
|
+
```python
|
|
777
|
+
from edda import workflow, subscribe, receive, WorkflowContext
|
|
778
|
+
|
|
779
|
+
@workflow
|
|
780
|
+
async def job_worker(ctx: WorkflowContext, worker_id: str):
|
|
781
|
+
await subscribe(ctx, channel="jobs", mode="competing")
|
|
782
|
+
|
|
783
|
+
# Process one job
|
|
784
|
+
job = await receive(ctx, channel="jobs")
|
|
785
|
+
await process_job(ctx, job.data)
|
|
786
|
+
|
|
787
|
+
# Archive history and restart with same instance_id
|
|
788
|
+
# Prevents unbounded history growth
|
|
789
|
+
await ctx.recur(worker_id)
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
**Key benefits**:
|
|
793
|
+
- **Prevents history growth**: Archives old history, starts fresh
|
|
794
|
+
- **Maintains instance ID**: Same workflow continues logically
|
|
795
|
+
- **Preserves subscriptions**: Channel subscriptions survive recurrence
|
|
796
|
+
- **Enables infinite loops**: Essential for long-running workers
|
|
797
|
+
|
|
640
798
|
### ASGI Integration
|
|
641
799
|
|
|
642
800
|
Edda runs as an ASGI application:
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
edda/__init__.py,sha256=
|
|
1
|
+
edda/__init__.py,sha256=hGC6WR2R36M8LWC97F-0Rw4Ln0QUUT_1xC-7acOy_Fk,2237
|
|
2
2
|
edda/activity.py,sha256=nRm9eBrr0lFe4ZRQ2whyZ6mo5xd171ITIVhqytUhOpw,21025
|
|
3
|
-
edda/app.py,sha256=
|
|
4
|
-
edda/
|
|
5
|
-
edda/
|
|
6
|
-
edda/
|
|
3
|
+
edda/app.py,sha256=kZ-VEvjIe3GjUA8RhT6OimuezNyPf2IhrvQ2kL44zJs,45201
|
|
4
|
+
edda/channels.py,sha256=ozaXCcWMLwgu_i6p8I79C9FnfsoQ0uv2BpaPPonTJdc,33863
|
|
5
|
+
edda/compensation.py,sha256=iKLlnTxiF1YSatmYQW84EkPB1yMKUEZBtgjuGnghLtY,11824
|
|
6
|
+
edda/context.py,sha256=kxWok86IwLF7hvxlE803t7ayPySTzCKoW113TmwON1k,19752
|
|
7
7
|
edda/exceptions.py,sha256=-ntBLGpVQgPFG5N1o8m_7weejAYkNrUdxTkOP38vsHk,1766
|
|
8
8
|
edda/hooks.py,sha256=HUZ6FTM__DZjwuomDfTDEroQ3mugEPuJHcGm7CTQNvg,8193
|
|
9
|
-
edda/locking.py,sha256=
|
|
9
|
+
edda/locking.py,sha256=NAFJmw-JaSVsXn4Y4czJyv_s9bWG8cdrzDBWIEag5X8,13661
|
|
10
10
|
edda/pydantic_utils.py,sha256=dGVPNrrttDeq1k233PopCtjORYjZitsgASPfPnO6R10,9056
|
|
11
|
-
edda/replay.py,sha256=
|
|
11
|
+
edda/replay.py,sha256=_poGUfvsDJP8GiflAw6aCZzxMKJpo99z__JVdGHb75I,42567
|
|
12
12
|
edda/retry.py,sha256=t4_E1skrhotA1XWHTLbKi-DOgCMasOUnhI9OT-O_eCE,6843
|
|
13
|
-
edda/workflow.py,sha256=
|
|
13
|
+
edda/workflow.py,sha256=hfBZM0JrtK0IkvZSrva0VmYVyvKCdiJ5FWFmIVENfrM,8807
|
|
14
14
|
edda/wsgi.py,sha256=1pGE5fhHpcsYnDR8S3NEFKWUs5P0JK4roTAzX9BsIj0,2391
|
|
15
15
|
edda/integrations/__init__.py,sha256=F_CaTvlDEbldfOpPKq_U9ve1E573tS6XzqXnOtyHcXI,33
|
|
16
16
|
edda/integrations/mcp/__init__.py,sha256=YK-8m0DIdP-RSqewlIX7xnWU7TD3NioCiW2_aZSgnn8,1232
|
|
17
17
|
edda/integrations/mcp/decorators.py,sha256=31SmbDwmHEGvUNa3aaatW91hBkpnS5iN9uy47dID3J4,10037
|
|
18
18
|
edda/integrations/mcp/server.py,sha256=Q5r4AbMn-9gBcy2CZocbgW7O0fn7Qb4e9CBJa1FEmzU,14507
|
|
19
19
|
edda/integrations/opentelemetry/__init__.py,sha256=x1_PyyygGDW-rxQTwoIrGzyjKErXHOOKdquFAMlCOAo,906
|
|
20
|
-
edda/integrations/opentelemetry/hooks.py,sha256=
|
|
20
|
+
edda/integrations/opentelemetry/hooks.py,sha256=rCb6K_gJJMxjQ-UoJnbIOWsafapipzu7w-YPROZKxDA,21330
|
|
21
21
|
edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
|
|
22
22
|
edda/outbox/relayer.py,sha256=2tnN1aOQ8pKWfwEGIlYwYLLwyOKXBjZ4XZsIr1HjgK4,9454
|
|
23
23
|
edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
|
|
@@ -25,18 +25,19 @@ edda/serialization/__init__.py,sha256=hnOVJN-mJNIsSa_XH9jwhIydOsWvIfCaFaSd37HUpl
|
|
|
25
25
|
edda/serialization/base.py,sha256=xJy2CY9gdJDCF0tmCor8NomL2Lr_w7cveVvxccuc-tA,1998
|
|
26
26
|
edda/serialization/json.py,sha256=Dq96V4n1yozexjCPd_CL6Iuvh1u3jJhef6sTcNxXZeA,2842
|
|
27
27
|
edda/storage/__init__.py,sha256=Q-kNJsjF8hMc2Q5MYFlLBENKExlNlKkbmUkwBOosj9I,216
|
|
28
|
-
edda/storage/models.py,sha256=
|
|
29
|
-
edda/storage/protocol.py,sha256=
|
|
30
|
-
edda/storage/sqlalchemy_storage.py,sha256=
|
|
28
|
+
edda/storage/models.py,sha256=E_KFygX6DP2qRwgLGLiKPhcXdKtvnfKeqA2kMXcAchE,13188
|
|
29
|
+
edda/storage/protocol.py,sha256=0E5QSUDtSMlJX_lF6m3VAUx3WO6Qjm-Kq6_bXI7P52I,39841
|
|
30
|
+
edda/storage/sqlalchemy_storage.py,sha256=eswQxCyY2DdUNYdrkxW_7Pev6CHCohNb72kGJjf29tA,140825
|
|
31
31
|
edda/viewer_ui/__init__.py,sha256=N1-T33SXadOXcBsDSgJJ9Iqz4y4verJngWryQu70c5c,517
|
|
32
|
-
edda/viewer_ui/app.py,sha256=
|
|
33
|
-
edda/viewer_ui/components.py,sha256=
|
|
34
|
-
edda/viewer_ui/data_service.py,sha256=
|
|
32
|
+
edda/viewer_ui/app.py,sha256=CqHKsUj5pcysHCk0aRfkEqV4DIV4l3GzOPKBJ5DTYOQ,95624
|
|
33
|
+
edda/viewer_ui/components.py,sha256=A0IxLwgj_Lu51O57OfzOwME8jzoJtKegEVvSnWc7uPo,45174
|
|
34
|
+
edda/viewer_ui/data_service.py,sha256=yzmPz67rBoECY7eNK5nl6YS3jIZAi-haaqrP0GIgJYE,36373
|
|
35
|
+
edda/viewer_ui/theme.py,sha256=mrXoXLRzgSnvE2a58LuMcPJkhlvHEDMWVa8Smqtk4l0,8118
|
|
35
36
|
edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
|
|
36
37
|
edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
|
|
37
38
|
edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
|
|
38
|
-
edda_framework-0.
|
|
39
|
-
edda_framework-0.
|
|
40
|
-
edda_framework-0.
|
|
41
|
-
edda_framework-0.
|
|
42
|
-
edda_framework-0.
|
|
39
|
+
edda_framework-0.8.0.dist-info/METADATA,sha256=o5ANdknKkLtOAGnKZ_IIQ0D3Q2iKyccdYVB-jjf8LLA,37506
|
|
40
|
+
edda_framework-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
41
|
+
edda_framework-0.8.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
|
|
42
|
+
edda_framework-0.8.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
|
|
43
|
+
edda_framework-0.8.0.dist-info/RECORD,,
|