fix(container): stop every container being a reference cycle - #381
Merged
Conversation
Container.__init__ seeded _scope_map with `scope: self`, so each container referenced itself and none could be freed by refcounting -- 8 objects per container waiting for a generational GC pass, at the rate a request-scoped app builds children. Seed the map from the parent instead: it now holds ancestors only, which is all find_container ever needed, since it short-circuits on its own scope before consulting the map. Roots, children and full 5-deep chains all go from cyclic garbage to nothing for the collector: 100 closed REQUEST children drop from 792 objects reclaimable only by gc to 0, and 100 closed roots from 2300 to 0. Isolated construction is unchanged within noise (~500 vs ~506 ns with GC off and children retained). The win is the realistic path -- build a child and drop it, GC enabled -- at ~542 ns vs ~849 ns, roughly 36% faster, because the collector no longer has to reclaim what refcounting now frees. find_container is unchanged on both its own-scope and ancestor paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: d801df9 | Previous: 5c46fd6 | Ratio |
|---|---|---|---|
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type |
1760820.5883846807 iter/sec (stddev: 2.4302388820705835e-7) |
1776495.4185880087 iter/sec (stddev: 2.3876365374584567e-7) |
1.01 |
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry |
2274613.2972390437 iter/sec (stddev: 7.567030681185612e-8) |
2098131.28134814 iter/sec (stddev: 6.460542283799097e-8) |
0.92 |
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve |
25408.775385586385 iter/sec (stddev: 0.000018878966671758158) |
20929.397330882104 iter/sec (stddev: 0.0001677672758980295) |
0.82 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] |
307.4134482685561 iter/sec (stddev: 0.00008420068191491997) |
328.0705037882775 iter/sec (stddev: 0.000040796757969098145) |
1.07 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] |
292.4734495572977 iter/sec (stddev: 0.00018271240315432876) |
302.8779404736962 iter/sec (stddev: 0.00034165334178571624) |
1.04 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] |
273.9324232466593 iter/sec (stddev: 0.00005276029262558306) |
294.9869747771677 iter/sec (stddev: 0.00009002602027863251) |
1.08 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] |
2400.2347909678847 iter/sec (stddev: 0.00003090186276453794) |
1381.0449054790115 iter/sec (stddev: 0.0015364944007164193) |
0.58 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] |
1647.311601881019 iter/sec (stddev: 0.00035612313304824806) |
1290.3441418823516 iter/sec (stddev: 0.0005583511344704258) |
0.78 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] |
1166.5179259096137 iter/sec (stddev: 0.00003558330315955287) |
902.1140343245934 iter/sec (stddev: 0.0005996675252015803) |
0.77 |
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container |
647390.6462416914 iter/sec (stddev: 5.967936616653367e-7) |
443139.369097251 iter/sec (stddev: 0.00005757834177573751) |
0.68 |
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope |
612331.0479834188 iter/sec (stddev: 4.850519942443344e-7) |
456930.9781118482 iter/sec (stddev: 0.000010129679439609713) |
0.75 |
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch |
2099.4228287461365 iter/sec (stddev: 0.000014957869088860663) |
1819.095311946131 iter/sec (stddev: 0.0001015238786932972) |
0.87 |
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control |
62715.662043159944 iter/sec (stddev: 0.0000020873382448667114) |
61873.678279768945 iter/sec (stddev: 0.0000013801366212444619) |
0.99 |
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale |
44916.24409610219 iter/sec (stddev: 0.000002189407624844477) |
41005.764669941214 iter/sec (stddev: 0.000022216300746542194) |
0.91 |
benchmarks/test_guard_resolve.py::test_g1_transient_resolve |
1182762.6686876388 iter/sec (stddev: 4.3793201176020497e-7) |
1206666.3981995212 iter/sec (stddev: 3.819041100265692e-7) |
1.02 |
benchmarks/test_guard_resolve.py::test_g2_cached_resolve |
2721994.7083659554 iter/sec (stddev: 8.286984773784885e-8) |
2552729.6470805504 iter/sec (stddev: 5.545615761001395e-8) |
0.94 |
benchmarks/test_guard_resolve.py::test_g3_deep_chain |
482928.6591642835 iter/sec (stddev: 7.32631684464223e-7) |
496370.8506889218 iter/sec (stddev: 4.860804335165113e-7) |
1.03 |
benchmarks/test_guard_resolve.py::test_g4_wide_resolve |
301262.15260199294 iter/sec (stddev: 0.0000010893785029986201) |
311827.4353613602 iter/sec (stddev: 6.527226551052641e-7) |
1.04 |
benchmarks/test_guard_resolve.py::test_g5_cross_scope |
1050092.3859710589 iter/sec (stddev: 5.868860957945767e-7) |
1091669.224633086 iter/sec (stddev: 4.3090727658030746e-7) |
1.04 |
benchmarks/test_guard_resolve.py::test_g9_context_resolve |
557035.205086495 iter/sec (stddev: 6.216056332878339e-7) |
557629.7402789904 iter/sec (stddev: 5.74188028526361e-7) |
1.00 |
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve |
373799.5538810236 iter/sec (stddev: 6.372616601376392e-7) |
359178.5990524778 iter/sec (stddev: 4.317070602455115e-7) |
0.96 |
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain |
28661.203650122272 iter/sec (stddev: 0.0000037631919399003493) |
27164.02425589421 iter/sec (stddev: 0.00006644609094396787) |
0.95 |
benchmarks/test_guard_validate.py::test_g11_validate_wide |
17309.681423076243 iter/sec (stddev: 0.000005259469942229982) |
18257.453056036305 iter/sec (stddev: 0.000011197171312353043) |
1.05 |
This comment was automatically generated by workflow using github-action-benchmark.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
planning/changes/2026-07-27.03-container-reference-cycles.md,from the audit in
planning/audits/2026-07-27-container-reference-cycles-report.md.The defect
Container.__init__seeded_scope_mapwithscope: self. A container referencedits own dict and the dict referenced the container, so no container could ever be freed
by reference counting — every one waited for a generational GC pass. A request-scoped
application produced cyclic garbage at exactly its request rate.
find_containershort-circuits on its own scope before consulting the map, so theself-entry was never actually read. Seeding the map from the parent instead removes the
cycle and changes nothing about lookup.
Effect
Isolated construction is unchanged within noise (~500 vs ~506 ns, GC off, children
retained) — an earlier draft of this commit claimed ~2% from the smaller dict; review
refuted it as noise and it has been corrected. The win is the collector no longer having
to reclaim what refcounting now frees.
find_containeris unchanged on both its own-scope and ancestor paths.Verification
Failing test first (
assert 792 == 0), and it still goes red againstmainafter beinghardened to also assert the children genuinely became garbage — so it cannot pass by them
never being collectable.
Review independently proved behavioural equivalence exhaustively: all 31 strictly-increasing
scope chains plus 15 chains over a non-contiguous custom
IntEnum, probing every scope atevery level — identical results, including both error paths and skipped-scope chains.
just test-ci450 passed at 100% coverage ·just lint-ciclean ·just docs-buildclean.architecture/containers.md,architecture/scopes.mdanddocs/providers/advanced-api.mdupdated in the same PR, per the repo's promotion rule.
Compatibility
scope_mapis a deprecated public property whose contents change (it no longer contains thecontainer itself). Review confirmed zero references to it across all 12 sibling
modern-di-*integration repos. Worth a line in the next release notes.🤖 Generated with Claude Code