durable-workflow 2.6.0rc2__tar.gz → 2.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.6.0rc2/src/durable_workflow.egg-info → durable_workflow-2.6.1}/PKG-INFO +28 -9
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/README.md +27 -8
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/pyproject.toml +5 -5
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_cooperative_cancellation.py +9 -1
- durable_workflow-2.6.1/src/durable_workflow/_query_task_authority.py +153 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/client.py +169 -13
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/errors.py +14 -3
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/replay_conformance.py +35 -16
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/retry_policy.py +22 -1
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/worker.py +167 -18
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/workflow.py +100 -40
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1/src/durable_workflow.egg-info}/PKG-INFO +28 -9
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/SOURCES.txt +5 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_opening.py +2 -2
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_ci_checkout.py +39 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_client.py +23 -9
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_condition_replay_drift.py +4 -2
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_cancellation_client.py +4 -7
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_cancellation_worker.py +139 -4
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_remote_worker.py +3 -3
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_errors.py +5 -3
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_external_storage.py +8 -4
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_nexus_durable_wait.py +91 -6
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_nexus_namespace.py +12 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_patch_history.py +74 -1
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_client.py +2 -2
- durable_workflow-2.6.1/tests/test_query_task_authority.py +305 -0
- durable_workflow-2.6.1/tests/test_query_timeout_retry.py +73 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_release_metadata.py +2 -2
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay.py +18 -6
- durable_workflow-2.6.1/tests/test_replay_clock.py +93 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay_conformance.py +44 -3
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay_regression_corpus.py +128 -2
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_runtime_external_payload_transport.py +204 -2
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_storage_admission.py +16 -6
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_worker.py +164 -35
- durable_workflow-2.6.1/tests/test_worker_observation.py +299 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/LICENSE +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/setup.cfg +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_invocable.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_metrics.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_queries.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_schedules.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_serializer.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_signals.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_sleep.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_sync.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_updates.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.6.0rc2 → durable_workflow-2.6.1}/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.6.
|
|
3
|
+
Version: 2.6.1
|
|
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/)
|
|
@@ -138,6 +139,7 @@ for the complete cross-SDK contract.
|
|
|
138
139
|
- [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
|
|
139
140
|
- [Complete SDK reference](docs/sdk-reference.md)
|
|
140
141
|
- [Runnable examples](examples/)
|
|
142
|
+
- [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
|
|
141
143
|
- [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
|
|
142
144
|
|
|
143
145
|
## Runtime choices
|
|
@@ -155,16 +157,26 @@ capabilities with Server at startup. Use stable `2.x` SDK and Server channels
|
|
|
155
157
|
for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/)
|
|
156
158
|
documents protocol and upgrade guarantees.
|
|
157
159
|
|
|
158
|
-
##
|
|
160
|
+
## Worker protocol and cooperative cancellation
|
|
161
|
+
|
|
162
|
+
Workers use protocol 1.20 by default. For a Server that supports only 1.19, set
|
|
163
|
+
`DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.19`. This changes the request protocol
|
|
164
|
+
without enabling optional worker features.
|
|
159
165
|
|
|
160
166
|
Use `request_cancellation()` for bounded, replayable workflow cleanup. Opt in
|
|
161
167
|
against a Server that advertises protocol 1.20 and the required capabilities:
|
|
162
|
-
|
|
163
|
-
`cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
|
|
168
|
+
include `cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
|
|
164
169
|
admission also needs `prepared_local_activities`, with
|
|
165
170
|
`prepared_local_activity_cancellation_policies` for explicit local policies.
|
|
166
171
|
Independently cancellable scopes remain disabled.
|
|
167
172
|
|
|
173
|
+
For synchronous Nexus completion waits, the cooperative worker also negotiates
|
|
174
|
+
`nexus_cooperative_cancellation` when Server advertises the installed
|
|
175
|
+
`workflow_run` binding. Owned target cleanup keeps the caller pending until the
|
|
176
|
+
Server commits delivery at the original call. Async and acceptance-only calls
|
|
177
|
+
keep their admission behavior. Existing terminal results and recorded legacy
|
|
178
|
+
Nexus histories retain their replay semantics.
|
|
179
|
+
|
|
168
180
|
The cooperative worker supervises async and synchronous activity callbacks
|
|
169
181
|
independently of application heartbeats. Existing terminal cancellation remains
|
|
170
182
|
available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
|
|
@@ -172,6 +184,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
|
|
|
172
184
|
|
|
173
185
|
## Development
|
|
174
186
|
|
|
187
|
+
The installed `durable-workflow-replay-conformance` command runs an offline
|
|
188
|
+
Python replay shard. Its `sdk-python` artifact pin must match the installed
|
|
189
|
+
distribution version reported by `importlib.metadata.version("durable-workflow")`.
|
|
190
|
+
The report records that installed identity separately from the declared platform
|
|
191
|
+
tuple and fails if metadata is missing or the SDK pin differs. Replay success
|
|
192
|
+
does not prove installation or execution of the other declared artifacts; the
|
|
193
|
+
aggregate runner supplies its own verified installation receipts.
|
|
194
|
+
|
|
175
195
|
```bash
|
|
176
196
|
pip install -e '.[dev]'
|
|
177
197
|
ruff check src/ tests/
|
|
@@ -196,11 +216,10 @@ to true. CI verifies that checkout, builds the candidate Server, enables protoco
|
|
|
196
216
|
authority and exact source provenance. An optional exact `native_commit` mounts
|
|
197
217
|
that public Native checkout read-only into the test stack. The image's published
|
|
198
218
|
Composer authority stays intact and the evidence identifies the source overlay.
|
|
199
|
-
These are source qualification runs. For a local
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
keeps protocol 1.19 and skips this unpublished feature's connected cases.
|
|
219
|
+
These are source qualification runs. For a local candidate, set
|
|
220
|
+
`DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases fail if the
|
|
221
|
+
runtime does not discover the required capability. Ordinary CI uses the SDK's
|
|
222
|
+
default protocol and skips the optional connected cooperative cases.
|
|
204
223
|
|
|
205
224
|
## License
|
|
206
225
|
|
|
@@ -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/)
|
|
@@ -98,6 +99,7 @@ for the complete cross-SDK contract.
|
|
|
98
99
|
- [Python SDK guide](https://durable-workflow.com/docs/2.0/polyglot/python/)
|
|
99
100
|
- [Complete SDK reference](docs/sdk-reference.md)
|
|
100
101
|
- [Runnable examples](examples/)
|
|
102
|
+
- [Separate workflow and activity worker processes](docs/guides/separate-workers.md)
|
|
101
103
|
- [Symmetric SDK playground](https://github.com/durable-workflow/sample-app#symmetric-sdk-playground)
|
|
102
104
|
|
|
103
105
|
## Runtime choices
|
|
@@ -115,16 +117,26 @@ capabilities with Server at startup. Use stable `2.x` SDK and Server channels
|
|
|
115
117
|
for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/)
|
|
116
118
|
documents protocol and upgrade guarantees.
|
|
117
119
|
|
|
118
|
-
##
|
|
120
|
+
## Worker protocol and cooperative cancellation
|
|
121
|
+
|
|
122
|
+
Workers use protocol 1.20 by default. For a Server that supports only 1.19, set
|
|
123
|
+
`DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.19`. This changes the request protocol
|
|
124
|
+
without enabling optional worker features.
|
|
119
125
|
|
|
120
126
|
Use `request_cancellation()` for bounded, replayable workflow cleanup. Opt in
|
|
121
127
|
against a Server that advertises protocol 1.20 and the required capabilities:
|
|
122
|
-
|
|
123
|
-
`cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
|
|
128
|
+
include `cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
|
|
124
129
|
admission also needs `prepared_local_activities`, with
|
|
125
130
|
`prepared_local_activity_cancellation_policies` for explicit local policies.
|
|
126
131
|
Independently cancellable scopes remain disabled.
|
|
127
132
|
|
|
133
|
+
For synchronous Nexus completion waits, the cooperative worker also negotiates
|
|
134
|
+
`nexus_cooperative_cancellation` when Server advertises the installed
|
|
135
|
+
`workflow_run` binding. Owned target cleanup keeps the caller pending until the
|
|
136
|
+
Server commits delivery at the original call. Async and acceptance-only calls
|
|
137
|
+
keep their admission behavior. Existing terminal results and recorded legacy
|
|
138
|
+
Nexus histories retain their replay semantics.
|
|
139
|
+
|
|
128
140
|
The cooperative worker supervises async and synchronous activity callbacks
|
|
129
141
|
independently of application heartbeats. Existing terminal cancellation remains
|
|
130
142
|
available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
|
|
@@ -132,6 +144,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
|
|
|
132
144
|
|
|
133
145
|
## Development
|
|
134
146
|
|
|
147
|
+
The installed `durable-workflow-replay-conformance` command runs an offline
|
|
148
|
+
Python replay shard. Its `sdk-python` artifact pin must match the installed
|
|
149
|
+
distribution version reported by `importlib.metadata.version("durable-workflow")`.
|
|
150
|
+
The report records that installed identity separately from the declared platform
|
|
151
|
+
tuple and fails if metadata is missing or the SDK pin differs. Replay success
|
|
152
|
+
does not prove installation or execution of the other declared artifacts; the
|
|
153
|
+
aggregate runner supplies its own verified installation receipts.
|
|
154
|
+
|
|
135
155
|
```bash
|
|
136
156
|
pip install -e '.[dev]'
|
|
137
157
|
ruff check src/ tests/
|
|
@@ -156,11 +176,10 @@ to true. CI verifies that checkout, builds the candidate Server, enables protoco
|
|
|
156
176
|
authority and exact source provenance. An optional exact `native_commit` mounts
|
|
157
177
|
that public Native checkout read-only into the test stack. The image's published
|
|
158
178
|
Composer authority stays intact and the evidence identifies the source overlay.
|
|
159
|
-
These are source qualification runs. For a local
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
keeps protocol 1.19 and skips this unpublished feature's connected cases.
|
|
179
|
+
These are source qualification runs. For a local candidate, set
|
|
180
|
+
`DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases fail if the
|
|
181
|
+
runtime does not discover the required capability. Ordinary CI uses the SDK's
|
|
182
|
+
default protocol and skips the optional connected cooperative cases.
|
|
164
183
|
|
|
165
184
|
## License
|
|
166
185
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.6.
|
|
7
|
+
version = "2.6.1"
|
|
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,10 +71,10 @@ 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.6.
|
|
75
|
-
registry-version = "2.6.
|
|
76
|
-
supported-server-versions = "2.
|
|
77
|
-
worker-protocol-version = "1.
|
|
74
|
+
product-train = "2.6.1"
|
|
75
|
+
registry-version = "2.6.1"
|
|
76
|
+
supported-server-versions = "2.8.0"
|
|
77
|
+
worker-protocol-version = "1.20"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
80
80
|
durable-selection-minimum-worker-protocol-version = "1.19"
|
|
@@ -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 = {
|
|
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)
|