Skip to content

perf: /cache/stats stats every file twice and walks the cache tree on the event loop #1394

Description

@groupthinking

Summary

GET /api/v1/cache/stats has two independent performance defects in the same call path:

  1. Every cached file is stat()ed twice. CacheService._analyze_legacy_cache_stats reads st_size in one pass and st_mtime in a second pass, issuing 2N syscalls where N suffice.
  2. The walk runs inline on the event loop. get_cache_stats_v1 is an async def that calls the fully blocking cache_service.get_cache_statistics() directly, so on every TTL miss one request stalls every other request served by that process.

Detail

1. Double stat() per file

src/youtube_extension/backend/services/cache_service.py:

markdown_files = list(category_dir.glob("*_analysis.md"))
category_count = len(markdown_files)
category_size = sum(f.stat().st_size for f in markdown_files)   # <-- pass 1: N syscalls
...
for f in markdown_files:
    mtime = f.stat().st_mtime                                    # <-- pass 2: N more syscalls

pathlib.Path.stat() performs an uncached stat(2) on every call - unlike os.DirEntry.stat(), it memoises nothing. The two passes therefore cost 2N syscalls for N cached analyses, and both values are available from a single os.stat_result.

2. Blocking walk on the event loop

src/youtube_extension/backend/api/v1/router.py:

async def get_cache_stats_v1(cache_service: CacheService = Depends(get_cache_service)):
    ...
    stats = cache_service.get_cache_statistics()   # <-- blocking, on the loop

get_cache_statistics() walks the legacy cache tree and the enhanced cache tree, calling iterdir(), glob() and stat() throughout. None of that is awaitable, so the coroutine holds the event loop for the full duration of the walk.

The result is memoised in a module-global with a 60 second TTL (_stats_cache_ttl: float = 60), which bounds how often the stall happens but not how long it lasts. Once per minute, every other in-flight request on that worker waits for a full two-tree filesystem walk to finish.

The Dockerfile starts Uvicorn without --workers, so there is exactly one event loop per process and nothing else can make progress during the stall.

Impact

  • Halving the syscalls removes N stat(2) calls per statistics refresh, where N is the number of cached analyses. The saving grows linearly with cache size.
  • Offloading removes the periodic head-of-line stall from the endpoint entirely.

Proposed fix

  • Capture one os.stat_result per file and read both st_size and st_mtime from it.
  • Await the walk via asyncio.to_thread, admitted through the existing shared _get_fs_walk_gate() budget so concurrent statistics refreshes cannot saturate the default ThreadPoolExecutor.
  • Keep the TTL fast path in front of the gate so cache hits stay allocation-free and never queue.

Out of scope

_stats_cache has no single-flight guard: _stats_cache_time is only updated after the walk completes, so every request arriving during a refresh starts its own redundant walk. Deduplicating that needs a per-event-loop asyncio.Lock, which carries the same loop-binding hazard addressed in #1389, and belongs in its own change.

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