Skip to content

Skip revisiting cleared dependencies when validating resolution paths - #143

Merged
maldoinc merged 2 commits into
maldoinc:masterfrom
Max-Moro:perf/memoise-resolution-path-validation
Aug 16, 2026
Merged

Skip revisiting cleared dependencies when validating resolution paths#143
maldoinc merged 2 commits into
maldoinc:masterfrom
Max-Moro:perf/memoise-resolution-path-validation

Conversation

@Max-Moro

Copy link
Copy Markdown
Contributor

Problem

assert_valid_resolution_path detects cycles by walking the dependency graph
depth-first, but it enumerates every distinct root-to-leaf path and never records
that a node's subtree already came back clean. In a graph where many injectables
share dependencies — the normal shape once a few services sit on top of a common
core — each shared subgraph is re-walked once per incoming path, so the visit
count grows with the number of paths through the graph rather than with its
size. Container creation pays this on every startup.

It goes unnoticed easily: for a long-lived web app the container is built once
per deploy. It surfaced here in a CLI, where the container is built once per
invocation, and an IDE addon calls that CLI on every user interaction.

Fix

Thread a set of cleared object ids through the walk and return early for any
dependency already in it, adding to it only once the whole subtree has returned
without raising.

Skipping a cleared dependency is sound: were it part of a cycle, the walk would
have come back to it while it was still on the current path — and that check,
along with the resulting error message, is unchanged. Marking happens after the
recursion, so a node still on the stack is never treated as cleared.

The set is created in validate_registry and lives for exactly one run. It is
deliberately not module level: two registries describe two different graphs, and
a node cleared in one says nothing about the other.

Numbers

Synthetic graph, 316 injectables, fan-out 3, depth 9:

path-check visits validate_registry
before 1,637,664 2612 ms
after 2,412 2.6 ms

On the real ~320-type application container that prompted this: 418,891 visits
to 550, cutting 1.40 s off a 1.97 s container build.

Tests

Three added to test/unit/test_container_creation.py:

  • a shared dependency reached by several paths is descended into exactly once;
  • a cycle sitting behind an already-cleared dependency is still detected;
  • cleared state does not carry over between two containers.

The last two assert the verbatim error message, so the diagnostic is pinned as
well as the detection.

Because the latter two pass on unmodified master too, I verified they have
teeth by breaking the fix on purpose:

  • marking a node before the recursion instead of after — fails both new cycle
    tests and the pre-existing test_validates_container_raises_when_cyclical_dependencies;
  • making cleared module level — fails exactly the cross-container test, and
    nothing else.

Both mutations surface as RecursionError in _is_dependency_async: a cycle that
slips past validation kills container creation on the very next step. That is why
the skip has to be provably sound rather than merely plausible.

Verification

check-fmt, check-ruff, check-mypy --strict and check-docs are clean.
make test is green apart from 6 pre-existing failures in
test/unit/test_inject_from_container.py, unrelated to this change: those tests
match 0x[0-9a-f]+ against a function repr, and CPython on Windows prints the
address in uppercase hex. Confirmed identical on pristine master, three runs
each side, on both 3.11 and 3.14. They should be green on CI.

assert_valid_resolution_path gains a sixth parameter and so trips PLR0913;
suppressed with # noqa following the six existing uses of that suppression in
wireup/.

Unrelated observation

Not touched here, but noticed while reading validate_registry: a dependency that
is unknown but has a default is removed from registry.dependencies[factory]
only after that factory's own loop finishes. If a different factory is validated
earlier and its walk descends into this one, the walk iterates the not-yet-cleaned
dependency dict and reaches factories[object_id] with a missing key, raising
KeyError. This is present on master; memoisation strictly reduces the set of
visited nodes, so it can only make it less likely, never more. Happy to open a
separate issue if useful.


The cycle validation this changes was added in #83 for #80.

assert_valid_resolution_path detects cycles by walking the dependency graph
depth-first, but it enumerates every distinct root-to-leaf path and never
records that a node's subtree already came back clean. In a graph where many
injectables share dependencies -- the normal shape once a few services sit on
top of a common core -- each shared subgraph is re-walked once per incoming
path, so the visit count grows with the number of paths through the graph
rather than with its size. Container creation pays for this on every startup.

Thread a set of cleared object ids through the walk and return early for any
dependency already in it, adding to it only once the whole subtree has
returned without raising. Skipping a cleared dependency is sound: were it part
of a cycle, the walk would have come back to it while it was still on the
current path, and that check -- along with the resulting error message -- is
unchanged. Marking happens after the recursion, not before, so a node that is
still on the stack is never treated as cleared.

The set is created in validate_registry and lives for exactly one run. It is
deliberately not module level: two registries describe two different graphs,
and a node cleared in one says nothing about the other.

On a synthetic graph of 316 injectables (fan-out 3, depth 9) the path check
goes from 1,637,664 visits to 2,412, and validate_registry from 2612 ms to
2.6 ms. On a real ~320-type application container it goes from 418,891 visits
to 550, cutting 1.40 s off a 1.97 s container build.

The cycle validation this changes was added in maldoinc#83 for maldoinc#80.

@maldoinc maldoinc left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Overall MR looks good, just a tiny naming nit.

Unrelated observation
We can probably refactor the current graph validation to not do two jobs at once.

perhaps there can be a separate step after extending but before validation runs to perform any cleanup on unknown objects. Feel free to send an additional PR for this.

Comment thread wireup/ioc/registry_validation.py Outdated
# Dependencies are shared between injectables, so the same subtree is reachable via many paths.
# Remember the ones already known to be cycle-free to avoid walking them again.
# Only valid for this run as a different registry describes a different graph.
cleared: set[ContainerObjectIdentifier] = set()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

nit: cleared also makes sense, but I think something like cycle_free_objects / known_cycle_free_objects is a bit more self-documenting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — went with known_cycle_free_objects.

The set holds the objects already established to be cycle-free rather than every object that happens to be one, so the longer of your two suggestions seemed worth the characters: it states that outright instead of leaving it to be inferred from entries only being added after the subtree returns.

Pushed in d2e15e1. The workflow run is sitting on action_required and needs your approval to go green again.

@maldoinc

Copy link
Copy Markdown
Owner

On the real ~320-type application container that prompted this

Unrelated to this PR, but feel free to also check out #116, might be useful with a graph of this size.

Per review. The set holds the objects already established to be
cycle-free rather than every object that happens to be one, and the
longer name says so without a reader having to infer it from the fact
that entries are added only after the subtree returns.
@maldoinc
maldoinc merged commit 074eddd into maldoinc:master Aug 16, 2026
8 checks passed
@maldoinc

Copy link
Copy Markdown
Owner

Thank you @Max-Moro

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.

2 participants