Coverage for src/local_deep_research/utilities/threading_utils.py: 100%
44 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1import threading
2import uuid
3from functools import wraps
4from typing import Any, Callable, Hashable, Tuple
6from cachetools import cached, keys
7from flask import current_app, g
8from flask.ctx import AppContext
9from loguru import logger
11g_thread_local_store = threading.local()
13# Keys in Flask ``g`` that are created and cleaned up by a
14# ``teardown_appcontext`` handler (see ``web/app_factory.cleanup_db_session``).
15# They must never be copied into a worker context: the value is a resource
16# (a SQLAlchemy session) still owned by the submitting thread, and copying it
17# would let a teardown roll back and close the parent thread's session.
18_TEARDOWN_OWNED_G_KEYS = frozenset({"db_session"})
21def thread_specific_cache(*args: Any, **kwargs: Any) -> Callable:
22 """
23 A version of `cached()` that is local to a single thread. In other words,
24 cache entries will only be valid in the thread where they were created.
26 Args:
27 *args: Will be forwarded to `cached()`.
28 **kwargs: Will be forwarded to `cached()`.
30 Returns:
31 The wrapped function.
33 """
35 def _key_func(*args_: Any, **kwargs_: Any) -> Tuple[Hashable, ...]:
36 base_hash = keys.hashkey(*args_, **kwargs_)
38 if hasattr(g_thread_local_store, "thread_id"):
39 # We already gave this thread a unique ID. Use that.
40 thread_id = g_thread_local_store.thread_id
41 else:
42 # Give this thread a new unique ID.
43 thread_id = uuid.uuid4().hex
44 g_thread_local_store.thread_id = thread_id
46 return (thread_id,) + base_hash
48 return cached(*args, **kwargs, key=_key_func)
51def thread_with_app_context(to_wrap: Callable) -> Callable:
52 """
53 Decorator that wraps the entry point to a thread and injects the current
54 app context from Flask. This is useful when we want to use multiple
55 threads to handle a single request.
57 When using this wrapped function, `current_app.app_context()` should be
58 passed as the first argument when initializing the thread.
60 Args:
61 to_wrap: The function to wrap.
63 Returns:
64 The wrapped function.
66 """
68 @wraps(to_wrap)
69 def _run_with_context(
70 app_context: AppContext | None, *args: Any, **kwargs: Any
71 ) -> Any:
72 if app_context is None:
73 # Do nothing.
74 return to_wrap(*args, **kwargs)
76 with app_context:
77 return to_wrap(*args, **kwargs)
79 return _run_with_context
82def thread_context() -> AppContext | None:
83 """
84 Builds (but does not push) a new app context for a thread that is being
85 spawned to handle the current request, pre-populating its ``g`` with the
86 current context's global data. Teardown-owned resources such as
87 ``db_session`` are not copied, so the worker never shares the submitting
88 thread's session. The caller is responsible for entering the returned
89 context in the worker thread (see :func:`thread_with_app_context`).
91 Returns:
92 The new context, or None if no context is active.
94 """
95 # Copy global data.
96 global_data = {}
97 try:
98 for key in g:
99 global_data[key] = g.get(key)
100 except (TypeError, RuntimeError):
101 # Context is not initialized (TypeError) or no request/app context
102 # is active (RuntimeError). Don't change anything.
103 logger.debug(
104 "No active request context, skipping Flask g copy for child thread"
105 )
107 try:
108 context = current_app.app_context()
109 except RuntimeError:
110 # Context is not initialized.
111 logger.debug("No current app context, not passing to thread.")
112 return None
114 # Populate the new context's ``g`` directly instead of pushing the context
115 # on the submitting thread. Pushing and popping it here would fire the app's
116 # ``teardown_appcontext`` handlers on this thread, and a handler that owns
117 # ``db_session`` (``web/app_factory.cleanup_db_session``) rolls back and
118 # closes it. Since ``g`` is shallow-copied, that session is the one the
119 # submitting thread still holds, so the parent's uncommitted work would be
120 # discarded. Teardown-owned keys are excluded for the same reason: a worker
121 # that needs the database acquires its own context-local session (see
122 # ``database/session_context.get_user_db_session``) rather than reusing the
123 # parent's. The worker enters ``with context:`` itself (via
124 # ``thread_with_app_context``), so its own teardown cleans up only what it
125 # created.
126 for key, value in global_data.items():
127 if key in _TEARDOWN_OWNED_G_KEYS:
128 continue
129 setattr(context.g, key, value)
131 return context