Skip to content

fix: finalize a cached instance built during a close at the next close - #726

Merged
lesnik512 merged 1 commit into
mainfrom
fix/close-keeps-racing-builds
Oct 18, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
fix/close-keeps-racing-builds

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Closes #719.

The lifecycle page and both close docstrings promise that a cached instance still being built on another thread when a close starts "is finalized by the next close". It never was.

The builder appends its item to the container's creation order while the close is iterating it. The close then emptied the list (close_async) or overwrote it with what stayed pending (close_sync), and that dropped the new item. The instance stayed cached but was no longer queued. Every later close skipped it, and so did reopening, because a resolve after reopening is a cache hit and never queues it again. Its finalizer never ran.

Each close now takes a copy of the queue, finalizes that copy newest first, and removes only that prefix from the queue: del creation_order[:n] in close_async, and creation_order[:n] = remaining in close_sync. An item appended during the close stays queued for the next one. Everything else is unchanged:

  • teardown order;
  • FinalizerError aggregation;
  • an async finalizer under close_sync() staying queued;
  • the documented cancelled close, which still leaves the whole queue in place because the removal comes after the loop.

The docs needed no change, because they now hold. The two docstrings now say a close covers "the items queued when this starts".

Tests, all through the public API, with threading.Events inside the slow creator and the already-cached instance's finalizer to make the race deterministic:

  • close_sync() with a build finishing mid-close: the next close finalizes it;
  • the same with close_async();
  • the same with an async finalizer under close_sync(): it stays pending, the next close_sync() reports it, and close_async() runs it.

All three fail on main, and they passed 5 of 5 runs in a row.

Cost: the copy runs only when a container has cached items to close. Guard benchmarks, 2 runs each, main against branch:

scenario main branch
G7b, one cached item per cycle 1.11-1.12 µs 1.15-1.16 µs (about +40 ns)
G13, 10 finalizers 6.75 µs 6.83-6.88 µs
G7, G13b within noise

A copy-free version would depend on how a reversed list iterator behaves while the list is appended to, which Python does not document, so I kept the copy.

@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: eefe5ce Previous: b7a1775 Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 6882481.558534362 iter/sec (stddev: 5.678813475383938e-9) 6829021.182046608 iter/sec (stddev: 5.484705719035411e-9) 0.99
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 6784686.284592966 iter/sec (stddev: 6.423747081822658e-9) 6697985.105419868 iter/sec (stddev: 4.150738470799008e-9) 0.99
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 30852.368785451512 iter/sec (stddev: 0.000032928483830973726) 30757.49282217767 iter/sec (stddev: 0.00003212381064992869) 1.00
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 24728.553239585548 iter/sec (stddev: 0.0001319345998715771) 24483.236846288204 iter/sec (stddev: 0.00015154400300924624) 0.99
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 848.4788674499208 iter/sec (stddev: 0.00020358785362665122) 857.6505269844425 iter/sec (stddev: 0.000031478572667331435) 1.01
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 861.4720094375748 iter/sec (stddev: 0.00007661108420709384) 852.2634799214209 iter/sec (stddev: 0.000029566400203879867) 0.99
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 824.311483616578 iter/sec (stddev: 0.00028075228894478265) 767.6217950190414 iter/sec (stddev: 0.0002570266758932922) 0.93
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 24176.92675018871 iter/sec (stddev: 0.0000068239367399431945) 8156.588001364661 iter/sec (stddev: 0.0001306104049620297) 0.34
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 13205.438659813415 iter/sec (stddev: 0.000010498056818339527) 6062.480530611141 iter/sec (stddev: 0.00018017904286592358) 0.46
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 6045.137530650579 iter/sec (stddev: 0.0000232428049148342) 3415.1846852251756 iter/sec (stddev: 0.0002481508400191217) 0.56
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 20028.634270697385 iter/sec (stddev: 0.00000706439853333875) 19060.870891138744 iter/sec (stddev: 0.000010501867428911193) 0.95
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 7954.634718195636 iter/sec (stddev: 0.000012954430237881891) 7946.913031583689 iter/sec (stddev: 0.000014106057259191144) 1.00
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 3969.7021744200083 iter/sec (stddev: 0.00003366580341022959) 3882.0758637220474 iter/sec (stddev: 0.000042286664071694886) 0.98
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[1] 60679.15140311891 iter/sec (stddev: 0.0000016530974622086325) 57980.00559382983 iter/sec (stddev: 0.0000016583671333003195) 0.96
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[2] 30666.380446807718 iter/sec (stddev: 0.000007826030009930658) 26583.077655991503 iter/sec (stddev: 0.000028387436691761624) 0.87
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[4] 11031.207377591405 iter/sec (stddev: 0.00001995052011402385) 10858.506065052692 iter/sec (stddev: 0.000019239849675808495) 0.98
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 3153204.676797992 iter/sec (stddev: 2.1204107695961346e-8) 3109200.162117778 iter/sec (stddev: 1.6655170160438705e-8) 0.99
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 3442681.2083284548 iter/sec (stddev: 1.3674530557437456e-8) 3355865.1714153057 iter/sec (stddev: 4.5860760432145475e-8) 0.97
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 6220.30075086975 iter/sec (stddev: 0.000005355523991866202) 5518.268723840956 iter/sec (stddev: 0.000053690988201758585) 0.89
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 112598.07605265576 iter/sec (stddev: 9.932716295045303e-7) 111291.94431021478 iter/sec (stddev: 7.415236228873871e-7) 0.99
benchmarks/test_guard_lifecycle.py::test_g7b_request_cycle_sync 795642.9320026148 iter/sec (stddev: 5.531747995118882e-8) 811531.4726922724 iter/sec (stddev: 7.030419208342152e-8) 1.02
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 125765.95650227071 iter/sec (stddev: 0.0000010439777481947348) 128880.30989858645 iter/sec (stddev: 7.515728812940163e-7) 1.02
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 1086.3427258839065 iter/sec (stddev: 0.0012314906078289942) 1106.9222569933033 iter/sec (stddev: 0.0012045400255381931) 1.02
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 4083822.9156295564 iter/sec (stddev: 1.8265069499612637e-8) 3927978.9350432907 iter/sec (stddev: 3.170569802722682e-8) 0.96
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 6744127.011877839 iter/sec (stddev: 1.1599214283324147e-8) 6651633.973879918 iter/sec (stddev: 4.456018484664371e-9) 0.99
benchmarks/test_guard_resolve.py::test_g3_deep_chain 1535164.2418488436 iter/sec (stddev: 2.4158987596323282e-8) 1540870.274285195 iter/sec (stddev: 2.8074047812986162e-8) 1.00
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 903958.0708113296 iter/sec (stddev: 3.187018603879424e-7) 866746.707836144 iter/sec (stddev: 3.635108057427795e-7) 0.96
benchmarks/test_guard_resolve.py::test_g5_cross_scope 3736297.0139374286 iter/sec (stddev: 2.1459848168561528e-8) 3642649.193107508 iter/sec (stddev: 1.6992111188829588e-8) 0.97
benchmarks/test_guard_resolve.py::test_g9_context_resolve 2839586.706670974 iter/sec (stddev: 1.1634801499035885e-7) 2725232.6428932473 iter/sec (stddev: 1.4077741255402713e-7) 0.96
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 1548533.7978748723 iter/sec (stddev: 1.97198413446461e-8) 1524730.519123093 iter/sec (stddev: 2.5341878937965144e-8) 0.98
benchmarks/test_guard_resolve.py::test_g18_alias_hop 6690258.74908751 iter/sec (stddev: 4.281685465868504e-9) 6463835.3904325925 iter/sec (stddev: 4.452053658239917e-9) 0.97
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 72926.28812543418 iter/sec (stddev: 0.000011126339635657216) 53458.71373762307 iter/sec (stddev: 0.00025732724667223246) 0.73
benchmarks/test_guard_validate.py::test_g11_validate_wide 42101.082594108724 iter/sec (stddev: 0.000014641501437904197) 41550.27511260399 iter/sec (stddev: 0.000015081427680084859) 0.99

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

@lesnik512
lesnik512 merged commit 39b91a1 into main Oct 18, 2026
12 checks passed
@lesnik512
lesnik512 deleted the fix/close-keeps-racing-builds branch October 18, 2026 10:02
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.

A cached instance built during a close is never finalized

1 participant