pyworkflow-engine 0.2.15__tar.gz → 0.3.1__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.2.15 → pyworkflow_engine-0.3.1}/PKG-INFO +1 -1
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyproject.toml +1 -1
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/__init__.py +1 -1
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/celery/singleton.py +82 -12
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/celery/tasks.py +43 -3
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/config.py +3 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/context/base.py +17 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/registry.py +26 -2
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/step.py +47 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/workflow.py +75 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/engine/executor.py +4 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/runtime/base.py +1 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/runtime/celery.py +2 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/runtime/local.py +2 -0
- pyworkflow_engine-0.3.1/pyworkflow/tracing/__init__.py +17 -0
- pyworkflow_engine-0.3.1/pyworkflow/tracing/base.py +166 -0
- pyworkflow_engine-0.3.1/pyworkflow/tracing/factory.py +46 -0
- pyworkflow_engine-0.3.1/pyworkflow/tracing/langfuse.py +184 -0
- pyworkflow_engine-0.3.1/pyworkflow/tracing/types.py +69 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow_engine.egg-info/SOURCES.txt +5 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_singleton.py +126 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/LICENSE +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/README.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/RELEASING.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/stream-steps.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/stream-workflows.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/layers.md +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/celery/app.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/celery/scheduler.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/hooks.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/runs.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/schedules.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/context/local.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/engine/events.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/engine/replay.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/step_checkpoint.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/primitives/step_hook.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/base.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/cassandra.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/citus.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/config.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/dynamodb.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/file.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/memory.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/mysql.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/postgres.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/storage/sqlite.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/checkpoint.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/consumer.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/decorator.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/dispatcher.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/emit.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/registry.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/runtime.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/signal.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/streams/step_context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/setup.cfg +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_stream_e2e.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/backends/test_citus_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_emit.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_retention.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_signal.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_step_checkpoint.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_step_hook.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_stream_storage.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_stream_workflow.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.2.15 → pyworkflow_engine-0.3.1}/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.3.1"
|
|
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"
|
|
@@ -11,8 +11,9 @@ Based on:
|
|
|
11
11
|
|
|
12
12
|
import inspect
|
|
13
13
|
import json
|
|
14
|
+
from collections.abc import Callable
|
|
14
15
|
from hashlib import md5
|
|
15
|
-
from typing import TYPE_CHECKING, Any
|
|
16
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
16
17
|
from uuid import uuid4
|
|
17
18
|
|
|
18
19
|
from celery import Task
|
|
@@ -20,8 +21,11 @@ from celery.exceptions import WorkerLostError
|
|
|
20
21
|
from loguru import logger
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
24
|
+
from redis import Redis
|
|
23
25
|
from redis.sentinel import Sentinel
|
|
24
26
|
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
def generate_lock_key(
|
|
27
31
|
task_name: str,
|
|
@@ -74,10 +78,17 @@ class SingletonConfig:
|
|
|
74
78
|
|
|
75
79
|
|
|
76
80
|
class RedisLockBackend:
|
|
77
|
-
"""Redis backend for distributed locking with Sentinel support.
|
|
81
|
+
"""Redis backend for distributed locking with Sentinel support.
|
|
82
|
+
|
|
83
|
+
During Sentinel failover (master rotation), cached connections can raise
|
|
84
|
+
MasterNotFoundError or socket TimeoutError. On those, we refresh the client
|
|
85
|
+
via ``sentinel.master_for()`` and retry the operation once. See
|
|
86
|
+
``_execute_with_refresh``.
|
|
87
|
+
"""
|
|
78
88
|
|
|
79
89
|
_sentinel: "Sentinel | None"
|
|
80
90
|
_master_name: str | None
|
|
91
|
+
redis: "Redis"
|
|
81
92
|
|
|
82
93
|
def __init__(
|
|
83
94
|
self,
|
|
@@ -91,21 +102,62 @@ class RedisLockBackend:
|
|
|
91
102
|
from redis.sentinel import Sentinel
|
|
92
103
|
|
|
93
104
|
sentinels = self._parse_sentinel_url(url)
|
|
105
|
+
# socket_timeout=5 matches FlowHunt's production-tested value.
|
|
106
|
+
# The previous 0.5s was too tight during Sentinel failover windows.
|
|
94
107
|
self._sentinel = Sentinel(
|
|
95
108
|
sentinels,
|
|
96
|
-
socket_timeout=
|
|
109
|
+
socket_timeout=5,
|
|
97
110
|
decode_responses=True,
|
|
98
111
|
)
|
|
99
112
|
self._master_name = sentinel_master or "mymaster"
|
|
100
|
-
self.redis = self.
|
|
101
|
-
self._master_name,
|
|
102
|
-
decode_responses=True,
|
|
103
|
-
)
|
|
113
|
+
self.redis = self._build_master_client()
|
|
104
114
|
else:
|
|
105
115
|
self._sentinel = None
|
|
106
116
|
self._master_name = None
|
|
107
117
|
self.redis = redis.from_url(url, decode_responses=True)
|
|
108
118
|
|
|
119
|
+
def _build_master_client(self) -> "Redis":
|
|
120
|
+
"""Get a fresh master client from Sentinel. Sentinel mode only."""
|
|
121
|
+
assert self._sentinel is not None and self._master_name is not None
|
|
122
|
+
return self._sentinel.master_for(
|
|
123
|
+
self._master_name,
|
|
124
|
+
decode_responses=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _refresh_client(self) -> None:
|
|
128
|
+
"""Re-resolve master via Sentinel after a failover-related error."""
|
|
129
|
+
if self._sentinel is None:
|
|
130
|
+
return
|
|
131
|
+
self.redis = self._build_master_client()
|
|
132
|
+
logger.info(
|
|
133
|
+
"Refreshed Redis master connection for sentinel master '{}'",
|
|
134
|
+
self._master_name,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _execute_with_refresh(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
138
|
+
"""Run ``fn``; on Sentinel failover errors, refresh the client and retry once.
|
|
139
|
+
|
|
140
|
+
In non-sentinel mode this is a no-op passthrough — refresh has nothing
|
|
141
|
+
to do, so any exception propagates as before.
|
|
142
|
+
"""
|
|
143
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
144
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
145
|
+
from redis.sentinel import MasterNotFoundError
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
return fn(*args, **kwargs)
|
|
149
|
+
except (MasterNotFoundError, RedisConnectionError, RedisTimeoutError) as exc:
|
|
150
|
+
if self._sentinel is None:
|
|
151
|
+
raise
|
|
152
|
+
logger.warning(
|
|
153
|
+
"Redis lock op hit sentinel failover error ({}: {}). "
|
|
154
|
+
"Refreshing master and retrying once.",
|
|
155
|
+
type(exc).__name__,
|
|
156
|
+
exc,
|
|
157
|
+
)
|
|
158
|
+
self._refresh_client()
|
|
159
|
+
return fn(*args, **kwargs)
|
|
160
|
+
|
|
109
161
|
@staticmethod
|
|
110
162
|
def _parse_sentinel_url(url: str) -> list[tuple[str, int]]:
|
|
111
163
|
"""
|
|
@@ -142,15 +194,19 @@ class RedisLockBackend:
|
|
|
142
194
|
|
|
143
195
|
def lock(self, lock_key: str, task_id: str, expiry: int | None = None) -> bool:
|
|
144
196
|
"""Acquire lock atomically. Returns True if acquired."""
|
|
145
|
-
return bool(
|
|
197
|
+
return bool(
|
|
198
|
+
self._execute_with_refresh(
|
|
199
|
+
lambda: self.redis.set(lock_key, task_id, nx=True, ex=expiry)
|
|
200
|
+
)
|
|
201
|
+
)
|
|
146
202
|
|
|
147
203
|
def unlock(self, lock_key: str) -> None:
|
|
148
204
|
"""Release the lock."""
|
|
149
|
-
self.redis.delete(lock_key)
|
|
205
|
+
self._execute_with_refresh(lambda: self.redis.delete(lock_key))
|
|
150
206
|
|
|
151
207
|
def get(self, lock_key: str) -> str | None:
|
|
152
208
|
"""Get the task ID holding the lock."""
|
|
153
|
-
return self.redis.get(lock_key)
|
|
209
|
+
return self._execute_with_refresh(lambda: self.redis.get(lock_key))
|
|
154
210
|
|
|
155
211
|
|
|
156
212
|
class DuplicateTaskError(Exception):
|
|
@@ -364,6 +420,16 @@ class SingletonWorkflowTask(Task):
|
|
|
364
420
|
|
|
365
421
|
# Lock not acquired - check for existing task
|
|
366
422
|
existing_task_id = self.get_existing_task_id(lock_key)
|
|
423
|
+
|
|
424
|
+
# Self-retry: when celery's Task.retry() calls apply_async with the
|
|
425
|
+
# same task_id we already hold, it's the running task re-queueing
|
|
426
|
+
# itself. Allow the queue write through — on_retry will release the
|
|
427
|
+
# lock before the retried message is picked up. Without this branch,
|
|
428
|
+
# apply_async would silently return AsyncResult without enqueueing,
|
|
429
|
+
# and the retry would be lost.
|
|
430
|
+
if existing_task_id == task_id:
|
|
431
|
+
return super().apply_async(args, kwargs, task_id=task_id, **options)
|
|
432
|
+
|
|
367
433
|
if existing_task_id:
|
|
368
434
|
logger.debug(
|
|
369
435
|
"Singleton: duplicate task blocked",
|
|
@@ -427,13 +493,17 @@ class SingletonWorkflowTask(Task):
|
|
|
427
493
|
error=str(exc),
|
|
428
494
|
)
|
|
429
495
|
|
|
430
|
-
# Log appropriately
|
|
496
|
+
# Log appropriately. Keep str(exc) out of the message string: when
|
|
497
|
+
# kwargs are passed, Loguru calls .format() on the message, which
|
|
498
|
+
# parses any `{` from the exception (e.g. JSON in the message) as a
|
|
499
|
+
# format spec and raises KeyError.
|
|
431
500
|
if isinstance(exc, WorkerLostError):
|
|
432
501
|
logger.warning("Task interrupted due to worker loss", task_id=task_id)
|
|
433
502
|
else:
|
|
434
503
|
logger.error(
|
|
435
|
-
f"Task {self.name} failed
|
|
504
|
+
f"Task {self.name} failed",
|
|
436
505
|
task_id=task_id,
|
|
506
|
+
error=str(exc),
|
|
437
507
|
traceback=einfo.traceback if einfo else None,
|
|
438
508
|
)
|
|
439
509
|
|
|
@@ -10,6 +10,7 @@ These tasks enable:
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
|
+
import hashlib
|
|
13
14
|
import random
|
|
14
15
|
import uuid
|
|
15
16
|
from collections.abc import Callable
|
|
@@ -93,6 +94,8 @@ def execute_step_task(
|
|
|
93
94
|
context_data: dict[str, Any] | None = None,
|
|
94
95
|
context_class_name: str | None = None,
|
|
95
96
|
workflow_name: str | None = None,
|
|
97
|
+
tracing: dict[str, Any] | None = None,
|
|
98
|
+
is_generator: bool = False,
|
|
96
99
|
) -> Any:
|
|
97
100
|
"""
|
|
98
101
|
Execute a workflow step on a Celery worker.
|
|
@@ -242,6 +245,7 @@ def execute_step_task(
|
|
|
242
245
|
step_workflow_ctx._runtime = "celery"
|
|
243
246
|
step_workflow_ctx._storage_config = storage_config
|
|
244
247
|
step_workflow_ctx._is_step_worker = True
|
|
248
|
+
step_workflow_ctx.tracing = tracing
|
|
245
249
|
|
|
246
250
|
# Set parent_run_id if this workflow is a child
|
|
247
251
|
wf_run = run_async(storage.get_run(run_id))
|
|
@@ -284,6 +288,26 @@ def execute_step_task(
|
|
|
284
288
|
step_id=step_id,
|
|
285
289
|
)
|
|
286
290
|
|
|
291
|
+
# Record tracing span for this step on the worker
|
|
292
|
+
if tracing and result is not None:
|
|
293
|
+
try:
|
|
294
|
+
from pyworkflow.tracing import create_tracing_provider
|
|
295
|
+
|
|
296
|
+
_tp = create_tracing_provider(tracing)
|
|
297
|
+
if _tp:
|
|
298
|
+
_tp.record_step_span(
|
|
299
|
+
trace_id=hashlib.md5(run_id.encode()).hexdigest(),
|
|
300
|
+
step_name=step_name,
|
|
301
|
+
step_id=step_id,
|
|
302
|
+
is_generator=is_generator,
|
|
303
|
+
result=result,
|
|
304
|
+
step_input=dict(kwargs) if kwargs else None,
|
|
305
|
+
trace_name=workflow_name or "workflow",
|
|
306
|
+
)
|
|
307
|
+
run_async(_tp.shutdown())
|
|
308
|
+
except Exception:
|
|
309
|
+
pass # Never fail step because of tracing
|
|
310
|
+
|
|
287
311
|
# Record STEP_COMPLETED event and trigger workflow resumption
|
|
288
312
|
run_async(
|
|
289
313
|
_record_step_completion_and_resume(
|
|
@@ -418,8 +442,12 @@ def execute_step_task(
|
|
|
418
442
|
if self.request.retries < max_retries:
|
|
419
443
|
# Use exponential backoff for unexpected errors
|
|
420
444
|
countdown = _calculate_exponential_backoff(self.request.retries)
|
|
445
|
+
# Don't inline str(e) into the message: Loguru calls .format() on
|
|
446
|
+
# the message when kwargs are present, and any `{` from the
|
|
447
|
+
# exception (e.g. a JSON body) is parsed as a format spec and
|
|
448
|
+
# raises KeyError, which would skip the retry below.
|
|
421
449
|
logger.warning(
|
|
422
|
-
f"Step failed (unexpected): {step_name}, retrying in {countdown:.1f}s
|
|
450
|
+
f"Step failed (unexpected): {step_name}, retrying in {countdown:.1f}s...",
|
|
423
451
|
run_id=run_id,
|
|
424
452
|
step_id=step_id,
|
|
425
453
|
error=str(e),
|
|
@@ -855,6 +883,7 @@ def start_workflow_task(
|
|
|
855
883
|
run_id: str,
|
|
856
884
|
storage_config: dict[str, Any] | None = None,
|
|
857
885
|
idempotency_key: str | None = None,
|
|
886
|
+
tracing: dict[str, Any] | None = None,
|
|
858
887
|
) -> str:
|
|
859
888
|
"""
|
|
860
889
|
Start a workflow execution.
|
|
@@ -906,6 +935,7 @@ def start_workflow_task(
|
|
|
906
935
|
storage_config=storage_config,
|
|
907
936
|
idempotency_key=idempotency_key,
|
|
908
937
|
run_id=run_id,
|
|
938
|
+
tracing=tracing,
|
|
909
939
|
)
|
|
910
940
|
)
|
|
911
941
|
|
|
@@ -1431,6 +1461,9 @@ async def _recover_workflow_on_worker(
|
|
|
1431
1461
|
args = deserialize_args(run.input_args)
|
|
1432
1462
|
kwargs = deserialize_kwargs(run.input_kwargs)
|
|
1433
1463
|
|
|
1464
|
+
# Extract tracing config persisted at start
|
|
1465
|
+
_recover_tracing = kwargs.pop("_tracing_config", None)
|
|
1466
|
+
|
|
1434
1467
|
# Execute workflow with event replay
|
|
1435
1468
|
try:
|
|
1436
1469
|
result = await execute_workflow_with_context(
|
|
@@ -1444,6 +1477,7 @@ async def _recover_workflow_on_worker(
|
|
|
1444
1477
|
runtime="celery",
|
|
1445
1478
|
storage_config=storage_config,
|
|
1446
1479
|
parent_run_id=run.parent_run_id,
|
|
1480
|
+
tracing=workflow_meta.tracing or _recover_tracing,
|
|
1447
1481
|
)
|
|
1448
1482
|
|
|
1449
1483
|
# Update run status to completed
|
|
@@ -1579,6 +1613,7 @@ async def _start_workflow_on_worker(
|
|
|
1579
1613
|
storage_config: dict[str, Any] | None = None,
|
|
1580
1614
|
idempotency_key: str | None = None,
|
|
1581
1615
|
run_id: str | None = None,
|
|
1616
|
+
tracing: dict[str, Any] | None = None,
|
|
1582
1617
|
) -> str:
|
|
1583
1618
|
"""
|
|
1584
1619
|
Internal function to start workflow on Celery worker.
|
|
@@ -1779,10 +1814,10 @@ async def _start_workflow_on_worker(
|
|
|
1779
1814
|
created_at=datetime.now(UTC),
|
|
1780
1815
|
started_at=datetime.now(UTC),
|
|
1781
1816
|
input_args=serialize_args(*args),
|
|
1782
|
-
input_kwargs=serialize_kwargs(**kwargs),
|
|
1817
|
+
input_kwargs=serialize_kwargs(**kwargs, _tracing_config=tracing),
|
|
1783
1818
|
idempotency_key=idempotency_key,
|
|
1784
1819
|
max_duration=workflow_meta.max_duration,
|
|
1785
|
-
context={}, # Step context
|
|
1820
|
+
context={}, # Step context data
|
|
1786
1821
|
recovery_attempts=0,
|
|
1787
1822
|
max_recovery_attempts=max_recovery_attempts,
|
|
1788
1823
|
recover_on_worker_loss=recover_on_worker_loss,
|
|
@@ -1812,6 +1847,7 @@ async def _start_workflow_on_worker(
|
|
|
1812
1847
|
kwargs=kwargs,
|
|
1813
1848
|
runtime="celery",
|
|
1814
1849
|
storage_config=storage_config,
|
|
1850
|
+
tracing=tracing or workflow_meta.tracing,
|
|
1815
1851
|
)
|
|
1816
1852
|
|
|
1817
1853
|
# Update run status to completed
|
|
@@ -2392,6 +2428,9 @@ async def _resume_workflow_on_worker(
|
|
|
2392
2428
|
args = deserialize_args(run.input_args)
|
|
2393
2429
|
kwargs = deserialize_kwargs(run.input_kwargs)
|
|
2394
2430
|
|
|
2431
|
+
# Extract tracing config persisted at start
|
|
2432
|
+
_resume_tracing = kwargs.pop("_tracing_config", None)
|
|
2433
|
+
|
|
2395
2434
|
# Execute workflow with event replay
|
|
2396
2435
|
try:
|
|
2397
2436
|
result = await execute_workflow_with_context(
|
|
@@ -2406,6 +2445,7 @@ async def _resume_workflow_on_worker(
|
|
|
2406
2445
|
runtime="celery",
|
|
2407
2446
|
storage_config=storage_config,
|
|
2408
2447
|
parent_run_id=run.parent_run_id,
|
|
2448
|
+
tracing=workflow_meta.tracing or _resume_tracing,
|
|
2409
2449
|
)
|
|
2410
2450
|
|
|
2411
2451
|
# Update run status to completed
|
|
@@ -171,6 +171,9 @@ class PyWorkflowConfig:
|
|
|
171
171
|
event_hard_limit: int = 50_000 # Fail workflow at this count
|
|
172
172
|
event_warning_interval: int = 100 # Log warning every N events after soft limit
|
|
173
173
|
|
|
174
|
+
# Tracing Configuration
|
|
175
|
+
TRACING_CONFIG: dict | None = None
|
|
176
|
+
|
|
174
177
|
|
|
175
178
|
def _config_from_env_and_yaml() -> PyWorkflowConfig:
|
|
176
179
|
"""
|
|
@@ -133,6 +133,9 @@ class WorkflowContext(ABC):
|
|
|
133
133
|
"""
|
|
134
134
|
self._run_id = run_id
|
|
135
135
|
self._workflow_name = workflow_name
|
|
136
|
+
self._tracing: dict[str, Any] | None = None
|
|
137
|
+
self._tracing_provider: Any = None # TracingProvider instance
|
|
138
|
+
self._trace_id: str | None = None # Stable trace ID
|
|
136
139
|
|
|
137
140
|
@property
|
|
138
141
|
def run_id(self) -> str:
|
|
@@ -144,6 +147,20 @@ class WorkflowContext(ABC):
|
|
|
144
147
|
"""Get the current workflow name."""
|
|
145
148
|
return self._workflow_name
|
|
146
149
|
|
|
150
|
+
@property
|
|
151
|
+
def tracing(self) -> dict[str, Any] | None:
|
|
152
|
+
"""Get the tracing provider config (e.g. Langfuse credentials), or None."""
|
|
153
|
+
return self._tracing
|
|
154
|
+
|
|
155
|
+
@tracing.setter
|
|
156
|
+
def tracing(self, value: dict[str, Any] | None) -> None:
|
|
157
|
+
self._tracing = value
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def tracing_provider(self) -> Any:
|
|
161
|
+
"""Get the active TracingProvider instance, or None."""
|
|
162
|
+
return self._tracing_provider
|
|
163
|
+
|
|
147
164
|
# =========================================================================
|
|
148
165
|
# Abstract methods - must be implemented by subclasses
|
|
149
166
|
# =========================================================================
|
|
@@ -23,6 +23,9 @@ class WorkflowMetadata:
|
|
|
23
23
|
tags: list[str] | None = None
|
|
24
24
|
description: str | None = None # Docstring from the workflow function
|
|
25
25
|
context_class: type | None = None # StepContext subclass for step context access
|
|
26
|
+
tracing: dict[str, Any] | None = (
|
|
27
|
+
None # Optional tracing provider config (e.g. Langfuse credentials)
|
|
28
|
+
)
|
|
26
29
|
|
|
27
30
|
def __post_init__(self) -> None:
|
|
28
31
|
if self.tags is None:
|
|
@@ -44,6 +47,9 @@ class StepMetadata:
|
|
|
44
47
|
timeout: int | None = None
|
|
45
48
|
metadata: dict[str, Any] | None = None
|
|
46
49
|
force_local: bool = False
|
|
50
|
+
is_generator: bool = (
|
|
51
|
+
False # If True, creates a Langfuse generation span instead of a regular span
|
|
52
|
+
)
|
|
47
53
|
|
|
48
54
|
def __post_init__(self) -> None:
|
|
49
55
|
if self.metadata is None:
|
|
@@ -73,6 +79,7 @@ class WorkflowRegistry:
|
|
|
73
79
|
max_duration: str | None = None,
|
|
74
80
|
tags: list[str] | None = None,
|
|
75
81
|
context_class: type | None = None,
|
|
82
|
+
tracing: dict[str, Any] | None = None,
|
|
76
83
|
) -> None:
|
|
77
84
|
"""
|
|
78
85
|
Register a workflow.
|
|
@@ -84,6 +91,7 @@ class WorkflowRegistry:
|
|
|
84
91
|
max_duration: Optional maximum duration
|
|
85
92
|
tags: Optional list of tags (max 3)
|
|
86
93
|
context_class: Optional StepContext subclass for step context access
|
|
94
|
+
tracing: Optional tracing provider config dict (e.g. Langfuse credentials)
|
|
87
95
|
"""
|
|
88
96
|
if name in self._workflows:
|
|
89
97
|
existing = self._workflows[name]
|
|
@@ -101,6 +109,7 @@ class WorkflowRegistry:
|
|
|
101
109
|
max_duration=max_duration,
|
|
102
110
|
tags=tags or [],
|
|
103
111
|
context_class=context_class,
|
|
112
|
+
tracing=tracing,
|
|
104
113
|
)
|
|
105
114
|
|
|
106
115
|
self._workflows[name] = workflow_meta
|
|
@@ -160,6 +169,7 @@ class WorkflowRegistry:
|
|
|
160
169
|
timeout: int | None = None,
|
|
161
170
|
metadata: dict[str, Any] | None = None,
|
|
162
171
|
force_local: bool = False,
|
|
172
|
+
is_generator: bool = False,
|
|
163
173
|
) -> None:
|
|
164
174
|
"""
|
|
165
175
|
Register a step.
|
|
@@ -173,6 +183,7 @@ class WorkflowRegistry:
|
|
|
173
183
|
timeout: Optional timeout in seconds
|
|
174
184
|
metadata: Optional metadata dict
|
|
175
185
|
force_local: If True, always execute inline even in distributed runtimes
|
|
186
|
+
is_generator: If True, creates a Langfuse generation span instead of a regular span
|
|
176
187
|
"""
|
|
177
188
|
if name in self._steps:
|
|
178
189
|
existing = self._steps[name]
|
|
@@ -190,6 +201,7 @@ class WorkflowRegistry:
|
|
|
190
201
|
timeout=timeout,
|
|
191
202
|
metadata=metadata or {},
|
|
192
203
|
force_local=force_local,
|
|
204
|
+
is_generator=is_generator,
|
|
193
205
|
)
|
|
194
206
|
|
|
195
207
|
self._steps[name] = step_meta
|
|
@@ -259,9 +271,12 @@ def register_workflow(
|
|
|
259
271
|
max_duration: str | None = None,
|
|
260
272
|
tags: list[str] | None = None,
|
|
261
273
|
context_class: type | None = None,
|
|
274
|
+
tracing: dict[str, Any] | None = None,
|
|
262
275
|
) -> None:
|
|
263
276
|
"""Register a workflow in the global registry."""
|
|
264
|
-
_registry.register_workflow(
|
|
277
|
+
_registry.register_workflow(
|
|
278
|
+
name, func, original_func, max_duration, tags, context_class, tracing
|
|
279
|
+
)
|
|
265
280
|
|
|
266
281
|
|
|
267
282
|
def get_workflow(name: str) -> WorkflowMetadata | None:
|
|
@@ -293,10 +308,19 @@ def register_step(
|
|
|
293
308
|
timeout: int | None = None,
|
|
294
309
|
metadata: dict[str, Any] | None = None,
|
|
295
310
|
force_local: bool = False,
|
|
311
|
+
is_generator: bool = False,
|
|
296
312
|
) -> None:
|
|
297
313
|
"""Register a step in the global registry."""
|
|
298
314
|
_registry.register_step(
|
|
299
|
-
name,
|
|
315
|
+
name,
|
|
316
|
+
func,
|
|
317
|
+
original_func,
|
|
318
|
+
max_retries,
|
|
319
|
+
retry_delay,
|
|
320
|
+
timeout,
|
|
321
|
+
metadata,
|
|
322
|
+
force_local,
|
|
323
|
+
is_generator,
|
|
300
324
|
)
|
|
301
325
|
|
|
302
326
|
|
|
@@ -58,6 +58,7 @@ def step(
|
|
|
58
58
|
timeout: int | None = None,
|
|
59
59
|
metadata: dict[str, Any] | None = None,
|
|
60
60
|
force_local: bool = False,
|
|
61
|
+
is_generator: bool = False,
|
|
61
62
|
) -> Callable:
|
|
62
63
|
"""
|
|
63
64
|
Decorator to mark functions as workflow steps.
|
|
@@ -77,6 +78,8 @@ def step(
|
|
|
77
78
|
force_local: If True, always execute inline even in distributed
|
|
78
79
|
runtimes (e.g., Celery). Useful for lightweight steps where
|
|
79
80
|
the overhead of message broker dispatch is undesirable.
|
|
81
|
+
is_generator: If True, creates a Langfuse generation span instead of
|
|
82
|
+
a regular span. Use for LLM/AI steps that produce generated content.
|
|
80
83
|
|
|
81
84
|
Returns:
|
|
82
85
|
Decorated step function
|
|
@@ -209,6 +212,7 @@ def step(
|
|
|
209
212
|
max_retries=max_retries,
|
|
210
213
|
retry_delay=retry_delay,
|
|
211
214
|
timeout=timeout,
|
|
215
|
+
is_generator=is_generator,
|
|
212
216
|
)
|
|
213
217
|
|
|
214
218
|
# Check if we're resuming from a retry
|
|
@@ -297,6 +301,9 @@ def step(
|
|
|
297
301
|
step_id=step_id,
|
|
298
302
|
)
|
|
299
303
|
|
|
304
|
+
# Record tracing span for locally executed step
|
|
305
|
+
_record_step_tracing(ctx, step_name, step_id, is_generator, result, args, kwargs)
|
|
306
|
+
|
|
300
307
|
return result
|
|
301
308
|
|
|
302
309
|
except SuspensionSignal:
|
|
@@ -458,6 +465,7 @@ def step(
|
|
|
458
465
|
timeout=timeout,
|
|
459
466
|
metadata=metadata,
|
|
460
467
|
force_local=force_local,
|
|
468
|
+
is_generator=is_generator,
|
|
461
469
|
)
|
|
462
470
|
|
|
463
471
|
# Store metadata on wrapper
|
|
@@ -468,6 +476,7 @@ def step(
|
|
|
468
476
|
wrapper.__step_timeout__ = timeout # type: ignore[attr-defined]
|
|
469
477
|
wrapper.__step_metadata__ = metadata or {} # type: ignore[attr-defined]
|
|
470
478
|
wrapper.__step_force_local__ = force_local # type: ignore[attr-defined]
|
|
479
|
+
wrapper.__step_is_generator__ = is_generator # type: ignore[attr-defined]
|
|
471
480
|
|
|
472
481
|
return wrapper
|
|
473
482
|
|
|
@@ -627,6 +636,7 @@ async def _dispatch_step_to_celery(
|
|
|
627
636
|
max_retries: int,
|
|
628
637
|
retry_delay: str | int | list[int],
|
|
629
638
|
timeout: int | None,
|
|
639
|
+
is_generator: bool = False,
|
|
630
640
|
) -> Any:
|
|
631
641
|
"""
|
|
632
642
|
Dispatch step execution to Celery step worker.
|
|
@@ -736,6 +746,8 @@ async def _dispatch_step_to_celery(
|
|
|
736
746
|
context_data=context_data,
|
|
737
747
|
context_class_name=context_class_name,
|
|
738
748
|
workflow_name=ctx.workflow_name,
|
|
749
|
+
tracing=ctx.tracing,
|
|
750
|
+
is_generator=is_generator,
|
|
739
751
|
)
|
|
740
752
|
|
|
741
753
|
logger.info(
|
|
@@ -753,3 +765,38 @@ async def _dispatch_step_to_celery(
|
|
|
753
765
|
step_name=step_name,
|
|
754
766
|
task_id=task_result.id,
|
|
755
767
|
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _record_step_tracing(
|
|
771
|
+
ctx: Any,
|
|
772
|
+
step_name: str,
|
|
773
|
+
step_id: str,
|
|
774
|
+
is_generator: bool,
|
|
775
|
+
result: Any,
|
|
776
|
+
step_args: tuple = (),
|
|
777
|
+
step_kwargs: dict | None = None,
|
|
778
|
+
) -> None:
|
|
779
|
+
"""Record a tracing span for a completed step. No-op if tracing is not configured."""
|
|
780
|
+
tp = getattr(ctx, "_tracing_provider", None)
|
|
781
|
+
if not tp:
|
|
782
|
+
return
|
|
783
|
+
try:
|
|
784
|
+
trace_id = getattr(ctx, "_trace_id", None)
|
|
785
|
+
if not trace_id:
|
|
786
|
+
return
|
|
787
|
+
|
|
788
|
+
step_input = dict(step_kwargs) if step_kwargs else {}
|
|
789
|
+
if step_args:
|
|
790
|
+
step_input["_args"] = [str(a)[:500] for a in step_args]
|
|
791
|
+
|
|
792
|
+
tp.record_step_span(
|
|
793
|
+
trace_id=trace_id,
|
|
794
|
+
step_name=step_name,
|
|
795
|
+
step_id=step_id,
|
|
796
|
+
is_generator=is_generator,
|
|
797
|
+
result=result,
|
|
798
|
+
step_input=step_input or None,
|
|
799
|
+
trace_name=ctx.workflow_name,
|
|
800
|
+
)
|
|
801
|
+
except Exception:
|
|
802
|
+
pass # Never fail the workflow because of tracing
|