Skip to content

perf: blocking file read and base64 encode inside concurrent GitHub upload fan-out #1268

Description

@groupthinking

Problem

DeploymentManager._upload_to_github fans out per-file uploads with asyncio.gather behind an asyncio.Semaphore(10), so the code is explicitly written to upload up to ten files concurrently. Each task then reads its payload with a blocking open(...).read() and base64-encodes it inline, both on the event loop.

Because every one of those tasks executes its read on the single event-loop thread, the reads cannot overlap with each other and cannot overlap with the HTTP round-trips the fan-out exists to parallelise. The semaphore bounds concurrency that the blocking read then removes.

Site Line Blocking work on the event loop
upload_single_file 612-613 open(file_path, 'rb') + f.read() — synchronous disk read
upload_single_file 616 base64.b64encode(content) — CPU-bound, scales with file size

Two consequences:

  1. The fan-out is defeated for the read phase. N file reads are serialised on the loop thread regardless of the semaphore.
  2. In-flight uploads stall. The reads happen inside async with aiohttp.ClientSession(). While the loop is blocked reading file k from disk, aiohttp cannot service the responses of the other in-flight uploads, so their completion is deferred by the aggregate read time rather than only by their own network latency.

The payload is read into memory in full and base64-encoded before the PUT, so the encode cost is paid on the loop as well.

Reachability evidence

Call-level chain from a mounted route, verified by AST traversal of deployment_manager.py rather than by import edges alone:

POST /api/v1/video-to-software      backend/api/v1/router.py:777   (mounted main.py:192)
  -> process_video_to_software      backend/services/video_processing_service.py:317
  -> deploy_project                 backend/services/video_processing_service.py:389
  -> deploy_project                 backend/deployment_manager.py
  -> _deploy_to_github              backend/deployment_manager.py:489
  -> _upload_to_github              backend/deployment_manager.py:579
  -> upload_single_file             backend/deployment_manager.py:609   (x N files)

The deployed entrypoint is youtube_extension.main:app (Dockerfile:93, infrastructure/docker/Dockerfile.production:72), and main.py:192 mounts the v1 router, so this path is live rather than library-surface only.

The uploaded set is project_path_obj.rglob("*") filtered by EXCLUDED_DIRS (node_modules, .next, .git, __pycache__, .vercel, dist, .turbo) and dotfiles, i.e. the generated application sources for a scaffolded project. N grows with generated project size.

Acceptance criteria

  • The file read and the base64 encode in upload_single_file execute on a worker thread, not the event loop.
  • Uploaded bytes are unchanged: the same content is transmitted for the same input tree.
  • The existing Semaphore(10) bound on concurrent uploads is preserved.
  • Per-file failures remain isolated — one unreadable file must not abort the remaining uploads.
  • CancelledError continues to propagate rather than being swallowed by the per-file handler.
  • Regression tests fail against the current implementation and pass against the fix.

Proposed fix

Move the read and encode into a single synchronous helper and hand it to asyncio.to_thread, so one thread hop covers both the disk read and the CPU-bound encode:

def _read_and_encode(path: Path) -> str:
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

encoded_content = await asyncio.to_thread(_read_and_encode, file_path)

Combining both operations into one offloaded callable avoids a second thread hop and keeps the large bytes object off the loop entirely — only the encoded str crosses back.

Everything else is unchanged: the semaphore still bounds concurrency, the PUT still happens on the loop via aiohttp, and the per-file except Exception still isolates failures.

Note on backlog substitution

This item replaces a previously ranked backlog entry, "concurrent cache layer fan-out" in backend/services/intelligent_cache.py. That entry was measured and found to be vacuous — every proposed fan-out site would have saved nothing:

Proposed site Line Why concurrency saves nothing
set 630-641 L1 set's only await is _evict_if_needed, which contains zero await expressions, so L1 never yields
delete 643-650 InMemoryCacheLayer.delete contains zero await expressions
clear 652-659 InMemoryCacheLayer.clear contains zero await expressions
invalidate_by_tags 661-669 guarded by hasattr; only RedisCacheLayer matches, so the loop body runs once
get_comprehensive_stats 704-710 get_stats is return self.stats (L130-132), inherited by both layers — zero awaits
_promote_cache_entry 721-723 loop is range(found_at_layer); with two layers found_at_layer <= 1, so at most one iteration
initialize 602-606 only RedisCacheLayer defines connect, so one real await

An async def containing no Await node never yields to the loop, so placing it in asyncio.gather alongside a genuinely awaiting sibling produces exactly zero overlap. Shipping that change would have added concurrency machinery for no measurable gain, so it is dropped in favour of this issue, which is both measurable and on a live request path.

A separate residual defect was noted in the same file while measuring — warm_cache (L671-685) performs an unbounded asyncio.gather over caller-supplied keys_and_values — and is left for its own issue rather than folded in here.

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