edda-framework 0.8.0__tar.gz → 0.9.1__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.
Files changed (173) hide show
  1. {edda_framework-0.8.0 → edda_framework-0.9.1}/.github/workflows/docs.yml +3 -3
  2. {edda_framework-0.8.0 → edda_framework-0.9.1}/PKG-INFO +10 -66
  3. {edda_framework-0.8.0 → edda_framework-0.9.1}/README.md +9 -65
  4. {edda_framework-0.8.0 → edda_framework-0.9.1}/demo_app.py +6 -10
  5. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/messages.md +76 -0
  6. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/workflows-activities.md +4 -4
  7. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/index.md +1 -1
  8. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/app.py +15 -36
  9. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/channels.py +34 -9
  10. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/context.py +36 -1
  11. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/storage/models.py +0 -23
  12. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/storage/protocol.py +18 -36
  13. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/storage/sqlalchemy_storage.py +110 -226
  14. {edda_framework-0.8.0 → edda_framework-0.9.1}/pyproject.toml +5 -1
  15. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/conftest.py +0 -4
  16. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_auto_migration.py +0 -16
  17. edda_framework-0.9.1/tests/test_channel_transactional.py +351 -0
  18. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_message_delivery_lock.py +16 -6
  19. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_multidb_storage.py +19 -7
  20. edda_framework-0.9.1/tests/test_receive_timeout.py +217 -0
  21. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_recur_cleanup.py +31 -12
  22. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_storage.py +21 -9
  23. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_workflow_cancellation.py +24 -12
  24. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_workflow_resumption.py +9 -3
  25. {edda_framework-0.8.0 → edda_framework-0.9.1}/uv.lock +1 -1
  26. {edda_framework-0.8.0 → edda_framework-0.9.1}/.github/workflows/ci.yml +0 -0
  27. {edda_framework-0.8.0 → edda_framework-0.9.1}/.github/workflows/release.yml +0 -0
  28. {edda_framework-0.8.0 → edda_framework-0.9.1}/.gitignore +0 -0
  29. {edda_framework-0.8.0 → edda_framework-0.9.1}/.python-version +0 -0
  30. {edda_framework-0.8.0 → edda_framework-0.9.1}/Justfile +0 -0
  31. {edda_framework-0.8.0 → edda_framework-0.9.1}/LICENSE +0 -0
  32. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/api/reference.md +0 -0
  33. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/durable-execution/replay.md +0 -0
  34. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/events/cloudevents-http-binding.md +0 -0
  35. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/events/wait-event.md +0 -0
  36. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/hooks.md +0 -0
  37. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/retry.md +0 -0
  38. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/saga-compensation.md +0 -0
  39. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/core-features/transactional-outbox.md +0 -0
  40. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/examples/ecommerce.md +0 -0
  41. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/examples/events.md +0 -0
  42. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/examples/fastapi-integration.md +0 -0
  43. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/examples/saga.md +0 -0
  44. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/examples/simple.md +0 -0
  45. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/getting-started/concepts.md +0 -0
  46. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/getting-started/first-workflow.md +0 -0
  47. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/getting-started/installation.md +0 -0
  48. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/getting-started/quick-start.md +0 -0
  49. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/integrations/mcp.md +0 -0
  50. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/integrations/opentelemetry.md +0 -0
  51. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/integrations/pydantic-rpc.md +0 -0
  52. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
  53. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/compensation-execution.png +0 -0
  54. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
  55. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
  56. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
  57. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
  58. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
  59. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-list-view.png +0 -0
  60. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
  61. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/setup.md +0 -0
  62. {edda_framework-0.8.0 → edda_framework-0.9.1}/docs/viewer-ui/visualization.md +0 -0
  63. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/__init__.py +0 -0
  64. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/activity.py +0 -0
  65. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/compensation.py +0 -0
  66. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/exceptions.py +0 -0
  67. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/hooks.py +0 -0
  68. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/integrations/__init__.py +0 -0
  69. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/integrations/mcp/__init__.py +0 -0
  70. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/integrations/mcp/decorators.py +0 -0
  71. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/integrations/mcp/server.py +0 -0
  72. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/integrations/opentelemetry/__init__.py +0 -0
  73. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/integrations/opentelemetry/hooks.py +0 -0
  74. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/locking.py +0 -0
  75. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/outbox/__init__.py +0 -0
  76. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/outbox/relayer.py +0 -0
  77. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/outbox/transactional.py +0 -0
  78. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/pydantic_utils.py +0 -0
  79. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/replay.py +0 -0
  80. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/retry.py +0 -0
  81. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/serialization/__init__.py +0 -0
  82. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/serialization/base.py +0 -0
  83. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/serialization/json.py +0 -0
  84. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/storage/__init__.py +0 -0
  85. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/viewer_ui/__init__.py +0 -0
  86. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/viewer_ui/app.py +0 -0
  87. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/viewer_ui/components.py +0 -0
  88. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/viewer_ui/data_service.py +0 -0
  89. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/viewer_ui/theme.py +0 -0
  90. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/visualizer/__init__.py +0 -0
  91. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/visualizer/ast_analyzer.py +0 -0
  92. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/visualizer/mermaid_generator.py +0 -0
  93. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/workflow.py +0 -0
  94. {edda_framework-0.8.0 → edda_framework-0.9.1}/edda/wsgi.py +0 -0
  95. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/__init__.py +0 -0
  96. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/cancellable_workflow.py +0 -0
  97. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/compensation_workflow.py +0 -0
  98. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/event_waiting_app.py +0 -0
  99. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/event_waiting_workflow.py +0 -0
  100. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/event_waiting_workflow_complete.py +0 -0
  101. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/long_running_loop.py +0 -0
  102. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/mcp/README.md +0 -0
  103. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/mcp/order_processing_mcp.py +0 -0
  104. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/mcp/prompts_example.py +0 -0
  105. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/mcp/remote_server_example.py +0 -0
  106. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/mcp/simple_mcp_server.py +0 -0
  107. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/message_passing.py +0 -0
  108. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/observability_with_logfire.py +0 -0
  109. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/observability_with_opentelemetry.py +0 -0
  110. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/pydantic_rpc_integration.py +0 -0
  111. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/pydantic_saga.py +0 -0
  112. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/retry_example.py +0 -0
  113. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/retry_with_compensation.py +0 -0
  114. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/simple_workflow.py +0 -0
  115. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/typeddict_example.py +0 -0
  116. {edda_framework-0.8.0 → edda_framework-0.9.1}/examples/with_outbox.py +0 -0
  117. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/__init__.py +0 -0
  118. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/__init__.py +0 -0
  119. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/mcp/__init__.py +0 -0
  120. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_cancel.py +0 -0
  121. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_integration.py +0 -0
  122. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_jsonrpc.py +0 -0
  123. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_prompts.py +0 -0
  124. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_server.py +0 -0
  125. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/__init__.py +0 -0
  126. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/test_hooks.py +0 -0
  127. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_activity.py +0 -0
  128. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_activity_retry.py +0 -0
  129. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_activity_sync.py +0 -0
  130. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_app.py +0 -0
  131. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_ast_analyzer.py +0 -0
  132. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_atomic_wait_event.py +0 -0
  133. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_binary_data.py +0 -0
  134. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_channel_competing.py +0 -0
  135. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_cloudevents_http_binding.py +0 -0
  136. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_compensation.py +0 -0
  137. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_compensation_crash_recovery.py.wip +0 -0
  138. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_concurrent_outbox.py +0 -0
  139. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_context.py +0 -0
  140. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_ctx_session.py +0 -0
  141. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_distributed_event_delivery.py +0 -0
  142. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_events.py +0 -0
  143. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_instance_id_routing.py +0 -0
  144. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_lock_race_condition.py +0 -0
  145. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_lock_timeout_customization.py +0 -0
  146. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_locking.py +0 -0
  147. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_message_cleanup.py +0 -0
  148. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_messages.py +0 -0
  149. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_outbox.py +0 -0
  150. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_pydantic_activity.py +0 -0
  151. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_pydantic_enum.py +0 -0
  152. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_pydantic_events.py +0 -0
  153. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_pydantic_saga.py +0 -0
  154. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_pydantic_utils.py +0 -0
  155. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_received_event.py +0 -0
  156. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_recur.py +0 -0
  157. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_replay.py +0 -0
  158. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_retry_policy.py +0 -0
  159. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_saga_parameter_extraction.py +0 -0
  160. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_serialization.py +0 -0
  161. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_skip_locked.py +0 -0
  162. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_stale_workflow_recovery.py +0 -0
  163. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_storage_mysql.py +0 -0
  164. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_storage_postgresql.py +0 -0
  165. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_transactions.py +0 -0
  166. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_viewer_pagination.py +0 -0
  167. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_viewer_pydantic_form.py +0 -0
  168. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_viewer_start_saga.py +0 -0
  169. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_wait_timer.py +0 -0
  170. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_workflow.py +0 -0
  171. {edda_framework-0.8.0 → edda_framework-0.9.1}/tests/test_workflow_auto_register.py +0 -0
  172. {edda_framework-0.8.0 → edda_framework-0.9.1}/viewer_app.py +0 -0
  173. {edda_framework-0.8.0 → edda_framework-0.9.1}/zensical.toml +0 -0
@@ -23,11 +23,11 @@ jobs:
23
23
  with:
24
24
  python-version: "3.11"
25
25
 
26
- - name: Install Zensical
27
- run: pip install zensical
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v7
28
28
 
29
29
  - name: Build documentation
30
- run: zensical build --clean
30
+ run: uv run --with zensical --with mkdocstrings-python -- zensical build --clean
31
31
 
32
32
  - name: Upload artifact
33
33
  uses: actions/upload-pages-artifact@v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.8.0
3
+ Version: 0.9.1
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
@@ -65,6 +65,7 @@ Description-Content-Type: text/markdown
65
65
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
66
66
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
67
67
  [![Documentation](https://img.shields.io/badge/docs-latest-green.svg)](https://i2y.github.io/edda/)
68
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/i2y/edda)
68
69
 
69
70
  ## Overview
70
71
 
@@ -665,72 +666,12 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
665
666
 
666
667
  **For technical details**, see [Multi-Worker Continuations](local-docs/distributed-coroutines.md).
667
668
 
668
- ### Message Passing (Workflow-to-Workflow)
669
+ ### Channel-based Messaging
669
670
 
670
- Edda provides actor-model style message passing for direct workflow-to-workflow communication:
671
+ Edda provides channel-based messaging for workflow-to-workflow communication with two delivery modes:
671
672
 
672
673
  ```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
674
+ from edda import workflow, subscribe, receive, publish, send_to, WorkflowContext
734
675
 
735
676
  # Job Worker - processes jobs exclusively (competing mode)
736
677
  @workflow
@@ -754,8 +695,11 @@ async def notification_handler(ctx: WorkflowContext, handler_id: str):
754
695
  await send_notification(ctx, msg.data)
755
696
  await ctx.recur(handler_id)
756
697
 
757
- # Publisher - send messages to channel
698
+ # Publish to channel (all subscribers or one competing subscriber)
758
699
  await publish(ctx, channel="jobs", data={"task": "send_report"})
700
+
701
+ # Direct message to specific workflow instance
702
+ await send_to(ctx, instance_id="workflow-123", channel="approval", data={"approved": True})
759
703
  ```
760
704
 
761
705
  **Delivery modes**:
@@ -765,7 +709,7 @@ await publish(ctx, channel="jobs", data={"task": "send_report"})
765
709
  **Key features**:
766
710
  - **Channel-based messaging**: Messages are delivered to workflows waiting on specific channels
767
711
  - **Competing vs Broadcast**: Choose semantics per subscription
768
- - **Group communication**: Erlang pg-style groups for loose coupling and fan-out
712
+ - **Direct messaging**: `send_to()` for workflow-to-workflow communication
769
713
  - **Database-backed**: All messages are persisted for durability
770
714
  - **Lock-first delivery**: Safe for multi-worker environments
771
715
 
@@ -7,6 +7,7 @@
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
9
9
  [![Documentation](https://img.shields.io/badge/docs-latest-green.svg)](https://i2y.github.io/edda/)
10
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/i2y/edda)
10
11
 
11
12
  ## Overview
12
13
 
@@ -607,72 +608,12 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
607
608
 
608
609
  **For technical details**, see [Multi-Worker Continuations](local-docs/distributed-coroutines.md).
609
610
 
610
- ### Message Passing (Workflow-to-Workflow)
611
+ ### Channel-based Messaging
611
612
 
612
- Edda provides actor-model style message passing for direct workflow-to-workflow communication:
613
+ Edda provides channel-based messaging for workflow-to-workflow communication with two delivery modes:
613
614
 
614
615
  ```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
616
+ from edda import workflow, subscribe, receive, publish, send_to, WorkflowContext
676
617
 
677
618
  # Job Worker - processes jobs exclusively (competing mode)
678
619
  @workflow
@@ -696,8 +637,11 @@ async def notification_handler(ctx: WorkflowContext, handler_id: str):
696
637
  await send_notification(ctx, msg.data)
697
638
  await ctx.recur(handler_id)
698
639
 
699
- # Publisher - send messages to channel
640
+ # Publish to channel (all subscribers or one competing subscriber)
700
641
  await publish(ctx, channel="jobs", data={"task": "send_report"})
642
+
643
+ # Direct message to specific workflow instance
644
+ await send_to(ctx, instance_id="workflow-123", channel="approval", data={"approved": True})
701
645
  ```
702
646
 
703
647
  **Delivery modes**:
@@ -707,7 +651,7 @@ await publish(ctx, channel="jobs", data={"task": "send_report"})
707
651
  **Key features**:
708
652
  - **Channel-based messaging**: Messages are delivered to workflows waiting on specific channels
709
653
  - **Competing vs Broadcast**: Choose semantics per subscription
710
- - **Group communication**: Erlang pg-style groups for loose coupling and fan-out
654
+ - **Direct messaging**: `send_to()` for workflow-to-workflow communication
711
655
  - **Database-backed**: All messages are persisted for durability
712
656
  - **Lock-first delivery**: Safe for multi-worker environments
713
657
 
@@ -1953,19 +1953,17 @@ async def job_worker_workflow(
1953
1953
  - Start this workflow multiple times with different worker_id values
1954
1954
  - Each worker will subscribe to the "jobs" channel in competing mode
1955
1955
 
1956
- 2. Publish jobs to the channel:
1956
+ 2. Publish jobs using the job_publisher_workflow:
1957
1957
  curl -X POST http://localhost:8001/ \\
1958
1958
  -H "Content-Type: application/cloudevents+json" \\
1959
1959
  -d '{
1960
1960
  "specversion": "1.0",
1961
- "type": "job_published",
1961
+ "type": "job_publisher_workflow",
1962
1962
  "source": "demo-client",
1963
1963
  "id": "job-1",
1964
1964
  "datacontenttype": "application/json",
1965
1965
  "data": {
1966
- "channel": "jobs",
1967
- "task": "send_report",
1968
- "user_id": 123
1966
+ "task": "send_report"
1969
1967
  }
1970
1968
  }'
1971
1969
 
@@ -2043,19 +2041,17 @@ async def notification_service_workflow(
2043
2041
  - Start this workflow multiple times with different service_id values
2044
2042
  - Each instance will subscribe to the "notifications" channel
2045
2043
 
2046
- 2. Publish a notification:
2044
+ 2. Publish a notification using the notification_publisher_workflow:
2047
2045
  curl -X POST http://localhost:8001/ \\
2048
2046
  -H "Content-Type: application/cloudevents+json" \\
2049
2047
  -d '{
2050
2048
  "specversion": "1.0",
2051
- "type": "notification_published",
2049
+ "type": "notification_publisher_workflow",
2052
2050
  "source": "demo-client",
2053
2051
  "id": "notification-1",
2054
2052
  "datacontenttype": "application/json",
2055
2053
  "data": {
2056
- "channel": "notifications",
2057
- "message": "System maintenance scheduled",
2058
- "priority": "high"
2054
+ "message": "System maintenance scheduled"
2059
2055
  }
2060
2056
  }'
2061
2057
 
@@ -496,6 +496,82 @@ async def temporary_subscriber(ctx: WorkflowContext):
496
496
  # Continue with other work...
497
497
  ```
498
498
 
499
+ ## Transactional Message Processing
500
+
501
+ When using channel-based messaging inside activities, both `publish()` and `receive()` participate in the activity's database transaction.
502
+
503
+ ### Transactional Publish
504
+
505
+ When `publish()` is called inside an activity, the message is only published **after the transaction commits**:
506
+
507
+ ```python
508
+ @activity # transactional=True by default
509
+ async def process_order(ctx: WorkflowContext, order_id: str):
510
+ # Do some work...
511
+ result = await do_processing(order_id)
512
+
513
+ # Message is queued for post-commit delivery
514
+ await publish(ctx, "order.completed", {"order_id": order_id})
515
+
516
+ return result # Commit: message is now published
517
+ ```
518
+
519
+ **Behavior:**
520
+
521
+ - If the activity **succeeds**: Message is published after commit
522
+ - If the activity **fails**: Message is **NOT** published (rollback)
523
+
524
+ This ensures that messages are only sent when the associated business logic succeeds.
525
+
526
+ ### Transactional Receive
527
+
528
+ When `receive()` is called inside an activity, the message claim is part of the transaction:
529
+
530
+ ```python
531
+ @activity # transactional=True by default
532
+ async def process_job(ctx: WorkflowContext, channel: str):
533
+ msg = await receive(ctx, channel) # Claim is part of transaction
534
+
535
+ # Process the message...
536
+ result = await do_work(msg.data)
537
+
538
+ return result # Commit: claim is finalized
539
+ ```
540
+
541
+ **Behavior:**
542
+
543
+ - If the activity **succeeds**: Message claim is committed, message is processed
544
+ - If the activity **fails**: Message claim is rolled back, message returns to queue
545
+
546
+ This provides **at-least-once delivery** semantics - if processing fails, the message will be redelivered to another subscriber.
547
+
548
+ ### Recommended Pattern
549
+
550
+ For reliable message processing, wrap `receive()` calls inside activities:
551
+
552
+ ```python
553
+ @workflow
554
+ async def job_worker(ctx: WorkflowContext, worker_id: str):
555
+ await subscribe(ctx, "jobs", mode="competing")
556
+
557
+ while True:
558
+ # Process job inside activity for transactional guarantees
559
+ await process_job(ctx, "jobs", activity_id="process:1")
560
+ await ctx.recur(worker_id)
561
+
562
+ @activity
563
+ async def process_job(ctx: WorkflowContext, channel: str):
564
+ msg = await receive(ctx, channel) # Part of activity transaction
565
+
566
+ # Do work...
567
+ await execute_task(msg.data)
568
+
569
+ # Publish completion notification (also transactional)
570
+ await publish(ctx, "job.completed", {"job_id": msg.id})
571
+
572
+ return {"processed": msg.id}
573
+ ```
574
+
499
575
  ## Performance Considerations
500
576
 
501
577
  ### Database-Backed Durability
@@ -764,10 +764,10 @@ In long-running loops, every activity adds an entry to the workflow history. Aft
764
764
  # ❌ Problematic: History grows forever
765
765
  @workflow
766
766
  async def notification_service(ctx: WorkflowContext):
767
- await join_group(ctx, group="order_watchers")
767
+ await subscribe(ctx, "order.completed", mode="broadcast")
768
768
 
769
769
  while True:
770
- msg = await wait_message(ctx, channel="order.completed")
770
+ msg = await receive(ctx, "order.completed")
771
771
  await send_notification(ctx, msg.data)
772
772
  # After 10,000 iterations: 10,000+ history entries!
773
773
  ```
@@ -780,11 +780,11 @@ Use `ctx.recur()` to restart the workflow with fresh history while preserving st
780
780
  # ✅ Good: Reset history periodically
781
781
  @workflow
782
782
  async def notification_service(ctx: WorkflowContext, processed_count: int = 0):
783
- await join_group(ctx, group="order_watchers")
783
+ await subscribe(ctx, "order.completed", mode="broadcast")
784
784
 
785
785
  count = 0
786
786
  while True:
787
- msg = await wait_message(ctx, channel="order.completed")
787
+ msg = await receive(ctx, "order.completed")
788
788
  await send_notification(ctx, msg.data, activity_id=f"notify:{msg.id}")
789
789
 
790
790
  count += 1
@@ -26,7 +26,7 @@ Edda is a lightweight durable execution framework for Python that runs as a **li
26
26
  - 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
27
27
  - ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
28
28
  - ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
29
- - 📬 **Message Passing**: Actor-model style workflow-to-workflow communication with groups for fan-out (Erlang pg style)
29
+ - 📬 **Message Passing**: Channel-based messaging (broadcast/competing modes) and direct workflow-to-workflow communication
30
30
 
31
31
  ## Use Cases
32
32
 
@@ -709,7 +709,6 @@ class EddaApp:
709
709
  instance_id = subscription["instance_id"]
710
710
  channel = subscription["channel"]
711
711
  timeout_at = subscription["timeout_at"]
712
- created_at = subscription["created_at"]
713
712
 
714
713
  # Lock-First pattern: Try to acquire the lock before processing
715
714
  # If we can't get the lock, another worker is processing this workflow
@@ -777,48 +776,28 @@ class EddaApp:
777
776
  # 2. Remove message subscription
778
777
  await self.storage.remove_message_subscription(instance_id, channel)
779
778
 
780
- # 3. Fail the workflow with TimeoutError
781
- import traceback
782
-
783
- # Get timeout_seconds from timeout_at and created_at
784
- # Handle both datetime objects and ISO strings
785
- try:
786
- timeout_dt = (
787
- timeout_at
788
- if isinstance(timeout_at, dt_type)
789
- else dt_type.fromisoformat(str(timeout_at))
790
- )
791
- created_dt = (
792
- created_at
793
- if isinstance(created_at, dt_type)
794
- else dt_type.fromisoformat(str(created_at))
779
+ # 3. Resume workflow (lock already held - distributed coroutine pattern)
780
+ # The workflow will replay and receive() will raise TimeoutError from cached history
781
+ workflow_name = subscription.get("workflow_name")
782
+ if not workflow_name:
783
+ logger.warning(
784
+ "No workflow_name in subscription for %s, skipping",
785
+ instance_id,
795
786
  )
796
- # Calculate the original timeout duration (timeout_at - created_at)
797
- timeout_seconds = int((timeout_dt - created_dt).total_seconds())
798
- except Exception:
799
- timeout_seconds = 0 # Fallback
787
+ continue
800
788
 
801
- error = TimeoutError(
802
- f"Message on channel '{channel}' did not arrive within {timeout_seconds} seconds"
803
- )
804
- stack_trace = "".join(
805
- traceback.format_exception(type(error), error, error.__traceback__)
806
- )
789
+ if self.replay_engine is None:
790
+ logger.error("Replay engine not initialized")
791
+ continue
807
792
 
808
- # Update workflow status to failed with error details
809
- await self.storage.update_instance_status(
810
- instance_id,
811
- "failed",
812
- {
813
- "error_message": str(error),
814
- "error_type": "TimeoutError",
815
- "stack_trace": stack_trace,
816
- },
793
+ await self.replay_engine.resume_by_name(
794
+ instance_id, workflow_name, already_locked=True
817
795
  )
818
796
 
819
797
  logger.debug(
820
- "Marked workflow %s as failed due to message timeout",
798
+ "Resumed workflow %s after message timeout on channel '%s'",
821
799
  instance_id,
800
+ channel,
822
801
  )
823
802
 
824
803
  except Exception as e:
@@ -445,15 +445,40 @@ async def publish(
445
445
  message_id = await storage.publish_to_channel(channel, data, full_metadata)
446
446
 
447
447
  # Wake up waiting subscribers
448
- await _wake_waiting_subscribers(
449
- storage,
450
- channel,
451
- message_id,
452
- data,
453
- full_metadata,
454
- target_instance_id=target_instance_id,
455
- worker_id=effective_worker_id,
456
- )
448
+ # If in a transaction, defer delivery until after commit to ensure atomicity
449
+ if storage.in_transaction():
450
+ # Capture current values for the closure
451
+ _storage = storage
452
+ _channel = channel
453
+ _message_id = message_id
454
+ _data = data
455
+ _metadata = full_metadata
456
+ _target_instance_id = target_instance_id
457
+ _worker_id = effective_worker_id
458
+
459
+ async def deferred_wake() -> None:
460
+ await _wake_waiting_subscribers(
461
+ _storage,
462
+ _channel,
463
+ _message_id,
464
+ _data,
465
+ _metadata,
466
+ target_instance_id=_target_instance_id,
467
+ worker_id=_worker_id,
468
+ )
469
+
470
+ storage.register_post_commit_callback(deferred_wake)
471
+ else:
472
+ # Not in transaction - deliver immediately
473
+ await _wake_waiting_subscribers(
474
+ storage,
475
+ channel,
476
+ message_id,
477
+ data,
478
+ full_metadata,
479
+ target_instance_id=target_instance_id,
480
+ worker_id=effective_worker_id,
481
+ )
457
482
 
458
483
  return message_id
459
484
 
@@ -5,7 +5,7 @@ This module provides the WorkflowContext class for workflow execution,
5
5
  managing state, history, and replay during workflow execution.
6
6
  """
7
7
 
8
- from collections.abc import AsyncIterator
8
+ from collections.abc import AsyncIterator, Awaitable, Callable
9
9
  from contextlib import asynccontextmanager
10
10
  from typing import TYPE_CHECKING, Any, cast
11
11
 
@@ -217,6 +217,14 @@ class WorkflowContext:
217
217
  # Cache the timer result for wait_timer replay
218
218
  # Timer returns None, so we cache the result field
219
219
  self._history_cache[activity_id] = event_data.get("result")
220
+ elif event_type == "MessageTimeout":
221
+ # Cache the timeout error for receive() replay
222
+ # This allows TimeoutError to be raised and caught in workflow code
223
+ self._history_cache[activity_id] = {
224
+ "_error": True,
225
+ "error_type": event_data.get("error_type", "TimeoutError"),
226
+ "error_message": event_data.get("error_message", "Message timeout"),
227
+ }
220
228
 
221
229
  self._history_loaded = True
222
230
 
@@ -451,6 +459,33 @@ class WorkflowContext:
451
459
  """
452
460
  return self.storage.in_transaction()
453
461
 
462
+ def register_post_commit(self, callback: Callable[[], Awaitable[None]]) -> None:
463
+ """
464
+ Register a callback to be executed after the current transaction commits.
465
+
466
+ The callback will be executed after the top-level transaction commits successfully.
467
+ If the transaction is rolled back, the callback will NOT be executed.
468
+ This is useful for deferring side effects (like message delivery) until after
469
+ the transaction has been committed.
470
+
471
+ Args:
472
+ callback: An async function to call after commit.
473
+
474
+ Raises:
475
+ RuntimeError: If not in a transaction.
476
+
477
+ Example:
478
+ async with ctx.transaction():
479
+ # Save order to database
480
+ await ctx.storage.append_history(...)
481
+
482
+ # Defer message delivery until after commit
483
+ async def deliver_notifications():
484
+ await notify_subscribers(order_id)
485
+ ctx.register_post_commit(deliver_notifications)
486
+ """
487
+ self.storage.register_post_commit_callback(callback)
488
+
454
489
  async def recur(self, **kwargs: Any) -> None:
455
490
  """
456
491
  Restart the workflow with fresh history (Erlang-style tail recursion).
@@ -136,27 +136,6 @@ WORKFLOW_TIMER_SUBSCRIPTIONS_INDEXES = [
136
136
  "CREATE INDEX IF NOT EXISTS idx_timer_subscriptions_instance ON workflow_timer_subscriptions(instance_id);",
137
137
  ]
138
138
 
139
- # SQL schema for message subscriptions (for wait_message)
140
- WORKFLOW_MESSAGE_SUBSCRIPTIONS_TABLE = """
141
- CREATE TABLE IF NOT EXISTS workflow_message_subscriptions (
142
- id INTEGER PRIMARY KEY AUTOINCREMENT,
143
- instance_id TEXT NOT NULL,
144
- channel TEXT NOT NULL,
145
- activity_id TEXT,
146
- timeout_at TEXT,
147
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
148
- FOREIGN KEY (instance_id) REFERENCES workflow_instances(instance_id) ON DELETE CASCADE,
149
- CONSTRAINT unique_instance_channel UNIQUE (instance_id, channel)
150
- );
151
- """
152
-
153
- # Indexes for message subscriptions
154
- WORKFLOW_MESSAGE_SUBSCRIPTIONS_INDEXES = [
155
- "CREATE INDEX IF NOT EXISTS idx_message_subscriptions_channel ON workflow_message_subscriptions(channel);",
156
- "CREATE INDEX IF NOT EXISTS idx_message_subscriptions_timeout ON workflow_message_subscriptions(timeout_at);",
157
- "CREATE INDEX IF NOT EXISTS idx_message_subscriptions_instance ON workflow_message_subscriptions(instance_id);",
158
- ]
159
-
160
139
  # SQL schema for group memberships (Erlang pg style)
161
140
  WORKFLOW_GROUP_MEMBERSHIPS_TABLE = """
162
141
  CREATE TABLE IF NOT EXISTS workflow_group_memberships (
@@ -306,7 +285,6 @@ ALL_TABLES = [
306
285
  WORKFLOW_HISTORY_ARCHIVE_TABLE,
307
286
  WORKFLOW_COMPENSATIONS_TABLE,
308
287
  WORKFLOW_TIMER_SUBSCRIPTIONS_TABLE,
309
- WORKFLOW_MESSAGE_SUBSCRIPTIONS_TABLE,
310
288
  WORKFLOW_GROUP_MEMBERSHIPS_TABLE,
311
289
  OUTBOX_EVENTS_TABLE,
312
290
  # Channel-based Message Queue System
@@ -324,7 +302,6 @@ ALL_INDEXES = (
324
302
  + WORKFLOW_HISTORY_ARCHIVE_INDEXES
325
303
  + WORKFLOW_COMPENSATIONS_INDEXES
326
304
  + WORKFLOW_TIMER_SUBSCRIPTIONS_INDEXES
327
- + WORKFLOW_MESSAGE_SUBSCRIPTIONS_INDEXES
328
305
  + WORKFLOW_GROUP_MEMBERSHIPS_INDEXES
329
306
  + OUTBOX_EVENTS_INDEXES
330
307
  # Channel-based Message Queue System