durable-workflow 2.2.0__tar.gz → 2.3.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.2.0/src/durable_workflow.egg-info → durable_workflow-2.3.0}/PKG-INFO +1 -1
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/pyproject.toml +4 -4
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/__init__.py +3 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/client.py +35 -3
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/worker.py +54 -0
- durable_workflow-2.3.0/src/durable_workflow/worker_session.py +115 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/workflow.py +8 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_release_metadata.py +1 -1
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_worker.py +8 -2
- durable_workflow-2.3.0/tests/test_worker_session.py +118 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/LICENSE +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/README.md +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/setup.cfg +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_client.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -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.3.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.4.
|
|
74
|
+
product-train = "2.3.0"
|
|
75
|
+
registry-version = "2.3.0"
|
|
76
|
+
supported-server-versions = "2.4.2"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
79
79
|
durable-selection = true
|
|
@@ -210,6 +210,7 @@ from .serializer import (
|
|
|
210
210
|
to_avro_payload_values,
|
|
211
211
|
)
|
|
212
212
|
from .worker import Worker
|
|
213
|
+
from .worker_session import WorkerSession, WorkerSessionOptions
|
|
213
214
|
from .workflow import (
|
|
214
215
|
ActivityRetryPolicy,
|
|
215
216
|
ChildWorkflowRetryPolicy,
|
|
@@ -290,6 +291,8 @@ __all__ = [
|
|
|
290
291
|
"TaskQueueQueryAdmission",
|
|
291
292
|
"TaskQueueTaskAdmission",
|
|
292
293
|
"Worker",
|
|
294
|
+
"WorkerSession",
|
|
295
|
+
"WorkerSessionOptions",
|
|
293
296
|
"WorkerInterceptor",
|
|
294
297
|
"WorkflowCommandResult",
|
|
295
298
|
"WorkflowExecution",
|
|
@@ -31,7 +31,7 @@ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
|
31
31
|
from dataclasses import dataclass
|
|
32
32
|
from importlib.metadata import PackageNotFoundError
|
|
33
33
|
from importlib.metadata import version as _pkg_version
|
|
34
|
-
from typing import Any
|
|
34
|
+
from typing import Any, cast
|
|
35
35
|
from urllib.parse import quote, unquote, urlencode, urlsplit
|
|
36
36
|
|
|
37
37
|
import httpx
|
|
@@ -61,6 +61,7 @@ from .external_storage import (
|
|
|
61
61
|
from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
|
|
62
62
|
from .nexus import NexusOperationResult, nexus_request_payload
|
|
63
63
|
from .retry_policy import TransportRetryPolicy
|
|
64
|
+
from .worker_session import WorkerSessionOptions
|
|
64
65
|
|
|
65
66
|
PROTOCOL_VERSION = "1.19"
|
|
66
67
|
CONTROL_PLANE_VERSION = "2"
|
|
@@ -71,9 +72,9 @@ PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST: dict[str, dict[str, str | bool]] =
|
|
|
71
72
|
"implementation": "record_local_activity",
|
|
72
73
|
},
|
|
73
74
|
"worker_sessions": {
|
|
74
|
-
"supported":
|
|
75
|
+
"supported": True,
|
|
75
76
|
"minimum_protocol_version": "1.18",
|
|
76
|
-
"
|
|
77
|
+
"implementation": "typed_worker_session",
|
|
77
78
|
},
|
|
78
79
|
"sticky_execution": {
|
|
79
80
|
"supported": False,
|
|
@@ -4670,6 +4671,7 @@ class Client:
|
|
|
4670
4671
|
supported_activity_types: list[str] | None = None,
|
|
4671
4672
|
max_concurrent_workflow_tasks: int | None = None,
|
|
4672
4673
|
max_concurrent_activity_tasks: int | None = None,
|
|
4674
|
+
max_concurrent_worker_sessions: int | None = None,
|
|
4673
4675
|
runtime: str = "python",
|
|
4674
4676
|
sdk_version: str | None = None,
|
|
4675
4677
|
build_id: str | None = None,
|
|
@@ -4693,6 +4695,8 @@ class Client:
|
|
|
4693
4695
|
raise ValueError("max_concurrent_workflow_tasks must be at least 1")
|
|
4694
4696
|
if max_concurrent_activity_tasks is not None and max_concurrent_activity_tasks < 1:
|
|
4695
4697
|
raise ValueError("max_concurrent_activity_tasks must be at least 1")
|
|
4698
|
+
if max_concurrent_worker_sessions is not None and max_concurrent_worker_sessions < 1:
|
|
4699
|
+
raise ValueError("max_concurrent_worker_sessions must be at least 1")
|
|
4696
4700
|
if (
|
|
4697
4701
|
capabilities
|
|
4698
4702
|
and _MESSAGE_STREAMS_CAPABILITY in capabilities
|
|
@@ -4721,6 +4725,8 @@ class Client:
|
|
|
4721
4725
|
body["max_concurrent_workflow_tasks"] = max_concurrent_workflow_tasks
|
|
4722
4726
|
if max_concurrent_activity_tasks is not None:
|
|
4723
4727
|
body["max_concurrent_activity_tasks"] = max_concurrent_activity_tasks
|
|
4728
|
+
if max_concurrent_worker_sessions is not None:
|
|
4729
|
+
body["max_concurrent_worker_sessions"] = max_concurrent_worker_sessions
|
|
4724
4730
|
if task_slots is not None:
|
|
4725
4731
|
body["task_slots"] = task_slots
|
|
4726
4732
|
if process_metrics is not None:
|
|
@@ -4729,6 +4735,32 @@ class Client:
|
|
|
4729
4735
|
body["heartbeat_interval_seconds"] = heartbeat_interval_seconds
|
|
4730
4736
|
return await self._request("POST", "/worker/register", worker=True, json=body)
|
|
4731
4737
|
|
|
4738
|
+
async def create_worker_session(
|
|
4739
|
+
self, worker_id: str, options: WorkerSessionOptions
|
|
4740
|
+
) -> dict[str, Any]:
|
|
4741
|
+
return cast(dict[str, Any], await self._request(
|
|
4742
|
+
"POST", "/worker/sessions", worker=True,
|
|
4743
|
+
json={"worker_id": worker_id, **options.to_wire()},
|
|
4744
|
+
))
|
|
4745
|
+
|
|
4746
|
+
async def renew_worker_session(
|
|
4747
|
+
self, worker_id: str, session_id: str, lease_seconds: int
|
|
4748
|
+
) -> dict[str, Any]:
|
|
4749
|
+
if lease_seconds < 1:
|
|
4750
|
+
raise ValueError("worker session lease_seconds must be positive")
|
|
4751
|
+
return cast(dict[str, Any], await self._request(
|
|
4752
|
+
"POST", f"/worker/sessions/{quote(session_id, safe='')}/heartbeat", worker=True,
|
|
4753
|
+
json={"worker_id": worker_id, "lease_seconds": lease_seconds},
|
|
4754
|
+
))
|
|
4755
|
+
|
|
4756
|
+
async def close_worker_session(
|
|
4757
|
+
self, worker_id: str, session_id: str, reason: str = "worker_shutdown"
|
|
4758
|
+
) -> dict[str, Any]:
|
|
4759
|
+
return cast(dict[str, Any], await self._request(
|
|
4760
|
+
"DELETE", f"/worker/sessions/{quote(session_id, safe='')}", worker=True,
|
|
4761
|
+
json={"worker_id": worker_id, "reason": reason},
|
|
4762
|
+
))
|
|
4763
|
+
|
|
4732
4764
|
async def deregister_worker_registration(self, worker_id: str) -> dict[str, Any]:
|
|
4733
4765
|
"""Remove this runtime's successful worker-plane registration.
|
|
4734
4766
|
|
|
@@ -79,6 +79,7 @@ from .metrics import (
|
|
|
79
79
|
MetricsRecorder,
|
|
80
80
|
)
|
|
81
81
|
from .retry_policy import _worker_storage_admission_stop
|
|
82
|
+
from .worker_session import WorkerSession, WorkerSessionOptions
|
|
82
83
|
from .workflow import (
|
|
83
84
|
Command,
|
|
84
85
|
FailWorkflow,
|
|
@@ -985,11 +986,13 @@ class Worker:
|
|
|
985
986
|
task_queue: str,
|
|
986
987
|
workflows: Iterable[type] = (),
|
|
987
988
|
activities: Iterable[Callable[..., Any]] = (),
|
|
989
|
+
capabilities: Iterable[str] = (),
|
|
988
990
|
worker_id: str | None = None,
|
|
989
991
|
build_id: str | None = None,
|
|
990
992
|
poll_timeout: float = 35.0,
|
|
991
993
|
max_concurrent_workflow_tasks: int = 10,
|
|
992
994
|
max_concurrent_activity_tasks: int = 10,
|
|
995
|
+
max_concurrent_worker_sessions: int = 10,
|
|
993
996
|
shutdown_timeout: float = 30.0,
|
|
994
997
|
heartbeat_interval: float = 60.0,
|
|
995
998
|
metrics: MetricsRecorder | None = None,
|
|
@@ -1010,6 +1013,9 @@ class Worker:
|
|
|
1010
1013
|
for workflow_type, workflow_cls in self.workflows.items()
|
|
1011
1014
|
}
|
|
1012
1015
|
self.activities = {_activity_name(a): a for a in activities}
|
|
1016
|
+
self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
|
|
1017
|
+
if any(not capability for capability in self.capabilities):
|
|
1018
|
+
raise ValueError("worker capabilities must be non-empty strings")
|
|
1013
1019
|
self.worker_id = worker_id or f"py-worker-{uuid.uuid4().hex[:8]}"
|
|
1014
1020
|
if build_id is not None:
|
|
1015
1021
|
if not isinstance(build_id, str) or build_id.strip() == "":
|
|
@@ -1022,6 +1028,8 @@ class Worker:
|
|
|
1022
1028
|
raise ValueError("max_concurrent_workflow_tasks must be at least 1")
|
|
1023
1029
|
if max_concurrent_activity_tasks < 1:
|
|
1024
1030
|
raise ValueError("max_concurrent_activity_tasks must be at least 1")
|
|
1031
|
+
if max_concurrent_worker_sessions < 1:
|
|
1032
|
+
raise ValueError("max_concurrent_worker_sessions must be at least 1")
|
|
1025
1033
|
if heartbeat_interval <= 0:
|
|
1026
1034
|
raise ValueError("heartbeat_interval must be positive")
|
|
1027
1035
|
|
|
@@ -1029,6 +1037,8 @@ class Worker:
|
|
|
1029
1037
|
self._poll_http_timeout = poll_timeout
|
|
1030
1038
|
self.max_concurrent_workflow_tasks = max_concurrent_workflow_tasks
|
|
1031
1039
|
self.max_concurrent_activity_tasks = max_concurrent_activity_tasks
|
|
1040
|
+
self.max_concurrent_worker_sessions = max_concurrent_worker_sessions
|
|
1041
|
+
self._worker_sessions: dict[str, WorkerSession] = {}
|
|
1032
1042
|
self._stop = asyncio.Event()
|
|
1033
1043
|
self._wf_semaphore = asyncio.Semaphore(max_concurrent_workflow_tasks)
|
|
1034
1044
|
self._act_semaphore = asyncio.Semaphore(max_concurrent_activity_tasks)
|
|
@@ -1192,6 +1202,9 @@ class Worker:
|
|
|
1192
1202
|
if any(contract["updates"] for contract in self.workflow_command_contracts.values()):
|
|
1193
1203
|
capabilities.append(WORKFLOW_UPDATES_CAPABILITY)
|
|
1194
1204
|
capabilities.append(MESSAGE_STREAMS_CAPABILITY)
|
|
1205
|
+
capabilities.extend(self.capabilities)
|
|
1206
|
+
if PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST["worker_sessions"]["supported"]:
|
|
1207
|
+
capabilities.append("worker_sessions")
|
|
1195
1208
|
|
|
1196
1209
|
ack = await self.client.register_worker(
|
|
1197
1210
|
worker_id=self.worker_id,
|
|
@@ -1202,6 +1215,7 @@ class Worker:
|
|
|
1202
1215
|
supported_activity_types=list(self.activities),
|
|
1203
1216
|
max_concurrent_workflow_tasks=self.max_concurrent_workflow_tasks,
|
|
1204
1217
|
max_concurrent_activity_tasks=self.max_concurrent_activity_tasks,
|
|
1218
|
+
max_concurrent_worker_sessions=self.max_concurrent_worker_sessions,
|
|
1205
1219
|
build_id=self.build_id,
|
|
1206
1220
|
capabilities=capabilities,
|
|
1207
1221
|
capability_manifest=PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST,
|
|
@@ -1988,6 +2002,7 @@ class Worker:
|
|
|
1988
2002
|
)
|
|
1989
2003
|
|
|
1990
2004
|
async def _run_activity_task(self, task: dict[str, Any]) -> str:
|
|
2005
|
+
self._track_worker_session_from_task(task)
|
|
1991
2006
|
task_id: str = task["task_id"]
|
|
1992
2007
|
attempt_id: str = task.get("activity_attempt_id") or task.get("attempt_id", "")
|
|
1993
2008
|
activity_type: str = task.get("activity_type", "")
|
|
@@ -3025,6 +3040,10 @@ class Worker:
|
|
|
3025
3040
|
"activity_available": max(
|
|
3026
3041
|
0, self.max_concurrent_activity_tasks - self._activity_inflight
|
|
3027
3042
|
),
|
|
3043
|
+
"session_available": max(
|
|
3044
|
+
0, self.max_concurrent_worker_sessions
|
|
3045
|
+
- sum(session.active for session in self._worker_sessions.values())
|
|
3046
|
+
),
|
|
3028
3047
|
}
|
|
3029
3048
|
|
|
3030
3049
|
def _current_process_metrics(self) -> dict[str, Any]:
|
|
@@ -3263,6 +3282,30 @@ class Worker:
|
|
|
3263
3282
|
raise RuntimeError("worker cannot run after stop() has been called")
|
|
3264
3283
|
self._run_started = True
|
|
3265
3284
|
|
|
3285
|
+
def worker_session(self, options: WorkerSessionOptions) -> WorkerSession:
|
|
3286
|
+
"""Return a typed lease handle held by this worker until shutdown."""
|
|
3287
|
+
existing = self._worker_sessions.get(options.session_id)
|
|
3288
|
+
if existing is not None:
|
|
3289
|
+
if existing.options != options:
|
|
3290
|
+
raise ValueError("worker session id already has different options")
|
|
3291
|
+
return existing
|
|
3292
|
+
session = WorkerSession(self.client, self.worker_id, options)
|
|
3293
|
+
self._worker_sessions[options.session_id] = session
|
|
3294
|
+
return session
|
|
3295
|
+
|
|
3296
|
+
def _track_worker_session_from_task(self, task: dict[str, Any]) -> None:
|
|
3297
|
+
raw = task.get("worker_session")
|
|
3298
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("session_id"), str):
|
|
3299
|
+
return
|
|
3300
|
+
try:
|
|
3301
|
+
options = WorkerSessionOptions.from_wire(raw)
|
|
3302
|
+
session = self._worker_sessions.get(options.session_id)
|
|
3303
|
+
if session is None:
|
|
3304
|
+
session = self.worker_session(options)
|
|
3305
|
+
session._track_leased_task()
|
|
3306
|
+
except (ValueError, TypeError) as error:
|
|
3307
|
+
log.warning("worker session metadata is invalid: %s", error)
|
|
3308
|
+
|
|
3266
3309
|
async def stop(self) -> None:
|
|
3267
3310
|
"""Stop polling, drain tasks, and remove the worker registration.
|
|
3268
3311
|
|
|
@@ -3321,6 +3364,17 @@ class Worker:
|
|
|
3321
3364
|
)
|
|
3322
3365
|
await asyncio.gather(*in_flight, return_exceptions=True)
|
|
3323
3366
|
|
|
3367
|
+
for session in self._worker_sessions.values():
|
|
3368
|
+
if not session.active:
|
|
3369
|
+
continue
|
|
3370
|
+
try:
|
|
3371
|
+
await asyncio.wait_for(
|
|
3372
|
+
session.close("worker_shutdown"),
|
|
3373
|
+
timeout=self._remaining_shutdown_time(deadline),
|
|
3374
|
+
)
|
|
3375
|
+
except Exception as error:
|
|
3376
|
+
log.warning("worker session %s close failed: %s", session.options.session_id, error)
|
|
3377
|
+
|
|
3324
3378
|
if self._registered:
|
|
3325
3379
|
await self.client.deregister_worker_registration(self.worker_id)
|
|
3326
3380
|
self._registered = False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Typed worker-held session leases for activity routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .client import Client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class WorkerSessionOptions:
|
|
15
|
+
session_id: str
|
|
16
|
+
queue: str | None = None
|
|
17
|
+
requirements: tuple[str, ...] = ()
|
|
18
|
+
lease_seconds: int = 120
|
|
19
|
+
ttl_seconds: int = 1800
|
|
20
|
+
max_concurrent_activities: int = 1
|
|
21
|
+
create_if_missing: bool = True
|
|
22
|
+
allow_reacquire_after_failure: bool = True
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
session_id = self.session_id.strip()
|
|
26
|
+
if not session_id:
|
|
27
|
+
raise ValueError("worker session id must be a non-empty string")
|
|
28
|
+
object.__setattr__(self, "session_id", session_id)
|
|
29
|
+
if self.queue is not None and not self.queue.strip():
|
|
30
|
+
raise ValueError("worker session queue must be a non-empty string when provided")
|
|
31
|
+
for name in ("lease_seconds", "ttl_seconds", "max_concurrent_activities"):
|
|
32
|
+
if getattr(self, name) < 1:
|
|
33
|
+
raise ValueError(f"worker session {name} must be positive")
|
|
34
|
+
requirements = tuple(dict.fromkeys(requirement.strip() for requirement in self.requirements))
|
|
35
|
+
if any(not requirement for requirement in requirements):
|
|
36
|
+
raise ValueError("worker session requirements must be non-empty strings")
|
|
37
|
+
object.__setattr__(self, "requirements", requirements)
|
|
38
|
+
|
|
39
|
+
def to_wire(self) -> dict[str, Any]:
|
|
40
|
+
wire: dict[str, Any] = {
|
|
41
|
+
"session_id": self.session_id,
|
|
42
|
+
"requirements": list(self.requirements),
|
|
43
|
+
"lease_seconds": self.lease_seconds,
|
|
44
|
+
"ttl_seconds": self.ttl_seconds,
|
|
45
|
+
"max_concurrent_activities": self.max_concurrent_activities,
|
|
46
|
+
"create_if_missing": self.create_if_missing,
|
|
47
|
+
"allow_reacquire_after_failure": self.allow_reacquire_after_failure,
|
|
48
|
+
}
|
|
49
|
+
if self.queue is not None:
|
|
50
|
+
wire["queue"] = self.queue
|
|
51
|
+
return wire
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_wire(cls, wire: dict[str, Any]) -> WorkerSessionOptions:
|
|
55
|
+
return cls(
|
|
56
|
+
session_id=wire["session_id"],
|
|
57
|
+
queue=wire.get("queue"),
|
|
58
|
+
requirements=tuple(wire.get("requirements") or ()),
|
|
59
|
+
lease_seconds=wire.get("lease_seconds", 120),
|
|
60
|
+
ttl_seconds=wire.get("ttl_seconds", 1800),
|
|
61
|
+
max_concurrent_activities=wire.get("max_concurrent_activities", 1),
|
|
62
|
+
create_if_missing=wire.get("create_if_missing", True),
|
|
63
|
+
allow_reacquire_after_failure=wire.get("allow_reacquire_after_failure", True),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WorkerSession:
|
|
68
|
+
"""A lease owned by one registered worker; state must be rebuilt after holder loss."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, client: Client, worker_id: str, options: WorkerSessionOptions) -> None:
|
|
71
|
+
self.client = client
|
|
72
|
+
self.worker_id = worker_id
|
|
73
|
+
self.options = options
|
|
74
|
+
self._active = False
|
|
75
|
+
self._close_response: dict[str, Any] | None = None
|
|
76
|
+
self._lock = asyncio.Lock()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def active(self) -> bool:
|
|
80
|
+
return self._active
|
|
81
|
+
|
|
82
|
+
async def create(self) -> dict[str, Any]:
|
|
83
|
+
async with self._lock:
|
|
84
|
+
response = await self.client.create_worker_session(self.worker_id, self.options)
|
|
85
|
+
self._active = True
|
|
86
|
+
self._close_response = None
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
async def renew(self, lease_seconds: int | None = None) -> dict[str, Any]:
|
|
90
|
+
async with self._lock:
|
|
91
|
+
if not self._active:
|
|
92
|
+
raise RuntimeError("a closed or uncreated worker session cannot be renewed")
|
|
93
|
+
requested_lease = self.options.lease_seconds if lease_seconds is None else lease_seconds
|
|
94
|
+
return await self.client.renew_worker_session(
|
|
95
|
+
self.worker_id, self.options.session_id, requested_lease
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async def close(self, reason: str = "worker_shutdown") -> dict[str, Any]:
|
|
99
|
+
async with self._lock:
|
|
100
|
+
if self._close_response is not None:
|
|
101
|
+
return self._close_response
|
|
102
|
+
response = await self.client.close_worker_session(self.worker_id, self.options.session_id, reason)
|
|
103
|
+
self._active = False
|
|
104
|
+
self._close_response = response
|
|
105
|
+
return response
|
|
106
|
+
|
|
107
|
+
def activity_options(self) -> dict[str, Any]:
|
|
108
|
+
return {"worker_session": self.options.to_wire()}
|
|
109
|
+
|
|
110
|
+
def rebuild_required_after_holder_loss(self) -> bool:
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def _track_leased_task(self) -> None:
|
|
114
|
+
self._active = True
|
|
115
|
+
self._close_response = None
|
|
@@ -49,6 +49,7 @@ from .errors import (
|
|
|
49
49
|
)
|
|
50
50
|
from .external_storage import ExternalPayloadCache, ExternalStorageDriver
|
|
51
51
|
from .nexus import NexusOperationResult, stable_nexus_idempotency_key
|
|
52
|
+
from .worker_session import WorkerSessionOptions
|
|
52
53
|
|
|
53
54
|
_WorkflowT = TypeVar("_WorkflowT")
|
|
54
55
|
|
|
@@ -467,6 +468,7 @@ class ScheduleActivity:
|
|
|
467
468
|
schedule_to_start_timeout: int | None = None
|
|
468
469
|
schedule_to_close_timeout: int | None = None
|
|
469
470
|
heartbeat_timeout: int | None = None
|
|
471
|
+
worker_session: WorkerSessionOptions | None = None
|
|
470
472
|
_parallel_group_path: list[dict[str, Any]] | None = field(
|
|
471
473
|
default=None,
|
|
472
474
|
init=False,
|
|
@@ -518,6 +520,8 @@ class ScheduleActivity:
|
|
|
518
520
|
command["schedule_to_close_timeout"] = self.schedule_to_close_timeout
|
|
519
521
|
if self.heartbeat_timeout is not None:
|
|
520
522
|
command["heartbeat_timeout"] = self.heartbeat_timeout
|
|
523
|
+
if self.worker_session is not None:
|
|
524
|
+
command["worker_session"] = self.worker_session.to_wire()
|
|
521
525
|
return command
|
|
522
526
|
|
|
523
527
|
def _validate_timeouts(self) -> None:
|
|
@@ -1400,6 +1404,8 @@ def commands_to_server_commands(
|
|
|
1400
1404
|
server_command["schedule_to_close_timeout"] = command.schedule_to_close_timeout
|
|
1401
1405
|
if command.heartbeat_timeout is not None:
|
|
1402
1406
|
server_command["heartbeat_timeout"] = command.heartbeat_timeout
|
|
1407
|
+
if command.worker_session is not None:
|
|
1408
|
+
server_command["worker_session"] = command.worker_session.to_wire()
|
|
1403
1409
|
server_commands.append(server_command)
|
|
1404
1410
|
continue
|
|
1405
1411
|
|
|
@@ -1831,6 +1837,7 @@ class WorkflowContext:
|
|
|
1831
1837
|
schedule_to_start_timeout: int | None = None,
|
|
1832
1838
|
schedule_to_close_timeout: int | None = None,
|
|
1833
1839
|
heartbeat_timeout: int | None = None,
|
|
1840
|
+
worker_session: WorkerSessionOptions | None = None,
|
|
1834
1841
|
) -> ScheduleActivity:
|
|
1835
1842
|
return ScheduleActivity(
|
|
1836
1843
|
activity_type=activity_type,
|
|
@@ -1841,6 +1848,7 @@ class WorkflowContext:
|
|
|
1841
1848
|
schedule_to_start_timeout=schedule_to_start_timeout,
|
|
1842
1849
|
schedule_to_close_timeout=schedule_to_close_timeout,
|
|
1843
1850
|
heartbeat_timeout=heartbeat_timeout,
|
|
1851
|
+
worker_session=worker_session,
|
|
1844
1852
|
)
|
|
1845
1853
|
|
|
1846
1854
|
def local_activity(
|
|
@@ -24,6 +24,7 @@ src/durable_workflow/serializer.py
|
|
|
24
24
|
src/durable_workflow/sync.py
|
|
25
25
|
src/durable_workflow/testing.py
|
|
26
26
|
src/durable_workflow/worker.py
|
|
27
|
+
src/durable_workflow/worker_session.py
|
|
27
28
|
src/durable_workflow/workflow.py
|
|
28
29
|
src/durable_workflow/workflow_updates_conformance.py
|
|
29
30
|
src/durable_workflow.egg-info/PKG-INFO
|
|
@@ -81,6 +82,7 @@ tests/test_update_signal_condition_replay.py
|
|
|
81
82
|
tests/test_updates.py
|
|
82
83
|
tests/test_wait_condition.py
|
|
83
84
|
tests/test_worker.py
|
|
85
|
+
tests/test_worker_session.py
|
|
84
86
|
tests/test_workflow_result_timeout.py
|
|
85
87
|
tests/test_workflow_streams.py
|
|
86
88
|
tests/test_workflow_updates_conformance.py
|
|
@@ -44,7 +44,7 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None
|
|
|
44
44
|
assert check_release_metadata.VERSION_PATTERN.fullmatch(project["version"]) is not None
|
|
45
45
|
assert release["product-train"] == project["version"]
|
|
46
46
|
assert release["registry-version"] == project["version"].replace("-rc.", "rc")
|
|
47
|
-
assert release["supported-server-versions"] == "2.4.
|
|
47
|
+
assert release["supported-server-versions"] == "2.4.2"
|
|
48
48
|
assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.19"
|
|
49
49
|
assert release["durable-selection"] is True
|
|
50
50
|
assert release["durable-selection-minimum-worker-protocol-version"] == "1.19"
|
|
@@ -422,6 +422,7 @@ class TestWorkerRegistration:
|
|
|
422
422
|
"supported_activity_types": ["test-act"],
|
|
423
423
|
"max_concurrent_workflow_tasks": 10,
|
|
424
424
|
"max_concurrent_activity_tasks": 10,
|
|
425
|
+
"max_concurrent_worker_sessions": 10,
|
|
425
426
|
"build_id": None,
|
|
426
427
|
"capabilities": [
|
|
427
428
|
"memo_upserts",
|
|
@@ -430,6 +431,7 @@ class TestWorkerRegistration:
|
|
|
430
431
|
"local_activities",
|
|
431
432
|
"query_tasks",
|
|
432
433
|
MESSAGE_STREAMS_CAPABILITY,
|
|
434
|
+
"worker_sessions",
|
|
433
435
|
],
|
|
434
436
|
"capability_manifest": {
|
|
435
437
|
"local_activities": {
|
|
@@ -438,9 +440,9 @@ class TestWorkerRegistration:
|
|
|
438
440
|
"implementation": "record_local_activity",
|
|
439
441
|
},
|
|
440
442
|
"worker_sessions": {
|
|
441
|
-
"supported":
|
|
443
|
+
"supported": True,
|
|
442
444
|
"minimum_protocol_version": "1.18",
|
|
443
|
-
"
|
|
445
|
+
"implementation": "typed_worker_session",
|
|
444
446
|
},
|
|
445
447
|
"sticky_execution": {
|
|
446
448
|
"supported": False,
|
|
@@ -451,6 +453,7 @@ class TestWorkerRegistration:
|
|
|
451
453
|
"task_slots": {
|
|
452
454
|
"workflow_available": 10,
|
|
453
455
|
"activity_available": 10,
|
|
456
|
+
"session_available": 10,
|
|
454
457
|
},
|
|
455
458
|
"process_metrics": process_metrics,
|
|
456
459
|
}
|
|
@@ -576,6 +579,7 @@ class TestWorkerRegistration:
|
|
|
576
579
|
"query_tasks",
|
|
577
580
|
WORKFLOW_UPDATES_CAPABILITY,
|
|
578
581
|
MESSAGE_STREAMS_CAPABILITY,
|
|
582
|
+
"worker_sessions",
|
|
579
583
|
]
|
|
580
584
|
|
|
581
585
|
@pytest.mark.asyncio
|
|
@@ -663,6 +667,7 @@ class TestWorkerRegistration:
|
|
|
663
667
|
UPDATE_VALIDATION_TASKS_CAPABILITY,
|
|
664
668
|
WORKFLOW_UPDATES_CAPABILITY,
|
|
665
669
|
MESSAGE_STREAMS_CAPABILITY,
|
|
670
|
+
"worker_sessions",
|
|
666
671
|
]
|
|
667
672
|
|
|
668
673
|
@pytest.mark.asyncio
|
|
@@ -762,6 +767,7 @@ class TestWorkerRegistration:
|
|
|
762
767
|
DURABLE_SELECTION_CAPABILITY,
|
|
763
768
|
"local_activities",
|
|
764
769
|
MESSAGE_STREAMS_CAPABILITY,
|
|
770
|
+
"worker_sessions",
|
|
765
771
|
]
|
|
766
772
|
|
|
767
773
|
@pytest.mark.asyncio
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from durable_workflow import Client, Worker, WorkerSessionOptions
|
|
8
|
+
from durable_workflow.workflow import ScheduleActivity, commands_to_server_commands
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_options_are_canonical_and_validate_bounds() -> None:
|
|
12
|
+
options = WorkerSessionOptions(
|
|
13
|
+
session_id=" render ",
|
|
14
|
+
queue="gpu",
|
|
15
|
+
requirements=(" gpu:l4 ", "gpu:l4"),
|
|
16
|
+
max_concurrent_activities=2,
|
|
17
|
+
)
|
|
18
|
+
assert options.to_wire() == {
|
|
19
|
+
"session_id": "render",
|
|
20
|
+
"queue": "gpu",
|
|
21
|
+
"requirements": ["gpu:l4"],
|
|
22
|
+
"lease_seconds": 120,
|
|
23
|
+
"ttl_seconds": 1800,
|
|
24
|
+
"max_concurrent_activities": 2,
|
|
25
|
+
"create_if_missing": True,
|
|
26
|
+
"allow_reacquire_after_failure": True,
|
|
27
|
+
}
|
|
28
|
+
for invalid in ("", " \t"):
|
|
29
|
+
with pytest.raises(ValueError, match="session id"):
|
|
30
|
+
WorkerSessionOptions(invalid)
|
|
31
|
+
with pytest.raises(ValueError, match="positive"):
|
|
32
|
+
WorkerSessionOptions("render", lease_seconds=0)
|
|
33
|
+
with pytest.raises(ValueError, match="non-empty"):
|
|
34
|
+
WorkerSessionOptions("render", requirements=(" ",))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_activity_command_carries_session_routing_in_both_encoding_paths() -> None:
|
|
38
|
+
options = WorkerSessionOptions("render", requirements=("gpu:l4",))
|
|
39
|
+
command = ScheduleActivity("render.frame", [{"frame": 1}], worker_session=options)
|
|
40
|
+
assert command.to_server_command("gpu")["worker_session"] == options.to_wire()
|
|
41
|
+
assert commands_to_server_commands([command], "gpu")[0]["worker_session"] == options.to_wire()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_client_session_verbs_use_worker_auth_and_canonical_paths() -> None:
|
|
46
|
+
client = Client("https://server.example", worker_token="worker-only", namespace="ns")
|
|
47
|
+
options = WorkerSessionOptions("render/one")
|
|
48
|
+
with patch.object(client, "_request", new_callable=AsyncMock, return_value={"outcome": "ok"}) as request:
|
|
49
|
+
await client.create_worker_session("worker-1", options)
|
|
50
|
+
await client.renew_worker_session("worker-1", options.session_id, 180)
|
|
51
|
+
await client.close_worker_session("worker-1", options.session_id, "shutdown")
|
|
52
|
+
assert request.await_count == 3
|
|
53
|
+
assert request.await_args_list[0].args == ("POST", "/worker/sessions")
|
|
54
|
+
assert request.await_args_list[0].kwargs == {
|
|
55
|
+
"worker": True, "json": {"worker_id": "worker-1", **options.to_wire()}
|
|
56
|
+
}
|
|
57
|
+
assert request.await_args_list[1].args == ("POST", "/worker/sessions/render%2Fone/heartbeat")
|
|
58
|
+
assert request.await_args_list[1].kwargs["json"]["lease_seconds"] == 180
|
|
59
|
+
assert request.await_args_list[2].args == ("DELETE", "/worker/sessions/render%2Fone")
|
|
60
|
+
assert request.await_args_list[2].kwargs["json"] == {"worker_id": "worker-1", "reason": "shutdown"}
|
|
61
|
+
with pytest.raises(ValueError, match="positive"):
|
|
62
|
+
await client.renew_worker_session("worker-1", "render", 0)
|
|
63
|
+
await client.aclose()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_worker_session_lifecycle_and_shutdown_are_idempotent() -> None:
|
|
68
|
+
client = Client("https://server.example", worker_token="worker-only", namespace="ns")
|
|
69
|
+
worker = Worker(client, task_queue="gpu", worker_id="holder-1")
|
|
70
|
+
options = WorkerSessionOptions("render", queue="gpu")
|
|
71
|
+
session = worker.worker_session(options)
|
|
72
|
+
assert worker.worker_session(options) is session
|
|
73
|
+
assert worker._current_task_slots()["session_available"] == worker.max_concurrent_worker_sessions
|
|
74
|
+
|
|
75
|
+
with (
|
|
76
|
+
patch.object(client, "create_worker_session", new_callable=AsyncMock, return_value={"outcome": "created"}),
|
|
77
|
+
patch.object(
|
|
78
|
+
client, "renew_worker_session", new_callable=AsyncMock, return_value={"outcome": "renewed"}
|
|
79
|
+
) as renew,
|
|
80
|
+
patch.object(
|
|
81
|
+
client, "close_worker_session", new_callable=AsyncMock, return_value={"outcome": "closed"}
|
|
82
|
+
) as close,
|
|
83
|
+
):
|
|
84
|
+
assert (await session.create())["outcome"] == "created"
|
|
85
|
+
assert worker._current_task_slots()["session_available"] == worker.max_concurrent_worker_sessions - 1
|
|
86
|
+
assert (await session.renew(180))["outcome"] == "renewed"
|
|
87
|
+
renew.assert_awaited_once_with("holder-1", "render", 180)
|
|
88
|
+
await worker.stop()
|
|
89
|
+
await worker.stop()
|
|
90
|
+
assert (await session.close())["outcome"] == "closed"
|
|
91
|
+
close.assert_awaited_once_with("holder-1", "render", "worker_shutdown")
|
|
92
|
+
assert worker._current_task_slots()["session_available"] == worker.max_concurrent_worker_sessions
|
|
93
|
+
with pytest.raises(RuntimeError, match="closed"):
|
|
94
|
+
await session.renew()
|
|
95
|
+
|
|
96
|
+
await client.aclose()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_claimed_session_is_tracked_and_closed_without_explicit_create() -> None:
|
|
101
|
+
client = Client("https://server.example", worker_token="worker-only", namespace="ns")
|
|
102
|
+
worker = Worker(client, task_queue="gpu", worker_id="replacement-holder")
|
|
103
|
+
worker._track_worker_session_from_task({
|
|
104
|
+
"worker_session": {
|
|
105
|
+
"session_id": "render",
|
|
106
|
+
"queue": "gpu",
|
|
107
|
+
"requirements": ["gpu:l4"],
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
session = worker.worker_session(WorkerSessionOptions("render", queue="gpu", requirements=("gpu:l4",)))
|
|
111
|
+
assert session.active
|
|
112
|
+
assert session.rebuild_required_after_holder_loss()
|
|
113
|
+
with patch.object(
|
|
114
|
+
client, "close_worker_session", new_callable=AsyncMock, return_value={"outcome": "closed"}
|
|
115
|
+
) as close:
|
|
116
|
+
await worker.stop()
|
|
117
|
+
close.assert_awaited_once_with("replacement-holder", "render", "worker_shutdown")
|
|
118
|
+
await client.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|