fix(prompt): start PromptCache workers lazily instead of on import - #26
fix(prompt): start PromptCache workers lazily instead of on import#26aloktripathi1 wants to merge 2 commits into
Conversation
PromptCache spawned 2 background _RefreshWorker daemon threads eagerly at construction, including for the module-level singleton created at import. But refresh_async() -- the only thing that feeds those threads -- is never called in the SDK, so every user importing fi.prompt got 2 permanently idle threads. _TaskManager now starts workers lazily on first submit() via double-checked locking. No public API change; refresh_async() works as before, threads just start on demand. Adds 4 regression tests covering lazy startup, on-demand worker creation, idempotent start, and safe shutdown before any submit.
NVJKKartik
left a comment
There was a problem hiding this comment.
Diagnosis is right and I reproduced it. Clean 3.12 venv, uv pip install -e python/, then the README import:
origin/main -> ['MainThread', 'PromptCacheWorker-0', 'PromptCacheWorker-1']
this branch -> ['MainThread']
cache.py:94 is also the only .start() under python/fi/, so nothing else is spawning on that import path. And your note about test_annotation_queues.py checks out, both of those fail on origin/main too.
My problem is with the fix, not the finding.
1. The thing you're making lazy is dead code. Delete it instead.
_TaskManager.submit() has exactly one caller, refresh_async(). refresh_async() has zero callers. Not in python/, not in typescript/, not exported from fi/prompt/__init__.py, not in the README, not in any example. So _RefreshWorker, _TaskManager, refresh_async and _refreshing_keys are ~60 lines nothing reaches.
This PR adds 20 lines of lazy-start machinery plus 38 lines of tests so that dead code stops being harmful. Deleting it fixes the same bug with a negative diff and no lock to reason about. client.py:591 already says the SWR path was skipped on purpose. Bring the workers back when something actually calls them.
One caveat to put in the description if you go this way: it also drops max_workers from PromptCache.__init__. Same bucket though, public-named but undocumented and unused, and we're pre-1.0.
2. If you want to keep refresh_async as a deliberate hook, use ThreadPoolExecutor
It's already lazy. Threads spawn on first submit, and it registers its own atexit join:
>>> p = ThreadPoolExecutor(max_workers=2, thread_name_prefix="PromptCacheWorker")
>>> [t.name for t in threading.enumerate()]
['MainThread']
>>> p.submit(lambda: None).result()
>>> [t.name for t in threading.enumerate()]
['MainThread', 'PromptCacheWorker_0']
That removes _RefreshWorker and _TaskManager both, ~55 lines, and no hand-rolled double-checked locking. We already wrap TPE in fi/utils/executor.py.
3. This is the one I actually need changed: none of the four tests catch the bug
I ran them against origin/main. Two fail on assert tm._workers == [], two on AttributeError for _started / _ensure_started. They fail because the attributes don't exist yet, not because behavior regressed.
So if the bug comes back a different way (someone calls refresh_async at import, adds an eager thread elsewhere, starts a pool in __init__) all four still pass. And any refactor of the lazy mechanism breaks all four for no reason.
The test worth writing is the check you already did by hand. Subprocess, because in-process it's order dependent, another test importing fi.prompt first makes it vacuous:
def test_importing_fi_prompt_spawns_no_threads():
code = "import threading, fi.prompt; print([t.name for t in threading.enumerate()])"
out = subprocess.run([sys.executable, "-c", code],
capture_output=True, text=True, check=True).stdout
assert "PromptCacheWorker" not in outThat one survives refactors and fails on any reintroduction.
Smaller stuff
-
Nothing exercises the lock.
test_ensure_started_is_idempotentis three sequential calls on one thread, and the_startedflag alone handles that. Delete the lock and the test still passes. If you keep the DCL, fire N threads atsubmit()at once and assertlen(tm._workers) == num_workers. -
timeandpytestare imported in the new test file and never used. No lint in CI so nothing catches it. -
self._workers: list = []loses the element type. Module already hasfrom __future__ import annotations, solist[_RefreshWorker]._ensure_startedis missing-> None. -
_ensure_startedsits under the# Public APIbanner but it's private. -
Description still has the template placeholder: "Closes (link the cache thread issue if you filed one separately)".
-
Not a regression, just noting it:
_shutdown()doesn't reset_startedor_workers, so asubmit()after shutdown queues a task nobody runs. Same on main. Goes away with 1 or 2 anyway.
One thing you didn't mention that I liked: deferring the atexit.register also stops leaking every _TaskManager we construct, since atexit holds a strong ref to the bound method.
So, 1 (or 2 if you're keeping the hook), and 3 either way.
Per review: refresh_async had zero callers anywhere in the codebase and wasn't part of the public API (not exported from fi/prompt/__init__.py, not referenced in README or examples). Removing it fixes the same bug with a smaller diff than making it lazy, and matches the existing intent already documented in client.py: "Async refresh logic is not applied here to simplify the fallback flow." Deleted _RefreshWorker, _TaskManager, refresh_async(), _refreshing_keys, and the max_workers param. get(), get_stale(), set(), invalidate(), make_key(), and the prompt_cache singleton are untouched -- none of them referenced the deleted code. Replaced the 4 implementation-detail tests (which asserted on internal attributes like _workers/_started) with a single subprocess-based black-box test that checks actual thread names after importing fi.prompt. This is robust to refactors and catches the real regression class -- any new eager thread anywhere on import, not just this specific mechanism.
|
@NVJKKartik went with option 1, deleted refresh_async/_RefreshWorker/_TaskManager Net diff is -138/+13. get(), get_stale(), set(), invalidate(), make_key(), Replaced all 4 tests with the subprocess-based one you wrote, confirmed it 68/70 tests pass locally, the 2 failures are test_annotation_queues.py's Thanks for the thorough review, caught a much better fix than what I |
Closes (link the cache thread issue if you filed one separately)
Importing fi.prompt (per the README's own quickstart,
from fi.prompt import Prompt, PromptTemplate, ModelConfig) eagerly spawns 2 background_RefreshWorker daemon threads via the module-level prompt_cache singleton.
These threads poll an empty queue forever, because refresh_async(), the
only thing that feeds work to them, is never called anywhere in the SDK.
get_by_name() explicitly opts out, per its own comment: "Async refresh logic
is not applied here to simplify the fallback flow." So every user importing
fi.prompt gets 2 permanently idle threads for zero benefit.
Verified with threading.active_count() before and after the README import:
Fix: _TaskManager now starts its workers lazily on the first submit() call
via double-checked locking, instead of eagerly in init. No public API
change, refresh_async() behaves exactly as before, the threads just start
on demand if and when something actually uses them.
4 new regression tests:
Note: the 2 failing tests in test_annotation_queues.py
(TestAnalytics::test_get_analytics, TestExport::test_export_json) are
pre-existing on main and unrelated to this change.