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:
- The fan-out is defeated for the read phase. N file reads are serialised on the loop thread regardless of the semaphore.
- 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
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.
Problem
DeploymentManager._upload_to_githubfans out per-file uploads withasyncio.gatherbehind anasyncio.Semaphore(10), so the code is explicitly written to upload up to ten files concurrently. Each task then reads its payload with a blockingopen(...).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.
upload_single_fileopen(file_path, 'rb')+f.read()— synchronous disk readupload_single_filebase64.b64encode(content)— CPU-bound, scales with file sizeTwo consequences:
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.pyrather than by import edges alone:The deployed entrypoint is
youtube_extension.main:app(Dockerfile:93,infrastructure/docker/Dockerfile.production:72), andmain.py:192mounts the v1 router, so this path is live rather than library-surface only.The uploaded set is
project_path_obj.rglob("*")filtered byEXCLUDED_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
upload_single_fileexecute on a worker thread, not the event loop.Semaphore(10)bound on concurrent uploads is preserved.CancelledErrorcontinues to propagate rather than being swallowed by the per-file handler.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:Combining both operations into one offloaded callable avoids a second thread hop and keeps the large
bytesobject off the loop entirely — only the encodedstrcrosses back.Everything else is unchanged: the semaphore still bounds concurrency, the
PUTstill happens on the loop via aiohttp, and the per-fileexcept Exceptionstill 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:setset's only await is_evict_if_needed, which contains zeroawaitexpressions, so L1 never yieldsdeleteInMemoryCacheLayer.deletecontains zeroawaitexpressionsclearInMemoryCacheLayer.clearcontains zeroawaitexpressionsinvalidate_by_tagshasattr; onlyRedisCacheLayermatches, so the loop body runs onceget_comprehensive_statsget_statsisreturn self.stats(L130-132), inherited by both layers — zero awaits_promote_cache_entryrange(found_at_layer); with two layersfound_at_layer <= 1, so at most one iterationinitializeRedisCacheLayerdefinesconnect, so one real awaitAn
async defcontaining noAwaitnode never yields to the loop, so placing it inasyncio.gatheralongside 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 unboundedasyncio.gatherover caller-suppliedkeys_and_values— and is left for its own issue rather than folded in here.