Skip to content

Commit 9cc01a8

Browse files
Bernd VerstCopilot
andcommitted
Reuse sandbox registration transport across retries
The sandbox worker registration loop built a brand new SandboxActivitiesGrpcTransport on every attempt, so each transient failure paid for a fresh gRPC channel, stub, interceptor, and Entra token acquisition. That amplified startup and recovery latency exactly when the service was already degraded. The loop now creates the transport once and reuses it across retriable stream failures, replacing it only when a failure leaves the channel unusable (a shut-down channel or a non-gRPC failure such as an error raised while the channel is being created). The transport is closed once when the loop exits, so channels are not leaked. The retry backoff schedule, stop-signal responsiveness, thread join semantics, log messages, and exception types on terminal failure are unchanged. Fixes #188 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9a361145-b7cc-437d-895d-0bea3d19bf91
1 parent 4d87430 commit 9cc01a8

2 files changed

Lines changed: 203 additions & 21 deletions

File tree

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

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -130,30 +130,50 @@ def _stop_sandbox_registration(self) -> None:
130130

131131
def _run_sandbox_registration_loop(self) -> None:
132132
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():
140136
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)
141143
client.connect_sandbox_activity_worker(self._registration_messages())
142144
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)
157177

158178
def _registration_messages(self) -> Iterator[pb.SandboxActivityWorkerMessage]:
159179
yield build_sandbox_worker_start(
@@ -264,6 +284,26 @@ def _is_retriable_registration_failure(ex: Exception) -> bool:
264284
return isinstance(ex, OSError)
265285

266286

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() is not 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+
267307
def _resolve_max_concurrent_activities() -> int:
268308
value = os.getenv("DTS_SANDBOX_MAX_ACTIVITIES")
269309
if value is None:

tests/durabletask-azuremanaged/test_sandboxes_extension.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Licensed under the MIT License.
33

44
import inspect
5+
import threading
56

67
import grpc
78
from azure.core.credentials import AccessToken
@@ -766,6 +767,147 @@ def test_sandbox_registration_retries_failed_precondition_until_structured_reaso
766767
_FakeRpcError(grpc.StatusCode.FAILED_PRECONDITION, "worker profile does not match"))
767768

768769

770+
def test_sandbox_registration_recreates_transport_only_when_channel_is_unusable() -> None:
771+
assert not sandbox_worker._requires_new_registration_transport(
772+
_FakeRpcError(grpc.StatusCode.UNAVAILABLE))
773+
assert not sandbox_worker._requires_new_registration_transport(
774+
_FakeRpcError(grpc.StatusCode.FAILED_PRECONDITION, "sandbox not ready"))
775+
assert not sandbox_worker._requires_new_registration_transport(
776+
_FakeRpcError(grpc.StatusCode.CANCELLED, "Locally cancelled by application!"))
777+
assert sandbox_worker._requires_new_registration_transport(
778+
_FakeRpcError(grpc.StatusCode.CANCELLED, "Channel closed!"))
779+
assert sandbox_worker._requires_new_registration_transport(OSError("connection reset"))
780+
781+
782+
def test_sandbox_registration_reuses_one_transport_across_retriable_failures(monkeypatch) -> None:
783+
worker = _build_registration_test_worker(monkeypatch)
784+
attempts = 0
785+
786+
def connect(_messages):
787+
nonlocal attempts
788+
attempts += 1
789+
if attempts <= 3:
790+
raise _FakeRpcError(grpc.StatusCode.UNAVAILABLE)
791+
worker._sandbox_registration_stop.set()
792+
return object()
793+
794+
transports = _install_fake_registration_transport(monkeypatch, connect)
795+
backoff = _StubBackoff()
796+
monkeypatch.setattr(sandbox_worker, "random", backoff)
797+
798+
worker._run_sandbox_registration_loop()
799+
800+
assert attempts == 4
801+
assert len(transports) == 1
802+
assert transports[0].close_count == 1
803+
# The existing exponential backoff schedule must be preserved.
804+
assert backoff.upper_bounds == [1.0, 2.0, 4.0]
805+
806+
807+
def test_sandbox_registration_rebuilds_transport_after_channel_shutdown(monkeypatch) -> None:
808+
worker = _build_registration_test_worker(monkeypatch)
809+
attempts = 0
810+
811+
def connect(_messages):
812+
nonlocal attempts
813+
attempts += 1
814+
if attempts == 1:
815+
raise _FakeRpcError(grpc.StatusCode.CANCELLED, "Channel closed!")
816+
worker._sandbox_registration_stop.set()
817+
return object()
818+
819+
transports = _install_fake_registration_transport(monkeypatch, connect)
820+
monkeypatch.setattr(sandbox_worker, "random", _StubBackoff())
821+
822+
worker._run_sandbox_registration_loop()
823+
824+
assert attempts == 2
825+
assert len(transports) == 2
826+
assert [transport.close_count for transport in transports] == [1, 1]
827+
828+
829+
def test_sandbox_registration_loop_exits_promptly_on_stop_signal(monkeypatch) -> None:
830+
worker = _build_registration_test_worker(monkeypatch)
831+
worker._sandbox_heartbeat_interval_seconds = 0.05
832+
connected = threading.Event()
833+
834+
def connect(messages):
835+
connected.set()
836+
for _ in messages:
837+
pass
838+
return object()
839+
840+
transports = _install_fake_registration_transport(monkeypatch, connect)
841+
842+
thread = threading.Thread(target=worker._run_sandbox_registration_loop, daemon=True)
843+
thread.start()
844+
try:
845+
assert connected.wait(10)
846+
worker._sandbox_registration_stop.set()
847+
thread.join(timeout=10)
848+
assert not thread.is_alive()
849+
finally:
850+
worker._sandbox_registration_stop.set()
851+
thread.join(timeout=10)
852+
853+
assert len(transports) == 1
854+
assert transports[0].close_count == 1
855+
856+
857+
class _StubBackoff:
858+
"""Records the backoff window used for each retry and never actually sleeps."""
859+
860+
def __init__(self) -> None:
861+
self.upper_bounds: list[float] = []
862+
863+
def uniform(self, lower: float, upper: float) -> float:
864+
self.upper_bounds.append(upper)
865+
return 0.0
866+
867+
868+
class _FakeRegistrationTransport:
869+
def __init__(self, connect, kwargs) -> None:
870+
self.kwargs = kwargs
871+
self.close_count = 0
872+
self._connect = connect
873+
874+
def connect_sandbox_activity_worker(self, messages):
875+
return self._connect(messages)
876+
877+
def close(self) -> None:
878+
self.close_count += 1
879+
880+
881+
def _install_fake_registration_transport(
882+
monkeypatch, connect) -> list[_FakeRegistrationTransport]:
883+
transports: list[_FakeRegistrationTransport] = []
884+
885+
def factory(**kwargs) -> _FakeRegistrationTransport:
886+
transport = _FakeRegistrationTransport(connect, kwargs)
887+
transports.append(transport)
888+
return transport
889+
890+
monkeypatch.setattr(sandbox_worker, "SandboxActivitiesGrpcTransport", factory)
891+
return transports
892+
893+
894+
def _build_registration_test_worker(monkeypatch) -> SandboxWorker:
895+
monkeypatch.setenv("DTS_ENDPOINT", "http://localhost:8080")
896+
monkeypatch.setenv("DTS_TASK_HUB", "env-hub")
897+
monkeypatch.setenv("DTS_WORKER_PROFILE_ID", "env-profile")
898+
monkeypatch.setenv("DTS_SANDBOX_PROVIDER", "Sandbox")
899+
_configure_sandbox_worker_auth(monkeypatch)
900+
901+
def RegistrationActivity(_ctx, value):
902+
return value
903+
904+
worker = SandboxWorker()
905+
worker.add_activity(RegistrationActivity)
906+
worker._configure_sandbox_activity_filters()
907+
worker._sandbox_registration_stop.clear()
908+
return worker
909+
910+
769911
class _RecordingChannel:
770912
def __init__(self) -> None:
771913
self.methods: list[str] = []

0 commit comments

Comments
 (0)