perf: enqueue Cloud Tasks batches concurrently - #1319
Conversation
enqueue_batch awaited a full gRPC round-trip per task, making batch latency N x RTT with peak in-flight of 1. Fan out through a bounded asyncio.gather so the queue's provisioned concurrency is actually used. Bound derives from the shared default thread pool width (min(32, cpu_count+4)) and takes at most half of it, capped at 8, so enqueueing never starves co-tenant asyncio.to_thread callers. Measured (real code, simulated RTT): 20ms/N=50 1338 -> 181 ms (7.4x). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure 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 FilesNone |
Agent Completion Truth Gate: NOT_APPLICABLEEvidence agrees. Machine-readable verdict{
"details": {},
"reasons": [],
"verdict": "not_applicable"
} |
|
@linear-code review Two areas I would most like a second opinion on:
Also disclosed in |
There was a problem hiding this comment.
Pull request overview
Improves Cloud Tasks batch enqueue latency through bounded concurrency while preserving ordering and failure isolation.
Changes:
- Adds semaphore-bounded concurrent enqueueing.
- Adds concurrency, ordering, failure, and cancellation tests.
- Makes success mocks request-keyed.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
cloud_tasks_queue.py |
Implements bounded concurrent batch enqueueing. |
test_cloud_tasks_queue.py |
Adds concurrency regression coverage. |
| omitting any task that failed to enqueue. | ||
| """ | ||
| task_ids = [] | ||
| semaphore = asyncio.Semaphore(_ENQUEUE_MAX_CONCURRENCY) |
There was a problem hiding this comment.
Valid observation — the per-call semaphore bounds fan-out within a batch but not across overlapping enqueue_batch calls, so two concurrent batches on the shared singleton could admit 2× the limit and re-approach pool saturation.
I'm deliberately not changing it in this PR. Moving to a service-shared limiter (or a dedicated bounded ThreadPoolExecutor for these RPCs) is a cross-call concurrency-model change beyond this PR's stated scope ("two files, no API/signature change"), and it carries its own design decisions — limiter lifecycle tied to the singleton, fairness across batches, and interaction with _run_sync_rpc's use of the default executor (a dedicated executor would also decouple it from the co-tenant pool entirely, which may be the better end state). That deserves its own change with its own measurement rather than being folded in here.
Recommend tracking it as a follow-up. Worth noting the practical exposure is bounded: it only bites when multiple batches overlap in the same process, and the single production caller (cloud_video_processor.py:288) is one await per request — so the regression this PR fixes (serial N×RTT) is real today, while the cross-call case is a latent edge. Deferring to the author on whether to spin up the follow-up issue.
Generated by Claude Code
groupthinking
left a comment
There was a problem hiding this comment.
Adversarial review pass on the diff — focused on the two areas you flagged, plus the concurrency correctness.
Concurrency bound (_ENQUEUE_MAX_CONCURRENCY) — checks out. min(8, max(2, min(32, (os.cpu_count() or 1) + 4) // 2)) evaluates to 8 on a 12-core host, 4 on a 4-vCPU Cloud Run instance (half of that host's 8-wide default to_thread pool), and floors at 2. The "at most half the pool" derivation is the right call: since _run_sync_rpc dispatches through the process-wide default executor, a hardcoded 8 would consume the entire pool on 4-vCPU and starve co-tenant to_thread callers. os and asyncio are both already imported, so no NameError at module load.
test_returns_all_task_ids_on_success harness change — agree this is a de-flake, not a weakening. The assertions (ids == ["t0","t1","t2"], call_count == 3) are preserved verbatim; only the mock's response source changed from call-order-popped (list side_effect) to request-keyed. Under concurrent fan-out, call order no longer implies input order, so the list mock was green only because it returned instantly — keying off video_id makes the test assert the id↔input mapping, which is the invariant that actually matters.
Correctness of the rewrite:
- Order preserved —
asyncio.gatherreturns positionally andzip(..., strict=True)keeps ids aligned withvideo_tasks;strictcan't trip since gather yields exactly one result per awaitable. - Error semantics preserved —
return_exceptions=Truethen re-raising non-ExceptionBaseException(CancelledError/KeyboardInterrupt) while log-and-skipping ordinaryExceptionfaithfully reproduces the originalexcept Exceptionloop without silently widening the catch. All tasks are complete by the time gather returns, so re-raising mid-iteration orphans nothing. - Empty batch →
gather()returns[]→ returns[].
No blocking findings from this pass. The disclosed-but-unfixed duplicate task_name case is correctly scoped out (equally broken serially).
Status: CI still in progress at review time (test and Generate and Upload Coverage running; everything else green, no failures). Leaving the merge decision to a human maintainer — main is protected and there's no automerge label, so this is not auto-merged. Once CI is green, squash merge is the staged next step.
Generated by Claude Code
… count test_batch_enqueues_concurrently asserted elapsed < serial_floor/2 (200ms for 8x50ms) while the fan-out bound derives from os.cpu_count(). On a single-CPU runner _ENQUEUE_MAX_CONCURRENCY floors at 2, so the batch takes four 50ms waves (200ms) and the strict '<' bound trips. Pin the bound to 'count' for this timing-only test so it measures that fan-out happens, independent of host CPU; the derived value is still exercised by test_batch_bounds_in_flight_concurrency. Addresses reviewer feedback on PR #1319 (copilot-pull-request-reviewer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kr2kmNRYfWdN49WGG5zz6d
|
CI status triage — the two red checks ( Both fail on a single unrelated test: ( Verified this is pre-existing on
The perf change's own suite is green: 7941 passed, including all of This is out of scope for #1319 and I haven't touched it. Flagging for a separate fix since it blocks the whole PR queue. Generated by Claude Code |
groupthinking
left a comment
There was a problem hiding this comment.
Second opinion on #1319 — concurrent Cloud Tasks enqueue
Requested review. Bottom line: the core change is correct and the diff is clean. One design point I'd resolve before merge (agreeing with Copilot's semaphore thread), one test-robustness nit, and a note on the red CI so you're not chasing a phantom.
Correctness of the concurrent rewrite — confirmed
asyncio.gatherreturns positionally, sozip(video_tasks, results, strict=True)re-aligns ids to input order. ✓ (strict=Trueis fine —requires-python = ">=3.10", and it's already used inwebsocket_service.pyandcloud_ai_routes.py.)return_exceptions=True+if not isinstance(result, Exception): raise resultfaithfully preserves the originalexcept Exceptionsemantics —CancelledError/KeyboardInterruptkeep propagating rather than being silently downgraded to skip-and-log. That's the subtle bit and it's right;test_cancellation_propagates_and_is_not_logged_as_failurecovers it.
Your Q1 — is half-the-pool the right ceiling?
The derivation min(8, max(2, min(32, (cpu+4))//2)) is sound; I verified the corners (1→2, 4→4, 12→8, 64→8). Half-the-pool is a defensible default. But see the next point: under concurrency the per-call semaphore doesn't actually hold the pool to that ceiling, which undercuts the "cannot starve co-tenants" invariant the derivation exists to protect. If that invariant is the goal, a dedicated bounded executor (your stated alternative) is the more honest mechanism than a semaphore — it bounds the pool itself rather than one caller's fan-out.
The one thing I'd change before merge — Copilot's cloud_tasks_queue.py:276 thread is correct
semaphore = asyncio.Semaphore(...) is created inside enqueue_batch, so two concurrent batches each admit up to _ENQUEUE_MAX_CONCURRENCY → 2× the bound, i.e. back to ~pool-width in flight, exactly the starvation the bound was derived to prevent. enqueue_batch is reachable concurrently (the batch path can invoke the singleton service more than once in flight). Hoist the limiter to instance scope (constructed once in __init__, shared across calls) or use the dedicated bounded executor. This also wants a regression test that overlaps two batches and asserts combined peak-in-flight stays ≤ bound — the current test_batch_bounds_in_flight_concurrency exercises a single batch, so it structurally cannot catch this.
Your Q2 — the test_returns_all_task_ids_on_success harness change
Reads as a legitimate de-flake, not a weakening. The assertion (ids == ["t0","t1","t2"], call_count == 3) is preserved verbatim; you swapped a completion-order-dependent list side_effect for a request-keyed one so the test asserts the id→input mapping instead of RPC completion order — precisely the property concurrency makes load-bearing. The 170/200-with-jitter measurement is the right way to justify it. No concern.
Test-robustness nit — Copilot's :1351 thread
test_batch_enqueues_concurrently asserts elapsed < 8*0.05/2 = 200 ms, but on a 1-vCPU runner the bound is 2, so the floor is 8×50 ms / 2 = 200 ms and the assertion becomes unsatisfiable → flake. Patch _ENQUEUE_MAX_CONCURRENCY (or delay/count) for this timing-only test so it measures fan-out independent of host CPU; test_batch_bounds_in_flight_concurrency already validates the real bound.
On the red CI (so you don't chase it)
The failing test/Coverage jobs are not from this diff. The sole test failure is test_gh_aw_workflow_governance.py::test_ci_investigator_requires_dedicated_codex_credential — FileNotFoundError: .github/workflows/eventrelay-ci-investigator.md, a governance test for a workflow file that isn't in the tree. Unrelated to cloud_tasks_queue.py (7941 passed, 1 failed); Coverage fails downstream of it. Looks pre-existing/environmental on main — worth confirming against a fresh main build, but not attributable to #1319.
task_name collision — agree with deferring
Equally broken serially, out of scope for a perf diff, and disclosed. Fine to leave — but please file it (or drop a # TODO(#…)) so the disclosure doesn't evaporate when this merges.
Net: correct and mergeable in substance. Before merge I'd (1) make the limiter instance/executor-scoped and add an overlapping-batch test, (2) fix the 1-CPU timing assertion, and (3) treat the red CI as an unrelated pre-existing break, not a blocker from this change.
Generated by Claude Code
Address review findings on the bounded enqueue fan-out. The semaphore was constructed inside `enqueue_batch`, so it bounded concurrency only within a single batch. `batch_process_videos_cloud` is a FastAPI endpoint that reaches `enqueue_batch` through the process-wide `get_cloud_tasks_service()` singleton, so N concurrent requests each built their own limiter and admitted N x the bound against the shared default thread pool -- the co-tenant starvation the bound exists to prevent. A new regression test reaches 16 concurrent RPCs against a limit of 8 on the pre-fix code. The limiter is now held on the service and keyed by event loop. `asyncio.Semaphore` binds to the loop that first awaits it *while contended*, so a plain `__init__` semaphore survives light use and then raises "bound to a different event loop" precisely once the bound saturates. Weak keys let finished loops be collected. Also pin `_ENQUEUE_MAX_CONCURRENCY` in the timing-only test: the bound is derived from `os.cpu_count()`, and on a 1-CPU host it resolves to 2, making 8 tasks take 4 waves x 50ms = 200ms and fail the `< 200ms` assertion exactly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Both review findings were real defects in my implementation. I verified each against the code rather than taking them on faith, and both held up. Fixed in 1. Per-call semaphore did not enforce the boundI traced the call chain to check the "concurrent calls" claim was actually reachable, rather than theoretical:
FastAPI serves concurrent requests on one loop, so N in-flight POSTs each built their own limiter and admitted N× the bound against the shared default thread pool. That is exactly the co-tenant starvation the bound exists to prevent, so my own stated rationale ("never take more than half the pool") was violated by the implementation. Added a regression test, 16 = the entire measured 16-worker pool. Passes after the fix. The limiter now lives on the service, keyed by event loop. Why keyed by loop, and not simply built in
|
| cpu_count | bound | waves (8 tasks) | elapsed | vs < 200 ms |
|---|---|---|---|---|
| 1 | 2 | 4 | 200 ms | FAIL |
| 2 | 3 | 3 | 150 ms | ok (tight) |
| 4 | 4 | 2 | 100 ms | ok |
| 12 | 8 | 1 | 50 ms | ok |
Exactly as described — a 1-CPU runner lands on 200 ms and trips the strict <. Note ee12e7ab8 landed the same fix in parallel while I was working; I rebased onto it and dropped my duplicate comment in favour of its docstring. test_batch_bounds_in_flight_concurrency reads the constant dynamically, so it still exercises the derived value and needed no change.
Verification
ruff check clean; 77 passed (76 + the new regression test).
@linear-code this is ready for your review — flagging again as I have not seen a reply on this PR yet. The functional change since your queue picked it up is that the enqueue limiter moved from per-call to service-scoped and per-event-loop; the fan-out semantics, ordering guarantees, and error handling are unchanged from the original diff.
CI status: the two red checks are pre-existing
|
Canonical issue
Closes #1318
Outcome
enqueue_batchsubmitted Cloud Tasks in a serialforloop, awaiting a full gRPCround-trip before starting the next. Batch latency was
N x RTTand peak in-flightwas 1, while the queue is provisioned for
max_concurrent_dispatches=50/max_dispatches_per_second=100— the client was the bottleneck, not the service.Now fans out through
asyncio.gatherbounded by a semaphore.Measured on the actual shipped code (not a simulation), median of 3, mock
create_tasksleeping for the stated RTT:Baseline column was produced by checking out
origin/main's version of the modulein place, running the same harness, then restoring — so both rows are the same
measurement against different code, not a model.
Why the bound is derived, not a literal
_run_sync_rpcdispatches viaasyncio.to_thread, which uses the process-widedefault executor sized
min(32, cpu_count + 4). Consuming all of it starves everyother
to_threadcaller in the process. Measured here (12 cores, pool widthconfirmed empirically at 16; N=50 @ 20 ms; "co-tenant" is an unrelated concurrent
to_threadcaller):Bound 16 is ~1.7x faster than 8 but inflates co-tenant worst-case latency 6.7x.
8 is the knee. A hardcoded
8would still be wrong on a 4-vCPU Cloud Run instance(
min(32, 4+4) = 8— the whole pool), hence the derivation: at most half the pool,capped at 8, floor 2. Evaluates to 8 on this host.
Risk
Result ordering — preserved.
asyncio.gatherreturns positionally, so returnedids stay aligned with
video_tasksinput order. Documented in the docstring.Error isolation — preserved.
return_exceptions=Trueplus per-task logging keepsthe original "skip the failure, continue the batch" behaviour. The original
except Exceptiondeliberately letCancelledError/BaseExceptionpropagate, sonon-
Exceptiongather results are explicitly re-raised rather than swallowed —without this,
return_exceptions=Truewould have silently widened the catch.Pre-existing latent bug, disclosed but NOT fixed here: if
task_config.task_nameis set, every task in a batch is created with the same name and Cloud Tasks rejects
the duplicates. This is equally broken serially; concurrency only changes which one
wins. Out of scope — deliberately not touched to keep this diff to the perf change.
Load on downstream: bound 8 is well inside the queue's own
max_concurrent_dispatches=50, so this cannot push the queue past its configured limit.Verification
76 passedintests/unit/test_cloud_tasks_queue.py.ruff checkclean on bothchanged files.
Prove-fail — the two new behavioural tests were run against pre-change source first:
443ms for 8 tasks(serial), now well under the concurrent boundpeak in-flight = 1, now > 1 and never exceeding the boundNew
TestEnqueueBatchConcurrency(5 tests) covers: elapsed-time speedup, peakin-flight stays within the bound, input-order alignment, per-task failure isolation,
and empty-batch handling.
One pre-existing test harness changed — please look here
test_returns_all_task_ids_on_successused a listside_effect, which pops incall order, and asserted against input order. Serially those coincide; concurrently
they need not. It passed 40/40 stress runs after the change, so I did not take that as
proof — I measured it:
side_effect(before)side_effect(before)So it was green only because the mock returns instantly. The fix keys each mock
response off the request's
video_id, making the test assert the id-to-input mappingrather than RPC completion order. The assertion itself is unchanged —
assert ids == ["t0", "t1", "t2"]andcall_count == 3are preserved verbatim. Thisremoves an ordering dependency that concurrency made load-bearing; it is not a
weakening, and it was not a break.
Production evidence
Caller:
src/youtube_extension/services/cloud/cloud_video_processor.py:288User-facing batch submission — before this change every additional video in a batch
added a full RTT to the response. This is the only production caller, so the blast
radius is exactly that path.
Scope
Two files. No API, signature, or return-type change. No new dependency (
osandasynciowere already imported).Agent handoff
@linear-code review — particularly the harness change in
test_returns_all_task_ids_on_success(Verification section) and the concurrency bound derivation (Outcome section).