3.1.1
modern-di 3.1.1 — containers stop being garbage the refcounter can't free
A single-line fix in Container.__init__ with a disproportionate effect. Every
container stored itself inside its own scope map, which made it a reference
cycle: no container — root or child — could be freed by reference counting, so
each one waited for a generational GC pass. An application that builds a
container per request was handing the collector work at its request rate.
No API changes. No integration needs updating.
Fix
-
Containers are no longer reference cycles.
Container.__init__seeded
_scope_mapwith{scope: self}, so the container referenced a dict that
referenced the container. The map is now seeded from the parent instead — it
holds ancestors only.find_containeralready returnedselffor its own
scope before consulting the map, so the self-entry was never read and
resolution is unaffected.Measured on an Apple M4 / CPython 3.14.6:
before after 100 closed REQUEST children 792 objects reclaimable only by the GC 0 100 closed root containers 2300 0 100 full APP→SESSION→REQUEST→ACTION→STEP chains 5500 0 build a child and drop it, GC enabled ~849 ns ~542 ns Isolated construction is unchanged within noise (~500 vs ~506 ns with the GC
off); the gain is the collector no longer reclaiming what reference counting
now frees. On the comparative request-lifecycle benchmark the median fell from
232.7 µs to 194.8 µs per 100-request batch, and the standard deviation from
123.0 µs to 7.9 µs — the tail that scenario carried was GC, and it is gone.
Behavior change
Container.scope_mapno longer contains the container itself. A root's
map is now empty and a child's holds only its ancestors. The property is
documented as an internal surface with no stability guarantee and already
emits aDeprecationWarning; a survey of all 12 siblingmodern-di-*
integration repositories found no references to it.find_containeris
unaffected on both its own-scope and ancestor paths.
Internal
-
The benchmark suite was rebuilt for measurement fidelity. Nine defects
found by audit, none of them affecting library code. The largest: the
request-lifecycle scenarios entered the asyncio event loop once per timed
iteration, and that entry costs ~27 µs regardless of the body — so ~93% of
every published figure was a shared constant that compressed the real
differences between frameworks toward parity. Scenarios are now batched, the
per-framework setup asymmetries are levelled,iterationsis pinned so no
cell sits on the platform timer's ~42 ns grid, and the published comparison
table is generated byjust bench-reportrather than hand-assembled.This changed a published claim: modern-di is not level with dishka on the
request lifecycle, as the old numbers implied. See
Performance. -
The reference-cycle fix above was found while investigating why modern-di's
request-lifecycle measurement was the least stable cell on that page.
Downstream
No action needed. No API changed, and no integration reads the affected
property.
Internals
- 450 tests, 100% line coverage, Python 3.10–3.14 including the free-threaded
3.14t build.