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.
Files changed (90) hide show
  1. {durable_workflow-2.2.0/src/durable_workflow.egg-info → durable_workflow-2.3.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/pyproject.toml +4 -4
  3. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/__init__.py +3 -0
  4. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/client.py +35 -3
  5. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/worker.py +54 -0
  6. durable_workflow-2.3.0/src/durable_workflow/worker_session.py +115 -0
  7. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/workflow.py +8 -0
  8. {durable_workflow-2.2.0 → durable_workflow-2.3.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  10. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_release_metadata.py +1 -1
  11. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_worker.py +8 -2
  12. durable_workflow-2.3.0/tests/test_worker_session.py +118 -0
  13. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/LICENSE +0 -0
  14. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/README.md +0 -0
  15. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/setup.cfg +0 -0
  16. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/activity.py +0 -0
  18. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/auth_composition.py +0 -0
  19. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/errors.py +0 -0
  20. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_storage.py +0 -0
  21. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_task_input.py +0 -0
  22. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/external_task_result.py +0 -0
  23. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  24. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/interceptors.py +0 -0
  25. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/invocable.py +0 -0
  26. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/metrics.py +0 -0
  27. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/nexus.py +0 -0
  28. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/py.typed +0 -0
  29. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/python_conformance.py +0 -0
  30. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/replay_conformance.py +0 -0
  31. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/replay_verify.py +0 -0
  32. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/retry_policy.py +0 -0
  33. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/serializer.py +0 -0
  34. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/sync.py +0 -0
  35. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/testing.py +0 -0
  36. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  37. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  38. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  39. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  40. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  41. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_activity_context.py +0 -0
  42. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_auth_composition.py +0 -0
  43. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_avro_value_benchmark.py +0 -0
  44. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_avro_value_protocol.py +0 -0
  45. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_ci_checkout.py +0 -0
  46. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_ci_integration_endpoint.py +0 -0
  47. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_client.py +0 -0
  48. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_codec_regression_corpus.py +0 -0
  49. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  50. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_docstring_cross_references.py +0 -0
  51. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_durable_selection.py +0 -0
  52. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_errors.py +0 -0
  53. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_external_storage.py +0 -0
  54. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_external_task_input.py +0 -0
  55. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_external_task_result.py +0 -0
  56. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_golden_history_replay.py +0 -0
  57. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_history_bundle_verify.py +0 -0
  58. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_history_event_contract.py +0 -0
  59. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_invocable.py +0 -0
  60. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_message_streams.py +0 -0
  61. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_metrics.py +0 -0
  62. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_order_processing_example.py +0 -0
  63. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_parallel_completion_order_replay.py +0 -0
  64. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_parallel_saga_authoring.py +0 -0
  65. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_public_boundary_scanner.py +0 -0
  66. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_pypi_project_surface.py +0 -0
  67. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_python_conformance.py +0 -0
  68. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_queries.py +0 -0
  69. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_readme_quickstart.py +0 -0
  70. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_redrive_replay.py +0 -0
  71. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay.py +0 -0
  72. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay_conformance.py +0 -0
  73. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay_regression_corpus.py +0 -0
  74. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_replay_verify.py +0 -0
  75. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_retry_policy.py +0 -0
  76. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_runtime_external_payload_transport.py +0 -0
  77. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_schedules.py +0 -0
  78. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_serializer.py +0 -0
  79. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_signals.py +0 -0
  80. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_sleep.py +0 -0
  81. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_standalone_activity_client.py +0 -0
  82. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_storage_admission.py +0 -0
  83. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_sync.py +0 -0
  84. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_testing_harness.py +0 -0
  85. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_update_signal_condition_replay.py +0 -0
  86. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_updates.py +0 -0
  87. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_wait_condition.py +0 -0
  88. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_workflow_result_timeout.py +0 -0
  89. {durable_workflow-2.2.0 → durable_workflow-2.3.0}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.2.0 → durable_workflow-2.3.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.2.0
3
+ Version: 2.3.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.2.0"
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.2.0"
75
- registry-version = "2.2.0"
76
- supported-server-versions = "2.4.0"
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": False,
75
+ "supported": True,
75
76
  "minimum_protocol_version": "1.18",
76
- "reason": "python_worker_has_no_typed_session_lifecycle",
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.2.0
3
+ Version: 2.3.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
@@ -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.0"
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": False,
443
+ "supported": True,
442
444
  "minimum_protocol_version": "1.18",
443
- "reason": "python_worker_has_no_typed_session_lifecycle",
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()