pyworkflow-engine 0.1.36__tar.gz → 0.1.37__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.
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/PKG-INFO +1 -1
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyproject.toml +1 -1
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/__init__.py +1 -1
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/app.py +9 -2
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/tasks.py +42 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/config.py +22 -3
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/__init__.py +7 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/base.py +16 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/cassandra.py +66 -0
- pyworkflow_engine-0.1.37/pyworkflow/storage/citus.py +370 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/config.py +23 -2
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/dynamodb.py +70 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/file.py +37 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/memory.py +31 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/mysql.py +25 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/postgres.py +22 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/sqlite.py +22 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow_engine.egg-info/SOURCES.txt +3 -0
- pyworkflow_engine-0.1.37/tests/unit/backends/test_citus_storage.py +286 -0
- pyworkflow_engine-0.1.37/tests/unit/test_retention.py +245 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/LICENSE +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/README.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/RELEASING.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/layers.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/scheduler.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/singleton.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/runs.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/schedules.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/base.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/registry.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/events.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/executor.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/replay.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/base.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/celery.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/setup.cfg +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_singleton.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_workflow_suspended.py +0 -0
|
@@ -7,7 +7,7 @@ packages = [{include = "pyworkflow"}]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "pyworkflow-engine"
|
|
10
|
-
version = "0.1.
|
|
10
|
+
version = "0.1.37"
|
|
11
11
|
description = "A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -13,6 +13,7 @@ garbage collector and Celery's saferepr module. It does not affect functionality
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import os
|
|
16
|
+
from datetime import timedelta
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
18
19
|
from celery import Celery
|
|
@@ -343,8 +344,14 @@ def create_celery_app(
|
|
|
343
344
|
# Monitoring
|
|
344
345
|
"worker_send_task_events": True,
|
|
345
346
|
"task_send_sent_event": True,
|
|
346
|
-
# Beat scheduler (for sleep resumption)
|
|
347
|
-
"beat_schedule": {
|
|
347
|
+
# Beat scheduler (for sleep resumption and periodic tasks)
|
|
348
|
+
"beat_schedule": {
|
|
349
|
+
"pyworkflow-data-retention": {
|
|
350
|
+
"task": "pyworkflow.run_data_retention",
|
|
351
|
+
"schedule": timedelta(hours=24),
|
|
352
|
+
"options": {"queue": "pyworkflow.default"},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
348
355
|
# Logging
|
|
349
356
|
"worker_log_format": "[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
|
|
350
357
|
"worker_task_log_format": "[%(asctime)s: %(levelname)s/%(processName)s] [%(task_name)s(%(task_id)s)] %(message)s",
|
|
@@ -2749,3 +2749,45 @@ async def _handle_continue_as_new_celery(
|
|
|
2749
2749
|
)
|
|
2750
2750
|
|
|
2751
2751
|
return new_run_id
|
|
2752
|
+
|
|
2753
|
+
|
|
2754
|
+
@celery_app.task(
|
|
2755
|
+
name="pyworkflow.run_data_retention",
|
|
2756
|
+
base=SingletonWorkflowTask,
|
|
2757
|
+
bind=True,
|
|
2758
|
+
queue="pyworkflow.default",
|
|
2759
|
+
release_lock_on_failure=True,
|
|
2760
|
+
lock_expiry=7200, # 2h safety net
|
|
2761
|
+
)
|
|
2762
|
+
def run_data_retention_task(self: SingletonWorkflowTask) -> dict[str, Any]:
|
|
2763
|
+
"""
|
|
2764
|
+
Periodic task: delete workflow runs older than data_retention_days.
|
|
2765
|
+
|
|
2766
|
+
Singleton (only one instance runs at a time). Skips if data_retention_days
|
|
2767
|
+
is not configured.
|
|
2768
|
+
"""
|
|
2769
|
+
from datetime import timedelta
|
|
2770
|
+
|
|
2771
|
+
from pyworkflow.config import get_config
|
|
2772
|
+
from pyworkflow.storage.config import storage_to_config
|
|
2773
|
+
|
|
2774
|
+
config = get_config()
|
|
2775
|
+
if config.data_retention_days is None:
|
|
2776
|
+
logger.debug("Data retention not configured; skipping.")
|
|
2777
|
+
return {"deleted": 0, "skipped": True}
|
|
2778
|
+
|
|
2779
|
+
storage = config.storage
|
|
2780
|
+
if storage is None:
|
|
2781
|
+
logger.warning("No storage configured; skipping data retention.")
|
|
2782
|
+
return {"deleted": 0, "skipped": True}
|
|
2783
|
+
|
|
2784
|
+
cutoff = datetime.now(UTC) - timedelta(days=config.data_retention_days)
|
|
2785
|
+
logger.info(
|
|
2786
|
+
"Running data retention: deleting runs updated before {}",
|
|
2787
|
+
cutoff.isoformat(),
|
|
2788
|
+
)
|
|
2789
|
+
storage_config = storage_to_config(storage)
|
|
2790
|
+
backend = _get_storage_backend(storage_config)
|
|
2791
|
+
count = run_async(backend.delete_old_runs(cutoff))
|
|
2792
|
+
logger.info("Data retention complete: deleted {} runs", count)
|
|
2793
|
+
return {"deleted": count, "skipped": False}
|
|
@@ -18,7 +18,7 @@ Usage:
|
|
|
18
18
|
... )
|
|
19
19
|
|
|
20
20
|
Environment Variables:
|
|
21
|
-
PYWORKFLOW_STORAGE_TYPE: Storage backend type (file, memory, sqlite, postgres, mysql)
|
|
21
|
+
PYWORKFLOW_STORAGE_TYPE: Storage backend type (file, memory, sqlite, postgres, mysql, citus)
|
|
22
22
|
PYWORKFLOW_STORAGE_PATH: Path for file/sqlite backends
|
|
23
23
|
PYWORKFLOW_POSTGRES_HOST: PostgreSQL host
|
|
24
24
|
PYWORKFLOW_POSTGRES_PORT: PostgreSQL port
|
|
@@ -33,6 +33,7 @@ Environment Variables:
|
|
|
33
33
|
PYWORKFLOW_CELERY_BROKER: Celery broker URL
|
|
34
34
|
PYWORKFLOW_CELERY_RESULT_BACKEND: Celery result backend URL
|
|
35
35
|
PYWORKFLOW_RUNTIME: Default runtime (local, celery)
|
|
36
|
+
PYWORKFLOW_DATA_RETENTION_DAYS: Days to retain completed/failed/cancelled runs (unset = keep forever)
|
|
36
37
|
"""
|
|
37
38
|
|
|
38
39
|
import os
|
|
@@ -58,9 +59,9 @@ def _load_env_storage_config() -> dict[str, Any] | None:
|
|
|
58
59
|
|
|
59
60
|
storage_type = storage_type.lower()
|
|
60
61
|
|
|
61
|
-
if storage_type
|
|
62
|
+
if storage_type in ("postgres", "citus"):
|
|
62
63
|
return {
|
|
63
|
-
"type":
|
|
64
|
+
"type": storage_type,
|
|
64
65
|
"host": os.getenv("PYWORKFLOW_POSTGRES_HOST", "localhost"),
|
|
65
66
|
"port": int(os.getenv("PYWORKFLOW_POSTGRES_PORT", "5432")),
|
|
66
67
|
"user": os.getenv("PYWORKFLOW_POSTGRES_USER", "pyworkflow"),
|
|
@@ -161,6 +162,9 @@ class PyWorkflowConfig:
|
|
|
161
162
|
celery_broker: str | None = None
|
|
162
163
|
aws_region: str | None = None
|
|
163
164
|
|
|
165
|
+
# Data retention policy
|
|
166
|
+
data_retention_days: int | None = None # None = keep forever
|
|
167
|
+
|
|
164
168
|
# Event limit settings (WARNING: Do not modify unless you understand the implications)
|
|
165
169
|
# These limits prevent runaway workflows from consuming excessive resources
|
|
166
170
|
event_soft_limit: int = 10_000 # Log warning at this count
|
|
@@ -196,11 +200,21 @@ def _config_from_env_and_yaml() -> PyWorkflowConfig:
|
|
|
196
200
|
celery_config = yaml_config.get("celery", {})
|
|
197
201
|
celery_broker = os.getenv("PYWORKFLOW_CELERY_BROKER") or celery_config.get("broker")
|
|
198
202
|
|
|
203
|
+
# Data retention: env var > yaml > None (keep forever)
|
|
204
|
+
retention_env = os.getenv("PYWORKFLOW_DATA_RETENTION_DAYS")
|
|
205
|
+
if retention_env is not None:
|
|
206
|
+
data_retention_days: int | None = int(retention_env)
|
|
207
|
+
elif yaml_config.get("retention_days") is not None:
|
|
208
|
+
data_retention_days = int(yaml_config["retention_days"])
|
|
209
|
+
else:
|
|
210
|
+
data_retention_days = None
|
|
211
|
+
|
|
199
212
|
return PyWorkflowConfig(
|
|
200
213
|
default_runtime=runtime,
|
|
201
214
|
default_durable=durable,
|
|
202
215
|
storage=storage,
|
|
203
216
|
celery_broker=celery_broker,
|
|
217
|
+
data_retention_days=data_retention_days,
|
|
204
218
|
)
|
|
205
219
|
|
|
206
220
|
|
|
@@ -348,11 +362,16 @@ def configure_from_yaml(path: str | Path, discover: bool = True) -> None:
|
|
|
348
362
|
celery_config = yaml_config.get("celery", {})
|
|
349
363
|
celery_broker = celery_config.get("broker")
|
|
350
364
|
|
|
365
|
+
# Data retention
|
|
366
|
+
retention_raw = yaml_config.get("retention_days")
|
|
367
|
+
data_retention_days: int | None = int(retention_raw) if retention_raw is not None else None
|
|
368
|
+
|
|
351
369
|
_config = PyWorkflowConfig(
|
|
352
370
|
default_runtime=runtime,
|
|
353
371
|
default_durable=durable,
|
|
354
372
|
storage=storage,
|
|
355
373
|
celery_broker=celery_broker,
|
|
374
|
+
data_retention_days=data_retention_days,
|
|
356
375
|
)
|
|
357
376
|
_config_loaded_from_yaml = True
|
|
358
377
|
|
|
@@ -47,6 +47,12 @@ try:
|
|
|
47
47
|
except ImportError:
|
|
48
48
|
MySQLStorageBackend = None # type: ignore
|
|
49
49
|
|
|
50
|
+
# Citus distributed PostgreSQL backend - optional import (requires asyncpg + Citus extension)
|
|
51
|
+
try:
|
|
52
|
+
from pyworkflow.storage.citus import CitusStorageBackend
|
|
53
|
+
except ImportError:
|
|
54
|
+
CitusStorageBackend = None # type: ignore
|
|
55
|
+
|
|
50
56
|
__all__ = [
|
|
51
57
|
"StorageBackend",
|
|
52
58
|
"FileStorageBackend",
|
|
@@ -56,6 +62,7 @@ __all__ = [
|
|
|
56
62
|
"DynamoDBStorageBackend",
|
|
57
63
|
"CassandraStorageBackend",
|
|
58
64
|
"MySQLStorageBackend",
|
|
65
|
+
"CitusStorageBackend",
|
|
59
66
|
"WorkflowRun",
|
|
60
67
|
"StepExecution",
|
|
61
68
|
"Hook",
|
|
@@ -700,6 +700,22 @@ class StorageBackend(ABC):
|
|
|
700
700
|
"""
|
|
701
701
|
pass
|
|
702
702
|
|
|
703
|
+
@abstractmethod
|
|
704
|
+
async def delete_old_runs(self, older_than: datetime) -> int:
|
|
705
|
+
"""
|
|
706
|
+
Delete workflow runs in terminal states last updated before `older_than`.
|
|
707
|
+
|
|
708
|
+
Terminal states: COMPLETED, FAILED, CANCELLED, CONTINUED_AS_NEW, INTERRUPTED.
|
|
709
|
+
Associated events, steps, hooks, and cancellation flags are deleted too.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
older_than: Delete runs where updated_at < older_than
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
Number of workflow runs deleted
|
|
716
|
+
"""
|
|
717
|
+
pass
|
|
718
|
+
|
|
703
719
|
async def health_check(self) -> bool:
|
|
704
720
|
"""
|
|
705
721
|
Check if storage backend is healthy and accessible.
|
|
@@ -1709,6 +1709,72 @@ class CassandraStorageBackend(StorageBackend):
|
|
|
1709
1709
|
schedule.updated_at = datetime.now(UTC)
|
|
1710
1710
|
await self.update_schedule(schedule)
|
|
1711
1711
|
|
|
1712
|
+
async def delete_old_runs(self, older_than: datetime) -> int:
|
|
1713
|
+
"""Delete terminal runs (and all related data) updated before older_than."""
|
|
1714
|
+
session = self._ensure_connected()
|
|
1715
|
+
terminal = ["completed", "failed", "cancelled", "continued_as_new", "interrupted"]
|
|
1716
|
+
count = 0
|
|
1717
|
+
|
|
1718
|
+
for status_val in terminal:
|
|
1719
|
+
rows = session.execute(
|
|
1720
|
+
SimpleStatement(
|
|
1721
|
+
"SELECT run_id FROM runs_by_status WHERE status = %s",
|
|
1722
|
+
consistency_level=self.read_consistency,
|
|
1723
|
+
),
|
|
1724
|
+
(status_val,),
|
|
1725
|
+
)
|
|
1726
|
+
for row in rows:
|
|
1727
|
+
run_id = row.run_id
|
|
1728
|
+
# Fetch run to check updated_at
|
|
1729
|
+
run_row = session.execute(
|
|
1730
|
+
SimpleStatement(
|
|
1731
|
+
"SELECT updated_at FROM workflow_runs WHERE run_id = %s",
|
|
1732
|
+
consistency_level=self.read_consistency,
|
|
1733
|
+
),
|
|
1734
|
+
(run_id,),
|
|
1735
|
+
).one()
|
|
1736
|
+
if run_row is None or run_row.updated_at >= older_than:
|
|
1737
|
+
continue
|
|
1738
|
+
# Delete all related data
|
|
1739
|
+
session.execute(
|
|
1740
|
+
SimpleStatement(
|
|
1741
|
+
"DELETE FROM events WHERE run_id = %s",
|
|
1742
|
+
consistency_level=self.write_consistency,
|
|
1743
|
+
),
|
|
1744
|
+
(run_id,),
|
|
1745
|
+
)
|
|
1746
|
+
session.execute(
|
|
1747
|
+
SimpleStatement(
|
|
1748
|
+
"DELETE FROM steps WHERE run_id = %s",
|
|
1749
|
+
consistency_level=self.write_consistency,
|
|
1750
|
+
),
|
|
1751
|
+
(run_id,),
|
|
1752
|
+
)
|
|
1753
|
+
session.execute(
|
|
1754
|
+
SimpleStatement(
|
|
1755
|
+
"DELETE FROM hooks WHERE run_id = %s",
|
|
1756
|
+
consistency_level=self.write_consistency,
|
|
1757
|
+
),
|
|
1758
|
+
(run_id,),
|
|
1759
|
+
)
|
|
1760
|
+
session.execute(
|
|
1761
|
+
SimpleStatement(
|
|
1762
|
+
"DELETE FROM cancellation_flags WHERE run_id = %s",
|
|
1763
|
+
consistency_level=self.write_consistency,
|
|
1764
|
+
),
|
|
1765
|
+
(run_id,),
|
|
1766
|
+
)
|
|
1767
|
+
session.execute(
|
|
1768
|
+
SimpleStatement(
|
|
1769
|
+
"DELETE FROM workflow_runs WHERE run_id = %s",
|
|
1770
|
+
consistency_level=self.write_consistency,
|
|
1771
|
+
),
|
|
1772
|
+
(run_id,),
|
|
1773
|
+
)
|
|
1774
|
+
count += 1
|
|
1775
|
+
|
|
1776
|
+
return count
|
|
1777
|
+
|
|
1712
1778
|
# Helper methods for converting Cassandra rows to domain objects
|
|
1713
1779
|
|
|
1714
1780
|
def _row_to_workflow_run(self, row: Any) -> WorkflowRun:
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Citus distributed PostgreSQL storage backend.
|
|
3
|
+
|
|
4
|
+
Citus is a PostgreSQL extension that shards tables across multiple nodes for
|
|
5
|
+
horizontal scalability. This backend extends PostgresStorageBackend, reusing all
|
|
6
|
+
query logic while overriding schema initialization to create Citus-compatible
|
|
7
|
+
distributed tables.
|
|
8
|
+
|
|
9
|
+
Distribution strategy:
|
|
10
|
+
- All workflow tables are co-located on `run_id` so that all data for a given
|
|
11
|
+
workflow run lands on the same Citus shard.
|
|
12
|
+
- Schedules are distributed on `schedule_id` (independent of workflow runs).
|
|
13
|
+
- `schema_versions` becomes a reference table (replicated to all workers).
|
|
14
|
+
|
|
15
|
+
Citus constraints vs plain PostgreSQL:
|
|
16
|
+
- Primary keys and unique constraints MUST include the distribution column.
|
|
17
|
+
- Cross-shard foreign key constraints are unsupported.
|
|
18
|
+
- Global unique constraints on non-distribution columns cannot be enforced.
|
|
19
|
+
|
|
20
|
+
See: https://docs.citusdata.com/en/stable/develop/api.html
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncpg
|
|
24
|
+
|
|
25
|
+
from pyworkflow.storage.migrations import Migration
|
|
26
|
+
from pyworkflow.storage.postgres import PostgresMigrationRunner, PostgresStorageBackend
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CitusMigrationRunner(PostgresMigrationRunner):
|
|
30
|
+
"""
|
|
31
|
+
Citus-specific migration runner.
|
|
32
|
+
|
|
33
|
+
Extends PostgresMigrationRunner to handle Citus schema constraints.
|
|
34
|
+
V2 migration: same step_id backfill as PostgreSQL, but skips creating
|
|
35
|
+
unique constraints that would conflict with Citus distribution requirements.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
async def apply_migration(self, migration: Migration) -> None:
|
|
39
|
+
"""Apply a migration with Citus-specific handling."""
|
|
40
|
+
from datetime import UTC, datetime
|
|
41
|
+
|
|
42
|
+
async with self._pool.acquire() as conn, conn.transaction():
|
|
43
|
+
if migration.version == 2:
|
|
44
|
+
# V2: Add step_id column to events table
|
|
45
|
+
# Check if events table exists (fresh databases won't have it yet)
|
|
46
|
+
table_exists = await conn.fetchrow("""
|
|
47
|
+
SELECT EXISTS (
|
|
48
|
+
SELECT FROM information_schema.tables
|
|
49
|
+
WHERE table_name = 'events'
|
|
50
|
+
) as exists
|
|
51
|
+
""")
|
|
52
|
+
|
|
53
|
+
if table_exists and table_exists["exists"]:
|
|
54
|
+
# Use IF NOT EXISTS for idempotency
|
|
55
|
+
await conn.execute("""
|
|
56
|
+
DO $$
|
|
57
|
+
BEGIN
|
|
58
|
+
IF NOT EXISTS (
|
|
59
|
+
SELECT 1 FROM information_schema.columns
|
|
60
|
+
WHERE table_name = 'events' AND column_name = 'step_id'
|
|
61
|
+
) THEN
|
|
62
|
+
ALTER TABLE events ADD COLUMN step_id TEXT;
|
|
63
|
+
END IF;
|
|
64
|
+
END $$
|
|
65
|
+
""")
|
|
66
|
+
|
|
67
|
+
# Create index for optimized has_event() queries
|
|
68
|
+
await conn.execute("""
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_events_run_id_step_id_type
|
|
70
|
+
ON events(run_id, step_id, type)
|
|
71
|
+
""")
|
|
72
|
+
|
|
73
|
+
# Backfill step_id from JSON data
|
|
74
|
+
await conn.execute("""
|
|
75
|
+
UPDATE events
|
|
76
|
+
SET step_id = (data::jsonb)->>'step_id'
|
|
77
|
+
WHERE step_id IS NULL
|
|
78
|
+
AND (data::jsonb)->>'step_id' IS NOT NULL
|
|
79
|
+
""")
|
|
80
|
+
# NOTE: No UNIQUE constraints added here — Citus requires distribution
|
|
81
|
+
# column in every unique constraint, which V2 doesn't add.
|
|
82
|
+
elif migration.up_func:
|
|
83
|
+
await migration.up_func(conn)
|
|
84
|
+
elif migration.up_sql and migration.up_sql != "SELECT 1":
|
|
85
|
+
await conn.execute(migration.up_sql)
|
|
86
|
+
|
|
87
|
+
# Record the migration
|
|
88
|
+
await conn.execute(
|
|
89
|
+
"""
|
|
90
|
+
INSERT INTO schema_versions (version, applied_at, description)
|
|
91
|
+
VALUES ($1, $2, $3)
|
|
92
|
+
""",
|
|
93
|
+
migration.version,
|
|
94
|
+
datetime.now(UTC),
|
|
95
|
+
migration.description,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CitusStorageBackend(PostgresStorageBackend):
|
|
100
|
+
"""
|
|
101
|
+
Citus distributed PostgreSQL storage backend.
|
|
102
|
+
|
|
103
|
+
Extends PostgresStorageBackend with Citus-specific schema initialization.
|
|
104
|
+
All query logic is inherited unchanged — only DDL differs to satisfy Citus's
|
|
105
|
+
distribution column requirements.
|
|
106
|
+
|
|
107
|
+
Tables are sharded as follows:
|
|
108
|
+
- workflow_runs, events, steps, hooks, cancellation_flags: distributed on run_id
|
|
109
|
+
- schedules: distributed on schedule_id
|
|
110
|
+
- schema_versions: reference table (replicated to all workers)
|
|
111
|
+
|
|
112
|
+
Requirements:
|
|
113
|
+
- PostgreSQL with the Citus extension installed and loaded
|
|
114
|
+
- The calling database user must have permission to call Citus functions
|
|
115
|
+
|
|
116
|
+
Usage:
|
|
117
|
+
backend = CitusStorageBackend(
|
|
118
|
+
host="citus-coordinator",
|
|
119
|
+
database="pyworkflow",
|
|
120
|
+
)
|
|
121
|
+
await backend.connect()
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
async def _initialize_schema(self) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Create Citus-compatible schema and distribute tables.
|
|
127
|
+
|
|
128
|
+
Steps:
|
|
129
|
+
1. Verify Citus extension is available.
|
|
130
|
+
2. Create tables with Citus-compatible PKs/constraints (no cross-shard FKs,
|
|
131
|
+
no cross-shard UNIQUE constraints, composite PKs include distribution col).
|
|
132
|
+
3. Create indexes.
|
|
133
|
+
4. Distribute tables (idempotent — already-distributed tables are skipped).
|
|
134
|
+
5. Run migrations via CitusMigrationRunner.
|
|
135
|
+
"""
|
|
136
|
+
pool = await self._get_pool()
|
|
137
|
+
|
|
138
|
+
# Step 1: Verify Citus extension is present
|
|
139
|
+
async with pool.acquire() as conn:
|
|
140
|
+
try:
|
|
141
|
+
await conn.fetchval("SELECT citus_version()")
|
|
142
|
+
except asyncpg.UndefinedFunctionError:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"Citus extension is not available on this PostgreSQL server. "
|
|
145
|
+
"Install Citus and run `CREATE EXTENSION citus;` before using "
|
|
146
|
+
"CitusStorageBackend. See: https://docs.citusdata.com/en/stable/installation/"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Step 2 & 3: Create tables and indexes with Citus-compatible DDL
|
|
150
|
+
async with pool.acquire() as conn:
|
|
151
|
+
# schema_versions — created first so CitusMigrationRunner can use it
|
|
152
|
+
await conn.execute("""
|
|
153
|
+
CREATE TABLE IF NOT EXISTS schema_versions (
|
|
154
|
+
version INTEGER PRIMARY KEY,
|
|
155
|
+
applied_at TIMESTAMPTZ NOT NULL,
|
|
156
|
+
description TEXT
|
|
157
|
+
)
|
|
158
|
+
""")
|
|
159
|
+
|
|
160
|
+
# workflow_runs — distributed on run_id
|
|
161
|
+
# Differences vs postgres:
|
|
162
|
+
# - DROP FK on parent_run_id (self-referential FK can't be enforced cross-shard)
|
|
163
|
+
# - UNIQUE INDEX on idempotency_key → plain INDEX (global uniqueness unenforceable)
|
|
164
|
+
await conn.execute("""
|
|
165
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
166
|
+
run_id TEXT PRIMARY KEY,
|
|
167
|
+
workflow_name TEXT NOT NULL,
|
|
168
|
+
status TEXT NOT NULL,
|
|
169
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
170
|
+
updated_at TIMESTAMPTZ NOT NULL,
|
|
171
|
+
started_at TIMESTAMPTZ,
|
|
172
|
+
completed_at TIMESTAMPTZ,
|
|
173
|
+
input_args TEXT NOT NULL DEFAULT '[]',
|
|
174
|
+
input_kwargs TEXT NOT NULL DEFAULT '{}',
|
|
175
|
+
result TEXT,
|
|
176
|
+
error TEXT,
|
|
177
|
+
idempotency_key TEXT,
|
|
178
|
+
max_duration TEXT,
|
|
179
|
+
metadata TEXT DEFAULT '{}',
|
|
180
|
+
recovery_attempts INTEGER DEFAULT 0,
|
|
181
|
+
max_recovery_attempts INTEGER DEFAULT 3,
|
|
182
|
+
recover_on_worker_loss BOOLEAN DEFAULT TRUE,
|
|
183
|
+
parent_run_id TEXT,
|
|
184
|
+
nesting_depth INTEGER DEFAULT 0,
|
|
185
|
+
continued_from_run_id TEXT,
|
|
186
|
+
continued_to_run_id TEXT
|
|
187
|
+
)
|
|
188
|
+
""")
|
|
189
|
+
|
|
190
|
+
await conn.execute(
|
|
191
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_status ON workflow_runs(status)"
|
|
192
|
+
)
|
|
193
|
+
await conn.execute(
|
|
194
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_workflow_name ON workflow_runs(workflow_name)"
|
|
195
|
+
)
|
|
196
|
+
await conn.execute(
|
|
197
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_created_at ON workflow_runs(created_at DESC)"
|
|
198
|
+
)
|
|
199
|
+
# Non-unique index: Citus cannot enforce global uniqueness on non-distribution columns
|
|
200
|
+
await conn.execute(
|
|
201
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_idempotency_key ON workflow_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
|
|
202
|
+
)
|
|
203
|
+
await conn.execute(
|
|
204
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_parent_run_id ON workflow_runs(parent_run_id)"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# events — distributed on run_id, co-located with workflow_runs
|
|
208
|
+
# Differences vs postgres:
|
|
209
|
+
# - PK: event_id → (run_id, event_id) to include distribution column
|
|
210
|
+
# - FK on run_id retained (co-located FK is supported by Citus)
|
|
211
|
+
await conn.execute("""
|
|
212
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
213
|
+
event_id TEXT NOT NULL,
|
|
214
|
+
run_id TEXT NOT NULL,
|
|
215
|
+
sequence INTEGER NOT NULL,
|
|
216
|
+
type TEXT NOT NULL,
|
|
217
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
218
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
219
|
+
step_id TEXT,
|
|
220
|
+
PRIMARY KEY (run_id, event_id)
|
|
221
|
+
)
|
|
222
|
+
""")
|
|
223
|
+
|
|
224
|
+
await conn.execute(
|
|
225
|
+
"CREATE INDEX IF NOT EXISTS idx_events_run_id_sequence ON events(run_id, sequence)"
|
|
226
|
+
)
|
|
227
|
+
await conn.execute(
|
|
228
|
+
"CREATE INDEX IF NOT EXISTS idx_events_run_id_type ON events(run_id, type)"
|
|
229
|
+
)
|
|
230
|
+
await conn.execute(
|
|
231
|
+
"CREATE INDEX IF NOT EXISTS idx_events_run_id_step_id_type ON events(run_id, step_id, type)"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# steps — distributed on run_id, co-located with workflow_runs
|
|
235
|
+
# Differences vs postgres:
|
|
236
|
+
# - PK: step_id → (run_id, step_id) to include distribution column
|
|
237
|
+
# - FK on run_id retained (co-located FK)
|
|
238
|
+
# - Extra index on step_id alone for get_step(step_id) scatter-gather queries
|
|
239
|
+
await conn.execute("""
|
|
240
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
241
|
+
step_id TEXT NOT NULL,
|
|
242
|
+
run_id TEXT NOT NULL,
|
|
243
|
+
step_name TEXT NOT NULL,
|
|
244
|
+
status TEXT NOT NULL,
|
|
245
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
246
|
+
started_at TIMESTAMPTZ,
|
|
247
|
+
completed_at TIMESTAMPTZ,
|
|
248
|
+
input_args TEXT NOT NULL DEFAULT '[]',
|
|
249
|
+
input_kwargs TEXT NOT NULL DEFAULT '{}',
|
|
250
|
+
result TEXT,
|
|
251
|
+
error TEXT,
|
|
252
|
+
retry_count INTEGER DEFAULT 0,
|
|
253
|
+
PRIMARY KEY (run_id, step_id)
|
|
254
|
+
)
|
|
255
|
+
""")
|
|
256
|
+
|
|
257
|
+
await conn.execute("CREATE INDEX IF NOT EXISTS idx_steps_run_id ON steps(run_id)")
|
|
258
|
+
# Shard-local index to speed up get_step(step_id) scatter-gather queries
|
|
259
|
+
await conn.execute("CREATE INDEX IF NOT EXISTS idx_steps_step_id ON steps(step_id)")
|
|
260
|
+
|
|
261
|
+
# hooks — distributed on run_id, co-located with workflow_runs
|
|
262
|
+
# Differences vs postgres:
|
|
263
|
+
# - UNIQUE on token → plain INDEX (token = run_id:hook_id, collisions impossible)
|
|
264
|
+
# - FK on run_id retained (co-located FK)
|
|
265
|
+
await conn.execute("""
|
|
266
|
+
CREATE TABLE IF NOT EXISTS hooks (
|
|
267
|
+
run_id TEXT NOT NULL,
|
|
268
|
+
hook_id TEXT NOT NULL,
|
|
269
|
+
token TEXT NOT NULL,
|
|
270
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
271
|
+
received_at TIMESTAMPTZ,
|
|
272
|
+
expires_at TIMESTAMPTZ,
|
|
273
|
+
status TEXT NOT NULL,
|
|
274
|
+
payload TEXT,
|
|
275
|
+
metadata TEXT DEFAULT '{}',
|
|
276
|
+
PRIMARY KEY (run_id, hook_id)
|
|
277
|
+
)
|
|
278
|
+
""")
|
|
279
|
+
|
|
280
|
+
await conn.execute("CREATE INDEX IF NOT EXISTS idx_hooks_token ON hooks(token)")
|
|
281
|
+
await conn.execute("CREATE INDEX IF NOT EXISTS idx_hooks_run_id ON hooks(run_id)")
|
|
282
|
+
await conn.execute("CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status)")
|
|
283
|
+
|
|
284
|
+
# schedules — distributed on schedule_id (independent of runs)
|
|
285
|
+
await conn.execute("""
|
|
286
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
287
|
+
schedule_id TEXT PRIMARY KEY,
|
|
288
|
+
workflow_name TEXT NOT NULL,
|
|
289
|
+
spec TEXT NOT NULL,
|
|
290
|
+
spec_type TEXT NOT NULL,
|
|
291
|
+
timezone TEXT,
|
|
292
|
+
input_args TEXT NOT NULL DEFAULT '[]',
|
|
293
|
+
input_kwargs TEXT NOT NULL DEFAULT '{}',
|
|
294
|
+
status TEXT NOT NULL,
|
|
295
|
+
overlap_policy TEXT NOT NULL,
|
|
296
|
+
next_run_time TIMESTAMPTZ,
|
|
297
|
+
last_run_time TIMESTAMPTZ,
|
|
298
|
+
running_run_ids TEXT DEFAULT '[]',
|
|
299
|
+
metadata TEXT DEFAULT '{}',
|
|
300
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
301
|
+
updated_at TIMESTAMPTZ NOT NULL,
|
|
302
|
+
paused_at TIMESTAMPTZ,
|
|
303
|
+
deleted_at TIMESTAMPTZ
|
|
304
|
+
)
|
|
305
|
+
""")
|
|
306
|
+
|
|
307
|
+
await conn.execute(
|
|
308
|
+
"CREATE INDEX IF NOT EXISTS idx_schedules_status ON schedules(status)"
|
|
309
|
+
)
|
|
310
|
+
await conn.execute(
|
|
311
|
+
"CREATE INDEX IF NOT EXISTS idx_schedules_next_run_time ON schedules(next_run_time)"
|
|
312
|
+
)
|
|
313
|
+
await conn.execute(
|
|
314
|
+
"CREATE INDEX IF NOT EXISTS idx_schedules_workflow_name ON schedules(workflow_name)"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# cancellation_flags — distributed on run_id, co-located with workflow_runs
|
|
318
|
+
# Differences vs postgres:
|
|
319
|
+
# - FK on run_id dropped (cross-shard FK unsupported; co-location ensures correctness)
|
|
320
|
+
await conn.execute("""
|
|
321
|
+
CREATE TABLE IF NOT EXISTS cancellation_flags (
|
|
322
|
+
run_id TEXT PRIMARY KEY,
|
|
323
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
324
|
+
)
|
|
325
|
+
""")
|
|
326
|
+
|
|
327
|
+
# Step 4: Distribute tables (idempotent — already-distributed tables are skipped)
|
|
328
|
+
await self._distribute_tables(pool)
|
|
329
|
+
|
|
330
|
+
# Step 5: Run migrations via Citus-aware runner
|
|
331
|
+
runner = CitusMigrationRunner(pool)
|
|
332
|
+
await runner.run_migrations()
|
|
333
|
+
|
|
334
|
+
async def _distribute_tables(self, pool: asyncpg.Pool) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Call Citus distribution functions for each table.
|
|
337
|
+
|
|
338
|
+
Uses pg_dist_partition to detect already-distributed tables so this
|
|
339
|
+
method is idempotent and safe to call on every connect().
|
|
340
|
+
"""
|
|
341
|
+
async with pool.acquire() as conn:
|
|
342
|
+
# Fetch already-distributed table names using relname to avoid
|
|
343
|
+
# schema-prefix ambiguity (logicalrelid::text is search_path-dependent)
|
|
344
|
+
rows = await conn.fetch("""
|
|
345
|
+
SELECT c.relname AS tbl
|
|
346
|
+
FROM pg_dist_partition dp
|
|
347
|
+
JOIN pg_class c ON dp.logicalrelid = c.oid
|
|
348
|
+
""")
|
|
349
|
+
distributed = {row["tbl"] for row in rows}
|
|
350
|
+
|
|
351
|
+
# workflow_runs: anchor table, distribute first
|
|
352
|
+
if "workflow_runs" not in distributed:
|
|
353
|
+
await conn.execute("SELECT create_distributed_table('workflow_runs', 'run_id')")
|
|
354
|
+
|
|
355
|
+
# Co-located tables: must reference the same distribution column
|
|
356
|
+
colocated_run_id = ["events", "steps", "hooks", "cancellation_flags"]
|
|
357
|
+
for table in colocated_run_id:
|
|
358
|
+
if table not in distributed:
|
|
359
|
+
await conn.execute(
|
|
360
|
+
f"SELECT create_distributed_table('{table}', 'run_id', "
|
|
361
|
+
f"colocate_with => 'workflow_runs')"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# schedules: independent distribution
|
|
365
|
+
if "schedules" not in distributed:
|
|
366
|
+
await conn.execute("SELECT create_distributed_table('schedules', 'schedule_id')")
|
|
367
|
+
|
|
368
|
+
# schema_versions: reference table (replicated to all workers)
|
|
369
|
+
if "schema_versions" not in distributed:
|
|
370
|
+
await conn.execute("SELECT create_reference_table('schema_versions')")
|