diff --git a/benchmarks/README.md b/benchmarks/README.md index 8e9bafd8..9c23c672 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -24,6 +24,7 @@ cost. Runs in CI (informational, non-gating) and locally via `just bench`. | G9 | Context resolve: request value by type + APP dep, warm child | a `ContextProvider` dependency's own resolver | | G10 | `validate()` on a depth-6 chain (isolated via `pedantic`) | graph-validation traversal, deep | | G11 | `validate()` on a wide 10-sibling graph (isolated via `pedantic`) | graph-validation traversal, fan-out | +| G11b | `validate()` with 100 factories sharing a three-hop alias chain | reuse of effective scope lookups | | G12 | Resolve a depth-6 chain with one unrelated override active | that an active override costs the unrelated chain nothing | | G13 | Per-request cycle finalizing 10 cached resources (`close_sync`) | LIFO teardown at scale | | G13b | Batch of K=100 request cycles, 10 finalizer-less cached REQUEST providers, `await close_async()` | the async close loop when there is nothing to finalize | diff --git a/benchmarks/test_guard_validate.py b/benchmarks/test_guard_validate.py index d23a1f9b..82c47499 100644 --- a/benchmarks/test_guard_validate.py +++ b/benchmarks/test_guard_validate.py @@ -157,3 +157,29 @@ def test_g11_validate_wide(benchmark): rounds=3000, iterations=1, ) + + +def test_g11b_validate_shared_alias_chain(benchmark): + class SharedAliasGroup(Group): + source = providers.Factory(creator=L0, scope=Scope.APP) + inner = providers.Alias(L0, bound_type=L1) + middle = providers.Alias(L1, bound_type=L2) + outer = providers.Alias(L2, bound_type=L3) + + def consume(value: L3) -> L3: + return value + + consumers = tuple(providers.Factory(creator=consume, bound_type=None, scope=Scope.APP) for _ in range(100)) + + def build() -> Container: + container = Container(scope=Scope.APP, groups=[SharedAliasGroup]) + container.add_providers(*consumers) + return container + + assert isinstance(build().resolve_provider(consumers[0]), L0) + benchmark.pedantic( + lambda container: container.validate(), + setup=lambda: ((build(),), {}), + rounds=3000, + iterations=1, + ) diff --git a/modern_di/dependency_graph.py b/modern_di/dependency_graph.py index 24ced274..98196923 100644 --- a/modern_di/dependency_graph.py +++ b/modern_di/dependency_graph.py @@ -212,27 +212,45 @@ def find_cycle_from( return None +def _scope_owner( + provider: AbstractProvider[typing.Any], + registry: "ProvidersRegistry", + scope_owners: dict[int, AbstractProvider[typing.Any]], +) -> AbstractProvider[typing.Any]: + """Return the scope owner, memoizing redirected and derived providers for this walk.""" + if type(provider) not in (Alias, _Derived): + return provider + provider_id = provider.provider_id + owner = scope_owners.get(provider_id) + if owner is None: + owner = scope_chain(provider, registry)[-1] + scope_owners[provider_id] = owner + return owner + + def collect_errors(registry: "ProvidersRegistry") -> list[Exception]: """Walk the graph rooted at every provider in ``registry``, reference-only ones included, once. Return every wiring error in walk order. """ + scope_owners: dict[int, AbstractProvider[typing.Any]] = {} + errors: list[Exception] = [] roots = [*registry, *registry.reference_only_providers()] for event in walk(roots=roots, registry=registry): if type(event) is Edge: parent, name, dep = event - dependency_chain = scope_chain(dep, registry) - if dependency_chain[-1] is container_provider: + dependency_owner = _scope_owner(dep, registry, scope_owners) + if dependency_owner is container_provider: continue - dependency_scope = dependency_chain[-1].scope - parent_scope = effective_scope(parent, registry) + dependency_scope = dependency_owner.scope + parent_scope = _scope_owner(parent, registry, scope_owners).scope if dependency_scope > parent_scope: errors.append( exceptions.InvalidScopeDependencyError( provider=parent, parameter_name=name, - dependency_chain=dependency_chain, + dependency_chain=scope_chain(dep, registry), ) ) elif dependency_scope == parent_scope and dependency_scope is not parent_scope: @@ -240,7 +258,7 @@ def collect_errors(registry: "ProvidersRegistry") -> list[Exception]: exceptions.ScopeEnumMismatchError( provider=parent, parameter_name=name, - dependency_chain=dependency_chain, + dependency_chain=scope_chain(dep, registry), ) ) elif type(event) is NodeEntered: