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.
Files changed (121) hide show
  1. {durable_workflow-2.3.8/src/durable_workflow.egg-info → durable_workflow-2.4.0}/PKG-INFO +32 -2
  2. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/README.md +31 -1
  3. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/pyproject.toml +4 -4
  4. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/__init__.py +14 -0
  5. durable_workflow-2.4.0/src/durable_workflow/_activity_process.py +322 -0
  6. durable_workflow-2.4.0/src/durable_workflow/_cancellation_scope.py +221 -0
  7. durable_workflow-2.4.0/src/durable_workflow/_cancellation_scope_history.py +775 -0
  8. durable_workflow-2.4.0/src/durable_workflow/_cooperative_cancellation.py +279 -0
  9. durable_workflow-2.4.0/src/durable_workflow/_prepared_local_activity.py +371 -0
  10. durable_workflow-2.4.0/src/durable_workflow/cancellation.py +430 -0
  11. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/client.py +543 -6
  12. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/errors.py +8 -1
  13. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/worker.py +1023 -47
  14. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/workflow.py +1240 -25
  15. {durable_workflow-2.3.8 → durable_workflow-2.4.0/src/durable_workflow.egg-info}/PKG-INFO +32 -2
  16. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/SOURCES.txt +30 -0
  17. durable_workflow-2.4.0/tests/test_activity_cancellation_policies.py +136 -0
  18. durable_workflow-2.4.0/tests/test_activity_process.py +270 -0
  19. durable_workflow-2.4.0/tests/test_cancellation_context.py +203 -0
  20. durable_workflow-2.4.0/tests/test_cancellation_remaining_time.py +265 -0
  21. durable_workflow-2.4.0/tests/test_cancellation_scope_admission.py +108 -0
  22. durable_workflow-2.4.0/tests/test_cancellation_scope_authoring.py +372 -0
  23. durable_workflow-2.4.0/tests/test_cancellation_scope_boundary.py +335 -0
  24. durable_workflow-2.4.0/tests/test_cancellation_scope_opening.py +209 -0
  25. durable_workflow-2.4.0/tests/test_child_workflow_policies.py +261 -0
  26. durable_workflow-2.4.0/tests/test_ci_checkout.py +120 -0
  27. durable_workflow-2.4.0/tests/test_client_timeouts.py +93 -0
  28. durable_workflow-2.4.0/tests/test_committed_cancellation_scope_descendants.py +342 -0
  29. durable_workflow-2.4.0/tests/test_committed_cancellation_scope_groups.py +227 -0
  30. durable_workflow-2.4.0/tests/test_committed_cancellation_scope_history.py +229 -0
  31. durable_workflow-2.4.0/tests/test_committed_cancellation_scope_replay.py +276 -0
  32. durable_workflow-2.4.0/tests/test_cooperative_cancellation.py +425 -0
  33. durable_workflow-2.4.0/tests/test_cooperative_cancellation_client.py +503 -0
  34. durable_workflow-2.4.0/tests/test_cooperative_cancellation_worker.py +611 -0
  35. durable_workflow-2.4.0/tests/test_cooperative_remote_worker.py +335 -0
  36. durable_workflow-2.4.0/tests/test_prepared_local_activity_client.py +163 -0
  37. durable_workflow-2.4.0/tests/test_prepared_local_activity_groups.py +390 -0
  38. durable_workflow-2.4.0/tests/test_prepared_local_activity_policies.py +206 -0
  39. durable_workflow-2.4.0/tests/test_prepared_local_activity_worker.py +578 -0
  40. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay_regression_corpus.py +50 -5
  41. durable_workflow-2.4.0/tests/test_run_cancellation_scope_replay.py +171 -0
  42. durable_workflow-2.4.0/tests/test_scoped_run_cancellation_context.py +201 -0
  43. durable_workflow-2.3.8/tests/test_ci_checkout.py +0 -74
  44. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/LICENSE +0 -0
  45. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/setup.cfg +0 -0
  46. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/_avro.py +0 -0
  47. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/activity.py +0 -0
  48. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/auth_composition.py +0 -0
  49. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/external_storage.py +0 -0
  50. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/external_task_input.py +0 -0
  51. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/external_task_result.py +0 -0
  52. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  53. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/interceptors.py +0 -0
  54. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/invocable.py +0 -0
  55. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/metrics.py +0 -0
  56. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/nexus.py +0 -0
  57. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/py.typed +0 -0
  58. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/python_conformance.py +0 -0
  59. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/replay_conformance.py +0 -0
  60. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/replay_verify.py +0 -0
  61. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/retry_policy.py +0 -0
  62. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/serializer.py +0 -0
  63. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/sync.py +0 -0
  64. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/testing.py +0 -0
  65. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/worker_session.py +0 -0
  66. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  67. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  68. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  69. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  70. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  71. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_activity_context.py +0 -0
  72. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_auth_composition.py +0 -0
  73. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_avro_value_benchmark.py +0 -0
  74. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_avro_value_protocol.py +0 -0
  75. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_ci_integration_endpoint.py +0 -0
  76. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_client.py +0 -0
  77. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_codec_regression_corpus.py +0 -0
  78. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  79. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_docstring_cross_references.py +0 -0
  80. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_durable_selection.py +0 -0
  81. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_errors.py +0 -0
  82. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_external_storage.py +0 -0
  83. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_external_task_input.py +0 -0
  84. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_external_task_result.py +0 -0
  85. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_golden_history_replay.py +0 -0
  86. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_history_bundle_verify.py +0 -0
  87. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_history_event_contract.py +0 -0
  88. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_invocable.py +0 -0
  89. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_message_streams.py +0 -0
  90. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_metrics.py +0 -0
  91. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_order_processing_example.py +0 -0
  92. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_parallel_completion_order_replay.py +0 -0
  93. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_parallel_saga_authoring.py +0 -0
  94. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_public_boundary_scanner.py +0 -0
  95. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_pypi_project_surface.py +0 -0
  96. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_python_conformance.py +0 -0
  97. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_queries.py +0 -0
  98. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_readme_quickstart.py +0 -0
  99. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_redrive_replay.py +0 -0
  100. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_release_metadata.py +0 -0
  101. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay.py +0 -0
  102. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay_conformance.py +0 -0
  103. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_replay_verify.py +0 -0
  104. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_retry_policy.py +0 -0
  105. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_runtime_external_payload_transport.py +0 -0
  106. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_schedules.py +0 -0
  107. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_serializer.py +0 -0
  108. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_signals.py +0 -0
  109. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_sleep.py +0 -0
  110. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_standalone_activity_client.py +0 -0
  111. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_storage_admission.py +0 -0
  112. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_sync.py +0 -0
  113. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_testing_harness.py +0 -0
  114. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_update_signal_condition_replay.py +0 -0
  115. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_updates.py +0 -0
  116. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_wait_condition.py +0 -0
  117. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_worker.py +0 -0
  118. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_worker_session.py +0 -0
  119. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_workflow_result_timeout.py +0 -0
  120. {durable_workflow-2.3.8 → durable_workflow-2.4.0}/tests/test_workflow_streams.py +0 -0
  121. {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.8
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
- pytest tests/integration/ -v
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
- pytest tests/integration/ -v
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.3.8"
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.3.8"
75
- registry-version = "2.3.8"
76
- supported-server-versions = "2.4.8"
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