edda-framework 0.12.0__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {edda_framework-0.12.0 → edda_framework-0.14.0}/PKG-INFO +1 -1
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/channels.py +27 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/replay.py +41 -23
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/protocol.py +12 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/sqlalchemy_storage.py +20 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/viewer_ui/app.py +11 -4
- {edda_framework-0.12.0 → edda_framework-0.14.0}/pyproject.toml +1 -1
- {edda_framework-0.12.0 → edda_framework-0.14.0}/schema/README.md +4 -0
- edda_framework-0.14.0/schema/docs/column-values.md +91 -0
- edda_framework-0.14.0/tests/test_channel_mode_locking.py +201 -0
- edda_framework-0.14.0/tests/test_cross_language_channel.py +870 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/uv.lock +1 -1
- {edda_framework-0.12.0 → edda_framework-0.14.0}/.github/workflows/ci.yml +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/.github/workflows/docs.yml +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/.github/workflows/release.yml +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/.gitignore +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/.gitmodules +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/.python-version +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/Justfile +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/LICENSE +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/README.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/demo_app.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/api/reference.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/durable-execution/replay.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/events/cloudevents-http-binding.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/events/postgres-notify.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/events/wait-event.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/hooks.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/messages.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/retry.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/saga-compensation.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/transactional-outbox.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/core-features/workflows-activities.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/examples/ecommerce.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/examples/events.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/examples/fastapi-integration.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/examples/saga.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/examples/simple.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/getting-started/concepts.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/getting-started/first-workflow.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/getting-started/installation.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/getting-started/quick-start.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/index.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/integrations/mcp.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/integrations/mirascope.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/integrations/opentelemetry.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/integrations/pydantic-rpc.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/compensation-execution.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/workflow-list-view.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/setup.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/docs/viewer-ui/visualization.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/activity.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/app.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/compensation.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/context.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/exceptions.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/hooks.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mcp/decorators.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mcp/server.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mirascope/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mirascope/agent.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mirascope/call.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mirascope/decorator.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/mirascope/types.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/integrations/opentelemetry/hooks.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/locking.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/migrations/mysql/20251217000000_initial_schema.sql +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/migrations/postgresql/20251217000000_initial_schema.sql +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/migrations/sqlite/20251217000000_initial_schema.sql +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/outbox/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/outbox/relayer.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/outbox/transactional.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/pydantic_utils.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/retry.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/serialization/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/serialization/base.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/serialization/json.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/migrations.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/models.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/notify_base.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/storage/pg_notify.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/viewer_ui/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/viewer_ui/components.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/viewer_ui/data_service.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/viewer_ui/theme.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/visualizer/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/visualizer/ast_analyzer.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/visualizer/mermaid_generator.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/workflow.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/edda/wsgi.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/cancellable_workflow.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/compensation_workflow.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/event_waiting_app.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/event_waiting_workflow.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/event_waiting_workflow_complete.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/long_running_loop.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mcp/README.md +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mcp/order_processing_mcp.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mcp/prompts_example.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mcp/remote_server_example.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mcp/simple_mcp_server.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/message_passing.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mirascope/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mirascope/durable_agent.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mirascope/multi_turn.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mirascope/simple_call.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/mirascope/with_tools.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/observability_with_logfire.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/observability_with_opentelemetry.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/pydantic_rpc_integration.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/pydantic_saga.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/retry_example.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/retry_with_compensation.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/simple_workflow.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/typeddict_example.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/examples/with_outbox.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/schema/.dbmate.yml +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/schema/.git +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/schema/.gitignore +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/schema/LICENSE +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/conftest.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mcp/test_cancel.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mcp/test_integration.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mcp/test_jsonrpc.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mcp/test_prompts.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mcp/test_server.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mirascope/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mirascope/test_agent.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mirascope/test_call.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mirascope/test_decorator.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/mirascope/test_types.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/integrations/opentelemetry/test_hooks.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_activity.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_activity_retry.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_activity_sync.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_app.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_ast_analyzer.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_atomic_wait_event.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_auto_migration.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_binary_data.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_channel_competing.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_channel_direct.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_channel_transactional.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_cloudevents_http_binding.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_compensation.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_compensation_crash_recovery.py.wip +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_concurrent_outbox.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_context.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_ctx_session.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_distributed_event_delivery.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_events.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_instance_id_routing.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_lock_race_condition.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_lock_timeout_customization.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_locking.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_message_cleanup.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_message_delivery_lock.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_messages.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_migrations_integration.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_multidb_storage.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_outbox.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_pg_notify.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_polling_optimization.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_pydantic_activity.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_pydantic_enum.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_pydantic_events.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_pydantic_saga.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_pydantic_utils.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_receive_timeout.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_received_event.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_recur.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_recur_cleanup.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_replay.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_retry_policy.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_saga_parameter_extraction.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_serialization.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_skip_locked.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_stale_workflow_recovery.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_storage.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_storage_mysql.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_storage_postgresql.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_transactions.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_viewer_pagination.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_viewer_pydantic_form.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_viewer_start_saga.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_wait_timer.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_workflow.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_workflow_auto_register.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_workflow_cancellation.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/tests/test_workflow_resumption.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/viewer_app.py +0 -0
- {edda_framework-0.12.0 → edda_framework-0.14.0}/zensical.toml +0 -0
|
@@ -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
|
|
|
@@ -368,16 +368,22 @@ class ReplayEngine:
|
|
|
368
368
|
traceback.format_exception(type(error), error, error.__traceback__)
|
|
369
369
|
)
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
"
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
371
|
+
error_data = {
|
|
372
|
+
"error_message": str(error),
|
|
373
|
+
"error_type": type(error).__name__,
|
|
374
|
+
"stack_trace": stack_trace,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await self.storage.append_history(
|
|
378
|
+
instance_id=instance_id,
|
|
379
|
+
activity_id="workflow_failed",
|
|
380
|
+
event_type="WorkflowFailed",
|
|
381
|
+
event_data=error_data,
|
|
379
382
|
)
|
|
380
383
|
|
|
384
|
+
# Mark as failed with detailed error information
|
|
385
|
+
await ctx._update_status("failed", error_data)
|
|
386
|
+
|
|
381
387
|
# Call hook: workflow failed
|
|
382
388
|
if self.hooks and hasattr(self.hooks, "on_workflow_failed"):
|
|
383
389
|
await self.hooks.on_workflow_failed(instance_id, workflow_name, error)
|
|
@@ -576,15 +582,21 @@ class ReplayEngine:
|
|
|
576
582
|
traceback.format_exception(type(error), error, error.__traceback__)
|
|
577
583
|
)
|
|
578
584
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
"
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
585
|
+
error_data = {
|
|
586
|
+
"error_message": str(error),
|
|
587
|
+
"error_type": type(error).__name__,
|
|
588
|
+
"stack_trace": stack_trace,
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
await self.storage.append_history(
|
|
592
|
+
instance_id=instance_id,
|
|
593
|
+
activity_id="workflow_failed",
|
|
594
|
+
event_type="WorkflowFailed",
|
|
595
|
+
event_data=error_data,
|
|
587
596
|
)
|
|
597
|
+
|
|
598
|
+
# Mark as failed with detailed error information
|
|
599
|
+
await ctx._update_status("failed", error_data)
|
|
588
600
|
raise
|
|
589
601
|
|
|
590
602
|
async def resume_by_name(
|
|
@@ -775,15 +787,21 @@ class ReplayEngine:
|
|
|
775
787
|
traceback.format_exception(type(error), error, error.__traceback__)
|
|
776
788
|
)
|
|
777
789
|
|
|
778
|
-
|
|
779
|
-
"
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
790
|
+
error_data = {
|
|
791
|
+
"error_message": str(error),
|
|
792
|
+
"error_type": type(error).__name__,
|
|
793
|
+
"stack_trace": stack_trace,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
await self.storage.append_history(
|
|
797
|
+
instance_id=new_instance_id,
|
|
798
|
+
activity_id="workflow_failed",
|
|
799
|
+
event_type="WorkflowFailed",
|
|
800
|
+
event_data=error_data,
|
|
785
801
|
)
|
|
786
802
|
|
|
803
|
+
await ctx._update_status("failed", error_data)
|
|
804
|
+
|
|
787
805
|
if self.hooks and hasattr(self.hooks, "on_workflow_failed"):
|
|
788
806
|
await self.hooks.on_workflow_failed(new_instance_id, workflow_name, error)
|
|
789
807
|
|
|
@@ -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,
|
|
@@ -309,6 +309,10 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
309
309
|
all_workflows = service.get_all_workflows()
|
|
310
310
|
workflow_names = list(all_workflows.keys())
|
|
311
311
|
|
|
312
|
+
workflow_select: Any = None
|
|
313
|
+
params_container: Any = None
|
|
314
|
+
param_fields: dict[str, Any] = {}
|
|
315
|
+
|
|
312
316
|
if not workflow_names:
|
|
313
317
|
ui.label("No workflows registered").classes("text-red-500")
|
|
314
318
|
ui.button("Close", on_click=start_dialog.close)
|
|
@@ -323,9 +327,6 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
323
327
|
# Container for dynamic parameter fields
|
|
324
328
|
params_container = ui.column().classes("w-full mb-4")
|
|
325
329
|
|
|
326
|
-
# Store input field references
|
|
327
|
-
param_fields: dict[str, Any] = {}
|
|
328
|
-
|
|
329
330
|
# Factory functions for creating field managers with proper closures
|
|
330
331
|
# These must be defined outside the loop to avoid closure issues
|
|
331
332
|
|
|
@@ -778,6 +779,8 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
778
779
|
|
|
779
780
|
def update_parameter_fields() -> None:
|
|
780
781
|
"""Update parameter input fields based on selected workflow."""
|
|
782
|
+
if workflow_select is None or params_container is None:
|
|
783
|
+
return
|
|
781
784
|
selected_workflow = workflow_select.value
|
|
782
785
|
if not selected_workflow:
|
|
783
786
|
return
|
|
@@ -1106,13 +1109,17 @@ def start_viewer(edda_app: EddaApp, port: int = 8080, reload: bool = False) -> N
|
|
|
1106
1109
|
update_parameter_fields()
|
|
1107
1110
|
|
|
1108
1111
|
# Update fields when workflow selection changes
|
|
1109
|
-
workflow_select
|
|
1112
|
+
if workflow_select is not None:
|
|
1113
|
+
workflow_select.on_value_change(lambda _: update_parameter_fields())
|
|
1110
1114
|
|
|
1111
1115
|
# Action buttons
|
|
1112
1116
|
with ui.row().classes("w-full gap-2"):
|
|
1113
1117
|
|
|
1114
1118
|
async def handle_start() -> None:
|
|
1115
1119
|
"""Handle workflow start."""
|
|
1120
|
+
if workflow_select is None:
|
|
1121
|
+
ui.notify("No workflows available", type="negative")
|
|
1122
|
+
return
|
|
1116
1123
|
try:
|
|
1117
1124
|
selected_workflow = workflow_select.value
|
|
1118
1125
|
if not selected_workflow:
|
|
@@ -48,6 +48,10 @@ dbmate status # Check migration status
|
|
|
48
48
|
dbmate down # Rollback latest migration
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
- [Column Values Reference](docs/column-values.md) - Standard values for database columns across all implementations
|
|
54
|
+
|
|
51
55
|
## License
|
|
52
56
|
|
|
53
57
|
MIT
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Database Column Values Reference
|
|
2
|
+
|
|
3
|
+
This document defines the standard values for database columns that must be consistent across all Durax framework implementations (Edda/Python, Romancy/Go, etc.).
|
|
4
|
+
|
|
5
|
+
## workflow_instances.status
|
|
6
|
+
|
|
7
|
+
| Value | Description |
|
|
8
|
+
|-------|-------------|
|
|
9
|
+
| `running` | Workflow is actively executing |
|
|
10
|
+
| `completed` | Workflow finished successfully |
|
|
11
|
+
| `failed` | Workflow failed with an error |
|
|
12
|
+
| `cancelled` | Workflow was cancelled by user |
|
|
13
|
+
| `waiting_for_event` | Workflow is waiting for an external event (via `wait_event`) |
|
|
14
|
+
| `waiting_for_timer` | Workflow is waiting for a timer to expire (via `sleep`/`sleep_until`) |
|
|
15
|
+
| `waiting_for_message` | Workflow is waiting for a channel message (via `receive`) |
|
|
16
|
+
| `recurred` | Workflow completed one iteration and is ready to restart (Erlang-style tail recursion) |
|
|
17
|
+
| `compensating` | Workflow is executing compensation handlers |
|
|
18
|
+
|
|
19
|
+
## workflow_history.event_type
|
|
20
|
+
|
|
21
|
+
All event types use **PascalCase** for cross-language consistency.
|
|
22
|
+
|
|
23
|
+
| Value | Description |
|
|
24
|
+
|-------|-------------|
|
|
25
|
+
| `ActivityCompleted` | An activity finished successfully |
|
|
26
|
+
| `ActivityFailed` | An activity failed with an error |
|
|
27
|
+
| `EventReceived` | An external event was received (via `wait_event`) |
|
|
28
|
+
| `TimerExpired` | A timer expired (via `sleep`/`sleep_until`) |
|
|
29
|
+
| `ChannelMessageReceived` | A channel message was received (via `receive`) |
|
|
30
|
+
| `MessageTimeout` | A channel message receive timed out |
|
|
31
|
+
| `CompensationExecuted` | A compensation handler executed successfully |
|
|
32
|
+
| `CompensationFailed` | A compensation handler failed |
|
|
33
|
+
| `WorkflowFailed` | The workflow failed (recorded before status update) |
|
|
34
|
+
| `WorkflowCancelled` | The workflow was cancelled |
|
|
35
|
+
|
|
36
|
+
## workflow_history.data_type
|
|
37
|
+
|
|
38
|
+
| Value | Description |
|
|
39
|
+
|-------|-------------|
|
|
40
|
+
| `json` | Event data is stored as JSON text in `event_data` column |
|
|
41
|
+
| `binary` | Event data is stored as raw bytes in `event_data_binary` column |
|
|
42
|
+
|
|
43
|
+
## channel_subscriptions.mode
|
|
44
|
+
|
|
45
|
+
| Value | Description |
|
|
46
|
+
|-------|-------------|
|
|
47
|
+
| `broadcast` | All subscribers receive a copy of each message |
|
|
48
|
+
| `competing` | Only one subscriber receives each message (work queue pattern) |
|
|
49
|
+
|
|
50
|
+
## channel_messages.data_type
|
|
51
|
+
|
|
52
|
+
| Value | Description |
|
|
53
|
+
|-------|-------------|
|
|
54
|
+
| `json` | Message data is stored as JSON text in `data` column |
|
|
55
|
+
| `binary` | Message data is stored as raw bytes in `data_binary` column |
|
|
56
|
+
|
|
57
|
+
## outbox_events.status
|
|
58
|
+
|
|
59
|
+
| Value | Description |
|
|
60
|
+
|-------|-------------|
|
|
61
|
+
| `pending` | Event is waiting to be published |
|
|
62
|
+
| `processing` | Event is currently being processed by the relayer |
|
|
63
|
+
| `published` | Event was successfully published |
|
|
64
|
+
| `failed` | Event publishing failed after all retries |
|
|
65
|
+
| `invalid` | Event is malformed and cannot be published |
|
|
66
|
+
| `expired` | Event expired before it could be published |
|
|
67
|
+
|
|
68
|
+
## workflow_instances.framework
|
|
69
|
+
|
|
70
|
+
| Value | Description |
|
|
71
|
+
|-------|-------------|
|
|
72
|
+
| `python` | Workflow is managed by Edda (Python) |
|
|
73
|
+
| `go` | Workflow is managed by Romancy (Go) |
|
|
74
|
+
|
|
75
|
+
This column enables cross-language interoperability: any framework can deliver messages to workflows managed by other frameworks, while each framework only processes (replays/resumes) its own workflows.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Cross-Language Compatibility Notes
|
|
80
|
+
|
|
81
|
+
1. **Event type naming**: Always use PascalCase (e.g., `ActivityCompleted`, not `activity_completed`)
|
|
82
|
+
|
|
83
|
+
2. **Message delivery**: Any framework can deliver channel messages to any workflow, regardless of the target workflow's framework. The delivering framework:
|
|
84
|
+
- Acquires a lock on the target workflow
|
|
85
|
+
- Records the message in `workflow_history` with the correct `event_type`
|
|
86
|
+
- Updates the workflow status to `running`
|
|
87
|
+
- Releases the lock
|
|
88
|
+
|
|
89
|
+
3. **Workflow processing**: Each framework only processes workflows where `framework` matches its own identifier. This ensures correct replay behavior.
|
|
90
|
+
|
|
91
|
+
4. **Activity IDs**: Both frameworks use the format `{activity_name}:{counter}` for deterministic replay (e.g., `fetch_data:1`, `receive_orders:2`).
|
|
@@ -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
|