edda-framework 0.9.0__py3-none-any.whl → 0.10.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 +428 -56
- edda/context.py +8 -0
- edda/outbox/relayer.py +21 -2
- edda/storage/__init__.py +8 -0
- edda/storage/notify_base.py +162 -0
- edda/storage/pg_notify.py +325 -0
- edda/storage/protocol.py +9 -1
- edda/storage/sqlalchemy_storage.py +193 -13
- edda/viewer_ui/app.py +26 -0
- edda/viewer_ui/data_service.py +4 -0
- {edda_framework-0.9.0.dist-info → edda_framework-0.10.0.dist-info}/METADATA +13 -1
- {edda_framework-0.9.0.dist-info → edda_framework-0.10.0.dist-info}/RECORD +15 -13
- {edda_framework-0.9.0.dist-info → edda_framework-0.10.0.dist-info}/WHEEL +0 -0
- {edda_framework-0.9.0.dist-info → edda_framework-0.10.0.dist-info}/entry_points.txt +0 -0
- {edda_framework-0.9.0.dist-info → edda_framework-0.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,6 +8,7 @@ and transactional outbox pattern.
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
import re
|
|
11
12
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
12
13
|
from contextlib import asynccontextmanager
|
|
13
14
|
from contextvars import ContextVar
|
|
@@ -511,14 +512,43 @@ class SQLAlchemyStorage:
|
|
|
511
512
|
- Automatic transaction management via @activity decorator
|
|
512
513
|
"""
|
|
513
514
|
|
|
514
|
-
def __init__(
|
|
515
|
+
def __init__(
|
|
516
|
+
self,
|
|
517
|
+
engine: AsyncEngine,
|
|
518
|
+
notify_listener: Any | None = None,
|
|
519
|
+
):
|
|
515
520
|
"""
|
|
516
521
|
Initialize SQLAlchemy storage.
|
|
517
522
|
|
|
518
523
|
Args:
|
|
519
524
|
engine: SQLAlchemy AsyncEngine instance
|
|
525
|
+
notify_listener: Optional notify listener for PostgreSQL LISTEN/NOTIFY.
|
|
526
|
+
If provided and PostgreSQL is used, NOTIFY messages
|
|
527
|
+
will be sent after key operations.
|
|
520
528
|
"""
|
|
521
529
|
self.engine = engine
|
|
530
|
+
self._notify_listener = notify_listener
|
|
531
|
+
|
|
532
|
+
@property
|
|
533
|
+
def _is_postgresql(self) -> bool:
|
|
534
|
+
"""Check if the database is PostgreSQL."""
|
|
535
|
+
return self.engine.dialect.name == "postgresql"
|
|
536
|
+
|
|
537
|
+
@property
|
|
538
|
+
def _notify_enabled(self) -> bool:
|
|
539
|
+
"""Check if NOTIFY is enabled (PostgreSQL with listener)."""
|
|
540
|
+
return self._is_postgresql and self._notify_listener is not None
|
|
541
|
+
|
|
542
|
+
def set_notify_listener(self, listener: Any) -> None:
|
|
543
|
+
"""Set the notify listener after initialization.
|
|
544
|
+
|
|
545
|
+
This allows setting the listener after EddaApp creates the storage,
|
|
546
|
+
useful for dependency injection patterns.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
listener: NotifyProtocol implementation (PostgresNotifyListener or NoopNotifyListener)
|
|
550
|
+
"""
|
|
551
|
+
self._notify_listener = listener
|
|
522
552
|
|
|
523
553
|
async def initialize(self) -> None:
|
|
524
554
|
"""Initialize database connection and create tables.
|
|
@@ -544,6 +574,39 @@ class SQLAlchemyStorage:
|
|
|
544
574
|
"""Close database connection."""
|
|
545
575
|
await self.engine.dispose()
|
|
546
576
|
|
|
577
|
+
async def _send_notify(
|
|
578
|
+
self,
|
|
579
|
+
channel: str,
|
|
580
|
+
payload: dict[str, Any],
|
|
581
|
+
) -> None:
|
|
582
|
+
"""Send PostgreSQL NOTIFY message.
|
|
583
|
+
|
|
584
|
+
This method sends a notification on the specified channel with the given
|
|
585
|
+
payload. It's a no-op if NOTIFY is not enabled (non-PostgreSQL or no listener).
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
channel: PostgreSQL NOTIFY channel name (max 63 chars).
|
|
589
|
+
payload: Dictionary to serialize as JSON payload (max ~7500 bytes).
|
|
590
|
+
"""
|
|
591
|
+
if not self._notify_enabled:
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
import json as json_module
|
|
596
|
+
|
|
597
|
+
payload_str = json_module.dumps(payload, separators=(",", ":"))
|
|
598
|
+
|
|
599
|
+
# Use a separate connection for NOTIFY to avoid transaction issues
|
|
600
|
+
async with self.engine.connect() as conn:
|
|
601
|
+
await conn.execute(
|
|
602
|
+
text("SELECT pg_notify(:channel, :payload)"),
|
|
603
|
+
{"channel": channel, "payload": payload_str},
|
|
604
|
+
)
|
|
605
|
+
await conn.commit()
|
|
606
|
+
except Exception as e:
|
|
607
|
+
# Log but don't fail - polling will catch it as backup
|
|
608
|
+
logger.warning(f"Failed to send NOTIFY on channel {channel}: {e}")
|
|
609
|
+
|
|
547
610
|
async def _initialize_schema_version(self) -> None:
|
|
548
611
|
"""Initialize schema version for a fresh database."""
|
|
549
612
|
async with AsyncSession(self.engine) as session:
|
|
@@ -849,6 +912,60 @@ class SQLAlchemyStorage:
|
|
|
849
912
|
# PostgreSQL/MySQL: column is already timezone-aware
|
|
850
913
|
return column
|
|
851
914
|
|
|
915
|
+
def _validate_json_path(self, json_path: str) -> bool:
|
|
916
|
+
"""
|
|
917
|
+
Validate JSON path to prevent SQL injection.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
json_path: JSON path string (e.g., "order_id" or "customer.email")
|
|
921
|
+
|
|
922
|
+
Returns:
|
|
923
|
+
True if valid, False otherwise
|
|
924
|
+
"""
|
|
925
|
+
# Only allow alphanumeric characters, dots, and underscores
|
|
926
|
+
# Must start with letter or underscore
|
|
927
|
+
return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_.]*$", json_path))
|
|
928
|
+
|
|
929
|
+
def _build_json_extract_expr(self, column: Any, json_path: str) -> Any:
|
|
930
|
+
"""
|
|
931
|
+
Build a database-agnostic JSON extraction expression.
|
|
932
|
+
|
|
933
|
+
Args:
|
|
934
|
+
column: SQLAlchemy column containing JSON text
|
|
935
|
+
json_path: Dot-notation path (e.g., "order_id" or "customer.email")
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
SQLAlchemy expression that extracts the value at json_path
|
|
939
|
+
|
|
940
|
+
Raises:
|
|
941
|
+
ValueError: If json_path is invalid or dialect is unsupported
|
|
942
|
+
"""
|
|
943
|
+
if not self._validate_json_path(json_path):
|
|
944
|
+
raise ValueError(f"Invalid JSON path: {json_path}")
|
|
945
|
+
|
|
946
|
+
full_path = f"$.{json_path}"
|
|
947
|
+
dialect = self.engine.dialect.name
|
|
948
|
+
|
|
949
|
+
if dialect == "sqlite":
|
|
950
|
+
# SQLite: json_extract(column, '$.key')
|
|
951
|
+
return func.json_extract(column, full_path)
|
|
952
|
+
elif dialect == "postgresql":
|
|
953
|
+
# PostgreSQL: For nested paths, use #>> operator with array path
|
|
954
|
+
# For simple paths, use ->> operator
|
|
955
|
+
# Since we're dealing with Text column, we need to cast first
|
|
956
|
+
if "." in json_path:
|
|
957
|
+
# Nested: (column)::json #>> '{customer,email}'
|
|
958
|
+
path_array = "{" + json_path.replace(".", ",") + "}"
|
|
959
|
+
return text(f"(input_data)::json #>> '{path_array}'")
|
|
960
|
+
else:
|
|
961
|
+
# Simple: (column)::json->>'key'
|
|
962
|
+
return text(f"(input_data)::json->>'{json_path}'")
|
|
963
|
+
elif dialect == "mysql":
|
|
964
|
+
# MySQL: JSON_UNQUOTE(JSON_EXTRACT(column, '$.key'))
|
|
965
|
+
return func.JSON_UNQUOTE(func.JSON_EXTRACT(column, full_path))
|
|
966
|
+
else:
|
|
967
|
+
raise ValueError(f"Unsupported database dialect: {dialect}")
|
|
968
|
+
|
|
852
969
|
# -------------------------------------------------------------------------
|
|
853
970
|
# Transaction Management Methods
|
|
854
971
|
# -------------------------------------------------------------------------
|
|
@@ -1200,6 +1317,7 @@ class SQLAlchemyStorage:
|
|
|
1200
1317
|
instance_id_filter: str | None = None,
|
|
1201
1318
|
started_after: datetime | None = None,
|
|
1202
1319
|
started_before: datetime | None = None,
|
|
1320
|
+
input_filters: dict[str, Any] | None = None,
|
|
1203
1321
|
) -> dict[str, Any]:
|
|
1204
1322
|
"""List workflow instances with cursor-based pagination and filtering."""
|
|
1205
1323
|
session = self._get_session_for_operation()
|
|
@@ -1292,6 +1410,42 @@ class SQLAlchemyStorage:
|
|
|
1292
1410
|
started_before_comparable = started_before
|
|
1293
1411
|
stmt = stmt.where(started_at_comparable <= started_before_comparable)
|
|
1294
1412
|
|
|
1413
|
+
# Apply input data filters (JSON field matching)
|
|
1414
|
+
if input_filters:
|
|
1415
|
+
for json_path, expected_value in input_filters.items():
|
|
1416
|
+
dialect = self.engine.dialect.name
|
|
1417
|
+
if dialect == "postgresql":
|
|
1418
|
+
# PostgreSQL: use text() for the entire condition
|
|
1419
|
+
if not self._validate_json_path(json_path):
|
|
1420
|
+
raise ValueError(f"Invalid JSON path: {json_path}")
|
|
1421
|
+
if "." in json_path:
|
|
1422
|
+
path_array = "{" + json_path.replace(".", ",") + "}"
|
|
1423
|
+
json_sql = f"(input_data)::json #>> '{path_array}'"
|
|
1424
|
+
else:
|
|
1425
|
+
json_sql = f"(input_data)::json->>'{json_path}'"
|
|
1426
|
+
if expected_value is None:
|
|
1427
|
+
stmt = stmt.where(text(f"({json_sql} IS NULL OR {json_sql} = 'null')"))
|
|
1428
|
+
else:
|
|
1429
|
+
# Escape single quotes in value
|
|
1430
|
+
safe_value = str(expected_value).replace("'", "''")
|
|
1431
|
+
stmt = stmt.where(text(f"{json_sql} = '{safe_value}'"))
|
|
1432
|
+
else:
|
|
1433
|
+
# SQLite and MySQL: use func-based approach
|
|
1434
|
+
json_expr = self._build_json_extract_expr(
|
|
1435
|
+
WorkflowInstance.input_data, json_path
|
|
1436
|
+
)
|
|
1437
|
+
if expected_value is None:
|
|
1438
|
+
stmt = stmt.where(or_(json_expr.is_(None), json_expr == "null"))
|
|
1439
|
+
elif isinstance(expected_value, bool):
|
|
1440
|
+
stmt = stmt.where(json_expr == str(expected_value).lower())
|
|
1441
|
+
elif isinstance(expected_value, (int, float)):
|
|
1442
|
+
if dialect == "sqlite":
|
|
1443
|
+
stmt = stmt.where(json_expr == expected_value)
|
|
1444
|
+
else:
|
|
1445
|
+
stmt = stmt.where(json_expr == str(expected_value))
|
|
1446
|
+
else:
|
|
1447
|
+
stmt = stmt.where(json_expr == str(expected_value))
|
|
1448
|
+
|
|
1295
1449
|
# Fetch limit+1 to determine if there are more pages
|
|
1296
1450
|
stmt = stmt.limit(limit + 1)
|
|
1297
1451
|
|
|
@@ -2096,6 +2250,12 @@ class SQLAlchemyStorage:
|
|
|
2096
2250
|
session.add(event)
|
|
2097
2251
|
await self._commit_if_not_in_transaction(session)
|
|
2098
2252
|
|
|
2253
|
+
# Send NOTIFY for new outbox event
|
|
2254
|
+
await self._send_notify(
|
|
2255
|
+
"edda_outbox_pending",
|
|
2256
|
+
{"evt_id": event_id, "evt_type": event_type},
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2099
2259
|
async def get_pending_outbox_events(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
2100
2260
|
"""
|
|
2101
2261
|
Get pending/failed outbox events for publishing (with row-level locking).
|
|
@@ -2719,29 +2879,34 @@ class SQLAlchemyStorage:
|
|
|
2719
2879
|
# Workflow Resumption Methods
|
|
2720
2880
|
# -------------------------------------------------------------------------
|
|
2721
2881
|
|
|
2722
|
-
async def find_resumable_workflows(self) -> list[dict[str, Any]]:
|
|
2882
|
+
async def find_resumable_workflows(self, limit: int | None = None) -> list[dict[str, Any]]:
|
|
2723
2883
|
"""
|
|
2724
2884
|
Find workflows that are ready to be resumed.
|
|
2725
2885
|
|
|
2726
2886
|
Returns workflows with status='running' that don't have an active lock.
|
|
2727
2887
|
Used for immediate resumption after message delivery.
|
|
2728
2888
|
|
|
2889
|
+
Args:
|
|
2890
|
+
limit: Optional maximum number of workflows to return.
|
|
2891
|
+
If None, returns all resumable workflows.
|
|
2892
|
+
|
|
2729
2893
|
Returns:
|
|
2730
2894
|
List of resumable workflows with instance_id and workflow_name.
|
|
2731
2895
|
"""
|
|
2732
2896
|
session = self._get_session_for_operation()
|
|
2733
2897
|
async with self._session_scope(session) as session:
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2898
|
+
query = select(
|
|
2899
|
+
WorkflowInstance.instance_id,
|
|
2900
|
+
WorkflowInstance.workflow_name,
|
|
2901
|
+
).where(
|
|
2902
|
+
and_(
|
|
2903
|
+
WorkflowInstance.status == "running",
|
|
2904
|
+
WorkflowInstance.locked_by.is_(None),
|
|
2905
|
+
)
|
|
2906
|
+
)
|
|
2907
|
+
if limit is not None:
|
|
2908
|
+
query = query.limit(limit)
|
|
2909
|
+
result = await session.execute(query)
|
|
2745
2910
|
return [
|
|
2746
2911
|
{
|
|
2747
2912
|
"instance_id": row.instance_id,
|
|
@@ -2837,6 +3002,15 @@ class SQLAlchemyStorage:
|
|
|
2837
3002
|
session.add(msg)
|
|
2838
3003
|
await self._commit_if_not_in_transaction(session)
|
|
2839
3004
|
|
|
3005
|
+
# Send NOTIFY for message published (channel-specific)
|
|
3006
|
+
import hashlib
|
|
3007
|
+
|
|
3008
|
+
channel_hash = hashlib.sha256(channel.encode()).hexdigest()[:16]
|
|
3009
|
+
await self._send_notify(
|
|
3010
|
+
f"edda_msg_{channel_hash}",
|
|
3011
|
+
{"ch": channel, "msg_id": message_id},
|
|
3012
|
+
)
|
|
3013
|
+
|
|
2840
3014
|
return message_id
|
|
2841
3015
|
|
|
2842
3016
|
async def subscribe_to_channel(
|
|
@@ -3395,6 +3569,12 @@ class SQLAlchemyStorage:
|
|
|
3395
3569
|
|
|
3396
3570
|
await session.commit()
|
|
3397
3571
|
|
|
3572
|
+
# Send NOTIFY for workflow resumable
|
|
3573
|
+
await self._send_notify(
|
|
3574
|
+
"edda_workflow_resumable",
|
|
3575
|
+
{"wf_id": instance_id, "wf_name": workflow_name},
|
|
3576
|
+
)
|
|
3577
|
+
|
|
3398
3578
|
return {
|
|
3399
3579
|
"instance_id": instance_id,
|
|
3400
3580
|
"workflow_name": workflow_name,
|
edda/viewer_ui/app.py
CHANGED
|
@@ -1296,6 +1296,8 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1296
1296
|
"search_query": "",
|
|
1297
1297
|
"started_after": None,
|
|
1298
1298
|
"started_before": None,
|
|
1299
|
+
"input_filter_key": "",
|
|
1300
|
+
"input_filter_value": "",
|
|
1299
1301
|
"instances": [],
|
|
1300
1302
|
"next_page_token": None,
|
|
1301
1303
|
"has_more": False,
|
|
@@ -1352,6 +1354,17 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1352
1354
|
"click", lambda m=date_to_menu: m.open()
|
|
1353
1355
|
)
|
|
1354
1356
|
|
|
1357
|
+
# Input data filter fields
|
|
1358
|
+
input_key = ui.input(
|
|
1359
|
+
label="Input Key",
|
|
1360
|
+
placeholder="e.g., input.order_id",
|
|
1361
|
+
).classes("w-40")
|
|
1362
|
+
|
|
1363
|
+
input_value = ui.input(
|
|
1364
|
+
label="Input Value",
|
|
1365
|
+
placeholder="e.g., ORD-123",
|
|
1366
|
+
).classes("w-36")
|
|
1367
|
+
|
|
1355
1368
|
# Refresh button
|
|
1356
1369
|
async def handle_refresh() -> None:
|
|
1357
1370
|
"""Handle refresh button click."""
|
|
@@ -1360,6 +1373,11 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1360
1373
|
pagination_state["page_size"] = page_size_select.value
|
|
1361
1374
|
pagination_state["started_after"] = date_from.value
|
|
1362
1375
|
pagination_state["started_before"] = date_to.value
|
|
1376
|
+
pagination_state["input_filter_key"] = input_key.value
|
|
1377
|
+
pagination_state["input_filter_value"] = input_value.value
|
|
1378
|
+
# Debug output
|
|
1379
|
+
print(f"DEBUG: input_key.value = '{input_key.value}'")
|
|
1380
|
+
print(f"DEBUG: input_value.value = '{input_value.value}'")
|
|
1363
1381
|
# Reset to first page
|
|
1364
1382
|
pagination_state["current_token"] = None
|
|
1365
1383
|
pagination_state["token_stack"] = []
|
|
@@ -1389,6 +1407,13 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1389
1407
|
pagination_state["started_before"] + "T23:59:59"
|
|
1390
1408
|
)
|
|
1391
1409
|
|
|
1410
|
+
# Build input_filters if key is provided
|
|
1411
|
+
input_filters = None
|
|
1412
|
+
if pagination_state["input_filter_key"]:
|
|
1413
|
+
input_filters = {
|
|
1414
|
+
pagination_state["input_filter_key"]: pagination_state["input_filter_value"]
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1392
1417
|
result = await service.get_instances_paginated(
|
|
1393
1418
|
page_size=pagination_state["page_size"],
|
|
1394
1419
|
page_token=pagination_state["current_token"],
|
|
@@ -1396,6 +1421,7 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1396
1421
|
search_query=pagination_state["search_query"] or None,
|
|
1397
1422
|
started_after=started_after,
|
|
1398
1423
|
started_before=started_before,
|
|
1424
|
+
input_filters=input_filters,
|
|
1399
1425
|
)
|
|
1400
1426
|
pagination_state["instances"] = result["instances"]
|
|
1401
1427
|
pagination_state["next_page_token"] = result["next_page_token"]
|
edda/viewer_ui/data_service.py
CHANGED
|
@@ -48,6 +48,7 @@ class WorkflowDataService:
|
|
|
48
48
|
search_query: str | None = None,
|
|
49
49
|
started_after: datetime | None = None,
|
|
50
50
|
started_before: datetime | None = None,
|
|
51
|
+
input_filters: dict[str, Any] | None = None,
|
|
51
52
|
) -> dict[str, Any]:
|
|
52
53
|
"""
|
|
53
54
|
Get workflow instances with cursor-based pagination and filtering.
|
|
@@ -59,6 +60,8 @@ class WorkflowDataService:
|
|
|
59
60
|
search_query: Search by workflow name or instance ID (partial match)
|
|
60
61
|
started_after: Filter instances started after this datetime
|
|
61
62
|
started_before: Filter instances started before this datetime
|
|
63
|
+
input_filters: Filter by input data values. Keys are JSON paths
|
|
64
|
+
(e.g., "order_id"), values are expected values (exact match).
|
|
62
65
|
|
|
63
66
|
Returns:
|
|
64
67
|
Dictionary containing:
|
|
@@ -75,6 +78,7 @@ class WorkflowDataService:
|
|
|
75
78
|
instance_id_filter=search_query,
|
|
76
79
|
started_after=started_after,
|
|
77
80
|
started_before=started_before,
|
|
81
|
+
input_filters=input_filters,
|
|
78
82
|
)
|
|
79
83
|
return result
|
|
80
84
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edda-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -28,6 +28,8 @@ Requires-Dist: httpx>=0.28.1
|
|
|
28
28
|
Requires-Dist: pydantic>=2.0.0
|
|
29
29
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
30
30
|
Requires-Dist: uvloop>=0.22.1
|
|
31
|
+
Provides-Extra: cpu-monitor
|
|
32
|
+
Requires-Dist: psutil>=5.9.0; extra == 'cpu-monitor'
|
|
31
33
|
Provides-Extra: dev
|
|
32
34
|
Requires-Dist: black>=25.9.0; extra == 'dev'
|
|
33
35
|
Requires-Dist: mcp>=1.22.0; extra == 'dev'
|
|
@@ -48,6 +50,8 @@ Provides-Extra: opentelemetry
|
|
|
48
50
|
Requires-Dist: opentelemetry-api>=1.20.0; extra == 'opentelemetry'
|
|
49
51
|
Requires-Dist: opentelemetry-exporter-otlp>=1.20.0; extra == 'opentelemetry'
|
|
50
52
|
Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'opentelemetry'
|
|
53
|
+
Provides-Extra: postgres-notify
|
|
54
|
+
Requires-Dist: asyncpg>=0.30.0; extra == 'postgres-notify'
|
|
51
55
|
Provides-Extra: postgresql
|
|
52
56
|
Requires-Dist: asyncpg>=0.30.0; extra == 'postgresql'
|
|
53
57
|
Provides-Extra: server
|
|
@@ -87,6 +91,7 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
|
|
|
87
91
|
- ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
|
|
88
92
|
- ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
|
|
89
93
|
- 📬 **Channel-based Messaging**: Actor-model style communication with competing (job queue) and broadcast (fan-out) modes
|
|
94
|
+
- ⚡ **Instant Notifications**: PostgreSQL LISTEN/NOTIFY for near-instant event delivery (optional)
|
|
90
95
|
- 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
|
|
91
96
|
- 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
|
|
92
97
|
|
|
@@ -221,6 +226,9 @@ uv add edda-framework --extra mysql
|
|
|
221
226
|
# With Viewer UI
|
|
222
227
|
uv add edda-framework --extra viewer
|
|
223
228
|
|
|
229
|
+
# With PostgreSQL instant notifications (LISTEN/NOTIFY)
|
|
230
|
+
uv add edda-framework --extra postgres-notify
|
|
231
|
+
|
|
224
232
|
# All extras (PostgreSQL, MySQL, Viewer UI)
|
|
225
233
|
uv add edda-framework --extra postgresql --extra mysql --extra viewer
|
|
226
234
|
```
|
|
@@ -272,6 +280,8 @@ pip install "git+https://github.com/i2y/edda.git[postgresql,viewer]"
|
|
|
272
280
|
|
|
273
281
|
**Important**: For multi-process or multi-pod deployments (K8s, Docker Compose with multiple replicas, etc.), you **must** use PostgreSQL or MySQL. SQLite supports multiple async workers within a single process, but its table-level locking makes it unsuitable for multi-process/multi-pod scenarios.
|
|
274
282
|
|
|
283
|
+
> **Tip**: For PostgreSQL, install the `postgres-notify` extra for near-instant event delivery using LISTEN/NOTIFY instead of polling.
|
|
284
|
+
|
|
275
285
|
### Development Installation
|
|
276
286
|
|
|
277
287
|
If you want to contribute to Edda or modify the framework itself:
|
|
@@ -492,6 +502,8 @@ app = EddaApp(
|
|
|
492
502
|
# Connection pool settings (optional)
|
|
493
503
|
pool_size=5, # Concurrent connections
|
|
494
504
|
max_overflow=10, # Additional burst capacity
|
|
505
|
+
# Batch processing (optional)
|
|
506
|
+
max_workflows_per_batch=10, # Or "auto" / "auto:cpu" for dynamic scaling
|
|
495
507
|
)
|
|
496
508
|
```
|
|
497
509
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
edda/__init__.py,sha256=hGC6WR2R36M8LWC97F-0Rw4Ln0QUUT_1xC-7acOy_Fk,2237
|
|
2
2
|
edda/activity.py,sha256=nRm9eBrr0lFe4ZRQ2whyZ6mo5xd171ITIVhqytUhOpw,21025
|
|
3
|
-
edda/app.py,sha256=
|
|
3
|
+
edda/app.py,sha256=hoxDKp6q5qHl_dNLMkLxKhuibUC6D8FtuiWsIZkzwhA,61546
|
|
4
4
|
edda/channels.py,sha256=Budi0FyxalmcAMwj50mX3WzRce5OuLKXGws0Hp_snfw,34745
|
|
5
5
|
edda/compensation.py,sha256=iKLlnTxiF1YSatmYQW84EkPB1yMKUEZBtgjuGnghLtY,11824
|
|
6
|
-
edda/context.py,sha256=
|
|
6
|
+
edda/context.py,sha256=pPn98-G5HgaOGDRzEhma58TzBulwsiTvmNEMLIu0XwI,21330
|
|
7
7
|
edda/exceptions.py,sha256=-ntBLGpVQgPFG5N1o8m_7weejAYkNrUdxTkOP38vsHk,1766
|
|
8
8
|
edda/hooks.py,sha256=HUZ6FTM__DZjwuomDfTDEroQ3mugEPuJHcGm7CTQNvg,8193
|
|
9
9
|
edda/locking.py,sha256=NAFJmw-JaSVsXn4Y4czJyv_s9bWG8cdrzDBWIEag5X8,13661
|
|
@@ -19,25 +19,27 @@ edda/integrations/mcp/server.py,sha256=Q5r4AbMn-9gBcy2CZocbgW7O0fn7Qb4e9CBJa1FEm
|
|
|
19
19
|
edda/integrations/opentelemetry/__init__.py,sha256=x1_PyyygGDW-rxQTwoIrGzyjKErXHOOKdquFAMlCOAo,906
|
|
20
20
|
edda/integrations/opentelemetry/hooks.py,sha256=rCb6K_gJJMxjQ-UoJnbIOWsafapipzu7w-YPROZKxDA,21330
|
|
21
21
|
edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
|
|
22
|
-
edda/outbox/relayer.py,sha256=
|
|
22
|
+
edda/outbox/relayer.py,sha256=_yOZVpjj862lzMtEK47RMdVdbxXmL8v-FXppZWdy4Ag,10444
|
|
23
23
|
edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
|
|
24
24
|
edda/serialization/__init__.py,sha256=hnOVJN-mJNIsSa_XH9jwhIydOsWvIfCaFaSd37HUplg,216
|
|
25
25
|
edda/serialization/base.py,sha256=xJy2CY9gdJDCF0tmCor8NomL2Lr_w7cveVvxccuc-tA,1998
|
|
26
26
|
edda/serialization/json.py,sha256=Dq96V4n1yozexjCPd_CL6Iuvh1u3jJhef6sTcNxXZeA,2842
|
|
27
|
-
edda/storage/__init__.py,sha256=
|
|
27
|
+
edda/storage/__init__.py,sha256=NjvAzYV3SknACrC16ZQOA-xCKOj1-s3rBIWOS1ZGCaM,407
|
|
28
28
|
edda/storage/models.py,sha256=vUwjiAOvp9uFNQgLK57kEGo7uzXplDZikOfnlOyed2M,12146
|
|
29
|
-
edda/storage/
|
|
30
|
-
edda/storage/
|
|
29
|
+
edda/storage/notify_base.py,sha256=gUb-ypG1Bo0c-KrleYmC7eKtdwQNUeqGS5k7UILlSsQ,5055
|
|
30
|
+
edda/storage/pg_notify.py,sha256=to5rDIQbiqqkNNVMODye_KvY4EDqRSUQblTeoeDZv8w,11850
|
|
31
|
+
edda/storage/protocol.py,sha256=vdB5GvBen8lgUA0qEfBXfQTLbVfGKeBTQuEwSUqLZtI,39463
|
|
32
|
+
edda/storage/sqlalchemy_storage.py,sha256=IAc8SYHM2xoJEIVDG05_mkguWQGB3wIAALsc0QI8EcE,144484
|
|
31
33
|
edda/viewer_ui/__init__.py,sha256=N1-T33SXadOXcBsDSgJJ9Iqz4y4verJngWryQu70c5c,517
|
|
32
|
-
edda/viewer_ui/app.py,sha256=
|
|
34
|
+
edda/viewer_ui/app.py,sha256=K3c5sMeJz_AE9gh5QftxwvfDthLeJi1i2CDkP9gb4Ig,96695
|
|
33
35
|
edda/viewer_ui/components.py,sha256=A0IxLwgj_Lu51O57OfzOwME8jzoJtKegEVvSnWc7uPo,45174
|
|
34
|
-
edda/viewer_ui/data_service.py,sha256=
|
|
36
|
+
edda/viewer_ui/data_service.py,sha256=KOqnWr-Y8seH_dkJH_ejHRfxQqn7aY8Ni5C54tx2Z-E,36621
|
|
35
37
|
edda/viewer_ui/theme.py,sha256=mrXoXLRzgSnvE2a58LuMcPJkhlvHEDMWVa8Smqtk4l0,8118
|
|
36
38
|
edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
|
|
37
39
|
edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
|
|
38
40
|
edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
|
|
39
|
-
edda_framework-0.
|
|
40
|
-
edda_framework-0.
|
|
41
|
-
edda_framework-0.
|
|
42
|
-
edda_framework-0.
|
|
43
|
-
edda_framework-0.
|
|
41
|
+
edda_framework-0.10.0.dist-info/METADATA,sha256=HGJf790N4Rh8EDGIC4e7lFhH8xV5kCQP_xNPTLK8kpE,36366
|
|
42
|
+
edda_framework-0.10.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
43
|
+
edda_framework-0.10.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
|
|
44
|
+
edda_framework-0.10.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
|
|
45
|
+
edda_framework-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|