Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions src/youtube_extension/backend/real_api_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any, Final, Optional

from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, Field
Expand All @@ -28,6 +28,11 @@
# Configure logging
logger = logging.getLogger(__name__)

# Distinguishes "no cache entry" from an entry that parses to ``None`` -- which is
# what a file holding the JSON literal ``null`` yields. A plain ``None`` return
# conflates the two and turns a stored ``null`` analysis into a 404.
_CACHE_MISS: Final = object()


def _collect_processed_videos_sync(cache_dir: Path) -> list[dict[str, Any]]:
"""Scan the processing cache directory and parse every cached result.
Expand Down Expand Up @@ -85,6 +90,67 @@ def _collect_processed_videos_sync(cache_dir: Path) -> list[dict[str, Any]]:
return processed_videos


def _read_video_analysis_sync(cache_path: Path) -> Any:
"""Read and parse a single cached video analysis.

This performs blocking filesystem work (``open()`` and a full
``json.load()`` of the analysis payload) and is therefore intended to be
executed in a worker thread via :func:`asyncio.to_thread` rather than
directly on the event loop.

What that offload does and does not buy is worth stating precisely, because
the two halves of this function behave differently under the GIL:

* ``open()``/``read()`` release the GIL, so moving them off the loop removes
the caller's exposure to filesystem latency entirely. This is the part
that is unbounded -- a cold page cache, a networked mount or a contended
disk can stall for hundreds of milliseconds.
* ``json.load()`` is CPU-bound C code that *holds* the GIL for its whole
duration. Running it in a worker thread does not stop it blocking the
loop; it only relocates it. Measured stall tracks payload size at roughly
3 ms/MB (~0.4 ms for a typical one-hour transcript, ~3 ms for long-form).

So this converts an unbounded, environment-dependent stall into a bounded,
payload-proportional one. It does not make the read non-blocking. On a warm
page cache with a small payload the executor hop is measurably *worse* than
reading inline (~0.5 ms of dispatch overhead against a ~0.4 ms parse); the
change earns its keep when the filesystem is slow, which is precisely the
case that cannot be predicted from inside the handler.

Removing the residual parse stall needs a different fix (streaming/incremental
parse, a size cap, or a process pool) and is tracked in issue #1306.

Opening directly and treating :class:`FileNotFoundError` as the miss
replaces a separate ``Path.exists()`` probe. That is one syscall instead of
two, and it closes the window in which the entry could be removed between
the check and the open. Every other ``OSError`` still propagates, so a
directory or an unreadable entry keeps surfacing as a 500 rather than being
silently reported as a missing video.

:class:`ValueError` is also treated as a miss. ``Path.exists()`` swallows it
and reports the entry as absent, so a ``video_id`` carrying an embedded null
byte used to yield a 404; letting the bare ``open()`` raise would turn that
malformed-identifier case into a 500.

Deliberately does *not* apply the processor's cache TTL. This endpoint has
always served a cached analysis regardless of age, whereas
``RealVideoProcessor._read_cache_file`` in ``services/real_video_processor.py``
treats anything older than ``_CACHE_TTL_SECONDS`` (24 hours) as a miss;
reusing it here would turn every analysis over a day old into a 404.

Returns the parsed JSON payload, which may legitimately be ``None`` when the
entry holds the literal ``null``. Absence is reported as the distinct
:data:`_CACHE_MISS` sentinel so the two cannot be confused.
"""
try:
handle = open(cache_path, encoding="utf-8")
except (FileNotFoundError, ValueError):
return _CACHE_MISS

with handle as f:
return json.load(f)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# Pydantic models for API requests/responses
class VideoProcessingRequest(BaseModel):
video_url: str = Field(..., description="YouTube video URL or ID")
Expand Down Expand Up @@ -268,15 +334,23 @@ async def get_video_analysis(video_id: str):
processor = get_real_video_processor()
cache_path = processor._get_cache_path(video_id)

if not cache_path.exists():
# Reading an entry opens and JSON-parses a file whose size is set
# by the stored analysis payload, so the parse cost scales with the
# video rather than being bounded. Run it in a worker thread so a
# large analysis cannot stall the event loop for every other
# in-flight request. Building the path stays here: it is pure
# string arithmetic and touches no filesystem.
video_data = await asyncio.to_thread(_read_video_analysis_sync, cache_path)

# Identity check against the sentinel, not a truthiness or ``is None``
# test: an entry holding the JSON literal ``null`` parses to ``None``
# and has always been served as a 200, so it must not 404 here.
if video_data is _CACHE_MISS:
raise HTTPException(
status_code=404,
detail=f"Video analysis not found: {video_id}"
)

with open(cache_path, encoding='utf-8') as f:
video_data = json.load(f)

return video_data

except HTTPException:
Expand Down
Loading
Loading