Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions planning/releases/3.2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# modern-di 3.2.0 — two silent-wrong-answer bugs closed, and a resolve path four steps lighter

Two defects in this release could each make a container answer *incorrectly*
rather than fail: a group could restamp the scope of an already-compiled
provider, and a registration racing a compile could be silently and permanently
lost. Both are fixed, and one of them introduces a new exception — the reason
this is a minor rather than a patch release.

Alongside them, four independent trims to the resolve path: the alias hop, the
context-kwarg path, the cached resolver's warm hit, and container reopen.

## Fix

- **A registration racing a compile could be lost, permanently.**
`ProvidersRegistry.resolver_for` and `plan_for` both built their memo entry
outside `_lock` and published it after. A `register()` / `add_providers()`
landing in that window calls `_invalidate()`, clearing a memo the entry has not
been written to yet — the entry then lands and is never dropped, because the
invalidation meant to remove it already happened. The provider resolves with
its dependency absent even though that dependency *is* registered. Both now
read `_generation` before building and publish under `_lock` only if it is
unchanged; a build that loses the race is still returned to its caller, just
not memoized.

The window spans the whole nested compile, so it widens with graph depth —
without instrumentation, 60/100 at depth 10 and 99/100 at depth 40. After the
fix, 0/150 at depths 10 and 60, under both the GIL and free-threaded 3.14t.
Both windows have a regression test driven by an event rather than by racing
threads.

- **A `Group` could restamp the scope of an already-registered provider.** A
group declared without `scope=` stamps nothing, so a provider listed only there
kept the `Scope.APP` default and stayed unclaimed — leaving a later scoped
group free to restamp it. That is unsound once the provider is registered: a
compiled resolver captures `scope` in its closure, and the stamp touches no
registry, so nothing invalidates the memo. The same provider then answered
differently depending on whether a resolver happened to be compiled before the
restamp — resolving fine from `APP` through the stale resolver, while a fresh
container raised `ScopeNotInitializedError`.

## Behaviour changes

- **`ProviderScopeFrozenError` (new, a `RegistrationError`).** Raised when a
group's scope stamp would *change* the scope of an already-registered provider.
A same-scope stamp still returns early, so sharing one provider instance across
groups at the same scope — documented in `docs/providers/scopes.md` — is
unaffected, and a rejected `add_providers` does not freeze anything.
`GroupScopeConflictError` is unchanged: it still covers two groups that both
declare a scope and disagree, registered or not.

- **Reopening a closed container warns at least once, not exactly once.** The
reopen path is no longer serialized under the container lock. Threads racing
one closed container may each warn; every one of them writes the same
`closed = False`. Only the warning count is affected.

## Performance

Measured on an Apple M4, CPython 3.14.6, each against its own immediate baseline
rather than cumulatively.

| Path | Before | After | |
|---|---|---|---|
| alias resolve | ~322 ns | **~252 ns** | −22% |
| warm cached hit | 162.5 ns | **144.2 ns** | −11.3% |
| warm cross-scope | 217.2 ns | **199.2 ns** | −8.3% |
| override hit | 153.4 ns | **142.0 ns** | −7.4% |
| context kwarg, no overrides | 686.6 ns | **645.5 ns** | −6.0% |

- **An alias hop costs one Python frame instead of four.** `Alias` was the only
compiled closure that did not reach its dependency's resolver by direct
reference — it went through `Alias._find_source` → `find_provider`, then
re-entered `Container.resolve_provider`. It now inlines both lookups and calls
the source's compiled resolver directly. It caches nothing and captures
nothing, so a source registered later is still picked up on the next resolve;
the binding variants that measured faster are declined with reasoning in
`planning/decisions/2026-08-03-alias-binds-nothing.md`.

- **The cached resolver's warm path lost its `MAKE_CELL`.** The cold-miss thunk
is built with `functools.partial(build_cold, target)` rather than a lambda
closing over `target`; a closure promotes `target` to a cell for the *whole*
resolver, so `MAKE_CELL` ran in the prologue on every call — including the warm
hit that returns two lines later and the override hit that never reaches
`target` at all.

- **The context-kwarg path front-guards its override lookup** on
`has_overrides`, and `ContextRegistry.find_context` dropped a `typing.cast` and
a dead `None` check.

## What did not change

Resolution stays sync-only. Overrides, caching, scope transparency,
`redirect_target`, and validation behave exactly as before — including through an
alias, where overriding either the alias or its source still works unchanged and
a dangling source still raises `AliasSourceNotRegisteredError` carrying the
alias's own resolution step. The memo miss paths still own the cycle-safe
compile, the memo write, and the `setdefault` that makes concurrent
first-resolvers share one `CacheItem`.

## Downstream

**No action needed for integrations** (FastAPI, Litestar, FastStream, Typer,
`modern-di-pytest`, and the rest) — no API they call has changed, and the
performance work is picked up on upgrade.

One case warrants a look before upgrading: a suite that relies on a second
`Group` silently changing a registered provider's scope now raises
`ProviderScopeFrozenError`. That pattern was already producing
resolver-dependent answers, so the raise is surfacing an existing bug rather
than creating one.

## Internals

- 466 tests, 100% line coverage, Python 3.10–3.14 including free-threaded 3.14t;
`ruff`, `ty` clean.
- The per-node frame budget is now enforced rather than only documented:
`tests/test_resolver_compiler.py` holds a chain node at one resolver frame and
an alias hop at one, and pins that no compiled resolver's closure captures its
registry.
- The comparative table in `docs/introduction/performance.md` still reports 3.1.2
and does not include this release's trims.
Loading