Skip to content

Commit 39b91a1

Browse files
authored
fix: finalize a cached instance built during a close at the next close (#726)
1 parent 15a5641 commit 39b91a1

2 files changed

Lines changed: 91 additions & 6 deletions

File tree

‎modern_di/cache.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ def cached_count(items: dict[int, CacheItem]) -> int:
4747

4848

4949
async def close_async(creation_order: list[CacheItem]) -> None:
50-
"""Close every item newest first and empty ``creation_order``; failures raise together at the end."""
50+
"""Close the items queued when this starts, newest first, and dequeue them; failures raise together."""
5151
finalizer_errors: list[Exception] = []
52-
for cache_item in reversed(creation_order):
52+
closing = creation_order[:]
53+
for cache_item in reversed(closing):
5354
finalizer = cache_item.settings.finalizer
5455
if finalizer is not None and cache_item.cache is not types.UNSET and not cache_item.finalized:
5556
try:
@@ -62,16 +63,17 @@ async def close_async(creation_order: list[CacheItem]) -> None:
6263
else:
6364
cache_item.finalized = True
6465
cache_item.clear()
65-
creation_order.clear()
66+
del creation_order[: len(closing)]
6667
if finalizer_errors:
6768
raise exceptions.FinalizerError(exceptions=finalizer_errors, is_async=True)
6869

6970

7071
def close_sync(creation_order: list[CacheItem]) -> None:
71-
"""Close every item newest first; an async finalizer stays in ``creation_order`` for a later async close."""
72+
"""Close the items queued when this starts, newest first; an async finalizer stays queued for an async close."""
7273
finalizer_errors: list[Exception] = []
7374
remaining: list[CacheItem] = []
74-
for cache_item in reversed(creation_order):
75+
closing = creation_order[:]
76+
for cache_item in reversed(closing):
7577
finalizer = cache_item.settings.finalizer
7678
value = cache_item.cache
7779
if finalizer is not None and value is not types.UNSET and not cache_item.finalized:
@@ -94,6 +96,6 @@ def close_sync(creation_order: list[CacheItem]) -> None:
9496
cache_item.finalized = True
9597
cache_item.clear()
9698
remaining.reverse()
97-
creation_order[:] = remaining
99+
creation_order[: len(closing)] = remaining
98100
if finalizer_errors:
99101
raise exceptions.FinalizerError(exceptions=finalizer_errors, is_async=False)

‎tests/test_cache.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import threading
23
import typing
34
from concurrent.futures import ThreadPoolExecutor
@@ -6,6 +7,7 @@
67

78
from modern_di import Container, Group, Scope
89
from modern_di.cache import CacheItem, close_async
10+
from modern_di.exceptions import FinalizerError
911
from modern_di.providers import CacheSettings, Factory
1012
from modern_di.types import UNSET
1113
from tests.helpers import cache_item
@@ -160,3 +162,84 @@ async def test_close_async_runs_only_owed_finalizers_and_empties_the_order() ->
160162
assert persistent.cache == "persistent"
161163
assert with_finalizer.cache is UNSET
162164
assert creation_order == []
165+
166+
167+
def _close_with_a_build_finishing_mid_close(
168+
close: typing.Callable[[Container], typing.Any], *, x_finalizer: typing.Callable[[object], typing.Any]
169+
) -> tuple[Container, list[str]]:
170+
"""Close a container while another thread's cached build finishes during that close.
171+
172+
``Y`` is cached before the close starts. ``X``'s creator blocks until ``Y``'s finalizer
173+
releases it, and that finalizer waits for the build to finish, so ``X`` is cached and queued
174+
while the close is still running.
175+
"""
176+
log: list[str] = []
177+
started = threading.Event()
178+
go = threading.Event()
179+
180+
class X:
181+
def __init__(self) -> None:
182+
started.set()
183+
go.wait(timeout=5)
184+
185+
class Y: ...
186+
187+
def finalize_y(_: Y) -> None:
188+
log.append("Y")
189+
go.set()
190+
builder.join(timeout=5)
191+
192+
class G(Group):
193+
x = Factory(creator=X, cache=CacheSettings(finalizer=x_finalizer))
194+
y = Factory(creator=Y, cache=CacheSettings(finalizer=finalize_y))
195+
196+
container = Container(groups=[G])
197+
container.resolve(Y)
198+
builder = threading.Thread(target=container.resolve, args=(X,))
199+
builder.start()
200+
assert started.wait(timeout=5)
201+
close(container)
202+
assert not builder.is_alive()
203+
return container, log
204+
205+
206+
def test_a_build_finishing_during_close_sync_is_finalized_by_the_next_close() -> None:
207+
log: list[str] = []
208+
container, closed = _close_with_a_build_finishing_mid_close(
209+
Container.close_sync, x_finalizer=lambda _: log.append("X")
210+
)
211+
assert (closed, log) == (["Y"], [])
212+
213+
container.close_sync()
214+
215+
assert log == ["X"]
216+
217+
218+
def _close_async(container: Container) -> None:
219+
asyncio.run(container.close_async())
220+
221+
222+
def test_a_build_finishing_during_close_async_is_finalized_by_the_next_close() -> None:
223+
log: list[str] = []
224+
container, closed = _close_with_a_build_finishing_mid_close(_close_async, x_finalizer=lambda _: log.append("X"))
225+
assert (closed, log) == (["Y"], [])
226+
227+
_close_async(container)
228+
229+
assert log == ["X"]
230+
231+
232+
def test_a_build_finishing_during_close_sync_keeps_an_async_finalizer_pending() -> None:
233+
log: list[str] = []
234+
235+
async def finalize_x(_: object) -> None:
236+
log.append("X")
237+
238+
container, _ = _close_with_a_build_finishing_mid_close(Container.close_sync, x_finalizer=finalize_x)
239+
with pytest.raises(FinalizerError):
240+
container.close_sync()
241+
assert log == []
242+
243+
_close_async(container)
244+
245+
assert log == ["X"]

0 commit comments

Comments
 (0)