Skip to content

perf: /cache/stats has no single-flight guard, so a burst at TTL expiry triggers duplicate cache walks #1395

Description

@groupthinking

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions