pyworkflow-engine 0.1.36__tar.gz → 0.2.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.
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/PKG-INFO +1 -1
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyproject.toml +1 -1
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/__init__.py +39 -1
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/app.py +9 -2
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/scheduler.py +5 -3
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/tasks.py +195 -19
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/hooks.py +15 -15
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/runs.py +9 -9
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/schedules.py +30 -24
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/config.py +22 -3
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/local.py +11 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/step.py +28 -9
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/events.py +160 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/replay.py +49 -0
- pyworkflow_engine-0.2.0/pyworkflow/primitives/step_checkpoint.py +157 -0
- pyworkflow_engine-0.2.0/pyworkflow/primitives/step_hook.py +205 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/__init__.py +7 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/base.py +237 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/cassandra.py +125 -3
- pyworkflow_engine-0.2.0/pyworkflow/storage/citus.py +387 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/config.py +23 -2
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/dynamodb.py +159 -39
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/file.py +87 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/memory.py +197 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/mysql.py +71 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/postgres.py +76 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/sqlite.py +76 -3
- pyworkflow_engine-0.2.0/pyworkflow/streams/__init__.py +83 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/checkpoint.py +201 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/consumer.py +124 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/context.py +91 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/decorator.py +131 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/dispatcher.py +233 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/emit.py +157 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/registry.py +190 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/signal.py +70 -0
- pyworkflow_engine-0.2.0/pyworkflow/streams/step_context.py +79 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow_engine.egg-info/SOURCES.txt +22 -0
- pyworkflow_engine-0.2.0/tests/integration/test_stream_e2e.py +308 -0
- pyworkflow_engine-0.2.0/tests/unit/backends/test_citus_storage.py +286 -0
- pyworkflow_engine-0.2.0/tests/unit/test_emit.py +123 -0
- pyworkflow_engine-0.2.0/tests/unit/test_retention.py +245 -0
- pyworkflow_engine-0.2.0/tests/unit/test_signal.py +76 -0
- pyworkflow_engine-0.2.0/tests/unit/test_step_checkpoint.py +102 -0
- pyworkflow_engine-0.2.0/tests/unit/test_step_hook.py +202 -0
- pyworkflow_engine-0.2.0/tests/unit/test_stream_storage.py +188 -0
- pyworkflow_engine-0.2.0/tests/unit/test_stream_workflow.py +203 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/LICENSE +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/README.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/RELEASING.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/layers.md +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/singleton.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/base.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/registry.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/executor.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/base.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/celery.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/setup.cfg +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_singleton.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/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.
|
|
10
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -29,7 +29,7 @@ Quick Start:
|
|
|
29
29
|
>>> run_id = await start(my_workflow, "Alice")
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "0.2.0"
|
|
33
33
|
|
|
34
34
|
# Configuration
|
|
35
35
|
from pyworkflow.config import (
|
|
@@ -135,6 +135,12 @@ from pyworkflow.primitives.schedule import (
|
|
|
135
135
|
)
|
|
136
136
|
from pyworkflow.primitives.shield import shield
|
|
137
137
|
from pyworkflow.primitives.sleep import sleep
|
|
138
|
+
from pyworkflow.primitives.step_checkpoint import (
|
|
139
|
+
delete_step_checkpoint,
|
|
140
|
+
load_step_checkpoint,
|
|
141
|
+
save_step_checkpoint,
|
|
142
|
+
)
|
|
143
|
+
from pyworkflow.primitives.step_hook import step_hook
|
|
138
144
|
|
|
139
145
|
# Runtime
|
|
140
146
|
from pyworkflow.runtime import LocalRuntime, Runtime, get_runtime, register_runtime
|
|
@@ -156,6 +162,21 @@ from pyworkflow.storage.schemas import (
|
|
|
156
162
|
WorkflowRun,
|
|
157
163
|
)
|
|
158
164
|
|
|
165
|
+
# Streams (pub/sub signal pattern)
|
|
166
|
+
from pyworkflow.streams import (
|
|
167
|
+
CheckpointBackend,
|
|
168
|
+
Signal,
|
|
169
|
+
Stream,
|
|
170
|
+
StreamConsumer,
|
|
171
|
+
StreamStepContext,
|
|
172
|
+
emit,
|
|
173
|
+
get_checkpoint,
|
|
174
|
+
get_current_signal,
|
|
175
|
+
save_checkpoint,
|
|
176
|
+
stream_step,
|
|
177
|
+
stream_workflow,
|
|
178
|
+
)
|
|
179
|
+
|
|
159
180
|
__all__ = [
|
|
160
181
|
# Version
|
|
161
182
|
"__version__",
|
|
@@ -264,4 +285,21 @@ __all__ = [
|
|
|
264
285
|
"get_logger",
|
|
265
286
|
"bind_workflow_context",
|
|
266
287
|
"bind_step_context",
|
|
288
|
+
# Step checkpoint + hooks
|
|
289
|
+
"save_step_checkpoint",
|
|
290
|
+
"load_step_checkpoint",
|
|
291
|
+
"delete_step_checkpoint",
|
|
292
|
+
"step_hook",
|
|
293
|
+
# Streams
|
|
294
|
+
"stream_workflow",
|
|
295
|
+
"stream_step",
|
|
296
|
+
"emit",
|
|
297
|
+
"Signal",
|
|
298
|
+
"Stream",
|
|
299
|
+
"StreamStepContext",
|
|
300
|
+
"StreamConsumer",
|
|
301
|
+
"CheckpointBackend",
|
|
302
|
+
"get_current_signal",
|
|
303
|
+
"get_checkpoint",
|
|
304
|
+
"save_checkpoint",
|
|
267
305
|
]
|
|
@@ -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",
|
|
@@ -181,9 +181,11 @@ class PyWorkflowScheduler(Scheduler):
|
|
|
181
181
|
execute_scheduled_workflow_task.apply_async(
|
|
182
182
|
kwargs={
|
|
183
183
|
"schedule_id": schedule.schedule_id,
|
|
184
|
-
"scheduled_time":
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
"scheduled_time": (
|
|
185
|
+
schedule.next_run_time.isoformat()
|
|
186
|
+
if schedule.next_run_time
|
|
187
|
+
else now.isoformat()
|
|
188
|
+
),
|
|
187
189
|
"storage_config": self._storage_config,
|
|
188
190
|
},
|
|
189
191
|
queue="pyworkflow.schedules",
|
|
@@ -198,14 +198,36 @@ def execute_step_task(
|
|
|
198
198
|
_exec_lock_key, self.request.id or "unknown", expiry=self._lock_expiry
|
|
199
199
|
)
|
|
200
200
|
if not _exec_lock_acquired:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
208
|
-
|
|
201
|
+
# Check for worker-loss re-delivery: when a worker dies mid-step
|
|
202
|
+
# (e.g., spot instance termination), Celery re-delivers the same
|
|
203
|
+
# message with the same task_id (task_reject_on_worker_lost=True).
|
|
204
|
+
# The dead worker's lock still exists in Redis (up to 1h TTL).
|
|
205
|
+
# If the lock holder matches our own task_id, this IS a re-delivery
|
|
206
|
+
# of the same task — force-acquire and proceed.
|
|
207
|
+
_holding_task_id = _exec_lock_backend.get(_exec_lock_key)
|
|
208
|
+
_our_task_id = self.request.id or "unknown"
|
|
209
|
+
if _holding_task_id == _our_task_id:
|
|
210
|
+
logger.warning(
|
|
211
|
+
"Step lock held by same task_id — worker-loss re-delivery detected, "
|
|
212
|
+
"force-acquiring lock",
|
|
213
|
+
run_id=run_id,
|
|
214
|
+
step_id=step_id,
|
|
215
|
+
step_name=step_name,
|
|
216
|
+
task_id=_our_task_id,
|
|
217
|
+
)
|
|
218
|
+
_exec_lock_backend.unlock(_exec_lock_key)
|
|
219
|
+
_exec_lock_acquired = _exec_lock_backend.lock(
|
|
220
|
+
_exec_lock_key, _our_task_id, expiry=self._lock_expiry
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
logger.warning(
|
|
224
|
+
"Step already being executed by another worker, skipping duplicate",
|
|
225
|
+
run_id=run_id,
|
|
226
|
+
step_id=step_id,
|
|
227
|
+
step_name=step_name,
|
|
228
|
+
existing_worker_task=_holding_task_id,
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
209
231
|
else:
|
|
210
232
|
_exec_lock_acquired = True # No Redis = no locking
|
|
211
233
|
|
|
@@ -279,6 +301,18 @@ def execute_step_task(
|
|
|
279
301
|
step_id=step_id,
|
|
280
302
|
)
|
|
281
303
|
|
|
304
|
+
# Set up step execution context for checkpoint/hook primitives
|
|
305
|
+
step_exec_key = f"{run_id}:{step_id}"
|
|
306
|
+
step_exec_tokens = None
|
|
307
|
+
try:
|
|
308
|
+
from pyworkflow.primitives.step_checkpoint import (
|
|
309
|
+
set_step_execution_context,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
step_exec_tokens = set_step_execution_context(step_exec_key, storage)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.warning(f"Failed to set up step execution context: {e}")
|
|
315
|
+
|
|
282
316
|
# Execute step function
|
|
283
317
|
try:
|
|
284
318
|
# Get the original function (unwrapped from decorator)
|
|
@@ -339,10 +373,29 @@ def execute_step_task(
|
|
|
339
373
|
raise
|
|
340
374
|
|
|
341
375
|
except SuspensionSignal as e:
|
|
342
|
-
#
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
376
|
+
# Check if this is a step_hook suspension (supported)
|
|
377
|
+
if e.reason and e.reason.startswith("step_hook:"):
|
|
378
|
+
hook_id = e.data.get("hook_id")
|
|
379
|
+
logger.info(
|
|
380
|
+
f"Step suspended via step_hook: {step_name}",
|
|
381
|
+
run_id=run_id,
|
|
382
|
+
step_id=step_id,
|
|
383
|
+
hook_id=hook_id,
|
|
384
|
+
)
|
|
385
|
+
# Record STEP_SUSPENDED event so the workflow knows this step
|
|
386
|
+
# needs re-dispatch (clears in-progress state during replay)
|
|
387
|
+
run_async(
|
|
388
|
+
_record_step_suspended(
|
|
389
|
+
storage_config=storage_config,
|
|
390
|
+
run_id=run_id,
|
|
391
|
+
step_id=step_id,
|
|
392
|
+
step_name=step_name,
|
|
393
|
+
hook_id=hook_id or "",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
# Other SuspensionSignals are not supported from steps
|
|
346
399
|
logger.error(
|
|
347
400
|
f"Step raised SuspensionSignal (unsupported from steps): {step_name}",
|
|
348
401
|
run_id=run_id,
|
|
@@ -449,6 +502,12 @@ def execute_step_task(
|
|
|
449
502
|
with contextlib.suppress(Exception):
|
|
450
503
|
_exec_lock_backend.unlock(_exec_lock_key)
|
|
451
504
|
|
|
505
|
+
# Clean up step execution context (checkpoint/hook primitives)
|
|
506
|
+
if step_exec_tokens is not None:
|
|
507
|
+
from pyworkflow.primitives.step_checkpoint import reset_step_execution_context
|
|
508
|
+
|
|
509
|
+
reset_step_execution_context(step_exec_tokens)
|
|
510
|
+
|
|
452
511
|
# Clean up workflow context (must be reset before step context)
|
|
453
512
|
if workflow_context_token is not None:
|
|
454
513
|
from pyworkflow.context.base import reset_context
|
|
@@ -567,6 +626,63 @@ async def _record_step_completion_and_resume(
|
|
|
567
626
|
)
|
|
568
627
|
|
|
569
628
|
|
|
629
|
+
async def _record_step_suspended(
|
|
630
|
+
storage_config: dict[str, Any] | None,
|
|
631
|
+
run_id: str,
|
|
632
|
+
step_id: str,
|
|
633
|
+
step_name: str,
|
|
634
|
+
hook_id: str,
|
|
635
|
+
) -> None:
|
|
636
|
+
"""
|
|
637
|
+
Record STEP_SUSPENDED event when a step suspends via step_hook.
|
|
638
|
+
|
|
639
|
+
This clears the step's in-progress state during replay so that the
|
|
640
|
+
workflow re-dispatches the step on resume (instead of thinking it's
|
|
641
|
+
still running).
|
|
642
|
+
|
|
643
|
+
Does NOT schedule workflow resumption — that happens when resume_hook()
|
|
644
|
+
is called externally.
|
|
645
|
+
"""
|
|
646
|
+
from pyworkflow.engine.events import create_step_suspended_event
|
|
647
|
+
|
|
648
|
+
storage = _get_storage_backend(storage_config)
|
|
649
|
+
if hasattr(storage, "connect"):
|
|
650
|
+
await storage.connect()
|
|
651
|
+
|
|
652
|
+
# Wait for WORKFLOW_SUSPENDED event to avoid sequence number race
|
|
653
|
+
max_wait_attempts = 50
|
|
654
|
+
wait_interval = 0.01
|
|
655
|
+
|
|
656
|
+
for _attempt in range(max_wait_attempts):
|
|
657
|
+
has_suspended = await storage.has_event(
|
|
658
|
+
run_id, EventType.WORKFLOW_SUSPENDED.value, step_id=step_id
|
|
659
|
+
)
|
|
660
|
+
if has_suspended:
|
|
661
|
+
break
|
|
662
|
+
await asyncio.sleep(wait_interval)
|
|
663
|
+
else:
|
|
664
|
+
logger.warning(
|
|
665
|
+
"Timeout waiting for WORKFLOW_SUSPENDED before recording STEP_SUSPENDED",
|
|
666
|
+
run_id=run_id,
|
|
667
|
+
step_id=step_id,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
event = create_step_suspended_event(
|
|
671
|
+
run_id=run_id,
|
|
672
|
+
step_id=step_id,
|
|
673
|
+
step_name=step_name,
|
|
674
|
+
hook_id=hook_id,
|
|
675
|
+
)
|
|
676
|
+
await storage.record_event(event)
|
|
677
|
+
logger.info(
|
|
678
|
+
"Step suspended event recorded",
|
|
679
|
+
run_id=run_id,
|
|
680
|
+
step_id=step_id,
|
|
681
|
+
step_name=step_name,
|
|
682
|
+
hook_id=hook_id,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
570
686
|
async def _record_step_failure_and_resume(
|
|
571
687
|
storage_config: dict[str, Any] | None,
|
|
572
688
|
run_id: str,
|
|
@@ -782,13 +898,31 @@ def start_workflow_task(
|
|
|
782
898
|
_exec_lock_key, self.request.id or "unknown", expiry=self._lock_expiry
|
|
783
899
|
)
|
|
784
900
|
if not _exec_lock_acquired:
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
901
|
+
# Check for worker-loss re-delivery (same logic as step tasks):
|
|
902
|
+
# if the lock holder matches our task_id, this is the same message
|
|
903
|
+
# re-delivered after the original worker died.
|
|
904
|
+
_holding_task_id = _exec_lock_backend.get(_exec_lock_key)
|
|
905
|
+
_our_task_id = self.request.id or "unknown"
|
|
906
|
+
if _holding_task_id == _our_task_id:
|
|
907
|
+
logger.warning(
|
|
908
|
+
"Workflow start lock held by same task_id — worker-loss re-delivery "
|
|
909
|
+
"detected, force-acquiring lock",
|
|
910
|
+
run_id=run_id,
|
|
911
|
+
workflow_name=workflow_name,
|
|
912
|
+
task_id=_our_task_id,
|
|
913
|
+
)
|
|
914
|
+
_exec_lock_backend.unlock(_exec_lock_key)
|
|
915
|
+
_exec_lock_acquired = _exec_lock_backend.lock(
|
|
916
|
+
_exec_lock_key, _our_task_id, expiry=self._lock_expiry
|
|
917
|
+
)
|
|
918
|
+
else:
|
|
919
|
+
logger.warning(
|
|
920
|
+
"Workflow start already being executed by another worker, skipping duplicate",
|
|
921
|
+
run_id=run_id,
|
|
922
|
+
workflow_name=workflow_name,
|
|
923
|
+
existing_worker_task=_holding_task_id,
|
|
924
|
+
)
|
|
925
|
+
return run_id
|
|
792
926
|
else:
|
|
793
927
|
_exec_lock_acquired = True # No Redis = no locking
|
|
794
928
|
|
|
@@ -2749,3 +2883,45 @@ async def _handle_continue_as_new_celery(
|
|
|
2749
2883
|
)
|
|
2750
2884
|
|
|
2751
2885
|
return new_run_id
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
@celery_app.task(
|
|
2889
|
+
name="pyworkflow.run_data_retention",
|
|
2890
|
+
base=SingletonWorkflowTask,
|
|
2891
|
+
bind=True,
|
|
2892
|
+
queue="pyworkflow.default",
|
|
2893
|
+
release_lock_on_failure=True,
|
|
2894
|
+
lock_expiry=7200, # 2h safety net
|
|
2895
|
+
)
|
|
2896
|
+
def run_data_retention_task(self: SingletonWorkflowTask) -> dict[str, Any]:
|
|
2897
|
+
"""
|
|
2898
|
+
Periodic task: delete workflow runs older than data_retention_days.
|
|
2899
|
+
|
|
2900
|
+
Singleton (only one instance runs at a time). Skips if data_retention_days
|
|
2901
|
+
is not configured.
|
|
2902
|
+
"""
|
|
2903
|
+
from datetime import timedelta
|
|
2904
|
+
|
|
2905
|
+
from pyworkflow.config import get_config
|
|
2906
|
+
from pyworkflow.storage.config import storage_to_config
|
|
2907
|
+
|
|
2908
|
+
config = get_config()
|
|
2909
|
+
if config.data_retention_days is None:
|
|
2910
|
+
logger.debug("Data retention not configured; skipping.")
|
|
2911
|
+
return {"deleted": 0, "skipped": True}
|
|
2912
|
+
|
|
2913
|
+
storage = config.storage
|
|
2914
|
+
if storage is None:
|
|
2915
|
+
logger.warning("No storage configured; skipping data retention.")
|
|
2916
|
+
return {"deleted": 0, "skipped": True}
|
|
2917
|
+
|
|
2918
|
+
cutoff = datetime.now(UTC) - timedelta(days=config.data_retention_days)
|
|
2919
|
+
logger.info(
|
|
2920
|
+
"Running data retention: deleting runs updated before {}",
|
|
2921
|
+
cutoff.isoformat(),
|
|
2922
|
+
)
|
|
2923
|
+
storage_config = storage_to_config(storage)
|
|
2924
|
+
backend = _get_storage_backend(storage_config)
|
|
2925
|
+
count = run_async(backend.delete_old_runs(cutoff))
|
|
2926
|
+
logger.info("Data retention complete: deleted {} runs", count)
|
|
2927
|
+
return {"deleted": count, "skipped": False}
|
|
@@ -299,12 +299,12 @@ async def list_hooks_cmd(
|
|
|
299
299
|
"Name": hook.name or "-",
|
|
300
300
|
"Status": hook.status.value,
|
|
301
301
|
"Run ID": hook.run_id,
|
|
302
|
-
"Created":
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
"Expires":
|
|
306
|
-
|
|
307
|
-
|
|
302
|
+
"Created": (
|
|
303
|
+
hook.created_at.strftime("%Y-%m-%d %H:%M") if hook.created_at else "-"
|
|
304
|
+
),
|
|
305
|
+
"Expires": (
|
|
306
|
+
hook.expires_at.strftime("%Y-%m-%d %H:%M") if hook.expires_at else "-"
|
|
307
|
+
),
|
|
308
308
|
}
|
|
309
309
|
for hook in hooks_list
|
|
310
310
|
]
|
|
@@ -376,15 +376,15 @@ async def hook_info_cmd(ctx: click.Context, token: str) -> None:
|
|
|
376
376
|
"Run ID": hook.run_id,
|
|
377
377
|
"Name": hook.name or "-",
|
|
378
378
|
"Status": hook.status.value,
|
|
379
|
-
"Created":
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
"Expires":
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"Received":
|
|
386
|
-
|
|
387
|
-
|
|
379
|
+
"Created": (
|
|
380
|
+
hook.created_at.strftime("%Y-%m-%d %H:%M:%S") if hook.created_at else "-"
|
|
381
|
+
),
|
|
382
|
+
"Expires": (
|
|
383
|
+
hook.expires_at.strftime("%Y-%m-%d %H:%M:%S") if hook.expires_at else "-"
|
|
384
|
+
),
|
|
385
|
+
"Received": (
|
|
386
|
+
hook.received_at.strftime("%Y-%m-%d %H:%M:%S") if hook.received_at else "-"
|
|
387
|
+
),
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
# Show payload if received
|
|
@@ -151,9 +151,9 @@ async def list_runs(
|
|
|
151
151
|
"Run ID": run.run_id,
|
|
152
152
|
"Workflow": run.workflow_name,
|
|
153
153
|
"Status": run.status.value,
|
|
154
|
-
"Started":
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
"Started": (
|
|
155
|
+
run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "-"
|
|
156
|
+
),
|
|
157
157
|
"Duration": durations.get(run.run_id, "-"),
|
|
158
158
|
}
|
|
159
159
|
for run in runs_list
|
|
@@ -238,9 +238,9 @@ async def run_status(ctx: click.Context, run_id: str) -> None:
|
|
|
238
238
|
"Status": run.status.value,
|
|
239
239
|
"Created": run.created_at.strftime("%Y-%m-%d %H:%M:%S") if run.created_at else "-",
|
|
240
240
|
"Started": run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "-",
|
|
241
|
-
"Completed":
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
"Completed": (
|
|
242
|
+
run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "-"
|
|
243
|
+
),
|
|
244
244
|
"Duration": duration_str,
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -622,9 +622,9 @@ async def list_children(
|
|
|
622
622
|
"Workflow": child.workflow_name,
|
|
623
623
|
"Status": child.status.value,
|
|
624
624
|
"Depth": child.nesting_depth,
|
|
625
|
-
"Started":
|
|
626
|
-
|
|
627
|
-
|
|
625
|
+
"Started": (
|
|
626
|
+
child.started_at.strftime("%Y-%m-%d %H:%M:%S") if child.started_at else "-"
|
|
627
|
+
),
|
|
628
628
|
"Duration": _calc_duration(child),
|
|
629
629
|
}
|
|
630
630
|
for child in children
|
|
@@ -123,9 +123,9 @@ async def list_schedules_cmd(
|
|
|
123
123
|
"Workflow": s.workflow_name,
|
|
124
124
|
"Status": s.status.value,
|
|
125
125
|
"Schedule": describe_schedule(s.spec),
|
|
126
|
-
"Next Run":
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
"Next Run": (
|
|
127
|
+
s.next_run_time.strftime("%Y-%m-%d %H:%M:%S") if s.next_run_time else "-"
|
|
128
|
+
),
|
|
129
129
|
"Runs": f"{s.successful_runs}/{s.total_runs}",
|
|
130
130
|
}
|
|
131
131
|
for s in schedules_list
|
|
@@ -241,9 +241,9 @@ async def create_schedule_cmd(
|
|
|
241
241
|
"status": schedule.status.value,
|
|
242
242
|
"spec": describe_schedule(schedule.spec),
|
|
243
243
|
"overlap_policy": schedule.overlap_policy.value,
|
|
244
|
-
"next_run_time":
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
"next_run_time": (
|
|
245
|
+
schedule.next_run_time.isoformat() if schedule.next_run_time else None
|
|
246
|
+
),
|
|
247
247
|
}
|
|
248
248
|
format_json(data)
|
|
249
249
|
else:
|
|
@@ -308,9 +308,9 @@ async def show_schedule_cmd(
|
|
|
308
308
|
"timezone": schedule.spec.timezone,
|
|
309
309
|
},
|
|
310
310
|
"overlap_policy": schedule.overlap_policy.value,
|
|
311
|
-
"next_run_time":
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
"next_run_time": (
|
|
312
|
+
schedule.next_run_time.isoformat() if schedule.next_run_time else None
|
|
313
|
+
),
|
|
314
314
|
"last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
|
|
315
315
|
"total_runs": schedule.total_runs,
|
|
316
316
|
"successful_runs": schedule.successful_runs,
|
|
@@ -327,19 +327,25 @@ async def show_schedule_cmd(
|
|
|
327
327
|
"Status": schedule.status.value,
|
|
328
328
|
"Schedule": describe_schedule(schedule.spec),
|
|
329
329
|
"Overlap Policy": schedule.overlap_policy.value,
|
|
330
|
-
"Next Run":
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
330
|
+
"Next Run": (
|
|
331
|
+
schedule.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
332
|
+
if schedule.next_run_time
|
|
333
|
+
else "-"
|
|
334
|
+
),
|
|
335
|
+
"Last Run": (
|
|
336
|
+
schedule.last_run_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
337
|
+
if schedule.last_run_at
|
|
338
|
+
else "-"
|
|
339
|
+
),
|
|
336
340
|
"Total Runs": schedule.total_runs,
|
|
337
341
|
"Successful": schedule.successful_runs,
|
|
338
342
|
"Failed": schedule.failed_runs,
|
|
339
343
|
"Skipped": schedule.skipped_runs,
|
|
340
|
-
"Created":
|
|
341
|
-
|
|
342
|
-
|
|
344
|
+
"Created": (
|
|
345
|
+
schedule.created_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
346
|
+
if schedule.created_at
|
|
347
|
+
else "-"
|
|
348
|
+
),
|
|
343
349
|
}
|
|
344
350
|
format_key_value(data, title=f"Schedule: {schedule_id}")
|
|
345
351
|
|
|
@@ -440,9 +446,9 @@ async def resume_schedule_cmd(
|
|
|
440
446
|
data = {
|
|
441
447
|
"schedule_id": schedule.schedule_id,
|
|
442
448
|
"status": schedule.status.value,
|
|
443
|
-
"next_run_time":
|
|
444
|
-
|
|
445
|
-
|
|
449
|
+
"next_run_time": (
|
|
450
|
+
schedule.next_run_time.isoformat() if schedule.next_run_time else None
|
|
451
|
+
),
|
|
446
452
|
}
|
|
447
453
|
format_json(data)
|
|
448
454
|
|
|
@@ -774,9 +780,9 @@ async def update_schedule_cmd(
|
|
|
774
780
|
"workflow_name": schedule.workflow_name,
|
|
775
781
|
"spec": describe_schedule(schedule.spec),
|
|
776
782
|
"overlap_policy": schedule.overlap_policy.value,
|
|
777
|
-
"next_run_time":
|
|
778
|
-
|
|
779
|
-
|
|
783
|
+
"next_run_time": (
|
|
784
|
+
schedule.next_run_time.isoformat() if schedule.next_run_time else None
|
|
785
|
+
),
|
|
780
786
|
}
|
|
781
787
|
format_json(data)
|
|
782
788
|
else:
|
|
@@ -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
|
|
|
@@ -94,6 +94,10 @@ class LocalContext(WorkflowContext):
|
|
|
94
94
|
self._cancellation_blocked: bool = False
|
|
95
95
|
self._cancellation_reason: str | None = None
|
|
96
96
|
|
|
97
|
+
# Signal/stream state (used by EventReplayer for stream workflows)
|
|
98
|
+
self._signal_waits: dict[str, dict[str, Any]] = {}
|
|
99
|
+
self._received_signals: dict[str, dict[str, Any]] = {}
|
|
100
|
+
|
|
97
101
|
# Child workflow state
|
|
98
102
|
self._child_results: dict[str, dict[str, Any]] = {}
|
|
99
103
|
self._pending_children: dict[str, str] = {} # child_id -> child_run_id
|
|
@@ -161,6 +165,13 @@ class LocalContext(WorkflowContext):
|
|
|
161
165
|
# Step completed - no longer in progress
|
|
162
166
|
self._steps_in_progress.discard(step_id)
|
|
163
167
|
|
|
168
|
+
elif event.type == EventType.STEP_SUSPENDED:
|
|
169
|
+
step_id = event.data.get("step_id")
|
|
170
|
+
if step_id:
|
|
171
|
+
# Step suspended via step_hook — no longer in progress,
|
|
172
|
+
# needs re-dispatch on workflow resume
|
|
173
|
+
self._steps_in_progress.discard(step_id)
|
|
174
|
+
|
|
164
175
|
elif event.type == EventType.SLEEP_COMPLETED:
|
|
165
176
|
sleep_id = event.data.get("sleep_id")
|
|
166
177
|
self._completed_sleeps.add(sleep_id)
|