edda-framework 0.6.0__tar.gz → 0.8.0__tar.gz
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_framework-0.6.0 → edda_framework-0.8.0}/.gitignore +1 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/Justfile +110 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/PKG-INFO +167 -9
- {edda_framework-0.6.0 → edda_framework-0.8.0}/README.md +164 -8
- {edda_framework-0.6.0 → edda_framework-0.8.0}/demo_app.py +426 -36
- edda_framework-0.8.0/docs/api/reference.md +143 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/durable-execution/replay.md +1 -1
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/events/wait-event.md +17 -10
- edda_framework-0.8.0/docs/core-features/messages.md +516 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/retry.md +5 -3
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/workflows-activities.md +175 -2
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/examples/events.md +8 -8
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/getting-started/first-workflow.md +1 -1
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/index.md +7 -4
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/integrations/mcp.md +89 -1
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/integrations/opentelemetry.md +5 -1
- edda_framework-0.8.0/docs/integrations/pydantic-rpc.md +193 -0
- edda_framework-0.8.0/docs/viewer-ui/images/compensation-execution.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/detail-page-match-case.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/wait-event-visualization.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/workflow-list-view.png +0 -0
- edda_framework-0.8.0/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/viewer-ui/setup.md +11 -1
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/viewer-ui/visualization.md +38 -33
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/__init__.py +39 -5
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/app.py +383 -223
- edda_framework-0.8.0/edda/channels.py +992 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/compensation.py +22 -22
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/context.py +77 -51
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/integrations/opentelemetry/hooks.py +7 -2
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/locking.py +130 -67
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/replay.py +312 -82
- edda_framework-0.8.0/edda/storage/models.py +335 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/storage/protocol.py +575 -122
- edda_framework-0.8.0/edda/storage/sqlalchemy_storage.py +3563 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/viewer_ui/app.py +558 -127
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/viewer_ui/components.py +81 -68
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/viewer_ui/data_service.py +61 -25
- edda_framework-0.8.0/edda/viewer_ui/theme.py +200 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/workflow.py +43 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/cancellable_workflow.py +3 -4
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/event_waiting_app.py +9 -9
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/event_waiting_workflow.py +1 -1
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/event_waiting_workflow_complete.py +3 -3
- edda_framework-0.8.0/examples/long_running_loop.py +274 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/mcp/order_processing_mcp.py +5 -5
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/mcp/prompts_example.py +5 -5
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/mcp/remote_server_example.py +3 -3
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/mcp/simple_mcp_server.py +1 -1
- edda_framework-0.8.0/examples/message_passing.py +263 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/observability_with_logfire.py +6 -6
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/observability_with_opentelemetry.py +3 -4
- edda_framework-0.8.0/examples/pydantic_rpc_integration.py +461 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/pydantic_saga.py +1 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/retry_example.py +18 -19
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/retry_with_compensation.py +29 -32
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/typeddict_example.py +0 -1
- {edda_framework-0.6.0 → edda_framework-0.8.0}/pyproject.toml +5 -2
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/conftest.py +47 -10
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_cancel.py +2 -2
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_server.py +4 -4
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_atomic_wait_event.py +80 -52
- edda_framework-0.8.0/tests/test_auto_migration.py +405 -0
- edda_framework-0.8.0/tests/test_channel_competing.py +432 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_distributed_event_delivery.py +28 -15
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_events.py +115 -57
- edda_framework-0.8.0/tests/test_instance_id_routing.py +366 -0
- edda_framework-0.8.0/tests/test_message_cleanup.py +198 -0
- edda_framework-0.8.0/tests/test_message_delivery_lock.py +303 -0
- edda_framework-0.8.0/tests/test_messages.py +477 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_multidb_storage.py +39 -20
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_pydantic_events.py +34 -27
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_received_event.py +56 -38
- edda_framework-0.8.0/tests/test_recur.py +581 -0
- edda_framework-0.8.0/tests/test_recur_cleanup.py +310 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_stale_workflow_recovery.py +3 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_storage.py +43 -19
- edda_framework-0.8.0/tests/test_viewer_pagination.py +318 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_wait_timer.py +12 -12
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_workflow_cancellation.py +28 -16
- edda_framework-0.8.0/tests/test_workflow_resumption.py +253 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/uv.lock +199 -17
- {edda_framework-0.6.0 → edda_framework-0.8.0}/viewer_app.py +2 -3
- {edda_framework-0.6.0 → edda_framework-0.8.0}/zensical.toml +23 -9
- edda_framework-0.6.0/docs/markdown.md +0 -98
- edda_framework-0.6.0/docs/viewer-ui/images/compensation-execution.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/conditional-branching-diagram.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/detail-overview-panel.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/execution-history-panel.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/form-generation-example.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/hybrid-diagram-example.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/start-workflow-dialog.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/status-badges-example.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/wait-event-visualization.png +0 -0
- edda_framework-0.6.0/docs/viewer-ui/images/workflow-list-view.png +0 -0
- edda_framework-0.6.0/edda/events.py +0 -505
- edda_framework-0.6.0/edda/storage/models.py +0 -194
- edda_framework-0.6.0/edda/storage/sqlalchemy_storage.py +0 -1809
- {edda_framework-0.6.0 → edda_framework-0.8.0}/.github/workflows/ci.yml +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/.github/workflows/docs.yml +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/.github/workflows/release.yml +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/.python-version +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/LICENSE +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/events/cloudevents-http-binding.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/hooks.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/saga-compensation.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/core-features/transactional-outbox.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/examples/ecommerce.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/examples/fastapi-integration.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/examples/saga.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/examples/simple.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/getting-started/concepts.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/getting-started/installation.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/getting-started/quick-start.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/activity.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/exceptions.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/hooks.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/integrations/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/integrations/mcp/decorators.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/integrations/mcp/server.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/outbox/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/outbox/relayer.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/outbox/transactional.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/pydantic_utils.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/retry.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/serialization/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/serialization/base.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/serialization/json.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/storage/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/viewer_ui/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/visualizer/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/visualizer/ast_analyzer.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/visualizer/mermaid_generator.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/edda/wsgi.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/compensation_workflow.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/mcp/README.md +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/simple_workflow.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/examples/with_outbox.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_integration.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_jsonrpc.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_prompts.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/integrations/opentelemetry/test_hooks.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_activity.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_activity_retry.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_activity_sync.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_app.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_ast_analyzer.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_binary_data.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_cloudevents_http_binding.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_compensation.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_compensation_crash_recovery.py.wip +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_concurrent_outbox.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_context.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_ctx_session.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_lock_race_condition.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_lock_timeout_customization.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_locking.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_outbox.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_pydantic_activity.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_pydantic_enum.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_pydantic_saga.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_pydantic_utils.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_replay.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_retry_policy.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_saga_parameter_extraction.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_serialization.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_skip_locked.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_storage_mysql.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_storage_postgresql.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_transactions.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_viewer_pydantic_form.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_viewer_start_saga.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_workflow.py +0 -0
- {edda_framework-0.6.0 → edda_framework-0.8.0}/tests/test_workflow_auto_register.py +0 -0
|
@@ -31,18 +31,22 @@ test-file FILE:
|
|
|
31
31
|
|
|
32
32
|
# Format code with black
|
|
33
33
|
format:
|
|
34
|
+
@uv sync --extra dev --quiet
|
|
34
35
|
uv run black edda tests
|
|
35
36
|
|
|
36
37
|
# Check code formatting
|
|
37
38
|
format-check:
|
|
39
|
+
@uv sync --extra dev --quiet
|
|
38
40
|
uv run black --check edda tests
|
|
39
41
|
|
|
40
42
|
# Lint code with ruff
|
|
41
43
|
lint:
|
|
44
|
+
@uv sync --extra dev --quiet
|
|
42
45
|
uv run ruff check edda tests
|
|
43
46
|
|
|
44
47
|
# Type check with mypy
|
|
45
48
|
type-check:
|
|
49
|
+
@uv sync --extra dev --quiet
|
|
46
50
|
uv run mypy edda
|
|
47
51
|
|
|
48
52
|
# Run all checks (format, lint, type-check, test)
|
|
@@ -50,6 +54,7 @@ check: format-check lint type-check test
|
|
|
50
54
|
|
|
51
55
|
# Auto-fix issues (format + lint with auto-fix)
|
|
52
56
|
fix:
|
|
57
|
+
@uv sync --extra dev --quiet
|
|
53
58
|
uv run black edda tests
|
|
54
59
|
uv run ruff check --fix edda tests
|
|
55
60
|
|
|
@@ -116,6 +121,18 @@ viewer-demo DB='demo.db' PORT='8080':
|
|
|
116
121
|
just viewer {{DB}} {{PORT}} "--import-module demo_app"
|
|
117
122
|
|
|
118
123
|
|
|
124
|
+
# Build documentation (clears cache for fresh API reference)
|
|
125
|
+
docs:
|
|
126
|
+
rm -rf .cache site
|
|
127
|
+
uv run zensical build
|
|
128
|
+
|
|
129
|
+
# Serve documentation locally (clears cache for fresh API reference)
|
|
130
|
+
docs-serve:
|
|
131
|
+
@lsof -ti :8000 | xargs kill -15 2>/dev/null || true
|
|
132
|
+
@sleep 1
|
|
133
|
+
rm -rf .cache site
|
|
134
|
+
uv run zensical serve
|
|
135
|
+
|
|
119
136
|
# Clean build artifacts and caches
|
|
120
137
|
clean:
|
|
121
138
|
rm -rf .pytest_cache
|
|
@@ -123,9 +140,102 @@ clean:
|
|
|
123
140
|
rm -rf .coverage
|
|
124
141
|
rm -rf .mypy_cache
|
|
125
142
|
rm -rf .ruff_cache
|
|
143
|
+
rm -rf .cache
|
|
144
|
+
rm -rf site
|
|
126
145
|
rm -rf dist
|
|
127
146
|
rm -rf build
|
|
128
147
|
rm -rf *.egg-info
|
|
129
148
|
find . -type d -name __pycache__ -exec rm -rf {} +
|
|
130
149
|
find . -type f -name "*.pyc" -delete
|
|
131
150
|
rm -f demo.db
|
|
151
|
+
|
|
152
|
+
# Helper recipe for point-to-point test (uses bash for command substitution)
|
|
153
|
+
_test-point-to-point LOG_FILE:
|
|
154
|
+
#!/usr/bin/env bash
|
|
155
|
+
set -e
|
|
156
|
+
echo "=== Test 3: Point-to-Point Mode (Direct Message) ==="
|
|
157
|
+
echo "Starting receiver..."
|
|
158
|
+
curl -s -X POST http://localhost:8001/ \
|
|
159
|
+
-H "Content-Type: application/cloudevents+json" \
|
|
160
|
+
-d '{"specversion":"1.0","type":"direct_message_receiver_workflow","source":"test","id":"receiver-1","data":{"receiver_id":"receiver-1"}}' > /dev/null
|
|
161
|
+
sleep 2
|
|
162
|
+
# Parse instance_id from server log (format: [RECEIVER] Instance ID: workflow-uuid)
|
|
163
|
+
# Strip ANSI color codes before grepping
|
|
164
|
+
INSTANCE_ID=$(sed 's/\x1b\[[0-9;]*m//g' "{{LOG_FILE}}" | grep -o '\[RECEIVER\] Instance ID: [^ ]*' | tail -1 | cut -d' ' -f4)
|
|
165
|
+
echo "Receiver instance_id: $INSTANCE_ID"
|
|
166
|
+
if [ -z "$INSTANCE_ID" ]; then
|
|
167
|
+
echo "ERROR: Could not extract instance_id from server log"
|
|
168
|
+
echo "Log content (stripped):"
|
|
169
|
+
sed 's/\x1b\[[0-9;]*m//g' "{{LOG_FILE}}" | grep -i receiver || echo "(no receiver logs found)"
|
|
170
|
+
exit 1
|
|
171
|
+
fi
|
|
172
|
+
echo "Sending direct message to receiver..."
|
|
173
|
+
curl -s -X POST http://localhost:8001/ \
|
|
174
|
+
-H "Content-Type: application/cloudevents+json" \
|
|
175
|
+
-d "{\"specversion\":\"1.0\",\"type\":\"direct_message_sender_workflow\",\"source\":\"test\",\"id\":\"sender-1\",\"data\":{\"target_instance_id\":\"$INSTANCE_ID\",\"message\":\"Hello from sender!\"}}"
|
|
176
|
+
echo ""
|
|
177
|
+
sleep 5
|
|
178
|
+
# Verify receiver got the message (strip ANSI codes)
|
|
179
|
+
if sed 's/\x1b\[[0-9;]*m//g' "{{LOG_FILE}}" | grep -q "\[RECEIVER\] Received message:"; then
|
|
180
|
+
echo "✓ Point-to-Point message delivered successfully!"
|
|
181
|
+
else
|
|
182
|
+
echo "✗ Point-to-Point message delivery failed"
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
# Test message passing (competing, broadcast, and point-to-point modes)
|
|
186
|
+
test-messages:
|
|
187
|
+
#!/usr/bin/env bash
|
|
188
|
+
set -e
|
|
189
|
+
LOG_FILE=$(mktemp)
|
|
190
|
+
echo "=== Message Passing Test ==="
|
|
191
|
+
echo "Server log: $LOG_FILE"
|
|
192
|
+
echo "Starting demo app in background..."
|
|
193
|
+
rm -f demo.db
|
|
194
|
+
uv sync --extra server --quiet
|
|
195
|
+
PYTHONUNBUFFERED=1 uv run tsuno demo_app:application --bind 127.0.0.1:8001 --workers 1 > "$LOG_FILE" 2>&1 &
|
|
196
|
+
SERVER_PID=$!
|
|
197
|
+
sleep 2
|
|
198
|
+
|
|
199
|
+
echo ""
|
|
200
|
+
echo "=== Test 1: Competing Mode (Job Worker) ==="
|
|
201
|
+
echo "Starting worker..."
|
|
202
|
+
curl -s -X POST http://localhost:8001/ \
|
|
203
|
+
-H "Content-Type: application/cloudevents+json" \
|
|
204
|
+
-d '{"specversion":"1.0","type":"job_worker_workflow","source":"test","id":"worker-1","data":{"worker_id":"worker-1"}}'
|
|
205
|
+
echo ""
|
|
206
|
+
sleep 1
|
|
207
|
+
echo "Publishing job..."
|
|
208
|
+
curl -s -X POST http://localhost:8001/ \
|
|
209
|
+
-H "Content-Type: application/cloudevents+json" \
|
|
210
|
+
-d '{"specversion":"1.0","type":"job_publisher_workflow","source":"test","id":"job-1","data":{"task":"test-task"}}'
|
|
211
|
+
echo ""
|
|
212
|
+
sleep 2
|
|
213
|
+
|
|
214
|
+
echo ""
|
|
215
|
+
echo "=== Test 2: Broadcast Mode (Notification) ==="
|
|
216
|
+
echo "Starting notification service..."
|
|
217
|
+
curl -s -X POST http://localhost:8001/ \
|
|
218
|
+
-H "Content-Type: application/cloudevents+json" \
|
|
219
|
+
-d '{"specversion":"1.0","type":"notification_service_workflow","source":"test","id":"service-1","data":{"service_id":"service-1"}}'
|
|
220
|
+
echo ""
|
|
221
|
+
sleep 1
|
|
222
|
+
echo "Publishing notification..."
|
|
223
|
+
curl -s -X POST http://localhost:8001/ \
|
|
224
|
+
-H "Content-Type: application/cloudevents+json" \
|
|
225
|
+
-d '{"specversion":"1.0","type":"notification_publisher_workflow","source":"test","id":"notification-1","data":{"message":"Test notification"}}'
|
|
226
|
+
echo ""
|
|
227
|
+
sleep 2
|
|
228
|
+
|
|
229
|
+
just _test-point-to-point "$LOG_FILE"
|
|
230
|
+
|
|
231
|
+
echo ""
|
|
232
|
+
echo "=== Stopping demo app ==="
|
|
233
|
+
kill -15 $SERVER_PID 2>/dev/null || true
|
|
234
|
+
sleep 1
|
|
235
|
+
|
|
236
|
+
echo ""
|
|
237
|
+
echo "=== Server Log ==="
|
|
238
|
+
cat "$LOG_FILE"
|
|
239
|
+
rm -f "$LOG_FILE"
|
|
240
|
+
echo ""
|
|
241
|
+
echo "Done!"
|
|
@@ -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:
|
|
@@ -27,6 +27,7 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
|
|
|
27
27
|
- 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
|
|
28
28
|
- ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
|
|
29
29
|
- ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
|
|
30
|
+
- 📬 **Channel-based Messaging**: Actor-model style communication with competing (job queue) and broadcast (fan-out) modes
|
|
30
31
|
- 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
|
|
31
32
|
- 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
|
|
32
33
|
|
|
@@ -49,14 +50,14 @@ Edda's waiting functions make it ideal for time-based and event-driven business
|
|
|
49
50
|
- **📦 Scheduled Notifications**: Shipping updates, subscription renewals, appointment reminders
|
|
50
51
|
|
|
51
52
|
**Waiting functions**:
|
|
52
|
-
- `
|
|
53
|
-
- `
|
|
53
|
+
- `sleep(seconds)`: Wait for a relative duration
|
|
54
|
+
- `sleep_until(target_time)`: Wait until an absolute datetime (e.g., campaign end date)
|
|
54
55
|
- `wait_event(event_type)`: Wait for external events (near real-time response)
|
|
55
56
|
|
|
56
57
|
```python
|
|
57
58
|
@workflow
|
|
58
59
|
async def onboarding_reminder(ctx: WorkflowContext, user_id: str):
|
|
59
|
-
await
|
|
60
|
+
await sleep(ctx, seconds=3*24*60*60) # Wait 3 days
|
|
60
61
|
if not await check_completed(ctx, user_id):
|
|
61
62
|
await send_reminder(ctx, user_id)
|
|
62
63
|
```
|
|
@@ -108,7 +109,7 @@ graph TB
|
|
|
108
109
|
|
|
109
110
|
- Multiple workers can run simultaneously across different pods/servers
|
|
110
111
|
- Each workflow instance runs on only one worker at a time (automatic coordination)
|
|
111
|
-
- `wait_event()` and `
|
|
112
|
+
- `wait_event()` and `sleep()` free up worker resources while waiting, resume on any worker when event arrives or timer expires
|
|
112
113
|
- Automatic crash recovery with stale lock cleanup and workflow auto-resume
|
|
113
114
|
|
|
114
115
|
## Quick Start
|
|
@@ -428,7 +429,10 @@ Multiple workers can safely process workflows using database-based exclusive con
|
|
|
428
429
|
|
|
429
430
|
app = EddaApp(
|
|
430
431
|
db_url="postgresql://localhost/workflows", # Shared database for coordination
|
|
431
|
-
service_name="order-service"
|
|
432
|
+
service_name="order-service",
|
|
433
|
+
# Connection pool settings (optional)
|
|
434
|
+
pool_size=5, # Concurrent connections
|
|
435
|
+
max_overflow=10, # Additional burst capacity
|
|
432
436
|
)
|
|
433
437
|
```
|
|
434
438
|
|
|
@@ -556,10 +560,32 @@ async def payment_workflow(ctx: WorkflowContext, order_id: str):
|
|
|
556
560
|
return payment_event.data
|
|
557
561
|
```
|
|
558
562
|
|
|
559
|
-
**
|
|
563
|
+
**ReceivedEvent attributes**: The `wait_event()` function returns a `ReceivedEvent` object:
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
event = await wait_event(ctx, "payment.completed")
|
|
567
|
+
amount = event.data["amount"] # Event payload (dict or bytes)
|
|
568
|
+
source = event.metadata.source # CloudEvents source
|
|
569
|
+
event_type = event.metadata.type # CloudEvents type
|
|
570
|
+
extensions = event.extensions # CloudEvents extensions
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Timeout handling with EventTimeoutError**:
|
|
560
574
|
|
|
561
575
|
```python
|
|
562
|
-
from edda import
|
|
576
|
+
from edda import wait_event, EventTimeoutError
|
|
577
|
+
|
|
578
|
+
try:
|
|
579
|
+
event = await wait_event(ctx, "payment.completed", timeout_seconds=60)
|
|
580
|
+
except EventTimeoutError:
|
|
581
|
+
# Handle timeout (e.g., cancel order, send reminder)
|
|
582
|
+
await cancel_order(ctx, order_id)
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
**sleep() for time-based waiting**:
|
|
586
|
+
|
|
587
|
+
```python
|
|
588
|
+
from edda import sleep
|
|
563
589
|
|
|
564
590
|
@workflow
|
|
565
591
|
async def order_with_timeout(ctx: WorkflowContext, order_id: str):
|
|
@@ -567,7 +593,7 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
|
|
|
567
593
|
await create_order(ctx, order_id)
|
|
568
594
|
|
|
569
595
|
# Wait 60 seconds for payment
|
|
570
|
-
await
|
|
596
|
+
await sleep(ctx, seconds=60)
|
|
571
597
|
|
|
572
598
|
# Check payment status
|
|
573
599
|
return await check_payment(ctx, order_id)
|
|
@@ -581,6 +607,136 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
|
|
|
581
607
|
|
|
582
608
|
**For technical details**, see [Multi-Worker Continuations](local-docs/distributed-coroutines.md).
|
|
583
609
|
|
|
610
|
+
### Message Passing (Workflow-to-Workflow)
|
|
611
|
+
|
|
612
|
+
Edda provides actor-model style message passing for direct workflow-to-workflow communication:
|
|
613
|
+
|
|
614
|
+
```python
|
|
615
|
+
from edda import workflow, wait_message, send_message_to, WorkflowContext
|
|
616
|
+
|
|
617
|
+
# Receiver workflow - waits for approval message
|
|
618
|
+
@workflow
|
|
619
|
+
async def approval_workflow(ctx: WorkflowContext, request_id: str):
|
|
620
|
+
# Wait for message on "approval" channel
|
|
621
|
+
msg = await wait_message(ctx, channel="approval")
|
|
622
|
+
|
|
623
|
+
if msg.data["approved"]:
|
|
624
|
+
return {"status": "approved", "approver": msg.data["approver"]}
|
|
625
|
+
return {"status": "rejected"}
|
|
626
|
+
|
|
627
|
+
# Sender workflow - sends approval decision
|
|
628
|
+
@workflow
|
|
629
|
+
async def manager_workflow(ctx: WorkflowContext, request_id: str):
|
|
630
|
+
# Review and make decision
|
|
631
|
+
decision = await review_request(ctx, request_id)
|
|
632
|
+
|
|
633
|
+
# Send message to waiting workflow
|
|
634
|
+
await send_message_to(
|
|
635
|
+
ctx,
|
|
636
|
+
target_instance_id=request_id,
|
|
637
|
+
channel="approval",
|
|
638
|
+
data={"approved": decision, "approver": "manager-123"},
|
|
639
|
+
)
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Group Communication (Erlang pg style)** - for fan-out messaging without knowing receiver instance IDs:
|
|
643
|
+
|
|
644
|
+
```python
|
|
645
|
+
from edda import workflow, join_group, wait_message, publish_to_group
|
|
646
|
+
|
|
647
|
+
# Receiver workflow - joins a group and listens
|
|
648
|
+
@workflow
|
|
649
|
+
async def notification_service(ctx: WorkflowContext, service_id: str):
|
|
650
|
+
# Join group at startup (loose coupling - sender doesn't need to know us)
|
|
651
|
+
await join_group(ctx, group="order_watchers")
|
|
652
|
+
|
|
653
|
+
while True:
|
|
654
|
+
msg = await wait_message(ctx, channel="order.created")
|
|
655
|
+
await send_notification(ctx, msg.data)
|
|
656
|
+
|
|
657
|
+
# Sender workflow - publishes to all group members
|
|
658
|
+
@workflow
|
|
659
|
+
async def order_processor(ctx: WorkflowContext, order_id: str):
|
|
660
|
+
result = await process_order(ctx, order_id)
|
|
661
|
+
|
|
662
|
+
# Broadcast to all watchers (doesn't need to know instance IDs)
|
|
663
|
+
count = await publish_to_group(
|
|
664
|
+
ctx,
|
|
665
|
+
group="order_watchers",
|
|
666
|
+
channel="order.created",
|
|
667
|
+
data={"order_id": order_id, "status": "completed"},
|
|
668
|
+
)
|
|
669
|
+
print(f"Notified {count} watchers")
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
**Channel API with Delivery Modes** - subscribe to channels with explicit delivery semantics:
|
|
673
|
+
|
|
674
|
+
```python
|
|
675
|
+
from edda import workflow, subscribe, receive, publish, WorkflowContext
|
|
676
|
+
|
|
677
|
+
# Job Worker - processes jobs exclusively (competing mode)
|
|
678
|
+
@workflow
|
|
679
|
+
async def job_worker(ctx: WorkflowContext, worker_id: str):
|
|
680
|
+
# Subscribe with competing mode - each job goes to ONE worker only
|
|
681
|
+
await subscribe(ctx, channel="jobs", mode="competing")
|
|
682
|
+
|
|
683
|
+
while True:
|
|
684
|
+
job = await receive(ctx, channel="jobs") # Get next job
|
|
685
|
+
await process_job(ctx, job.data)
|
|
686
|
+
await ctx.recur(worker_id) # Continue processing
|
|
687
|
+
|
|
688
|
+
# Notification Handler - receives ALL messages (broadcast mode)
|
|
689
|
+
@workflow
|
|
690
|
+
async def notification_handler(ctx: WorkflowContext, handler_id: str):
|
|
691
|
+
# Subscribe with broadcast mode - ALL handlers receive each message
|
|
692
|
+
await subscribe(ctx, channel="notifications", mode="broadcast")
|
|
693
|
+
|
|
694
|
+
while True:
|
|
695
|
+
msg = await receive(ctx, channel="notifications")
|
|
696
|
+
await send_notification(ctx, msg.data)
|
|
697
|
+
await ctx.recur(handler_id)
|
|
698
|
+
|
|
699
|
+
# Publisher - send messages to channel
|
|
700
|
+
await publish(ctx, channel="jobs", data={"task": "send_report"})
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**Delivery modes**:
|
|
704
|
+
- **`competing`**: Each message goes to exactly ONE subscriber (job queue/task distribution)
|
|
705
|
+
- **`broadcast`**: Each message goes to ALL subscribers (notifications/fan-out)
|
|
706
|
+
|
|
707
|
+
**Key features**:
|
|
708
|
+
- **Channel-based messaging**: Messages are delivered to workflows waiting on specific channels
|
|
709
|
+
- **Competing vs Broadcast**: Choose semantics per subscription
|
|
710
|
+
- **Group communication**: Erlang pg-style groups for loose coupling and fan-out
|
|
711
|
+
- **Database-backed**: All messages are persisted for durability
|
|
712
|
+
- **Lock-first delivery**: Safe for multi-worker environments
|
|
713
|
+
|
|
714
|
+
### Workflow Recurrence
|
|
715
|
+
|
|
716
|
+
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.):
|
|
717
|
+
|
|
718
|
+
```python
|
|
719
|
+
from edda import workflow, subscribe, receive, WorkflowContext
|
|
720
|
+
|
|
721
|
+
@workflow
|
|
722
|
+
async def job_worker(ctx: WorkflowContext, worker_id: str):
|
|
723
|
+
await subscribe(ctx, channel="jobs", mode="competing")
|
|
724
|
+
|
|
725
|
+
# Process one job
|
|
726
|
+
job = await receive(ctx, channel="jobs")
|
|
727
|
+
await process_job(ctx, job.data)
|
|
728
|
+
|
|
729
|
+
# Archive history and restart with same instance_id
|
|
730
|
+
# Prevents unbounded history growth
|
|
731
|
+
await ctx.recur(worker_id)
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
**Key benefits**:
|
|
735
|
+
- **Prevents history growth**: Archives old history, starts fresh
|
|
736
|
+
- **Maintains instance ID**: Same workflow continues logically
|
|
737
|
+
- **Preserves subscriptions**: Channel subscriptions survive recurrence
|
|
738
|
+
- **Enables infinite loops**: Essential for long-running workers
|
|
739
|
+
|
|
584
740
|
### ASGI Integration
|
|
585
741
|
|
|
586
742
|
Edda runs as an ASGI application:
|