edda-framework 0.13.0__tar.gz → 0.14.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 (209) hide show
  1. {edda_framework-0.13.0 → edda_framework-0.14.1}/PKG-INFO +1 -1
  2. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/app.py +6 -21
  3. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/channels.py +27 -0
  4. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/locking.py +12 -37
  5. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/protocol.py +12 -0
  6. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/sqlalchemy_storage.py +20 -0
  7. {edda_framework-0.13.0 → edda_framework-0.14.1}/pyproject.toml +1 -1
  8. edda_framework-0.14.1/tests/test_channel_mode_locking.py +201 -0
  9. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_cross_language_channel.py +637 -0
  10. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_stale_workflow_recovery.py +0 -3
  11. {edda_framework-0.13.0 → edda_framework-0.14.1}/uv.lock +1 -1
  12. {edda_framework-0.13.0 → edda_framework-0.14.1}/.github/workflows/ci.yml +0 -0
  13. {edda_framework-0.13.0 → edda_framework-0.14.1}/.github/workflows/docs.yml +0 -0
  14. {edda_framework-0.13.0 → edda_framework-0.14.1}/.github/workflows/release.yml +0 -0
  15. {edda_framework-0.13.0 → edda_framework-0.14.1}/.gitignore +0 -0
  16. {edda_framework-0.13.0 → edda_framework-0.14.1}/.gitmodules +0 -0
  17. {edda_framework-0.13.0 → edda_framework-0.14.1}/.python-version +0 -0
  18. {edda_framework-0.13.0 → edda_framework-0.14.1}/Justfile +0 -0
  19. {edda_framework-0.13.0 → edda_framework-0.14.1}/LICENSE +0 -0
  20. {edda_framework-0.13.0 → edda_framework-0.14.1}/README.md +0 -0
  21. {edda_framework-0.13.0 → edda_framework-0.14.1}/demo_app.py +0 -0
  22. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/api/reference.md +0 -0
  23. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/durable-execution/replay.md +0 -0
  24. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/events/cloudevents-http-binding.md +0 -0
  25. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/events/postgres-notify.md +0 -0
  26. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/events/wait-event.md +0 -0
  27. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/hooks.md +0 -0
  28. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/messages.md +0 -0
  29. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/retry.md +0 -0
  30. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/saga-compensation.md +0 -0
  31. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/transactional-outbox.md +0 -0
  32. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/workflows-activities.md +0 -0
  33. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/ecommerce.md +0 -0
  34. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/events.md +0 -0
  35. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/fastapi-integration.md +0 -0
  36. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/saga.md +0 -0
  37. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/simple.md +0 -0
  38. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/concepts.md +0 -0
  39. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/first-workflow.md +0 -0
  40. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/installation.md +0 -0
  41. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/quick-start.md +0 -0
  42. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/index.md +0 -0
  43. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/mcp.md +0 -0
  44. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/mirascope.md +0 -0
  45. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/opentelemetry.md +0 -0
  46. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/pydantic-rpc.md +0 -0
  47. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
  48. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/compensation-execution.png +0 -0
  49. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
  50. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
  51. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
  52. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
  53. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
  54. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/workflow-list-view.png +0 -0
  55. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
  56. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/setup.md +0 -0
  57. {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/visualization.md +0 -0
  58. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/__init__.py +0 -0
  59. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/activity.py +0 -0
  60. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/compensation.py +0 -0
  61. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/context.py +0 -0
  62. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/exceptions.py +0 -0
  63. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/hooks.py +0 -0
  64. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/__init__.py +0 -0
  65. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mcp/__init__.py +0 -0
  66. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mcp/decorators.py +0 -0
  67. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mcp/server.py +0 -0
  68. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/__init__.py +0 -0
  69. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/agent.py +0 -0
  70. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/call.py +0 -0
  71. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/decorator.py +0 -0
  72. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/types.py +0 -0
  73. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/opentelemetry/__init__.py +0 -0
  74. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/opentelemetry/hooks.py +0 -0
  75. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/migrations/mysql/20251217000000_initial_schema.sql +0 -0
  76. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/migrations/postgresql/20251217000000_initial_schema.sql +0 -0
  77. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/migrations/sqlite/20251217000000_initial_schema.sql +0 -0
  78. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/outbox/__init__.py +0 -0
  79. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/outbox/relayer.py +0 -0
  80. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/outbox/transactional.py +0 -0
  81. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/pydantic_utils.py +0 -0
  82. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/replay.py +0 -0
  83. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/retry.py +0 -0
  84. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/serialization/__init__.py +0 -0
  85. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/serialization/base.py +0 -0
  86. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/serialization/json.py +0 -0
  87. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/__init__.py +0 -0
  88. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/migrations.py +0 -0
  89. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/models.py +0 -0
  90. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/notify_base.py +0 -0
  91. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/pg_notify.py +0 -0
  92. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/__init__.py +0 -0
  93. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/app.py +0 -0
  94. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/components.py +0 -0
  95. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/data_service.py +0 -0
  96. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/theme.py +0 -0
  97. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/visualizer/__init__.py +0 -0
  98. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/visualizer/ast_analyzer.py +0 -0
  99. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/visualizer/mermaid_generator.py +0 -0
  100. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/workflow.py +0 -0
  101. {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/wsgi.py +0 -0
  102. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/__init__.py +0 -0
  103. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/cancellable_workflow.py +0 -0
  104. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/compensation_workflow.py +0 -0
  105. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/event_waiting_app.py +0 -0
  106. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/event_waiting_workflow.py +0 -0
  107. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/event_waiting_workflow_complete.py +0 -0
  108. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/long_running_loop.py +0 -0
  109. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/README.md +0 -0
  110. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/order_processing_mcp.py +0 -0
  111. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/prompts_example.py +0 -0
  112. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/remote_server_example.py +0 -0
  113. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/simple_mcp_server.py +0 -0
  114. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/message_passing.py +0 -0
  115. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/__init__.py +0 -0
  116. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/durable_agent.py +0 -0
  117. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/multi_turn.py +0 -0
  118. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/simple_call.py +0 -0
  119. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/with_tools.py +0 -0
  120. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/observability_with_logfire.py +0 -0
  121. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/observability_with_opentelemetry.py +0 -0
  122. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/pydantic_rpc_integration.py +0 -0
  123. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/pydantic_saga.py +0 -0
  124. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/retry_example.py +0 -0
  125. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/retry_with_compensation.py +0 -0
  126. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/simple_workflow.py +0 -0
  127. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/typeddict_example.py +0 -0
  128. {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/with_outbox.py +0 -0
  129. {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/.dbmate.yml +0 -0
  130. {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/.git +0 -0
  131. {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/.gitignore +0 -0
  132. {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/LICENSE +0 -0
  133. {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/README.md +0 -0
  134. {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/docs/column-values.md +0 -0
  135. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/__init__.py +0 -0
  136. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/conftest.py +0 -0
  137. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/__init__.py +0 -0
  138. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/__init__.py +0 -0
  139. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_cancel.py +0 -0
  140. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_integration.py +0 -0
  141. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_jsonrpc.py +0 -0
  142. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_prompts.py +0 -0
  143. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_server.py +0 -0
  144. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/__init__.py +0 -0
  145. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_agent.py +0 -0
  146. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_call.py +0 -0
  147. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_decorator.py +0 -0
  148. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_types.py +0 -0
  149. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/opentelemetry/__init__.py +0 -0
  150. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/opentelemetry/test_hooks.py +0 -0
  151. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_activity.py +0 -0
  152. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_activity_retry.py +0 -0
  153. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_activity_sync.py +0 -0
  154. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_app.py +0 -0
  155. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_ast_analyzer.py +0 -0
  156. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_atomic_wait_event.py +0 -0
  157. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_auto_migration.py +0 -0
  158. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_binary_data.py +0 -0
  159. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_channel_competing.py +0 -0
  160. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_channel_direct.py +0 -0
  161. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_channel_transactional.py +0 -0
  162. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_cloudevents_http_binding.py +0 -0
  163. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_compensation.py +0 -0
  164. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_compensation_crash_recovery.py.wip +0 -0
  165. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_concurrent_outbox.py +0 -0
  166. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_context.py +0 -0
  167. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_ctx_session.py +0 -0
  168. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_distributed_event_delivery.py +0 -0
  169. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_events.py +0 -0
  170. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_instance_id_routing.py +0 -0
  171. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_lock_race_condition.py +0 -0
  172. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_lock_timeout_customization.py +0 -0
  173. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_locking.py +0 -0
  174. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_message_cleanup.py +0 -0
  175. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_message_delivery_lock.py +0 -0
  176. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_messages.py +0 -0
  177. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_migrations_integration.py +0 -0
  178. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_multidb_storage.py +0 -0
  179. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_outbox.py +0 -0
  180. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pg_notify.py +0 -0
  181. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_polling_optimization.py +0 -0
  182. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_activity.py +0 -0
  183. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_enum.py +0 -0
  184. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_events.py +0 -0
  185. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_saga.py +0 -0
  186. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_utils.py +0 -0
  187. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_receive_timeout.py +0 -0
  188. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_received_event.py +0 -0
  189. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_recur.py +0 -0
  190. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_recur_cleanup.py +0 -0
  191. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_replay.py +0 -0
  192. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_retry_policy.py +0 -0
  193. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_saga_parameter_extraction.py +0 -0
  194. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_serialization.py +0 -0
  195. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_skip_locked.py +0 -0
  196. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_storage.py +0 -0
  197. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_storage_mysql.py +0 -0
  198. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_storage_postgresql.py +0 -0
  199. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_transactions.py +0 -0
  200. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_viewer_pagination.py +0 -0
  201. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_viewer_pydantic_form.py +0 -0
  202. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_viewer_start_saga.py +0 -0
  203. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_wait_timer.py +0 -0
  204. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow.py +0 -0
  205. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow_auto_register.py +0 -0
  206. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow_cancellation.py +0 -0
  207. {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow_resumption.py +0 -0
  208. {edda_framework-0.13.0 → edda_framework-0.14.1}/viewer_app.py +0 -0
  209. {edda_framework-0.13.0 → edda_framework-0.14.1}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -583,7 +583,6 @@ class EddaApp:
583
583
  auto_resume_stale_workflows_periodically(
584
584
  self.storage,
585
585
  self.replay_engine,
586
- self.worker_id,
587
586
  interval=60,
588
587
  ),
589
588
  name="leader_stale_workflow_resume",
@@ -628,7 +627,6 @@ class EddaApp:
628
627
  auto_resume_stale_workflows_periodically(
629
628
  self.storage,
630
629
  self.replay_engine,
631
- self.worker_id,
632
630
  interval=60,
633
631
  ),
634
632
  name="leader_stale_workflow_resume",
@@ -1411,7 +1409,8 @@ class EddaApp:
1411
1409
  from growing indefinitely with orphaned messages (messages that were
1412
1410
  published but never received by any subscriber).
1413
1411
 
1414
- Uses system-level locking to ensure only one pod executes cleanup at a time.
1412
+ Important: This task should only be run by a single worker (e.g., via leader
1413
+ election). It does not perform its own distributed coordination.
1415
1414
 
1416
1415
  Args:
1417
1416
  interval: Cleanup interval in seconds (default: 3600 = 1 hour)
@@ -1422,27 +1421,13 @@ class EddaApp:
1422
1421
  """
1423
1422
  while True:
1424
1423
  try:
1425
- # Add jitter to prevent thundering herd in multi-pod deployments
1424
+ # Add jitter to prevent thundering herd
1426
1425
  jitter = random.uniform(0, interval * 0.3)
1427
1426
  await asyncio.sleep(interval + jitter)
1428
1427
 
1429
- # Try to acquire global lock for this task
1430
- lock_acquired = await self.storage.try_acquire_system_lock(
1431
- lock_name="cleanup_old_messages",
1432
- worker_id=self.worker_id,
1433
- timeout_seconds=interval,
1434
- )
1435
-
1436
- if not lock_acquired:
1437
- # Another pod is handling this task
1438
- continue
1439
-
1440
- try:
1441
- deleted_count = await self.storage.cleanup_old_channel_messages(retention_days)
1442
- if deleted_count > 0:
1443
- logger.info("Cleaned up %d old channel messages", deleted_count)
1444
- finally:
1445
- await self.storage.release_system_lock("cleanup_old_messages", self.worker_id)
1428
+ deleted_count = await self.storage.cleanup_old_channel_messages(retention_days)
1429
+ if deleted_count > 0:
1430
+ logger.info("Cleaned up %d old channel messages", deleted_count)
1446
1431
  except Exception as e:
1447
1432
  logger.error("Error cleaning up old messages: %s", e, exc_info=True)
1448
1433
 
@@ -129,6 +129,24 @@ class WaitForTimerException(Exception):
129
129
  super().__init__(f"Waiting for timer: {timer_id}")
130
130
 
131
131
 
132
+ class ChannelModeConflictError(Exception):
133
+ """
134
+ Raised when subscribing with a different mode than the channel's established mode.
135
+
136
+ A channel's mode is locked when the first subscription is created. Subsequent
137
+ subscriptions must use the same mode.
138
+ """
139
+
140
+ def __init__(self, channel: str, existing_mode: str, requested_mode: str) -> None:
141
+ self.channel = channel
142
+ self.existing_mode = existing_mode
143
+ self.requested_mode = requested_mode
144
+ super().__init__(
145
+ f"Channel '{channel}' is already configured as '{existing_mode}' mode. "
146
+ f"Cannot subscribe with '{requested_mode}' mode."
147
+ )
148
+
149
+
132
150
  # =============================================================================
133
151
  # Subscription Functions
134
152
  # =============================================================================
@@ -150,6 +168,10 @@ async def subscribe(
150
168
  - "competing": Each message goes to only one subscriber (work queue pattern)
151
169
  - "direct": Receive messages sent via send_to() to this instance
152
170
 
171
+ Raises:
172
+ ChannelModeConflictError: If the channel is already configured with a different mode
173
+ ValueError: If mode is not 'broadcast', 'competing', or 'direct'
174
+
153
175
  The "direct" mode is syntactic sugar that subscribes to "channel:instance_id" internally,
154
176
  allowing simpler code when receiving direct messages:
155
177
 
@@ -204,6 +226,11 @@ async def subscribe(
204
226
  f"Invalid subscription mode: {mode}. Must be 'broadcast', 'competing', or 'direct'"
205
227
  )
206
228
 
229
+ # Check for mode conflict
230
+ existing_mode = await ctx.storage.get_channel_mode(actual_channel)
231
+ if existing_mode is not None and existing_mode != actual_mode:
232
+ raise ChannelModeConflictError(channel, existing_mode, mode)
233
+
207
234
  await ctx.storage.subscribe_to_channel(ctx.instance_id, actual_channel, actual_mode)
208
235
 
209
236
 
@@ -192,7 +192,6 @@ async def _refresh_lock_periodically(
192
192
 
193
193
  async def cleanup_stale_locks_periodically(
194
194
  storage: StorageProtocol,
195
- worker_id: str,
196
195
  interval: int = 60,
197
196
  ) -> None:
198
197
  """
@@ -204,49 +203,37 @@ async def cleanup_stale_locks_periodically(
204
203
  Note: This function only cleans up locks without resuming workflows.
205
204
  For automatic workflow resumption, use auto_resume_stale_workflows_periodically().
206
205
 
207
- Uses system-level locking to ensure only one pod executes cleanup at a time.
206
+ Important: This function should only be run by a single worker (e.g., via leader
207
+ election). It does not perform its own distributed coordination.
208
208
 
209
209
  Example:
210
210
  >>> asyncio.create_task(
211
- ... cleanup_stale_locks_periodically(storage, worker_id, interval=60)
211
+ ... cleanup_stale_locks_periodically(storage, interval=60)
212
212
  ... )
213
213
 
214
214
  Args:
215
215
  storage: Storage backend
216
- worker_id: Unique identifier for this worker (for global lock coordination)
217
216
  interval: Cleanup interval in seconds (default: 60)
218
217
  """
219
218
  with suppress(asyncio.CancelledError):
220
219
  while True:
221
- # Add jitter to prevent thundering herd in multi-pod deployments
220
+ # Add jitter to prevent thundering herd
222
221
  jitter = random.uniform(0, interval * 0.3)
223
222
  await asyncio.sleep(interval + jitter)
224
223
 
225
- # Try to acquire global lock for this task
226
- lock_acquired = await storage.try_acquire_system_lock(
227
- lock_name="cleanup_stale_locks",
228
- worker_id=worker_id,
229
- timeout_seconds=interval,
230
- )
231
-
232
- if not lock_acquired:
233
- # Another pod is handling this task
234
- continue
235
-
236
224
  try:
237
225
  # Clean up stale locks
238
226
  workflows = await storage.cleanup_stale_locks()
239
227
 
240
228
  if len(workflows) > 0:
241
229
  logger.info("Cleaned up %d stale locks", len(workflows))
242
- finally:
243
- await storage.release_system_lock("cleanup_stale_locks", worker_id)
230
+ except Exception as e:
231
+ logger.error("Failed to cleanup stale locks: %s", e, exc_info=True)
244
232
 
245
233
 
246
234
  async def auto_resume_stale_workflows_periodically(
247
235
  storage: StorageProtocol,
248
236
  replay_engine: Any,
249
- worker_id: str,
250
237
  interval: int = 60,
251
238
  ) -> None:
252
239
  """
@@ -255,39 +242,27 @@ async def auto_resume_stale_workflows_periodically(
255
242
  This combines lock cleanup with automatic workflow resumption, ensuring
256
243
  that workflows interrupted by worker crashes are automatically recovered.
257
244
 
258
- Uses system-level locking to ensure only one pod executes this task at a time,
259
- preventing duplicate workflow execution (CRITICAL for safety).
245
+ Important: This function should only be run by a single worker (e.g., via leader
246
+ election). It does not perform its own distributed coordination.
260
247
 
261
248
  Example:
262
249
  >>> asyncio.create_task(
263
250
  ... auto_resume_stale_workflows_periodically(
264
- ... storage, replay_engine, worker_id, interval=60
251
+ ... storage, replay_engine, interval=60
265
252
  ... )
266
253
  ... )
267
254
 
268
255
  Args:
269
256
  storage: Storage backend
270
257
  replay_engine: ReplayEngine instance for resuming workflows
271
- worker_id: Unique identifier for this worker (for global lock coordination)
272
258
  interval: Cleanup interval in seconds (default: 60)
273
259
  """
274
260
  with suppress(asyncio.CancelledError):
275
261
  while True:
276
- # Add jitter to prevent thundering herd in multi-pod deployments
262
+ # Add jitter to prevent thundering herd
277
263
  jitter = random.uniform(0, interval * 0.3)
278
264
  await asyncio.sleep(interval + jitter)
279
265
 
280
- # Try to acquire global lock for this task
281
- lock_acquired = await storage.try_acquire_system_lock(
282
- lock_name="auto_resume_stale_workflows",
283
- worker_id=worker_id,
284
- timeout_seconds=interval,
285
- )
286
-
287
- if not lock_acquired:
288
- # Another pod is handling this task
289
- continue
290
-
291
266
  try:
292
267
  # Clean up stale locks and get workflows to resume
293
268
  workflows_to_resume = await storage.cleanup_stale_locks()
@@ -369,8 +344,8 @@ async def auto_resume_stale_workflows_periodically(
369
344
  e,
370
345
  exc_info=True,
371
346
  )
372
- finally:
373
- await storage.release_system_lock("auto_resume_stale_workflows", worker_id)
347
+ except Exception as e:
348
+ logger.error("Failed to cleanup stale locks: %s", e, exc_info=True)
374
349
 
375
350
 
376
351
  class LockNotAcquiredError(Exception):
@@ -990,6 +990,18 @@ class StorageProtocol(Protocol):
990
990
  """
991
991
  ...
992
992
 
993
+ async def get_channel_mode(self, channel: str) -> str | None:
994
+ """
995
+ Get the mode for a channel (from any existing subscription).
996
+
997
+ Args:
998
+ channel: Channel name
999
+
1000
+ Returns:
1001
+ The mode ('broadcast' or 'competing') or None if no subscriptions exist
1002
+ """
1003
+ ...
1004
+
993
1005
  async def register_channel_receive_and_release_lock(
994
1006
  self,
995
1007
  instance_id: str,
@@ -3170,6 +3170,26 @@ class SQLAlchemyStorage:
3170
3170
  "cursor_message_id": subscription.cursor_message_id,
3171
3171
  }
3172
3172
 
3173
+ async def get_channel_mode(self, channel: str) -> str | None:
3174
+ """
3175
+ Get the mode for a channel (from any existing subscription).
3176
+
3177
+ Args:
3178
+ channel: Channel name
3179
+
3180
+ Returns:
3181
+ The mode ('broadcast' or 'competing') or None if no subscriptions exist
3182
+ """
3183
+ session = self._get_session_for_operation()
3184
+ async with self._session_scope(session) as session:
3185
+ result = await session.execute(
3186
+ select(ChannelSubscription.mode)
3187
+ .where(ChannelSubscription.channel == channel)
3188
+ .limit(1)
3189
+ )
3190
+ row = result.scalar_one_or_none()
3191
+ return row
3192
+
3173
3193
  async def register_channel_receive_and_release_lock(
3174
3194
  self,
3175
3195
  instance_id: str,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edda-framework"
3
- version = "0.13.0"
3
+ version = "0.14.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,201 @@
1
+ """
2
+ Tests for channel mode locking functionality.
3
+
4
+ Ensures that once a channel is subscribed with a specific mode (broadcast/competing),
5
+ subsequent subscriptions with a different mode are rejected with ChannelModeConflictError.
6
+ """
7
+
8
+ import pytest
9
+ import pytest_asyncio
10
+
11
+ from edda.channels import ChannelModeConflictError, subscribe
12
+ from edda.context import WorkflowContext
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ class TestChannelModeLocking:
17
+ """Test suite for channel mode locking."""
18
+
19
+ @pytest_asyncio.fixture
20
+ async def workflow_instances(self, sqlite_storage, create_test_instance):
21
+ """Create multiple workflow instances for testing."""
22
+ instances = []
23
+ for i in range(1, 4):
24
+ instance_id = f"mode-lock-test-{i}"
25
+ await create_test_instance(
26
+ instance_id=instance_id,
27
+ workflow_name="test_workflow",
28
+ owner_service="test-service",
29
+ input_data={"test": True},
30
+ )
31
+ await sqlite_storage.update_instance_status(instance_id, "running")
32
+ instances.append(instance_id)
33
+ return instances
34
+
35
+ async def test_broadcast_then_competing_raises_error(self, sqlite_storage, workflow_instances):
36
+ """Test that subscribing with competing mode after broadcast raises error."""
37
+ ctx1 = WorkflowContext(
38
+ instance_id=workflow_instances[0],
39
+ workflow_name="test_workflow",
40
+ storage=sqlite_storage,
41
+ worker_id="worker-1",
42
+ is_replaying=False,
43
+ )
44
+
45
+ # Subscribe with broadcast mode first
46
+ await subscribe(ctx1, "test-channel-1", mode="broadcast")
47
+
48
+ # Verify mode was stored
49
+ mode = await sqlite_storage.get_channel_mode("test-channel-1")
50
+ assert mode == "broadcast"
51
+
52
+ # Try to subscribe with competing mode - should fail
53
+ ctx2 = WorkflowContext(
54
+ instance_id=workflow_instances[1],
55
+ workflow_name="test_workflow",
56
+ storage=sqlite_storage,
57
+ worker_id="worker-1",
58
+ is_replaying=False,
59
+ )
60
+
61
+ with pytest.raises(ChannelModeConflictError) as exc_info:
62
+ await subscribe(ctx2, "test-channel-1", mode="competing")
63
+
64
+ assert exc_info.value.channel == "test-channel-1"
65
+ assert exc_info.value.existing_mode == "broadcast"
66
+ assert exc_info.value.requested_mode == "competing"
67
+
68
+ async def test_competing_then_broadcast_raises_error(self, sqlite_storage, workflow_instances):
69
+ """Test that subscribing with broadcast mode after competing raises error."""
70
+ ctx1 = WorkflowContext(
71
+ instance_id=workflow_instances[0],
72
+ workflow_name="test_workflow",
73
+ storage=sqlite_storage,
74
+ worker_id="worker-1",
75
+ is_replaying=False,
76
+ )
77
+
78
+ # Subscribe with competing mode first
79
+ await subscribe(ctx1, "test-channel-2", mode="competing")
80
+
81
+ # Verify mode was stored
82
+ mode = await sqlite_storage.get_channel_mode("test-channel-2")
83
+ assert mode == "competing"
84
+
85
+ # Try to subscribe with broadcast mode - should fail
86
+ ctx2 = WorkflowContext(
87
+ instance_id=workflow_instances[1],
88
+ workflow_name="test_workflow",
89
+ storage=sqlite_storage,
90
+ worker_id="worker-1",
91
+ is_replaying=False,
92
+ )
93
+
94
+ with pytest.raises(ChannelModeConflictError) as exc_info:
95
+ await subscribe(ctx2, "test-channel-2", mode="broadcast")
96
+
97
+ assert exc_info.value.channel == "test-channel-2"
98
+ assert exc_info.value.existing_mode == "competing"
99
+ assert exc_info.value.requested_mode == "broadcast"
100
+
101
+ async def test_same_mode_subscription_allowed(self, sqlite_storage, workflow_instances):
102
+ """Test that multiple subscriptions with the same mode are allowed."""
103
+ # Subscribe first instance with broadcast
104
+ ctx1 = WorkflowContext(
105
+ instance_id=workflow_instances[0],
106
+ workflow_name="test_workflow",
107
+ storage=sqlite_storage,
108
+ worker_id="worker-1",
109
+ is_replaying=False,
110
+ )
111
+ await subscribe(ctx1, "test-channel-3", mode="broadcast")
112
+
113
+ # Subscribe second instance with same mode - should succeed
114
+ ctx2 = WorkflowContext(
115
+ instance_id=workflow_instances[1],
116
+ workflow_name="test_workflow",
117
+ storage=sqlite_storage,
118
+ worker_id="worker-1",
119
+ is_replaying=False,
120
+ )
121
+ await subscribe(ctx2, "test-channel-3", mode="broadcast")
122
+
123
+ # Subscribe third instance with same mode - should succeed
124
+ ctx3 = WorkflowContext(
125
+ instance_id=workflow_instances[2],
126
+ workflow_name="test_workflow",
127
+ storage=sqlite_storage,
128
+ worker_id="worker-1",
129
+ is_replaying=False,
130
+ )
131
+ await subscribe(ctx3, "test-channel-3", mode="broadcast")
132
+
133
+ # All three should be subscribed
134
+ sub1 = await sqlite_storage.get_channel_subscription(
135
+ workflow_instances[0], "test-channel-3"
136
+ )
137
+ sub2 = await sqlite_storage.get_channel_subscription(
138
+ workflow_instances[1], "test-channel-3"
139
+ )
140
+ sub3 = await sqlite_storage.get_channel_subscription(
141
+ workflow_instances[2], "test-channel-3"
142
+ )
143
+
144
+ assert sub1 is not None
145
+ assert sub2 is not None
146
+ assert sub3 is not None
147
+
148
+ async def test_different_channels_independent_modes(self, sqlite_storage, workflow_instances):
149
+ """Test that different channels can have different modes."""
150
+ ctx1 = WorkflowContext(
151
+ instance_id=workflow_instances[0],
152
+ workflow_name="test_workflow",
153
+ storage=sqlite_storage,
154
+ worker_id="worker-1",
155
+ is_replaying=False,
156
+ )
157
+
158
+ # Channel A with broadcast
159
+ await subscribe(ctx1, "channel-a", mode="broadcast")
160
+
161
+ # Channel B with competing - should succeed (different channel)
162
+ await subscribe(ctx1, "channel-b", mode="competing")
163
+
164
+ # Verify modes
165
+ mode_a = await sqlite_storage.get_channel_mode("channel-a")
166
+ mode_b = await sqlite_storage.get_channel_mode("channel-b")
167
+
168
+ assert mode_a == "broadcast"
169
+ assert mode_b == "competing"
170
+
171
+ async def test_get_channel_mode_returns_none_for_new_channel(self, sqlite_storage):
172
+ """Test that get_channel_mode returns None for channels with no subscriptions."""
173
+ mode = await sqlite_storage.get_channel_mode("nonexistent-channel")
174
+ assert mode is None
175
+
176
+ async def test_error_message_is_informative(self, sqlite_storage, workflow_instances):
177
+ """Test that ChannelModeConflictError has a clear message."""
178
+ ctx1 = WorkflowContext(
179
+ instance_id=workflow_instances[0],
180
+ workflow_name="test_workflow",
181
+ storage=sqlite_storage,
182
+ worker_id="worker-1",
183
+ is_replaying=False,
184
+ )
185
+ await subscribe(ctx1, "msg-test-channel", mode="broadcast")
186
+
187
+ ctx2 = WorkflowContext(
188
+ instance_id=workflow_instances[1],
189
+ workflow_name="test_workflow",
190
+ storage=sqlite_storage,
191
+ worker_id="worker-1",
192
+ is_replaying=False,
193
+ )
194
+
195
+ with pytest.raises(ChannelModeConflictError) as exc_info:
196
+ await subscribe(ctx2, "msg-test-channel", mode="competing")
197
+
198
+ error_msg = str(exc_info.value)
199
+ assert "msg-test-channel" in error_msg
200
+ assert "broadcast" in error_msg
201
+ assert "competing" in error_msg