pyworkflow-engine 0.1.37__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.37 → pyworkflow_engine-0.2.0}/PKG-INFO +1 -1
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyproject.toml +1 -1
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/__init__.py +39 -1
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/scheduler.py +5 -3
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/tasks.py +153 -19
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/hooks.py +15 -15
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/runs.py +9 -9
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/schedules.py +30 -24
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/local.py +11 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/step.py +28 -9
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/events.py +160 -0
- {pyworkflow_engine-0.1.37 → 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.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/base.py +221 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/cassandra.py +59 -3
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/citus.py +17 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/dynamodb.py +89 -39
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/file.py +50 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/memory.py +166 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/mysql.py +46 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/postgres.py +54 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/sqlite.py +54 -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.37 → pyworkflow_engine-0.2.0}/pyworkflow_engine.egg-info/SOURCES.txt +19 -0
- pyworkflow_engine-0.2.0/tests/integration/test_stream_e2e.py +308 -0
- pyworkflow_engine-0.2.0/tests/unit/test_emit.py +123 -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.37 → pyworkflow_engine-0.2.0}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/LICENSE +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/README.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/RELEASING.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/layers.md +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/app.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/celery/singleton.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/config.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/base.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/registry.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/core/workflow.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/engine/executor.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/base.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/celery.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/local.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/config.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/setup.cfg +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_citus_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_retention.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_singleton.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.1.37 → pyworkflow_engine-0.2.0}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.1.37 → 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
|
]
|
|
@@ -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
|
|
|
@@ -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:
|
|
@@ -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)
|
|
@@ -21,7 +21,7 @@ from typing import Any
|
|
|
21
21
|
from loguru import logger
|
|
22
22
|
|
|
23
23
|
from pyworkflow.context import get_context, has_context
|
|
24
|
-
from pyworkflow.core.exceptions import FatalError, RetryableError
|
|
24
|
+
from pyworkflow.core.exceptions import FatalError, RetryableError, SuspensionSignal
|
|
25
25
|
from pyworkflow.core.registry import register_step
|
|
26
26
|
from pyworkflow.core.validation import validate_step_parameters
|
|
27
27
|
from pyworkflow.engine.events import (
|
|
@@ -29,6 +29,10 @@ from pyworkflow.engine.events import (
|
|
|
29
29
|
create_step_failed_event,
|
|
30
30
|
create_step_started_event,
|
|
31
31
|
)
|
|
32
|
+
from pyworkflow.primitives.step_checkpoint import (
|
|
33
|
+
reset_step_execution_context,
|
|
34
|
+
set_step_execution_context,
|
|
35
|
+
)
|
|
32
36
|
from pyworkflow.serialization.encoder import serialize, serialize_args, serialize_kwargs
|
|
33
37
|
|
|
34
38
|
|
|
@@ -177,8 +181,6 @@ def step(
|
|
|
177
181
|
step_id=step_id,
|
|
178
182
|
)
|
|
179
183
|
# Re-suspend and wait for existing task to complete
|
|
180
|
-
from pyworkflow.core.exceptions import SuspensionSignal
|
|
181
|
-
|
|
182
184
|
raise SuspensionSignal(
|
|
183
185
|
reason=f"step_dispatch:{step_id}",
|
|
184
186
|
step_id=step_id,
|
|
@@ -229,8 +231,6 @@ def step(
|
|
|
229
231
|
current_attempt=current_attempt,
|
|
230
232
|
resume_at=resume_at.isoformat(),
|
|
231
233
|
)
|
|
232
|
-
from pyworkflow.core.exceptions import SuspensionSignal
|
|
233
|
-
|
|
234
234
|
raise SuspensionSignal(
|
|
235
235
|
reason=f"retry:{step_id}",
|
|
236
236
|
resume_at=resume_at,
|
|
@@ -268,6 +268,10 @@ def step(
|
|
|
268
268
|
# Validate parameters before execution
|
|
269
269
|
validate_step_parameters(func, args, kwargs, step_name)
|
|
270
270
|
|
|
271
|
+
# Set up step execution context for checkpoint/hook primitives
|
|
272
|
+
step_exec_key = f"{ctx.run_id}:{step_id}"
|
|
273
|
+
step_exec_tokens = set_step_execution_context(step_exec_key, ctx.storage)
|
|
274
|
+
|
|
271
275
|
try:
|
|
272
276
|
# Execute step function
|
|
273
277
|
result = await func(*args, **kwargs)
|
|
@@ -295,6 +299,15 @@ def step(
|
|
|
295
299
|
|
|
296
300
|
return result
|
|
297
301
|
|
|
302
|
+
except SuspensionSignal:
|
|
303
|
+
# step_hook() raised SuspensionSignal — propagate to suspend workflow
|
|
304
|
+
logger.info(
|
|
305
|
+
f"Step suspended via step_hook: {step_name}",
|
|
306
|
+
run_id=ctx.run_id,
|
|
307
|
+
step_id=step_id,
|
|
308
|
+
)
|
|
309
|
+
raise
|
|
310
|
+
|
|
298
311
|
except FatalError as e:
|
|
299
312
|
# Fatal error - don't retry
|
|
300
313
|
logger.error(
|
|
@@ -393,8 +406,6 @@ def step(
|
|
|
393
406
|
|
|
394
407
|
# Raise suspension signal to pause workflow
|
|
395
408
|
# Note: The workflow-level exception handler will schedule automatic resumption
|
|
396
|
-
from pyworkflow.core.exceptions import SuspensionSignal
|
|
397
|
-
|
|
398
409
|
raise SuspensionSignal(
|
|
399
410
|
reason=f"retry:{step_id}",
|
|
400
411
|
resume_at=resume_at,
|
|
@@ -434,6 +445,9 @@ def step(
|
|
|
434
445
|
else:
|
|
435
446
|
raise
|
|
436
447
|
|
|
448
|
+
finally:
|
|
449
|
+
reset_step_execution_context(step_exec_tokens)
|
|
450
|
+
|
|
437
451
|
# Register step
|
|
438
452
|
register_step(
|
|
439
453
|
name=step_name,
|
|
@@ -645,7 +659,6 @@ async def _dispatch_step_to_celery(
|
|
|
645
659
|
SuspensionSignal: To pause workflow while step executes on worker
|
|
646
660
|
"""
|
|
647
661
|
from pyworkflow.celery.tasks import execute_step_task
|
|
648
|
-
from pyworkflow.core.exceptions import SuspensionSignal
|
|
649
662
|
from pyworkflow.engine.events import EventType
|
|
650
663
|
|
|
651
664
|
logger.info(
|
|
@@ -657,11 +670,17 @@ async def _dispatch_step_to_celery(
|
|
|
657
670
|
# Defense-in-depth: check if STEP_STARTED was already recorded for this step.
|
|
658
671
|
# This guards against duplicate dispatch when two resume tasks race and both
|
|
659
672
|
# replay past the same step. If already started, re-suspend to wait.
|
|
673
|
+
# Exception: if STEP_SUSPENDED was recorded (step_hook suspension), the step
|
|
674
|
+
# needs re-dispatch so it can re-execute and find the HOOK_RECEIVED event.
|
|
660
675
|
events = await ctx.storage.get_events(ctx.run_id)
|
|
661
676
|
already_started = any(
|
|
662
677
|
evt.type == EventType.STEP_STARTED and evt.data.get("step_id") == step_id for evt in events
|
|
663
678
|
)
|
|
664
|
-
|
|
679
|
+
was_suspended = any(
|
|
680
|
+
evt.type == EventType.STEP_SUSPENDED and evt.data.get("step_id") == step_id
|
|
681
|
+
for evt in events
|
|
682
|
+
)
|
|
683
|
+
if already_started and not was_suspended:
|
|
665
684
|
logger.info(
|
|
666
685
|
f"Step {step_name} already has STEP_STARTED event, re-suspending",
|
|
667
686
|
run_id=ctx.run_id,
|