Coverage for src/local_deep_research/embeddings/model_loading.py: 95%
64 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Keep model loading exclusive while allowing resident-model inference."""
3from contextlib import contextmanager
4from threading import Condition, RLock, get_ident
5from typing import Any, Callable, TypeVar
7_Model = TypeVar("_Model")
10class ModelAccessLock:
11 """Reentrant writer lock with shared inference access.
13 Shared holders create handles for the warm model and run admitted
14 embedding calls; the model cache lets those calls use the model one at a
15 time. Writers take priority over new readers, so configuration changes and
16 cleanup cannot starve behind a stream of inference requests. The cache's
17 model constructors also use this gate: Transformers temporarily changes
18 process-wide dtype and initialization state, which must not overlap
19 resident inference.
20 """
22 def __init__(self) -> None:
23 self._condition = Condition(RLock())
24 self._writer: int | None = None
25 self._depth = 0
26 self._readers: dict[int, int] = {}
27 self._waiting_writers = 0
29 def acquire(self, blocking: bool = True) -> bool:
30 if not self._condition.acquire(blocking=blocking): 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
31 return False
32 try:
33 owner = get_ident()
34 if self._writer == owner:
35 self._depth += 1
36 return True
37 if not blocking and (self._writer is not None or self._readers):
38 return False
39 if owner in self._readers:
40 raise RuntimeError(
41 "Cannot load or clear a model during inference"
42 )
43 self._waiting_writers += 1
44 try:
45 while self._writer is not None or self._readers:
46 self._condition.wait()
47 self._writer = owner
48 self._depth = 1
49 return True
50 finally:
51 self._waiting_writers -= 1
52 self._condition.notify_all()
53 finally:
54 self._condition.release()
56 def release(self) -> None:
57 with self._condition:
58 if self._writer != get_ident(): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 raise RuntimeError(
60 "Model loading lock is not owned by this thread"
61 )
62 self._depth -= 1
63 if not self._depth:
64 self._writer = None
65 self._condition.notify_all()
67 def __enter__(self):
68 self.acquire()
69 return self
71 def __exit__(self, *_exc):
72 self.release()
74 @contextmanager
75 def inference(self):
76 owner = get_ident()
77 with self._condition:
78 while self._writer != owner and (
79 self._writer is not None
80 or (self._waiting_writers and owner not in self._readers)
81 ):
82 self._condition.wait()
83 self._readers[owner] = self._readers.get(owner, 0) + 1
84 try:
85 yield
86 finally:
87 with self._condition:
88 self._readers[owner] -= 1
89 if not self._readers[owner]:
90 del self._readers[owner]
91 self._condition.notify_all()
94MODEL_LOAD_LOCK = ModelAccessLock()
97def load_sentence_transformer(
98 factory: Callable[..., _Model], **kwargs: Any
99) -> _Model:
100 """Keep Transformers' dtype and initialization helpers out of races.
102 The gate is held for the whole constructor call and blocks every local
103 embedding call, so a constructor that downloads holds it for the whole
104 download.
105 """
106 with MODEL_LOAD_LOCK:
107 return factory(**kwargs)