Skip to content

perf: /api/v1/videos blocks the event loop on uncached per-page filesystem reads #1379

Description

@groupthinking

Problem

GET /api/v1/videos is declared async def but performs its entire filesystem workload synchronously on the event loop, so every request blocks the shared asyncio thread for the duration of the scan.

list_videos_v1 (src/youtube_extension/backend/api/v1/router.py:917-924) calls two blocking DataService methods directly:

async def list_videos_v1(limit: int = 50, offset: int = 0, ...):
    total = data_service.count_videos()                                   # blocking
    ...
    paginated_videos = data_service.get_videos_summary(limit=limit, offset=offset)  # blocking

Neither call is awaited off-loop. The work they perform is not trivial:

  • count_videos()_get_all_files_cached() (data_service.py:136-160) walks the entire enhanced-analysis tree with rglob("*_enhanced.md") and issues one stat() per file. This result is TTL-cached, so it is only paid on cache miss — but on miss it is an unbounded directory walk.
  • get_videos_summary() (data_service.py:168-221) then runs "Pass 2" over the requested page and, for every item on the page, performs Path.glob(), Path.exists(), open() and json.load(). This per-page metadata read is never cached, so it is paid in full on every request.

With the default limit=50, a single request therefore performs on the order of 200 uncached blocking syscalls plus up to 50 JSON parses while holding the event loop. Every other in-flight request — health checks, SSE streams, unrelated endpoints — is stalled behind it.

Why this matters

This is the primary listing endpoint backing the dashboard, and the dashboard polls it. The stall scales linearly with page size and with the size of the analysis tree, so the failure mode worsens exactly as the deployment grows.

Expected behaviour

The blocking filesystem work should run in a worker thread via asyncio.to_thread, leaving the event loop free to service concurrent requests. Observable API behaviour (response shape, pagination semantics, the offset >= total short-circuit, error handling) must not change.

Suggested approach

Hoist both calls into a single synchronous helper and dispatch it with one asyncio.to_thread hop. A single hop — rather than two — preserves the existing read-consistency between count_videos() and get_videos_summary() and avoids a redundant second cache-refresh window.

Acceptance criteria

  • list_videos_v1 performs no blocking filesystem I/O on the event loop.
  • Response payload, pagination maths and the offset >= total early return are unchanged.
  • Regression test asserts the work executes on a non-event-loop thread (thread identity), not merely that the endpoint returns 200.
  • Regression test asserts the event loop stays responsive while the scan is in flight.
  • Test fails against the current implementation (RED) and passes after the fix (GREEN).

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