edda-framework 0.7.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.
Files changed (174) hide show
  1. {edda_framework-0.7.0 → edda_framework-0.8.0}/Justfile +110 -0
  2. {edda_framework-0.7.0 → edda_framework-0.8.0}/PKG-INFO +165 -9
  3. {edda_framework-0.7.0 → edda_framework-0.8.0}/README.md +164 -8
  4. {edda_framework-0.7.0 → edda_framework-0.8.0}/demo_app.py +426 -36
  5. edda_framework-0.8.0/docs/api/reference.md +143 -0
  6. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/durable-execution/replay.md +1 -1
  7. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/events/wait-event.md +17 -10
  8. edda_framework-0.8.0/docs/core-features/messages.md +516 -0
  9. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/retry.md +5 -3
  10. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/workflows-activities.md +175 -2
  11. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/examples/events.md +8 -8
  12. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/getting-started/first-workflow.md +1 -1
  13. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/index.md +6 -4
  14. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/integrations/mcp.md +87 -1
  15. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/integrations/opentelemetry.md +1 -1
  16. edda_framework-0.8.0/docs/integrations/pydantic-rpc.md +193 -0
  17. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/__init__.py +39 -5
  18. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/app.py +383 -223
  19. edda_framework-0.8.0/edda/channels.py +992 -0
  20. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/compensation.py +22 -22
  21. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/context.py +77 -51
  22. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/integrations/opentelemetry/hooks.py +7 -2
  23. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/locking.py +130 -67
  24. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/replay.py +312 -82
  25. edda_framework-0.8.0/edda/storage/models.py +335 -0
  26. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/storage/protocol.py +557 -118
  27. edda_framework-0.8.0/edda/storage/sqlalchemy_storage.py +3563 -0
  28. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/viewer_ui/app.py +6 -1
  29. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/viewer_ui/data_service.py +19 -22
  30. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/workflow.py +43 -0
  31. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/cancellable_workflow.py +3 -4
  32. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/event_waiting_app.py +9 -9
  33. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/event_waiting_workflow.py +1 -1
  34. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/event_waiting_workflow_complete.py +3 -3
  35. edda_framework-0.8.0/examples/long_running_loop.py +274 -0
  36. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/mcp/order_processing_mcp.py +5 -5
  37. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/mcp/prompts_example.py +5 -5
  38. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/mcp/remote_server_example.py +3 -3
  39. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/mcp/simple_mcp_server.py +1 -1
  40. edda_framework-0.8.0/examples/message_passing.py +263 -0
  41. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/observability_with_logfire.py +6 -6
  42. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/observability_with_opentelemetry.py +3 -4
  43. edda_framework-0.8.0/examples/pydantic_rpc_integration.py +461 -0
  44. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/pydantic_saga.py +1 -0
  45. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/retry_example.py +18 -19
  46. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/retry_with_compensation.py +29 -32
  47. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/typeddict_example.py +0 -1
  48. {edda_framework-0.7.0 → edda_framework-0.8.0}/pyproject.toml +3 -2
  49. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/conftest.py +47 -10
  50. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_cancel.py +2 -2
  51. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_server.py +4 -4
  52. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_atomic_wait_event.py +80 -52
  53. edda_framework-0.8.0/tests/test_auto_migration.py +405 -0
  54. edda_framework-0.8.0/tests/test_channel_competing.py +432 -0
  55. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_distributed_event_delivery.py +28 -15
  56. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_events.py +115 -57
  57. edda_framework-0.8.0/tests/test_instance_id_routing.py +366 -0
  58. edda_framework-0.8.0/tests/test_message_cleanup.py +198 -0
  59. edda_framework-0.8.0/tests/test_message_delivery_lock.py +303 -0
  60. edda_framework-0.8.0/tests/test_messages.py +477 -0
  61. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_multidb_storage.py +39 -20
  62. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_pydantic_events.py +34 -27
  63. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_received_event.py +56 -38
  64. edda_framework-0.8.0/tests/test_recur.py +581 -0
  65. edda_framework-0.8.0/tests/test_recur_cleanup.py +310 -0
  66. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_stale_workflow_recovery.py +3 -0
  67. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_storage.py +43 -19
  68. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_wait_timer.py +12 -12
  69. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_workflow_cancellation.py +28 -16
  70. edda_framework-0.8.0/tests/test_workflow_resumption.py +253 -0
  71. {edda_framework-0.7.0 → edda_framework-0.8.0}/uv.lock +195 -17
  72. {edda_framework-0.7.0 → edda_framework-0.8.0}/viewer_app.py +2 -3
  73. {edda_framework-0.7.0 → edda_framework-0.8.0}/zensical.toml +15 -0
  74. edda_framework-0.7.0/edda/events.py +0 -505
  75. edda_framework-0.7.0/edda/storage/models.py +0 -194
  76. edda_framework-0.7.0/edda/storage/sqlalchemy_storage.py +0 -1909
  77. {edda_framework-0.7.0 → edda_framework-0.8.0}/.github/workflows/ci.yml +0 -0
  78. {edda_framework-0.7.0 → edda_framework-0.8.0}/.github/workflows/docs.yml +0 -0
  79. {edda_framework-0.7.0 → edda_framework-0.8.0}/.github/workflows/release.yml +0 -0
  80. {edda_framework-0.7.0 → edda_framework-0.8.0}/.gitignore +0 -0
  81. {edda_framework-0.7.0 → edda_framework-0.8.0}/.python-version +0 -0
  82. {edda_framework-0.7.0 → edda_framework-0.8.0}/LICENSE +0 -0
  83. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/events/cloudevents-http-binding.md +0 -0
  84. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/hooks.md +0 -0
  85. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/saga-compensation.md +0 -0
  86. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/core-features/transactional-outbox.md +0 -0
  87. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/examples/ecommerce.md +0 -0
  88. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/examples/fastapi-integration.md +0 -0
  89. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/examples/saga.md +0 -0
  90. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/examples/simple.md +0 -0
  91. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/getting-started/concepts.md +0 -0
  92. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/getting-started/installation.md +0 -0
  93. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/getting-started/quick-start.md +0 -0
  94. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
  95. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/compensation-execution.png +0 -0
  96. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
  97. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
  98. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
  99. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
  100. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
  101. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/workflow-list-view.png +0 -0
  102. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
  103. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/setup.md +0 -0
  104. {edda_framework-0.7.0 → edda_framework-0.8.0}/docs/viewer-ui/visualization.md +0 -0
  105. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/activity.py +0 -0
  106. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/exceptions.py +0 -0
  107. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/hooks.py +0 -0
  108. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/integrations/__init__.py +0 -0
  109. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/integrations/mcp/__init__.py +0 -0
  110. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/integrations/mcp/decorators.py +0 -0
  111. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/integrations/mcp/server.py +0 -0
  112. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/integrations/opentelemetry/__init__.py +0 -0
  113. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/outbox/__init__.py +0 -0
  114. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/outbox/relayer.py +0 -0
  115. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/outbox/transactional.py +0 -0
  116. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/pydantic_utils.py +0 -0
  117. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/retry.py +0 -0
  118. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/serialization/__init__.py +0 -0
  119. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/serialization/base.py +0 -0
  120. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/serialization/json.py +0 -0
  121. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/storage/__init__.py +0 -0
  122. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/viewer_ui/__init__.py +0 -0
  123. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/viewer_ui/components.py +0 -0
  124. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/viewer_ui/theme.py +0 -0
  125. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/visualizer/__init__.py +0 -0
  126. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/visualizer/ast_analyzer.py +0 -0
  127. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/visualizer/mermaid_generator.py +0 -0
  128. {edda_framework-0.7.0 → edda_framework-0.8.0}/edda/wsgi.py +0 -0
  129. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/__init__.py +0 -0
  130. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/compensation_workflow.py +0 -0
  131. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/mcp/README.md +0 -0
  132. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/simple_workflow.py +0 -0
  133. {edda_framework-0.7.0 → edda_framework-0.8.0}/examples/with_outbox.py +0 -0
  134. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/__init__.py +0 -0
  135. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/__init__.py +0 -0
  136. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/mcp/__init__.py +0 -0
  137. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_integration.py +0 -0
  138. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_jsonrpc.py +0 -0
  139. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/mcp/test_prompts.py +0 -0
  140. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/opentelemetry/__init__.py +0 -0
  141. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/integrations/opentelemetry/test_hooks.py +0 -0
  142. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_activity.py +0 -0
  143. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_activity_retry.py +0 -0
  144. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_activity_sync.py +0 -0
  145. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_app.py +0 -0
  146. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_ast_analyzer.py +0 -0
  147. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_binary_data.py +0 -0
  148. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_cloudevents_http_binding.py +0 -0
  149. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_compensation.py +0 -0
  150. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_compensation_crash_recovery.py.wip +0 -0
  151. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_concurrent_outbox.py +0 -0
  152. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_context.py +0 -0
  153. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_ctx_session.py +0 -0
  154. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_lock_race_condition.py +0 -0
  155. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_lock_timeout_customization.py +0 -0
  156. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_locking.py +0 -0
  157. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_outbox.py +0 -0
  158. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_pydantic_activity.py +0 -0
  159. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_pydantic_enum.py +0 -0
  160. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_pydantic_saga.py +0 -0
  161. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_pydantic_utils.py +0 -0
  162. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_replay.py +0 -0
  163. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_retry_policy.py +0 -0
  164. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_saga_parameter_extraction.py +0 -0
  165. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_serialization.py +0 -0
  166. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_skip_locked.py +0 -0
  167. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_storage_mysql.py +0 -0
  168. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_storage_postgresql.py +0 -0
  169. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_transactions.py +0 -0
  170. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_viewer_pagination.py +0 -0
  171. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_viewer_pydantic_form.py +0 -0
  172. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_viewer_start_saga.py +0 -0
  173. {edda_framework-0.7.0 → edda_framework-0.8.0}/tests/test_workflow.py +0 -0
  174. {edda_framework-0.7.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.7.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
@@ -85,6 +85,7 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
85
85
  - 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
86
86
  - ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
87
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
88
89
  - 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
89
90
  - 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
90
91
 
@@ -107,14 +108,14 @@ Edda's waiting functions make it ideal for time-based and event-driven business
107
108
  - **📦 Scheduled Notifications**: Shipping updates, subscription renewals, appointment reminders
108
109
 
109
110
  **Waiting functions**:
110
- - `wait_timer(duration_seconds)`: Wait for a relative duration
111
- - `wait_until(until_time)`: Wait until an absolute datetime (e.g., campaign end date)
111
+ - `sleep(seconds)`: Wait for a relative duration
112
+ - `sleep_until(target_time)`: Wait until an absolute datetime (e.g., campaign end date)
112
113
  - `wait_event(event_type)`: Wait for external events (near real-time response)
113
114
 
114
115
  ```python
115
116
  @workflow
116
117
  async def onboarding_reminder(ctx: WorkflowContext, user_id: str):
117
- await wait_timer(ctx, duration_seconds=3*24*60*60) # Wait 3 days
118
+ await sleep(ctx, seconds=3*24*60*60) # Wait 3 days
118
119
  if not await check_completed(ctx, user_id):
119
120
  await send_reminder(ctx, user_id)
120
121
  ```
@@ -166,7 +167,7 @@ graph TB
166
167
 
167
168
  - Multiple workers can run simultaneously across different pods/servers
168
169
  - Each workflow instance runs on only one worker at a time (automatic coordination)
169
- - `wait_event()` and `wait_timer()` free up worker resources while waiting, resume on any worker when event arrives or timer expires
170
+ - `wait_event()` and `sleep()` free up worker resources while waiting, resume on any worker when event arrives or timer expires
170
171
  - Automatic crash recovery with stale lock cleanup and workflow auto-resume
171
172
 
172
173
  ## Quick Start
@@ -486,7 +487,10 @@ Multiple workers can safely process workflows using database-based exclusive con
486
487
 
487
488
  app = EddaApp(
488
489
  db_url="postgresql://localhost/workflows", # Shared database for coordination
489
- 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
490
494
  )
491
495
  ```
492
496
 
@@ -614,10 +618,32 @@ async def payment_workflow(ctx: WorkflowContext, order_id: str):
614
618
  return payment_event.data
615
619
  ```
616
620
 
617
- **wait_timer() for time-based waiting**:
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**:
618
632
 
619
633
  ```python
620
- from edda import wait_timer
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
621
647
 
622
648
  @workflow
623
649
  async def order_with_timeout(ctx: WorkflowContext, order_id: str):
@@ -625,7 +651,7 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
625
651
  await create_order(ctx, order_id)
626
652
 
627
653
  # Wait 60 seconds for payment
628
- await wait_timer(ctx, duration_seconds=60)
654
+ await sleep(ctx, seconds=60)
629
655
 
630
656
  # Check payment status
631
657
  return await check_payment(ctx, order_id)
@@ -639,6 +665,136 @@ async def order_with_timeout(ctx: WorkflowContext, order_id: str):
639
665
 
640
666
  **For technical details**, see [Multi-Worker Continuations](local-docs/distributed-coroutines.md).
641
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
+
642
798
  ### ASGI Integration
643
799
 
644
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
- - `wait_timer(duration_seconds)`: Wait for a relative duration
53
- - `wait_until(until_time)`: Wait until an absolute datetime (e.g., campaign end date)
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 wait_timer(ctx, duration_seconds=3*24*60*60) # Wait 3 days
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 `wait_timer()` free up worker resources while waiting, resume on any worker when event arrives or timer expires
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
- **wait_timer() for time-based waiting**:
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 wait_timer
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 wait_timer(ctx, duration_seconds=60)
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: