durable-workflow 2.5.14__tar.gz → 2.6.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.5.14/src/durable_workflow.egg-info → durable_workflow-2.6.0}/PKG-INFO +9 -1
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/README.md +8 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/pyproject.toml +4 -4
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cooperative_cancellation.py +9 -1
- durable_workflow-2.6.0/src/durable_workflow/_query_task_authority.py +153 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/client.py +85 -17
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/errors.py +9 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/worker.py +107 -130
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/workflow.py +207 -32
- {durable_workflow-2.5.14 → durable_workflow-2.6.0/src/durable_workflow.egg-info}/PKG-INFO +9 -1
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/SOURCES.txt +4 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_ci_checkout.py +39 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_client.py +17 -8
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_condition_replay_drift.py +4 -2
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_cancellation_worker.py +138 -3
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_external_storage.py +8 -4
- durable_workflow-2.6.0/tests/test_nexus_durable_wait.py +241 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_nexus_namespace.py +12 -0
- durable_workflow-2.6.0/tests/test_query_task_authority.py +305 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_regression_corpus.py +63 -1
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_runtime_external_payload_transport.py +3 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_storage_admission.py +16 -6
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_worker.py +42 -82
- durable_workflow-2.6.0/tests/test_worker_observation.py +299 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/LICENSE +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/setup.cfg +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: durable-workflow
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.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
|
|
@@ -40,6 +40,7 @@ Dynamic: license-file
|
|
|
40
40
|
|
|
41
41
|
# Durable Workflow Python SDK
|
|
42
42
|
|
|
43
|
+
[](https://sonarcloud.io/summary/new_code?id=durable-workflow_sdk-python)
|
|
43
44
|
[](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml)
|
|
44
45
|
[](https://pypi.org/project/durable-workflow/)
|
|
45
46
|
[](https://pypi.org/project/durable-workflow/)
|
|
@@ -168,6 +169,13 @@ admission also needs `prepared_local_activities`, with
|
|
|
168
169
|
`prepared_local_activity_cancellation_policies` for explicit local policies.
|
|
169
170
|
Independently cancellable scopes remain disabled.
|
|
170
171
|
|
|
172
|
+
For synchronous Nexus completion waits, the cooperative worker also negotiates
|
|
173
|
+
`nexus_cooperative_cancellation` when Server advertises the installed
|
|
174
|
+
`workflow_run` binding. Owned target cleanup keeps the caller pending until the
|
|
175
|
+
Server commits delivery at the original call. Async and acceptance-only calls
|
|
176
|
+
keep their admission behavior. Existing terminal results and recorded legacy
|
|
177
|
+
Nexus histories retain their replay semantics.
|
|
178
|
+
|
|
171
179
|
The cooperative worker supervises async and synchronous activity callbacks
|
|
172
180
|
independently of application heartbeats. Existing terminal cancellation remains
|
|
173
181
|
available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Durable Workflow Python SDK
|
|
2
2
|
|
|
3
|
+
[](https://sonarcloud.io/summary/new_code?id=durable-workflow_sdk-python)
|
|
3
4
|
[](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml)
|
|
4
5
|
[](https://pypi.org/project/durable-workflow/)
|
|
5
6
|
[](https://pypi.org/project/durable-workflow/)
|
|
@@ -128,6 +129,13 @@ admission also needs `prepared_local_activities`, with
|
|
|
128
129
|
`prepared_local_activity_cancellation_policies` for explicit local policies.
|
|
129
130
|
Independently cancellable scopes remain disabled.
|
|
130
131
|
|
|
132
|
+
For synchronous Nexus completion waits, the cooperative worker also negotiates
|
|
133
|
+
`nexus_cooperative_cancellation` when Server advertises the installed
|
|
134
|
+
`workflow_run` binding. Owned target cleanup keeps the caller pending until the
|
|
135
|
+
Server commits delivery at the original call. Async and acceptance-only calls
|
|
136
|
+
keep their admission behavior. Existing terminal results and recorded legacy
|
|
137
|
+
Nexus histories retain their replay semantics.
|
|
138
|
+
|
|
131
139
|
The cooperative worker supervises async and synchronous activity callbacks
|
|
132
140
|
independently of application heartbeats. Existing terminal cancellation remains
|
|
133
141
|
available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
|
|
@@ -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.6.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"
|
|
@@ -71,9 +71,9 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
71
71
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
72
72
|
|
|
73
73
|
[tool.durable-workflow]
|
|
74
|
-
product-train = "2.
|
|
75
|
-
registry-version = "2.
|
|
76
|
-
supported-server-versions = "2.
|
|
74
|
+
product-train = "2.6.0"
|
|
75
|
+
registry-version = "2.6.0"
|
|
76
|
+
supported-server-versions = "2.8.0"
|
|
77
77
|
worker-protocol-version = "1.20"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -88,6 +88,7 @@ from .errors import (
|
|
|
88
88
|
NonDeterministicReplayError,
|
|
89
89
|
NonRetryableError,
|
|
90
90
|
QueryFailed,
|
|
91
|
+
QueryTaskAuthorityLost,
|
|
91
92
|
RuntimeCapabilityUnsupported,
|
|
92
93
|
RuntimeDiscoveryUnavailable,
|
|
93
94
|
SagaCompensationFailed,
|
|
@@ -375,6 +376,7 @@ __all__ = [
|
|
|
375
376
|
"InvalidWorkflowDefinition",
|
|
376
377
|
"NoopMetrics",
|
|
377
378
|
"QueryFailed",
|
|
379
|
+
"QueryTaskAuthorityLost",
|
|
378
380
|
"RuntimeCapabilityUnsupported",
|
|
379
381
|
"RuntimeDiscoveryUnavailable",
|
|
380
382
|
"RuntimeExternalPayloadReference",
|
{durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cooperative_cancellation.py
RENAMED
|
@@ -16,7 +16,10 @@ from .errors import NonDeterministicReplayError
|
|
|
16
16
|
|
|
17
17
|
REQUEST_EVENT = "CooperativeCancellationRequested"
|
|
18
18
|
DELIVERY_EVENT = "CooperativeCancellationDelivered"
|
|
19
|
-
_CALL_KINDS = {
|
|
19
|
+
_CALL_KINDS = {
|
|
20
|
+
"activity", "local_activity", "timer", "condition", "signal", "child", "parallel", "selection_handle",
|
|
21
|
+
"service_operation",
|
|
22
|
+
}
|
|
20
23
|
_RESOLUTION_EVENTS = {
|
|
21
24
|
"ActivityCompleted",
|
|
22
25
|
"ActivityFailed",
|
|
@@ -31,6 +34,9 @@ _RESOLUTION_EVENTS = {
|
|
|
31
34
|
"ChildRunFailed",
|
|
32
35
|
"ChildRunCancelled",
|
|
33
36
|
"ChildRunTerminated",
|
|
37
|
+
"ServiceCallCompleted",
|
|
38
|
+
"ServiceCallFailed",
|
|
39
|
+
"ServiceCallCancelled",
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
|
|
@@ -265,6 +271,8 @@ def read_cancellation_history(
|
|
|
265
271
|
"ChildRunFailed",
|
|
266
272
|
"ChildRunCancelled",
|
|
267
273
|
"ChildRunTerminated",
|
|
274
|
+
"ServiceCallFailed",
|
|
275
|
+
"ServiceCallCancelled",
|
|
268
276
|
}:
|
|
269
277
|
failed.add(sequence)
|
|
270
278
|
state = CancellationHistory(
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""A query delivery's original, non-renewing execution budget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
from .errors import QueryTaskAuthorityLost
|
|
15
|
+
|
|
16
|
+
_T = TypeVar("_T")
|
|
17
|
+
_UTC6 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\Z")
|
|
18
|
+
_OBSERVATION_INTERVAL_SECONDS = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _timestamp(value: Any, *, canonical: bool) -> datetime:
|
|
22
|
+
if not isinstance(value, str) or (canonical and _UTC6.fullmatch(value) is None):
|
|
23
|
+
raise ValueError("missing or malformed query timing")
|
|
24
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
25
|
+
if parsed.tzinfo is None:
|
|
26
|
+
raise ValueError("query timing must include a timezone")
|
|
27
|
+
return parsed
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class QueryTaskAuthority:
|
|
32
|
+
client: object
|
|
33
|
+
namespace: str
|
|
34
|
+
task_id: str
|
|
35
|
+
attempt: int
|
|
36
|
+
owner: str
|
|
37
|
+
observed: bool
|
|
38
|
+
timing: dict[str, str]
|
|
39
|
+
until: float
|
|
40
|
+
identity: dict[str, Any]
|
|
41
|
+
observer: Callable[[], Awaitable[Mapping[str, Any]]] | None = field(default=None, repr=False)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def delivery(cls, client: object, namespace: str, task: Mapping[str, Any], *,
|
|
45
|
+
owner: str, task_queue: str, observed: bool) -> QueryTaskAuthority:
|
|
46
|
+
task_id, attempt = task.get("query_task_id"), task.get("query_task_attempt")
|
|
47
|
+
if (not isinstance(owner, str) or not owner or not isinstance(task_queue, str) or not task_queue
|
|
48
|
+
or not isinstance(task_id, str) or not task_id or type(attempt) is not int or attempt < 1
|
|
49
|
+
or task.get("lease_owner") != owner or task.get("task_queue") != task_queue
|
|
50
|
+
or task.get("namespace", namespace) != namespace):
|
|
51
|
+
raise QueryTaskAuthorityLost(str(task_id or ""), "invalid_query_claim")
|
|
52
|
+
try:
|
|
53
|
+
lease = _timestamp(task.get("lease_expires_at"), canonical=observed)
|
|
54
|
+
timing = {"lease_expires_at": task["lease_expires_at"]}
|
|
55
|
+
bounds = [lease]
|
|
56
|
+
if observed or "query_admitted_at" in task or "query_deadline_at" in task:
|
|
57
|
+
admitted = _timestamp(task.get("query_admitted_at"), canonical=True)
|
|
58
|
+
deadline = _timestamp(task.get("query_deadline_at"), canonical=True)
|
|
59
|
+
if deadline < admitted or (observed and lease > deadline):
|
|
60
|
+
raise ValueError("inconsistent original query timing")
|
|
61
|
+
timing.update({"query_admitted_at": task["query_admitted_at"],
|
|
62
|
+
"query_deadline_at": task["query_deadline_at"]})
|
|
63
|
+
bounds.append(deadline)
|
|
64
|
+
# Legacy compatibility has no non-renewing observation. Its local
|
|
65
|
+
# clock check cannot establish an undisclosed caller deadline.
|
|
66
|
+
reference = (_timestamp(task.get("server_time"), canonical=True) if observed
|
|
67
|
+
else datetime.now(timezone.utc))
|
|
68
|
+
until = time.monotonic() + (min(bounds) - reference).total_seconds()
|
|
69
|
+
except (ValueError, TypeError, OverflowError) as error:
|
|
70
|
+
raise QueryTaskAuthorityLost(task_id, "invalid_query_timing") from error
|
|
71
|
+
result = cls(client, namespace, task_id, attempt, owner, observed, timing, until,
|
|
72
|
+
{key: task.get(key) for key in ("workflow_id", "run_id", "task_queue")})
|
|
73
|
+
result.remaining()
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def matches(self, client: object, namespace: str, task: Mapping[str, Any], owner: str) -> bool:
|
|
77
|
+
return (self.client is client and self.namespace == namespace and self.owner == owner
|
|
78
|
+
and task.get("query_task_id") == self.task_id
|
|
79
|
+
and type(task.get("query_task_attempt")) is int and task.get("query_task_attempt") == self.attempt
|
|
80
|
+
and task.get("lease_owner") == owner
|
|
81
|
+
and all(task.get(key) == value for key, value in self.identity.items())
|
|
82
|
+
and all(task.get(key) == value for key, value in self.timing.items()))
|
|
83
|
+
|
|
84
|
+
def remaining(self) -> float:
|
|
85
|
+
remaining = self.until - time.monotonic()
|
|
86
|
+
if remaining <= 0:
|
|
87
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_execution_budget_expired")
|
|
88
|
+
return remaining
|
|
89
|
+
|
|
90
|
+
async def confirm(self) -> None:
|
|
91
|
+
remaining = self.remaining()
|
|
92
|
+
if not self.observed:
|
|
93
|
+
return
|
|
94
|
+
started = time.monotonic()
|
|
95
|
+
try:
|
|
96
|
+
if self.observer is None:
|
|
97
|
+
raise ValueError("query authority observer is unavailable")
|
|
98
|
+
reply = await asyncio.wait_for(self.observer(), timeout=min(5.0, remaining))
|
|
99
|
+
if (not isinstance(reply, Mapping) or reply.get("query_task_id") != self.task_id
|
|
100
|
+
or type(reply.get("query_task_attempt")) is not int
|
|
101
|
+
or reply.get("query_task_attempt") != self.attempt or reply.get("lease_owner") != self.owner
|
|
102
|
+
or any(reply.get(key) != value for key, value in self.timing.items())):
|
|
103
|
+
raise ValueError("query authority observation changed its original claim")
|
|
104
|
+
if (reply.get("can_continue") is not True or reply.get("query_task_status") != "leased"
|
|
105
|
+
or reply.get("reason") is not None):
|
|
106
|
+
raise QueryTaskAuthorityLost(self.task_id, str(reply.get("reason") or "query_claim_unavailable"))
|
|
107
|
+
server_time = _timestamp(reply.get("server_time"), canonical=True)
|
|
108
|
+
bound = min(_timestamp(value, canonical=True) for key, value in self.timing.items()
|
|
109
|
+
if key != "query_admitted_at")
|
|
110
|
+
# Charging the entire observation round trip is conservative. A
|
|
111
|
+
# later observation may shorten this bound but can never reset it.
|
|
112
|
+
self.until = min(self.until, started + (bound - server_time).total_seconds())
|
|
113
|
+
except QueryTaskAuthorityLost:
|
|
114
|
+
raise
|
|
115
|
+
except Exception as error:
|
|
116
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_authority_unavailable") from error
|
|
117
|
+
self.remaining()
|
|
118
|
+
|
|
119
|
+
async def run(self, operation: Callable[[], Awaitable[_T]], *, observe: bool = True,
|
|
120
|
+
confirm_after: bool = True) -> _T:
|
|
121
|
+
await self.confirm()
|
|
122
|
+
execution = asyncio.ensure_future(operation())
|
|
123
|
+
|
|
124
|
+
async def watch() -> None:
|
|
125
|
+
while True:
|
|
126
|
+
await asyncio.sleep(min(_OBSERVATION_INTERVAL_SECONDS, self.remaining()))
|
|
127
|
+
await self.confirm()
|
|
128
|
+
|
|
129
|
+
observation = asyncio.create_task(watch()) if observe and self.observed else None
|
|
130
|
+
pending: set[asyncio.Future[Any]] = {execution}
|
|
131
|
+
if observation is not None:
|
|
132
|
+
pending.add(observation)
|
|
133
|
+
try:
|
|
134
|
+
done, _ = await asyncio.wait(pending, timeout=self.remaining(), return_when=asyncio.FIRST_COMPLETED)
|
|
135
|
+
if not done:
|
|
136
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_execution_budget_expired")
|
|
137
|
+
if observation is not None and observation in done:
|
|
138
|
+
await observation
|
|
139
|
+
raise QueryTaskAuthorityLost(self.task_id, "query_authority_unavailable")
|
|
140
|
+
result = await execution
|
|
141
|
+
if confirm_after:
|
|
142
|
+
await self.confirm()
|
|
143
|
+
return result
|
|
144
|
+
finally:
|
|
145
|
+
for future in pending:
|
|
146
|
+
future.cancel()
|
|
147
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Async task creation copies this context. Each dispatched query keeps the
|
|
151
|
+
# loading budget without adding private fields to the public wire dictionary
|
|
152
|
+
# or retaining an unbounded task-ID registry on Client/Worker.
|
|
153
|
+
current_query_authority: ContextVar[QueryTaskAuthority | None] = ContextVar("query_task_authority", default=None)
|
|
@@ -45,6 +45,7 @@ from ._cancellation_scope_history import (
|
|
|
45
45
|
scope_timestamp,
|
|
46
46
|
)
|
|
47
47
|
from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
|
|
48
|
+
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
48
49
|
from .errors import (
|
|
49
50
|
ExternalPayloadIntegrityMismatch,
|
|
50
51
|
ExternalPayloadOversized,
|
|
@@ -301,8 +302,8 @@ def _contains_inline_payload_envelope(value: object) -> bool:
|
|
|
301
302
|
if set(value) == {"codec", "blob"} and isinstance(value.get("blob"), str):
|
|
302
303
|
return True
|
|
303
304
|
if (
|
|
304
|
-
value.get("type") == "record_side_effect"
|
|
305
|
-
and isinstance(value.get("
|
|
305
|
+
(value.get("type") == "record_side_effect" and isinstance(value.get("result"), str))
|
|
306
|
+
or (value.get("type") == "start_service_operation" and isinstance(value.get("request_payload"), str))
|
|
306
307
|
):
|
|
307
308
|
return True
|
|
308
309
|
return any(_contains_inline_payload_envelope(item) for item in value.values())
|
|
@@ -1809,6 +1810,9 @@ class Client:
|
|
|
1809
1810
|
if discovery and path != "/cluster/info":
|
|
1810
1811
|
raise ValueError("cluster discovery credentials can only authorize /cluster/info")
|
|
1811
1812
|
|
|
1813
|
+
query_poll = worker and path == "/worker/query-tasks/poll"
|
|
1814
|
+
if query_poll:
|
|
1815
|
+
current_query_authority.set(None)
|
|
1812
1816
|
request_json = json
|
|
1813
1817
|
if (
|
|
1814
1818
|
not discovery
|
|
@@ -1872,21 +1876,29 @@ class Client:
|
|
|
1872
1876
|
outcome = "ok"
|
|
1873
1877
|
return None
|
|
1874
1878
|
result = resp.json()
|
|
1879
|
+
query_authority = None
|
|
1880
|
+
if query_poll and isinstance(result, dict) and isinstance(result.get("task"), dict):
|
|
1881
|
+
query_authority = await self._query_delivery_authority(result["task"], request_json)
|
|
1882
|
+
current_query_authority.set(query_authority)
|
|
1875
1883
|
if not discovery and _contains_runtime_payload_envelope(result):
|
|
1876
|
-
transport = await self._runtime_external_payload_transport
|
|
1884
|
+
transport = (await query_authority.run(self._runtime_external_payload_transport)
|
|
1885
|
+
if query_authority is not None else await self._runtime_external_payload_transport())
|
|
1877
1886
|
if transport is None:
|
|
1878
1887
|
raise ExternalPayloadUnsupported(
|
|
1879
1888
|
"runtime returned an external payload reference without advertising "
|
|
1880
1889
|
"the authenticated namespace transport"
|
|
1881
1890
|
)
|
|
1882
|
-
|
|
1883
|
-
result
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1891
|
+
if query_authority is not None:
|
|
1892
|
+
result = await query_authority.run(lambda: self._resolve_runtime_payloads(
|
|
1893
|
+
result, worker=worker, transport=transport, resolved={},
|
|
1894
|
+
))
|
|
1895
|
+
else:
|
|
1896
|
+
result = await self._resolve_workflow_claim_payloads(
|
|
1897
|
+
result, worker=worker, transport=transport, path=path, request_json=request_json,
|
|
1898
|
+
)
|
|
1889
1899
|
result = self._hydrate_result_envelopes(result)
|
|
1900
|
+
if query_authority is not None:
|
|
1901
|
+
await query_authority.confirm()
|
|
1890
1902
|
outcome = "ok"
|
|
1891
1903
|
return result
|
|
1892
1904
|
except Exception as exc:
|
|
@@ -2085,21 +2097,23 @@ class Client:
|
|
|
2085
2097
|
slot: tuple[str | int, ...] = (),
|
|
2086
2098
|
) -> Any:
|
|
2087
2099
|
if isinstance(value, dict):
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2100
|
+
command_type = value.get("type")
|
|
2101
|
+
blob_key = (
|
|
2102
|
+
{"record_side_effect": "result", "start_service_operation": "request_payload"}.get(command_type)
|
|
2103
|
+
if isinstance(command_type, str) else None
|
|
2104
|
+
)
|
|
2105
|
+
if blob_key is not None and isinstance(value.get(blob_key), str):
|
|
2092
2106
|
normalized_command = dict(value)
|
|
2093
2107
|
externalized_result = await self._externalize_runtime_payloads(
|
|
2094
|
-
{"codec": serializer.AVRO_CODEC, "blob": value[
|
|
2108
|
+
{"codec": value.get("payload_codec", serializer.AVRO_CODEC), "blob": value[blob_key]},
|
|
2095
2109
|
worker=worker,
|
|
2096
2110
|
transport=transport,
|
|
2097
2111
|
uploaded=uploaded,
|
|
2098
2112
|
completion=completion,
|
|
2099
|
-
slot=(*slot,
|
|
2113
|
+
slot=(*slot, blob_key),
|
|
2100
2114
|
)
|
|
2101
2115
|
if "external_payload" in externalized_result:
|
|
2102
|
-
normalized_command[
|
|
2116
|
+
normalized_command[blob_key] = externalized_result
|
|
2103
2117
|
value = normalized_command
|
|
2104
2118
|
|
|
2105
2119
|
if set(value) == {"codec", "blob"} and isinstance(value.get("blob"), str):
|
|
@@ -2332,6 +2346,7 @@ class Client:
|
|
|
2332
2346
|
*,
|
|
2333
2347
|
worker: bool,
|
|
2334
2348
|
transport: _RuntimeExternalPayloadTransport,
|
|
2349
|
+
resolved: dict[tuple[str, str, int, str], str] | None = None,
|
|
2335
2350
|
) -> Any:
|
|
2336
2351
|
if isinstance(value, dict):
|
|
2337
2352
|
if "external_payload" in value:
|
|
@@ -2347,6 +2362,9 @@ class Client:
|
|
|
2347
2362
|
"runtime external payload envelope codec does not match its reference",
|
|
2348
2363
|
reference_id=reference.reference_id,
|
|
2349
2364
|
)
|
|
2365
|
+
identity = (reference.reference_id, reference.codec, reference.size_bytes, reference.sha256)
|
|
2366
|
+
if resolved is not None and identity in resolved:
|
|
2367
|
+
return {"codec": reference.codec, "blob": resolved[identity]}
|
|
2350
2368
|
data = await self._fetch_runtime_payload(
|
|
2351
2369
|
reference,
|
|
2352
2370
|
worker=worker,
|
|
@@ -2359,12 +2377,15 @@ class Client:
|
|
|
2359
2377
|
"runtime external payload bytes are not a UTF-8 payload blob",
|
|
2360
2378
|
reference_id=reference.reference_id,
|
|
2361
2379
|
) from exc
|
|
2380
|
+
if resolved is not None:
|
|
2381
|
+
resolved[identity] = blob
|
|
2362
2382
|
return {"codec": reference.codec, "blob": blob}
|
|
2363
2383
|
return {
|
|
2364
2384
|
key: await self._resolve_runtime_payloads(
|
|
2365
2385
|
item,
|
|
2366
2386
|
worker=worker,
|
|
2367
2387
|
transport=transport,
|
|
2388
|
+
resolved=resolved,
|
|
2368
2389
|
)
|
|
2369
2390
|
for key, item in value.items()
|
|
2370
2391
|
}
|
|
@@ -2374,6 +2395,7 @@ class Client:
|
|
|
2374
2395
|
item,
|
|
2375
2396
|
worker=worker,
|
|
2376
2397
|
transport=transport,
|
|
2398
|
+
resolved=resolved,
|
|
2377
2399
|
)
|
|
2378
2400
|
for item in value
|
|
2379
2401
|
]
|
|
@@ -5627,6 +5649,48 @@ class Client:
|
|
|
5627
5649
|
"POST", f"/worker/workflow-tasks/{task_id}/history", worker=True, json=body
|
|
5628
5650
|
)
|
|
5629
5651
|
|
|
5652
|
+
async def _query_delivery_authority(self, task: dict[str, Any], request: Any) -> QueryTaskAuthority:
|
|
5653
|
+
# Worker registration establishes discovery before it polls. Advanced
|
|
5654
|
+
# callers without discovery retain only the exposed local lease checks.
|
|
5655
|
+
info = self._cluster_info or {}
|
|
5656
|
+
protocol = info.get("worker_protocol")
|
|
5657
|
+
capabilities = protocol.get("server_capabilities") if isinstance(protocol, dict) else None
|
|
5658
|
+
observed = (isinstance(capabilities, dict) and capabilities.get("query_task_authority") is True
|
|
5659
|
+
and _supports_cooperative_cancellation_protocol(_protocol_version_from_env(
|
|
5660
|
+
"DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", PROTOCOL_VERSION)))
|
|
5661
|
+
request = request if isinstance(request, dict) else {}
|
|
5662
|
+
authority = QueryTaskAuthority.delivery(self, self.namespace, task, owner=request.get("worker_id", ""),
|
|
5663
|
+
task_queue=request.get("task_queue", ""), observed=observed)
|
|
5664
|
+
if observed:
|
|
5665
|
+
authority.observer = lambda: self.query_task_status(query_task_id=authority.task_id,
|
|
5666
|
+
lease_owner=authority.owner, query_task_attempt=authority.attempt)
|
|
5667
|
+
await authority.confirm()
|
|
5668
|
+
return authority
|
|
5669
|
+
|
|
5670
|
+
async def query_task_status(self, *, query_task_id: str, lease_owner: str,
|
|
5671
|
+
query_task_attempt: int) -> Any:
|
|
5672
|
+
"""Observe a protocol 1.20 query claim without renewing it.
|
|
5673
|
+
|
|
5674
|
+
Requires ``query_task_authority=true`` discovery. A positive observation
|
|
5675
|
+
does not reserve authority for later execution or completion.
|
|
5676
|
+
"""
|
|
5677
|
+
if not _supports_cooperative_cancellation_protocol(_protocol_version_from_env(
|
|
5678
|
+
"DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", PROTOCOL_VERSION,
|
|
5679
|
+
)):
|
|
5680
|
+
raise ValueError("query task observation requires worker protocol 1.20")
|
|
5681
|
+
capability_path = "worker_protocol.server_capabilities.query_task_authority"
|
|
5682
|
+
info = await self._runtime_discovery(operation="Client.query_task_status", required_path=capability_path)
|
|
5683
|
+
protocol = info.get("worker_protocol")
|
|
5684
|
+
capabilities = protocol.get("server_capabilities") if isinstance(protocol, dict) else None
|
|
5685
|
+
supported = capabilities.get("query_task_authority") if isinstance(capabilities, dict) else None
|
|
5686
|
+
if supported is not True:
|
|
5687
|
+
error_type = RuntimeCapabilityUnsupported if supported is False else RuntimeDiscoveryUnavailable
|
|
5688
|
+
raise error_type("Client.query_task_status", capability_path,
|
|
5689
|
+
"Query task observation requires Server query_task_authority=true discovery.")
|
|
5690
|
+
query = urlencode({"lease_owner": lease_owner, "query_task_attempt": query_task_attempt})
|
|
5691
|
+
return await self._request("GET", f"/worker/query-tasks/{quote(query_task_id, safe='')}/status?{query}",
|
|
5692
|
+
worker=True, timeout=5.0)
|
|
5693
|
+
|
|
5630
5694
|
async def poll_query_task(
|
|
5631
5695
|
self,
|
|
5632
5696
|
*,
|
|
@@ -5642,6 +5706,10 @@ class Client:
|
|
|
5642
5706
|
must route a control-plane query to a non-PHP workflow runtime.
|
|
5643
5707
|
``timeout`` controls the server long-poll window; the HTTP request
|
|
5644
5708
|
uses a small grace margin.
|
|
5709
|
+
|
|
5710
|
+
Worker registration establishes capability discovery. Custom workers
|
|
5711
|
+
should call ``get_cluster_info()`` first to enable advertised query
|
|
5712
|
+
status checks; without discovery only supplied local lease bounds apply.
|
|
5645
5713
|
"""
|
|
5646
5714
|
body: dict[str, Any] = {
|
|
5647
5715
|
"worker_id": worker_id,
|
|
@@ -27,6 +27,15 @@ class DurableWorkflowError(Exception):
|
|
|
27
27
|
"""Base class for every exception raised by the SDK."""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class QueryTaskAuthorityLost(DurableWorkflowError):
|
|
31
|
+
"""A query delivery cannot safely load or invoke its handler."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, query_task_id: str, reason: str) -> None:
|
|
34
|
+
super().__init__(f"query task {query_task_id!r} cannot continue: {reason}")
|
|
35
|
+
self.query_task_id = query_task_id
|
|
36
|
+
self.reason = reason
|
|
37
|
+
|
|
38
|
+
|
|
30
39
|
class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
|
|
31
40
|
"""Workflow authoring uses an unsupported entry point or result."""
|
|
32
41
|
|