1354679e33
Phase 2 Tasks T2.1-T2.4 of the startup_speedup_20260606 track.
NEW: src/io_pool.py
make_io_pool() factory: 4-worker ThreadPoolExecutor with
thread_name_prefix='controller-io'. The sanctioned way for any
background work. Replaces ad-hoc threading.Thread() calls per
the 'no new threads' rule.
NEW: src/warmup.py
WarmupManager: manages a list of modules to import on the shared
pool. Public API:
.submit(modules) - start warmup (call once)
.status() - {pending, completed, failed}
.is_done() - bool
.wait(timeout) - block until done
.on_complete(callback) - register completion callback
.reset() - clear state
Thread-safe (lock-guarded). 10 tests cover all paths.
NEW: tests/test_io_pool.py (4 tests):
- ThreadPoolExecutor returned
- 4 workers
- Threads named 'controller-io-*'
- Jobs run in parallel (barrier test)
NEW: tests/test_warmup.py (10 tests):
- One job per module submitted
- Initial pending list correct
- Failed imports tracked
- Done event set after all complete
- wait() blocks until done
- on_complete callback fires (and immediately if already done)
- Modules actually end up in sys.modules
- reset() clears state
- Jobs run concurrently (not serially)
All 14 tests pass. AppController integration is the next commit.
133 lines
3.6 KiB
Python
133 lines
3.6 KiB
Python
"""WarmupManager: import heavy modules on a background thread pool.
|
|
|
|
Per spec.md:2.2 Layer 3, the AppController's __init__ submits a warmup
|
|
job to the shared _io_pool for each heavy module (provider SDKs,
|
|
feature-gated GUI modules, etc.). After warmup completes, those modules
|
|
are in sys.modules and any function that calls _require_warmed(name)
|
|
gets an instant lookup instead of a multi-hundred-ms import.
|
|
|
|
Public API on the manager (and exposed on AppController via delegation):
|
|
mgr.submit(modules) - start warmup jobs (call once at init)
|
|
mgr.status() - {pending, completed, failed}
|
|
mgr.is_done() - bool
|
|
mgr.wait(timeout) - block until done
|
|
mgr.on_complete(callback) - register completion callback
|
|
mgr.reset() - clear state (for re-warmup, e.g. in tests)
|
|
"""
|
|
|
|
import importlib
|
|
import threading
|
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
from typing import Callable, Optional
|
|
|
|
|
|
CompletionCallback = Callable[[dict], None]
|
|
|
|
|
|
class WarmupManager:
|
|
def __init__(self, pool: ThreadPoolExecutor) -> None:
|
|
self._pool = pool
|
|
self._lock = threading.Lock()
|
|
self._done_event = threading.Event()
|
|
self._pending: list[str] = []
|
|
self._completed: list[str] = []
|
|
self._failed: list[str] = []
|
|
self._callbacks: list[CompletionCallback] = []
|
|
self._started = False
|
|
|
|
def submit(self, modules: list[str]) -> None:
|
|
with self._lock:
|
|
if self._started:
|
|
raise RuntimeError("WarmupManager.submit() called twice; call reset() first")
|
|
self._pending = list(modules)
|
|
self._completed = []
|
|
self._failed = []
|
|
self._done_event.clear()
|
|
self._started = True
|
|
for name in modules:
|
|
self._pool.submit(self._warmup_one, name)
|
|
|
|
def status(self) -> dict[str, list[str]]:
|
|
with self._lock:
|
|
return {
|
|
"pending": list(self._pending),
|
|
"completed": list(self._completed),
|
|
"failed": list(self._failed),
|
|
}
|
|
|
|
def is_done(self) -> bool:
|
|
return self._done_event.is_set()
|
|
|
|
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
return self._done_event.wait(timeout=timeout)
|
|
|
|
def on_complete(self, callback: CompletionCallback) -> None:
|
|
fire_now = False
|
|
with self._lock:
|
|
if self._done_event.is_set():
|
|
fire_now = True
|
|
snap = self._snapshot()
|
|
else:
|
|
self._callbacks.append(callback)
|
|
if fire_now:
|
|
try:
|
|
callback(snap)
|
|
except Exception:
|
|
pass
|
|
|
|
def reset(self) -> None:
|
|
with self._lock:
|
|
self._pending = []
|
|
self._completed = []
|
|
self._failed = []
|
|
self._done_event.clear()
|
|
self._callbacks = []
|
|
self._started = False
|
|
|
|
def _warmup_one(self, name: str) -> None:
|
|
try:
|
|
importlib.import_module(name)
|
|
except BaseException as e:
|
|
self._record_failure(name, e)
|
|
else:
|
|
self._record_success(name)
|
|
|
|
def _record_success(self, name: str) -> None:
|
|
callbacks: list[CompletionCallback] = []
|
|
with self._lock:
|
|
if name in self._pending:
|
|
self._pending.remove(name)
|
|
self._completed.append(name)
|
|
done = self._started and not self._pending
|
|
if done:
|
|
self._done_event.set()
|
|
callbacks = list(self._callbacks)
|
|
for cb in callbacks:
|
|
try:
|
|
cb(self._snapshot())
|
|
except Exception:
|
|
pass
|
|
|
|
def _record_failure(self, name: str, _err: BaseException) -> None:
|
|
callbacks: list[CompletionCallback] = []
|
|
with self._lock:
|
|
if name in self._pending:
|
|
self._pending.remove(name)
|
|
self._failed.append(name)
|
|
done = self._started and not self._pending
|
|
if done:
|
|
self._done_event.set()
|
|
callbacks = list(self._callbacks)
|
|
for cb in callbacks:
|
|
try:
|
|
cb(self._snapshot())
|
|
except Exception:
|
|
pass
|
|
|
|
def _snapshot(self) -> dict[str, list[str]]:
|
|
return {
|
|
"pending": list(self._pending),
|
|
"completed": list(self._completed),
|
|
"failed": list(self._failed),
|
|
}
|