Skip to content

Commit 883fb3d

Browse files
committed
save
1 parent 27d2745 commit 883fb3d

8 files changed

Lines changed: 200 additions & 61 deletions

File tree

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1313
worker profiles, and running sandbox activity workers.
1414
- Added optional activity versions to sandbox worker profile declarations and
1515
sandbox worker registration.
16+
- Required sandbox workers to fail fast when `DTS_SANDBOX_ID` is missing,
17+
treated `DTS_SANDBOX_PROVIDER` as optional metadata, and added context
18+
manager support to `SandboxActivitiesClient`.
1619

1720
## v1.5.0
1821

durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ def __init__(
4141
def close(self) -> None:
4242
self._transport.close()
4343

44+
def __enter__(self) -> "SandboxActivitiesClient":
45+
return self
46+
47+
def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
48+
self.close()
49+
4450
def enable_sandbox_activities(self) -> None:
4551
"""Declare all configured sandbox worker profiles with Durable Task Scheduler."""
4652
worker_profiles = build_sandbox_worker_profiles()

durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/worker.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from typing import Iterator, Optional
99

10+
import grpc
1011
from azure.core.credentials import TokenCredential
1112
from azure.identity import ManagedIdentityCredential
1213

@@ -45,6 +46,7 @@ def __init__(self) -> None:
4546
resolved_secure_channel = _resolve_secure_channel(resolved_host_address)
4647
resolved_token_credential = _resolve_token_credential()
4748
resolved_max_concurrent_activities = _resolve_max_concurrent_activities()
49+
resolved_dts_sandbox_identifier = _resolve_dts_sandbox_identifier()
4850
resolved_sandbox_provider = _resolve_sandbox_provider()
4951
concurrency_options = ConcurrencyOptions(
5052
maximum_concurrent_activity_work_items=resolved_max_concurrent_activities)
@@ -66,7 +68,7 @@ def __init__(self) -> None:
6668
self._sandbox_activities: list[SandboxActivity] = []
6769
self._sandbox_max_activities = resolved_max_concurrent_activities
6870
self._sandbox_provider = resolved_sandbox_provider
69-
self._sandbox_dts_sandbox_identifier = os.getenv("DTS_SANDBOX_ID")
71+
self._sandbox_dts_sandbox_identifier = resolved_dts_sandbox_identifier
7072
self._sandbox_heartbeat_interval_seconds = 2.0
7173
self._sandbox_registration_stop = threading.Event()
7274
self._sandbox_registration_thread: Optional[threading.Thread] = None
@@ -143,6 +145,11 @@ def _run_sandbox_registration_loop(self) -> None:
143145
except Exception as ex:
144146
if self._sandbox_registration_stop.is_set():
145147
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
146153
self._sandbox_logger.warning("Sandbox activity worker registration failed: %s", ex)
147154
delay = random.uniform(0, retry_delay)
148155
self._sandbox_registration_stop.wait(delay)
@@ -216,19 +223,59 @@ def _resolve_token_credential() -> TokenCredential | None:
216223
return ManagedIdentityCredential(client_id=client_id.strip())
217224

218225

219-
def _resolve_sandbox_provider() -> str:
226+
def _resolve_sandbox_provider() -> Optional[str]:
220227
sandbox_provider = os.getenv("DTS_SANDBOX_PROVIDER")
221-
if not sandbox_provider:
228+
if not sandbox_provider or not sandbox_provider.strip():
229+
return None
230+
return sandbox_provider.strip()
231+
232+
233+
def _resolve_dts_sandbox_identifier() -> str:
234+
dts_sandbox_identifier = os.getenv("DTS_SANDBOX_ID")
235+
if not dts_sandbox_identifier or not dts_sandbox_identifier.strip():
222236
raise ValueError(
223-
"Sandbox worker requires DTS_SANDBOX_PROVIDER to be injected in the "
237+
"Sandbox worker requires DTS_SANDBOX_ID to be injected in the "
224238
"sandbox environment.")
225239

226-
normalized = sandbox_provider.strip()
227-
if normalized.lower() not in ("sandbox", "acasessionpool"):
228-
raise ValueError(
229-
"Sandbox worker requires DTS_SANDBOX_PROVIDER to be Sandbox or AcaSessionPool.")
240+
return dts_sandbox_identifier.strip()
241+
242+
243+
_RETRIABLE_REGISTRATION_STATUS_CODES = {
244+
grpc.StatusCode.CANCELLED,
245+
grpc.StatusCode.DEADLINE_EXCEEDED,
246+
grpc.StatusCode.INTERNAL,
247+
grpc.StatusCode.RESOURCE_EXHAUSTED,
248+
grpc.StatusCode.UNAVAILABLE,
249+
grpc.StatusCode.UNKNOWN,
250+
}
251+
252+
_PERMANENT_FAILED_PRECONDITION_DETAILS = (
253+
"worker profile",
254+
"registered activit",
255+
"max activit",
256+
"sandbox identifier",
257+
"sandbox id",
258+
)
259+
260+
261+
def _is_retriable_registration_failure(ex: Exception) -> bool:
262+
if isinstance(ex, grpc.RpcError):
263+
status_code = ex.code()
264+
if status_code in _RETRIABLE_REGISTRATION_STATUS_CODES:
265+
return True
266+
if status_code == grpc.StatusCode.FAILED_PRECONDITION:
267+
details = _rpc_error_details(ex).casefold()
268+
return not any(
269+
detail in details
270+
for detail in _PERMANENT_FAILED_PRECONDITION_DETAILS)
271+
return False
272+
273+
return isinstance(ex, OSError)
274+
230275

231-
return normalized
276+
def _rpc_error_details(ex: grpc.RpcError) -> str:
277+
details = ex.details()
278+
return details if isinstance(details, str) else ""
232279

233280

234281
def _resolve_max_concurrent_activities() -> int:

durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/worker_messages.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from durabletask.azuremanaged.internal import sandbox_service_pb2 as pb
77
from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity
8+
from durabletask.azuremanaged.preview.sandboxes.helpers import normalize_required
89
from durabletask.azuremanaged.preview.sandboxes.helpers import resolve_activities
910

1011

@@ -29,13 +30,17 @@ def build_sandbox_worker_start(
2930
if not resolved_activities:
3031
raise ValueError("Sandbox activity worker registration requires at least one registered activity.")
3132

33+
resolved_dts_sandbox_identifier = normalize_required(
34+
dts_sandbox_identifier,
35+
"Sandbox activity worker registration requires a DTS sandbox ID.")
36+
3237
message = pb.SandboxActivityWorkerMessage(
3338
start=pb.SandboxActivityWorkerStart(
3439
task_hub=taskhub.strip(),
3540
worker_profile_id=worker_profile_id.strip(),
3641
max_activities_count=max_activities_count,
3742
sandbox_provider=_parse_sandbox_provider(sandbox_provider),
38-
dts_sandbox_identifier=(dts_sandbox_identifier or "").strip()))
43+
dts_sandbox_identifier=resolved_dts_sandbox_identifier))
3944
message.start.activities.extend([
4045
pb.SandboxActivity(name=activity.name, version=activity.version or "")
4146
for activity in resolved_activities
@@ -53,10 +58,11 @@ def build_sandbox_worker_heartbeat(active_activities_count: int) -> pb.SandboxAc
5358

5459

5560
def _parse_sandbox_provider(sandbox_provider: Optional[str]) -> "pb.SandboxProviderKind":
56-
if not sandbox_provider:
61+
if not sandbox_provider or not sandbox_provider.strip():
5762
return pb.SANDBOX_PROVIDER_KIND_UNSPECIFIED
58-
if sandbox_provider.lower() == "sandbox":
63+
normalized = sandbox_provider.strip().lower()
64+
if normalized == "sandbox":
5965
return pb.SANDBOX_PROVIDER_KIND_SANDBOX
60-
if sandbox_provider.lower() == "acasessionpool":
66+
if normalized == "acasessionpool":
6167
return pb.SANDBOX_PROVIDER_KIND_ACA_SESSION_POOL
6268
return pb.SANDBOX_PROVIDER_KIND_UNSPECIFIED

durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/worker_profiles.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,6 @@ class SandboxWorkerProfileImageOptions:
3434
class SandboxWorkerProfileOptions:
3535
"""Options for a decorated sandbox worker profile."""
3636

37-
@dataclass(frozen=True)
38-
class Activity:
39-
"""Activity name and optional version for a sandbox worker profile."""
40-
41-
name: str
42-
version: Optional[str]
43-
4437
worker_profile_id: str
4538
image: SandboxWorkerProfileImageOptions = field(default_factory=SandboxWorkerProfileImageOptions)
4639
scheduler_managed_identity_client_id: str = ""
@@ -60,7 +53,7 @@ def add_activity(
6053
name=normalize_required(activity_name, "Sandbox activity name is required."),
6154
version=(version.strip() if version and version.strip() else None)))
6255

63-
def add_activities(self, activities: Iterable[Activity]) -> None:
56+
def add_activities(self, activities: Iterable[SandboxActivity]) -> None:
6457
"""Add activity names and versions to the sandbox worker profile."""
6558
for activity in activities:
6659
self.add_activity(activity.name, activity.version)

examples/sandboxes/README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,15 @@ metadata API can carry an optional activity version for future activity-version
5151
support, but this sample intentionally omits it. The declarer and remote worker
5252
both use `activities.py` so they stay in sync.
5353

54-
The remote worker code cannot pass Durable Task Scheduler runtime settings to the SDK. In a
55-
sandbox, `SandboxWorker()` reads `DTS_ENDPOINT`,
56-
`DTS_TASK_HUB`, `DTS_WORKER_PROFILE_ID`, `DTS_SANDBOX_MAX_ACTIVITIES`,
57-
`DTS_SANDBOX_PROVIDER`, `DTS_AUTHENTICATION`, `DTS_UMI_CLIENT_ID`, and
58-
`DTS_SANDBOX_ID` from environment variables injected by Durable Task Scheduler.
59-
The worker requires `DTS_AUTHENTICATION=ManagedIdentity` and reports its
60-
registered activity identities when it connects. Durable Task Scheduler validates
61-
they match the worker_profile before advertising worker capacity.
54+
The remote worker code cannot pass Durable Task Scheduler runtime settings to
55+
the SDK. In a sandbox, `SandboxWorker()` reads `DTS_ENDPOINT`, `DTS_TASK_HUB`,
56+
`DTS_WORKER_PROFILE_ID`, `DTS_SANDBOX_MAX_ACTIVITIES`, `DTS_AUTHENTICATION`,
57+
`DTS_UMI_CLIENT_ID`, and `DTS_SANDBOX_ID` from environment variables injected by
58+
Durable Task Scheduler. It also reads optional `DTS_SANDBOX_PROVIDER` metadata
59+
when present. The worker requires `DTS_AUTHENTICATION=ManagedIdentity` and
60+
reports its sandbox ID plus registered activity identities when it connects.
61+
Durable Task Scheduler validates they match the worker_profile before
62+
advertising worker capacity.
6263

6364
## Build the remote worker image
6465

examples/sandboxes/main_app.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,12 @@ def configure(self, options) -> None:
9393
print(f"Using endpoint: {endpoint}")
9494
print(f"Declaring sandbox activity image: {container_image}")
9595

96-
sandboxes_client = SandboxActivitiesClient(
97-
host_address=endpoint,
98-
secure_channel=secure_channel,
99-
taskhub=taskhub_name,
100-
token_credential=credential)
101-
sandboxes_client.enable_sandbox_activities()
96+
with SandboxActivitiesClient(
97+
host_address=endpoint,
98+
secure_channel=secure_channel,
99+
taskhub=taskhub_name,
100+
token_credential=credential) as sandboxes_client:
101+
sandboxes_client.enable_sandbox_activities()
102102

103103
with DurableTaskSchedulerWorker(
104104
host_address=endpoint,

0 commit comments

Comments
 (0)