durabletask.azuremanaged 1.8.0__tar.gz → 1.9.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 (29) hide show
  1. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/PKG-INFO +3 -3
  2. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/internal/access_token_manager.py +37 -7
  3. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/internal/durabletask_grpc_interceptor.py +22 -17
  4. durabletask_azuremanaged-1.9.0/durabletask/azuremanaged/preview/sandboxes/__init__.py +80 -0
  5. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/profile_builder.py +92 -6
  6. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/worker.py +61 -21
  7. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask.azuremanaged.egg-info/PKG-INFO +3 -3
  8. durabletask_azuremanaged-1.9.0/durabletask.azuremanaged.egg-info/requires.txt +5 -0
  9. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/pyproject.toml +3 -3
  10. durabletask_azuremanaged-1.8.0/durabletask/azuremanaged/preview/sandboxes/__init__.py +0 -32
  11. durabletask_azuremanaged-1.8.0/durabletask.azuremanaged.egg-info/requires.txt +0 -5
  12. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/__init__.py +0 -0
  13. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/client.py +0 -0
  14. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/internal/py.typed +0 -0
  15. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/internal/sandbox_service_pb2.py +0 -0
  16. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/internal/sandbox_service_pb2.pyi +0 -0
  17. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/internal/sandbox_service_pb2_grpc.py +0 -0
  18. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/__init__.py +0 -0
  19. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/client.py +0 -0
  20. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/helpers.py +0 -0
  21. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/transport.py +0 -0
  22. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/worker_messages.py +0 -0
  23. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/preview/sandboxes/worker_profiles.py +0 -0
  24. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/py.typed +0 -0
  25. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask/azuremanaged/worker.py +0 -0
  26. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask.azuremanaged.egg-info/SOURCES.txt +0 -0
  27. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask.azuremanaged.egg-info/dependency_links.txt +0 -0
  28. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/durabletask.azuremanaged.egg-info/top_level.txt +0 -0
  29. {durabletask_azuremanaged-1.8.0 → durabletask_azuremanaged-1.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask.azuremanaged
3
- Version: 1.8.0
3
+ Version: 1.9.0
4
4
  Summary: Durable Task Python SDK provider implementation for the Azure Durable Task Scheduler
5
5
  Project-URL: repository, https://github.com/microsoft/durabletask-python
6
6
  Project-URL: changelog, https://github.com/microsoft/durabletask-python/blob/main/CHANGELOG.md
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
- Requires-Dist: durabletask>=1.8.0
13
+ Requires-Dist: durabletask>=1.9.0
14
14
  Requires-Dist: azure-identity>=1.19.0
15
15
  Provides-Extra: azure-blob-payloads
16
- Requires-Dist: durabletask[azure-blob-payloads]>=1.8.0; extra == "azure-blob-payloads"
16
+ Requires-Dist: durabletask[azure-blob-payloads]>=1.9.0; extra == "azure-blob-payloads"
@@ -1,5 +1,6 @@
1
1
  # Copyright (c) Microsoft Corporation.
2
2
  # Licensed under the MIT License.
3
+ import asyncio
3
4
  from datetime import datetime, timedelta, timezone
4
5
  from threading import Lock
5
6
 
@@ -13,6 +14,7 @@ import durabletask.internal.shared as shared
13
14
  class AccessTokenManager:
14
15
 
15
16
  _token: AccessToken | None
17
+ expiry_time: datetime | None
16
18
 
17
19
  def __init__(self, token_credential: TokenCredential | None, refresh_interval_seconds: int = 600):
18
20
  self._scope = "https://durabletask.io/.default"
@@ -22,12 +24,12 @@ class AccessTokenManager:
22
24
  self._credential = token_credential
23
25
  self._refresh_lock = Lock()
24
26
 
25
- if self._credential is not None:
26
- self._token = self._credential.get_token(self._scope)
27
- self.expiry_time = datetime.fromtimestamp(self._token.expires_on, tz=timezone.utc)
28
- else:
29
- self._token = None
30
- self.expiry_time = None
27
+ # Token acquisition is deferred to the first get_access_token() call so that
28
+ # constructing a client or worker does not perform a blocking credential round
29
+ # trip. The deferred first acquisition still goes through the double-checked
30
+ # refresh lock below, so it remains single-flight across threads.
31
+ self._token = None
32
+ self.expiry_time = None
31
33
 
32
34
  def get_access_token(self) -> AccessToken | None:
33
35
  if self._token is None or self.is_token_expired():
@@ -70,9 +72,37 @@ class AsyncAccessTokenManager:
70
72
  self._token = None
71
73
  self.expiry_time = None
72
74
 
75
+ # An asyncio.Lock binds itself to the event loop it is first used on, and this
76
+ # manager may outlive a loop or be shared across loops. Locks are therefore
77
+ # created lazily per running loop, guarded by a plain threading lock because
78
+ # different loops may run on different threads.
79
+ self._refresh_locks: dict[asyncio.AbstractEventLoop, asyncio.Lock] = {}
80
+ self._refresh_locks_guard = Lock()
81
+
82
+ def _get_refresh_lock(self) -> asyncio.Lock:
83
+ loop = asyncio.get_running_loop()
84
+ with self._refresh_locks_guard:
85
+ lock = self._refresh_locks.get(loop)
86
+ if lock is None:
87
+ # Discard locks belonging to loops that are no longer usable so the
88
+ # mapping does not grow without bound.
89
+ stale_loops = [
90
+ existing for existing in self._refresh_locks if existing.is_closed()
91
+ ]
92
+ for stale_loop in stale_loops:
93
+ del self._refresh_locks[stale_loop]
94
+ lock = asyncio.Lock()
95
+ self._refresh_locks[loop] = lock
96
+ return lock
97
+
73
98
  async def get_access_token(self) -> AccessToken | None:
74
99
  if self._token is None or self.is_token_expired():
75
- await self.refresh_token()
100
+ async with self._get_refresh_lock():
101
+ # Re-check under the lock: a concurrent caller may have already
102
+ # refreshed the token while this one was waiting, so only a single
103
+ # credential request is made per refresh window.
104
+ if self._token is None or self.is_token_expired():
105
+ await self.refresh_token()
76
106
  return self._token
77
107
 
78
108
  def is_token_expired(self) -> bool:
@@ -1,6 +1,7 @@
1
1
  # Copyright (c) Microsoft Corporation.
2
2
  # Licensed under the MIT License.
3
3
 
4
+ from functools import lru_cache
4
5
  from importlib.metadata import version
5
6
 
6
7
  import grpc
@@ -17,6 +18,22 @@ from durabletask.internal.grpc_interceptor import (
17
18
  )
18
19
 
19
20
 
21
+ @lru_cache(maxsize=1)
22
+ def _get_sdk_version() -> str:
23
+ """Return the installed version of the azuremanaged package.
24
+
25
+ Resolving the version walks distribution metadata on disk, so the result is
26
+ cached and shared by every interceptor instance instead of being recomputed
27
+ on each client or worker construction. Falls back to ``"unknown"`` when the
28
+ version cannot be determined.
29
+ """
30
+ try:
31
+ return version('durabletask-azuremanaged')
32
+ except Exception:
33
+ # Fallback if version cannot be determined
34
+ return "unknown"
35
+
36
+
20
37
  class DTSDefaultClientInterceptorImpl (DefaultClientInterceptorImpl):
21
38
  """The class implements a UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor,
22
39
  StreamUnaryClientInterceptor and StreamStreamClientInterceptor from grpc to add an
@@ -27,13 +44,7 @@ class DTSDefaultClientInterceptorImpl (DefaultClientInterceptorImpl):
27
44
  token_credential: TokenCredential | None,
28
45
  taskhub_name: str,
29
46
  worker_id: str | None = None):
30
- try:
31
- # Get the version of the azuremanaged package
32
- sdk_version = version('durabletask-azuremanaged')
33
- except Exception:
34
- # Fallback if version cannot be determined
35
- sdk_version = "unknown"
36
- user_agent = f"durabletask-python/{sdk_version}"
47
+ user_agent = f"durabletask-python/{_get_sdk_version()}"
37
48
  self._metadata = [
38
49
  ("taskhub", taskhub_name),
39
50
  ("x-user-agent", user_agent)] # 'user-agent' is a reserved header; use 'x-user-agent'
@@ -41,13 +52,13 @@ class DTSDefaultClientInterceptorImpl (DefaultClientInterceptorImpl):
41
52
  self._metadata.append(("workerid", worker_id))
42
53
  super().__init__(self._metadata)
43
54
 
55
+ # Token acquisition is deferred to the first _intercept_call invocation rather
56
+ # than happening in __init__, so that constructing a client or worker does not
57
+ # block on a credential round trip before any RPC is made.
44
58
  self._token_manager = None
45
59
  if token_credential is not None:
46
60
  self._token_credential = token_credential
47
61
  self._token_manager = AccessTokenManager(token_credential=self._token_credential)
48
- access_token = self._token_manager.get_access_token()
49
- if access_token is not None:
50
- self._upsert_authorization_header(access_token.token)
51
62
 
52
63
  def _upsert_authorization_header(self, token: str) -> None:
53
64
  found = False
@@ -81,13 +92,7 @@ class DTSAsyncDefaultClientInterceptorImpl(DefaultAsyncClientInterceptorImpl):
81
92
  (task hub name, user agent, and authentication token) to all async calls."""
82
93
 
83
94
  def __init__(self, token_credential: AsyncTokenCredential | None, taskhub_name: str):
84
- try:
85
- # Get the version of the azuremanaged package
86
- sdk_version = version('durabletask-azuremanaged')
87
- except Exception:
88
- # Fallback if version cannot be determined
89
- sdk_version = "unknown"
90
- user_agent = f"durabletask-python/{sdk_version}"
95
+ user_agent = f"durabletask-python/{_get_sdk_version()}"
91
96
  self._metadata = [
92
97
  ("taskhub", taskhub_name),
93
98
  ("x-user-agent", user_agent)]
@@ -0,0 +1,80 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Sandbox preview APIs for Durable Task Scheduler.
5
+
6
+ This extension provides preview APIs for declaring sandbox activity
7
+ worker images and running a Python activity worker inside a Durable Task
8
+ Scheduler-launched sandbox.
9
+
10
+ Usage::
11
+
12
+ from durabletask.azuremanaged.preview.sandboxes import (
13
+ SandboxWorker,
14
+ SandboxActivitiesClient,
15
+ )
16
+
17
+ The exports below are resolved lazily on first attribute access, so importing
18
+ this package does not load the sandbox worker runtime (and its Azure Identity
19
+ and gRPC dependencies) unless those APIs are actually used.
20
+ """
21
+
22
+ from importlib import import_module
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ if TYPE_CHECKING:
26
+ from durabletask.azuremanaged.preview.sandboxes.client import SandboxActivitiesClient
27
+ from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity
28
+ from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfile
29
+ from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfileOptions
30
+ from durabletask.azuremanaged.preview.sandboxes.worker_profiles import sandbox_worker_profile
31
+ from durabletask.azuremanaged.preview.sandboxes.worker import SandboxWorker
32
+
33
+ __all__ = [
34
+ "SandboxWorker",
35
+ "SandboxActivity",
36
+ "SandboxWorkerProfile",
37
+ "SandboxWorkerProfileOptions",
38
+ "SandboxActivitiesClient",
39
+ "sandbox_worker_profile",
40
+ ]
41
+
42
+ # Public export name -> submodule of this package that defines it.
43
+ _LAZY_EXPORTS: dict[str, str] = {
44
+ "SandboxWorker": "worker",
45
+ "SandboxActivity": "helpers",
46
+ "SandboxWorkerProfile": "worker_profiles",
47
+ "SandboxWorkerProfileOptions": "worker_profiles",
48
+ "SandboxActivitiesClient": "client",
49
+ "sandbox_worker_profile": "worker_profiles",
50
+ }
51
+
52
+ # Submodules that eager imports previously bound as attributes of this package.
53
+ # They remain reachable through attribute access without an explicit import.
54
+ _LAZY_SUBMODULES: frozenset[str] = frozenset({
55
+ "client",
56
+ "helpers",
57
+ "profile_builder",
58
+ "transport",
59
+ "worker",
60
+ "worker_messages",
61
+ "worker_profiles",
62
+ })
63
+
64
+
65
+ def __getattr__(name: str) -> Any:
66
+ """Import public sandbox exports on first access (PEP 562)."""
67
+ submodule = _LAZY_EXPORTS.get(name)
68
+ if submodule is not None:
69
+ value = getattr(import_module(f".{submodule}", __name__), name)
70
+ elif name in _LAZY_SUBMODULES:
71
+ value = import_module(f".{name}", __name__)
72
+ else:
73
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
74
+
75
+ globals()[name] = value
76
+ return value
77
+
78
+
79
+ def __dir__() -> list[str]:
80
+ return sorted(set(globals()) | set(__all__) | _LAZY_SUBMODULES)
@@ -7,7 +7,6 @@ from typing import Iterable, Optional
7
7
  from durabletask.azuremanaged.internal import sandbox_service_pb2 as pb
8
8
  from durabletask.azuremanaged.preview.sandboxes.helpers import (
9
9
  SandboxActivity,
10
- activities_overlap,
11
10
  format_activity,
12
11
  normalize_required,
13
12
  resolve_activities,
@@ -91,22 +90,109 @@ def _build_sandbox_worker_profile(
91
90
  return worker_profile
92
91
 
93
92
 
93
+ class _ActivityOwnerSlot:
94
+ """Earliest worker profiles recorded for a single activity overlap bucket.
95
+
96
+ Only two owners ever need to be retained to answer "which worker profile
97
+ first claimed an activity in this bucket, ignoring `worker_profile_id`":
98
+ the very first owner, plus the first owner that differs from it.
99
+ """
100
+
101
+ __slots__ = ("_first", "_first_other")
102
+
103
+ def __init__(self) -> None:
104
+ self._first: Optional[tuple[int, str]] = None
105
+ self._first_other: Optional[tuple[int, str]] = None
106
+
107
+ def add(self, order: int, worker_profile_id: str) -> None:
108
+ if self._first is None:
109
+ self._first = (order, worker_profile_id)
110
+ elif self._first_other is None and worker_profile_id != self._first[1]:
111
+ self._first_other = (order, worker_profile_id)
112
+
113
+ def first_owner_other_than(self, worker_profile_id: str) -> Optional[tuple[int, str]]:
114
+ if self._first is not None and self._first[1] != worker_profile_id:
115
+ return self._first
116
+ return self._first_other
117
+
118
+
119
+ class _ActivityOwnerIndex:
120
+ """Indexes activity ownership so overlap checks cost O(1) per activity.
121
+
122
+ Reproduces the semantics of
123
+ :func:`durabletask.azuremanaged.preview.sandboxes.helpers.activities_overlap`
124
+ exactly: activity names are compared case insensitively, an unversioned
125
+ activity overlaps every version of the same name, and two activities with
126
+ the same name overlap when their explicit versions are equal. Registration
127
+ order is tracked so the reported conflict matches the first overlapping
128
+ activity, exactly as a linear scan would report it.
129
+ """
130
+
131
+ def __init__(self) -> None:
132
+ self._registration_count = 0
133
+ self._by_name: dict[str, _ActivityOwnerSlot] = {}
134
+ self._by_name_and_version: dict[tuple[str, Optional[str]], _ActivityOwnerSlot] = {}
135
+
136
+ def find_conflicting_profile(
137
+ self,
138
+ activity: SandboxActivity,
139
+ worker_profile_id: str) -> Optional[str]:
140
+ """Return the profile that first claimed an overlapping activity, if any."""
141
+ name_key = activity.name.casefold()
142
+ if activity.version is None:
143
+ # An unversioned activity overlaps every version of the same name.
144
+ owner = _first_owner_other_than(self._by_name.get(name_key), worker_profile_id)
145
+ else:
146
+ # A versioned activity overlaps the same version plus any
147
+ # unversioned registration of the same name.
148
+ owner = _earlier_owner(
149
+ _first_owner_other_than(
150
+ self._by_name_and_version.get((name_key, None)), worker_profile_id),
151
+ _first_owner_other_than(
152
+ self._by_name_and_version.get((name_key, activity.version)), worker_profile_id))
153
+ return None if owner is None else owner[1]
154
+
155
+ def add(self, activity: SandboxActivity, worker_profile_id: str) -> None:
156
+ """Record `worker_profile_id` as an owner of `activity`."""
157
+ name_key = activity.name.casefold()
158
+ order = self._registration_count
159
+ self._registration_count += 1
160
+ self._by_name.setdefault(name_key, _ActivityOwnerSlot()).add(order, worker_profile_id)
161
+ self._by_name_and_version.setdefault(
162
+ (name_key, activity.version), _ActivityOwnerSlot()).add(order, worker_profile_id)
163
+
164
+
165
+ def _first_owner_other_than(
166
+ slot: Optional[_ActivityOwnerSlot],
167
+ worker_profile_id: str) -> Optional[tuple[int, str]]:
168
+ return None if slot is None else slot.first_owner_other_than(worker_profile_id)
169
+
170
+
171
+ def _earlier_owner(
172
+ left: Optional[tuple[int, str]],
173
+ right: Optional[tuple[int, str]]) -> Optional[tuple[int, str]]:
174
+ if left is None:
175
+ return right
176
+ if right is None:
177
+ return left
178
+ return left if left[0] <= right[0] else right
179
+
180
+
94
181
  def build_sandbox_worker_profiles() -> list[pb.SandboxWorkerProfile]:
95
182
  """Build sandbox worker_profiles from worker profile configuration."""
96
183
  worker_profiles: list[pb.SandboxWorkerProfile] = []
97
- activity_owners: list[tuple[SandboxActivity, str]] = []
184
+ activity_owners = _ActivityOwnerIndex()
98
185
  for profile in registered_sandbox_worker_profiles():
99
186
  activities = resolve_activities(profile.activities)
100
187
 
101
188
  for activity in activities:
102
- existing_profile = next((owner_profile for owner_activity, owner_profile in activity_owners
103
- if activities_overlap(owner_activity, activity)
104
- and owner_profile != profile.worker_profile_id), None)
189
+ existing_profile = activity_owners.find_conflicting_profile(
190
+ activity, profile.worker_profile_id)
105
191
  if existing_profile:
106
192
  raise ValueError(
107
193
  f"Sandbox activity '{format_activity(activity)}' is assigned to both worker profile "
108
194
  f"'{existing_profile}' and '{profile.worker_profile_id}'.")
109
- activity_owners.append((activity, profile.worker_profile_id))
195
+ activity_owners.add(activity, profile.worker_profile_id)
110
196
 
111
197
  worker_profiles.append(_build_sandbox_worker_profile(
112
198
  activities=activities,
@@ -130,30 +130,50 @@ class SandboxWorker(DurableTaskSchedulerWorker):
130
130
 
131
131
  def _run_sandbox_registration_loop(self) -> None:
132
132
  retry_delay = 1.0
133
- while not self._sandbox_registration_stop.is_set():
134
- try:
135
- client = SandboxActivitiesGrpcTransport(
136
- host_address=self._sandbox_host_address,
137
- taskhub=self._sandbox_taskhub,
138
- token_credential=self._sandbox_token_credential,
139
- secure_channel=self._sandbox_secure_channel)
133
+ client: Optional[SandboxActivitiesGrpcTransport] = None
134
+ try:
135
+ while not self._sandbox_registration_stop.is_set():
140
136
  try:
137
+ if client is None:
138
+ client = SandboxActivitiesGrpcTransport(
139
+ host_address=self._sandbox_host_address,
140
+ taskhub=self._sandbox_taskhub,
141
+ token_credential=self._sandbox_token_credential,
142
+ secure_channel=self._sandbox_secure_channel)
141
143
  client.connect_sandbox_activity_worker(self._registration_messages())
142
144
  retry_delay = 1.0
143
- finally:
144
- client.close()
145
- except Exception as ex:
146
- if self._sandbox_registration_stop.is_set():
147
- break
148
- if not _is_retriable_registration_failure(ex):
149
- self._sandbox_logger.error(
150
- "Sandbox activity worker registration failed permanently: %s", ex)
151
- self._sandbox_registration_stop.set()
152
- break
153
- self._sandbox_logger.warning("Sandbox activity worker registration failed: %s", ex)
154
- delay = random.uniform(0, retry_delay)
155
- self._sandbox_registration_stop.wait(delay)
156
- retry_delay = min(retry_delay * 2, 30.0)
145
+ except Exception as ex:
146
+ # gRPC channels reconnect on their own after transient stream
147
+ # failures, so the transport (and its channel, stub, and cached
148
+ # access token) is reused instead of being rebuilt per attempt.
149
+ if _requires_new_registration_transport(ex):
150
+ self._close_sandbox_registration_transport(client)
151
+ client = None
152
+ if self._sandbox_registration_stop.is_set():
153
+ break
154
+ if not _is_retriable_registration_failure(ex):
155
+ self._sandbox_logger.error(
156
+ "Sandbox activity worker registration failed permanently: %s", ex)
157
+ self._sandbox_registration_stop.set()
158
+ break
159
+ self._sandbox_logger.warning(
160
+ "Sandbox activity worker registration failed: %s", ex)
161
+ delay = random.uniform(0, retry_delay)
162
+ self._sandbox_registration_stop.wait(delay)
163
+ retry_delay = min(retry_delay * 2, 30.0)
164
+ finally:
165
+ self._close_sandbox_registration_transport(client)
166
+
167
+ def _close_sandbox_registration_transport(
168
+ self,
169
+ client: Optional[SandboxActivitiesGrpcTransport]) -> None:
170
+ if client is None:
171
+ return
172
+ try:
173
+ client.close()
174
+ except Exception as ex:
175
+ self._sandbox_logger.debug(
176
+ "Sandbox activity worker registration transport failed to close: %s", ex)
157
177
 
158
178
  def _registration_messages(self) -> Iterator[pb.SandboxActivityWorkerMessage]:
159
179
  yield build_sandbox_worker_start(
@@ -264,6 +284,26 @@ def _is_retriable_registration_failure(ex: Exception) -> bool:
264
284
  return isinstance(ex, OSError)
265
285
 
266
286
 
287
+ def _requires_new_registration_transport(ex: Exception) -> bool:
288
+ """Returns whether a failure leaves the registration transport unusable.
289
+
290
+ A gRPC channel transparently reconnects after a stream fails, so the same
291
+ transport can serve the next registration attempt. A channel that has been
292
+ shut down rejects every later RPC, and a failure raised outside of gRPC (for
293
+ example while the channel itself is being created) can leave the transport
294
+ only partially initialized, so both cases need a replacement transport.
295
+ """
296
+ if not isinstance(ex, grpc.RpcError):
297
+ return True
298
+
299
+ if ex.code() != grpc.StatusCode.CANCELLED:
300
+ return False
301
+
302
+ details = getattr(ex, "details", None)
303
+ resolved_details = details() if callable(details) else None
304
+ return isinstance(resolved_details, str) and "channel closed" in resolved_details.lower()
305
+
306
+
267
307
  def _resolve_max_concurrent_activities() -> int:
268
308
  value = os.getenv("DTS_SANDBOX_MAX_ACTIVITIES")
269
309
  if value is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask.azuremanaged
3
- Version: 1.8.0
3
+ Version: 1.9.0
4
4
  Summary: Durable Task Python SDK provider implementation for the Azure Durable Task Scheduler
5
5
  Project-URL: repository, https://github.com/microsoft/durabletask-python
6
6
  Project-URL: changelog, https://github.com/microsoft/durabletask-python/blob/main/CHANGELOG.md
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
- Requires-Dist: durabletask>=1.8.0
13
+ Requires-Dist: durabletask>=1.9.0
14
14
  Requires-Dist: azure-identity>=1.19.0
15
15
  Provides-Extra: azure-blob-payloads
16
- Requires-Dist: durabletask[azure-blob-payloads]>=1.8.0; extra == "azure-blob-payloads"
16
+ Requires-Dist: durabletask[azure-blob-payloads]>=1.9.0; extra == "azure-blob-payloads"
@@ -0,0 +1,5 @@
1
+ durabletask>=1.9.0
2
+ azure-identity>=1.19.0
3
+
4
+ [azure-blob-payloads]
5
+ durabletask[azure-blob-payloads]>=1.9.0
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
 
10
10
  [project]
11
11
  name = "durabletask.azuremanaged"
12
- version = "1.8.0"
12
+ version = "1.9.0"
13
13
  description = "Durable Task Python SDK provider implementation for the Azure Durable Task Scheduler"
14
14
  keywords = [
15
15
  "durable",
@@ -26,13 +26,13 @@ requires-python = ">=3.10"
26
26
  license = {file = "LICENSE"}
27
27
  readme = "README.md"
28
28
  dependencies = [
29
- "durabletask>=1.8.0",
29
+ "durabletask>=1.9.0",
30
30
  "azure-identity>=1.19.0"
31
31
  ]
32
32
 
33
33
  [project.optional-dependencies]
34
34
  azure-blob-payloads = [
35
- "durabletask[azure-blob-payloads]>=1.8.0"
35
+ "durabletask[azure-blob-payloads]>=1.9.0"
36
36
  ]
37
37
 
38
38
  [project.urls]
@@ -1,32 +0,0 @@
1
- # Copyright (c) Microsoft Corporation.
2
- # Licensed under the MIT License.
3
-
4
- """Sandbox preview APIs for Durable Task Scheduler.
5
-
6
- This extension provides preview APIs for declaring sandbox activity
7
- worker images and running a Python activity worker inside a Durable Task
8
- Scheduler-launched sandbox.
9
-
10
- Usage::
11
-
12
- from durabletask.azuremanaged.preview.sandboxes import (
13
- SandboxWorker,
14
- SandboxActivitiesClient,
15
- )
16
- """
17
-
18
- from durabletask.azuremanaged.preview.sandboxes.client import SandboxActivitiesClient
19
- from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity
20
- from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfile
21
- from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfileOptions
22
- from durabletask.azuremanaged.preview.sandboxes.worker_profiles import sandbox_worker_profile
23
- from durabletask.azuremanaged.preview.sandboxes.worker import SandboxWorker
24
-
25
- __all__ = [
26
- "SandboxWorker",
27
- "SandboxActivity",
28
- "SandboxWorkerProfile",
29
- "SandboxWorkerProfileOptions",
30
- "SandboxActivitiesClient",
31
- "sandbox_worker_profile",
32
- ]
@@ -1,5 +0,0 @@
1
- durabletask>=1.8.0
2
- azure-identity>=1.19.0
3
-
4
- [azure-blob-payloads]
5
- durabletask[azure-blob-payloads]>=1.8.0