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.
- {edda_framework-0.13.0 → edda_framework-0.14.1}/PKG-INFO +1 -1
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/app.py +6 -21
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/channels.py +27 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/locking.py +12 -37
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/protocol.py +12 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/sqlalchemy_storage.py +20 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/pyproject.toml +1 -1
- edda_framework-0.14.1/tests/test_channel_mode_locking.py +201 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_cross_language_channel.py +637 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_stale_workflow_recovery.py +0 -3
- {edda_framework-0.13.0 → edda_framework-0.14.1}/uv.lock +1 -1
- {edda_framework-0.13.0 → edda_framework-0.14.1}/.github/workflows/ci.yml +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/.github/workflows/docs.yml +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/.github/workflows/release.yml +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/.gitignore +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/.gitmodules +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/.python-version +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/Justfile +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/LICENSE +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/README.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/demo_app.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/api/reference.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/durable-execution/replay.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/events/cloudevents-http-binding.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/events/postgres-notify.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/events/wait-event.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/hooks.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/messages.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/retry.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/saga-compensation.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/transactional-outbox.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/core-features/workflows-activities.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/ecommerce.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/events.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/fastapi-integration.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/saga.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/examples/simple.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/concepts.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/first-workflow.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/installation.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/getting-started/quick-start.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/index.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/mcp.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/mirascope.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/opentelemetry.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/integrations/pydantic-rpc.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/compensation-execution.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/workflow-list-view.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/setup.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/docs/viewer-ui/visualization.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/activity.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/compensation.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/context.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/exceptions.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/hooks.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mcp/decorators.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mcp/server.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/agent.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/call.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/decorator.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/mirascope/types.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/integrations/opentelemetry/hooks.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/migrations/mysql/20251217000000_initial_schema.sql +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/migrations/postgresql/20251217000000_initial_schema.sql +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/migrations/sqlite/20251217000000_initial_schema.sql +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/outbox/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/outbox/relayer.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/outbox/transactional.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/pydantic_utils.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/replay.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/retry.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/serialization/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/serialization/base.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/serialization/json.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/migrations.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/models.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/notify_base.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/storage/pg_notify.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/app.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/components.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/data_service.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/viewer_ui/theme.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/visualizer/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/visualizer/ast_analyzer.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/visualizer/mermaid_generator.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/workflow.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/edda/wsgi.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/cancellable_workflow.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/compensation_workflow.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/event_waiting_app.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/event_waiting_workflow.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/event_waiting_workflow_complete.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/long_running_loop.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/README.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/order_processing_mcp.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/prompts_example.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/remote_server_example.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mcp/simple_mcp_server.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/message_passing.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/durable_agent.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/multi_turn.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/simple_call.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/mirascope/with_tools.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/observability_with_logfire.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/observability_with_opentelemetry.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/pydantic_rpc_integration.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/pydantic_saga.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/retry_example.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/retry_with_compensation.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/simple_workflow.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/typeddict_example.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/examples/with_outbox.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/.dbmate.yml +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/.git +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/.gitignore +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/LICENSE +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/README.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/schema/docs/column-values.md +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/conftest.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_cancel.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_integration.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_jsonrpc.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_prompts.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mcp/test_server.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_agent.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_call.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_decorator.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/mirascope/test_types.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/integrations/opentelemetry/test_hooks.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_activity.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_activity_retry.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_activity_sync.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_app.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_ast_analyzer.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_atomic_wait_event.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_auto_migration.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_binary_data.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_channel_competing.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_channel_direct.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_channel_transactional.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_cloudevents_http_binding.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_compensation.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_compensation_crash_recovery.py.wip +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_concurrent_outbox.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_context.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_ctx_session.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_distributed_event_delivery.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_events.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_instance_id_routing.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_lock_race_condition.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_lock_timeout_customization.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_locking.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_message_cleanup.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_message_delivery_lock.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_messages.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_migrations_integration.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_multidb_storage.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_outbox.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pg_notify.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_polling_optimization.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_activity.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_enum.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_events.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_saga.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_pydantic_utils.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_receive_timeout.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_received_event.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_recur.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_recur_cleanup.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_replay.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_retry_policy.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_saga_parameter_extraction.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_serialization.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_skip_locked.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_storage.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_storage_mysql.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_storage_postgresql.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_transactions.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_viewer_pagination.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_viewer_pydantic_form.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_viewer_start_saga.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_wait_timer.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow_auto_register.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow_cancellation.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/tests/test_workflow_resumption.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/viewer_app.py +0 -0
- {edda_framework-0.13.0 → edda_framework-0.14.1}/zensical.toml +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
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,
|
|
@@ -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
|