pyworkflow-engine 0.3.4__tar.gz → 0.3.7__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.3.4 → pyworkflow_engine-0.3.7}/PKG-INFO +1 -1
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyproject.toml +1 -1
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/__init__.py +8 -2
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/celery/app.py +42 -1
- pyworkflow_engine-0.3.7/pyworkflow/celery/reschedule.py +339 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/celery/singleton.py +5 -1
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/celery/tasks.py +22 -3
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/step_hook.py +72 -9
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow_engine.egg-info/SOURCES.txt +5 -0
- pyworkflow_engine-0.3.7/tests/integration/_reschedule_e2e_app.py +41 -0
- pyworkflow_engine-0.3.7/tests/integration/test_reschedule.py +133 -0
- pyworkflow_engine-0.3.7/tests/integration/test_reschedule_e2e.py +200 -0
- pyworkflow_engine-0.3.7/tests/unit/test_reschedule.py +323 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_singleton.py +96 -1
- pyworkflow_engine-0.3.7/tests/unit/test_step_hook.py +400 -0
- pyworkflow_engine-0.3.4/tests/unit/test_step_hook.py +0 -202
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/LICENSE +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/README.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/RELEASING.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/stream-steps.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/stream-workflows.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/layers.md +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/celery/scheduler.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/hooks.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/runs.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/schedules.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/config.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/context/base.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/context/local.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/registry.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/step.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/core/workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/engine/events.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/engine/executor.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/engine/replay.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/primitives/step_checkpoint.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/runtime/base.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/runtime/celery.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/runtime/local.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/base.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/cassandra.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/citus.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/config.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/dynamodb.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/file.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/memory.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/mysql.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/postgres.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/storage/sqlite.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/checkpoint.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/consumer.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/decorator.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/dispatcher.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/emit.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/registry.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/runtime.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/signal.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/streams/step_context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/tracing/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/tracing/base.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/tracing/factory.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/tracing/langfuse.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/tracing/types.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/setup.cfg +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_list_runs_pagination.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_stream_e2e.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/backends/test_citus_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_emit.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_retention.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_signal.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_step_checkpoint.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_stream_storage.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_stream_workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.7}/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.3.
|
|
10
|
+
version = "0.3.7"
|
|
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.3.
|
|
32
|
+
__version__ = "0.3.7"
|
|
33
33
|
|
|
34
34
|
# Configuration
|
|
35
35
|
from pyworkflow.config import (
|
|
@@ -140,7 +140,11 @@ from pyworkflow.primitives.step_checkpoint import (
|
|
|
140
140
|
load_step_checkpoint,
|
|
141
141
|
save_step_checkpoint,
|
|
142
142
|
)
|
|
143
|
-
from pyworkflow.primitives.step_hook import
|
|
143
|
+
from pyworkflow.primitives.step_hook import (
|
|
144
|
+
STEP_HOOK_TIMEOUT,
|
|
145
|
+
StepHookTimeout,
|
|
146
|
+
step_hook,
|
|
147
|
+
)
|
|
144
148
|
|
|
145
149
|
# Runtime
|
|
146
150
|
from pyworkflow.runtime import LocalRuntime, Runtime, get_runtime, register_runtime
|
|
@@ -290,6 +294,8 @@ __all__ = [
|
|
|
290
294
|
"load_step_checkpoint",
|
|
291
295
|
"delete_step_checkpoint",
|
|
292
296
|
"step_hook",
|
|
297
|
+
"StepHookTimeout",
|
|
298
|
+
"STEP_HOOK_TIMEOUT",
|
|
293
299
|
# Streams
|
|
294
300
|
"stream_workflow",
|
|
295
301
|
"stream_step",
|
|
@@ -12,12 +12,19 @@ warnings in Celery logs. This is a known compatibility issue between Python 3.13
|
|
|
12
12
|
garbage collector and Celery's saferepr module. It does not affect functionality.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import contextlib
|
|
15
16
|
import os
|
|
16
17
|
from datetime import timedelta
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
19
20
|
from celery import Celery
|
|
20
|
-
from celery.signals import
|
|
21
|
+
from celery.signals import (
|
|
22
|
+
task_postrun,
|
|
23
|
+
task_prerun,
|
|
24
|
+
worker_init,
|
|
25
|
+
worker_process_init,
|
|
26
|
+
worker_shutdown,
|
|
27
|
+
)
|
|
21
28
|
from kombu import Exchange, Queue
|
|
22
29
|
|
|
23
30
|
from pyworkflow.observability.logging import configure_logging
|
|
@@ -281,6 +288,9 @@ def create_celery_app(
|
|
|
281
288
|
PYWORKFLOW_CELERY_SENTINEL_MASTER: Sentinel master name (used if sentinel_master_name param not provided)
|
|
282
289
|
PYWORKFLOW_WORKER_MAX_MEMORY: Max memory per worker child (KB) (used if worker_max_memory_per_child param not provided)
|
|
283
290
|
PYWORKFLOW_WORKER_MAX_TASKS: Max tasks per worker child (used if worker_max_tasks_per_child param not provided)
|
|
291
|
+
PYWORKFLOW_RESCHEDULE_ON_SIGTERM: Re-enqueue in-flight tasks immediately when a worker is shut down
|
|
292
|
+
(SIGTERM / spot reclaim), instead of waiting out the broker visibility_timeout. Default on; set to
|
|
293
|
+
"0"/"false"/"no" to disable. Implemented as a consumer bootstep; see pyworkflow/celery/reschedule.py.
|
|
284
294
|
|
|
285
295
|
Examples:
|
|
286
296
|
# Default configuration (uses env vars if set, otherwise localhost Redis)
|
|
@@ -471,6 +481,14 @@ def create_celery_app(
|
|
|
471
481
|
# to ensure proper initialization AFTER process forking.
|
|
472
482
|
# See on_worker_init() and on_worker_process_init() below.
|
|
473
483
|
|
|
484
|
+
# Re-enqueue in-flight tasks on worker shutdown (SIGTERM / spot reclaim) so a
|
|
485
|
+
# live worker resumes them within seconds instead of waiting out the broker
|
|
486
|
+
# visibility_timeout. Hooked as a consumer bootstep so it fires exactly as the
|
|
487
|
+
# consumer is torn down (event-based). See pyworkflow/celery/reschedule.py.
|
|
488
|
+
from pyworkflow.celery.reschedule import RescheduleConsumerStep
|
|
489
|
+
|
|
490
|
+
app.steps["consumer"].add(RescheduleConsumerStep)
|
|
491
|
+
|
|
474
492
|
# Auto-discover workflows from environment variable or configured modules
|
|
475
493
|
discover_workflows()
|
|
476
494
|
|
|
@@ -555,6 +573,29 @@ def on_worker_shutdown(**kwargs):
|
|
|
555
573
|
close_worker_loop()
|
|
556
574
|
|
|
557
575
|
|
|
576
|
+
@task_prerun.connect
|
|
577
|
+
def on_task_prerun(task_id=None, task=None, args=None, kwargs=None, **_):
|
|
578
|
+
"""
|
|
579
|
+
Track the task now executing in this child process so it can be re-enqueued
|
|
580
|
+
if the worker is shut down (SIGTERM) before the task finishes.
|
|
581
|
+
|
|
582
|
+
Wrapped defensively: tracking must never interfere with task execution.
|
|
583
|
+
"""
|
|
584
|
+
from pyworkflow.celery.reschedule import track_task_start
|
|
585
|
+
|
|
586
|
+
with contextlib.suppress(Exception):
|
|
587
|
+
track_task_start(task, task_id, args, kwargs)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@task_postrun.connect
|
|
591
|
+
def on_task_postrun(task_id=None, **_):
|
|
592
|
+
"""Clear the in-flight task once it has finished (clean completion)."""
|
|
593
|
+
from pyworkflow.celery.reschedule import track_task_end
|
|
594
|
+
|
|
595
|
+
with contextlib.suppress(Exception):
|
|
596
|
+
track_task_end(task_id)
|
|
597
|
+
|
|
598
|
+
|
|
558
599
|
def get_celery_app() -> Celery:
|
|
559
600
|
"""
|
|
560
601
|
Get the global Celery application instance.
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reschedule in-flight tasks when a worker is shut down (SIGTERM).
|
|
3
|
+
|
|
4
|
+
When a Celery worker pod is reclaimed (e.g. an AWS spot node drain), it receives
|
|
5
|
+
SIGTERM and warm-shuts-down. Any step task that was executing is *not* promptly
|
|
6
|
+
redelivered: with ``task_acks_late=True`` the broker only re-queues it after the
|
|
7
|
+
Redis ``visibility_timeout`` (an hour by default), and the parent workflow stays
|
|
8
|
+
SUSPENDED that whole time -- so the user-facing flow silently hangs with no output.
|
|
9
|
+
|
|
10
|
+
This module makes the worker re-enqueue its in-flight task(s) *immediately* on
|
|
11
|
+
shutdown so a live worker resumes them within seconds instead of ~1h.
|
|
12
|
+
|
|
13
|
+
Why a shared Redis registry (not process-local state)
|
|
14
|
+
-----------------------------------------------------
|
|
15
|
+
Tasks execute in forked **child** processes, but the shutdown hook runs in the
|
|
16
|
+
**main** process. The main process can't see a child's in-memory task args, so
|
|
17
|
+
children publish what they are running to a per-pod Redis hash
|
|
18
|
+
(``pyworkflow:inflight:<pod>``) on ``task_prerun`` and remove it on
|
|
19
|
+
``task_postrun``. On shutdown the main process reads that hash and re-enqueues
|
|
20
|
+
every entry.
|
|
21
|
+
|
|
22
|
+
Why a consumer bootstep (event-based, no sleep)
|
|
23
|
+
-----------------------------------------------
|
|
24
|
+
The re-enqueue must happen *after* this worker's broker consumer is cancelled --
|
|
25
|
+
otherwise the worker's own still-active consumer grabs the message straight back
|
|
26
|
+
into ``unacked`` (stranded until the visibility_timeout). There is no Celery
|
|
27
|
+
*signal* for "consumer cancelled" (``worker_shutting_down`` fires before it;
|
|
28
|
+
``worker_shutdown`` fires after the pool has drained the long task -- far too
|
|
29
|
+
late). So we hook the consumer blueprint directly with a bootstep that
|
|
30
|
+
``requires`` the ``Tasks`` step: its ``stop()`` runs as the consumer is torn
|
|
31
|
+
down (early in shutdown, before the pool drain). There we cancel the task
|
|
32
|
+
consumer ourselves -- guaranteeing this worker stops fetching -- and only then
|
|
33
|
+
re-enqueue, so the message lands in the *ready* queue and another worker's
|
|
34
|
+
blocking BRPOP picks it up immediately. Event-based, deterministic, no sleep.
|
|
35
|
+
|
|
36
|
+
Safety against double execution
|
|
37
|
+
-------------------------------
|
|
38
|
+
``execute_step_task`` short-circuits on an existing ``STEP_COMPLETED`` event (and
|
|
39
|
+
on a terminal run status), and ``_record_step_completion_and_resume`` re-checks
|
|
40
|
+
before recording, so at most one completion is ever recorded no matter how many
|
|
41
|
+
copies run. Only ``SingletonWorkflowTask`` tasks that declare ``unique_on`` are
|
|
42
|
+
rescheduled -- those are idempotent by run_id/step_id. Tasks without a uniqueness
|
|
43
|
+
key are left alone.
|
|
44
|
+
|
|
45
|
+
Gate: ``PYWORKFLOW_RESCHEDULE_ON_SIGTERM`` (default on; set to ``0``/``false``/``no``
|
|
46
|
+
to disable and fall back to the old visibility_timeout redelivery behaviour).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import socket
|
|
52
|
+
import threading
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from celery import bootsteps
|
|
56
|
+
from loguru import logger
|
|
57
|
+
|
|
58
|
+
# Registry hash self-expires so a hard-killed pod (no postrun) cannot leak entries.
|
|
59
|
+
_REGISTRY_TTL_SECONDS = 6 * 3600
|
|
60
|
+
_REGISTRY_KEY_PREFIX = "pyworkflow:inflight:"
|
|
61
|
+
|
|
62
|
+
_backend_lock = threading.Lock()
|
|
63
|
+
_backend: Any = None
|
|
64
|
+
_backend_resolved = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _enabled() -> bool:
|
|
68
|
+
return os.getenv("PYWORKFLOW_RESCHEDULE_ON_SIGTERM", "1").strip().lower() not in (
|
|
69
|
+
"0",
|
|
70
|
+
"false",
|
|
71
|
+
"no",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _pod_id() -> str:
|
|
76
|
+
"""Stable per-pod id shared by the main process and its forked children."""
|
|
77
|
+
return os.environ.get("HOSTNAME") or socket.gethostname()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _registry_key() -> str:
|
|
81
|
+
return f"{_REGISTRY_KEY_PREFIX}{_pod_id()}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_backend() -> Any:
|
|
85
|
+
"""
|
|
86
|
+
Cached sentinel-aware Redis backend, reusing the singleton lock configuration
|
|
87
|
+
(same broker/sentinel the task locks already use). Returns None when no Redis
|
|
88
|
+
backend is configured, in which case rescheduling is a no-op.
|
|
89
|
+
"""
|
|
90
|
+
global _backend, _backend_resolved
|
|
91
|
+
if _backend_resolved:
|
|
92
|
+
return _backend
|
|
93
|
+
with _backend_lock:
|
|
94
|
+
if _backend_resolved:
|
|
95
|
+
return _backend
|
|
96
|
+
try:
|
|
97
|
+
from pyworkflow.celery.app import celery_app
|
|
98
|
+
from pyworkflow.celery.singleton import RedisLockBackend, SingletonConfig
|
|
99
|
+
|
|
100
|
+
cfg = SingletonConfig(celery_app)
|
|
101
|
+
url = cfg.backend_url
|
|
102
|
+
if url:
|
|
103
|
+
_backend = RedisLockBackend(
|
|
104
|
+
url,
|
|
105
|
+
is_sentinel=cfg.is_sentinel,
|
|
106
|
+
sentinel_master=cfg.sentinel_master,
|
|
107
|
+
)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.warning(f"reschedule: could not initialise redis backend: {exc}")
|
|
110
|
+
_backend = None
|
|
111
|
+
_backend_resolved = True
|
|
112
|
+
return _backend
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_task(name: str) -> Any:
|
|
116
|
+
"""Look up a registered task instance by name (used to re-enqueue)."""
|
|
117
|
+
from pyworkflow.celery.app import celery_app
|
|
118
|
+
|
|
119
|
+
return celery_app.tasks.get(name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_queue(task: Any) -> str | None:
|
|
123
|
+
"""Best-effort queue the task was delivered on, so the re-enqueue lands there."""
|
|
124
|
+
try:
|
|
125
|
+
delivery_info = getattr(task.request, "delivery_info", None) or {}
|
|
126
|
+
routing_key = delivery_info.get("routing_key")
|
|
127
|
+
if routing_key:
|
|
128
|
+
return routing_key
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
return getattr(task, "queue", None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def track_task_start(
|
|
135
|
+
task: Any,
|
|
136
|
+
task_id: str | None,
|
|
137
|
+
args: Any,
|
|
138
|
+
kwargs: Any,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Publish a task as in-flight (per pod) so it can be re-enqueued on SIGTERM."""
|
|
141
|
+
if not _enabled() or task is None or task_id is None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Import here to avoid import cycles at module load.
|
|
145
|
+
from pyworkflow.celery.singleton import SingletonWorkflowTask
|
|
146
|
+
|
|
147
|
+
# Only re-enqueue tasks keyed by a uniqueness field (run_id/step_id). These are
|
|
148
|
+
# idempotent; blindly re-enqueuing a non-unique task could start duplicate runs.
|
|
149
|
+
if not isinstance(task, SingletonWorkflowTask) or not task.unique_on:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
backend = _get_backend()
|
|
153
|
+
if backend is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
descriptor = json.dumps(
|
|
157
|
+
{
|
|
158
|
+
"name": task.name,
|
|
159
|
+
"args": list(args or ()),
|
|
160
|
+
"kwargs": dict(kwargs or {}),
|
|
161
|
+
"queue": _resolve_queue(task),
|
|
162
|
+
},
|
|
163
|
+
default=str,
|
|
164
|
+
)
|
|
165
|
+
key = _registry_key()
|
|
166
|
+
try:
|
|
167
|
+
backend._execute_with_refresh(lambda: backend.redis.hset(key, task_id, descriptor))
|
|
168
|
+
backend._execute_with_refresh(lambda: backend.redis.expire(key, _REGISTRY_TTL_SECONDS))
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
logger.warning(f"reschedule: failed to record in-flight task {task_id}: {exc}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def track_task_end(task_id: str | None) -> None:
|
|
174
|
+
"""Remove a task from the in-flight registry once it has finished."""
|
|
175
|
+
if not _enabled() or task_id is None:
|
|
176
|
+
return
|
|
177
|
+
backend = _get_backend()
|
|
178
|
+
if backend is None:
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
backend._execute_with_refresh(lambda: backend.redis.hdel(_registry_key(), task_id))
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
logger.warning(f"reschedule: failed to clear in-flight task {task_id}: {exc}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def reschedule_inflight_on_shutdown() -> None:
|
|
187
|
+
"""
|
|
188
|
+
Re-enqueue every task still recorded as in-flight for this pod. Assumes the
|
|
189
|
+
caller has already cancelled this worker's task consumer (see
|
|
190
|
+
:class:`RescheduleConsumerStep`) so the fresh copy is not re-grabbed. Never
|
|
191
|
+
raises -- shutdown must not be blocked.
|
|
192
|
+
"""
|
|
193
|
+
if not _enabled():
|
|
194
|
+
return
|
|
195
|
+
backend = _get_backend()
|
|
196
|
+
if backend is None:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
key = _registry_key()
|
|
200
|
+
try:
|
|
201
|
+
entries = backend._execute_with_refresh(lambda: backend.redis.hgetall(key)) or {}
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
logger.warning(f"reschedule: failed to read in-flight registry: {exc}")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if not entries:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
for task_id, raw in entries.items():
|
|
210
|
+
try:
|
|
211
|
+
descriptor = json.loads(raw)
|
|
212
|
+
task = _resolve_task(descriptor["name"])
|
|
213
|
+
if task is None:
|
|
214
|
+
logger.warning(
|
|
215
|
+
f"reschedule: unknown task {descriptor.get('name')!r}, "
|
|
216
|
+
f"cannot re-enqueue {task_id}"
|
|
217
|
+
)
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
args = descriptor.get("args") or []
|
|
221
|
+
kwargs = descriptor.get("kwargs") or {}
|
|
222
|
+
queue = descriptor.get("queue")
|
|
223
|
+
|
|
224
|
+
# Release the singleton lock held by the dying task so the re-enqueued
|
|
225
|
+
# copy is not rejected as a duplicate. release_lock() regenerates the
|
|
226
|
+
# exact lock key from the same args/kwargs the running task holds.
|
|
227
|
+
try:
|
|
228
|
+
task.release_lock(task_args=list(args), task_kwargs=kwargs)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logger.warning(
|
|
231
|
+
f"reschedule: failed to release lock for {task.name} "
|
|
232
|
+
f"(old_task_id={task_id}): {exc}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
options = {"queue": queue} if queue else {}
|
|
236
|
+
task.apply_async(args=list(args), kwargs=kwargs, **options)
|
|
237
|
+
logger.warning(
|
|
238
|
+
f"reschedule: re-enqueued in-flight task on shutdown "
|
|
239
|
+
f"task={task.name} old_task_id={task_id} queue={queue}"
|
|
240
|
+
)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
logger.opt(exception=True).error(
|
|
243
|
+
f"reschedule: failed to re-enqueue task {task_id}: {exc}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Drop the registry for this pod; a fresh worker starts with a clean slate.
|
|
247
|
+
try:
|
|
248
|
+
backend._execute_with_refresh(lambda: backend.redis.delete(key))
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
logger.warning(f"reschedule: failed to clear in-flight registry: {exc}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_worker_stopping() -> bool:
|
|
254
|
+
"""
|
|
255
|
+
True only when the worker is actually shutting down (SIGTERM warm or cold),
|
|
256
|
+
not on a transient consumer restart (e.g. broker reconnect), which also tears
|
|
257
|
+
the consumer down. Celery's SIGTERM handler sets these flags before the
|
|
258
|
+
consumer is stopped.
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
from celery.worker import state
|
|
262
|
+
|
|
263
|
+
# Identity checks (matching celery.worker.state): the flags are set to the
|
|
264
|
+
# exit code, which can be 0 (EX_OK) -- `0 == False`, so membership/equality
|
|
265
|
+
# against False would wrongly treat a clean exitcode-0 shutdown as "not
|
|
266
|
+
# stopping" and skip the reschedule.
|
|
267
|
+
def _set(v: Any) -> bool:
|
|
268
|
+
return v is not None and v is not False
|
|
269
|
+
|
|
270
|
+
return _set(state.should_stop) or _set(state.should_terminate)
|
|
271
|
+
except Exception:
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def on_consumer_stop(consumer: Any) -> None:
|
|
276
|
+
"""
|
|
277
|
+
Invoked from the consumer bootstep as the consumer is torn down. Cancels this
|
|
278
|
+
worker's task consumer (so it stops fetching) and re-enqueues in-flight work.
|
|
279
|
+
"""
|
|
280
|
+
if not _enabled() or not _is_worker_stopping():
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Cancel our own task consumer first: once we stop BRPOPing, the re-enqueued
|
|
284
|
+
# message stays in the ready list for another worker instead of being grabbed
|
|
285
|
+
# back here into `unacked`. Tasks.stop() will cancel again later (idempotent).
|
|
286
|
+
task_consumer = getattr(consumer, "task_consumer", None)
|
|
287
|
+
if task_consumer is not None:
|
|
288
|
+
try:
|
|
289
|
+
task_consumer.cancel()
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
logger.warning(f"reschedule: failed to cancel task consumer: {exc}")
|
|
292
|
+
|
|
293
|
+
# Cancelling is not enough on the Redis transport: a BRPOP this worker already
|
|
294
|
+
# issued is accepted by the server and will still pop the *next* message pushed
|
|
295
|
+
# to those queues -- including the copy we are about to re-enqueue. The dying
|
|
296
|
+
# worker then exits before recording it in `unacked`, so the message is lost
|
|
297
|
+
# entirely (neither in the ready list nor restorable via visibility_timeout).
|
|
298
|
+
# Drop the consumer's broker connection so any in-flight BRPOP is aborted
|
|
299
|
+
# server-side *before* we re-publish; the re-enqueue itself uses the producer
|
|
300
|
+
# pool (a separate connection), so it is unaffected.
|
|
301
|
+
_abort_consumer_fetching(consumer)
|
|
302
|
+
|
|
303
|
+
reschedule_inflight_on_shutdown()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _abort_consumer_fetching(consumer: Any) -> None:
|
|
307
|
+
"""Close the consumer's broker connection to abort any in-flight BRPOP."""
|
|
308
|
+
conn = getattr(consumer, "connection", None)
|
|
309
|
+
if conn is None:
|
|
310
|
+
return
|
|
311
|
+
try:
|
|
312
|
+
# collect() tears down channels + the underlying socket, aborting the
|
|
313
|
+
# pending BRPOP. Never raises into shutdown.
|
|
314
|
+
conn.collect()
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
logger.warning(f"reschedule: failed to drop consumer connection: {exc}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class RescheduleConsumerStep(bootsteps.StartStopStep):
|
|
320
|
+
"""
|
|
321
|
+
Consumer bootstep that re-enqueues this worker's in-flight tasks the moment
|
|
322
|
+
the consumer is torn down on shutdown -- event-based, no polling/sleep.
|
|
323
|
+
|
|
324
|
+
``requires`` the task consumer (``Tasks``) so that (a) ``c.task_consumer``
|
|
325
|
+
exists when our ``stop()`` runs and (b) we run *before* ``Tasks.stop()``,
|
|
326
|
+
letting us cancel the consumer ourselves and re-enqueue while it's guaranteed
|
|
327
|
+
not fetching.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
requires = ("celery.worker.consumer:Tasks",)
|
|
331
|
+
|
|
332
|
+
def start(self, c: Any) -> None: # noqa: D401 - nothing to start
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
def stop(self, c: Any) -> None:
|
|
336
|
+
try:
|
|
337
|
+
on_consumer_stop(c)
|
|
338
|
+
except Exception as exc: # never block consumer teardown
|
|
339
|
+
logger.warning(f"reschedule: consumer-stop hook failed: {exc}")
|
|
@@ -323,7 +323,11 @@ class SingletonWorkflowTask(Task):
|
|
|
323
323
|
|
|
324
324
|
# Bind arguments to function signature
|
|
325
325
|
sig = inspect.signature(self.run)
|
|
326
|
-
|
|
326
|
+
bound_sig = sig.bind(*task_args, **task_kwargs)
|
|
327
|
+
# Include declared defaults so unique_on may reference parameters
|
|
328
|
+
# the caller did not pass explicitly (e.g. dedup_scope).
|
|
329
|
+
bound_sig.apply_defaults()
|
|
330
|
+
bound = bound_sig.arguments
|
|
327
331
|
|
|
328
332
|
unique_args: list[Any] = []
|
|
329
333
|
for key in unique_on:
|
|
@@ -371,6 +371,19 @@ def execute_step_task(
|
|
|
371
371
|
hook_id=hook_id or "",
|
|
372
372
|
)
|
|
373
373
|
)
|
|
374
|
+
# step_hook(on_timeout="return") carries the hook deadline: schedule
|
|
375
|
+
# a resume at expiry so the workflow wakes even if nobody ever calls
|
|
376
|
+
# resume_hook (the step re-executes and receives STEP_HOOK_TIMEOUT).
|
|
377
|
+
# A racing resume_hook is harmless — duplicate resumes are dropped
|
|
378
|
+
# by try_claim_run.
|
|
379
|
+
resume_at = e.data.get("resume_at")
|
|
380
|
+
if resume_at is not None:
|
|
381
|
+
schedule_workflow_resumption(
|
|
382
|
+
run_id,
|
|
383
|
+
resume_at,
|
|
384
|
+
storage_config,
|
|
385
|
+
triggered_by="step_hook_timeout",
|
|
386
|
+
)
|
|
374
387
|
return None
|
|
375
388
|
|
|
376
389
|
# Other SuspensionSignals are not supported from steps
|
|
@@ -2025,12 +2038,13 @@ async def _start_workflow_on_worker(
|
|
|
2025
2038
|
name="pyworkflow.resume_workflow",
|
|
2026
2039
|
base=SingletonWorkflowTask,
|
|
2027
2040
|
queue="pyworkflow.schedules",
|
|
2028
|
-
unique_on=["run_id"],
|
|
2041
|
+
unique_on=["run_id", "dedup_scope"],
|
|
2029
2042
|
)
|
|
2030
2043
|
def resume_workflow_task(
|
|
2031
2044
|
run_id: str,
|
|
2032
2045
|
storage_config: dict[str, Any] | None = None,
|
|
2033
2046
|
triggered_by_hook_id: str | None = None,
|
|
2047
|
+
dedup_scope: str = "immediate",
|
|
2034
2048
|
) -> Any | None:
|
|
2035
2049
|
"""
|
|
2036
2050
|
Resume a suspended workflow.
|
|
@@ -2703,10 +2717,15 @@ def schedule_workflow_resumption(
|
|
|
2703
2717
|
triggered_by=triggered_by,
|
|
2704
2718
|
)
|
|
2705
2719
|
|
|
2706
|
-
# Schedule the resume task
|
|
2720
|
+
# Schedule the resume task. Delayed (deadline) resumes hold their
|
|
2721
|
+
# singleton lock for the whole countdown; scoping them separately keeps
|
|
2722
|
+
# them from swallowing immediate resumes (e.g. resume_hook wake-ups)
|
|
2723
|
+
# enqueued while the countdown is pending. Extra resume executions are
|
|
2724
|
+
# harmless: try_claim_run drops all but the first.
|
|
2725
|
+
dedup_scope = "deadline" if delay_seconds > 0 else "immediate"
|
|
2707
2726
|
resume_workflow_task.apply_async(
|
|
2708
2727
|
args=[run_id],
|
|
2709
|
-
kwargs={"storage_config": storage_config},
|
|
2728
|
+
kwargs={"storage_config": storage_config, "dedup_scope": dedup_scope},
|
|
2710
2729
|
countdown=delay_seconds,
|
|
2711
2730
|
)
|
|
2712
2731
|
|
|
@@ -25,7 +25,8 @@ Usage:
|
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
from collections.abc import Awaitable, Callable
|
|
28
|
-
from
|
|
28
|
+
from datetime import UTC, datetime, timedelta
|
|
29
|
+
from typing import Any, Literal
|
|
29
30
|
|
|
30
31
|
from loguru import logger
|
|
31
32
|
from pydantic import BaseModel
|
|
@@ -35,12 +36,29 @@ from pyworkflow.core.exceptions import SuspensionSignal
|
|
|
35
36
|
from pyworkflow.primitives.step_checkpoint import get_step_run_id
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
class StepHookTimeout:
|
|
40
|
+
"""Sentinel returned by ``step_hook(on_timeout="return")`` when the hook expires.
|
|
41
|
+
|
|
42
|
+
Check with ``isinstance(result, StepHookTimeout)`` or compare against the
|
|
43
|
+
``STEP_HOOK_TIMEOUT`` singleton.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ()
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return "STEP_HOOK_TIMEOUT"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
STEP_HOOK_TIMEOUT = StepHookTimeout()
|
|
53
|
+
|
|
54
|
+
|
|
38
55
|
async def step_hook(
|
|
39
56
|
name: str,
|
|
40
57
|
*,
|
|
41
58
|
timeout: str | int | None = None,
|
|
42
59
|
on_created: Callable[[str], Awaitable[None]] | None = None,
|
|
43
60
|
payload_schema: type[BaseModel] | None = None,
|
|
61
|
+
on_timeout: Literal["suspend", "return"] = "suspend",
|
|
44
62
|
) -> Any:
|
|
45
63
|
"""
|
|
46
64
|
Wait for an external event from within a @step function.
|
|
@@ -58,9 +76,14 @@ async def step_hook(
|
|
|
58
76
|
timeout: Optional max wait time (str duration or seconds)
|
|
59
77
|
on_created: Optional async callback with the hook token
|
|
60
78
|
payload_schema: Optional Pydantic model for payload validation
|
|
79
|
+
on_timeout: What to do when ``timeout`` elapses without a resume.
|
|
80
|
+
"suspend" (default): keep waiting for resume_hook() — legacy behavior.
|
|
81
|
+
"return": the runtime schedules a resume at the deadline and this
|
|
82
|
+
call returns the ``STEP_HOOK_TIMEOUT`` sentinel on re-execution.
|
|
61
83
|
|
|
62
84
|
Returns:
|
|
63
|
-
Payload from resume_hook()
|
|
85
|
+
Payload from resume_hook(), or ``STEP_HOOK_TIMEOUT`` when the hook
|
|
86
|
+
expired and ``on_timeout="return"``
|
|
64
87
|
|
|
65
88
|
Raises:
|
|
66
89
|
RuntimeError: If called outside a step context
|
|
@@ -107,11 +130,11 @@ async def step_hook(
|
|
|
107
130
|
|
|
108
131
|
events = await storage.get_events(ctx.run_id)
|
|
109
132
|
hook_received = None
|
|
110
|
-
|
|
133
|
+
hook_created_event = None
|
|
111
134
|
|
|
112
135
|
for event in events:
|
|
113
136
|
if event.type == EventType.HOOK_CREATED and event.data.get("hook_id") == hook_id:
|
|
114
|
-
|
|
137
|
+
hook_created_event = event
|
|
115
138
|
elif event.type == EventType.HOOK_RECEIVED and event.data.get("hook_id") == hook_id:
|
|
116
139
|
hook_received = event
|
|
117
140
|
|
|
@@ -127,17 +150,53 @@ async def step_hook(
|
|
|
127
150
|
)
|
|
128
151
|
return payload
|
|
129
152
|
|
|
130
|
-
# If hook was already created but not received, re-suspend
|
|
131
|
-
if
|
|
153
|
+
# If hook was already created but not received, check expiry, else re-suspend
|
|
154
|
+
if hook_created_event is not None:
|
|
155
|
+
expires_at: datetime | None = None
|
|
156
|
+
expires_at_raw = hook_created_event.data.get("expires_at")
|
|
157
|
+
if expires_at_raw:
|
|
158
|
+
expires_at = datetime.fromisoformat(expires_at_raw)
|
|
159
|
+
|
|
160
|
+
if on_timeout == "return" and expires_at is not None and datetime.now(UTC) >= expires_at:
|
|
161
|
+
from pyworkflow.engine.events import create_hook_expired_event
|
|
162
|
+
from pyworkflow.storage.schemas import HookStatus
|
|
163
|
+
|
|
164
|
+
await storage.record_event(
|
|
165
|
+
create_hook_expired_event(run_id=ctx.run_id, hook_id=hook_id)
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
await storage.update_hook_status(hook_id, HookStatus.EXPIRED)
|
|
169
|
+
except Exception:
|
|
170
|
+
# Best-effort: the HOOK_EXPIRED event is authoritative for replay;
|
|
171
|
+
# a failed status update must not fail the step.
|
|
172
|
+
logger.warning(
|
|
173
|
+
f"Step hook '{name}' expired but status update failed",
|
|
174
|
+
run_id=ctx.run_id,
|
|
175
|
+
hook_id=hook_id,
|
|
176
|
+
)
|
|
177
|
+
logger.info(
|
|
178
|
+
f"Step hook '{name}' expired, returning timeout sentinel",
|
|
179
|
+
run_id=ctx.run_id,
|
|
180
|
+
hook_id=hook_id,
|
|
181
|
+
)
|
|
182
|
+
return STEP_HOOK_TIMEOUT
|
|
183
|
+
|
|
132
184
|
logger.debug(
|
|
133
185
|
f"Step hook '{name}' already created, re-suspending",
|
|
134
186
|
run_id=ctx.run_id,
|
|
135
187
|
hook_id=hook_id,
|
|
136
188
|
)
|
|
189
|
+
# Re-arm the deadline resume only for on_timeout="return" with a future
|
|
190
|
+
# deadline: the runtime schedules a resume at resume_at, and scheduling
|
|
191
|
+
# one in the past would busy-loop resume → re-suspend.
|
|
192
|
+
resume_data: dict[str, Any] = {}
|
|
193
|
+
if on_timeout == "return" and expires_at is not None and datetime.now(UTC) < expires_at:
|
|
194
|
+
resume_data["resume_at"] = expires_at
|
|
137
195
|
raise SuspensionSignal(
|
|
138
196
|
reason=f"step_hook:{hook_id}",
|
|
139
197
|
hook_id=hook_id,
|
|
140
198
|
step_id=step_run_id,
|
|
199
|
+
**resume_data,
|
|
141
200
|
)
|
|
142
201
|
|
|
143
202
|
# Parse timeout
|
|
@@ -179,8 +238,6 @@ async def step_hook(
|
|
|
179
238
|
payload_schema=payload_schema.__name__ if payload_schema else None,
|
|
180
239
|
)
|
|
181
240
|
if timeout_seconds:
|
|
182
|
-
from datetime import UTC, datetime, timedelta
|
|
183
|
-
|
|
184
241
|
hook_record.expires_at = datetime.now(UTC) + timedelta(seconds=timeout_seconds)
|
|
185
242
|
|
|
186
243
|
await storage.create_hook(hook_record)
|
|
@@ -197,9 +254,15 @@ async def step_hook(
|
|
|
197
254
|
if on_created:
|
|
198
255
|
await on_created(token)
|
|
199
256
|
|
|
200
|
-
# Raise SuspensionSignal to suspend the step
|
|
257
|
+
# Raise SuspensionSignal to suspend the step. For on_timeout="return",
|
|
258
|
+
# carry the deadline so the runtime schedules a resume at expiry — without
|
|
259
|
+
# it, an expired hook nobody resumes would suspend the workflow forever.
|
|
260
|
+
suspend_data: dict[str, Any] = {}
|
|
261
|
+
if on_timeout == "return" and hook_record.expires_at is not None:
|
|
262
|
+
suspend_data["resume_at"] = hook_record.expires_at
|
|
201
263
|
raise SuspensionSignal(
|
|
202
264
|
reason=f"step_hook:{hook_id}",
|
|
203
265
|
hook_id=hook_id,
|
|
204
266
|
step_id=step_run_id,
|
|
267
|
+
**suspend_data,
|
|
205
268
|
)
|