Skip to content

perf: read and encode GitHub upload payloads off the event loop - #1269

Merged
groupthinking merged 2 commits into
mainfrom
perf/offload-github-upload-reads
Aug 4, 2026
Merged

perf: read and encode GitHub upload payloads off the event loop#1269
groupthinking merged 2 commits into
mainfrom
perf/offload-github-upload-reads

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1268

Head sha: d07bcf3b57e8aef0e4cce5f5973fb38e9562f840
Base: 94b517c52d14ed4f9b3dd4b7cc92ea9d96a4e4e5

Outcome

DeploymentManager._upload_to_github fans out over every file in the generated
project with a Semaphore(10) and a single asyncio.gather. Each task then did
a synchronous open() / read() plus a CPU-bound base64.b64encode directly
on the event loop
. This PR moves both into one asyncio.to_thread hop.

before after
payload read thread event loop worker thread
N payload reads strictly serialised overlap, bounded by the existing 10 permits
aiohttp transport during a read stalled keeps servicing the other 9 uploads
bytes transmitted identical (asserted byte-for-byte)
CancelledError handling propagates propagates (unchanged)
public API / call signatures unchanged

What this does NOT do. It does not change the concurrency limit, the retry
behaviour, the request shape, the commit messages, the exclusion rules, or any
error semantics. It does not touch verify_project. It is behaviour-preserving:
the same bytes are read and encoded, only the scheduling of that work changes.

Why this is not a no-op

Fan-out fixes are worth measuring before shipping, because a fan-out over work
that never yields saves nothing. That is not the case here:

  • The surrounding code already declares concurrency as its intent — the
    Semaphore(10) and the gather over upload_tasks exist for no other reason.
    The blocking read defeated a design goal the file already states.
  • The offloaded callable is genuinely blocking: a real read() syscall plus a
    base64 encode over the full file contents.
  • The reads are independent — distinct paths, no shared state, no ordering
    requirement. Nothing forces serialisation.
  • test_concurrent_files_are_read_concurrently parks three reads on a shared
    3-way threading.Barrier. Serialised reads can never fill it. This test
    fails on pristine main and passes here, which is the empirical proof
    that the concurrency was previously unrealised.

The transport-stall argument

The fan-out runs inside async with aiohttp.ClientSession(). aiohttp's transport
is driven by the same event loop. So a synchronous read did not merely delay its
own upload — it also prevented the loop from reading responses for the other
in-flight PUTs already on the wire. Offloading restores forward progress for
uploads that had nothing to do with the file being read.

Risk

Low. One blocking region moved to a worker thread; no logic rewritten.

Three specific risks were considered, and each is addressed:

  1. A new cancellation window. Pristine upload_single_file had no await
    between async with semaphore and session.put, so it was uninterruptible
    there. await asyncio.to_thread(...) introduces a yield point. This is safe
    because the offloaded callable is pure: it opens a file, reads, encodes,
    and returns a str. The with block closes the handle regardless, and
    nothing is written to disk, so a cancelled task leaves no partial state
    to reconcile. This is materially different from an offloaded routine that
    creates files or directories, where cancellation can leak artifacts.
  2. Executor sizing. asyncio.to_thread delegates to
    loop.run_in_executor(None, ...), i.e. the default ThreadPoolExecutor,
    sized min(32, (os.cpu_count() or 1) + 4). Stating this precisely rather
    than optimistically: on a 2-core runner that is 6 workers, which is fewer
    than the 10 semaphore permits
    , so on small hosts the executor — not the
    semaphore — is the binding constraint and effective read concurrency is 6.
    That is not a failure mode: to_thread simply queues the excess, the reads
    do not wait on one another, so the work drains without deadlock, and 6-way
    concurrency is still strictly better than the 1-way serialisation on main.
    The honest floor is 5 concurrent reads on a 1-core host. The one genuine
    caveat is that these reads share the default executor with any other
    to_thread work in the process, so a long-lived blocking task elsewhere
    would queue them behind it — a pre-existing property of the default executor
    rather than something this PR introduces.
  3. Cancellation being swallowed. asyncio.CancelledError derives from
    BaseException, not Exception
    (CancelledError.__mro__ == (CancelledError, BaseException, object)), so the
    pre-existing except Exception as e cannot absorb it. This holds by
    construction
    rather than by new code, and is pinned by a regression test.

asyncio.gather here is called without return_exceptions=True, but
upload_single_file catches Exception internally, so only BaseException
ever propagates out of it. That is unchanged by this PR.

Deliberately out of scope

  • The rglob("*") collection loop that builds upload_tasks still walks the
    tree synchronously. It is a directory scan, not file content I/O, and is a
    separate change with a different risk profile.
  • uploaded_files.append(...) is correct as-is — coroutines on a single loop
    do not interleave between statements, so no lock is needed. Noting this
    explicitly so it is not "fixed" later.
  • intelligent_cache.py::warm_cache has an unbounded gather; tracked
    separately, out of scope here.

Verification

Prove-fail harness — fixed source copied aside, git show origin/main:<path>
restored in place, tests run, source restored, diff -q confirming exact
restoration:

suite pristine main this PR
TestUploadToGithubOffLoopReads (5 tests) 2 failed, 3 passed 5 passed
tests/unit/test_deployment_manager.py (full) 110 passed

The 2 failures on main are the differential tests. The other 3 are guards that
correctly pass both ways and are labelled as such:

  • test_file_read_runs_off_the_event_loop_threaddifferential. Patches
    open in the module namespace, records threading.get_ident(), asserts it is
    not the loop thread. Proof is by thread identity, never wall-clock timing,
    so it cannot flake under CI load. Deliberately does not reference the new
    helper by name, so against main it fails on the assertion rather than
    erroring on an import.
  • test_concurrent_files_are_read_concurrentlydifferential. The 3-way
    barrier described above. The barrier wait is bounded (timeout=5) and the
    whole call is wrapped in asyncio.wait_for(..., timeout=30), so it can never
    hang the suite on main; it fails cleanly instead.
  • test_uploaded_payload_is_byte_identical — guard. Round-trips
    bytes(range(256)) plus embedded NUL and 0xff, asserting the transmitted
    content base64-decodes to the original bytes.
  • test_unreadable_file_does_not_abort_siblings — guard. One unreadable file
    must not cancel the fan-out.
  • test_cancellation_is_not_swallowed — guard. Pins risk 3 above.

Lint parity (no new findings introduced, compared finding-by-finding with
position columns normalised):

PARITY OK :: src/youtube_extension/backend/deployment_manager.py
PARITY OK :: tests/unit/test_deployment_manager.py

The repository's CI lint command reports 2 pre-existing errors in
backend/deploy/__init__.py and backend/services/data_service.py. Both are
present on main and are untouched by this PR; the finding sets before and
after are byte-identical.

Production evidence

Not applicable. This change touches a backend path that is not exercised by the
Vercel preview deployment, and it is behaviour-preserving — the same bytes are
read and encoded, only the scheduling of the work changes. Correctness is
covered by the 5 focused unit tests above rather than by a runtime deployment.

Agent handoff

Reviewers are asked to challenge four specific claims rather than the diff shape:

  1. Is one combined thread hop for read+encode better than two separate hops?
  2. Does the new to_thread await point introduce a harmful cancellation
    window, given the callable writes nothing to disk?
  3. Is the aiohttp transport-stall claim real, or would the loop have serviced
    the other responses anyway?
  4. I originally claimed the default executor bounds this fan-out at 10 and
    "cannot be exhausted". That was wrong and I corrected it above:
    min(32, (os.cpu_count() or 1) + 4) is 6 on a 2-core runner, below the
    10 permits. Please sanity-check my revised claim that this is a throughput
    ceiling (6-way instead of 10-way) rather than a correctness problem.

`_upload_to_github` fans out over every project file with a
`Semaphore(10)` and a single `asyncio.gather`, but each task performed a
synchronous `open()`/`read()` plus `base64.b64encode` directly on the
event loop. That serialised all N payload reads and, because the fan-out
runs inside a shared `aiohttp.ClientSession`, also stalled the transport
so the other in-flight uploads could not be serviced.

Move the read and the encode into one `asyncio.to_thread` hop via a new
module-level `_read_and_encode_file` helper. Only the encoded `str`
crosses back to the loop; the file handle is closed by the `with` block
even if the awaiting task is cancelled, and nothing is written to disk,
so the new await point introduces no recoverable state to leak.

Adds 5 regression tests, 2 of which fail against pristine main.

Closes #1268

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 2, 2026 19:10
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1a73001e-d0db-41dc-acd5-9b9dad9bc716

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 2, 2026 7:20pm

@github-actions github-actions Bot added the python label Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA a1fde16.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: NOT_APPLICABLE

Evidence agrees.

Machine-readable verdict
{
  "details": {},
  "reasons": [],
  "verdict": "not_applicable"
}

Workflow evidence

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Offloads GitHub upload file reading and Base64 encoding from the asyncio event loop.

Changes:

  • Adds a synchronous read-and-encode helper executed via asyncio.to_thread.
  • Adds regression tests for concurrency, payload integrity, failures, and cancellation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/youtube_extension/backend/deployment_manager.py Offloads upload payload preparation.
tests/unit/test_deployment_manager.py Tests upload scheduling and behavior.

Comment thread tests/unit/test_deployment_manager.py
Comment thread tests/unit/test_deployment_manager.py

Copy link
Copy Markdown
Owner Author

Shepherd disposition: verified green — awaiting human merge approval.

Independent verification of head d07bcf3:

  • All code-quality & security gates pass: CI, CodeQL, Coverage, Security Scan, Secret Scan, PR Checks, PR Governance, Copilot Code Review, Dependency Review, and the agent-completion truth-gate (not_applicable: all rules passed). E2E repo-skipped.
  • The only red mark is branch-cleanup.yml — a push-triggered maintenance workflow reporting 0 failed jobs (workflow-level/infra failure). It does not touch this diff (deployment_manager.py + its test) and is not a code signal for this PR.

Red-teamed the four claims you asked reviewers to challenge; all hold:

  1. Combined read+encode hop — correct; one executor round-trip, and the raw bytes never cross back to the loop (only the encoded str does).
  2. No harmful cancel window — the offloaded callable only reads, closes its handle via with, and writes nothing, so a cancelled task leaves no partial disk state to reconcile.
  3. Transport-stall is real — aiohttp's transport shares the loop, so a synchronous read blocked response servicing for the other in-flight PUTs, not just its own upload.
  4. Executor ceiling — correct: min(32, (os.cpu_count() or 1) + 4) = 6 on a 2-core runner, below the 10 permits; that's a throughput ceiling (still ≥5-way), not a correctness problem. to_thread queues the excess without deadlock since the reads don't await one another.

No actionable findings; no fix commit needed.

Not auto-merging. main is protected and this PR carries no automerge label, so the publish step needs your sign-off. Staged command:

gh pr merge 1269 --repo groupthinking/EventRelay --squash

Generated by Claude Code

Closes the two test-completeness gaps the Copilot reviewer flagged on
#1269. The existing off-loop test recorded only the thread that *calls*
open(); a split implementation that offloaded open() but ran read() or
base64.b64encode() back on the event loop would have passed the whole
suite while violating the acceptance criterion.

Add test_read_and_encode_both_run_off_the_event_loop_thread: it wraps the
returned handle to record the thread executing read(), patches the
module's base64.b64encode to record the thread executing the encode, and
asserts neither is the loop thread. Verified differential — it fails on
pristine main (read/encode run on the loop thread) and passes on this
branch. Source file is untouched; full module now 111 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8cg5SDVdhhcZGEaq2ZGkj

@groupthinking groupthinking left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — perf: read and encode GitHub upload payloads off the event loop

Reviewed the diff at d07bcf3 plus the full _upload_to_github context. Took the author up on the four challenge claims and probed them adversarially rather than eyeballing the diff shape. All four hold; the two Copilot inline findings are valid test-completeness gaps and I've closed them (a1fde16).

The four claims

  1. One combined read+encode hop vs. two hops — combined is correct and better. A single to_thread for _read_and_encode_file avoids a second executor round-trip and, as the docstring notes, keeps the raw bytes from ever crossing back to the loop — only the encoded str does. The helper is pure (open → read → encode → return str, handle closed by the with in-thread).

  2. No harmful cancellation window. The new await asyncio.to_thread(...) is a genuine new yield point, but the offloaded callable writes nothing to disk, so a cancelled task leaves no partial artifact to reconcile. On task.cancel() the worker thread runs to completion and its result is discarded — a wasted read, not a leak. Safe.

  3. The aiohttp transport-stall argument is real. A synchronous read() on the loop thread blocks the entire loop, including aiohttp's transport servicing responses for the other in-flight PUTs — not just its own upload. Offloading restores forward progress for the siblings.

  4. Executor sizing is a throughput ceiling, not a correctness problem. Confirmed: asyncio.to_threadrun_in_executor(None, …) → default ThreadPoolExecutor(min(32, (os.cpu_count() or 1) + 4)) = 6 on a 2-core runner, below the 10 permits. Excess reads queue and drain (no inter-read dependency, no deadlock); 6-way still beats the 1-way serialisation on main. The self-correction is accurate, including the honest caveat that the default executor is shared process-wide — though in this flow verify_project's subprocess.run offloads run before the upload, not concurrently, so they don't contend here.

Copilot's two inline findings — valid, addressed in a1fde16

Both were about test precision, not code defects: the suite pinned the thread of the open() call but not of read() or base64.b64encode(), so a hypothetical split implementation could pass while violating the acceptance criterion. For the current single-function helper the existing tests are sound, but the gap is worth closing against future refactors. Added test_read_and_encode_both_run_off_the_event_loop_thread, which wraps the returned handle to record the read() thread and patches the module's b64encode to record the encode thread, asserting neither is the loop thread. Verified differential: it fails on pristine main (read ran on the event loop thread) and passes here. Full module: 111 passed; source file untouched; ruff clean.

CI

Green on d07bcf3: test, lint-python, bandit, python-safety, both Security Scan jobs, CodeQL, dependency-review, agent-completion/truth-gate (NOT_APPLICABLE), Copilot reviewer — all success; coverage was the only job still running, no failures.

Assessment: mergeable on the merits. I can't formally approve my own account's PR, and main is protected with no automerge label, so this is left for human merge sign-off — squash once the new commit's CI settles.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Status: green — HALTED at the publish gate awaiting human merge approval.

Automated remediation pass (scheduled, unattended) reviewed this PR:

  • CodeRabbit: Review approved
  • agent-completion/truth-gate/pr-1269: success (not_applicable: all rules passed)
  • Copilot review threads: both resolved with genuine follow-up coverage in a1fde16
    (the two differential tests now pin read() and base64.b64encode to a worker
    thread, closing the "records open()'s thread only" and "encode not pinned" gaps)
  • Vercel: deployment READY

No automerge label is present and main is protected, so per policy this is not
auto-merged autonomously. Staged merge command for a human to run once approved:

gh pr merge 1269 --repo groupthinking/EventRelay --squash --delete-branch

Note: commit d07bcf3 from this branch was briefly carried by #1270; it has since
been dropped there, so this PR is the sole, canonical home for the upload-offload change.


Generated by Claude Code

@groupthinking
groupthinking marked this pull request as draft August 3, 2026 13:17

Copy link
Copy Markdown
Owner Author

Daily-control containment: returned this PR to draft at its exact current head. Focused issue #1268 is linked, but ready state preceded reconciliation of the required active-agent receipt, exact-head independent review, and deployment evidence. No code or branch was discarded.

@groupthinking
groupthinking marked this pull request as ready for review August 4, 2026 01:38
@groupthinking
groupthinking merged commit 76ed07f into main Aug 4, 2026
46 of 49 checks passed
@groupthinking
groupthinking deleted the perf/offload-github-upload-reads branch August 4, 2026 01:38
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

GRV-284

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants