Performance [P2]: Avoid eager worker imports from Azure managed package - #209
Conversation
The `durabletask` package initializer eagerly imported `durabletask.worker`
just to re-export a handful of public types. Because the
`durabletask-azuremanaged` distribution shares the same `durabletask`
namespace package, importing anything from it (for example
`durabletask.azuremanaged.client`) first executed this initializer and
therefore loaded the entire worker dependency graph - gRPC, protobuf,
entities, serialization, and OpenTelemetry - even for client-only
applications.
Resolve the public re-exports lazily with a module-level `__getattr__`
(PEP 562), paired with a `TYPE_CHECKING` block so static type checkers and
IDEs still resolve every symbol. Resolved values are cached in the module
globals so subsequent lookups bypass the hook entirely.
Measured with `python -X importtime`, median of 11 runs:
import durabletask 419.2 ms -> 5.5 ms (76x faster)
293 -> 81 modules loaded
import durabletask.azuremanaged.client 641.1 ms -> 610.8 ms
408 -> 398 modules loaded
No public API changes: every existing import path, `__all__`, `PACKAGE_NAME`,
`dir()`, star-imports, and the `AttributeError` message shape are preserved.
Fixes #196
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6ec6f9e-f919-4874-b146-fa764b5c5617
There was a problem hiding this comment.
Pull request overview
This PR improves import-time performance for the durabletask root package (and therefore for the durabletask.azuremanaged namespace package) by eliminating eager imports that previously pulled in the full worker dependency graph even for client-only usage.
Changes:
- Replaced eager re-exports in
durabletask/__init__.pywith PEP 562 lazy exports via__getattr__, plus aTYPE_CHECKINGblock to preserve IDE/static type resolution. - Added a focused test suite to pin lazy-import behavior and preserve backward-compatible public surface semantics (
__all__,dir(), star-import, and legacy import paths). - Documented the user-visible behavior change in
CHANGELOG.mdunder## Unreleased.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
durabletask/__init__.py |
Implements lazy public re-exports to avoid importing worker/payload dependencies during import durabletask. |
tests/durabletask/test_lazy_exports.py |
Adds regression coverage for lazy export behavior and backward-compatible import semantics. |
CHANGELOG.md |
Records the import-time behavior/performance improvement as a user-facing change under Unreleased. |
Making the public re-exports lazy also removed a side effect of the previous eager imports: the import system had been binding the imported submodules as attributes of the `durabletask` package. A bare `import durabletask` therefore left `entities`, `grpc_options`, `internal`, `payload`, `serialization`, `task`, and `worker` reachable through plain attribute access, and that stopped working. `from durabletask import task` kept working either way, because importlib's `_handle_fromlist` imports the submodule when `__getattr__` raises, which is why the existing submodule test did not catch this. Plain attribute access has no such fallback. Resolve those seven names through `__getattr__` as well, and include them in `__dir__`. They are still imported only when touched, so the cold-start win is unchanged: a bare `import durabletask` continues to load 81 modules and pulls in neither the worker nor gRPC. The list deliberately mirrors the previous surface rather than extending it - `durabletask.client` was never bound this way, so it stays unreachable by attribute access, and a test pins that. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b6ec6f9e-f919-4874-b146-fa764b5c5617
|
|
||
| CHANGED | ||
|
|
||
| - Importing `durabletask` no longer eagerly imports the worker implementation and its dependencies (gRPC, protobuf, entities, serialization, OpenTelemetry). The public names re-exported from the package — `ActivityWorkItemFilter`, `ConcurrencyOptions`, `EntityWorkItemFilter`, `GrpcChannelOptions`, `GrpcRetryPolicyOptions`, `LargePayloadStorageOptions`, `OrchestrationWorkItemFilter`, `PayloadStore`, `VersioningOptions`, and `WorkItemFilters` — are now resolved on first use, so `import durabletask` is substantially faster and loads far fewer modules. This measurably reduces cold-start time for client-only applications, including those using `durabletask.azuremanaged`, which shares the same `durabletask` namespace. All existing import paths, `__all__`, `dir()`, and star-imports behave exactly as before. |
There was a problem hiding this comment.
The new format has been non-indented lines, but agreed, this needs to be shortened or split
There was a problem hiding this comment.
Addressed in e1db0f4. The entry is now wrapped at <=89 characters across 10 lines, with 2-space indented continuations matching the rest of the file. Details and the supporting survey are in the newer thread.
There was a problem hiding this comment.
Correction — one detail in my reply above is now wrong against merged main.
I wrote that the entry landed "with 2-space indented continuations matching the rest of the file." It did not. I reverted the indentation in 1ea92d6, and 0edf379 shipped the entry with unindented continuation lines — which is the format @andystaples described on this thread, and which he stated before I made the indent commit rather than after. I indented on the strength of a survey of the existing entries; that measured the files as they were, not where he was taking them, and his call outranks the count. Reverting was the fix.
The substantive ask on this thread was satisfied either way. The entry is wrapped across 10 lines with a longest line of 87 characters, comfortably under the 100-character md013 limit, and indentation never affected that. Verified against merged main:
[ 85] - Importing `durabletask` no longer eagerly imports the worker implementation and its
[ 87] dependencies (gRPC, protobuf, entities, serialization, OpenTelemetry). The public names
[ 78] re-exported from the package — `ActivityWorkItemFilter`, `ConcurrencyOptions`,
...
So: line length addressed as asked, style corrected to match the repo's convention.
There was a problem hiding this comment.
This PR may improve import performance, but I think it's worth at least discussing the side-effects. Now, importing durabletask will not populate sys.modules with the sub-modules and module namespaces, so auto-discovery in code that manipulates sys.modules directly (like the azure functions worker/sdk) will be altered.
Also, if I'm understanding the test failure correctly, there are some quirks with accessing nested properties here - the test that imports durabletask.azuremanaged.client seems to be failing because it can't access durabletask.azuremanaged due to it not being lazy-initialized yet.
Overall, messing with __getattr__ in __init__.py feels unnatural and error-prone. I'd like to see examples of other widely adopted modules that do this and carefully evaluate the cost/benefit before merging.
Applies also to #208
…r-exports-issue-196
|
@andystaples thanks for the careful review — these are the right questions to ask. Taking your three points in turn, with evidence rather than assertion. 1. Precedent for module-level
|
| Path | Before | After |
|---|---|---|
import durabletask |
419 ms / 293 modules | ~5.5 ms / 81 modules |
import durabletask.azuremanaged.client |
641 ms | 611 ms |
The dramatic number is a bare import durabletask, which few real applications do in isolation. For the realistic client path the win is roughly 30 ms, not 414 ms, because the azuremanaged client imports gRPC and protobuf directly for its own work regardless. The benefit is real but concentrated in client-only and import-graph-sensitive scenarios rather than being a broad win. (These figures are being re-measured after merging latest main; I will correct them here if they move.)
So the trade is: a meaningful cold-start improvement for client-only consumers, against a genuine if narrow behavioural change in sys.modules population, plus the ongoing maintenance cost of a non-obvious initializer.
That trade is yours to make, not mine, and I am not going to argue you out of a "no." If you would rather not take this shape at all, a reasonable middle ground is to keep #208 — where the win is larger (965 ms → 376 ms, −252 modules) and the blast radius is confined to a preview namespace — and drop this one. Happy to go whichever way you prefer; just say which and I will action it.
`test_importing_azuremanaged_client_does_not_import_worker` failed on all
five Python versions in the `run-tests` job of `.github/workflows/durabletask.yml`
with `ModuleNotFoundError: No module named 'durabletask.azuremanaged'`.
The cause is a missing package, not lazy initialization. That job installs
`requirements.txt`, `.[azure-blob-payloads]` and `aiohttp`, and never installs
`durabletask-azuremanaged` -- so the test's subprocess could never import the
provider there. It passes locally only because a development environment has
both distributions installed.
Guard the test with `pytest.importorskip("durabletask.azuremanaged")` so it
skips cleanly where the provider is not installed. The assertion itself is
unchanged, and the test still runs (and still has teeth) wherever the provider
is present. Widening the workflow's install scope would be a broader change
than this fix needs.
Verified by blocking the provider behind a `sys.meta_path` hook to simulate the
CI environment: 42 passed, 1 skipped without the provider, 43 passed with it.
Also rewrap the CHANGELOG entry to the 100-character md013 limit using
non-indented continuation lines. The file has 16 pre-existing md013 violations
on `main`; this keeps that count unchanged rather than adding to it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6ec6f9e-f919-4874-b146-fa764b5c5617
|
@berndverst Thanks for the response. |
The wrapped bullet added by this PR used non-indented continuation lines. Surveying every wrapped bullet in the repository shows that is the sole exception: CHANGELOG.md 59 indented, 0 non-indented durabletask-azuremanaged/CHANGELOG 20 indented, 0 non-indented azure-functions-durable/CHANGELOG 26 indented, 0 non-indented Indent the nine continuation lines to match. Content is unchanged -- the diff is exactly 18 added space characters (9 lines x 2). Note the rendering rationale offered in review does not apply here. CommonMark lazy continuation keeps unindented paragraph text inside the list item, and GitHub's own renderer returns byte-identical HTML for both forms (1 <ul>, 1 <li>, no text escaping the item). The reason to indent is consistency with the other 105 wrapped bullets, not a rendering defect. Indented text is still more robust, since a future reflow that starts a line with "-" or "1." would silently open a new list block. Longest line is now 89 characters, within the md013 limit of 100. Violation count is unchanged at 16, all pre-existing and none on the lines this PR touches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b6ec6f9e-f919-4874-b146-fa764b5c5617
|
Thanks for the pointers — that was the right question to ask, and it changed what I checked. My earlier answer looked at the in-repo durable-functions SDK, which is not what you meant. I've now gone through both repos you named at their current tips ( Short answer: they don't change the conclusion — but not because the concern was unfounded. The concern is real, and the worker itself is the best evidence for it. The
|
…r-exports-issue-196
The repo's agent instructions, updated in #211, now explicitly codify the changelog style: 'wrapped entry text remains unindented rather than being aligned beneath the bullet.' My earlier commit e1db0f4 indented these continuations after surveying existing entries (105 of 105 wrapped bullets were indented). That survey measured the legacy style; the documented convention is a deliberate forward-looking change, and andystaples had said as much in review. The maintainer's stated preference is now written policy, so this reverts to unindented. Content is unchanged apart from removing 18 leading spaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b6ec6f9e-f919-4874-b146-fa764b5c5617
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
CHANGELOG.md:20
- This wrapped changelog bullet’s continuation lines are not indented, which is inconsistent with the rest of this changelog’s wrapped bullets (e.g. the v1.8.0 FIXED entry at lines 43-45 uses indented continuations). Indenting the continuation lines keeps formatting consistent and avoids accidental list breaks if a future line starts with
-/1..
- Importing `durabletask` no longer eagerly imports the worker implementation and its
dependencies (gRPC, protobuf, entities, serialization, OpenTelemetry). The public names
re-exported from the package — `ActivityWorkItemFilter`, `ConcurrencyOptions`,
`EntityWorkItemFilter`, `GrpcChannelOptions`, `GrpcRetryPolicyOptions`,
`LargePayloadStorageOptions`, `OrchestrationWorkItemFilter`, `PayloadStore`,
…r-exports-issue-196
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/durabletask/test_lazy_exports.py:79
_run_python()usessubprocess.run()without a timeout. If an import regresses into a hang (e.g., deadlock during import), the test suite can stall indefinitely instead of failing fast. Adding a conservative timeout makes CI failures deterministic.
def _run_python(code: str) -> "subprocess.CompletedProcess[str]":
"""Run a snippet in a fresh interpreter so ``sys.modules`` starts clean."""
return subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
)
Summary
The
durabletaskpackage initializer eagerly importeddurabletask.worker(anddurabletask.payload.store) purely to re-export a handful of public types. Becausedurabletask-azuremanaged/durabletask/has no__init__.pyof its own — the Azure managed distribution shares the samedurabletasknamespace package — importing anything from it (e.g.durabletask.azuremanaged.client) first executes the core initializer, and therefore drags in the entire worker dependency graph (gRPC, protobuf, entities, serialization, OpenTelemetry) even for client-only applications.This PR resolves those public re-exports lazily via a module-level
__getattr__(PEP 562), paired with aTYPE_CHECKINGblock so static type checkers and IDEs still resolve every symbol. Resolved values are cached into the module globals, so only the first attribute access goes through the hook.All 10 re-exports are made lazy rather than just the worker ones —
durabletask.payload.storetriggersdurabletask/payload/__init__.py, which importspayload/helpers.py, which importsgoogle.protobuf. Same amount of code, strictly more benefit.Measurements
Re-measured after merging
main(2269c44). The module counts below are unchanged from the earlier measurement, so the packages added by #155, #198, #206, #210 and #211 did not shift the baseline.python -X importtime, medians of 9 runs. Times are the cumulative column of the module's ownimporttimeline — i.e. the cost attributable to the import statement, excluding interpreter startup. Bare interpreter startup on this machine is 93.3 ms / 78 modules, which is the floor for the module counts.import durabletask— attributable timeimport durabletask— modules residentimport durabletask.azuremanaged.client— attributable timeimport durabletask.azuremanaged.client— modules residentImportant
The headline "415 ms → 5.7 ms" applies to a bare
import durabletask. The realistic client path,import durabletask.azuremanaged.client, improves far less — roughly 70 ms and 10 modules — because that module imports gRPC and protobuf directly for its own purposes, so this change cannot avoid them. The large win accrues to code that importsdurabletask(or anything under the namespace) without needing the worker; the client path benefits only modestly.Modules no longer loaded by
import durabletask.azuremanaged.client:durabletask.worker,durabletask.internal.exceptions,durabletask.internal.json_encode_output_exception,durabletask.internal.orchestration_entity_context,durabletask.internal.type_discovery,packaging,packaging.version,queue,_queue,concurrent.futures.thread.Note
This machine runs many concurrent workloads, so cold-import timings vary by roughly ±10% run to run. The module counts are exact and deterministic, and are the more reliable signal.
Interaction with #197
#197 makes the same kind of change for the sandboxes preview package. Measuring
import durabletask.azuremanaged.preview.sandboxesacross all four combinations shows the two changes are super-additive — neither alone gets close to the combined result. (These figures are totals including the interpreter floor, and were independently reproduced by the #197 author with identical module counts in all four cells.)To be explicit about what this PR does not do: on its own it gives no improvement to the sandboxes import path, because
sandboxes/__init__.pyindependently imports the worker graph, so that graph loads regardless of what the core initializer does. Symmetrically, #197 alone still pays for the eager core initializer. Only closing both doors avoids loading gRPC/protobuf at all. The two PRs are complementary and independently correct; neither blocks the other, and they touch disjoint files.Backwards compatibility
No public API changes. Explicitly preserved and covered by tests:
from durabletask import ConcurrencyOptions, GrpcChannelOptions, GrpcRetryPolicyOptions, LargePayloadStorageOptions, PayloadStore, ActivityWorkItemFilter, EntityWorkItemFilter, OrchestrationWorkItemFilter, VersioningOptions, WorkItemFilters__all__verbatim (same names, same order) andPACKAGE_NAMEdir(durabletask)still returns the public names (module-level__dir__added)from durabletask import *import durabletask— see belowfrom durabletask import task, client, worker, entities) — these keep working because__getattr__raisesAttributeError, letting importlib's_handle_fromlistfall back to importing the submoduleAttributeErrormessage shape for genuinely unknown attributesNo import cycle:
durabletask/worker.pydoesfrom durabletask import task, and both import orders (import durabletaskfirst,import durabletask.workerfirst) are verified.Preserving submodule attribute bindings
The eager
from durabletask.worker import ...had a second, easily-missed effect: importing a submodule binds it as an attribute of its parent package. So onmain, a bareimport durabletaskleft seven submodules reachable as attributes —entities,grpc_options,internal,payload,serialization,task,worker— and code doingimport durabletaskfollowed bydurabletask.task.Xworked.Making the re-exports lazy removed that side effect, so those attributes started raising
AttributeError. This was caught in review and is fixed here by a_LAZY_SUBMODULESfrozenset that__getattr__consults after the export lookup misses, resolving viaimport_module(f".{name}", __name__)and caching the result the same way. The names are also included in__dir__.This stays fully lazy — nothing is imported at module load, so the cold-start win is unaffected (re-measured post-merge: 5.7 ms, 82 modules, with
durabletask.workerandgrpcstill absent fromsys.modules).Two details worth noting:
maincheckout rather than hand-picked, so the restored surface matches exactly.durabletask.clientis deliberately not included. It was never transitively imported onmain, so it already failed this way; adding it would expand the public surface rather than preserve it. A test pins that it stays unbound.A full attribute-surface comparison against
mainconfirms nothing is lost.Compatibility with the new
azure-functions-durablepackagemainadded the in-repoazure-functions-durablepackage (#155), which imports heavily from this namespace. All 26 of its distinctdurabletaskimports use the explicitfrom X import Yform, which resolves correctly under PEP 562; every other textualdurabletask.occurrence is a docstring cross-reference or a deprecation message, not runtime attribute access. Verified empirically rather than assumed — its suite returns the same 202 passed both with this change and withmain's eager initializer.CI fix
The first CI run failed on all five Python versions with
ModuleNotFoundError: No module named 'durabletask.azuremanaged'intest_importing_azuremanaged_client_does_not_import_worker.The cause is a missing package, not lazy initialization: the
run-testsjob in.github/workflows/durabletask.ymlinstallsrequirements.txt,.[azure-blob-payloads]andaiohttp, and never installsdurabletask-azuremanaged. The test's subprocess could not import the provider there under any implementation; it passed locally only because a development environment has both distributions installed.Fixed by guarding that one test with
pytest.importorskip("durabletask.azuremanaged"), so it skips cleanly where the provider is absent and still runs — with its assertion unchanged — wherever it is present. Widening the workflow's install scope seemed out of scope for this PR. Verified by blocking the provider behind asys.meta_pathhook to simulate the CI environment: 42 passed / 1 skipped without the provider, 43 passed with it.Verification
All commands run against a session-local venv with the packages installed editable from this worktree, after merging
main(0f316cc).pytest tests/durabletask -q --ignore-glob="*_e2e.py"pytest tests/azure-functions-durable -m "not dts and not azurite and not functions_e2e"main's eager initializerpytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -qflake8 .(as the lint job runs it)pyrightstrict ondurabletask/__init__.pypymarkdown -c .pymarkdown.json scan CHANGELOG.mdNew tests in
tests/durabletask/test_lazy_exports.py(43 tests) cover:durabletask.workeris not insys.modulesafter a bareimport durabletask(run in a subprocess for isolation), and likewise forgrpc,google.protobuf, andopentelemetryimport durabletask.azuremanaged.clientimport durabletask, and does so lazily__all__,dir(), star-import,PACKAGE_NAME,AttributeErrorshape, caching, and both import ordersSanity check that the tests are meaningful: the submodule regression tests were written first and confirmed to fail (8 failures) against the unfixed implementation. Likewise, against the original eager initializer the lazy-behavior tests fail while the backwards-compatibility tests pass on both implementations — which is the intended split, since the compat tests pin behavior that must not change.
Note on the CHANGELOG entry
CHANGELOG.mdhas 16 pre-existing MD013 line-length violations onmain. The entry added here is wrapped to the 100-character limit using non-indented continuation lines, keeping that count unchanged rather than adding to it. Reflowing the rest of the file seemed out of scope.Fixes #196