Summary
GET /api/v1/cache/stats memoises its result in module globals with a 60 second TTL, but the timestamp is only written after the walk finishes. Every request that arrives while a refresh is in flight sees a stale timestamp, concludes the cache is cold, and starts its own redundant walk.
Detail
src/youtube_extension/backend/api/v1/router.py:
now = time.time()
if now - _stats_cache_time < _stats_cache_ttl and _stats_cache:
return CacheStats(**_stats_cache)
async with _get_fs_walk_gate():
stats = await asyncio.to_thread(cache_service.get_cache_statistics)
_stats_cache = stats
_stats_cache_time = now # <-- only now does the TTL window reopen
The shared fs-walk gate bounds how many of those duplicate walks run concurrently, but it does not deduplicate them - the surplus requests queue on the semaphore and then each perform the same walk in turn. The endpoint therefore converts a burst at TTL expiry into a serialised train of identical filesystem walks.
Proposed fix
Collapse concurrent refreshes onto a single in-flight computation, so the first caller performs the walk and the rest await its result.
Why this is not a one-line change
The natural implementation is a module-level asyncio.Lock or a shared future. Both are asyncio primitives that bind to the event loop that first waits on them, and this module is imported once per process but exercised by many short-lived loops in the test suite. Binding a lock to a dead loop is exactly the failure mode addressed for semaphores in #1389, whose fix required a per-loop weakref.WeakKeyDictionary registry, a threading lock around creation, and pruning of closed loops.
Any single-flight guard here needs the same treatment, plus a decision about failure semantics: whether a walk that raises should propagate to every waiter or let each retry independently. That is enough design surface to deserve its own change with its own regression tests.
Summary
GET /api/v1/cache/statsmemoises its result in module globals with a 60 second TTL, but the timestamp is only written after the walk finishes. Every request that arrives while a refresh is in flight sees a stale timestamp, concludes the cache is cold, and starts its own redundant walk.Detail
src/youtube_extension/backend/api/v1/router.py:The shared fs-walk gate bounds how many of those duplicate walks run concurrently, but it does not deduplicate them - the surplus requests queue on the semaphore and then each perform the same walk in turn. The endpoint therefore converts a burst at TTL expiry into a serialised train of identical filesystem walks.
Proposed fix
Collapse concurrent refreshes onto a single in-flight computation, so the first caller performs the walk and the rest await its result.
Why this is not a one-line change
The natural implementation is a module-level
asyncio.Lockor a shared future. Both areasyncioprimitives that bind to the event loop that first waits on them, and this module is imported once per process but exercised by many short-lived loops in the test suite. Binding a lock to a dead loop is exactly the failure mode addressed for semaphores in #1389, whose fix required a per-loopweakref.WeakKeyDictionaryregistry, a threading lock around creation, and pruning of closed loops.Any single-flight guard here needs the same treatment, plus a decision about failure semantics: whether a walk that raises should propagate to every waiter or let each retry independently. That is enough design surface to deserve its own change with its own regression tests.