Skip to content

fix!: lock each cached factory on its own and build its dependencies under that lock (#569) - #597

Merged
lesnik512 merged 2 commits into
mainfrom
md-569
Oct 5, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
md-569

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Closes #569.

Summary

The container tree had one RLock, held around create on a cache miss, with the dependencies resolved before the lock. That gave two bugs:

  1. A cached creator that hands resolve(B) for another cached type to a worker thread and waits for it deadlocked: the worker blocked on the lock the creator held. The same lock also serialised unrelated cold creations across every request in the tree.
  2. Threads that missed a cached Svc(conn: Conn) together each built their own Conn, and all but one were discarded. On 3.14t, 8 threads missing together built 1.38 Conn per miss on average on main (50 trials), and exactly 1 on this branch.

Each CacheItem now owns an RLock, created with the item, and get_or_create resolves the dependencies and calls the creator under it. Container._lock is gone: nothing else used it.

Design decisions

  • The lock is one RLock per cache item, created with the item, so a child build allocates nothing. The lock is re-entrant so a same-thread cycle through cached factories re-enters each item's lock and still reaches the recursion handler as CircularDependencyError. A cross-thread deadlock is now only possible when the creators form a real cycle.
  • Dependencies resolve inside the lock. Losers of a race wait for the winner's instance instead of building throwaway dependencies.
  • The warm path stays lock-free. The template checks the cached value before calling get_or_create, and get_or_create checks again before and under the lock.
  • Item creation is race-safe. fetch_cache_item publishes with dict.setdefault, which runs as one operation under the GIL and under the dict's own lock on free-threaded builds. A losing thread drops its own item and lock and gets the winner's. A barrier test checks 8 threads get the same item.
  • The lock comes from the documented threading.RLock. Calling _thread.RLock directly would save about 50 ns per cache item (CacheItem construction is 127 ns on main, 237 ns with threading.RLock, 183 ns with _thread.RLock), but _thread.RLock is not documented.
  • A cross-thread cycle can now block, and this PR documents it rather than changing code. When two threads cold-resolve opposite ends of an unvalidated cached cycle A <-> B at the same time, each holds one item lock and waits for the other forever. On main both got CircularDependencyError from the recursion guard. A single thread still gets that error. The troubleshooting page, the errors page, design decisions and the migration entry now say the runtime guard covers one thread and recommend validate() at startup.
  • get_or_create(resolve, create) loses its lock argument; the template passes the two callables positionally. The frame budget is unchanged: the build already ran inside get_or_create's frame.
  • The change is user-visible, hence fix!: creators of different cached factories can now run at the same time on different threads, where 3.x ran every cached creator under one lock. The migration guide gets an entry.

Tests

  • test_cached_creator_resolving_a_cached_type_on_another_thread_completes: the issue's deadlock; .result(timeout=5) turns a regression into a failure.
  • test_concurrent_cache_misses_build_the_value_and_its_dependencies_once: replaces test_concurrent_cache_misses_create_once, which put a barrier in a dependency and relied on dependencies resolving outside the lock. The new test swaps in a counting lock and makes the first Conn build wait until the other 7 threads are blocked on the item's lock, so it is deterministic in both directions.
  • test_unrelated_cached_items_are_created_concurrently: one creator blocked on an event, another item created meanwhile.
  • test_cached_cycle_reenters_the_item_lock_and_raises_circular_dependency_error: runs twice on daemon threads, so a lock left held fails the second run instead of hanging.
  • test_warm_cached_resolve_does_not_wait_for_the_item_lock (from test: 4.0 cleanup and missing coverage (#576) #595) now holds the item's lock. test_child_shares_the_root_lock is removed with the tree lock.
  • tests/helpers.py has a cache_item(container, provider) helper for the tests that reach a container's cache item.

Benchmark

New guard scenario G7b: build a REQUEST child, first-resolve one cached REQUEST provider, close_sync(). Every cycle creates a fresh cache item and its lock, so it is where a per-item cost shows. It is pinned at 200 rounds of 100 iterations and listed in benchmarks/README.md.

Benchmarks

Paired alternating runs, main at ed03e40 in its own checkout and venv against this branch, median of per-run medians. CPython 3.14.7 and 3.14.7t on an Apple M2. Base has no G7b, so R1 is the same scenario run from a file outside the repo on both sides.

The G2, G6, G7, G15b and R1 rows are from the final code (threading.RLock): 6 pairs under the GIL, 4 on 3.14t. The G15 rows are from the first round (8 pairs, _thread.RLock). G15 races on one root's items, so the lock factory's allocation cost does not change it.

scenario GIL base GIL branch Δ 3.14t base 3.14t branch Δ
G2 warm cached resolve 146.7 ns 147.9 ns +0.9% 160.8 ns 159.1 ns -1.0%
G6 child build 581.9 ns 577.5 ns -0.8% 1,086.7 ns 1,077.7 ns -0.8%
G7 request cycle batch (K=100) 221.2 µs 231.4 µs +4.6% 232.2 µs 243.2 µs +4.7%
R1 / G7b one request cycle, sync close 1,600 ns 1,723 ns +7.7% 1,708 ns 1,830 ns +7.2%
G15b sibling children, 1 thread 208.0 µs 214.6 µs +3.1% 553.9 µs 503.4 µs -9.1%
G15b, 2 threads 299.3 µs 311.6 µs +4.1% 8,548 µs 7,097 µs -17.0%
G15b, 4 threads 467.7 µs 489.4 µs +4.6% 37,327 µs 35,724 µs -4.3%
G15 concurrent first resolve, 1 thread 205.9 µs 207.0 µs +0.5% 527.5 µs 526.5 µs -0.2%
G15, 2 threads 265.2 µs 267.2 µs +0.8% 8,925 µs 8,809 µs -1.3%
G15, 4 threads 365.9 µs 367.1 µs +0.3% 50,726 µs 51,218 µs +1.0%

How to read it:

  • The warm path and the child build are unchanged.
  • A request that resolves a request-scoped cached factory pays for one lock allocation, about 120 ns with threading.RLock: +7.7% on R1/G7b and +4.6% on G7 under the GIL. G15b's +3 to +5% under the GIL is the same cost, paid by 50 cold items per child.
  • Under the GIL, G15 is flat, as expected: one root's items were serialised before and still are, one item at a time.
  • On 3.14t the G15/G15b rows are dominated by contention on shared objects and are very noisy. In the first round, per-run medians for G15b at 2 threads spanned 7.6 to 11.6 ms on main and 6.4 to 9.7 ms on the branch. The G15b gains point the expected way (sibling requests no longer share a lock), but the ranges overlap, so I would not claim them.

Open questions

Test plan

  • New tests fail on main (deadlock and concurrent-items tests time out, the dependency-count test has no item lock to hold) and pass here
  • just lint and just lint-ci clean
  • just test-ci: 611 passed, 100% line coverage
  • Full suite on 3.14t, --count=5
  • mkdocs build --strict
  • Paired benchmarks above, GIL and 3.14t
  • G7b runs and asserts its result

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Benchmark

Details
Benchmark suite Current: e1df19a Previous: f653f91 Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 6594271.674904684 iter/sec (stddev: 4.512464983434615e-9) 2808032.8497195244 iter/sec (stddev: 9.795020193349189e-9) 0.43
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 6308526.56663586 iter/sec (stddev: 5.347713078238282e-9) 2784559.5726145664 iter/sec (stddev: 9.167981887280914e-9) 0.44
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 37775.43222111646 iter/sec (stddev: 0.00005318764670639172) 20512.454898109616 iter/sec (stddev: 0.00005257311766882013) 0.54
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 28845.18412212416 iter/sec (stddev: 0.0001755055994743204) 16432.75503654348 iter/sec (stddev: 0.00011159457484330999) 0.57
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 852.3555032916524 iter/sec (stddev: 0.000028412255143672705) 346.3522241650109 iter/sec (stddev: 0.00005478765485046593) 0.41
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 802.43609664018 iter/sec (stddev: 0.00011685848210619385) 322.94426905724225 iter/sec (stddev: 0.0000921166425445254) 0.40
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 709.1859695790007 iter/sec (stddev: 0.000026774045790597278) 302.6646249644815 iter/sec (stddev: 0.00008618395957780148) 0.43
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 3362.9109761061186 iter/sec (stddev: 0.0001148809362016014) 1891.945863483343 iter/sec (stddev: 0.00016330519623458548) 0.56
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 2244.109563037999 iter/sec (stddev: 0.0003626030439924928) 1429.8289461081597 iter/sec (stddev: 0.00022430633429209966) 0.64
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 1796.1725000957658 iter/sec (stddev: 0.0001505734799106195) 1035.2239521970916 iter/sec (stddev: 0.00020739484717365007) 0.58
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 3432.8324563923543 iter/sec (stddev: 0.00011577046598816617) 1869.5371831393138 iter/sec (stddev: 0.00018570697788112053) 0.54
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 1720.388818192377 iter/sec (stddev: 0.001627053592405799) 1291.7767879469964 iter/sec (stddev: 0.00021786341182087493) 0.75
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 1376.6820846625126 iter/sec (stddev: 0.00016135805730881192) 728.8235843524715 iter/sec (stddev: 0.0016130279229931673) 0.53
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1757451.6388909854 iter/sec (stddev: 2.4116016172860228e-8) 972925.9990545122 iter/sec (stddev: 3.8841634458924165e-8) 0.55
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 1516637.5901995546 iter/sec (stddev: 1.8184763204963783e-8) 861585.4749432497 iter/sec (stddev: 2.559318414959247e-8) 0.57
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 4329.317375371346 iter/sec (stddev: 0.00000675778488499393) 2385.2748839344185 iter/sec (stddev: 0.000020601593475511862) 0.55
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 105271.61187841432 iter/sec (stddev: 0.000003804042165865548) 57003.747610629194 iter/sec (stddev: 0.0000020684942624501446) 0.54
benchmarks/test_guard_lifecycle.py::test_g7b_request_cycle_sync 391100.87504623196 iter/sec (stddev: 0.0000010547359925265641)
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 55907.14922556173 iter/sec (stddev: 0.00001671816519727452) 42613.95750751593 iter/sec (stddev: 0.0000020692069621908607) 0.76
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 720.7455213301145 iter/sec (stddev: 0.0014810945613538407) 532.8806923945746 iter/sec (stddev: 0.000023541091148788333) 0.74
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 3864860.8877912927 iter/sec (stddev: 2.7754623922591276e-8) 2123955.757148316 iter/sec (stddev: 3.2169744591608866e-8) 0.55
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 6462900.045415802 iter/sec (stddev: 6.435925550800141e-9) 3022366.6617990714 iter/sec (stddev: 1.591874027620522e-8) 0.47
benchmarks/test_guard_resolve.py::test_g3_deep_chain 1324664.4641491468 iter/sec (stddev: 2.9843918707921635e-8) 724918.7710399074 iter/sec (stddev: 5.7652116350526364e-8) 0.55
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 803490.7817731618 iter/sec (stddev: 3.433818491574874e-7) 444395.1560837804 iter/sec (stddev: 4.195349989203622e-7) 0.55
benchmarks/test_guard_resolve.py::test_g5_cross_scope 3521536.2631922797 iter/sec (stddev: 2.0395701996344087e-8) 1899234.4992829212 iter/sec (stddev: 3.2124622489837866e-8) 0.54
benchmarks/test_guard_resolve.py::test_g9_context_resolve 2681355.3097677585 iter/sec (stddev: 1.3604131901366342e-7) 1302325.3572747398 iter/sec (stddev: 1.6882419986561893e-7) 0.49
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 1356910.835113653 iter/sec (stddev: 3.4698021015850224e-8) 746349.3069998431 iter/sec (stddev: 5.358371448849914e-8) 0.55
benchmarks/test_guard_resolve.py::test_g18_alias_hop 6513531.7971865125 iter/sec (stddev: 4.549070659559068e-9) 2925676.066686839 iter/sec (stddev: 1.3628581836897855e-8) 0.45
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 46097.609906500664 iter/sec (stddev: 0.000015737030991330215) 27297.053414137306 iter/sec (stddev: 0.000021567751573877215) 0.59
benchmarks/test_guard_validate.py::test_g11_validate_wide 25693.781562704717 iter/sec (stddev: 0.00029424937586414033) 16076.279244196823 iter/sec (stddev: 0.00002850035408034272) 0.63

This comment was automatically generated by workflow using github-action-benchmark.

…under that lock (#569)

Replace the container tree's single RLock with one RLock per cache item,
created with the item. A cold miss resolves the dependencies and calls the
creator under the item's lock, so concurrent misses build the dependencies
once, and a cached creator can wait on a worker thread resolving another
cached type without deadlocking.
#569)

Switch the cache item lock to the documented threading.RLock. Document that
the runtime cycle guard covers one thread, and that concurrent cold resolves
of an unvalidated cyclic graph can block. Add the G7b guard benchmark for one
request cycle with a sync close.
@lesnik512
lesnik512 merged commit f300c2e into main Oct 5, 2026
9 checks passed
@lesnik512
lesnik512 deleted the md-569 branch October 5, 2026 14:59
lesnik512 added a commit that referenced this pull request Oct 5, 2026
Republish the comparative tables from just bench-report (5 runs) at f300c2e and tie the 4.0 notes to #557, #559, #561, #585, #593, #596 and #597.
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.

Tree-wide cache lock: cross-thread deadlock and per-thread dependency builds

1 participant