edda-framework 0.11.0__tar.gz → 0.12.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.11.0 → edda_framework-0.12.0}/.github/workflows/ci.yml +3 -1
- {edda_framework-0.11.0 → edda_framework-0.12.0}/.github/workflows/docs.yml +3 -1
- {edda_framework-0.11.0 → edda_framework-0.12.0}/.github/workflows/release.yml +6 -2
- edda_framework-0.12.0/.gitmodules +3 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/PKG-INFO +43 -3
- {edda_framework-0.11.0 → edda_framework-0.12.0}/README.md +42 -2
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/durable-execution/replay.md +3 -2
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/messages.md +35 -1
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/installation.md +82 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/pydantic-rpc.md +1 -1
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/app.py +203 -35
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/channels.py +57 -12
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/context.py +24 -0
- edda_framework-0.12.0/edda/migrations/mysql/20251217000000_initial_schema.sql +284 -0
- edda_framework-0.12.0/edda/migrations/postgresql/20251217000000_initial_schema.sql +284 -0
- edda_framework-0.12.0/edda/migrations/sqlite/20251217000000_initial_schema.sql +284 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/outbox/relayer.py +34 -7
- edda_framework-0.12.0/edda/storage/migrations.py +435 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/models.py +2 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/pg_notify.py +5 -8
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/sqlalchemy_storage.py +97 -61
- {edda_framework-0.11.0 → edda_framework-0.12.0}/pyproject.toml +8 -2
- edda_framework-0.12.0/schema/.dbmate.yml +16 -0
- edda_framework-0.12.0/schema/.git +1 -0
- edda_framework-0.12.0/schema/.gitignore +20 -0
- edda_framework-0.12.0/schema/LICENSE +21 -0
- edda_framework-0.12.0/schema/README.md +53 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/conftest.py +32 -10
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_app.py +46 -39
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_auto_migration.py +6 -1
- edda_framework-0.12.0/tests/test_channel_direct.py +329 -0
- edda_framework-0.12.0/tests/test_migrations_integration.py +211 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pg_notify.py +5 -5
- edda_framework-0.12.0/tests/test_polling_optimization.py +1026 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/uv.lock +17 -17
- {edda_framework-0.11.0 → edda_framework-0.12.0}/.gitignore +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/.python-version +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/Justfile +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/LICENSE +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/demo_app.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/api/reference.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/events/cloudevents-http-binding.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/events/postgres-notify.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/events/wait-event.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/hooks.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/retry.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/saga-compensation.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/transactional-outbox.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/workflows-activities.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/ecommerce.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/events.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/fastapi-integration.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/saga.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/simple.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/concepts.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/first-workflow.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/quick-start.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/index.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/mcp.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/mirascope.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/opentelemetry.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/compensation-execution.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/workflow-list-view.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/setup.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/visualization.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/activity.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/compensation.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/exceptions.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/hooks.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mcp/decorators.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mcp/server.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/agent.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/call.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/decorator.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/types.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/opentelemetry/hooks.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/locking.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/outbox/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/outbox/transactional.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/pydantic_utils.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/replay.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/retry.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/serialization/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/serialization/base.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/serialization/json.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/notify_base.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/protocol.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/app.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/components.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/data_service.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/theme.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/visualizer/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/visualizer/ast_analyzer.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/visualizer/mermaid_generator.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/workflow.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/wsgi.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/cancellable_workflow.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/compensation_workflow.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/event_waiting_app.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/event_waiting_workflow.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/event_waiting_workflow_complete.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/long_running_loop.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/README.md +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/order_processing_mcp.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/prompts_example.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/remote_server_example.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/simple_mcp_server.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/message_passing.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/durable_agent.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/multi_turn.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/simple_call.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/with_tools.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/observability_with_logfire.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/observability_with_opentelemetry.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/pydantic_rpc_integration.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/pydantic_saga.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/retry_example.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/retry_with_compensation.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/simple_workflow.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/typeddict_example.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/with_outbox.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_cancel.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_integration.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_jsonrpc.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_prompts.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_server.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_agent.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_call.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_decorator.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_types.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/opentelemetry/__init__.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/opentelemetry/test_hooks.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_activity.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_activity_retry.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_activity_sync.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_ast_analyzer.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_atomic_wait_event.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_binary_data.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_channel_competing.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_channel_transactional.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_cloudevents_http_binding.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_compensation.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_compensation_crash_recovery.py.wip +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_concurrent_outbox.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_context.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_ctx_session.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_distributed_event_delivery.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_events.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_instance_id_routing.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_lock_race_condition.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_lock_timeout_customization.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_locking.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_message_cleanup.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_message_delivery_lock.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_messages.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_multidb_storage.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_outbox.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_activity.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_enum.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_events.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_saga.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_utils.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_receive_timeout.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_received_event.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_recur.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_recur_cleanup.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_replay.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_retry_policy.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_saga_parameter_extraction.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_serialization.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_skip_locked.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_stale_workflow_recovery.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_storage.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_storage_mysql.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_storage_postgresql.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_transactions.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_viewer_pagination.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_viewer_pydantic_form.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_viewer_start_saga.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_wait_timer.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow_auto_register.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow_cancellation.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow_resumption.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/viewer_app.py +0 -0
- {edda_framework-0.11.0 → edda_framework-0.12.0}/zensical.toml +0 -0
|
@@ -17,7 +17,9 @@ jobs:
|
|
|
17
17
|
|
|
18
18
|
steps:
|
|
19
19
|
- name: Checkout code
|
|
20
|
-
uses: actions/checkout@
|
|
20
|
+
uses: actions/checkout@v6
|
|
21
|
+
with:
|
|
22
|
+
submodules: true
|
|
21
23
|
|
|
22
24
|
- name: Set up uv
|
|
23
25
|
uses: astral-sh/setup-uv@v3
|
|
@@ -29,7 +31,9 @@ jobs:
|
|
|
29
31
|
run: uv sync
|
|
30
32
|
|
|
31
33
|
- name: Build package
|
|
32
|
-
run:
|
|
34
|
+
run: |
|
|
35
|
+
uv build --sdist
|
|
36
|
+
uv build --wheel
|
|
33
37
|
|
|
34
38
|
- name: Publish to PyPI
|
|
35
39
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edda-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Lightweight Durable Execution Framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2y/edda
|
|
6
6
|
Project-URL: Documentation, https://github.com/i2y/edda#readme
|
|
@@ -286,6 +286,46 @@ pip install "git+https://github.com/i2y/edda.git[postgresql,viewer]"
|
|
|
286
286
|
|
|
287
287
|
> **Tip**: For PostgreSQL, install the `postgres-notify` extra for near-instant event delivery using LISTEN/NOTIFY instead of polling.
|
|
288
288
|
|
|
289
|
+
### Database Schema Migration
|
|
290
|
+
|
|
291
|
+
**Automatic Migration (Default)**
|
|
292
|
+
|
|
293
|
+
Edda automatically applies database migrations at startup. No manual commands needed:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from edda import EddaApp
|
|
297
|
+
|
|
298
|
+
# Migrations are applied automatically
|
|
299
|
+
app = EddaApp(db_url="postgresql://user:pass@localhost/dbname")
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
This is safe in multi-worker environments - Edda handles concurrent startup gracefully.
|
|
303
|
+
|
|
304
|
+
**Manual Migration with dbmate (Optional)**
|
|
305
|
+
|
|
306
|
+
For explicit schema control, you can disable auto-migration and use [dbmate](https://github.com/amacneil/dbmate):
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
# Disable auto-migration
|
|
310
|
+
app = EddaApp(
|
|
311
|
+
db_url="postgresql://...",
|
|
312
|
+
auto_migrate=False # Use dbmate-managed schema
|
|
313
|
+
)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# Install dbmate
|
|
318
|
+
brew install dbmate # macOS
|
|
319
|
+
|
|
320
|
+
# Add schema submodule
|
|
321
|
+
git submodule add https://github.com/durax-io/schema.git schema
|
|
322
|
+
|
|
323
|
+
# Run migration manually
|
|
324
|
+
DATABASE_URL="postgresql://user:pass@localhost/dbname" dbmate -d ./schema/db/migrations/postgresql up
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
> **Note**: Edda's auto-migration uses the same SQL files as dbmate, maintaining full compatibility.
|
|
328
|
+
|
|
289
329
|
### Development Installation
|
|
290
330
|
|
|
291
331
|
If you want to contribute to Edda or modify the framework itself:
|
|
@@ -293,7 +333,7 @@ If you want to contribute to Edda or modify the framework itself:
|
|
|
293
333
|
```bash
|
|
294
334
|
# Clone repository
|
|
295
335
|
git clone https://github.com/i2y/edda.git
|
|
296
|
-
cd
|
|
336
|
+
cd edda
|
|
297
337
|
uv sync --all-extras
|
|
298
338
|
```
|
|
299
339
|
|
|
@@ -333,7 +373,7 @@ async def user_signup(ctx: WorkflowContext, email: str):
|
|
|
333
373
|
return {"status": "completed"}
|
|
334
374
|
```
|
|
335
375
|
|
|
336
|
-
**Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`).
|
|
376
|
+
**Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`).
|
|
337
377
|
|
|
338
378
|
### Durable Execution
|
|
339
379
|
|
|
@@ -221,6 +221,46 @@ pip install "git+https://github.com/i2y/edda.git[postgresql,viewer]"
|
|
|
221
221
|
|
|
222
222
|
> **Tip**: For PostgreSQL, install the `postgres-notify` extra for near-instant event delivery using LISTEN/NOTIFY instead of polling.
|
|
223
223
|
|
|
224
|
+
### Database Schema Migration
|
|
225
|
+
|
|
226
|
+
**Automatic Migration (Default)**
|
|
227
|
+
|
|
228
|
+
Edda automatically applies database migrations at startup. No manual commands needed:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from edda import EddaApp
|
|
232
|
+
|
|
233
|
+
# Migrations are applied automatically
|
|
234
|
+
app = EddaApp(db_url="postgresql://user:pass@localhost/dbname")
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
This is safe in multi-worker environments - Edda handles concurrent startup gracefully.
|
|
238
|
+
|
|
239
|
+
**Manual Migration with dbmate (Optional)**
|
|
240
|
+
|
|
241
|
+
For explicit schema control, you can disable auto-migration and use [dbmate](https://github.com/amacneil/dbmate):
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
# Disable auto-migration
|
|
245
|
+
app = EddaApp(
|
|
246
|
+
db_url="postgresql://...",
|
|
247
|
+
auto_migrate=False # Use dbmate-managed schema
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
# Install dbmate
|
|
253
|
+
brew install dbmate # macOS
|
|
254
|
+
|
|
255
|
+
# Add schema submodule
|
|
256
|
+
git submodule add https://github.com/durax-io/schema.git schema
|
|
257
|
+
|
|
258
|
+
# Run migration manually
|
|
259
|
+
DATABASE_URL="postgresql://user:pass@localhost/dbname" dbmate -d ./schema/db/migrations/postgresql up
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
> **Note**: Edda's auto-migration uses the same SQL files as dbmate, maintaining full compatibility.
|
|
263
|
+
|
|
224
264
|
### Development Installation
|
|
225
265
|
|
|
226
266
|
If you want to contribute to Edda or modify the framework itself:
|
|
@@ -228,7 +268,7 @@ If you want to contribute to Edda or modify the framework itself:
|
|
|
228
268
|
```bash
|
|
229
269
|
# Clone repository
|
|
230
270
|
git clone https://github.com/i2y/edda.git
|
|
231
|
-
cd
|
|
271
|
+
cd edda
|
|
232
272
|
uv sync --all-extras
|
|
233
273
|
```
|
|
234
274
|
|
|
@@ -268,7 +308,7 @@ async def user_signup(ctx: WorkflowContext, email: str):
|
|
|
268
308
|
return {"status": "completed"}
|
|
269
309
|
```
|
|
270
310
|
|
|
271
|
-
**Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`).
|
|
311
|
+
**Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`).
|
|
272
312
|
|
|
273
313
|
### Durable Execution
|
|
274
314
|
|
{edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/durable-execution/replay.md
RENAMED
|
@@ -349,7 +349,7 @@ async def resume_workflow_endpoint(instance_id: str):
|
|
|
349
349
|
|
|
350
350
|
Edda automatically recovers from crashes in two stages:
|
|
351
351
|
|
|
352
|
-
#### 3-1. Stale Lock Cleanup
|
|
352
|
+
#### 3-1. Stale Lock Cleanup
|
|
353
353
|
|
|
354
354
|
When a worker process crashes, its locks become "stale." Edda automatically cleans these up:
|
|
355
355
|
|
|
@@ -394,7 +394,7 @@ This background task starts automatically when `EddaApp` launches.
|
|
|
394
394
|
|
|
395
395
|
The `status` field indicates whether the workflow was running normally (`"running"`) or executing compensations (`"compensating"`) when it crashed.
|
|
396
396
|
|
|
397
|
-
#### 3-2. Automatic Workflow Resume
|
|
397
|
+
#### 3-2. Automatic Workflow Resume
|
|
398
398
|
|
|
399
399
|
After cleaning stale locks, Edda automatically resumes workflows with `status="running"` or `status="compensating"`:
|
|
400
400
|
|
|
@@ -586,6 +586,7 @@ async with workflow_lock(storage, instance_id, worker_id, timeout_seconds=300):
|
|
|
586
586
|
```
|
|
587
587
|
|
|
588
588
|
Features:
|
|
589
|
+
|
|
589
590
|
- **5-minute timeout** by default (prevents indefinite locks)
|
|
590
591
|
- **Worker ID tracking** (know which worker holds the lock)
|
|
591
592
|
- **Stale lock cleanup** (automatic recovery after crashes)
|
|
@@ -47,6 +47,40 @@ async def notification_service(ctx: WorkflowContext, service_id: str):
|
|
|
47
47
|
|
|
48
48
|
- `"broadcast"` (default): All subscribers receive all messages. Use for fan-out patterns like notifications.
|
|
49
49
|
- `"competing"`: Each message is processed by only one subscriber. Use for job queues and task distribution.
|
|
50
|
+
- `"direct"`: Receive messages sent via `send_to()` to this specific instance. Syntactic sugar for point-to-point messaging.
|
|
51
|
+
|
|
52
|
+
**Using `mode="direct"`**:
|
|
53
|
+
|
|
54
|
+
The `"direct"` mode simplifies receiving messages sent via `send_to()`:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
@workflow
|
|
58
|
+
async def direct_receiver(ctx: WorkflowContext, id: str):
|
|
59
|
+
# Subscribe to receive direct messages
|
|
60
|
+
await subscribe(ctx, "notifications", mode="direct")
|
|
61
|
+
|
|
62
|
+
# Wait for a message sent via send_to()
|
|
63
|
+
msg = await receive(ctx, "notifications")
|
|
64
|
+
return msg.data
|
|
65
|
+
|
|
66
|
+
@workflow
|
|
67
|
+
async def sender(ctx: WorkflowContext, receiver_id: str):
|
|
68
|
+
# Send directly to the receiver instance
|
|
69
|
+
await send_to(ctx, instance_id=receiver_id, data={"hello": "world"}, channel="notifications")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This is equivalent to manually constructing the channel name:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# Without mode="direct" (manual approach)
|
|
76
|
+
direct_channel = f"notifications:{ctx.instance_id}"
|
|
77
|
+
await subscribe(ctx, direct_channel, mode="broadcast")
|
|
78
|
+
msg = await receive(ctx, direct_channel)
|
|
79
|
+
|
|
80
|
+
# With mode="direct" (simplified)
|
|
81
|
+
await subscribe(ctx, "notifications", mode="direct")
|
|
82
|
+
msg = await receive(ctx, "notifications")
|
|
83
|
+
```
|
|
50
84
|
|
|
51
85
|
#### `unsubscribe()`
|
|
52
86
|
|
|
@@ -202,7 +236,7 @@ async def subscribe(
|
|
|
202
236
|
|
|
203
237
|
- `ctx`: Workflow context
|
|
204
238
|
- `channel`: Channel name to subscribe to
|
|
205
|
-
- `mode`: `"broadcast"` (all subscribers receive)
|
|
239
|
+
- `mode`: `"broadcast"` (all subscribers receive), `"competing"` (one subscriber per message), or `"direct"` (receive messages from `send_to()`)
|
|
206
240
|
|
|
207
241
|
### receive()
|
|
208
242
|
|
|
@@ -310,6 +310,88 @@ app = EddaApp(
|
|
|
310
310
|
)
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
+
### Schema Migration
|
|
314
|
+
|
|
315
|
+
#### Automatic Migration (Default)
|
|
316
|
+
|
|
317
|
+
Edda automatically applies database migrations at startup. No manual commands needed:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
from edda import EddaApp
|
|
321
|
+
|
|
322
|
+
# Migrations are applied automatically at startup
|
|
323
|
+
app = EddaApp(
|
|
324
|
+
service_name="demo-service",
|
|
325
|
+
db_url="postgresql://user:pass@localhost/dbname"
|
|
326
|
+
)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Key features:**
|
|
330
|
+
|
|
331
|
+
- **Zero configuration**: Works out of the box
|
|
332
|
+
- **Multi-worker safe**: Handles concurrent startup gracefully (race condition protected)
|
|
333
|
+
- **dbmate compatible**: Uses the same SQL files and `schema_migrations` table
|
|
334
|
+
- **Incremental**: Only applies pending migrations
|
|
335
|
+
|
|
336
|
+
#### Manual Migration with dbmate (Optional)
|
|
337
|
+
|
|
338
|
+
For explicit schema control, you can disable auto-migration and use [dbmate](https://github.com/amacneil/dbmate):
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
# Disable auto-migration
|
|
342
|
+
app = EddaApp(
|
|
343
|
+
service_name="demo-service",
|
|
344
|
+
db_url="postgresql://...",
|
|
345
|
+
auto_migrate=False # Use dbmate-managed schema
|
|
346
|
+
)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
# Install dbmate
|
|
351
|
+
brew install dbmate # macOS
|
|
352
|
+
# Linux: curl -fsSL https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-amd64 -o /usr/local/bin/dbmate && chmod +x /usr/local/bin/dbmate
|
|
353
|
+
|
|
354
|
+
# Add schema submodule to your project
|
|
355
|
+
git submodule add https://github.com/durax-io/schema.git schema
|
|
356
|
+
|
|
357
|
+
# Run migration manually
|
|
358
|
+
DATABASE_URL="postgresql://user:pass@localhost/dbname" dbmate -d ./schema/db/migrations/postgresql up
|
|
359
|
+
|
|
360
|
+
# Check status
|
|
361
|
+
dbmate -d ./schema/db/migrations/postgresql status
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
> **Note**: Edda's auto-migration uses the same SQL files as dbmate, so you can switch between modes freely.
|
|
365
|
+
|
|
366
|
+
### Multi-Worker Configuration
|
|
367
|
+
|
|
368
|
+
When running multiple Edda workers (e.g., in Kubernetes or with multiple processes), Edda automatically coordinates background tasks using **leader election**. Only one worker runs maintenance tasks (timers, message cleanup, etc.) while others focus on workflow execution.
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
from edda import EddaApp
|
|
372
|
+
|
|
373
|
+
app = EddaApp(
|
|
374
|
+
service_name="demo-service",
|
|
375
|
+
db_url="postgresql://...",
|
|
376
|
+
# Leader election settings (optional - defaults work well for most cases)
|
|
377
|
+
leader_heartbeat_interval=15, # How often workers check/renew leadership
|
|
378
|
+
leader_lease_duration=45, # How long before a failed leader is replaced
|
|
379
|
+
)
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**Configuration options:**
|
|
383
|
+
|
|
384
|
+
| Parameter | Type | Default | Description |
|
|
385
|
+
|-----------|------|---------|-------------|
|
|
386
|
+
| `leader_heartbeat_interval` | `int` | `15` | Interval in seconds for leader heartbeat |
|
|
387
|
+
| `leader_lease_duration` | `int` | `45` | Duration in seconds before leadership expires |
|
|
388
|
+
|
|
389
|
+
**Notes:**
|
|
390
|
+
|
|
391
|
+
- Default values work well for most deployments
|
|
392
|
+
- Reduce `leader_lease_duration` for faster failover (minimum: 3x heartbeat interval)
|
|
393
|
+
- Leader election uses the database for coordination (no external dependencies)
|
|
394
|
+
|
|
313
395
|
## Next Steps
|
|
314
396
|
|
|
315
397
|
- **[Quick Start](quick-start.md)**: Build your first workflow in 5 minutes
|
|
@@ -189,5 +189,5 @@ These models work seamlessly with Edda's Pydantic integration for:
|
|
|
189
189
|
- [Workflows and Activities](../core-features/workflows-activities.md)
|
|
190
190
|
- [Pydantic Integration](../core-features/workflows-activities.md#pydantic-integration)
|
|
191
191
|
- [Example Code](https://github.com/i2y/edda/blob/main/examples/pydantic_rpc_integration.py)
|
|
192
|
-
- [pydantic-rpc](https://github.com/
|
|
192
|
+
- [pydantic-rpc](https://github.com/i2y/pydantic-rpc)
|
|
193
193
|
- [connect-python](https://github.com/connectrpc/connect-python)
|
|
@@ -63,6 +63,9 @@ class EddaApp:
|
|
|
63
63
|
notify_fallback_interval: int = 30,
|
|
64
64
|
# Batch processing settings
|
|
65
65
|
max_workflows_per_batch: int | Literal["auto", "auto:cpu"] = 10,
|
|
66
|
+
# Leader election settings (for coordinating background tasks across workers)
|
|
67
|
+
leader_heartbeat_interval: int = 15,
|
|
68
|
+
leader_lease_duration: int = 45,
|
|
66
69
|
):
|
|
67
70
|
"""
|
|
68
71
|
Initialize Edda application.
|
|
@@ -100,6 +103,10 @@ class EddaApp:
|
|
|
100
103
|
- int: Fixed batch size (default: 10)
|
|
101
104
|
- "auto": Scale 10-100 based on queue depth
|
|
102
105
|
- "auto:cpu": Scale 10-100 based on CPU utilization (requires psutil)
|
|
106
|
+
leader_heartbeat_interval: Interval in seconds for leader heartbeat (default: 15).
|
|
107
|
+
Controls how often workers attempt to become/maintain leadership.
|
|
108
|
+
leader_lease_duration: Duration in seconds for leader lease (default: 45).
|
|
109
|
+
If leader fails to heartbeat within this time, another worker takes over.
|
|
103
110
|
"""
|
|
104
111
|
self.db_url = db_url
|
|
105
112
|
self.service_name = service_name
|
|
@@ -168,6 +175,12 @@ class EddaApp:
|
|
|
168
175
|
"Must be int, 'auto', or 'auto:cpu'."
|
|
169
176
|
)
|
|
170
177
|
|
|
178
|
+
# Leader election settings (for coordinating background tasks)
|
|
179
|
+
self._leader_heartbeat_interval = leader_heartbeat_interval
|
|
180
|
+
self._leader_lease_duration = leader_lease_duration
|
|
181
|
+
self._is_leader = False
|
|
182
|
+
self._leader_tasks: list[asyncio.Task[Any]] = []
|
|
183
|
+
|
|
171
184
|
def _create_storage(self, db_url: str) -> SQLAlchemyStorage:
|
|
172
185
|
"""
|
|
173
186
|
Create storage backend from database URL.
|
|
@@ -309,19 +322,19 @@ class EddaApp:
|
|
|
309
322
|
|
|
310
323
|
# Subscribe to workflow resumable notifications
|
|
311
324
|
await self._notify_listener.subscribe(
|
|
312
|
-
"
|
|
325
|
+
"workflow_resumable",
|
|
313
326
|
self._on_workflow_resumable_notify,
|
|
314
327
|
)
|
|
315
328
|
|
|
316
329
|
# Subscribe to outbox notifications
|
|
317
330
|
await self._notify_listener.subscribe(
|
|
318
|
-
"
|
|
331
|
+
"workflow_outbox_pending",
|
|
319
332
|
self._on_outbox_pending_notify,
|
|
320
333
|
)
|
|
321
334
|
|
|
322
335
|
# Subscribe to timer expired notifications
|
|
323
336
|
await self._notify_listener.subscribe(
|
|
324
|
-
"
|
|
337
|
+
"workflow_timer_expired",
|
|
325
338
|
self._on_timer_expired_notify,
|
|
326
339
|
)
|
|
327
340
|
|
|
@@ -462,47 +475,202 @@ class EddaApp:
|
|
|
462
475
|
self._initialized = False
|
|
463
476
|
|
|
464
477
|
def _start_background_tasks(self) -> None:
|
|
465
|
-
"""Start background maintenance tasks.
|
|
466
|
-
# Task to cleanup stale locks and auto-resume workflows
|
|
467
|
-
auto_resume_task = asyncio.create_task(
|
|
468
|
-
auto_resume_stale_workflows_periodically(
|
|
469
|
-
self.storage,
|
|
470
|
-
self.replay_engine,
|
|
471
|
-
self.worker_id,
|
|
472
|
-
interval=60, # Check every 60 seconds
|
|
473
|
-
)
|
|
474
|
-
)
|
|
475
|
-
self._background_tasks.append(auto_resume_task)
|
|
478
|
+
"""Start background maintenance tasks.
|
|
476
479
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
#
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
self._check_expired_message_subscriptions_periodically(
|
|
487
|
-
interval=10
|
|
488
|
-
) # Check every 10 seconds
|
|
489
|
-
)
|
|
490
|
-
self._background_tasks.append(message_timeout_task)
|
|
480
|
+
Background tasks are divided into two categories:
|
|
481
|
+
1. All-worker tasks: Run on every worker (leader election, workflow resumption)
|
|
482
|
+
2. Leader-only tasks: Run only on the elected leader (timers, timeouts, cleanup)
|
|
483
|
+
|
|
484
|
+
This design reduces database polling load significantly in multi-worker deployments.
|
|
485
|
+
"""
|
|
486
|
+
# Leader election loop (all workers participate)
|
|
487
|
+
leader_election_task = asyncio.create_task(self._leader_election_loop())
|
|
488
|
+
self._background_tasks.append(leader_election_task)
|
|
491
489
|
|
|
492
|
-
# Task to resume workflows after message delivery (
|
|
490
|
+
# Task to resume workflows after message delivery (all workers - competitive lock)
|
|
493
491
|
message_resume_task = asyncio.create_task(
|
|
494
492
|
self._resume_running_workflows_periodically(interval=1) # Check every 1 second
|
|
495
493
|
)
|
|
496
494
|
self._background_tasks.append(message_resume_task)
|
|
497
495
|
|
|
498
|
-
#
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
496
|
+
# Note: Leader-only tasks (timer checks, message timeouts, stale workflow cleanup,
|
|
497
|
+
# old message cleanup) are started dynamically in _leader_election_loop() when
|
|
498
|
+
# this worker becomes the leader.
|
|
499
|
+
|
|
500
|
+
async def _leader_election_loop(self) -> None:
|
|
501
|
+
"""
|
|
502
|
+
Leader election loop that runs on all workers.
|
|
503
|
+
|
|
504
|
+
Uses system lock to elect a single leader among all workers.
|
|
505
|
+
The leader runs maintenance tasks (timer checks, message timeouts, etc.).
|
|
506
|
+
Non-leaders only participate in workflow resumption.
|
|
507
|
+
|
|
508
|
+
If a leader task crashes, it will be automatically restarted.
|
|
509
|
+
"""
|
|
510
|
+
while True:
|
|
511
|
+
try:
|
|
512
|
+
was_leader = self._is_leader
|
|
513
|
+
|
|
514
|
+
# Try to acquire/renew leadership
|
|
515
|
+
self._is_leader = await self.storage.try_acquire_system_lock(
|
|
516
|
+
lock_name="edda_leader",
|
|
517
|
+
worker_id=self.worker_id,
|
|
518
|
+
timeout_seconds=self._leader_lease_duration,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if self._is_leader and not was_leader:
|
|
522
|
+
# Became leader - start leader-only tasks
|
|
523
|
+
logger.info(f"Worker {self.worker_id} became leader")
|
|
524
|
+
self._leader_tasks = self._create_leader_only_tasks()
|
|
525
|
+
|
|
526
|
+
elif not self._is_leader and was_leader:
|
|
527
|
+
# Lost leadership - cancel leader-only tasks
|
|
528
|
+
logger.info(f"Worker {self.worker_id} lost leadership")
|
|
529
|
+
await self._cancel_tasks(self._leader_tasks)
|
|
530
|
+
self._leader_tasks = []
|
|
531
|
+
|
|
532
|
+
elif self._is_leader:
|
|
533
|
+
# Still leader - check if any leader tasks have crashed and restart
|
|
534
|
+
await self._monitor_and_restart_leader_tasks()
|
|
535
|
+
|
|
536
|
+
# Wait before next heartbeat
|
|
537
|
+
await asyncio.sleep(self._leader_heartbeat_interval)
|
|
538
|
+
|
|
539
|
+
except asyncio.CancelledError:
|
|
540
|
+
# Shutdown - cancel leader tasks and exit
|
|
541
|
+
await self._cancel_tasks(self._leader_tasks)
|
|
542
|
+
self._leader_tasks = []
|
|
543
|
+
raise
|
|
544
|
+
except Exception as e:
|
|
545
|
+
logger.error(f"Leader election error: {e}", exc_info=True)
|
|
546
|
+
self._is_leader = False
|
|
547
|
+
await self._cancel_tasks(self._leader_tasks)
|
|
548
|
+
self._leader_tasks = []
|
|
549
|
+
# Wait before retry
|
|
550
|
+
await asyncio.sleep(self._leader_heartbeat_interval)
|
|
551
|
+
|
|
552
|
+
def _create_leader_only_tasks(self) -> list[asyncio.Task[Any]]:
|
|
553
|
+
"""
|
|
554
|
+
Create tasks that should only run on the leader worker.
|
|
555
|
+
|
|
556
|
+
These tasks are responsible for:
|
|
557
|
+
- Timer expiration checks
|
|
558
|
+
- Message subscription timeout checks
|
|
559
|
+
- Stale workflow auto-resume
|
|
560
|
+
- Old message cleanup
|
|
561
|
+
"""
|
|
562
|
+
tasks = []
|
|
563
|
+
|
|
564
|
+
# Timer expiration check
|
|
565
|
+
tasks.append(
|
|
566
|
+
asyncio.create_task(
|
|
567
|
+
self._check_expired_timers_periodically(interval=10),
|
|
568
|
+
name="leader_timer_check",
|
|
503
569
|
)
|
|
504
570
|
)
|
|
505
|
-
|
|
571
|
+
|
|
572
|
+
# Message subscription timeout check
|
|
573
|
+
tasks.append(
|
|
574
|
+
asyncio.create_task(
|
|
575
|
+
self._check_expired_message_subscriptions_periodically(interval=10),
|
|
576
|
+
name="leader_message_timeout_check",
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# Stale workflow auto-resume
|
|
581
|
+
tasks.append(
|
|
582
|
+
asyncio.create_task(
|
|
583
|
+
auto_resume_stale_workflows_periodically(
|
|
584
|
+
self.storage,
|
|
585
|
+
self.replay_engine,
|
|
586
|
+
self.worker_id,
|
|
587
|
+
interval=60,
|
|
588
|
+
),
|
|
589
|
+
name="leader_stale_workflow_resume",
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# Old message cleanup
|
|
594
|
+
tasks.append(
|
|
595
|
+
asyncio.create_task(
|
|
596
|
+
self._cleanup_old_messages_periodically(
|
|
597
|
+
interval=3600,
|
|
598
|
+
retention_days=self._message_retention_days,
|
|
599
|
+
),
|
|
600
|
+
name="leader_message_cleanup",
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
return tasks
|
|
605
|
+
|
|
606
|
+
async def _cancel_tasks(self, tasks: list[asyncio.Task[Any]]) -> None:
|
|
607
|
+
"""Cancel a list of tasks and wait for them to finish."""
|
|
608
|
+
for task in tasks:
|
|
609
|
+
task.cancel()
|
|
610
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
611
|
+
|
|
612
|
+
async def _monitor_and_restart_leader_tasks(self) -> None:
|
|
613
|
+
"""
|
|
614
|
+
Monitor leader tasks and restart any that have crashed.
|
|
615
|
+
|
|
616
|
+
This ensures leader-only tasks keep running even if they encounter errors.
|
|
617
|
+
"""
|
|
618
|
+
task_creators = {
|
|
619
|
+
"leader_timer_check": lambda: asyncio.create_task(
|
|
620
|
+
self._check_expired_timers_periodically(interval=10),
|
|
621
|
+
name="leader_timer_check",
|
|
622
|
+
),
|
|
623
|
+
"leader_message_timeout_check": lambda: asyncio.create_task(
|
|
624
|
+
self._check_expired_message_subscriptions_periodically(interval=10),
|
|
625
|
+
name="leader_message_timeout_check",
|
|
626
|
+
),
|
|
627
|
+
"leader_stale_workflow_resume": lambda: asyncio.create_task(
|
|
628
|
+
auto_resume_stale_workflows_periodically(
|
|
629
|
+
self.storage,
|
|
630
|
+
self.replay_engine,
|
|
631
|
+
self.worker_id,
|
|
632
|
+
interval=60,
|
|
633
|
+
),
|
|
634
|
+
name="leader_stale_workflow_resume",
|
|
635
|
+
),
|
|
636
|
+
"leader_message_cleanup": lambda: asyncio.create_task(
|
|
637
|
+
self._cleanup_old_messages_periodically(
|
|
638
|
+
interval=3600,
|
|
639
|
+
retention_days=self._message_retention_days,
|
|
640
|
+
),
|
|
641
|
+
name="leader_message_cleanup",
|
|
642
|
+
),
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
# Check each task and restart if done (crashed)
|
|
646
|
+
new_tasks = []
|
|
647
|
+
for task in self._leader_tasks:
|
|
648
|
+
if task.done():
|
|
649
|
+
# Task has finished (possibly due to error)
|
|
650
|
+
task_name = task.get_name()
|
|
651
|
+
try:
|
|
652
|
+
# Check if it raised an exception
|
|
653
|
+
exc = task.exception()
|
|
654
|
+
if exc is not None:
|
|
655
|
+
logger.warning(
|
|
656
|
+
f"Leader task {task_name} crashed with {type(exc).__name__}: {exc}, "
|
|
657
|
+
"restarting..."
|
|
658
|
+
)
|
|
659
|
+
except asyncio.CancelledError:
|
|
660
|
+
# Task was cancelled, don't restart
|
|
661
|
+
logger.debug(f"Leader task {task_name} was cancelled")
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
# Restart the task
|
|
665
|
+
if task_name in task_creators:
|
|
666
|
+
new_task = task_creators[task_name]()
|
|
667
|
+
new_tasks.append(new_task)
|
|
668
|
+
logger.info(f"Restarted leader task: {task_name}")
|
|
669
|
+
else:
|
|
670
|
+
# Task is still running
|
|
671
|
+
new_tasks.append(task)
|
|
672
|
+
|
|
673
|
+
self._leader_tasks = new_tasks
|
|
506
674
|
|
|
507
675
|
def _auto_register_workflows(self) -> None:
|
|
508
676
|
"""
|