fix!: copy the context= dict so set_context does not leak between containers (#568) - #588
Merged
Merged
Conversation
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: db34177 | Previous: 6423f9e | Ratio |
|---|---|---|---|
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type |
4585155.741746889 iter/sec (stddev: 6.786017370957142e-9) |
3274737.9022109755 iter/sec (stddev: 1.4217577773135276e-8) |
0.71 |
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry |
4516149.456888419 iter/sec (stddev: 9.240038428499602e-9) |
3209832.873684291 iter/sec (stddev: 1.5683069225975477e-8) |
0.71 |
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve |
21132.409848689826 iter/sec (stddev: 0.00004727617921921452) |
19611.099725853812 iter/sec (stddev: 0.00005857815790537035) |
0.93 |
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached |
17163.83391336117 iter/sec (stddev: 0.00010991613612756768) |
15732.595502787824 iter/sec (stddev: 0.00012705574219610356) |
0.92 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] |
543.4727461596158 iter/sec (stddev: 0.00005948863051109168) |
332.75401405544864 iter/sec (stddev: 0.00003890731245642262) |
0.61 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] |
510.59734780275403 iter/sec (stddev: 0.00003952466801763249) |
319.89014821996403 iter/sec (stddev: 0.00003372610351095769) |
0.63 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] |
475.579773584964 iter/sec (stddev: 0.00003529612353172363) |
305.9350934183702 iter/sec (stddev: 0.00005272482593616402) |
0.64 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] |
2185.7093405295705 iter/sec (stddev: 0.00014595635980819063) |
1787.4294199952565 iter/sec (stddev: 0.00017580003174505487) |
0.82 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] |
1774.131320246873 iter/sec (stddev: 0.00013931077311760042) |
1387.3157627359249 iter/sec (stddev: 0.00017813732854738486) |
0.78 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] |
1238.9897178773442 iter/sec (stddev: 0.00015889751301613193) |
989.0759854327717 iter/sec (stddev: 0.00019897698313127887) |
0.80 |
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] |
2214.3608312070387 iter/sec (stddev: 0.0001598867600318388) |
1802.894989622912 iter/sec (stddev: 0.0001847804894103928) |
0.81 |
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] |
1482.904520595758 iter/sec (stddev: 0.00016108581557040038) |
1247.2541051280616 iter/sec (stddev: 0.00019948674222235787) |
0.84 |
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] |
830.7429671488252 iter/sec (stddev: 0.0012092173088061502) |
731.8253863910646 iter/sec (stddev: 0.0011328908553737416) |
0.88 |
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container |
1020497.0402327698 iter/sec (stddev: 5.5672361447937614e-8) |
941613.4900180469 iter/sec (stddev: 4.266590188435129e-8) |
0.92 |
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope |
938375.8492223457 iter/sec (stddev: 6.856677656411148e-8) |
869030.7634753458 iter/sec (stddev: 2.845671569705943e-8) |
0.93 |
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch |
2802.901114785747 iter/sec (stddev: 0.000012599416610919652) |
2470.939972520431 iter/sec (stddev: 0.00001000404296553261) |
0.88 |
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control |
72322.81955225223 iter/sec (stddev: 0.0000012472855301892135) |
58353.07323744343 iter/sec (stddev: 0.000001811221833940914) |
0.81 |
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale |
50284.93389326924 iter/sec (stddev: 0.000002377002540889153) |
43242.379573048835 iter/sec (stddev: 0.0000018484039762398666) |
0.86 |
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers |
666.7917977644279 iter/sec (stddev: 0.00010969663361120983) |
550.541644279654 iter/sec (stddev: 0.000026569551231199374) |
0.83 |
benchmarks/test_guard_resolve.py::test_g1_transient_resolve |
2350941.9078510846 iter/sec (stddev: 2.8433012835774994e-8) |
1990456.259876522 iter/sec (stddev: 2.9664739513844002e-8) |
0.85 |
benchmarks/test_guard_resolve.py::test_g2_cached_resolve |
4325863.677005932 iter/sec (stddev: 6.336510048830488e-9) |
2870051.446517434 iter/sec (stddev: 1.4456877265379857e-8) |
0.66 |
benchmarks/test_guard_resolve.py::test_g3_deep_chain |
830299.522667458 iter/sec (stddev: 3.8441486472379246e-8) |
677405.3937315469 iter/sec (stddev: 4.2086711658707184e-8) |
0.82 |
benchmarks/test_guard_resolve.py::test_g4_wide_resolve |
501916.9714958407 iter/sec (stddev: 4.599708037309397e-7) |
454446.1683852499 iter/sec (stddev: 4.399292297608773e-7) |
0.91 |
benchmarks/test_guard_resolve.py::test_g5_cross_scope |
2204998.1243200134 iter/sec (stddev: 1.5208466602792766e-8) |
1869216.0030977393 iter/sec (stddev: 2.4948572190745596e-8) |
0.85 |
benchmarks/test_guard_resolve.py::test_g9_context_resolve |
1641555.3161823992 iter/sec (stddev: 1.4475577005261042e-7) |
1290165.0556396716 iter/sec (stddev: 1.289606559846295e-7) |
0.79 |
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve |
841980.43733616 iter/sec (stddev: 2.9326963731542013e-8) |
699147.3513732532 iter/sec (stddev: 4.0350410131016355e-8) |
0.83 |
benchmarks/test_guard_resolve.py::test_g18_alias_hop |
4308720.328492626 iter/sec (stddev: 9.739660749252664e-9) |
2863128.595613249 iter/sec (stddev: 1.2733684196756908e-8) |
0.66 |
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain |
28243.914824784297 iter/sec (stddev: 0.000018827157187382493) |
26535.096694100852 iter/sec (stddev: 0.000024788071504037524) |
0.94 |
benchmarks/test_guard_validate.py::test_g11_validate_wide |
17110.919757914096 iter/sec (stddev: 0.000023325516062714315) |
15730.931271704567 iter/sec (stddev: 0.000026224409536436096) |
0.92 |
This comment was automatically generated by workflow using github-action-benchmark.
lesnik512
force-pushed
the
fix/copy-context-dict
branch
from
October 5, 2026 08:45
592ebcf to
db34177
Compare
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.
Closes #568.
Summary
Containerstored thecontext=dict it was given by reference.set_context()on one container wrote into the caller's dict, and every other container built from that dict saw the value. A module-level dict reused per request leaked values set on one request into the next.The container now copies the dict on construction:
copy.copy(context) if context is not None else {}. This applies to the root constructor and tobuild_child_container, which goes through it.tests/providers/test_context_provider.py: two siblings built from one dict, two roots built from one dict, and a caller who mutates the dict after construction.docs/migration/to-4.x.md: the container copiescontext=, so later changes to the caller's dict are not seen.docs/providers/context.mdunder "Context propagation", and theContainer.__init__docstring.Design decisions
copy.copy(context). It keeps the type of a dict subclass, so a context mapping that overrides__contains__/__getitem__keeps working, as perf(context): drop the typing.cast and dead None check from find_context #408 intended. A new test pins that..copy()was about 40 ns cheaper but returned a plaindictfor subclasses that don't overridecopy().None. That path still builds one fresh empty dict. An empty dict is copied too, since an empty subclass instance can still answer lookups through its hooks.{}before this change, so mutating it was never seen. Only non-empty dicts were shared. The new behavior is now the same for both.Benchmarks
Measured A/B, interleaved three times on the same machine (Apple M4, Python 3.14.7), swapping only
modern_di/container.py.Guard tier,
pytest benchmarks/test_guard_lifecycle.py -k "g6_ or g7_" --benchmark-only, median in ns:Neither G6 nor G7 passes
context=, and both are within run-to-run noise.Since neither guard scenario passes a context, a timeit script also builds a REQUEST child with
context=None,context={}andcontext={Req: r}(median of 7 x best-of-5 x 20,000 calls, ns):context=Nonecontext={}context={Req: r}These numbers are from the first
.copy()version. Withcopy.copya REQUEST child build measured 693 ns withcontext=None, 748 ns with{}and 756 ns with one entry (best of 7 x 100k, Python 3.14.7), so passing a context costs about 55 to 65 ns per child build. TheNonepath does not change.Test plan
just lintandjust lint-cicleanjust test-cipasses with 100% line coveragemkdocs build --strictpasses