Skip to content

Commit 9efe83d

Browse files
committed
Create the routing cache per router instance
ASGIRouter.handle_routing was decorated with a class-level lru_cache, which acts as a garbage collection root: every router instance (and, through it, every Litestar app with all of its state) stayed strongly referenced by the cache forever, so apps were never garbage collected. Long-running processes that create many app instances (e.g. test suites) accumulated them all. Bind the cache per instance in __init__ instead (self.handle_routing = lru_cache(1024)(self._handle_routing)). The cache then only participates in a self-referential cycle that the garbage collector can reclaim once the app is no longer referenced. The call surface is unchanged, including cache_clear()/cache_info() on the bound cache. Fixes #4876
1 parent 1aa4aa5 commit 9efe83d

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

litestar/_asgi/asgi_router.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class ASGIRouter:
5555
"_static_routes",
5656
"_trie_initialized",
5757
"app",
58+
"handle_routing",
5859
"root_route_map_node",
5960
"route_handler_index",
6061
"route_mapping",
@@ -73,6 +74,10 @@ def __init__(self, app: Litestar) -> None:
7374
self._registered_routes: set[HTTPRoute | WebSocketRoute | ASGIRoute] = set()
7475
self._trie_initialized = False
7576
self.app = app
77+
# The routing cache is created per instance: a class-level lru_cache would
78+
# act as a GC root keeping every router (and through it, every app) alive
79+
# forever. See https://github.com/litestar-org/litestar/issues/4876
80+
self.handle_routing = lru_cache(1024)(self._handle_routing)
7681
self.root_route_map_node: RouteTrieNode = create_node()
7782
self.route_handler_index: dict[str, RouteHandlerType] = {}
7883
self.route_mapping: dict[str, list[BaseRoute]] = defaultdict(list)
@@ -109,12 +114,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
109114
scope["path_template"] = path_template
110115
await asgi_app(scope, receive, send)
111116

112-
@lru_cache(1024) # noqa: B019
113-
def handle_routing(
117+
def _handle_routing(
114118
self, path: str, method: Method | None
115119
) -> tuple[ASGIApp, RouteHandlerType, str, dict[str, Any], str]:
116120
"""Handle routing for a given path / method combo. This method is meant to allow easy caching.
117121
122+
The cached version of this method is available as ``self.handle_routing``,
123+
set up in ``__init__``.
124+
118125
Args:
119126
path: The path of the request.
120127
method: The scope's method, if any.

tests/unit/test_asgi/test_asgi_router.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,29 @@ async def handler() -> None:
271271
assert state.exception_handlers is not Empty
272272
assert state.exception_handlers[RuntimeError] is app_exception_handlers_mock
273273
assert state.exception_handlers[TypeError] is handler_exception_handlers_mock
274+
275+
276+
def test_routing_cache_does_not_prevent_garbage_collection() -> None:
277+
"""The routing cache must not keep the app alive after all references are dropped.
278+
279+
A class-level ``lru_cache`` on ``handle_routing`` acted as a GC root anchoring
280+
every router (and, through it, every app) forever.
281+
https://github.com/litestar-org/litestar/issues/4876
282+
"""
283+
import gc
284+
285+
def make_app_and_request() -> int:
286+
# The client (which references the app) must go out of scope before
287+
# collecting, hence the helper function.
288+
@get("/ping", sync_to_thread=False)
289+
def ping() -> str:
290+
return "pong"
291+
292+
app = Litestar(route_handlers=[ping])
293+
with TestClient(app) as client:
294+
assert client.get("/ping").status_code == 200
295+
return id(app)
296+
297+
app_id = make_app_and_request()
298+
gc.collect()
299+
assert not any(id(obj) == app_id for obj in gc.get_objects() if type(obj) is Litestar)

0 commit comments

Comments
 (0)