Skip to content

Performance [P2]: Avoid eager worker imports from Azure managed package - #209

Merged
berndverst merged 8 commits into
mainfrom
berndverst-lazy-worker-exports-issue-196
Jul 27, 2026
Merged

Performance [P2]: Avoid eager worker imports from Azure managed package#209
berndverst merged 8 commits into
mainfrom
berndverst-lazy-worker-exports-issue-196

Conversation

@berndverst

@berndverst berndverst commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

The durabletask package initializer eagerly imported durabletask.worker (and durabletask.payload.store) purely to re-export a handful of public types. Because durabletask-azuremanaged/durabletask/ has no __init__.py of its own — the Azure managed distribution shares the same durabletask namespace 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 a TYPE_CHECKING block 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.store triggers durabletask/payload/__init__.py, which imports payload/helpers.py, which imports google.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 own importtime line — 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.

Scenario Before After Delta
import durabletask — attributable time 415.0 ms 5.7 ms ~73x faster
import durabletask — modules resident 294 82 −212 modules
import durabletask.azuremanaged.client — attributable time 716.8 ms 646.2 ms ~70 ms
import durabletask.azuremanaged.client — modules resident 409 399 −10 modules

Important

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 imports durabletask (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.sandboxes across 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.)

Configuration Time Modules
Neither change 1018.0 ms 548
This PR (#196) alone 1040.4 ms 548 (no change)
#197 alone 467.5 ms 296
Both 94.0 ms 84

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__.py independently 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:

  • Every existing import path: from durabletask import ConcurrencyOptions, GrpcChannelOptions, GrpcRetryPolicyOptions, LargePayloadStorageOptions, PayloadStore, ActivityWorkItemFilter, EntityWorkItemFilter, OrchestrationWorkItemFilter, VersioningOptions, WorkItemFilters
  • __all__ verbatim (same names, same order) and PACKAGE_NAME
  • dir(durabletask) still returns the public names (module-level __dir__ added)
  • from durabletask import *
  • Submodule attribute access after a bare import durabletask — see below
  • Submodule imports (from durabletask import task, client, worker, entities) — these keep working because __getattr__ raises AttributeError, letting importlib's _handle_fromlist fall back to importing the submodule
  • The exact AttributeError message shape for genuinely unknown attributes
  • Each lazily-resolved symbol is the same object as in its defining module (identity, not a copy)

No import cycle: durabletask/worker.py does from durabletask import task, and both import orders (import durabletask first, import durabletask.worker first) 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 on main, a bare import durabletask left seven submodules reachable as attributes — entities, grpc_options, internal, payload, serialization, task, worker — and code doing import durabletask followed by durabletask.task.X worked.

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_SUBMODULES frozenset that __getattr__ consults after the export lookup misses, resolving via import_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.worker and grpc still absent from sys.modules).

Two details worth noting:

  • The seven names were derived empirically from a clean main checkout rather than hand-picked, so the restored surface matches exactly.
  • durabletask.client is deliberately not included. It was never transitively imported on main, 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 main confirms nothing is lost.

Compatibility with the new azure-functions-durable package

main added the in-repo azure-functions-durable package (#155), which imports heavily from this namespace. All 26 of its distinct durabletask imports use the explicit from X import Y form, which resolves correctly under PEP 562; every other textual durabletask. 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 with main's eager initializer.

CI fix

The first CI run failed on all five Python versions with ModuleNotFoundError: No module named 'durabletask.azuremanaged' in test_importing_azuremanaged_client_does_not_import_worker.

The cause is a missing package, not lazy initialization: the run-tests job in .github/workflows/durabletask.yml installs requirements.txt, .[azure-blob-payloads] and aiohttp, and never installs durabletask-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 a sys.meta_path hook 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).

Check Result
pytest tests/durabletask -q --ignore-glob="*_e2e.py" 727 passed, 7 skipped
pytest tests/azure-functions-durable -m "not dts and not azurite and not functions_e2e" 202 passed — identical to the same run against main's eager initializer
pytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -q 45 passed
flake8 . (as the lint job runs it) clean, exit 0
pyright strict on durabletask/__init__.py 0 diagnostics
pymarkdown -c .pymarkdown.json scan CHANGELOG.md 0 findings on the added entry

New tests in tests/durabletask/test_lazy_exports.py (43 tests) cover:

  • durabletask.worker is not in sys.modules after a bare import durabletask (run in a subprocess for isolation), and likewise for grpc, google.protobuf, and opentelemetry
  • The same holds after import durabletask.azuremanaged.client
  • Each of the seven eagerly-bound submodules resolves by attribute access after a bare import durabletask, and does so lazily
  • Every public name is importable, resolves to the same object as in its defining module, and its source module is pinned independently of the implementation
  • __all__, dir(), star-import, PACKAGE_NAME, AttributeError shape, caching, and both import orders

Sanity 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.md has 16 pre-existing MD013 line-length violations on main. 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

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
Copilot AI review requested due to automatic review settings July 27, 2026 06:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__.py with PEP 562 lazy exports via __getattr__, plus a TYPE_CHECKING block 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.md under ## 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
Copilot AI review requested due to automatic review settings July 27, 2026 06:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread CHANGELOG.md Outdated

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new format has been non-indented lines, but agreed, this needs to be shortened or split

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@andystaples andystaples left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@berndverst

Copy link
Copy Markdown
Member Author

@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 __getattr__ in package initializers

You asked for examples from widely adopted modules. The strongest one is CPython itself, and specifically for exactly our motivation.

Lib/typing.py uses a module-level __getattr__ whose docstring states the purpose outright:

def __getattr__(attr):
    """Improve the import time of the typing module.

    Soft-deprecated objects which are costly to create
    are only created on-demand here.
    """

It defers import re and import contextlib until the relevant attribute is touched. Same mechanism, same motivation — import time.

Other stdlib modules using PEP 562 (verified against the CPython 3.13 stdlib in my environment): zoneinfo/__init__.py, sqlite3/__init__.py, sqlite3/dbapi2.py, ast.py, calendar.py, importlib/abc.py, types.py, urllib/parse.py.

Two honest caveats, because they matter for how much weight this carries:

  • Most of those are deprecation shims, not lazy loading. typing.py is the import-time one; zoneinfo/__init__.py is the one that demonstrates the idiom in a package __init__.py; ast.py demonstrates the globals()[name] = value memoisation our implementation uses.
  • I only cite what I verified locally. I did not have numpy/scipy/pandas/django installed to check, so I am not claiming them.

For third-party precedent, the closest match is python-dateutil, whose dateutil/__init__.py is structurally the same thing we wrote:

__all__ = ['easter', 'parser', 'relativedelta', 'rrule', 'tz',
           'utils', 'zoneinfo']

def __getattr__(name):
    import importlib
    if name in __all__:
        return importlib.import_module("." + name, __name__)
    raise AttributeError(
        "module {!r} has not attribute {!r}".format(__name__, name)
    )

def __dir__():
    # __dir__ should include all the lazy-importable modules as well.
    return [x for x in globals() if x not in sys.modules] + __all__

Also verified locally: astroid (pylint's core), markupsafe (a Jinja2/Flask dependency), and toolz.

Worth noting PEP 562 is a language feature since Python 3.7, and this repo requires 3.10+, so we are not relying on anything exotic.

2. The sys.modules concern — you are right that it is a real behaviour change

This is the most substantive point you raised, and it is distinct from the attribute-binding gap already fixed in fdd2b99. That commit restored durabletask.worker and friends as attributes, which imports and registers them on access. But a direct sys.modules['durabletask.worker'] lookup with no prior access still raises KeyError where it previously did not. So the concern is legitimate.

On whether it bites in practice, I checked rather than guessed:

  • The only sys.modules reference in this repository's source is durabletask/serialization.py:527, inside _type_namespace:

    module = sys.modules.get(getattr(cls, "__module__", None) or "")

    That resolves a class's own defining module, which is necessarily already imported for the class object to exist. Unaffected.

  • The new in-repo azure-functions-durable package from Add azure-functions-durable v2: a durabletask-based rewrite of Durable Functions for Python #155 — which is the "azure functions worker/sdk" you were concerned about — contains zero sys.modules references, and imports this SDK exclusively through the explicit from durabletask.X import Y form (from durabletask.worker import TaskHubGrpcWorker, from durabletask.client import ..., from durabletask import task, and so on). That form goes through _handle_fromlist, which imports and registers the submodule normally, so it resolves correctly under PEP 562.

That does not prove no external consumer anywhere does a bare sys.modules probe. It does mean nothing in this repo, including the Azure Functions SDK that ships alongside it, depends on the old behaviour.

3. Correcting the CI failure diagnosis

Your read was that the test fails "because it can't access durabletask.azuremanaged due to it not being lazy-initialized yet." That turns out not to be the cause, and I want to flag it so the fix targets the right thing.

The failure is ModuleNotFoundError: No module named 'durabletask.azuremanaged' in test_importing_azuremanaged_client_does_not_import_worker. The run-tests job in .github/workflows/durabletask.yml installs requirements.txt, .[azure-blob-payloads] and aiohttp — it never installs durabletask-azuremanaged at all. So the package genuinely is not present in that job, independent of lazy initialization; the test could never have passed there. It passes locally only because a dev environment has both packages installed editable.

The fix being applied is to guard that test on provider availability rather than to widen the CI install scope, which would be out of scope for this PR. The CHANGELOG line-length nit is being fixed at the same time, with non-indented continuation lines per your note.

4. Honest cost/benefit

You asked for a careful evaluation, so let me give you the unflattering version rather than the headline.

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
Copilot AI review requested due to automatic review settings July 27, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread CHANGELOG.md
@andystaples

andystaples commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@berndverst Thanks for the response.
One important thing you missed - by "azure functions worker/sdk", I did not mean the durable functions SDK included here. I meant
https://github.com/Azure/azure-functions-python-library and https://github.com/Azure/azure-functions-python-worker , the dependency for our package and the worker process that runs it in Functions. Besides, those are just examples, as you acknowledged, any code that access sysmodules directly is subject to a behavior change with this PR.
The CI failure misattribution is good news, though, as are the precedents you cited.
I'm not against merging this, but I want to discuss with the team. Can you check if the python sdk/worker referenced above change the equation for your previous analysis?

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
Copilot AI review requested due to automatic review settings July 27, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread tests/durabletask/test_lazy_exports.py
@berndverst

Copy link
Copy Markdown
Member Author

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 (azure-functions-python-library @ 21dfea5, azure-functions-python-worker @ 5f5ad12).

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 sys.modules-as-usage-proxy hazard is real

You're right that this is the dangerous pattern, and I want to state it as a general rule rather than wave it away:

If a package is both (a) probed via X in sys.modules as a proxy for "the customer is using X", and (b) made lazily loaded, the probe can wrongly conclude "not in use."

That is exactly the failure mode worth worrying about. The reason it doesn't bite here is narrow and specific, not general.

The v2 worker runtime already does this optimization, for this reason

runtimes/v2/azure_functions_runtime/bindings/meta.py::_get_deferred_binding_registry:

# Short-circuit: if the customer hasn't (transitively) imported the
# base extension, they cannot be using deferred bindings. Skip the
# cold-import cost entirely.
if 'azurefunctions.extensions.base' not in sys.modules:
    _DEFERRED_BINDING_REGISTRY_LOADED = True
    return None

Its docstring says the extension "is NOT eagerly imported here. Only apps that use deferred bindings or HTTP v2 actually need it." HttpV2Registry._check_http_v2_enabled does the same thing. There is a dedicated test file, test_lazy_base_extension.py, whose docstring gives the motivation: "The v2 runtime defers importing the base extension until first use to avoid paying its (200+ ms) cold-import cost."

So the component you named isn't merely tolerant of deferred imports — it implements the same pattern, for the same cold-start reason, pinned with tests structured much like the ones in this PR. I think that's a stronger precedent than the stdlib examples I gave earlier, because it's your own runtime and it's about cold start specifically.

It also shows precisely where the danger lives: those probes key on azurefunctions.extensions.base and azure.functions. Making those lazy would break them.

Why durabletask isn't one of those

There are zero Python references to durabletask in either repo. The only textual hit is a .NET NuGet PackageReference string (Microsoft.Azure.WebJobs.Extensions.DurableTask) inside a test-fixture csproj in workers/tests/utils/constants.py. Neither repo imports, probes, reloads, or special-cases the Python durabletask package. I checked all 48 sys.modules occurrences across the 11 production files.

The three mechanisms that could plausibly interact, tested rather than reasoned about

I ran these against a real (non-editable) site-packages install using the worker's actual DependencyManager, comparing my branch against origin/main's eager initializer.

1. DependencyManager._remove_module_cache / _clear_module_cache — enumerates sys.modules and drops anything under a given path so it re-imports from the customer's dependency path.

[lazy ] before=  1  after=  0  cleared=  1  leftover=[]
[eager] before= 28  after=  0  cleared= 28  leftover=[]

Both end in the same state, and after the wipe from durabletask import ConcurrencyOptions, from durabletask.worker import TaskHubGrpcWorker, and attribute access to all 7 submodules all succeed in both. Lazy loading means there is less to clear, which is aligned with what the routine is trying to achieve, not adverse to it. A module that was never imported doesn't need clearing — its eventual import resolves from the corrected sys.path.

Incidentally, _remove_module_cache reads __path__ before falling back to __file__, so it handles durabletask correctly even when it resolves as a namespace package (which it does whenever both distributions are installed editable — __file__ is None there).

2. importlib.reloaddependency.py reloads a hardcoded list: ['azure', 'google'] and azure.functions. durabletask isn't in it. I tested reload anyway; it works identically on both variants, and from-imports and attribute access both still resolve afterward.

3. _durable_functions._deserialize_custom_object — this is the one place I found that does a sys.modules lookup and raises when the entry is missing ("modules are never imported on demand"). It only applies to objects that went through _serialize_custom_object, which requires a to_json attribute. In durabletask, to_json exists only on classes in durabletask.scheduled.*:

                                              main    this PR
durabletask.scheduled        in sys.modules   False    False
durabletask.scheduled.models in sys.modules   False    False
durabletask.worker           in sys.modules   True     False

durabletask.scheduled is not eagerly imported on main either, so this path is unchanged by the PR. (It's also not one of the 7 submodules I rebind — those were derived empirically from main, and scheduled was never among them.)

On the caching question

The lazily-resolved values are cached in the module's own globals(), so when the worker pops durabletask from sys.modules and it's re-imported, the cache goes away with the old module object. There's no cross-instance staleness, and _remove_module_cache pops parent and children together by path prefix, so they stay consistent.

Where I land

I don't think this changes the equation, but "no Functions component touches durabletask" is a statement about today's code, and you're right that it's the kind of coupling that could appear later. If the team wants, I'm happy to add a regression test that asserts the bare-import module set stays bounded, so any future coupling shows up as a test failure rather than a cold-start surprise.

And to repeat the caveat from the PR body since it's the honest framing: the 415 ms → 5.7 ms / 294 → 82 modules headline is for a bare import durabletask. The realistic client path, import durabletask.azuremanaged.client, goes 716.8 ms → 646.2 ms (409 → 399 modules), because that module pulls in gRPC and protobuf directly for its own purposes. The big win is for consumers that import the package without touching the worker.

Take the time you need with the team — I'd rather this get a proper look than land quickly.

Bernd Verst and others added 2 commits July 27, 2026 10:54
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
Copilot AI review requested due to automatic review settings July 27, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`,

Copilot AI review requested due to automatic review settings July 27, 2026 18:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() uses subprocess.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,
    )

@berndverst
berndverst merged commit 0edf379 into main Jul 27, 2026
25 checks passed
@berndverst
berndverst deleted the berndverst-lazy-worker-exports-issue-196 branch July 27, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance [P2]: Avoid eager worker imports from Azure managed package

3 participants