Coverage for src/local_deep_research/utilities/threading_utils.py: 100%
15 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1import threading
2import uuid
3from typing import Any, Callable, Hashable, Tuple
5from cachetools import cached, keys
7g_thread_local_store = threading.local()
10def thread_specific_cache(*args: Any, **kwargs: Any) -> Callable:
11 """
12 A version of `cached()` that is local to a single thread. In other words,
13 cache entries will only be valid in the thread where they were created.
15 WHY THIS IS STILL THREAD-KEYED UNDER FastAPI, AND NOT A `ContextVar`.
17 This survived the Flask -> FastAPI migration on purpose; it is not a
18 leftover. The two Flask helpers that used to live in this module
19 (`thread_with_app_context`, `thread_context`) were deleted in that
20 migration because they wrapped `flask.g` / `AppContext`, and identity and
21 research-context propagation genuinely did move to `ContextVar` — see
22 `utilities/request_context.py` and `utilities/thread_context.py`. This
23 decorator is a different case, because what it caches is not context but a
24 THREAD-AFFINE RESOURCE.
26 Its only production caller is `db_utils._get_cached_user_session`, which
27 caches a SQLAlchemy `Session`. The post-auth session accessor on
28 `db_manager` builds a fresh `sessionmaker(bind=engine)()` on every call and
29 does nothing to scope it
30 per thread, so this key is the only thing keeping one cached `Session` from
31 being handed to two anyio threadpool workers running concurrently for the
32 same user. A `Session` is documented as not thread-safe: concurrent
33 identity-map mutation and autoflush on one instance is the failure this
34 prevents.
36 A `ContextVar`-keyed cache would NOT be equivalent, and would be unsafe
37 here. A single request context can straddle two worker threads: Starlette
38 drives sync-generator dependencies through `contextmanager_in_threadpool`,
39 which dispatches `__enter__` and `__exit__` as two separate
40 `anyio.to_thread.run_sync` calls, and anyio selects a worker via
41 `idle_workers.pop()` with no task affinity. (This hazard is written up at
42 `web/dependencies/auth.py::get_db_session_dep`, and handled deliberately by
43 the streaming generators in `web/routers/library.py`.) Keying on context
44 would therefore return ONE `Session` to a context spread across two
45 threads — reintroducing exactly the cross-thread sharing that keying on
46 the thread prevents. `ContextVar` is the right primitive for propagating
47 identity; `threading.local()` is the right primitive for a resource whose
48 safety is defined per OS thread. Do not swap one for the other without
49 also giving `Session` acquisition a different lifetime model.
51 WHAT THE THREAD KEY DOES *NOT* DO. It provides no cross-user isolation.
52 That comes from `username` being part of `keys.hashkey(*args_, **kwargs_)`
53 below, i.e. from applying this decorator BELOW username resolution
54 (`_get_cached_user_session(username, _namespace)`) rather than above it.
55 See the note on the lock further down, and
56 `tests/security/test_cross_user_isolation_invariants.py`, which pins both
57 properties separately. Conflating the two is how the key-completeness bug
58 described below got misdiagnosed for weeks.
60 Args:
61 *args: Will be forwarded to `cached()`.
62 **kwargs: Will be forwarded to `cached()`.
64 Returns:
65 The wrapped function.
67 """
69 def _key_func(*args_: Any, **kwargs_: Any) -> Tuple[Hashable, ...]:
70 base_hash = keys.hashkey(*args_, **kwargs_)
72 if hasattr(g_thread_local_store, "thread_id"):
73 # We already gave this thread a unique ID. Use that.
74 thread_id = g_thread_local_store.thread_id
75 else:
76 # Give this thread a new unique ID.
77 thread_id = uuid.uuid4().hex
78 g_thread_local_store.thread_id = thread_id
80 return (thread_id,) + base_hash
82 # cachetools' `cached()` is NOT thread-safe unless a lock is supplied.
83 # The cache object is shared by every calling thread, and concurrent
84 # __setitem__/eviction can desync an LRUCache's internal ordering from
85 # its data dict, after which `popitem()` raises KeyError. With the
86 # per-thread key below the key space is (thread x args), so a small
87 # maxsize under many worker threads means near-constant eviction —
88 # precisely the condition that provokes it. Measured: without this lock,
89 # ~432k KeyErrors across 480k concurrent calls; with it, zero.
90 #
91 # NOTE ON WHAT THIS LOCK DOES *NOT* FIX. An earlier version of this
92 # comment claimed the unlocked cache could return "the entry stored
93 # under a DIFFERENT key", and credited the lock with fixing the
94 # cross-user settings leak this branch chased for weeks. That mechanism
95 # is not possible: `cachetools.Cache.__getitem__` is `return
96 # self.__data[key]`, a plain dict lookup on a tuple-of-str key, so a hit
97 # always returns that key's own value. The failure mode here is loud
98 # (KeyError), never a silently wrong value.
99 #
100 # The cross-user leak was a KEY-COMPLETENESS bug, fixed separately in
101 # `utilities/db_utils.py` by moving the cache below username resolution
102 # (`_get_cached_user_session(username, _namespace)`). Before that the key
103 # was effectively `(thread_uuid,)` for web callers, so every user served
104 # by one reused worker thread shared a single entry. Measured on the two
105 # shapes: key-above-resolution leaks ~472k wrong-user sessions per 480k
106 # calls with or without this lock; key-below-resolution leaks zero.
107 #
108 # Both changes are worth keeping — this one prevents crashes, that one
109 # prevents the leak. They are not interchangeable, and this is the wrong
110 # place to look for the leak fix.
111 kwargs.setdefault("lock", threading.RLock())
112 return cached(*args, **kwargs, key=_key_func)