Skip to content

Commit 6a52e1d

Browse files
berndverstBernd VerstCopilot
authored
Performance [P3]: Lazily load sandbox runtime API exports (#208)
* Lazily load sandbox runtime API exports Importing durabletask.azuremanaged.preview.sandboxes eagerly imported both the sandbox client and the sandbox worker. The worker module pulls in ManagedIdentityCredential from azure-identity plus the core worker graph, so merely touching the sandboxes namespace paid that cost at startup. Resolve the package's public names through a PEP 562 module __getattr__ instead, with TYPE_CHECKING imports so type checkers and IDEs still see them. __all__, dir(), star-imports, submodule attributes, and every import path are unchanged. Fixes #197 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9df32033-0d12-4be2-ad8c-3fea7890376f * Bound the isolated import probe subprocess with a timeout The subprocess-isolated lazy-import probes ran with no timeout, so a hung child (import deadlock or environment issue) would stall CI indefinitely. Pass an explicit 60s bound - generous for an import-time probe - and turn subprocess.TimeoutExpired into a readable pytest failure that reports the bound and any partial output, rather than letting it propagate raw. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9df32033-0d12-4be2-ad8c-3fea7890376f --------- Co-authored-by: Bernd Verst <beverst@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9df32033-0d12-4be2-ad8c-3fea7890376f
1 parent 0edf379 commit 6a52e1d

3 files changed

Lines changed: 168 additions & 6 deletions

File tree

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1313
result, credential failures (for example an unavailable managed identity or a misconfigured
1414
`DefaultAzureCredential`) now surface from the first request rather than from the constructor.
1515
The exception type and message are unchanged; only the timing differs.
16+
- Importing `durabletask.azuremanaged.preview.sandboxes` no longer loads the sandbox worker
17+
runtime or `azure-identity` up front. The package's public names are now resolved on first
18+
use, which roughly halves import cost for callers that only declare sandbox worker profiles
19+
or use the sandbox client. Exported names, `__all__`, and import paths are unchanged.
1620
- `FailureDetails.error_type` now carries the fully-qualified type name (e.g. `durabletask.task.TaskFailedError`) instead of the bare class name, and the new `FailureDetails.is_caused_by()` helper is available (both inherited from durabletask). See the core `durabletask` changelog for details, including the breaking-change notes.
1721
- Improved async access token refresh concurrency handling to avoid duplicate
1822
refresh operations under concurrent access, matching the existing sync

‎durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/__init__.py‎

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,22 @@
1313
SandboxWorker,
1414
SandboxActivitiesClient,
1515
)
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.
1620
"""
1721

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
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
2432

2533
__all__ = [
2634
"SandboxWorker",
@@ -30,3 +38,43 @@
3038
"SandboxActivitiesClient",
3139
"sandbox_worker_profile",
3240
]
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)

‎tests/durabletask-azuremanaged/test_sandboxes_extension.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33

44
import inspect
55
import random
6+
import subprocess
7+
import sys
68
import threading
79

810
import grpc
11+
import pytest
912
from azure.core.credentials import AccessToken
1013

1114
import durabletask.azuremanaged.preview.sandboxes as sandbox
@@ -69,6 +72,113 @@ def test_public_sandbox_package_exports_customer_entrypoints_only() -> None:
6972
assert not hasattr(SandboxWorker, f"_configure_{legacy_prefix}_activity_filters")
7073

7174

75+
# Runs in a subprocess because this test module imports the sandbox worker at
76+
# module scope, which would otherwise hide the lazy-import behavior.
77+
_LAZY_EXPORT_PROBE = """
78+
import sys
79+
80+
import durabletask.azuremanaged.preview.sandboxes as sandbox
81+
82+
eager = [
83+
name
84+
for name in (
85+
"durabletask.azuremanaged.preview.sandboxes.client",
86+
"durabletask.azuremanaged.preview.sandboxes.transport",
87+
"durabletask.azuremanaged.preview.sandboxes.worker",
88+
"azure.identity",
89+
)
90+
if name in sys.modules
91+
]
92+
assert not eager, f"sandbox package eagerly imported: {eager}"
93+
94+
expected = [
95+
"SandboxWorker",
96+
"SandboxActivity",
97+
"SandboxWorkerProfile",
98+
"SandboxWorkerProfileOptions",
99+
"SandboxActivitiesClient",
100+
"sandbox_worker_profile",
101+
]
102+
assert sandbox.__all__ == expected, sandbox.__all__
103+
104+
listing = dir(sandbox)
105+
assert not [name for name in expected if name not in listing], listing
106+
assert not [name for name in ("client", "helpers", "worker", "worker_profiles") if name not in listing], listing
107+
108+
from durabletask.azuremanaged.preview.sandboxes.client import SandboxActivitiesClient
109+
from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity
110+
from durabletask.azuremanaged.preview.sandboxes.worker import SandboxWorker
111+
from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfile
112+
from durabletask.azuremanaged.preview.sandboxes.worker_profiles import SandboxWorkerProfileOptions
113+
from durabletask.azuremanaged.preview.sandboxes.worker_profiles import sandbox_worker_profile
114+
115+
assert sandbox.SandboxWorker is SandboxWorker
116+
assert sandbox.SandboxActivity is SandboxActivity
117+
assert sandbox.SandboxWorkerProfile is SandboxWorkerProfile
118+
assert sandbox.SandboxWorkerProfileOptions is SandboxWorkerProfileOptions
119+
assert sandbox.SandboxActivitiesClient is SandboxActivitiesClient
120+
assert sandbox.sandbox_worker_profile is sandbox_worker_profile
121+
122+
# Submodules stay reachable as package attributes, as they were while the
123+
# package imported them eagerly.
124+
assert sandbox.worker is sys.modules["durabletask.azuremanaged.preview.sandboxes.worker"]
125+
assert sandbox.helpers is sys.modules["durabletask.azuremanaged.preview.sandboxes.helpers"]
126+
"""
127+
128+
129+
_STAR_IMPORT_PROBE = """
130+
from durabletask.azuremanaged.preview.sandboxes import * # noqa: F403
131+
132+
import durabletask.azuremanaged.preview.sandboxes as sandbox
133+
134+
star_names = {name: value for name, value in globals().items() if not name.startswith("_")}
135+
for name in sandbox.__all__:
136+
assert name in star_names, f"missing star export: {name}"
137+
assert star_names[name] is getattr(sandbox, name), name
138+
139+
# __all__ still gates the star import, so lazy-import plumbing stays private.
140+
assert "import_module" not in star_names
141+
assert "_LAZY_EXPORTS" not in globals()
142+
"""
143+
144+
145+
_ISOLATED_PROBE_TIMEOUT_SECONDS = 60
146+
147+
148+
def _run_isolated(source: str) -> None:
149+
try:
150+
result = subprocess.run(
151+
[sys.executable, "-c", source],
152+
capture_output=True,
153+
text=True,
154+
check=False,
155+
timeout=_ISOLATED_PROBE_TIMEOUT_SECONDS)
156+
except subprocess.TimeoutExpired as exc:
157+
pytest.fail(
158+
f"isolated import probe did not complete within "
159+
f"{_ISOLATED_PROBE_TIMEOUT_SECONDS}s and was terminated. "
160+
f"This usually means the import deadlocked.\n"
161+
f"stdout: {exc.stdout or ''}\n"
162+
f"stderr: {exc.stderr or ''}")
163+
assert result.returncode == 0, result.stdout + result.stderr
164+
165+
166+
def test_sandbox_package_defers_worker_runtime_imports() -> None:
167+
_run_isolated(_LAZY_EXPORT_PROBE)
168+
169+
170+
def test_sandbox_package_star_import_matches_all() -> None:
171+
_run_isolated(_STAR_IMPORT_PROBE)
172+
173+
174+
def test_sandbox_package_raises_attribute_error_for_unknown_attributes() -> None:
175+
with pytest.raises(AttributeError, match="has no attribute 'NotARealSandboxExport'"):
176+
getattr(sandbox, "NotARealSandboxExport")
177+
178+
assert not hasattr(sandbox, "NotARealSandboxExport")
179+
assert all(hasattr(sandbox, name) for name in sandbox.__all__)
180+
181+
72182
def _sandbox_image(
73183
image_ref: str,
74184
managed_identity_client_id: str = "image-pull-client-id",

0 commit comments

Comments
 (0)