Summary
GET /api/v1/cache/stats has two independent performance defects in the same call path:
- 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.
- 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.
Summary
GET /api/v1/cache/statshas two independent performance defects in the same call path:stat()ed twice.CacheService._analyze_legacy_cache_statsreadsst_sizein one pass andst_mtimein a second pass, issuing2Nsyscalls whereNsuffice.get_cache_stats_v1is anasync defthat calls the fully blockingcache_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 filesrc/youtube_extension/backend/services/cache_service.py:pathlib.Path.stat()performs an uncachedstat(2)on every call - unlikeos.DirEntry.stat(), it memoises nothing. The two passes therefore cost2Nsyscalls forNcached analyses, and both values are available from a singleos.stat_result.2. Blocking walk on the event loop
src/youtube_extension/backend/api/v1/router.py:get_cache_statistics()walks the legacy cache tree and the enhanced cache tree, callingiterdir(),glob()andstat()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
Nstat(2)calls per statistics refresh, whereNis the number of cached analyses. The saving grows linearly with cache size.Proposed fix
os.stat_resultper file and read bothst_sizeandst_mtimefrom it.asyncio.to_thread, admitted through the existing shared_get_fs_walk_gate()budget so concurrent statistics refreshes cannot saturate the defaultThreadPoolExecutor.Out of scope
_stats_cachehas no single-flight guard:_stats_cache_timeis only updated after the walk completes, so every request arriving during a refresh starts its own redundant walk. Deduplicating that needs a per-event-loopasyncio.Lock, which carries the same loop-binding hazard addressed in #1389, and belongs in its own change.