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.
Files changed (135) hide show
  1. {durable_workflow-2.5.14/src/durable_workflow.egg-info → durable_workflow-2.6.0}/PKG-INFO +9 -1
  2. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/README.md +8 -0
  3. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/pyproject.toml +4 -4
  4. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/__init__.py +2 -0
  5. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cooperative_cancellation.py +9 -1
  6. durable_workflow-2.6.0/src/durable_workflow/_query_task_authority.py +153 -0
  7. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/client.py +85 -17
  8. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/errors.py +9 -0
  9. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/worker.py +107 -130
  10. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/workflow.py +207 -32
  11. {durable_workflow-2.5.14 → durable_workflow-2.6.0/src/durable_workflow.egg-info}/PKG-INFO +9 -1
  12. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/SOURCES.txt +4 -0
  13. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_ci_checkout.py +39 -0
  14. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_client.py +17 -8
  15. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_condition_replay_drift.py +4 -2
  16. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_cancellation_worker.py +138 -3
  17. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_external_storage.py +8 -4
  18. durable_workflow-2.6.0/tests/test_nexus_durable_wait.py +241 -0
  19. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_nexus_namespace.py +12 -0
  20. durable_workflow-2.6.0/tests/test_query_task_authority.py +305 -0
  21. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_regression_corpus.py +63 -1
  22. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_runtime_external_payload_transport.py +3 -0
  23. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_storage_admission.py +16 -6
  24. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_worker.py +42 -82
  25. durable_workflow-2.6.0/tests/test_worker_observation.py +299 -0
  26. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/LICENSE +0 -0
  27. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/setup.cfg +0 -0
  28. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_activity_process.py +0 -0
  29. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_avro.py +0 -0
  30. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  31. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  32. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  33. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  34. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/activity.py +0 -0
  35. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/auth_composition.py +0 -0
  36. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/cancellation.py +0 -0
  37. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/external_storage.py +0 -0
  38. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/external_task_input.py +0 -0
  39. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/external_task_result.py +0 -0
  40. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  41. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/interceptors.py +0 -0
  42. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/invocable.py +0 -0
  43. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/metrics.py +0 -0
  44. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/nexus.py +0 -0
  45. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/py.typed +0 -0
  46. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/python_conformance.py +0 -0
  47. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/replay_conformance.py +0 -0
  48. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/replay_verify.py +0 -0
  49. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/retry_policy.py +0 -0
  50. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/serializer.py +0 -0
  51. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/sync.py +0 -0
  52. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/testing.py +0 -0
  53. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/worker_session.py +0 -0
  54. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  55. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  56. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  57. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  58. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  59. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_activity_cancellation_policies.py +0 -0
  60. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_activity_context.py +0 -0
  61. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_activity_process.py +0 -0
  62. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_auth_composition.py +0 -0
  63. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_avro_value_benchmark.py +0 -0
  64. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_avro_value_protocol.py +0 -0
  65. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_context.py +0 -0
  66. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_remaining_time.py +0 -0
  67. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_admission.py +0 -0
  68. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_authoring.py +0 -0
  69. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_boundary.py +0 -0
  70. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cancellation_scope_opening.py +0 -0
  71. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_child_workflow_policies.py +0 -0
  72. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_ci_integration_endpoint.py +0 -0
  73. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_client_timeouts.py +0 -0
  74. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_codec_regression_corpus.py +0 -0
  75. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  76. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  77. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  78. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  79. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  80. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_cancellation.py +0 -0
  81. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_cancellation_client.py +0 -0
  82. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_cooperative_remote_worker.py +0 -0
  83. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_docstring_cross_references.py +0 -0
  84. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_durable_selection.py +0 -0
  85. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_errors.py +0 -0
  86. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_external_task_input.py +0 -0
  87. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_external_task_result.py +0 -0
  88. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_golden_history_replay.py +0 -0
  89. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_history_bundle_verify.py +0 -0
  90. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_history_event_contract.py +0 -0
  91. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_invocable.py +0 -0
  92. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_message_streams.py +0 -0
  93. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_metrics.py +0 -0
  94. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_nexus_wire.py +0 -0
  95. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_order_processing_example.py +0 -0
  96. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_parallel_completion_order_replay.py +0 -0
  97. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_parallel_saga_authoring.py +0 -0
  98. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_patch_history.py +0 -0
  99. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_pending_operation_replay.py +0 -0
  100. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_client.py +0 -0
  101. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_groups.py +0 -0
  102. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_policies.py +0 -0
  103. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_prepared_local_activity_worker.py +0 -0
  104. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_public_boundary_scanner.py +0 -0
  105. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_pypi_project_surface.py +0 -0
  106. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_python_conformance.py +0 -0
  107. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_queries.py +0 -0
  108. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_query_timeout_retry.py +0 -0
  109. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_readme_quickstart.py +0 -0
  110. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_redrive_replay.py +0 -0
  111. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_registration_ambiguity.py +0 -0
  112. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_release_metadata.py +0 -0
  113. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay.py +0 -0
  114. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_clock.py +0 -0
  115. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_conformance.py +0 -0
  116. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_replay_verify.py +0 -0
  117. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_retry_policy.py +0 -0
  118. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  119. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_schedules.py +0 -0
  120. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  121. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_serializer.py +0 -0
  122. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_signals.py +0 -0
  123. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_sleep.py +0 -0
  124. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_standalone_activity_client.py +0 -0
  125. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_sticky_execution.py +0 -0
  126. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_sync.py +0 -0
  127. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_testing_harness.py +0 -0
  128. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_update_signal_condition_replay.py +0 -0
  129. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_updates.py +0 -0
  130. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_wait_condition.py +0 -0
  131. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_worker_session.py +0 -0
  132. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  133. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_result_timeout.py +0 -0
  134. {durable_workflow-2.5.14 → durable_workflow-2.6.0}/tests/test_workflow_streams.py +0 -0
  135. {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.5.14
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
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=durable-workflow_sdk-python&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=durable-workflow_sdk-python)
43
44
  [![CI](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml)
44
45
  [![PyPI](https://img.shields.io/pypi/v/durable-workflow.svg)](https://pypi.org/project/durable-workflow/)
45
46
  [![Python](https://img.shields.io/pypi/pyversions/durable-workflow.svg)](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
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=durable-workflow_sdk-python&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=durable-workflow_sdk-python)
3
4
  [![CI](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/durable-workflow/sdk-python/actions/workflows/ci.yml)
4
5
  [![PyPI](https://img.shields.io/pypi/v/durable-workflow.svg)](https://pypi.org/project/durable-workflow/)
5
6
  [![Python](https://img.shields.io/pypi/pyversions/durable-workflow.svg)](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.5.14"
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.5.14"
75
- registry-version = "2.5.14"
76
- supported-server-versions = "2.5.16"
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",
@@ -16,7 +16,10 @@ from .errors import NonDeterministicReplayError
16
16
 
17
17
  REQUEST_EVENT = "CooperativeCancellationRequested"
18
18
  DELIVERY_EVENT = "CooperativeCancellationDelivered"
19
- _CALL_KINDS = {"activity", "local_activity", "timer", "condition", "signal", "child", "parallel", "selection_handle"}
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("result"), str)
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
- result = await self._resolve_workflow_claim_payloads(
1883
- result,
1884
- worker=worker,
1885
- transport=transport,
1886
- path=path,
1887
- request_json=request_json,
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
- if (
2089
- value.get("type") == "record_side_effect"
2090
- and isinstance(value.get("result"), str)
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["result"]},
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, "result"),
2113
+ slot=(*slot, blob_key),
2100
2114
  )
2101
2115
  if "external_payload" in externalized_result:
2102
- normalized_command["result"] = externalized_result
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