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.
- {edda_framework-0.9.0 → edda_framework-0.9.1}/.github/workflows/docs.yml +3 -3
- {edda_framework-0.9.0 → edda_framework-0.9.1}/PKG-INFO +1 -1
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/messages.md +2 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/index.md +1 -1
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/app.py +15 -36
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/context.py +8 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/pyproject.toml +1 -1
- edda_framework-0.9.1/tests/test_receive_timeout.py +217 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/uv.lock +1 -1
- {edda_framework-0.9.0 → edda_framework-0.9.1}/.github/workflows/ci.yml +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/.github/workflows/release.yml +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/.gitignore +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/.python-version +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/Justfile +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/LICENSE +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/README.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/demo_app.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/api/reference.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/durable-execution/replay.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/events/cloudevents-http-binding.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/events/wait-event.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/hooks.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/retry.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/saga-compensation.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/transactional-outbox.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/workflows-activities.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/ecommerce.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/events.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/fastapi-integration.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/saga.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/examples/simple.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/concepts.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/first-workflow.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/installation.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/getting-started/quick-start.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/integrations/mcp.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/integrations/opentelemetry.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/integrations/pydantic-rpc.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/compensation-execution.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-list-view.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/setup.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/visualization.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/activity.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/channels.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/compensation.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/exceptions.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/hooks.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/mcp/decorators.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/mcp/server.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/integrations/opentelemetry/hooks.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/locking.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/outbox/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/outbox/relayer.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/outbox/transactional.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/pydantic_utils.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/replay.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/retry.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/serialization/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/serialization/base.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/serialization/json.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/models.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/protocol.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/storage/sqlalchemy_storage.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/app.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/components.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/data_service.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/viewer_ui/theme.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/visualizer/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/visualizer/ast_analyzer.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/visualizer/mermaid_generator.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/workflow.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/edda/wsgi.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/cancellable_workflow.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/compensation_workflow.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/event_waiting_app.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/event_waiting_workflow.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/event_waiting_workflow_complete.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/long_running_loop.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/README.md +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/order_processing_mcp.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/prompts_example.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/remote_server_example.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/mcp/simple_mcp_server.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/message_passing.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/observability_with_logfire.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/observability_with_opentelemetry.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/pydantic_rpc_integration.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/pydantic_saga.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/retry_example.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/retry_with_compensation.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/simple_workflow.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/typeddict_example.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/examples/with_outbox.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/conftest.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_cancel.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_integration.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_jsonrpc.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_prompts.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/mcp/test_server.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/test_hooks.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_activity.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_activity_retry.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_activity_sync.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_app.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_ast_analyzer.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_atomic_wait_event.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_auto_migration.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_binary_data.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_channel_competing.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_channel_transactional.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_cloudevents_http_binding.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_compensation.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_compensation_crash_recovery.py.wip +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_concurrent_outbox.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_context.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_ctx_session.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_distributed_event_delivery.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_events.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_instance_id_routing.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_lock_race_condition.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_lock_timeout_customization.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_locking.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_message_cleanup.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_message_delivery_lock.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_messages.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_multidb_storage.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_outbox.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_activity.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_enum.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_events.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_saga.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_pydantic_utils.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_received_event.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_recur.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_recur_cleanup.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_replay.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_retry_policy.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_saga_parameter_extraction.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_serialization.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_skip_locked.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_stale_workflow_recovery.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_storage.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_storage_mysql.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_storage_postgresql.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_transactions.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_viewer_pagination.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_viewer_pydantic_form.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_viewer_start_saga.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_wait_timer.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow_auto_register.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow_cancellation.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/tests/test_workflow_resumption.py +0 -0
- {edda_framework-0.9.0 → edda_framework-0.9.1}/viewer_app.py +0 -0
- {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
|
|
27
|
-
|
|
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
|
|
@@ -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**:
|
|
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.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
797
|
-
timeout_seconds = int((timeout_dt - created_dt).total_seconds())
|
|
798
|
-
except Exception:
|
|
799
|
-
timeout_seconds = 0 # Fallback
|
|
787
|
+
continue
|
|
800
788
|
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
"
|
|
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
|
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/durable-execution/replay.md
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/core-features/events/cloudevents-http-binding.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/cloudevents-cli-trigger.png
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/compensation-execution.png
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-loan-approval.png
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/detail-page-match-case.png
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/nested-pydantic-form.png
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/start-workflow-form-pydantic.png
RENAMED
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/wait-event-visualization.png
RENAMED
|
File without changes
|
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/docs/viewer-ui/images/workflow-selection-dropdown.png
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{edda_framework-0.9.0 → edda_framework-0.9.1}/tests/integrations/opentelemetry/test_hooks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|