edda-framework 0.8.0__tar.gz → 0.9.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.
Files changed (172) hide show
  1. {edda_framework-0.8.0 → edda_framework-0.9.0}/PKG-INFO +10 -66
  2. {edda_framework-0.8.0 → edda_framework-0.9.0}/README.md +9 -65
  3. {edda_framework-0.8.0 → edda_framework-0.9.0}/demo_app.py +6 -10
  4. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/messages.md +74 -0
  5. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/workflows-activities.md +4 -4
  6. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/channels.py +34 -9
  7. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/context.py +28 -1
  8. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/storage/models.py +0 -23
  9. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/storage/protocol.py +18 -36
  10. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/storage/sqlalchemy_storage.py +110 -226
  11. {edda_framework-0.8.0 → edda_framework-0.9.0}/pyproject.toml +5 -1
  12. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/conftest.py +0 -4
  13. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_auto_migration.py +0 -16
  14. edda_framework-0.9.0/tests/test_channel_transactional.py +351 -0
  15. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_message_delivery_lock.py +16 -6
  16. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_multidb_storage.py +19 -7
  17. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_recur_cleanup.py +31 -12
  18. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_storage.py +21 -9
  19. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_workflow_cancellation.py +24 -12
  20. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_workflow_resumption.py +9 -3
  21. {edda_framework-0.8.0 → edda_framework-0.9.0}/uv.lock +1 -1
  22. {edda_framework-0.8.0 → edda_framework-0.9.0}/.github/workflows/ci.yml +0 -0
  23. {edda_framework-0.8.0 → edda_framework-0.9.0}/.github/workflows/docs.yml +0 -0
  24. {edda_framework-0.8.0 → edda_framework-0.9.0}/.github/workflows/release.yml +0 -0
  25. {edda_framework-0.8.0 → edda_framework-0.9.0}/.gitignore +0 -0
  26. {edda_framework-0.8.0 → edda_framework-0.9.0}/.python-version +0 -0
  27. {edda_framework-0.8.0 → edda_framework-0.9.0}/Justfile +0 -0
  28. {edda_framework-0.8.0 → edda_framework-0.9.0}/LICENSE +0 -0
  29. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/api/reference.md +0 -0
  30. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/durable-execution/replay.md +0 -0
  31. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/events/cloudevents-http-binding.md +0 -0
  32. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/events/wait-event.md +0 -0
  33. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/hooks.md +0 -0
  34. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/retry.md +0 -0
  35. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/saga-compensation.md +0 -0
  36. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/core-features/transactional-outbox.md +0 -0
  37. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/examples/ecommerce.md +0 -0
  38. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/examples/events.md +0 -0
  39. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/examples/fastapi-integration.md +0 -0
  40. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/examples/saga.md +0 -0
  41. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/examples/simple.md +0 -0
  42. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/getting-started/concepts.md +0 -0
  43. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/getting-started/first-workflow.md +0 -0
  44. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/getting-started/installation.md +0 -0
  45. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/getting-started/quick-start.md +0 -0
  46. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/index.md +0 -0
  47. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/integrations/mcp.md +0 -0
  48. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/integrations/opentelemetry.md +0 -0
  49. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/integrations/pydantic-rpc.md +0 -0
  50. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
  51. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/compensation-execution.png +0 -0
  52. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
  53. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
  54. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
  55. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
  56. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
  57. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/workflow-list-view.png +0 -0
  58. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
  59. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/setup.md +0 -0
  60. {edda_framework-0.8.0 → edda_framework-0.9.0}/docs/viewer-ui/visualization.md +0 -0
  61. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/__init__.py +0 -0
  62. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/activity.py +0 -0
  63. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/app.py +0 -0
  64. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/compensation.py +0 -0
  65. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/exceptions.py +0 -0
  66. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/hooks.py +0 -0
  67. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/integrations/__init__.py +0 -0
  68. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/integrations/mcp/__init__.py +0 -0
  69. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/integrations/mcp/decorators.py +0 -0
  70. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/integrations/mcp/server.py +0 -0
  71. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/integrations/opentelemetry/__init__.py +0 -0
  72. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/integrations/opentelemetry/hooks.py +0 -0
  73. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/locking.py +0 -0
  74. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/outbox/__init__.py +0 -0
  75. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/outbox/relayer.py +0 -0
  76. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/outbox/transactional.py +0 -0
  77. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/pydantic_utils.py +0 -0
  78. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/replay.py +0 -0
  79. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/retry.py +0 -0
  80. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/serialization/__init__.py +0 -0
  81. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/serialization/base.py +0 -0
  82. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/serialization/json.py +0 -0
  83. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/storage/__init__.py +0 -0
  84. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/viewer_ui/__init__.py +0 -0
  85. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/viewer_ui/app.py +0 -0
  86. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/viewer_ui/components.py +0 -0
  87. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/viewer_ui/data_service.py +0 -0
  88. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/viewer_ui/theme.py +0 -0
  89. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/visualizer/__init__.py +0 -0
  90. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/visualizer/ast_analyzer.py +0 -0
  91. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/visualizer/mermaid_generator.py +0 -0
  92. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/workflow.py +0 -0
  93. {edda_framework-0.8.0 → edda_framework-0.9.0}/edda/wsgi.py +0 -0
  94. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/__init__.py +0 -0
  95. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/cancellable_workflow.py +0 -0
  96. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/compensation_workflow.py +0 -0
  97. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/event_waiting_app.py +0 -0
  98. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/event_waiting_workflow.py +0 -0
  99. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/event_waiting_workflow_complete.py +0 -0
  100. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/long_running_loop.py +0 -0
  101. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/mcp/README.md +0 -0
  102. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/mcp/order_processing_mcp.py +0 -0
  103. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/mcp/prompts_example.py +0 -0
  104. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/mcp/remote_server_example.py +0 -0
  105. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/mcp/simple_mcp_server.py +0 -0
  106. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/message_passing.py +0 -0
  107. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/observability_with_logfire.py +0 -0
  108. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/observability_with_opentelemetry.py +0 -0
  109. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/pydantic_rpc_integration.py +0 -0
  110. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/pydantic_saga.py +0 -0
  111. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/retry_example.py +0 -0
  112. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/retry_with_compensation.py +0 -0
  113. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/simple_workflow.py +0 -0
  114. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/typeddict_example.py +0 -0
  115. {edda_framework-0.8.0 → edda_framework-0.9.0}/examples/with_outbox.py +0 -0
  116. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/__init__.py +0 -0
  117. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/__init__.py +0 -0
  118. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/mcp/__init__.py +0 -0
  119. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/mcp/test_cancel.py +0 -0
  120. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/mcp/test_integration.py +0 -0
  121. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/mcp/test_jsonrpc.py +0 -0
  122. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/mcp/test_prompts.py +0 -0
  123. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/mcp/test_server.py +0 -0
  124. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/opentelemetry/__init__.py +0 -0
  125. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/integrations/opentelemetry/test_hooks.py +0 -0
  126. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_activity.py +0 -0
  127. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_activity_retry.py +0 -0
  128. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_activity_sync.py +0 -0
  129. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_app.py +0 -0
  130. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_ast_analyzer.py +0 -0
  131. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_atomic_wait_event.py +0 -0
  132. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_binary_data.py +0 -0
  133. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_channel_competing.py +0 -0
  134. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_cloudevents_http_binding.py +0 -0
  135. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_compensation.py +0 -0
  136. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_compensation_crash_recovery.py.wip +0 -0
  137. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_concurrent_outbox.py +0 -0
  138. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_context.py +0 -0
  139. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_ctx_session.py +0 -0
  140. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_distributed_event_delivery.py +0 -0
  141. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_events.py +0 -0
  142. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_instance_id_routing.py +0 -0
  143. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_lock_race_condition.py +0 -0
  144. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_lock_timeout_customization.py +0 -0
  145. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_locking.py +0 -0
  146. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_message_cleanup.py +0 -0
  147. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_messages.py +0 -0
  148. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_outbox.py +0 -0
  149. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_pydantic_activity.py +0 -0
  150. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_pydantic_enum.py +0 -0
  151. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_pydantic_events.py +0 -0
  152. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_pydantic_saga.py +0 -0
  153. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_pydantic_utils.py +0 -0
  154. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_received_event.py +0 -0
  155. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_recur.py +0 -0
  156. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_replay.py +0 -0
  157. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_retry_policy.py +0 -0
  158. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_saga_parameter_extraction.py +0 -0
  159. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_serialization.py +0 -0
  160. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_skip_locked.py +0 -0
  161. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_stale_workflow_recovery.py +0 -0
  162. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_storage_mysql.py +0 -0
  163. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_storage_postgresql.py +0 -0
  164. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_transactions.py +0 -0
  165. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_viewer_pagination.py +0 -0
  166. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_viewer_pydantic_form.py +0 -0
  167. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_viewer_start_saga.py +0 -0
  168. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_wait_timer.py +0 -0
  169. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_workflow.py +0 -0
  170. {edda_framework-0.8.0 → edda_framework-0.9.0}/tests/test_workflow_auto_register.py +0 -0
  171. {edda_framework-0.8.0 → edda_framework-0.9.0}/viewer_app.py +0 -0
  172. {edda_framework-0.8.0 → edda_framework-0.9.0}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -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,80 @@ 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
+ - If the activity **succeeds**: Message is published after commit
521
+ - If the activity **fails**: Message is **NOT** published (rollback)
522
+
523
+ This ensures that messages are only sent when the associated business logic succeeds.
524
+
525
+ ### Transactional Receive
526
+
527
+ When `receive()` is called inside an activity, the message claim is part of the transaction:
528
+
529
+ ```python
530
+ @activity # transactional=True by default
531
+ async def process_job(ctx: WorkflowContext, channel: str):
532
+ msg = await receive(ctx, channel) # Claim is part of transaction
533
+
534
+ # Process the message...
535
+ result = await do_work(msg.data)
536
+
537
+ return result # Commit: claim is finalized
538
+ ```
539
+
540
+ **Behavior:**
541
+ - If the activity **succeeds**: Message claim is committed, message is processed
542
+ - If the activity **fails**: Message claim is rolled back, message returns to queue
543
+
544
+ This provides **at-least-once delivery** semantics - if processing fails, the message will be redelivered to another subscriber.
545
+
546
+ ### Recommended Pattern
547
+
548
+ For reliable message processing, wrap `receive()` calls inside activities:
549
+
550
+ ```python
551
+ @workflow
552
+ async def job_worker(ctx: WorkflowContext, worker_id: str):
553
+ await subscribe(ctx, "jobs", mode="competing")
554
+
555
+ while True:
556
+ # Process job inside activity for transactional guarantees
557
+ await process_job(ctx, "jobs", activity_id="process:1")
558
+ await ctx.recur(worker_id)
559
+
560
+ @activity
561
+ async def process_job(ctx: WorkflowContext, channel: str):
562
+ msg = await receive(ctx, channel) # Part of activity transaction
563
+
564
+ # Do work...
565
+ await execute_task(msg.data)
566
+
567
+ # Publish completion notification (also transactional)
568
+ await publish(ctx, "job.completed", {"job_id": msg.id})
569
+
570
+ return {"processed": msg.id}
571
+ ```
572
+
499
573
  ## Performance Considerations
500
574
 
501
575
  ### 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
@@ -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
 
@@ -451,6 +451,33 @@ class WorkflowContext:
451
451
  """
452
452
  return self.storage.in_transaction()
453
453
 
454
+ def register_post_commit(self, callback: Callable[[], Awaitable[None]]) -> None:
455
+ """
456
+ Register a callback to be executed after the current transaction commits.
457
+
458
+ The callback will be executed after the top-level transaction commits successfully.
459
+ If the transaction is rolled back, the callback will NOT be executed.
460
+ This is useful for deferring side effects (like message delivery) until after
461
+ the transaction has been committed.
462
+
463
+ Args:
464
+ callback: An async function to call after commit.
465
+
466
+ Raises:
467
+ RuntimeError: If not in a transaction.
468
+
469
+ Example:
470
+ async with ctx.transaction():
471
+ # Save order to database
472
+ await ctx.storage.append_history(...)
473
+
474
+ # Defer message delivery until after commit
475
+ async def deliver_notifications():
476
+ await notify_subscribers(order_id)
477
+ ctx.register_post_commit(deliver_notifications)
478
+ """
479
+ self.storage.register_post_commit_callback(callback)
480
+
454
481
  async def recur(self, **kwargs: Any) -> None:
455
482
  """
456
483
  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
@@ -5,6 +5,7 @@ This module defines the StorageProtocol using Python's structural typing (Protoc
5
5
  Any storage implementation that conforms to this protocol can be used with Edda.
6
6
  """
7
7
 
8
+ from collections.abc import Awaitable, Callable
8
9
  from datetime import datetime
9
10
  from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
10
11
 
@@ -104,6 +105,21 @@ class StorageProtocol(Protocol):
104
105
  """
105
106
  ...
106
107
 
108
+ def register_post_commit_callback(self, callback: Callable[[], Awaitable[None]]) -> None:
109
+ """
110
+ Register a callback to be executed after the current transaction commits.
111
+
112
+ The callback will be executed after the top-level transaction commits successfully.
113
+ If the transaction is rolled back, the callback will NOT be executed.
114
+
115
+ Args:
116
+ callback: An async function to call after commit.
117
+
118
+ Raises:
119
+ RuntimeError: If not in a transaction.
120
+ """
121
+ ...
122
+
107
123
  # -------------------------------------------------------------------------
108
124
  # Workflow Definition Methods
109
125
  # -------------------------------------------------------------------------
@@ -713,39 +729,6 @@ class StorageProtocol(Protocol):
713
729
  # Message Subscription Methods (for wait_message)
714
730
  # -------------------------------------------------------------------------
715
731
 
716
- async def register_message_subscription_and_release_lock(
717
- self,
718
- instance_id: str,
719
- worker_id: str,
720
- channel: str,
721
- timeout_at: datetime | None = None,
722
- activity_id: str | None = None,
723
- ) -> None:
724
- """
725
- Atomically register message subscription and release workflow lock.
726
-
727
- This method performs the following operations in a SINGLE database transaction:
728
- 1. Register message subscription (INSERT into workflow_message_subscriptions)
729
- 2. Update current activity (UPDATE workflow_instances.current_activity_id)
730
- 3. Update status to 'waiting_for_event'
731
- 4. Release lock (UPDATE workflow_instances set locked_by=NULL)
732
-
733
- This ensures that when a workflow calls wait_message(), the subscription is
734
- registered and the lock is released atomically, preventing race conditions
735
- in distributed environments (distributed coroutines pattern).
736
-
737
- Args:
738
- instance_id: Workflow instance ID
739
- worker_id: Worker ID that currently holds the lock
740
- channel: Channel name to wait on
741
- timeout_at: Optional timeout timestamp
742
- activity_id: Current activity ID to record
743
-
744
- Raises:
745
- RuntimeError: If the worker doesn't hold the lock (sanity check)
746
- """
747
- ...
748
-
749
732
  async def find_waiting_instances_by_channel(
750
733
  self,
751
734
  channel: str,
@@ -911,9 +894,8 @@ class StorageProtocol(Protocol):
911
894
 
912
895
  Removes entries from:
913
896
  - workflow_timer_subscriptions
914
- - workflow_message_subscriptions
915
- - channel_subscriptions (new)
916
- - channel_message_claims (new)
897
+ - channel_subscriptions
898
+ - channel_message_claims
917
899
 
918
900
  Args:
919
901
  instance_id: Workflow instance ID to clean up