edda-framework 0.9.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.9.0 → edda_framework-0.9.1}/.github/workflows/docs.yml +3 -3
  2. {edda_framework-0.9.0 → edda_framework-0.9.1}/PKG-INFO +1 -1
  3. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/messages.md +2 -0
  4. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/index.md +1 -1
  5. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/app.py +15 -36
  6. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/context.py +8 -0
  7. {edda_framework-0.9.0 → edda_framework-0.9.1}/pyproject.toml +1 -1
  8. edda_framework-0.9.1/tests/test_receive_timeout.py +217 -0
  9. {edda_framework-0.9.0 → edda_framework-0.9.1}/uv.lock +1 -1
  10. {edda_framework-0.9.0 → edda_framework-0.9.1}/.github/workflows/ci.yml +0 -0
  11. {edda_framework-0.9.0 → edda_framework-0.9.1}/.github/workflows/release.yml +0 -0
  12. {edda_framework-0.9.0 → edda_framework-0.9.1}/.gitignore +0 -0
  13. {edda_framework-0.9.0 → edda_framework-0.9.1}/.python-version +0 -0
  14. {edda_framework-0.9.0 → edda_framework-0.9.1}/Justfile +0 -0
  15. {edda_framework-0.9.0 → edda_framework-0.9.1}/LICENSE +0 -0
  16. {edda_framework-0.9.0 → edda_framework-0.9.1}/README.md +0 -0
  17. {edda_framework-0.9.0 → edda_framework-0.9.1}/demo_app.py +0 -0
  18. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/api/reference.md +0 -0
  19. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/durable-execution/replay.md +0 -0
  20. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/events/cloudevents-http-binding.md +0 -0
  21. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/events/wait-event.md +0 -0
  22. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/hooks.md +0 -0
  23. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/retry.md +0 -0
  24. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/saga-compensation.md +0 -0
  25. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/transactional-outbox.md +0 -0
  26. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/workflows-activities.md +0 -0
  27. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/ecommerce.md +0 -0
  28. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/events.md +0 -0
  29. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/fastapi-integration.md +0 -0
  30. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/saga.md +0 -0
  31. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/simple.md +0 -0
  32. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/concepts.md +0 -0
  33. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/first-workflow.md +0 -0
  34. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/installation.md +0 -0
  35. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/quick-start.md +0 -0
  36. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/integrations/mcp.md +0 -0
  37. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/integrations/opentelemetry.md +0 -0
  38. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/integrations/pydantic-rpc.md +0 -0
  39. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
  40. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/compensation-execution.png +0 -0
  41. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
  42. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
  43. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
  44. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
  45. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
  46. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-list-view.png +0 -0
  47. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
  48. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/setup.md +0 -0
  49. {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/visualization.md +0 -0
  50. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/__init__.py +0 -0
  51. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/activity.py +0 -0
  52. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/channels.py +0 -0
  53. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/compensation.py +0 -0
  54. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/exceptions.py +0 -0
  55. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/hooks.py +0 -0
  56. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/__init__.py +0 -0
  57. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/mcp/__init__.py +0 -0
  58. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/mcp/decorators.py +0 -0
  59. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/mcp/server.py +0 -0
  60. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/opentelemetry/__init__.py +0 -0
  61. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/opentelemetry/hooks.py +0 -0
  62. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/locking.py +0 -0
  63. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/outbox/__init__.py +0 -0
  64. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/outbox/relayer.py +0 -0
  65. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/outbox/transactional.py +0 -0
  66. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/pydantic_utils.py +0 -0
  67. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/replay.py +0 -0
  68. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/retry.py +0 -0
  69. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/serialization/__init__.py +0 -0
  70. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/serialization/base.py +0 -0
  71. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/serialization/json.py +0 -0
  72. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/__init__.py +0 -0
  73. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/models.py +0 -0
  74. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/protocol.py +0 -0
  75. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/sqlalchemy_storage.py +0 -0
  76. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/__init__.py +0 -0
  77. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/app.py +0 -0
  78. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/components.py +0 -0
  79. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/data_service.py +0 -0
  80. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/theme.py +0 -0
  81. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/visualizer/__init__.py +0 -0
  82. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/visualizer/ast_analyzer.py +0 -0
  83. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/visualizer/mermaid_generator.py +0 -0
  84. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/workflow.py +0 -0
  85. {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/wsgi.py +0 -0
  86. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/__init__.py +0 -0
  87. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/cancellable_workflow.py +0 -0
  88. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/compensation_workflow.py +0 -0
  89. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/event_waiting_app.py +0 -0
  90. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/event_waiting_workflow.py +0 -0
  91. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/event_waiting_workflow_complete.py +0 -0
  92. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/long_running_loop.py +0 -0
  93. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/README.md +0 -0
  94. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/order_processing_mcp.py +0 -0
  95. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/prompts_example.py +0 -0
  96. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/remote_server_example.py +0 -0
  97. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/simple_mcp_server.py +0 -0
  98. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/message_passing.py +0 -0
  99. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/observability_with_logfire.py +0 -0
  100. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/observability_with_opentelemetry.py +0 -0
  101. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/pydantic_rpc_integration.py +0 -0
  102. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/pydantic_saga.py +0 -0
  103. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/retry_example.py +0 -0
  104. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/retry_with_compensation.py +0 -0
  105. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/simple_workflow.py +0 -0
  106. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/typeddict_example.py +0 -0
  107. {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/with_outbox.py +0 -0
  108. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/__init__.py +0 -0
  109. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/conftest.py +0 -0
  110. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/__init__.py +0 -0
  111. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/__init__.py +0 -0
  112. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_cancel.py +0 -0
  113. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_integration.py +0 -0
  114. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_jsonrpc.py +0 -0
  115. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_prompts.py +0 -0
  116. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_server.py +0 -0
  117. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/__init__.py +0 -0
  118. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/test_hooks.py +0 -0
  119. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_activity.py +0 -0
  120. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_activity_retry.py +0 -0
  121. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_activity_sync.py +0 -0
  122. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_app.py +0 -0
  123. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_ast_analyzer.py +0 -0
  124. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_atomic_wait_event.py +0 -0
  125. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_auto_migration.py +0 -0
  126. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_binary_data.py +0 -0
  127. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_channel_competing.py +0 -0
  128. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_channel_transactional.py +0 -0
  129. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_cloudevents_http_binding.py +0 -0
  130. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_compensation.py +0 -0
  131. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_compensation_crash_recovery.py.wip +0 -0
  132. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_concurrent_outbox.py +0 -0
  133. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_context.py +0 -0
  134. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_ctx_session.py +0 -0
  135. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_distributed_event_delivery.py +0 -0
  136. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_events.py +0 -0
  137. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_instance_id_routing.py +0 -0
  138. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_lock_race_condition.py +0 -0
  139. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_lock_timeout_customization.py +0 -0
  140. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_locking.py +0 -0
  141. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_message_cleanup.py +0 -0
  142. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_message_delivery_lock.py +0 -0
  143. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_messages.py +0 -0
  144. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_multidb_storage.py +0 -0
  145. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_outbox.py +0 -0
  146. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_activity.py +0 -0
  147. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_enum.py +0 -0
  148. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_events.py +0 -0
  149. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_saga.py +0 -0
  150. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_utils.py +0 -0
  151. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_received_event.py +0 -0
  152. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_recur.py +0 -0
  153. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_recur_cleanup.py +0 -0
  154. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_replay.py +0 -0
  155. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_retry_policy.py +0 -0
  156. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_saga_parameter_extraction.py +0 -0
  157. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_serialization.py +0 -0
  158. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_skip_locked.py +0 -0
  159. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_stale_workflow_recovery.py +0 -0
  160. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_storage.py +0 -0
  161. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_storage_mysql.py +0 -0
  162. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_storage_postgresql.py +0 -0
  163. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_transactions.py +0 -0
  164. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_viewer_pagination.py +0 -0
  165. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_viewer_pydantic_form.py +0 -0
  166. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_viewer_start_saga.py +0 -0
  167. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_wait_timer.py +0 -0
  168. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow.py +0 -0
  169. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow_auto_register.py +0 -0
  170. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow_cancellation.py +0 -0
  171. {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow_resumption.py +0 -0
  172. {edda_framework-0.9.0 → edda_framework-0.9.1}/viewer_app.py +0 -0
  173. {edda_framework-0.9.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.9.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
@@ -517,6 +517,7 @@ async def process_order(ctx: WorkflowContext, order_id: str):
517
517
  ```
518
518
 
519
519
  **Behavior:**
520
+
520
521
  - If the activity **succeeds**: Message is published after commit
521
522
  - If the activity **fails**: Message is **NOT** published (rollback)
522
523
 
@@ -538,6 +539,7 @@ async def process_job(ctx: WorkflowContext, channel: str):
538
539
  ```
539
540
 
540
541
  **Behavior:**
542
+
541
543
  - If the activity **succeeds**: Message claim is committed, message is processed
542
544
  - If the activity **fails**: Message claim is rolled back, message returns to queue
543
545
 
@@ -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:
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edda-framework"
3
- version = "0.9.0"
3
+ version = "0.9.1"
4
4
  description = "Lightweight Durable Execution Framework"
5
5
  authors = [
6
6
  { name = "Yasushi Itoh", email = "6240399+i2y@users.noreply.github.com" }
@@ -0,0 +1,217 @@
1
+ """
2
+ Tests for receive() timeout handling.
3
+
4
+ Tests verify that:
5
+ 1. TimeoutError is raised during replay when history contains timeout error
6
+ 2. Workflow can catch TimeoutError with try/except
7
+ 3. _check_expired_message_subscriptions() resumes workflow instead of failing it
8
+ """
9
+
10
+ import pytest
11
+ import pytest_asyncio
12
+
13
+ from edda.channels import receive, subscribe
14
+ from edda.context import WorkflowContext
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ class TestReceiveTimeoutReplay:
19
+ """Test that receive() raises TimeoutError during replay when history contains timeout."""
20
+
21
+ @pytest_asyncio.fixture
22
+ async def workflow_instance(self, sqlite_storage, create_test_instance):
23
+ """Create a workflow instance for testing."""
24
+ instance_id = "test-timeout-instance-001"
25
+ await create_test_instance(
26
+ instance_id=instance_id,
27
+ workflow_name="test_workflow",
28
+ owner_service="test-service",
29
+ input_data={"test": "data"},
30
+ )
31
+ await sqlite_storage.update_instance_status(instance_id, "running")
32
+ return instance_id
33
+
34
+ async def test_receive_raises_timeout_error_during_replay(
35
+ self, sqlite_storage, workflow_instance
36
+ ):
37
+ """Test that receive() raises TimeoutError when replaying a timeout event."""
38
+ # Record a timeout error in history (simulating what _check_expired_message_subscriptions does)
39
+ await sqlite_storage.append_history(
40
+ instance_id=workflow_instance,
41
+ activity_id="receive_payment:1",
42
+ event_type="MessageTimeout",
43
+ event_data={
44
+ "_error": True,
45
+ "error_type": "TimeoutError",
46
+ "error_message": "Message on channel 'payment' did not arrive within timeout",
47
+ "channel": "payment",
48
+ "timeout_at": "2025-01-01T00:00:00+00:00",
49
+ },
50
+ )
51
+
52
+ # Create context in replay mode
53
+ ctx = WorkflowContext(
54
+ instance_id=workflow_instance,
55
+ workflow_name="test_workflow",
56
+ storage=sqlite_storage,
57
+ worker_id="worker-1",
58
+ is_replaying=True,
59
+ )
60
+
61
+ # Subscribe first (required before receive)
62
+ await subscribe(ctx, "payment", mode="broadcast")
63
+
64
+ # Load history to populate cache with the timeout error
65
+ await ctx._load_history()
66
+
67
+ # Reset activity counter after subscribe
68
+ ctx._activity_call_counters.clear()
69
+
70
+ # receive() should raise TimeoutError from cached history
71
+ with pytest.raises(TimeoutError) as exc_info:
72
+ await receive(ctx, channel="payment", message_id="receive_payment:1")
73
+
74
+ assert "did not arrive within timeout" in str(exc_info.value)
75
+
76
+ async def test_receive_timeout_can_be_caught_in_workflow(
77
+ self, sqlite_storage, workflow_instance
78
+ ):
79
+ """Test that TimeoutError can be caught with try/except in workflow code."""
80
+ # Record a timeout error in history
81
+ await sqlite_storage.append_history(
82
+ instance_id=workflow_instance,
83
+ activity_id="receive_approval:1",
84
+ event_type="MessageTimeout",
85
+ event_data={
86
+ "_error": True,
87
+ "error_type": "TimeoutError",
88
+ "error_message": "Message on channel 'approval' did not arrive within 60 seconds",
89
+ "channel": "approval",
90
+ },
91
+ )
92
+
93
+ # Create context in replay mode
94
+ ctx = WorkflowContext(
95
+ instance_id=workflow_instance,
96
+ workflow_name="test_workflow",
97
+ storage=sqlite_storage,
98
+ worker_id="worker-1",
99
+ is_replaying=True,
100
+ )
101
+
102
+ await subscribe(ctx, "approval", mode="broadcast")
103
+
104
+ # Load history to populate cache with the timeout error
105
+ await ctx._load_history()
106
+
107
+ ctx._activity_call_counters.clear()
108
+
109
+ # Simulate workflow code that catches TimeoutError
110
+ timeout_caught = False
111
+ try:
112
+ await receive(ctx, channel="approval", message_id="receive_approval:1")
113
+ except TimeoutError:
114
+ timeout_caught = True
115
+
116
+ assert timeout_caught is True
117
+
118
+ async def test_receive_timeout_with_generic_error_type(self, sqlite_storage, workflow_instance):
119
+ """Test that non-TimeoutError errors are also re-raised during replay."""
120
+ # Record a timeout event but with a different error type (not TimeoutError)
121
+ # This tests that receive() properly handles various error types from MessageTimeout events
122
+ await sqlite_storage.append_history(
123
+ instance_id=workflow_instance,
124
+ activity_id="receive_data:1",
125
+ event_type="MessageTimeout",
126
+ event_data={
127
+ "_error": True,
128
+ "error_type": "ValueError",
129
+ "error_message": "Invalid message format",
130
+ "channel": "data",
131
+ },
132
+ )
133
+
134
+ # Create context in replay mode
135
+ ctx = WorkflowContext(
136
+ instance_id=workflow_instance,
137
+ workflow_name="test_workflow",
138
+ storage=sqlite_storage,
139
+ worker_id="worker-1",
140
+ is_replaying=True,
141
+ )
142
+
143
+ await subscribe(ctx, "data", mode="broadcast")
144
+
145
+ # Load history to populate cache with the error
146
+ await ctx._load_history()
147
+
148
+ ctx._activity_call_counters.clear()
149
+
150
+ # Should raise generic Exception for non-TimeoutError
151
+ with pytest.raises(Exception) as exc_info:
152
+ await receive(ctx, channel="data", message_id="receive_data:1")
153
+
154
+ assert "ValueError" in str(exc_info.value)
155
+ assert "Invalid message format" in str(exc_info.value)
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ class TestCheckExpiredMessageSubscriptions:
160
+ """Test that _check_expired_message_subscriptions resumes workflow."""
161
+
162
+ @pytest_asyncio.fixture
163
+ async def workflow_instance(self, sqlite_storage, create_test_instance):
164
+ """Create a workflow instance for testing."""
165
+ instance_id = "test-timeout-workflow-001"
166
+ await create_test_instance(
167
+ instance_id=instance_id,
168
+ workflow_name="timeout_test_workflow",
169
+ owner_service="test-service",
170
+ input_data={"test": "data"},
171
+ )
172
+ await sqlite_storage.update_instance_status(instance_id, "waiting_for_message")
173
+ return instance_id
174
+
175
+ async def test_find_expired_message_subscriptions_returns_workflow_name(
176
+ self, sqlite_storage, workflow_instance
177
+ ):
178
+ """Test that find_expired_message_subscriptions returns workflow_name."""
179
+ from datetime import UTC, datetime, timedelta
180
+
181
+ # Subscribe and set timeout in the past
182
+ await sqlite_storage.subscribe_to_channel(
183
+ workflow_instance, "test_channel", mode="broadcast"
184
+ )
185
+
186
+ # Manually set timeout_at to past (simulating expired timeout)
187
+ # This requires direct SQL since subscribe_to_channel doesn't set timeout
188
+ from sqlalchemy import text
189
+
190
+ past_time = datetime.now(UTC) - timedelta(seconds=60)
191
+ async with sqlite_storage.engine.begin() as conn:
192
+ await conn.execute(
193
+ text(
194
+ """
195
+ UPDATE channel_subscriptions
196
+ SET timeout_at = :timeout_at, activity_id = :activity_id
197
+ WHERE instance_id = :instance_id AND channel = :channel
198
+ """
199
+ ),
200
+ {
201
+ "timeout_at": past_time,
202
+ "activity_id": "receive_test_channel:1",
203
+ "instance_id": workflow_instance,
204
+ "channel": "test_channel",
205
+ },
206
+ )
207
+
208
+ # Find expired subscriptions
209
+ expired = await sqlite_storage.find_expired_message_subscriptions()
210
+
211
+ # Should find our expired subscription with workflow_name
212
+ assert len(expired) >= 1
213
+ our_sub = next((s for s in expired if s["instance_id"] == workflow_instance), None)
214
+ assert our_sub is not None
215
+ assert our_sub["workflow_name"] == "timeout_test_workflow"
216
+ assert our_sub["channel"] == "test_channel"
217
+ assert our_sub["activity_id"] == "receive_test_channel:1"
@@ -668,7 +668,7 @@ wheels = [
668
668
 
669
669
  [[package]]
670
670
  name = "edda-framework"
671
- version = "0.9.0"
671
+ version = "0.9.1"
672
672
  source = { editable = "." }
673
673
  dependencies = [
674
674
  { name = "a2wsgi" },
File without changes
File without changes
File without changes