durable-workflow 2.3.8__tar.gz → 2.4.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.3.8/src/durable_workflow.egg-info → durable_workflow-2.4.0}/PKG-INFO +32 -2
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/README.md +31 -1
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/pyproject.toml +4 -4
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/__init__.py +14 -0
- durable_workflow-2.4.0/src/durable_workflow/_activity_process.py +322 -0
- durable_workflow-2.4.0/src/durable_workflow/_cancellation_scope.py +221 -0
- durable_workflow-2.4.0/src/durable_workflow/_cancellation_scope_history.py +775 -0
- durable_workflow-2.4.0/src/durable_workflow/_cooperative_cancellation.py +279 -0
- durable_workflow-2.4.0/src/durable_workflow/_prepared_local_activity.py +371 -0
- durable_workflow-2.4.0/src/durable_workflow/cancellation.py +430 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/client.py +543 -6
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/errors.py +8 -1
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/worker.py +1023 -47
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/workflow.py +1240 -25
- {durable_workflow-2.3.8 → durable_workflow-2.4.0/src/durable_workflow.egg-info}/PKG-INFO +32 -2
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/SOURCES.txt +30 -0
- durable_workflow-2.4.0/tests/test_activity_cancellation_policies.py +136 -0
- durable_workflow-2.4.0/tests/test_activity_process.py +270 -0
- durable_workflow-2.4.0/tests/test_cancellation_context.py +203 -0
- durable_workflow-2.4.0/tests/test_cancellation_remaining_time.py +265 -0
- durable_workflow-2.4.0/tests/test_cancellation_scope_admission.py +108 -0
- durable_workflow-2.4.0/tests/test_cancellation_scope_authoring.py +372 -0
- durable_workflow-2.4.0/tests/test_cancellation_scope_boundary.py +335 -0
- durable_workflow-2.4.0/tests/test_cancellation_scope_opening.py +209 -0
- durable_workflow-2.4.0/tests/test_child_workflow_policies.py +261 -0
- durable_workflow-2.4.0/tests/test_ci_checkout.py +120 -0
- durable_workflow-2.4.0/tests/test_client_timeouts.py +93 -0
- durable_workflow-2.4.0/tests/test_committed_cancellation_scope_descendants.py +342 -0
- durable_workflow-2.4.0/tests/test_committed_cancellation_scope_groups.py +227 -0
- durable_workflow-2.4.0/tests/test_committed_cancellation_scope_history.py +229 -0
- durable_workflow-2.4.0/tests/test_committed_cancellation_scope_replay.py +276 -0
- durable_workflow-2.4.0/tests/test_cooperative_cancellation.py +425 -0
- durable_workflow-2.4.0/tests/test_cooperative_cancellation_client.py +503 -0
- durable_workflow-2.4.0/tests/test_cooperative_cancellation_worker.py +611 -0
- durable_workflow-2.4.0/tests/test_cooperative_remote_worker.py +335 -0
- durable_workflow-2.4.0/tests/test_prepared_local_activity_client.py +163 -0
- durable_workflow-2.4.0/tests/test_prepared_local_activity_groups.py +390 -0
- durable_workflow-2.4.0/tests/test_prepared_local_activity_policies.py +206 -0
- durable_workflow-2.4.0/tests/test_prepared_local_activity_worker.py +578 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay_regression_corpus.py +50 -5
- durable_workflow-2.4.0/tests/test_run_cancellation_scope_replay.py +171 -0
- durable_workflow-2.4.0/tests/test_scoped_run_cancellation_context.py +201 -0
- durable_workflow-2.3.8/tests/test_ci_checkout.py +0 -74
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/LICENSE +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/setup.cfg +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_client.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_worker.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.3.8 → durable_workflow-2.4.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.4.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
|
|
@@ -155,6 +155,21 @@ capabilities with Server at startup. Use stable `2.x` SDK and Server channels
|
|
|
155
155
|
for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/)
|
|
156
156
|
documents protocol and upgrade guarantees.
|
|
157
157
|
|
|
158
|
+
## Cooperative cancellation release candidate
|
|
159
|
+
|
|
160
|
+
Use `request_cancellation()` for bounded, replayable workflow cleanup. Opt in
|
|
161
|
+
against a Server that advertises protocol 1.20 and the required capabilities:
|
|
162
|
+
set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` and include
|
|
163
|
+
`cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
|
|
164
|
+
admission also needs `prepared_local_activities`, with
|
|
165
|
+
`prepared_local_activity_cancellation_policies` for explicit local policies.
|
|
166
|
+
Independently cancellable scopes remain disabled.
|
|
167
|
+
|
|
168
|
+
The cooperative worker supervises async and synchronous activity callbacks
|
|
169
|
+
independently of application heartbeats. Existing terminal cancellation remains
|
|
170
|
+
available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
|
|
171
|
+
for immutable context, operation policies, shielded cleanup and recovery.
|
|
172
|
+
|
|
158
173
|
## Development
|
|
159
174
|
|
|
160
175
|
```bash
|
|
@@ -167,11 +182,26 @@ pytest tests/ -m "not integration"
|
|
|
167
182
|
Integration tests use Docker:
|
|
168
183
|
|
|
169
184
|
```bash
|
|
185
|
+
export COMPOSE_PROJECT_NAME=sdk-python-local
|
|
170
186
|
docker compose -f docker-compose.test.yml up -d --build --wait
|
|
171
|
-
|
|
187
|
+
SERVER_PORT=$(docker compose -f docker-compose.test.yml port server 8080 | sed 's/.*://')
|
|
188
|
+
DURABLE_WORKFLOW_SERVER_URL="http://127.0.0.1:$SERVER_PORT" DURABLE_WORKFLOW_AUTH_TOKEN=test-token pytest tests/integration/ -v
|
|
172
189
|
docker compose -f docker-compose.test.yml down -v
|
|
173
190
|
```
|
|
174
191
|
|
|
192
|
+
Candidate cooperative cancellation qualification is explicit. In a manual CI
|
|
193
|
+
run, supply an exact public `server_commit` and set `cooperative_qualification`
|
|
194
|
+
to true. CI verifies that checkout, builds the candidate Server, enables protocol
|
|
195
|
+
1.20, runs the connected cases and retains JUnit, raw observations, image
|
|
196
|
+
authority and exact source provenance. An optional exact `native_commit` mounts
|
|
197
|
+
that public Native checkout read-only into the test stack. The image's published
|
|
198
|
+
Composer authority stays intact and the evidence identifies the source overlay.
|
|
199
|
+
These are source qualification runs. For a local
|
|
200
|
+
candidate, set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` before starting
|
|
201
|
+
Compose and `DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases
|
|
202
|
+
fail if the runtime does not discover the required capability. Ordinary CI
|
|
203
|
+
keeps protocol 1.19 and skips this unpublished feature's connected cases.
|
|
204
|
+
|
|
175
205
|
## License
|
|
176
206
|
|
|
177
207
|
[MIT](LICENSE)
|
|
@@ -115,6 +115,21 @@ capabilities with Server at startup. Use stable `2.x` SDK and Server channels
|
|
|
115
115
|
for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/)
|
|
116
116
|
documents protocol and upgrade guarantees.
|
|
117
117
|
|
|
118
|
+
## Cooperative cancellation release candidate
|
|
119
|
+
|
|
120
|
+
Use `request_cancellation()` for bounded, replayable workflow cleanup. Opt in
|
|
121
|
+
against a Server that advertises protocol 1.20 and the required capabilities:
|
|
122
|
+
set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` and include
|
|
123
|
+
`cooperative_cancellation` in the Worker's `capabilities`. Durable local callback
|
|
124
|
+
admission also needs `prepared_local_activities`, with
|
|
125
|
+
`prepared_local_activity_cancellation_policies` for explicit local policies.
|
|
126
|
+
Independently cancellable scopes remain disabled.
|
|
127
|
+
|
|
128
|
+
The cooperative worker supervises async and synchronous activity callbacks
|
|
129
|
+
independently of application heartbeats. Existing terminal cancellation remains
|
|
130
|
+
available. See the [cancellation guide](docs/cooperative-cancellation-design.md)
|
|
131
|
+
for immutable context, operation policies, shielded cleanup and recovery.
|
|
132
|
+
|
|
118
133
|
## Development
|
|
119
134
|
|
|
120
135
|
```bash
|
|
@@ -127,11 +142,26 @@ pytest tests/ -m "not integration"
|
|
|
127
142
|
Integration tests use Docker:
|
|
128
143
|
|
|
129
144
|
```bash
|
|
145
|
+
export COMPOSE_PROJECT_NAME=sdk-python-local
|
|
130
146
|
docker compose -f docker-compose.test.yml up -d --build --wait
|
|
131
|
-
|
|
147
|
+
SERVER_PORT=$(docker compose -f docker-compose.test.yml port server 8080 | sed 's/.*://')
|
|
148
|
+
DURABLE_WORKFLOW_SERVER_URL="http://127.0.0.1:$SERVER_PORT" DURABLE_WORKFLOW_AUTH_TOKEN=test-token pytest tests/integration/ -v
|
|
132
149
|
docker compose -f docker-compose.test.yml down -v
|
|
133
150
|
```
|
|
134
151
|
|
|
152
|
+
Candidate cooperative cancellation qualification is explicit. In a manual CI
|
|
153
|
+
run, supply an exact public `server_commit` and set `cooperative_qualification`
|
|
154
|
+
to true. CI verifies that checkout, builds the candidate Server, enables protocol
|
|
155
|
+
1.20, runs the connected cases and retains JUnit, raw observations, image
|
|
156
|
+
authority and exact source provenance. An optional exact `native_commit` mounts
|
|
157
|
+
that public Native checkout read-only into the test stack. The image's published
|
|
158
|
+
Composer authority stays intact and the evidence identifies the source overlay.
|
|
159
|
+
These are source qualification runs. For a local
|
|
160
|
+
candidate, set `DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION=1.20` before starting
|
|
161
|
+
Compose and `DURABLE_WORKFLOW_COOPERATIVE_QUALIFICATION=1` for pytest. These cases
|
|
162
|
+
fail if the runtime does not discover the required capability. Ordinary CI
|
|
163
|
+
keeps protocol 1.19 and skips this unpublished feature's connected cases.
|
|
164
|
+
|
|
135
165
|
## License
|
|
136
166
|
|
|
137
167
|
[MIT](LICENSE)
|
|
@@ -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.4.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.4.0"
|
|
75
|
+
registry-version = "2.4.0"
|
|
76
|
+
supported-server-versions = "2.5.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -16,6 +16,14 @@ from .auth_composition import (
|
|
|
16
16
|
AuthCompositionContractError,
|
|
17
17
|
parse_auth_composition_contract,
|
|
18
18
|
)
|
|
19
|
+
from .cancellation import (
|
|
20
|
+
CancellationContext,
|
|
21
|
+
CancellationLineage,
|
|
22
|
+
CancellationPolicy,
|
|
23
|
+
ParentClosePolicy,
|
|
24
|
+
ScopedCancellationContext,
|
|
25
|
+
ScopedCancellationLineage,
|
|
26
|
+
)
|
|
19
27
|
from .client import (
|
|
20
28
|
BridgeAdapterOutcome,
|
|
21
29
|
Client,
|
|
@@ -237,6 +245,12 @@ __all__ = [
|
|
|
237
245
|
"ActivityInterceptorContext",
|
|
238
246
|
"ActivityRetryPolicy",
|
|
239
247
|
"BridgeAdapterOutcome",
|
|
248
|
+
"CancellationContext",
|
|
249
|
+
"CancellationLineage",
|
|
250
|
+
"ScopedCancellationContext",
|
|
251
|
+
"ScopedCancellationLineage",
|
|
252
|
+
"CancellationPolicy",
|
|
253
|
+
"ParentClosePolicy",
|
|
240
254
|
"ChildWorkflowRetryPolicy",
|
|
241
255
|
"ChildWorkflowCancelled",
|
|
242
256
|
"ChildWorkflowFailed",
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Owned callback processes for the unfinished cooperative worker.
|
|
2
|
+
|
|
3
|
+
The supervisor never unpickles or invokes the serialized application callback.
|
|
4
|
+
Single-byte control messages keep owner disconnect observable even if an
|
|
5
|
+
application payload is large or its writer is killed. Attempt-local private
|
|
6
|
+
files carry payloads. This module does not grant or report Server authority.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import multiprocessing
|
|
14
|
+
import pickle
|
|
15
|
+
import selectors
|
|
16
|
+
import shutil
|
|
17
|
+
import socket
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import traceback
|
|
21
|
+
from collections.abc import Awaitable, Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from multiprocessing.process import BaseProcess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, cast
|
|
26
|
+
|
|
27
|
+
from .activity import ActivityContext, ActivityInfo, _set_context
|
|
28
|
+
from .errors import ActivityCancelled, NonRetryableError
|
|
29
|
+
from .interceptors import ActivityInterceptorContext, WorkerInterceptor
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CallbackProcessLost(RuntimeError):
|
|
33
|
+
"""The owner could not prove the callback outcome or physical stop."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CallbackInvocation:
|
|
38
|
+
handler: Callable[..., Any]
|
|
39
|
+
args: tuple[Any, ...]
|
|
40
|
+
info: ActivityInfo
|
|
41
|
+
task: dict[str, Any]
|
|
42
|
+
interceptors: tuple[WorkerInterceptor, ...] = ()
|
|
43
|
+
|
|
44
|
+
def serialize(self) -> bytes:
|
|
45
|
+
try:
|
|
46
|
+
return pickle.dumps(self, protocol=pickle.HIGHEST_PROTOCOL)
|
|
47
|
+
except Exception as error:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"cooperative activity {self.info.activity_type!r} requires a spawn-compatible "
|
|
50
|
+
"importable handler, arguments and interceptors"
|
|
51
|
+
) from error
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CallbackFailure:
|
|
56
|
+
message: str
|
|
57
|
+
failure_type: str
|
|
58
|
+
failure_class: str
|
|
59
|
+
failure_code: int | None
|
|
60
|
+
stack_trace: str
|
|
61
|
+
non_retryable: bool
|
|
62
|
+
cancelled: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CallbackOutcome:
|
|
67
|
+
value: Any = None
|
|
68
|
+
failure: CallbackFailure | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_payload(directory: str, name: str, payload: Any) -> None:
|
|
72
|
+
destination = Path(directory, name)
|
|
73
|
+
temporary = destination.with_suffix(".writing")
|
|
74
|
+
with temporary.open("wb") as stream:
|
|
75
|
+
pickle.dump(payload, stream, protocol=pickle.HIGHEST_PROTOCOL)
|
|
76
|
+
temporary.replace(destination)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_payload(directory: str, name: str) -> Any:
|
|
80
|
+
with Path(directory, name).open("rb") as stream:
|
|
81
|
+
return pickle.load(stream)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _callback(directory: str, channel: socket.socket) -> None:
|
|
85
|
+
# Deserialize only in the process that is permitted to run application code.
|
|
86
|
+
heartbeat_lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
def heartbeat_request(details: dict[str, Any] | None) -> None:
|
|
89
|
+
with heartbeat_lock:
|
|
90
|
+
_write_payload(directory, "heartbeat", details)
|
|
91
|
+
channel.sendall(b"H")
|
|
92
|
+
if channel.recv(1) != b"Y":
|
|
93
|
+
raise ActivityCancelled()
|
|
94
|
+
|
|
95
|
+
async def heartbeat(details: dict[str, Any] | None) -> None:
|
|
96
|
+
await asyncio.to_thread(heartbeat_request, details)
|
|
97
|
+
|
|
98
|
+
async def invoke(invocation: CallbackInvocation) -> Any:
|
|
99
|
+
_set_context(ActivityContext(info=invocation.info, client=cast(Any, None), heartbeat_callback=heartbeat))
|
|
100
|
+
context = ActivityInterceptorContext(
|
|
101
|
+
worker_id=invocation.info.worker_id, task_queue=invocation.info.task_queue,
|
|
102
|
+
task=invocation.task, activity_type=invocation.info.activity_type, args=invocation.args,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def call(ctx: ActivityInterceptorContext) -> Any:
|
|
106
|
+
value = (invocation.handler(*ctx.args) if inspect.iscoroutinefunction(invocation.handler)
|
|
107
|
+
else await asyncio.to_thread(invocation.handler, *ctx.args))
|
|
108
|
+
return await value if inspect.isawaitable(value) else value
|
|
109
|
+
|
|
110
|
+
handler = call
|
|
111
|
+
for interceptor in reversed(invocation.interceptors):
|
|
112
|
+
next_handler = handler
|
|
113
|
+
|
|
114
|
+
async def intercepted(
|
|
115
|
+
ctx: ActivityInterceptorContext, *, interceptor: WorkerInterceptor = interceptor,
|
|
116
|
+
next_handler: Callable[[ActivityInterceptorContext], Awaitable[Any]] = next_handler,
|
|
117
|
+
) -> Any:
|
|
118
|
+
return await interceptor.execute_activity(ctx, next_handler)
|
|
119
|
+
|
|
120
|
+
handler = intercepted
|
|
121
|
+
try:
|
|
122
|
+
return await handler(context)
|
|
123
|
+
finally:
|
|
124
|
+
_set_context(None)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
invocation = pickle.loads(Path(directory, "invocation").read_bytes())
|
|
128
|
+
if not isinstance(invocation, CallbackInvocation):
|
|
129
|
+
raise TypeError("invalid callback invocation")
|
|
130
|
+
channel.sendall(b"B")
|
|
131
|
+
try:
|
|
132
|
+
_write_payload(directory, "outcome", CallbackOutcome(value=asyncio.run(invoke(invocation))))
|
|
133
|
+
except BaseException as error:
|
|
134
|
+
code = getattr(error, "code", None)
|
|
135
|
+
_write_payload(directory, "outcome", CallbackOutcome(failure=CallbackFailure(
|
|
136
|
+
message=str(error), failure_type=type(error).__name__,
|
|
137
|
+
failure_class=f"{type(error).__module__}.{type(error).__qualname__}",
|
|
138
|
+
failure_code=code if isinstance(code, int) and not isinstance(code, bool) else None,
|
|
139
|
+
stack_trace=traceback.format_exc(), non_retryable=isinstance(error, NonRetryableError),
|
|
140
|
+
cancelled=isinstance(error, ActivityCancelled),
|
|
141
|
+
)))
|
|
142
|
+
finally:
|
|
143
|
+
channel.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _stop_owned_callback(callback: BaseProcess) -> None:
|
|
147
|
+
if callback.is_alive():
|
|
148
|
+
callback.terminate()
|
|
149
|
+
callback.join(timeout=0.5)
|
|
150
|
+
if callback.is_alive():
|
|
151
|
+
callback.kill()
|
|
152
|
+
# If the OS cannot reap it yet, retain the supervisor and ownership. Never
|
|
153
|
+
# send stop evidence before an actual join, even after the owner disappears.
|
|
154
|
+
callback.join()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _supervise(directory: str, owner: socket.socket) -> None:
|
|
158
|
+
context = multiprocessing.get_context("spawn")
|
|
159
|
+
supervisor_channel, callback_channel = socket.socketpair()
|
|
160
|
+
callback = context.Process(target=_callback, args=(directory, callback_channel))
|
|
161
|
+
callback_started = False
|
|
162
|
+
stopped = False
|
|
163
|
+
try:
|
|
164
|
+
callback.start()
|
|
165
|
+
callback_started = True
|
|
166
|
+
callback_channel.close()
|
|
167
|
+
with selectors.DefaultSelector() as selector:
|
|
168
|
+
selector.register(owner, selectors.EVENT_READ, "owner")
|
|
169
|
+
selector.register(supervisor_channel, selectors.EVENT_READ, "callback")
|
|
170
|
+
while True:
|
|
171
|
+
for key, _ in selector.select(timeout=0.05):
|
|
172
|
+
message = cast(socket.socket, key.fileobj).recv(1)
|
|
173
|
+
if key.data == "owner":
|
|
174
|
+
if message in (b"", b"F"):
|
|
175
|
+
return
|
|
176
|
+
if message == b"S":
|
|
177
|
+
_stop_owned_callback(callback)
|
|
178
|
+
stopped = True
|
|
179
|
+
owner.sendall(b"X")
|
|
180
|
+
elif message in (b"Y", b"N") and not stopped:
|
|
181
|
+
supervisor_channel.sendall(message)
|
|
182
|
+
elif message in (b"B", b"H"):
|
|
183
|
+
owner.sendall(message)
|
|
184
|
+
elif message == b"":
|
|
185
|
+
selector.unregister(supervisor_channel)
|
|
186
|
+
if not stopped and callback.exitcode is not None:
|
|
187
|
+
callback.join()
|
|
188
|
+
stopped = True
|
|
189
|
+
owner.sendall(b"D")
|
|
190
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
191
|
+
pass # Owner disappeared. The finally block still owns and reaps its callback.
|
|
192
|
+
finally:
|
|
193
|
+
if callback_started:
|
|
194
|
+
_stop_owned_callback(callback)
|
|
195
|
+
callback.close()
|
|
196
|
+
callback_channel.close()
|
|
197
|
+
supervisor_channel.close()
|
|
198
|
+
owner.close()
|
|
199
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SupervisedCallback:
|
|
203
|
+
"""One owner, one supervisor and one isolated application callback.
|
|
204
|
+
|
|
205
|
+
The owner must serialize access to result()/stop(). Cancellation of result()
|
|
206
|
+
leaves the process owned until stop() proves shutdown. A killed supervisor
|
|
207
|
+
never supplies stop evidence. The worker integration remains a separate gate.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, invocation: CallbackInvocation) -> None:
|
|
211
|
+
serialized = invocation.serialize()
|
|
212
|
+
self.directory = tempfile.mkdtemp(prefix="dw-activity-")
|
|
213
|
+
try:
|
|
214
|
+
Path(self.directory, "invocation").write_bytes(serialized)
|
|
215
|
+
except BaseException:
|
|
216
|
+
shutil.rmtree(self.directory, ignore_errors=True)
|
|
217
|
+
raise
|
|
218
|
+
self._owner, self._supervisor_channel = socket.socketpair()
|
|
219
|
+
self._owner.setblocking(False)
|
|
220
|
+
self._supervisor = multiprocessing.get_context("spawn").Process(
|
|
221
|
+
target=_supervise, args=(self.directory, self._supervisor_channel),
|
|
222
|
+
)
|
|
223
|
+
self._started = False
|
|
224
|
+
self._closed = False
|
|
225
|
+
self._joined = False
|
|
226
|
+
self._exitcode: int | None = None
|
|
227
|
+
self.stopped = False
|
|
228
|
+
|
|
229
|
+
async def start(self) -> None:
|
|
230
|
+
try:
|
|
231
|
+
# Pass only the private directory and control socket to spawn. The
|
|
232
|
+
# payload is already serialized, and this short start cannot leave
|
|
233
|
+
# a background spawn thread untracked if the owner is cancelled.
|
|
234
|
+
self._supervisor.start()
|
|
235
|
+
self._started = True
|
|
236
|
+
self._supervisor_channel.close()
|
|
237
|
+
message = await asyncio.wait_for(self._receive(), timeout=5.0)
|
|
238
|
+
if message != b"B":
|
|
239
|
+
raise CallbackProcessLost("callback did not prove spawn readiness")
|
|
240
|
+
except BaseException:
|
|
241
|
+
await self.close()
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
async def _receive(self) -> bytes:
|
|
245
|
+
try:
|
|
246
|
+
message = await asyncio.get_running_loop().sock_recv(self._owner, 1)
|
|
247
|
+
except (ConnectionResetError, OSError) as error:
|
|
248
|
+
raise CallbackProcessLost("callback supervisor disconnected") from error
|
|
249
|
+
if not message:
|
|
250
|
+
raise CallbackProcessLost("callback supervisor disconnected without stop evidence")
|
|
251
|
+
return message
|
|
252
|
+
|
|
253
|
+
async def _send(self, message: bytes) -> None:
|
|
254
|
+
await asyncio.get_running_loop().sock_sendall(self._owner, message)
|
|
255
|
+
|
|
256
|
+
async def result(self, heartbeat: Callable[[dict[str, Any] | None], Awaitable[None]]) -> CallbackOutcome:
|
|
257
|
+
while True:
|
|
258
|
+
message = await self._receive()
|
|
259
|
+
if message == b"H":
|
|
260
|
+
await heartbeat(_read_payload(self.directory, "heartbeat"))
|
|
261
|
+
await self._send(b"Y")
|
|
262
|
+
elif message == b"D":
|
|
263
|
+
outcome = _read_payload(self.directory, "outcome")
|
|
264
|
+
if not isinstance(outcome, CallbackOutcome):
|
|
265
|
+
raise CallbackProcessLost("callback did not produce a typed outcome")
|
|
266
|
+
await self._finish()
|
|
267
|
+
return outcome
|
|
268
|
+
else:
|
|
269
|
+
raise CallbackProcessLost("callback supervisor returned an unexpected result boundary")
|
|
270
|
+
|
|
271
|
+
async def stop(self) -> None:
|
|
272
|
+
if self.stopped:
|
|
273
|
+
return
|
|
274
|
+
async def wait_for_stop() -> None:
|
|
275
|
+
while await self._receive() not in (b"D", b"X"):
|
|
276
|
+
pass
|
|
277
|
+
await self._finish()
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
await self._send(b"S")
|
|
281
|
+
await asyncio.wait_for(wait_for_stop(), timeout=5.0)
|
|
282
|
+
except BaseException:
|
|
283
|
+
await self.close()
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
async def _finish(self) -> None:
|
|
287
|
+
# Once stop evidence arrives, cancellation cannot interrupt the OS
|
|
288
|
+
# join and leave a physically exited callback falsely unconfirmed.
|
|
289
|
+
join = asyncio.create_task(self._confirm_join())
|
|
290
|
+
cancelled = False
|
|
291
|
+
while not join.done():
|
|
292
|
+
try:
|
|
293
|
+
await asyncio.shield(join)
|
|
294
|
+
except asyncio.CancelledError:
|
|
295
|
+
cancelled = True
|
|
296
|
+
join.result()
|
|
297
|
+
if cancelled:
|
|
298
|
+
raise asyncio.CancelledError
|
|
299
|
+
|
|
300
|
+
async def _confirm_join(self) -> None:
|
|
301
|
+
await self._send(b"F")
|
|
302
|
+
await self.close()
|
|
303
|
+
if self._exitcode != 0:
|
|
304
|
+
raise CallbackProcessLost("callback supervisor did not confirm a clean join")
|
|
305
|
+
self.stopped = True
|
|
306
|
+
|
|
307
|
+
async def close(self) -> None:
|
|
308
|
+
if self._joined:
|
|
309
|
+
return
|
|
310
|
+
if not self._closed:
|
|
311
|
+
self._closed = True
|
|
312
|
+
self._owner.close()
|
|
313
|
+
self._supervisor_channel.close()
|
|
314
|
+
if self._started:
|
|
315
|
+
await asyncio.to_thread(self._supervisor.join, 5.0)
|
|
316
|
+
if self._supervisor.is_alive():
|
|
317
|
+
raise CallbackProcessLost("callback supervisor has not confirmed shutdown")
|
|
318
|
+
self._exitcode = self._supervisor.exitcode
|
|
319
|
+
else:
|
|
320
|
+
shutil.rmtree(self.directory, ignore_errors=True)
|
|
321
|
+
self._supervisor.close()
|
|
322
|
+
self._joined = True
|