durable-workflow 2.11.1__tar.gz → 2.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.11.1/src/durable_workflow.egg-info → durable_workflow-2.12.0}/PKG-INFO +1 -1
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/pyproject.toml +3 -3
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/__init__.py +2 -1
- durable_workflow-2.12.0/src/durable_workflow/_poll_transport.py +55 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/activity.py +61 -2
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/client.py +49 -7
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/sync.py +2 -1
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/worker.py +3 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
- durable_workflow-2.12.0/tests/test_activity_retry_info.py +82 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_client.py +35 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_remote_worker.py +20 -0
- durable_workflow-2.12.0/tests/test_poll_transport.py +255 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_worker.py +21 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sync.py +8 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker.py +39 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/LICENSE +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/README.md +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/setup.cfg +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_activity_lease.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_failure_reporting.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_metric_routes.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_heartbeat_authority.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_lease_authority.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_lease_renewal.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_lease_worker.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_failure_reporting.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_history_bundle_schema2.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_local_failure_reporting.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_metric_routes.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_poll_failure_backoff.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sdk_metrics_contract.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_testing_failures.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_testing_nexus.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_threaded_heartbeat_example.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_tls_trust.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_transport_diagnostics.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_deregistration.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_identity.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.12.0"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -76,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
76
76
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
77
77
|
|
|
78
78
|
[tool.durable-workflow]
|
|
79
|
-
product-train = "2.
|
|
80
|
-
registry-version = "2.
|
|
79
|
+
product-train = "2.12.0"
|
|
80
|
+
registry-version = "2.12.0"
|
|
81
81
|
supported-server-versions = "2.8.0"
|
|
82
82
|
worker-protocol-version = "1.20"
|
|
83
83
|
control-plane-version = "2"
|
|
@@ -7,7 +7,7 @@ except PackageNotFoundError: # source checkout without installed metadata
|
|
|
7
7
|
__version__ = "0.0.0+unknown"
|
|
8
8
|
|
|
9
9
|
from . import activity, sync, testing, workflow
|
|
10
|
-
from .activity import ActivityContext, ActivityInfo
|
|
10
|
+
from .activity import ActivityContext, ActivityInfo, ActivityRetryInfo
|
|
11
11
|
from .auth_composition import (
|
|
12
12
|
AUTH_COMPOSITION_CONTRACT_SCHEMA,
|
|
13
13
|
AUTH_COMPOSITION_CONTRACT_VERSION,
|
|
@@ -248,6 +248,7 @@ __all__ = [
|
|
|
248
248
|
"ActivityContext",
|
|
249
249
|
"ActivityHandler",
|
|
250
250
|
"ActivityInfo",
|
|
251
|
+
"ActivityRetryInfo",
|
|
251
252
|
"ActivityInterceptorContext",
|
|
252
253
|
"ActivityRetryPolicy",
|
|
253
254
|
"BridgeAdapterOutcome",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Negotiated long-poll responses and early transport failure diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
MEDIA_TYPE = "application/vnd.durable-workflow.poll.v1+json"
|
|
13
|
+
ACCEPT = f"{MEDIA_TYPE}, application/json;q=0.9"
|
|
14
|
+
_SCHEMA = "durable-workflow.poll-transport.v1"
|
|
15
|
+
POLL_PATHS = frozenset(
|
|
16
|
+
f"/worker/{kind}-tasks/poll" for kind in ("workflow", "activity", "query", "update-validation")
|
|
17
|
+
)
|
|
18
|
+
_HEADERS = frozenset(("retry-after", "x-durable-workflow-protocol-version"))
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unwrap_response(response: httpx.Response) -> httpx.Response:
|
|
23
|
+
if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != MEDIA_TYPE:
|
|
24
|
+
return response
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
envelope = response.json()
|
|
28
|
+
if not isinstance(envelope, dict) or envelope.get("schema") != _SCHEMA:
|
|
29
|
+
raise ValueError("unrecognized poll transport")
|
|
30
|
+
status, headers, body = envelope.get("status"), envelope.get("headers"), envelope.get("body")
|
|
31
|
+
if (type(status) is not int or not 200 <= status < 600
|
|
32
|
+
or response.status_code not in (200, status)
|
|
33
|
+
or not isinstance(body, dict) or not isinstance(headers, dict)
|
|
34
|
+
or any(key not in _HEADERS or not isinstance(value, str)
|
|
35
|
+
or "\r" in value or "\n" in value for key, value in headers.items())):
|
|
36
|
+
raise ValueError("invalid poll transport result")
|
|
37
|
+
# Apply the original status and retry headers before the normal client
|
|
38
|
+
# error handling. Outer HTTP 200 may have been committed by progress.
|
|
39
|
+
return httpx.Response(status, headers=headers, json=body, request=response.request)
|
|
40
|
+
except (ValueError, TypeError) as error:
|
|
41
|
+
raise httpx.RemoteProtocolError("Invalid worker poll transport response.", request=response.request) from error
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def warn_early_cutoff(path: str, body: Any, started: float, error: Exception) -> None:
|
|
45
|
+
wait = body.get("timeout_seconds") if isinstance(body, Mapping) else None
|
|
46
|
+
observed = time.monotonic() - started
|
|
47
|
+
if isinstance(wait, bool) or not isinstance(wait, int | float) or wait <= 0 or observed >= wait:
|
|
48
|
+
return
|
|
49
|
+
if isinstance(error, httpx.HTTPStatusError) and error.response.status_code not in (502, 504):
|
|
50
|
+
return
|
|
51
|
+
log.warning(
|
|
52
|
+
"Worker long poll %s ended after %.3fs before its configured wait of %.3fs (%s). "
|
|
53
|
+
"Check proxy and HTTP timeout budgets.",
|
|
54
|
+
path, observed, wait, type(error).__name__,
|
|
55
|
+
)
|
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import contextvars
|
|
6
|
-
|
|
7
|
-
from
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from dataclasses import InitVar, dataclass, field
|
|
8
9
|
from typing import TYPE_CHECKING, Any
|
|
9
10
|
|
|
10
11
|
from ._activity_lease import current_activity_authority
|
|
@@ -20,6 +21,53 @@ _current_context: contextvars.ContextVar[ActivityContext | None] = contextvars.C
|
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
@dataclass(frozen=True, init=False)
|
|
25
|
+
class ActivityRetryInfo:
|
|
26
|
+
"""Original attempt and immutable received retry snapshot, without defaults."""
|
|
27
|
+
|
|
28
|
+
attempt_number: int
|
|
29
|
+
policy_provided: bool
|
|
30
|
+
# Immutable JSON also survives the supervised callback's spawn serialization.
|
|
31
|
+
_policy_json: str = field(repr=False)
|
|
32
|
+
|
|
33
|
+
def __init__(self, attempt_number: int, policy_provided: bool = False, policy: Any = None) -> None:
|
|
34
|
+
object.__setattr__(self, "attempt_number", attempt_number)
|
|
35
|
+
object.__setattr__(self, "policy_provided", policy_provided)
|
|
36
|
+
object.__setattr__(self, "_policy_json", json.dumps(policy))
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_task(cls, attempt_number: int, task: Mapping[str, Any]) -> ActivityRetryInfo:
|
|
40
|
+
return cls(attempt_number, "retry_policy" in task, task.get("retry_policy"))
|
|
41
|
+
|
|
42
|
+
def policy(self) -> Any:
|
|
43
|
+
"""Return a fresh copy, including unknown fields and explicit legacy null."""
|
|
44
|
+
return json.loads(self._policy_json)
|
|
45
|
+
|
|
46
|
+
def max_attempts(self) -> int | None:
|
|
47
|
+
"""A positive configured limit. An unknown limit does not mean unlimited."""
|
|
48
|
+
policy = self.policy()
|
|
49
|
+
maximum = policy.get("max_attempts") if isinstance(policy, dict) else None
|
|
50
|
+
return maximum if type(maximum) is int and maximum > 0 else None
|
|
51
|
+
|
|
52
|
+
def is_unlimited(self) -> bool:
|
|
53
|
+
"""Only an explicit max_attempts:null inside a policy means unlimited."""
|
|
54
|
+
policy = self.policy()
|
|
55
|
+
return isinstance(policy, dict) and "max_attempts" in policy and policy["max_attempts"] is None
|
|
56
|
+
|
|
57
|
+
def is_last_configured_attempt(self) -> bool | None:
|
|
58
|
+
"""Check the budget, not whether another execution will actually occur.
|
|
59
|
+
|
|
60
|
+
Cancellation, non-retryable failure, workflow closure and deadlines can
|
|
61
|
+
end execution before the configured budget is exhausted.
|
|
62
|
+
"""
|
|
63
|
+
if self.attempt_number < 1:
|
|
64
|
+
return None
|
|
65
|
+
maximum = self.max_attempts()
|
|
66
|
+
if maximum is not None:
|
|
67
|
+
return self.attempt_number >= maximum
|
|
68
|
+
return False if self.is_unlimited() else None
|
|
69
|
+
|
|
70
|
+
|
|
23
71
|
@dataclass(frozen=True)
|
|
24
72
|
class ActivityInfo:
|
|
25
73
|
"""Metadata attached to the currently running activity attempt."""
|
|
@@ -30,6 +78,17 @@ class ActivityInfo:
|
|
|
30
78
|
attempt_number: int
|
|
31
79
|
task_queue: str
|
|
32
80
|
worker_id: str
|
|
81
|
+
task_metadata: InitVar[Mapping[str, Any] | None] = None
|
|
82
|
+
retry_info: ActivityRetryInfo = field(init=False)
|
|
83
|
+
workflow_id: str | None = field(init=False)
|
|
84
|
+
run_id: str | None = field(init=False)
|
|
85
|
+
|
|
86
|
+
def __post_init__(self, task_metadata: Mapping[str, Any] | None) -> None:
|
|
87
|
+
task = task_metadata if task_metadata is not None else {}
|
|
88
|
+
object.__setattr__(self, "retry_info", ActivityRetryInfo.from_task(self.attempt_number, task))
|
|
89
|
+
for name in ("workflow_id", "run_id"):
|
|
90
|
+
value = task.get(name)
|
|
91
|
+
object.__setattr__(self, name, value if isinstance(value, str) and value else None)
|
|
33
92
|
|
|
34
93
|
|
|
35
94
|
class ActivityContext:
|
|
@@ -57,6 +57,10 @@ from ._failure_reporting import (
|
|
|
57
57
|
prepare_failure_request,
|
|
58
58
|
)
|
|
59
59
|
from ._metric_routes import route_for_metrics as _route_for_metrics
|
|
60
|
+
from ._poll_transport import ACCEPT as _POLL_ACCEPT
|
|
61
|
+
from ._poll_transport import POLL_PATHS as _POLL_PATHS
|
|
62
|
+
from ._poll_transport import unwrap_response as _unwrap_poll_response
|
|
63
|
+
from ._poll_transport import warn_early_cutoff as _warn_poll_cutoff
|
|
60
64
|
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
61
65
|
from .errors import (
|
|
62
66
|
REQUEST_ID_CONFLICT,
|
|
@@ -1382,6 +1386,10 @@ class WorkflowHandle:
|
|
|
1382
1386
|
request_id: str | None = None,
|
|
1383
1387
|
) -> dict[str, Any]:
|
|
1384
1388
|
"""Deliver an external signal to this workflow. See :meth:`Client.signal_workflow`."""
|
|
1389
|
+
if self.run_id is not None:
|
|
1390
|
+
return await self._client.signal_workflow(
|
|
1391
|
+
self.workflow_id, signal_name, args=args, request_id=request_id, run_id=self.run_id,
|
|
1392
|
+
)
|
|
1385
1393
|
return await self._client.signal_workflow(
|
|
1386
1394
|
self.workflow_id, signal_name, args=args, request_id=request_id,
|
|
1387
1395
|
)
|
|
@@ -1882,6 +1890,7 @@ class Client:
|
|
|
1882
1890
|
|
|
1883
1891
|
query_poll = worker and path == "/worker/query-tasks/poll"
|
|
1884
1892
|
activity_poll = worker and path == "/worker/activity-tasks/poll"
|
|
1893
|
+
progress_poll = worker and method.upper() == "POST" and path in _POLL_PATHS
|
|
1885
1894
|
if query_poll:
|
|
1886
1895
|
current_query_authority.set(None)
|
|
1887
1896
|
if activity_poll:
|
|
@@ -1931,15 +1940,34 @@ class Client:
|
|
|
1931
1940
|
request_timeout = self.timeout if timeout is None else timeout
|
|
1932
1941
|
if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
|
|
1933
1942
|
request_timeout = min(request_timeout, activity_authority.remaining())
|
|
1934
|
-
|
|
1943
|
+
headers = self._discovery_headers() if discovery else self._headers(worker=worker)
|
|
1944
|
+
if progress_poll:
|
|
1945
|
+
headers["Accept"] = _POLL_ACCEPT
|
|
1946
|
+
pending = self._http.request(
|
|
1935
1947
|
method,
|
|
1936
1948
|
f"/api{path}",
|
|
1937
|
-
headers=
|
|
1949
|
+
headers=headers,
|
|
1938
1950
|
json=request_json,
|
|
1939
1951
|
timeout=request_timeout,
|
|
1940
1952
|
)
|
|
1941
|
-
|
|
1942
|
-
|
|
1953
|
+
try:
|
|
1954
|
+
if progress_poll:
|
|
1955
|
+
try:
|
|
1956
|
+
resp = await asyncio.wait_for(pending, timeout=request_timeout)
|
|
1957
|
+
except asyncio.TimeoutError as error:
|
|
1958
|
+
raise httpx.ReadTimeout(
|
|
1959
|
+
"Worker poll exceeded its absolute HTTP attempt deadline.",
|
|
1960
|
+
request=self._http.build_request(method, f"/api{path}"),
|
|
1961
|
+
) from error
|
|
1962
|
+
resp = _unwrap_poll_response(resp)
|
|
1963
|
+
else:
|
|
1964
|
+
resp = await pending
|
|
1965
|
+
# Interpret negotiated late errors before the usual retry policy.
|
|
1966
|
+
resp.raise_for_status()
|
|
1967
|
+
except (httpx.TransportError, httpx.HTTPStatusError) as error:
|
|
1968
|
+
if progress_poll:
|
|
1969
|
+
_warn_poll_cutoff(path, request_json, request_started, error)
|
|
1970
|
+
raise
|
|
1943
1971
|
return resp
|
|
1944
1972
|
|
|
1945
1973
|
try:
|
|
@@ -4437,6 +4465,7 @@ class Client:
|
|
|
4437
4465
|
*,
|
|
4438
4466
|
args: list[Any] | None = None,
|
|
4439
4467
|
request_id: str | None = None,
|
|
4468
|
+
run_id: str | None = None,
|
|
4440
4469
|
) -> dict[str, Any]:
|
|
4441
4470
|
"""Deliver an external signal to a running workflow.
|
|
4442
4471
|
|
|
@@ -4445,12 +4474,18 @@ class Client:
|
|
|
4445
4474
|
observe the signal. See the main docs for how to declare the allowed
|
|
4446
4475
|
signal names on a workflow type.
|
|
4447
4476
|
|
|
4477
|
+
Pass ``run_id`` to target the original run. Omitting it targets the
|
|
4478
|
+
current run when the Server receives the request. A run-pinned
|
|
4479
|
+
:class:`WorkflowHandle` forwards its run identity automatically.
|
|
4480
|
+
|
|
4448
4481
|
``request_id`` is the caller command key (1-255 opaque, case-sensitive
|
|
4449
|
-
bytes)
|
|
4482
|
+
bytes). When omitted the client
|
|
4450
4483
|
generates ``sdk-python-signal-<uuid>`` once per call, so the transport
|
|
4451
4484
|
retry policy resends the same key. Resending the same key with the same
|
|
4452
4485
|
signal name, arguments and run target returns the original receipt
|
|
4453
|
-
|
|
4486
|
+
only when the runtime supports and applies command-key retention.
|
|
4487
|
+
A runtime without that retention can record repeated signals. Keep
|
|
4488
|
+
business-event processing idempotent and reconcile uncertain delivery.
|
|
4454
4489
|
|
|
4455
4490
|
Returns the Server's admission receipt. ``request_id`` is always
|
|
4456
4491
|
present (the key that was sent); a Server that retains command keys
|
|
@@ -4462,6 +4497,12 @@ class Client:
|
|
|
4462
4497
|
in its ``details``. Every exception raised after the key was chosen,
|
|
4463
4498
|
including transport errors, carries it as ``request_id``.
|
|
4464
4499
|
"""
|
|
4500
|
+
if run_id is not None and (not isinstance(run_id, str) or not run_id):
|
|
4501
|
+
raise ValueError("run_id must be a non-empty string when provided")
|
|
4502
|
+
path = f"/workflows/{quote(workflow_id, safe='')}"
|
|
4503
|
+
if run_id is not None:
|
|
4504
|
+
path += f"/runs/{quote(run_id, safe='')}"
|
|
4505
|
+
path += f"/signal/{quote(signal_name, safe='')}"
|
|
4465
4506
|
key = _command_request_id(request_id, "signal")
|
|
4466
4507
|
body: dict[str, Any] = {}
|
|
4467
4508
|
if args:
|
|
@@ -4469,12 +4510,13 @@ class Client:
|
|
|
4469
4510
|
args,
|
|
4470
4511
|
kind="signal",
|
|
4471
4512
|
workflow_id=workflow_id,
|
|
4513
|
+
run_id=run_id,
|
|
4472
4514
|
signal_name=signal_name,
|
|
4473
4515
|
)
|
|
4474
4516
|
body["request_id"] = key
|
|
4475
4517
|
try:
|
|
4476
4518
|
data = await self._request(
|
|
4477
|
-
"POST",
|
|
4519
|
+
"POST", path,
|
|
4478
4520
|
json=self._with_observability_context(body), context=workflow_id,
|
|
4479
4521
|
)
|
|
4480
4522
|
except Exception as exc:
|
|
@@ -535,9 +535,10 @@ class Client:
|
|
|
535
535
|
*,
|
|
536
536
|
args: list[Any] | None = None,
|
|
537
537
|
request_id: str | None = None,
|
|
538
|
+
run_id: str | None = None,
|
|
538
539
|
) -> dict[str, Any]:
|
|
539
540
|
result: dict[str, Any] = self._runner.run(
|
|
540
|
-
self._async.signal_workflow(workflow_id, signal_name, args=args, request_id=request_id)
|
|
541
|
+
self._async.signal_workflow(workflow_id, signal_name, args=args, request_id=request_id, run_id=run_id)
|
|
541
542
|
)
|
|
542
543
|
return result
|
|
543
544
|
|
|
@@ -2105,6 +2105,7 @@ class Worker:
|
|
|
2105
2105
|
handler, tuple(call.command.arguments), ActivityInfo(
|
|
2106
2106
|
task_id, call.command.activity_type, attempt.attempt_id, attempt.attempt_number,
|
|
2107
2107
|
self.task_queue, self.worker_id,
|
|
2108
|
+
task_metadata={"workflow_id": task.get("workflow_id"), "run_id": task.get("run_id")},
|
|
2108
2109
|
), {**task, "activity_attempt_id": attempt.attempt_id}, self.interceptors,
|
|
2109
2110
|
namespace=getattr(self.client, "namespace", None),
|
|
2110
2111
|
execution_context=telemetry.capture() if telemetry is not None else None,
|
|
@@ -2462,6 +2463,7 @@ class Worker:
|
|
|
2462
2463
|
attempt_number=attempt_number,
|
|
2463
2464
|
task_queue=self.task_queue,
|
|
2464
2465
|
worker_id=self.worker_id,
|
|
2466
|
+
task_metadata={"workflow_id": task.get("workflow_id"), "run_id": task.get("run_id")},
|
|
2465
2467
|
)
|
|
2466
2468
|
_set_context(ActivityContext(info=info, client=self.client, heartbeat_callback=heartbeat))
|
|
2467
2469
|
try:
|
|
@@ -3196,6 +3198,7 @@ class Worker:
|
|
|
3196
3198
|
attempt_number=attempt_number,
|
|
3197
3199
|
task_queue=self.task_queue,
|
|
3198
3200
|
worker_id=self.worker_id,
|
|
3201
|
+
task_metadata=task,
|
|
3199
3202
|
),
|
|
3200
3203
|
client=self.client,
|
|
3201
3204
|
)
|
{durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/SOURCES.txt
RENAMED
|
@@ -10,6 +10,7 @@ src/durable_workflow/_cancellation_scope_history.py
|
|
|
10
10
|
src/durable_workflow/_cooperative_cancellation.py
|
|
11
11
|
src/durable_workflow/_failure_reporting.py
|
|
12
12
|
src/durable_workflow/_metric_routes.py
|
|
13
|
+
src/durable_workflow/_poll_transport.py
|
|
13
14
|
src/durable_workflow/_prepared_local_activity.py
|
|
14
15
|
src/durable_workflow/_query_task_authority.py
|
|
15
16
|
src/durable_workflow/_sticky_workflow_cache.py
|
|
@@ -53,6 +54,7 @@ tests/test_activity_lease_authority.py
|
|
|
53
54
|
tests/test_activity_lease_renewal.py
|
|
54
55
|
tests/test_activity_lease_worker.py
|
|
55
56
|
tests/test_activity_process.py
|
|
57
|
+
tests/test_activity_retry_info.py
|
|
56
58
|
tests/test_auth_composition.py
|
|
57
59
|
tests/test_avro_value_benchmark.py
|
|
58
60
|
tests/test_avro_value_protocol.py
|
|
@@ -104,6 +106,7 @@ tests/test_parallel_saga_authoring.py
|
|
|
104
106
|
tests/test_patch_history.py
|
|
105
107
|
tests/test_pending_operation_replay.py
|
|
106
108
|
tests/test_poll_failure_backoff.py
|
|
109
|
+
tests/test_poll_transport.py
|
|
107
110
|
tests/test_prepared_local_activity_client.py
|
|
108
111
|
tests/test_prepared_local_activity_groups.py
|
|
109
112
|
tests/test_prepared_local_activity_policies.py
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
from dataclasses import FrozenInstanceError
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from durable_workflow import ActivityInfo, ActivityRetryInfo, activity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@activity.defn(name="context.metadata")
|
|
13
|
+
def inspect_activity_context(_marker: str | None = None) -> dict[str, Any]:
|
|
14
|
+
info = activity.context().info
|
|
15
|
+
retry = info.retry_info
|
|
16
|
+
return {"workflow_id": info.workflow_id, "run_id": info.run_id, "attempt": retry.attempt_number,
|
|
17
|
+
"policy_provided": retry.policy_provided, "policy": retry.policy(), "maximum": retry.max_attempts(),
|
|
18
|
+
"unlimited": retry.is_unlimited(), "last": retry.is_last_configured_attempt()}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize(("attempt", "task", "maximum", "unlimited", "last"), [
|
|
22
|
+
(1, {"retry_policy": {"max_attempts": 3}}, 3, False, False),
|
|
23
|
+
(3, {"retry_policy": {"max_attempts": 3}}, 3, False, True),
|
|
24
|
+
(4, {"retry_policy": {"max_attempts": 3}}, 3, False, True),
|
|
25
|
+
(1, {"retry_policy": {"max_attempts": 1}}, 1, False, True),
|
|
26
|
+
(1, {}, None, False, None),
|
|
27
|
+
(1, {"retry_policy": None}, None, False, None),
|
|
28
|
+
(99, {"retry_policy": {"max_attempts": None}}, None, True, False),
|
|
29
|
+
(1, {"retry_policy": {"future": [True, 2.0]}}, None, False, None),
|
|
30
|
+
(1, {"retry_policy": {"max_attempts": "3"}}, None, False, None),
|
|
31
|
+
(1, {"retry_policy": {"max_attempts": True}}, None, False, None),
|
|
32
|
+
(1, {"retry_policy": {"max_attempts": 3.0}}, None, False, None),
|
|
33
|
+
(1, {"retry_policy": {"max_attempts": 0}}, None, False, None),
|
|
34
|
+
(1, {"retry_policy": "legacy"}, None, False, None),
|
|
35
|
+
(0, {"retry_policy": {"max_attempts": 1}}, 1, False, None),
|
|
36
|
+
])
|
|
37
|
+
def test_received_budget_preserves_unknown_and_unlimited(
|
|
38
|
+
attempt: int, task: dict[str, Any], maximum: int | None, unlimited: bool, last: bool | None,
|
|
39
|
+
) -> None:
|
|
40
|
+
retry = ActivityRetryInfo.from_task(attempt, task)
|
|
41
|
+
assert retry.attempt_number == attempt
|
|
42
|
+
assert retry.policy_provided is ("retry_policy" in task)
|
|
43
|
+
assert retry.policy() == task.get("retry_policy")
|
|
44
|
+
assert retry.max_attempts() == maximum
|
|
45
|
+
assert retry.is_unlimited() is unlimited
|
|
46
|
+
assert retry.is_last_configured_attempt() is last
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_snapshot_copies_and_original_identity_survive_spawn_serialization() -> None:
|
|
50
|
+
task = {"workflow_id": "0", "run_id": "017", "retry_policy": {"max_attempts": 3, "future": [True, 2.0, None]}}
|
|
51
|
+
info = ActivityInfo("task", "activity", "attempt", 3, "queue", "owner", task_metadata=task)
|
|
52
|
+
task["retry_policy"]["max_attempts"] = 10
|
|
53
|
+
task["workflow_id"] = "replacement"
|
|
54
|
+
copy = info.retry_info.policy()
|
|
55
|
+
copy["future"][0] = False
|
|
56
|
+
assert info.retry_info.policy() == {"max_attempts": 3, "future": [True, 2.0, None]}
|
|
57
|
+
assert info.workflow_id == "0" and info.run_id == "017"
|
|
58
|
+
assert info.retry_info.is_last_configured_attempt() is True
|
|
59
|
+
restored = pickle.loads(pickle.dumps(info))
|
|
60
|
+
assert restored == info
|
|
61
|
+
assert restored.retry_info.policy() == info.retry_info.policy()
|
|
62
|
+
with pytest.raises(FrozenInstanceError):
|
|
63
|
+
info.workflow_id = "replacement" # type: ignore[misc]
|
|
64
|
+
with pytest.raises(FrozenInstanceError):
|
|
65
|
+
info.retry_info.attempt_number = 100 # type: ignore[misc]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_legacy_constructor_has_no_invented_received_policy_or_identity() -> None:
|
|
69
|
+
info = ActivityInfo("task", "activity", "attempt", 2, "queue", "owner")
|
|
70
|
+
assert info.retry_info.attempt_number == 2
|
|
71
|
+
assert info.retry_info.policy_provided is False
|
|
72
|
+
assert info.retry_info.is_last_configured_attempt() is None
|
|
73
|
+
assert info.workflow_id is None and info.run_id is None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("task", [
|
|
77
|
+
{}, {"workflow_id": None, "run_id": None}, {"workflow_id": "", "run_id": 42},
|
|
78
|
+
{"workflow_instance_id": "internal", "workflow_run_id": "internal-run"},
|
|
79
|
+
])
|
|
80
|
+
def test_public_claim_identity_stays_unknown_when_missing_or_invalid(task: dict[str, Any]) -> None:
|
|
81
|
+
info = ActivityInfo("task", "activity", "attempt", 1, "queue", "owner", task_metadata=task)
|
|
82
|
+
assert info.workflow_id is None and info.run_id is None
|
|
@@ -869,6 +869,41 @@ class TestWorkflowHandleControlPlane:
|
|
|
869
869
|
|
|
870
870
|
|
|
871
871
|
class TestSignalWorkflow:
|
|
872
|
+
@pytest.mark.asyncio
|
|
873
|
+
@pytest.mark.parametrize("use_handle", [False, True])
|
|
874
|
+
async def test_signal_keeps_original_run_and_business_key(self, client: Client, use_handle: bool) -> None:
|
|
875
|
+
seen = []
|
|
876
|
+
|
|
877
|
+
def receive(request: httpx.Request) -> httpx.Response:
|
|
878
|
+
seen.append(request)
|
|
879
|
+
return httpx.Response(202, json={"outcome": "received"})
|
|
880
|
+
|
|
881
|
+
await client._http.aclose()
|
|
882
|
+
client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(receive))
|
|
883
|
+
async with client:
|
|
884
|
+
if use_handle:
|
|
885
|
+
handle = client.get_workflow_handle("invoice-42", run_id="original-run")
|
|
886
|
+
await handle.signal("payment_recorded", ["payment-17"], request_id="payment:17")
|
|
887
|
+
else:
|
|
888
|
+
await client.signal_workflow("invoice-42", "payment_recorded", args=["payment-17"],
|
|
889
|
+
run_id="original-run", request_id="payment:17")
|
|
890
|
+
assert len(seen) == 1
|
|
891
|
+
assert seen[0].url.path == "/api/workflows/invoice-42/runs/original-run/signal/payment_recorded"
|
|
892
|
+
body = json.loads(seen[0].content)
|
|
893
|
+
assert body["request_id"] == "payment:17"
|
|
894
|
+
assert serializer.decode_envelope(body["input"]) == ["payment-17"]
|
|
895
|
+
|
|
896
|
+
@pytest.mark.asyncio
|
|
897
|
+
@pytest.mark.parametrize("run_id", ["", False, 1])
|
|
898
|
+
async def test_invalid_run_pin_never_falls_back_to_current(self, client: Client, run_id: Any) -> None:
|
|
899
|
+
with (
|
|
900
|
+
patch.object(client._http, "request", new_callable=AsyncMock) as request,
|
|
901
|
+
pytest.raises(ValueError, match="run_id"),
|
|
902
|
+
):
|
|
903
|
+
await client.signal_workflow("invoice-42", "payment_recorded", run_id=run_id)
|
|
904
|
+
request.assert_not_awaited()
|
|
905
|
+
await client.aclose()
|
|
906
|
+
|
|
872
907
|
@pytest.mark.asyncio
|
|
873
908
|
async def test_signal(self, client: Client) -> None:
|
|
874
909
|
resp = _mock_response(200, {"ok": True})
|
|
@@ -19,6 +19,7 @@ from durable_workflow.errors import ActivityCancelled, NonRetryableError, Server
|
|
|
19
19
|
from durable_workflow.retry_policy import TransportRetryPolicy
|
|
20
20
|
from durable_workflow.worker import Worker
|
|
21
21
|
from tests.test_activity_process import wait_for_exit, wait_for_file
|
|
22
|
+
from tests.test_activity_retry_info import inspect_activity_context
|
|
22
23
|
from tests.test_worker import compatible_cluster_info
|
|
23
24
|
|
|
24
25
|
|
|
@@ -205,6 +206,25 @@ async def test_authored_heartbeat_stays_on_owner_loop_and_preserves_typed_result
|
|
|
205
206
|
client.fail_activity_task.assert_not_awaited()
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
@pytest.mark.parametrize("configured", [False, True])
|
|
210
|
+
async def test_supervised_worker_preserves_received_context(owner: Any, configured: bool) -> None:
|
|
211
|
+
worker, client = owner
|
|
212
|
+
worker.activities["remote"] = inspect_activity_context
|
|
213
|
+
work = task()
|
|
214
|
+
if configured:
|
|
215
|
+
work.update(attempt_number=3, workflow_id="0", run_id="017",
|
|
216
|
+
retry_policy={"max_attempts": 3, "future": [True, None]})
|
|
217
|
+
assert await worker._run_activity_task(work) == "completed"
|
|
218
|
+
client.fail_activity_task.assert_not_awaited()
|
|
219
|
+
assert client.complete_activity_task.await_args.kwargs["result"] == {
|
|
220
|
+
"workflow_id": "0" if configured else None, "run_id": "017" if configured else None,
|
|
221
|
+
"attempt": 3 if configured else 1, "policy_provided": configured,
|
|
222
|
+
"policy": {"max_attempts": 3, "future": [True, None]} if configured else None,
|
|
223
|
+
"maximum": 3 if configured else None, "unlimited": False, "last": True if configured else None,
|
|
224
|
+
}
|
|
225
|
+
assert not worker._remote_activity_processes
|
|
226
|
+
|
|
227
|
+
|
|
208
228
|
@pytest.mark.parametrize("field", ["lease", "heartbeat", "start_to_close", "schedule_to_close", "session", "malformed"])
|
|
209
229
|
async def test_invalid_or_elapsed_observed_bounds_prevent_callback_start(owner: Any, field: str) -> None:
|
|
210
230
|
worker, client = owner
|