durable-workflow 2.6.1__tar.gz → 2.7.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.6.1/src/durable_workflow.egg-info → durable_workflow-2.7.0}/PKG-INFO +4 -1
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/pyproject.toml +7 -3
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_activity_process.py +6 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/activity.py +28 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/client.py +160 -17
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/errors.py +91 -3
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/interceptors.py +9 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/retry_policy.py +18 -6
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/sync.py +23 -4
- durable_workflow-2.7.0/src/durable_workflow/telemetry.py +341 -0
- durable_workflow-2.7.0/src/durable_workflow/telemetry_carrier.py +223 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/worker.py +114 -15
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/workflow.py +128 -15
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/workflow_updates_conformance.py +66 -8
- {durable_workflow-2.6.1 → durable_workflow-2.7.0/src/durable_workflow.egg-info}/PKG-INFO +4 -1
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/SOURCES.txt +9 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/requires.txt +4 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_client.py +246 -3
- durable_workflow-2.7.0/tests/test_client_telemetry.py +93 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_errors.py +73 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_replay_regression_corpus.py +92 -2
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_retry_policy.py +12 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_sync.py +25 -3
- durable_workflow-2.7.0/tests/test_sync_activities.py +197 -0
- durable_workflow-2.7.0/tests/test_telemetry.py +293 -0
- durable_workflow-2.7.0/tests/test_telemetry_carrier.py +158 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_worker.py +7 -3
- durable_workflow-2.7.0/tests/test_worker_telemetry_wire.py +205 -0
- durable_workflow-2.7.0/tests/test_workflow_entry_arguments.py +141 -0
- durable_workflow-2.7.0/tests/test_workflow_handler_validation.py +150 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_workflow_updates_conformance.py +10 -1
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/LICENSE +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/README.md +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/setup.cfg +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.6.1 → durable_workflow-2.7.0}/tests/test_workflow_streams.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: durable-workflow
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
|
|
5
5
|
Author: Durable Workflow Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Requires-Dist: httpx>=0.27
|
|
23
23
|
Requires-Dist: fastavro<2,>=1.12.2
|
|
24
|
+
Provides-Extra: telemetry
|
|
25
|
+
Requires-Dist: opentelemetry-api<2,>=1.45.1; extra == "telemetry"
|
|
24
26
|
Provides-Extra: dev
|
|
25
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
28
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -29,6 +31,7 @@ Requires-Dist: playwright<2,>=1.48; extra == "dev"
|
|
|
29
31
|
Requires-Dist: PyYAML>=6.0; extra == "dev"
|
|
30
32
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
31
33
|
Requires-Dist: tomli>=2; python_version < "3.11" and extra == "dev"
|
|
34
|
+
Requires-Dist: opentelemetry-sdk==1.45.1; extra == "dev"
|
|
32
35
|
Provides-Extra: prometheus
|
|
33
36
|
Requires-Dist: prometheus-client>=0.20; extra == "prometheus"
|
|
34
37
|
Provides-Extra: docs
|
|
@@ -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.7.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"
|
|
@@ -38,6 +38,9 @@ dependencies = [
|
|
|
38
38
|
]
|
|
39
39
|
|
|
40
40
|
[project.optional-dependencies]
|
|
41
|
+
telemetry = [
|
|
42
|
+
"opentelemetry-api>=1.45.1,<2",
|
|
43
|
+
]
|
|
41
44
|
dev = [
|
|
42
45
|
"pytest>=8.0",
|
|
43
46
|
"pytest-asyncio>=0.23",
|
|
@@ -46,6 +49,7 @@ dev = [
|
|
|
46
49
|
"PyYAML>=6.0",
|
|
47
50
|
"ruff>=0.4",
|
|
48
51
|
"tomli>=2; python_version < '3.11'",
|
|
52
|
+
"opentelemetry-sdk==1.45.1",
|
|
49
53
|
]
|
|
50
54
|
prometheus = [
|
|
51
55
|
"prometheus-client>=0.20",
|
|
@@ -71,8 +75,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
75
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
76
|
|
|
73
77
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.
|
|
75
|
-
registry-version = "2.
|
|
78
|
+
product-train = "2.7.0"
|
|
79
|
+
registry-version = "2.7.0"
|
|
76
80
|
supported-server-versions = "2.8.0"
|
|
77
81
|
worker-protocol-version = "1.20"
|
|
78
82
|
control-plane-version = "2"
|
|
@@ -103,6 +103,7 @@ from .errors import (
|
|
|
103
103
|
UpdateValidationFailed,
|
|
104
104
|
WorkflowAlreadyStarted,
|
|
105
105
|
WorkflowCancelled,
|
|
106
|
+
WorkflowEntryArgumentsMismatchError,
|
|
106
107
|
WorkflowFailed,
|
|
107
108
|
WorkflowNotFound,
|
|
108
109
|
WorkflowPayloadDecodeError,
|
|
@@ -319,6 +320,7 @@ __all__ = [
|
|
|
319
320
|
"WorkflowHandle",
|
|
320
321
|
"WorkflowList",
|
|
321
322
|
"WorkflowPayloadDecodeError",
|
|
323
|
+
"WorkflowEntryArgumentsMismatchError",
|
|
322
324
|
"WorkflowRun",
|
|
323
325
|
"WorkflowRunList",
|
|
324
326
|
"WorkflowStreamAppendItem",
|
|
@@ -40,6 +40,10 @@ class CallbackInvocation:
|
|
|
40
40
|
info: ActivityInfo
|
|
41
41
|
task: dict[str, Any]
|
|
42
42
|
interceptors: tuple[WorkerInterceptor, ...] = ()
|
|
43
|
+
namespace: str | None = None
|
|
44
|
+
observability_supported: bool = False
|
|
45
|
+
remote: bool = False
|
|
46
|
+
execution_context: dict[str, Any] | None = None
|
|
43
47
|
|
|
44
48
|
def serialize(self) -> bytes:
|
|
45
49
|
try:
|
|
@@ -100,6 +104,8 @@ def _callback(directory: str, channel: socket.socket) -> None:
|
|
|
100
104
|
context = ActivityInterceptorContext(
|
|
101
105
|
worker_id=invocation.info.worker_id, task_queue=invocation.info.task_queue,
|
|
102
106
|
task=invocation.task, activity_type=invocation.info.activity_type, args=invocation.args,
|
|
107
|
+
namespace=invocation.namespace, observability_supported=invocation.observability_supported,
|
|
108
|
+
remote=invocation.remote, execution_context=invocation.execution_context,
|
|
103
109
|
)
|
|
104
110
|
|
|
105
111
|
async def call(ctx: ActivityInterceptorContext) -> Any:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Activity decorator, registry, and execution context."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import asyncio
|
|
4
5
|
import contextvars
|
|
5
6
|
from collections.abc import Awaitable, Callable
|
|
6
7
|
from dataclasses import dataclass
|
|
@@ -44,6 +45,10 @@ class ActivityContext:
|
|
|
44
45
|
self._client = client
|
|
45
46
|
self._heartbeat_callback = heartbeat_callback
|
|
46
47
|
self._cancel_requested = False
|
|
48
|
+
try:
|
|
49
|
+
self._event_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
|
|
50
|
+
except RuntimeError:
|
|
51
|
+
self._event_loop = None
|
|
47
52
|
|
|
48
53
|
@property
|
|
49
54
|
def info(self) -> ActivityInfo:
|
|
@@ -55,6 +60,29 @@ class ActivityContext:
|
|
|
55
60
|
"""``True`` once the server has signalled that the owning workflow cancelled this activity."""
|
|
56
61
|
return self._cancel_requested
|
|
57
62
|
|
|
63
|
+
def heartbeat_sync(self, details: dict[str, Any] | None = None) -> None:
|
|
64
|
+
"""Heartbeat from a synchronous activity thread and observe cancellation.
|
|
65
|
+
|
|
66
|
+
The request runs on the worker's event loop using the original attempt
|
|
67
|
+
and client. Async handlers must use ``await heartbeat()`` instead.
|
|
68
|
+
"""
|
|
69
|
+
loop = self._event_loop
|
|
70
|
+
if loop is None or not loop.is_running():
|
|
71
|
+
raise RuntimeError("heartbeat_sync requires a running worker event loop")
|
|
72
|
+
try:
|
|
73
|
+
caller_loop = asyncio.get_running_loop()
|
|
74
|
+
except RuntimeError:
|
|
75
|
+
caller_loop = None
|
|
76
|
+
if caller_loop is loop:
|
|
77
|
+
raise RuntimeError("use await activity.context().heartbeat() on the worker event loop")
|
|
78
|
+
heartbeat = self.heartbeat(details)
|
|
79
|
+
try:
|
|
80
|
+
pending = asyncio.run_coroutine_threadsafe(heartbeat, loop)
|
|
81
|
+
except RuntimeError:
|
|
82
|
+
heartbeat.close()
|
|
83
|
+
raise
|
|
84
|
+
pending.result()
|
|
85
|
+
|
|
58
86
|
async def heartbeat(self, details: dict[str, Any] | None = None) -> None:
|
|
59
87
|
"""Report liveness to the server and check for a cancellation request.
|
|
60
88
|
|
|
@@ -19,6 +19,7 @@ methods without repeating the id on every call.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
|
+
import contextlib
|
|
22
23
|
import copy
|
|
23
24
|
import hashlib
|
|
24
25
|
import json as json_module
|
|
@@ -47,6 +48,7 @@ from ._cancellation_scope_history import (
|
|
|
47
48
|
from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
|
|
48
49
|
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
49
50
|
from .errors import (
|
|
51
|
+
REQUEST_ID_CONFLICT,
|
|
50
52
|
ExternalPayloadIntegrityMismatch,
|
|
51
53
|
ExternalPayloadOversized,
|
|
52
54
|
ExternalPayloadUnavailable,
|
|
@@ -55,6 +57,8 @@ from .errors import (
|
|
|
55
57
|
RuntimeCapabilityUnsupported,
|
|
56
58
|
RuntimeDiscoveryUnavailable,
|
|
57
59
|
ServerError,
|
|
60
|
+
SignalFailed,
|
|
61
|
+
UpdateFailed,
|
|
58
62
|
WorkflowCancelled,
|
|
59
63
|
WorkflowFailed,
|
|
60
64
|
WorkflowTerminated,
|
|
@@ -71,6 +75,8 @@ from .external_storage import (
|
|
|
71
75
|
from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
|
|
72
76
|
from .nexus import NexusOperationResult, nexus_request_payload
|
|
73
77
|
from .retry_policy import TransportRetryPolicy
|
|
78
|
+
from .telemetry import ExecutionTelemetry, current_execution_telemetry
|
|
79
|
+
from .telemetry_carrier import supported as observability_supported
|
|
74
80
|
from .worker_session import WorkerSessionOptions
|
|
75
81
|
|
|
76
82
|
PROTOCOL_VERSION = "1.20"
|
|
@@ -239,6 +245,49 @@ def _normalize_base_url(base_url: str) -> str:
|
|
|
239
245
|
return base_url.rstrip("/")
|
|
240
246
|
|
|
241
247
|
|
|
248
|
+
def _command_request_id(request_id: str | None, command: str) -> str:
|
|
249
|
+
"""Return the caller command key for one logical signal or update call.
|
|
250
|
+
|
|
251
|
+
The key is the body field ``request_id`` (1-255 opaque, case-sensitive
|
|
252
|
+
bytes). A caller-supplied key is sent verbatim; otherwise one
|
|
253
|
+
``sdk-python-<command>-<uuid4 hex>`` key is generated per logical call so
|
|
254
|
+
the transport retry policy resends the identical key.
|
|
255
|
+
"""
|
|
256
|
+
return request_id or f"sdk-python-{command}-{uuid.uuid4().hex}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _attach_request_id(exc: BaseException, request_id: str) -> None:
|
|
260
|
+
"""Record the sent command key on an exception as ``exc.request_id``.
|
|
261
|
+
|
|
262
|
+
Typed SDK failures declare the attribute; transport errors raised by
|
|
263
|
+
``httpx`` receive it dynamically so a manual retry can reuse the key.
|
|
264
|
+
"""
|
|
265
|
+
with contextlib.suppress(AttributeError, TypeError):
|
|
266
|
+
exc.request_id = request_id # type: ignore[attr-defined]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _command_request_failure(exc: Exception, *, command: str, request_id: str) -> Exception:
|
|
270
|
+
"""Return the exception a failed signal or update call should raise.
|
|
271
|
+
|
|
272
|
+
A ``request_id_conflict`` refusal that reached the caller as a generic
|
|
273
|
+
:class:`ServerError` becomes the command's typed failure. Every other
|
|
274
|
+
exception is returned unchanged, annotated with the key that was sent.
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(exc, ServerError) and exc.reason() == REQUEST_ID_CONFLICT:
|
|
277
|
+
message = exc.body.get("message") if isinstance(exc.body, dict) else None
|
|
278
|
+
if not isinstance(message, str) or not message:
|
|
279
|
+
message = f"{command} request id already retained with a different intent"
|
|
280
|
+
if command == "update" and isinstance(exc.body, dict):
|
|
281
|
+
return UpdateFailed(
|
|
282
|
+
message, status=exc.status, body=exc.body, reason=REQUEST_ID_CONFLICT, request_id=request_id,
|
|
283
|
+
)
|
|
284
|
+
return SignalFailed(
|
|
285
|
+
message, reason=REQUEST_ID_CONFLICT, status=exc.status, body=exc.body, request_id=request_id,
|
|
286
|
+
)
|
|
287
|
+
_attach_request_id(exc, request_id)
|
|
288
|
+
return exc
|
|
289
|
+
|
|
290
|
+
|
|
242
291
|
def _route_for_metrics(path: str) -> str:
|
|
243
292
|
clean_path = path.split("?", 1)[0]
|
|
244
293
|
parts = [part for part in clean_path.strip("/").split("/") if part]
|
|
@@ -1346,9 +1395,17 @@ class WorkflowHandle:
|
|
|
1346
1395
|
raise ValueError("run_id is required to describe a workflow run from a handle")
|
|
1347
1396
|
return await self._client.describe_workflow_run(self.workflow_id, selected_run_id)
|
|
1348
1397
|
|
|
1349
|
-
async def signal(
|
|
1398
|
+
async def signal(
|
|
1399
|
+
self,
|
|
1400
|
+
signal_name: str,
|
|
1401
|
+
args: list[Any] | None = None,
|
|
1402
|
+
*,
|
|
1403
|
+
request_id: str | None = None,
|
|
1404
|
+
) -> dict[str, Any]:
|
|
1350
1405
|
"""Deliver an external signal to this workflow. See :meth:`Client.signal_workflow`."""
|
|
1351
|
-
await self._client.signal_workflow(
|
|
1406
|
+
return await self._client.signal_workflow(
|
|
1407
|
+
self.workflow_id, signal_name, args=args, request_id=request_id,
|
|
1408
|
+
)
|
|
1352
1409
|
|
|
1353
1410
|
async def append_message(
|
|
1354
1411
|
self,
|
|
@@ -1592,12 +1649,15 @@ class Client:
|
|
|
1592
1649
|
external_storage: ExternalStorageDriver | None = None,
|
|
1593
1650
|
external_storage_threshold_bytes: int | None = None,
|
|
1594
1651
|
external_storage_cache: ExternalPayloadCache | None = None,
|
|
1652
|
+
telemetry: ExecutionTelemetry | None = None,
|
|
1595
1653
|
) -> None:
|
|
1596
1654
|
self.base_url = _normalize_base_url(base_url)
|
|
1597
1655
|
self.token = token
|
|
1598
1656
|
self.control_token = control_token
|
|
1599
1657
|
self.worker_token = worker_token
|
|
1600
1658
|
self.namespace = namespace
|
|
1659
|
+
self.telemetry = telemetry
|
|
1660
|
+
self._observability_capability = False
|
|
1601
1661
|
self.timeout = timeout
|
|
1602
1662
|
self.retry_policy = retry_policy or TransportRetryPolicy()
|
|
1603
1663
|
self.metrics = metrics or NOOP_METRICS
|
|
@@ -2603,6 +2663,7 @@ class Client:
|
|
|
2603
2663
|
credential. Other client methods continue to require the credential
|
|
2604
2664
|
for their own protocol plane.
|
|
2605
2665
|
"""
|
|
2666
|
+
self._observability_capability = False
|
|
2606
2667
|
result = await self._request("GET", "/cluster/info", discovery=True, context="get_cluster_info")
|
|
2607
2668
|
if not isinstance(result, dict):
|
|
2608
2669
|
raise ServerError(
|
|
@@ -2610,8 +2671,16 @@ class Client:
|
|
|
2610
2671
|
{"reason": "invalid_cluster_info", "message": f"expected JSON object, got {type(result).__name__}"},
|
|
2611
2672
|
)
|
|
2612
2673
|
self._cluster_info = result
|
|
2674
|
+
self._observability_capability = observability_supported(result)
|
|
2613
2675
|
return result
|
|
2614
2676
|
|
|
2677
|
+
def _with_observability_context(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
2678
|
+
telemetry = self.telemetry or current_execution_telemetry()
|
|
2679
|
+
if telemetry is None or not self._observability_capability:
|
|
2680
|
+
return body
|
|
2681
|
+
carrier = telemetry.capture()
|
|
2682
|
+
return body if carrier is None else {**body, "observability_context": carrier}
|
|
2683
|
+
|
|
2615
2684
|
async def _runtime_discovery(
|
|
2616
2685
|
self,
|
|
2617
2686
|
*,
|
|
@@ -2783,6 +2852,7 @@ class Client:
|
|
|
2783
2852
|
# provider credentials. The worker keeps using the original client.
|
|
2784
2853
|
scoped.worker_token = None
|
|
2785
2854
|
scoped._cluster_info = None
|
|
2855
|
+
scoped._observability_capability = False
|
|
2786
2856
|
scoped._cluster_info_lock = asyncio.Lock()
|
|
2787
2857
|
scoped._runtime_external_payload_transport_cache = None
|
|
2788
2858
|
scoped._runtime_external_payload_transport_resolved = False
|
|
@@ -3599,7 +3669,9 @@ class Client:
|
|
|
3599
3669
|
body["build_id"] = build_id
|
|
3600
3670
|
if compatibility is not None:
|
|
3601
3671
|
body["compatibility"] = compatibility
|
|
3602
|
-
data = await self._request(
|
|
3672
|
+
data = await self._request(
|
|
3673
|
+
"POST", "/workflows", json=self._with_observability_context(body), context=workflow_id,
|
|
3674
|
+
)
|
|
3603
3675
|
return WorkflowHandle(
|
|
3604
3676
|
self,
|
|
3605
3677
|
workflow_id=data["workflow_id"],
|
|
@@ -4303,15 +4375,38 @@ class Client:
|
|
|
4303
4375
|
return BridgeAdapterOutcome.from_dict(data)
|
|
4304
4376
|
|
|
4305
4377
|
async def signal_workflow(
|
|
4306
|
-
self,
|
|
4307
|
-
|
|
4378
|
+
self,
|
|
4379
|
+
workflow_id: str,
|
|
4380
|
+
signal_name: str,
|
|
4381
|
+
*,
|
|
4382
|
+
args: list[Any] | None = None,
|
|
4383
|
+
request_id: str | None = None,
|
|
4384
|
+
) -> dict[str, Any]:
|
|
4308
4385
|
"""Deliver an external signal to a running workflow.
|
|
4309
4386
|
|
|
4310
4387
|
Signals are fire-and-forget: the server records the signal in durable
|
|
4311
4388
|
history and returns immediately. They do not wait for the workflow to
|
|
4312
4389
|
observe the signal. See the main docs for how to declare the allowed
|
|
4313
4390
|
signal names on a workflow type.
|
|
4391
|
+
|
|
4392
|
+
``request_id`` is the caller command key (1-255 opaque, case-sensitive
|
|
4393
|
+
bytes) that makes a retry of this signal safe. When omitted the client
|
|
4394
|
+
generates ``sdk-python-signal-<uuid>`` once per call, so the transport
|
|
4395
|
+
retry policy resends the same key. Resending the same key with the same
|
|
4396
|
+
signal name, arguments and run target returns the original receipt
|
|
4397
|
+
instead of recording a second signal.
|
|
4398
|
+
|
|
4399
|
+
Returns the Server's admission receipt. ``request_id`` is always
|
|
4400
|
+
present (the key that was sent); a Server that retains command keys
|
|
4401
|
+
also reports ``retained`` (``True`` when an earlier admission was
|
|
4402
|
+
returned) and ``intent_verified``. Raises
|
|
4403
|
+
:class:`~durable_workflow.errors.SignalFailed` with
|
|
4404
|
+
``reason == "request_id_conflict"`` when the key was already retained
|
|
4405
|
+
for a signal with a different intent; the retained command is described
|
|
4406
|
+
in its ``details``. Every exception raised after the key was chosen,
|
|
4407
|
+
including transport errors, carries it as ``request_id``.
|
|
4314
4408
|
"""
|
|
4409
|
+
key = _command_request_id(request_id, "signal")
|
|
4315
4410
|
body: dict[str, Any] = {}
|
|
4316
4411
|
if args:
|
|
4317
4412
|
body["input"] = self._payload_envelope(
|
|
@@ -4320,7 +4415,20 @@ class Client:
|
|
|
4320
4415
|
workflow_id=workflow_id,
|
|
4321
4416
|
signal_name=signal_name,
|
|
4322
4417
|
)
|
|
4323
|
-
|
|
4418
|
+
body["request_id"] = key
|
|
4419
|
+
try:
|
|
4420
|
+
data = await self._request(
|
|
4421
|
+
"POST", f"/workflows/{workflow_id}/signal/{signal_name}",
|
|
4422
|
+
json=self._with_observability_context(body), context=workflow_id,
|
|
4423
|
+
)
|
|
4424
|
+
except Exception as exc:
|
|
4425
|
+
failure = _command_request_failure(exc, command="signal", request_id=key)
|
|
4426
|
+
if failure is exc:
|
|
4427
|
+
raise
|
|
4428
|
+
raise failure from exc
|
|
4429
|
+
receipt: dict[str, Any] = data if isinstance(data, dict) else {}
|
|
4430
|
+
receipt.setdefault("request_id", key)
|
|
4431
|
+
return receipt
|
|
4324
4432
|
|
|
4325
4433
|
async def append_message_stream(
|
|
4326
4434
|
self,
|
|
@@ -4430,7 +4538,10 @@ class Client:
|
|
|
4430
4538
|
body: dict[str, Any] = {}
|
|
4431
4539
|
if reason is not None:
|
|
4432
4540
|
body["reason"] = reason
|
|
4433
|
-
await self._request(
|
|
4541
|
+
await self._request(
|
|
4542
|
+
"POST", f"/workflows/{workflow_id}/cancel",
|
|
4543
|
+
json=self._with_observability_context(body), context=workflow_id,
|
|
4544
|
+
)
|
|
4434
4545
|
|
|
4435
4546
|
async def terminate_workflow(self, workflow_id: str, *, reason: str | None = None) -> None:
|
|
4436
4547
|
"""Close the current run as terminated immediately.
|
|
@@ -4496,15 +4607,28 @@ class Client:
|
|
|
4496
4607
|
Validator rejection and validation-routing failures remain typed and
|
|
4497
4608
|
do not silently fall back to post-accept handler failure.
|
|
4498
4609
|
|
|
4499
|
-
``request_id``
|
|
4500
|
-
|
|
4501
|
-
|
|
4610
|
+
``request_id`` is the caller command key (1-255 opaque, case-sensitive
|
|
4611
|
+
bytes) that makes a retry of this update safe. When omitted the client
|
|
4612
|
+
generates ``sdk-python-update-<uuid>`` once per call, so the transport
|
|
4613
|
+
retry policy resends the same key. Resending the same key with the same
|
|
4614
|
+
update name, arguments and run target returns the original receipt
|
|
4615
|
+
(``retained`` is ``True`` on a Server that reports it) instead of
|
|
4616
|
+
admitting a second update. The returned response always carries the
|
|
4617
|
+
``request_id`` that was sent.
|
|
4618
|
+
|
|
4619
|
+
Raises :class:`~durable_workflow.errors.UpdateRejected` when the
|
|
4620
|
+
workflow's validator rejects the update, or
|
|
4502
4621
|
:class:`~durable_workflow.errors.UpdateValidationFailed` when the
|
|
4503
4622
|
declared validation boundary cannot be enforced. An accepted update
|
|
4504
4623
|
whose handler fails raises :class:`~durable_workflow.errors.UpdateFailed`
|
|
4505
|
-
with the failure message, Server response and durable identities
|
|
4624
|
+
with the failure message, Server response and durable identities; the
|
|
4625
|
+
same type with ``reason == "request_id_conflict"`` reports a key that
|
|
4626
|
+
was already retained for an update with a different intent, described
|
|
4627
|
+
in its ``details``. Every exception raised after the key was chosen,
|
|
4628
|
+
including transport errors, carries it as ``request_id``.
|
|
4506
4629
|
"""
|
|
4507
4630
|
await self._require_update_wait_stage(wait_for or "accepted")
|
|
4631
|
+
key = _command_request_id(request_id, "update")
|
|
4508
4632
|
body: dict[str, Any] = {}
|
|
4509
4633
|
if args:
|
|
4510
4634
|
body["input"] = self._payload_envelope(
|
|
@@ -4517,10 +4641,20 @@ class Client:
|
|
|
4517
4641
|
body["wait_for"] = wait_for
|
|
4518
4642
|
if wait_timeout_seconds is not None:
|
|
4519
4643
|
body["wait_timeout_seconds"] = wait_timeout_seconds
|
|
4520
|
-
body["request_id"] =
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4644
|
+
body["request_id"] = key
|
|
4645
|
+
try:
|
|
4646
|
+
data = await self._request(
|
|
4647
|
+
"POST", f"/workflows/{workflow_id}/update/{update_name}",
|
|
4648
|
+
json=self._with_observability_context(body), context=workflow_id,
|
|
4649
|
+
)
|
|
4650
|
+
except Exception as exc:
|
|
4651
|
+
failure = _command_request_failure(exc, command="update", request_id=key)
|
|
4652
|
+
if failure is exc:
|
|
4653
|
+
raise
|
|
4654
|
+
raise failure from exc
|
|
4655
|
+
if isinstance(data, dict):
|
|
4656
|
+
data.setdefault("request_id", key)
|
|
4657
|
+
return data
|
|
4524
4658
|
|
|
4525
4659
|
async def get_result(
|
|
4526
4660
|
self,
|
|
@@ -5291,7 +5425,11 @@ class Client:
|
|
|
5291
5425
|
body: dict[str, Any] = {
|
|
5292
5426
|
"lease_owner": lease_owner,
|
|
5293
5427
|
"workflow_task_attempt": workflow_task_attempt,
|
|
5294
|
-
"commands":
|
|
5428
|
+
"commands": [
|
|
5429
|
+
self._with_observability_context(command)
|
|
5430
|
+
if command.get("type") in {"start_child_workflow", "continue_as_new"} else command
|
|
5431
|
+
for command in commands
|
|
5432
|
+
] if self._observability_capability else commands,
|
|
5295
5433
|
}
|
|
5296
5434
|
if message_stream_cursors:
|
|
5297
5435
|
body["message_stream_cursors"] = message_stream_cursors
|
|
@@ -5312,15 +5450,20 @@ class Client:
|
|
|
5312
5450
|
message: str,
|
|
5313
5451
|
failure_type: str | None = None,
|
|
5314
5452
|
stack_trace: str | None = None,
|
|
5453
|
+
failure_reason: str | None = None,
|
|
5315
5454
|
) -> Any:
|
|
5316
|
-
"""Report a workflow task failure
|
|
5455
|
+
"""Report a workflow task failure without closing the run.
|
|
5317
5456
|
|
|
5318
5457
|
Worker-plane endpoint. Task failures (e.g. non-determinism) are
|
|
5319
5458
|
distinct from workflow failures (``FailWorkflow`` commands).
|
|
5459
|
+
The runtime may block incompatible replay until compatible code and
|
|
5460
|
+
a supported repair are available instead of automatically retrying it.
|
|
5320
5461
|
"""
|
|
5321
5462
|
failure: dict[str, Any] = {"message": message}
|
|
5322
5463
|
if failure_type is not None:
|
|
5323
5464
|
failure["type"] = failure_type
|
|
5465
|
+
if failure_reason is not None:
|
|
5466
|
+
failure["reason"] = failure_reason
|
|
5324
5467
|
if stack_trace is not None:
|
|
5325
5468
|
failure["stack_trace"] = stack_trace
|
|
5326
5469
|
body: dict[str, Any] = {
|
|
@@ -412,8 +412,45 @@ class QueryFailed(DurableWorkflowError):
|
|
|
412
412
|
return None
|
|
413
413
|
|
|
414
414
|
|
|
415
|
+
REQUEST_ID_CONFLICT = "request_id_conflict"
|
|
416
|
+
"""Reason reported when a command key is reused with a different intent."""
|
|
417
|
+
|
|
418
|
+
_REQUEST_ID_CONFLICT_FIELDS = (
|
|
419
|
+
"request_id",
|
|
420
|
+
"retained_command_id",
|
|
421
|
+
"retained_command_type",
|
|
422
|
+
"retained_outcome",
|
|
423
|
+
"conflict",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _body_string(body: object, name: str) -> str | None:
|
|
428
|
+
if isinstance(body, dict):
|
|
429
|
+
value = body.get(name)
|
|
430
|
+
if isinstance(value, str) and value:
|
|
431
|
+
return value
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _request_id_conflict_details(body: object) -> dict[str, Any]:
|
|
436
|
+
"""Return the retained-command fields of a ``request_id_conflict`` response body."""
|
|
437
|
+
if not isinstance(body, dict) or body.get("reason") != REQUEST_ID_CONFLICT:
|
|
438
|
+
return {}
|
|
439
|
+
return {name: body[name] for name in _REQUEST_ID_CONFLICT_FIELDS if name in body}
|
|
440
|
+
|
|
441
|
+
|
|
415
442
|
class SignalFailed(DurableWorkflowError):
|
|
416
|
-
"""A workflow signal was rejected before it could be delivered.
|
|
443
|
+
"""A workflow signal was rejected before it could be delivered.
|
|
444
|
+
|
|
445
|
+
:attr:`request_id` is the caller command key the client sent with the
|
|
446
|
+
signal (caller-supplied or SDK-generated), so the application can retry
|
|
447
|
+
the same logical signal with the same key. :attr:`reason` is the Server's
|
|
448
|
+
machine-readable refusal reason; ``request_id_conflict`` means the key was
|
|
449
|
+
already retained for a signal with a different intent, and
|
|
450
|
+
:attr:`details` then carries ``retained_command_id``,
|
|
451
|
+
``retained_command_type``, ``retained_outcome`` and the mismatched
|
|
452
|
+
``conflict`` parts.
|
|
453
|
+
"""
|
|
417
454
|
|
|
418
455
|
def __init__(
|
|
419
456
|
self,
|
|
@@ -422,11 +459,15 @@ class SignalFailed(DurableWorkflowError):
|
|
|
422
459
|
reason: str | None = None,
|
|
423
460
|
status: int | None = None,
|
|
424
461
|
body: object | None = None,
|
|
462
|
+
request_id: str | None = None,
|
|
463
|
+
details: dict[str, Any] | None = None,
|
|
425
464
|
) -> None:
|
|
426
465
|
super().__init__(message)
|
|
427
466
|
self.reason = reason
|
|
428
467
|
self.status = status
|
|
429
468
|
self.body = body
|
|
469
|
+
self.request_id = request_id if request_id is not None else _body_string(body, "request_id")
|
|
470
|
+
self.details: dict[str, Any] = dict(details) if details is not None else _request_id_conflict_details(body)
|
|
430
471
|
|
|
431
472
|
@property
|
|
432
473
|
def validation_errors(self) -> dict[str, Any] | None:
|
|
@@ -465,6 +506,21 @@ class WorkflowPayloadDecodeError(DurableWorkflowError):
|
|
|
465
506
|
self.exception_type = exception_type
|
|
466
507
|
|
|
467
508
|
|
|
509
|
+
class WorkflowEntryArgumentsMismatchError(DurableWorkflowError):
|
|
510
|
+
"""Recorded input cannot bind to the deployed entry method before it runs."""
|
|
511
|
+
|
|
512
|
+
reason = "workflow_entry_arguments_mismatch"
|
|
513
|
+
|
|
514
|
+
def __init__(self, workflow_type: str, binding_reason: str) -> None:
|
|
515
|
+
self.workflow_type = workflow_type
|
|
516
|
+
self.entry_method = "run"
|
|
517
|
+
self.binding_reason = binding_reason
|
|
518
|
+
super().__init__(
|
|
519
|
+
f"{self.reason}: {workflow_type}.run cannot bind the recorded arguments "
|
|
520
|
+
f"({binding_reason}). Restore compatible code and repair the workflow task."
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
468
524
|
class NonDeterministicReplayError(DurableWorkflowError):
|
|
469
525
|
"""Current workflow code yielded a command shape that does not match history."""
|
|
470
526
|
|
|
@@ -493,17 +549,37 @@ class NonDeterministicReplayError(DurableWorkflowError):
|
|
|
493
549
|
|
|
494
550
|
|
|
495
551
|
class UpdateFailed(InvalidArgument):
|
|
496
|
-
"""An accepted workflow update failed
|
|
552
|
+
"""An accepted workflow update failed, or its command key was refused.
|
|
497
553
|
|
|
498
554
|
``body`` preserves the Server response, including its durable failure
|
|
499
555
|
identity. This inherits from :class:`InvalidArgument` to preserve catches
|
|
500
556
|
written for earlier SDK versions that mapped all HTTP 422 responses there.
|
|
557
|
+
|
|
558
|
+
:attr:`request_id` is the caller command key the client sent with the
|
|
559
|
+
update (caller-supplied or SDK-generated). :attr:`reason` is the Server's
|
|
560
|
+
machine-readable reason when it returned one; ``request_id_conflict``
|
|
561
|
+
means the key was already retained for an update with a different intent,
|
|
562
|
+
and :attr:`details` then carries ``retained_command_id``,
|
|
563
|
+
``retained_command_type``, ``retained_outcome`` and the mismatched
|
|
564
|
+
``conflict`` parts.
|
|
501
565
|
"""
|
|
502
566
|
|
|
503
|
-
def __init__(
|
|
567
|
+
def __init__(
|
|
568
|
+
self,
|
|
569
|
+
message: str,
|
|
570
|
+
*,
|
|
571
|
+
status: int,
|
|
572
|
+
body: dict[str, Any],
|
|
573
|
+
reason: str | None = None,
|
|
574
|
+
request_id: str | None = None,
|
|
575
|
+
details: dict[str, Any] | None = None,
|
|
576
|
+
) -> None:
|
|
504
577
|
super().__init__(message)
|
|
505
578
|
self.status = status
|
|
506
579
|
self.body = body
|
|
580
|
+
self.reason = reason if reason is not None else self._identity(body, "reason")
|
|
581
|
+
self.request_id = request_id if request_id is not None else self._identity(body, "request_id")
|
|
582
|
+
self.details: dict[str, Any] = dict(details) if details is not None else _request_id_conflict_details(body)
|
|
507
583
|
self.workflow_id = self._identity(body, "workflow_id")
|
|
508
584
|
self.run_id = self._identity(body, "run_id")
|
|
509
585
|
self.update_id = self._identity(body, "update_id")
|
|
@@ -894,6 +970,18 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
|
|
|
894
970
|
raise UpdateRejected(message or "update rejected", body=body)
|
|
895
971
|
if reason in update_validation_failure_reasons:
|
|
896
972
|
raise update_validation_failed("update validation failed")
|
|
973
|
+
if reason == REQUEST_ID_CONFLICT:
|
|
974
|
+
# The retained command type names the key scope; the Server also
|
|
975
|
+
# labels the control-plane operation. Either selects the typed failure.
|
|
976
|
+
command_type = _body_string(body, "retained_command_type") or operation
|
|
977
|
+
if command_type == "signal":
|
|
978
|
+
raise signal_failed("signal request id already retained with a different intent")
|
|
979
|
+
if command_type == "update" and isinstance(body, dict):
|
|
980
|
+
raise UpdateFailed(
|
|
981
|
+
message or "update request id already retained with a different intent",
|
|
982
|
+
status=status,
|
|
983
|
+
body=body,
|
|
984
|
+
)
|
|
897
985
|
raise ServerError(status, body)
|
|
898
986
|
|
|
899
987
|
if status == 504:
|
|
@@ -14,6 +14,9 @@ class WorkflowTaskInterceptorContext:
|
|
|
14
14
|
worker_id: str
|
|
15
15
|
task_queue: str
|
|
16
16
|
task: dict[str, Any]
|
|
17
|
+
namespace: str | None = None
|
|
18
|
+
observability_supported: bool = False
|
|
19
|
+
defer_observability: bool = False
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
@dataclass(frozen=True)
|
|
@@ -25,6 +28,10 @@ class ActivityInterceptorContext:
|
|
|
25
28
|
task: dict[str, Any]
|
|
26
29
|
activity_type: str
|
|
27
30
|
args: tuple[Any, ...]
|
|
31
|
+
namespace: str | None = None
|
|
32
|
+
observability_supported: bool = False
|
|
33
|
+
remote: bool = False
|
|
34
|
+
execution_context: dict[str, Any] | None = None
|
|
28
35
|
|
|
29
36
|
|
|
30
37
|
@dataclass(frozen=True)
|
|
@@ -34,6 +41,8 @@ class QueryTaskInterceptorContext:
|
|
|
34
41
|
worker_id: str
|
|
35
42
|
task_queue: str
|
|
36
43
|
task: dict[str, Any]
|
|
44
|
+
namespace: str | None = None
|
|
45
|
+
observability_supported: bool = False
|
|
37
46
|
|
|
38
47
|
|
|
39
48
|
WorkflowTaskHandler = Callable[[WorkflowTaskInterceptorContext], Awaitable[list[dict[str, Any]] | None]]
|