perf: extract transcript chunks with bounded concurrency - #1338
Conversation
/api/v1/events/extract splits long transcripts into overlapping 24k-char
chunks and extracted them strictly one at a time:
for chunk in transcript_chunks:
for ev in await _extract_chunk(chunk):
Each _extract_chunk call is an independent, billed Gemini round-trip, so
wall-clock latency was the sum of every chunk's provider latency. A ~90
minute podcast produces ~6 chunks; a 4 hour stream ~22.
The chunks are mutually independent -- nothing in _extract_chunk reads
state produced by a previous chunk -- so walk them in bounded windows of
_EXTRACT_CONCURRENCY (4) via asyncio.gather instead.
Correctness is preserved exactly:
* asyncio.gather returns results in *input* order, so merging a window's
results reproduces the serial walk's dedup and _MAX_EVENTS cut-off for
every chunk actually processed.
* The _MAX_EVENTS budget is re-checked between windows, so extraction
still stops early once it is full.
* _extract_chunk catches Exception and returns [], so the default
return_exceptions=False can never abort a sibling chunk.
Tradeoff: the serial loop stopped issuing calls the instant the budget
filled, whereas a window of 4 can issue up to 3 extra billed Gemini
calls past that point. The window is deliberately small and the budget
is re-checked between windows to bound that overshoot.
Closes #1337
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 |
|
@linear-code @coderabbitai review Three specific things I'd like challenged rather than a general pass:
Note on the prove-fail split: 3 failed / 3 passed against pre-change source is expected. |
Agent Completion Truth Gate: NOT_APPLICABLEEvidence agrees. Machine-readable verdict{
"details": {},
"reasons": [],
"verdict": "not_applicable"
} |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- PR diff (target files) ---'
git diff --find-renames origin/main...HEAD -- \
src/youtube_extension/backend/api/v1/router.py \
tests/unit/test_events_extract_concurrency.py
printf '%s\n' '--- router outline ---'
ast-grep outline src/youtube_extension/backend/api/v1/router.py \
--items all --type function --match 'extract_events|_extract_chunk' || true
printf '%s\n' '--- handler and helper references ---'
rg -n -A100 -B15 \
'async def extract_events\b|async def _extract_chunk\b|_EXTRACT_CONCURRENCY|asyncio\.gather|CancelledError' \
src/youtube_extension/backend/api/v1/router.py
printf '%s\n' '--- new test outline and contents ---'
ast-grep outline tests/unit/test_events_extract_concurrency.py --items all || true
cat -n tests/unit/test_events_extract_concurrency.py
printf '%s\n' '--- project Python support metadata ---'
rg -n -C2 'python_requires|requires-python|target-version|python_version' \
pyproject.toml setup.cfg setup.py tox.ini 2>/dev/null || trueLength of output: 23690 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- checkout status and changed paths ---'
git status --short
git diff --name-status origin/main...HEAD
git ls-tree -r --name-only HEAD | rg '(^|/)test_events_extract_concurrency\.py$' || true
printf '%s\n' '--- router imports ---'
sed -n '1,150p' src/youtube_extension/backend/api/v1/router.py | nl -ba
printf '%s\n' '--- Python and tool configuration files ---'
fd -HI -t f '^(pyproject\.toml|setup\.cfg|setup\.py|tox\.ini|\.python-version|Pipfile|poetry\.lock)$' . \
-E .git -E node_modules -E dist -E build \
| while IFS= read -r file; do
echo "--- $file ---"
rg -n -C2 'requires-python|python_requires|python_version|target-version|python' "$file" || true
done
printf '%s\n' '--- cancellation-specific handlers or task creation in extraction dependencies ---'
rg -n -C3 \
'CancelledError|create_task\s*\(|asyncio\.shield\s*\(|asyncio\.wait_for\s*\(' \
src/youtube_extension/services src/youtube_extension/backend 2>/dev/null || trueLength of output: 1565 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- router imports from HEAD ---'
git show HEAD:src/youtube_extension/backend/api/v1/router.py | sed -n '1,150p'
printf '%s\n' '--- committed concurrency test key assertions ---'
git show HEAD:tests/unit/test_events_extract_concurrency.py \
| grep -n -E -A8 -B4 \
'test_(chunks_are_extracted_concurrently|concurrency_is_bounded|events_are_merged_in_chunk_order|event_budget_is_rechecked_between_windows|one_failing_chunk_does_not_abort_siblings)|CancelledError|gather'
printf '%s\n' '--- Python metadata ---'
find . -path ./.git -prune -o \
\( -name pyproject.toml -o -name setup.cfg -o -name setup.py -o -name tox.ini -o -name .python-version -o -name Pipfile \) \
-type f -print \
| while IFS= read -r file; do
echo "--- $file ---"
grep -n -E -C2 'requires-python|python_requires|python_version|target-version|python' "$file" || true
done
printf '%s\n' '--- cancellation handling in source ---'
grep -R -n -E -C3 \
'CancelledError|create_task[[:space:]]*\(|asyncio\.shield[[:space:]]*\(|asyncio\.wait_for[[:space:]]*\(' \
src/youtube_extension/services src/youtube_extension/backend 2>/dev/null || trueLength of output: 56419
I consider this acceptable only if the product accepts that bounded direct-provider cost increase. The bound is three calls, not a fixed currency amount. A 24,000-character chunk can have a non-trivial per-call cost. The PR documents this trade-off clearly. I would keep
The claim is correct, with one precise condition: it holds for the same per-chunk results.
A chunk that the serial loop would skip after the budget fills can run in the same concurrent window. It cannot contribute an event because the merge condition still requires
It is not correct to say that This behavior is appropriate for request cancellation. Do not replace the call with I recommend changing the adjacent comment to say that I verified the changed source and committed tests statically. I could not run the test command in this inspection sandbox. ✅ Action performedReview finished.
|
There was a problem hiding this comment.
Pull request overview
Adds bounded concurrency to reduce long-transcript event extraction latency while preserving ordered merging and event limits.
Changes:
- Processes transcript chunks concurrently in windows of four.
- Adds regression tests for concurrency, ordering, limits, failures, and short transcripts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/youtube_extension/backend/api/v1/router.py |
Adds bounded concurrent chunk extraction. |
tests/unit/test_events_extract_concurrency.py |
Tests scheduling and preserved behavior. |
groupthinking
left a comment
There was a problem hiding this comment.
Independent review — no blocking defects
I read the windowed loop and the full _extract_chunk body at 03d7ebe and verified your three challenges against the source directly. All three hold. One non-blocking comment-accuracy nit.
1. ≤3 billed-call overshoot — acceptable and correctly bounded.
The budget is re-checked at the top of every window (if len(events) >= _MAX_EVENTS: break), so overshoot cannot compound across windows; it is confined to a single window, worst case _EXTRACT_CONCURRENCY - 1 = 3 extra calls when the budget fills on the first chunk of a window. The bound is on calls, not cost, and a 24k-char chunk is a non-trivial Gemini round-trip — but that's a disclosed product tradeoff, not a correctness issue, and _EXTRACT_CONCURRENCY = 4 is the right knob if the worst case ever needs halving. Keep 4 for the stated latency target.
2. gather order-preservation ⇒ identical selection — confirmed.
asyncio.gather returns in input order; chunks are passed in transcript order and window_results is merged in that same order, with the merge guard ev.title not in seen_titles and len(events) < _MAX_EVENTS unchanged. I specifically checked the budget-fills-mid-window case you flagged: a chunk the serial walk would have skipped after the budget filled can now execute in the same window, but it contributes zero events because the len(events) < _MAX_EVENTS guard still gates every append. So the selected event set is identical to the serial walk — only billed-call count differs. No path where a would-be-skipped chunk leaks an event.
3. return_exceptions=False is safe — but tighten the adjacent comment.
Verified _extract_chunk ends in except Exception as exc: logger.warning(...); return chunk_events, so every ordinary provider/parsing/construction failure is swallowed and the default return_exceptions=False has no ordinary-exception path to abort a sibling. Do not switch to return_exceptions=True — that would turn a child CancelledError into a result value and swallow request cancellation, which you correctly want to propagate (CancelledError is BaseException on 3.10+).
The one imprecision is the inline comment, which slightly overstates on two counts:
# _extract_chunk never raises (it catches Exception and returns []), so the
# default return_exceptions=False cannot abort a sibling chunk.
- "never raises" → true only for
Exception;CancelledError/KeyboardInterrupt/SystemExit(BaseException) still propagate, and that's the intended behavior. - "returns []" → it returns
chunk_events, which on a mid-parse failure holds the events accumulated before the exception, not necessarily an empty list.
Suggested wording:
# _extract_chunk isolates ordinary Exception failures (logs and returns
# whatever it parsed so far), so the default return_exceptions=False cannot
# abort a sibling chunk on a provider error. A BaseException such as
# CancelledError still propagates, which correctly cancels the request.
Non-blocking — behavior is already correct; this only makes the invariant the comment claims match what the code guarantees. If request-cancellation is part of the endpoint contract, a cancellation test would lock that in, but that's optional and out of scope for this PR.
Tests: the 6 new tests exercise the real mounted route through TestClient(app) with a concurrency-aware stand-in that records peak in-flight — max_inflight > 1 is genuinely unreachable under a serial loop, so test_chunks_are_extracted_concurrently / test_concurrency_is_bounded are real proofs, and invert_latency makes test_events_are_merged_in_chunk_order fail loudly under a completion-ordered merge. The 3-fail/3-pass prove-fail split is exactly what a correct change should produce.
Verdict: correct, well-scoped, well-tested. Ready to merge once un-drafted for merge sign-off — I'm not merging automatically, since main is protected and this is an unattended run with no live human approval. Nothing here should block that sign-off.
Generated by Claude Code
CodeRabbit's targeted review flagged the inline comment on the asyncio.gather fan-out as inaccurate: it claimed _extract_chunk "never raises". _extract_chunk only catches ordinary Exception; asyncio.CancelledError (a BaseException) can still propagate, and that propagation is intended so request cancellation tears down the whole fan-out rather than being swallowed by return_exceptions. Comment-only change -- no behavioural change to the windowed extraction logic. return_exceptions=False is deliberately retained. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BU9egq4TCB34cqGCsPuvjX
|
Independent remediation pass — red-team of the diff on top of CodeRabbit's review. All three of your challenge points hold; I pushed one comment-only fix ( 1. ≤3-call overshoot — acceptable, and the bound is exactly as stated. A window can dispatch at most 2. 3. Optional, non-blocking follow-up: a test asserting a Status: truth-gate green, CI green, no conflicts, review addressed. This sits at the PUBLISH GATE — merging to protected Generated by Claude Code |
|
Evidence it isn't from #1338:
This is a separate If Generated by Claude Code |
|
CI status correction — the earlier "CI green" note above was premature. The This is not this PR's diff. The failure is a base-branch issue: The fix is owned elsewhere — the investigator-removal cleanup in draft PRs #1317 / #1320 (and the Publish gate unchanged: this still awaits your merge sign-off (no Generated by Claude Code |
Canonical issue
Closes #1337
Scope
One production file and one new test file.
src/youtube_extension/backend/api/v1/router.py— theextract_eventshandler only.tests/unit/test_events_extract_concurrency.py— new.No other handler, no signature change, no new dependency, no config surface.
Outcome
POST /api/v1/events/extractchunks long transcripts into overlapping 24 000-characterwindows and extracted them strictly one at a time:
Every
_extract_chunkcall is an independent, billed Gemini round-trip, so wall-clocklatency was the sum of all chunk latencies. Chunks are now walked in bounded windows
of
_EXTRACT_CONCURRENCY = 4viaasyncio.gather.Chunk count scales with transcript length (stride
_CHUNK_SIZE - _CHUNK_OVERLAP= 23 500):Single-chunk transcripts — the common case — are completely unaffected: one window, one
call, identical behaviour.
Risk
Correctness is preserved exactly, and each guarantee has a named mechanism:
asyncio.gatherreturns results in input order, not completion order. Merginga window's results therefore reproduces the serial walk's dedup and
_MAX_EVENTScut-offfor every chunk actually processed.
test_events_are_merged_in_chunk_orderproves this bygiving later chunks shorter latency, so a completion-ordered merge would visibly reverse
the output.
_extract_chunkends inexcept Exception as exc: logger.warning(...)and returns
chunk_events, so it never raises. The defaultreturn_exceptions=Falseconsequently has no path to abort a sibling chunk. Covered by
test_one_failing_chunk_does_not_abort_siblings.len(events) >= _MAX_EVENTSis re-checked at the top of every window.Disclosed tradeoff — up to 3 extra billed Gemini calls. The serial loop stopped issuing
calls the instant the 50-event budget filled. A window of 4 dispatches all 4 before the
budget can be re-checked, so extraction can issue up to
_EXTRACT_CONCURRENCY - 1= 3more billed calls than before on transcripts that fill the budget early. This is deliberate:
the window is kept small precisely to bound that overshoot, and the budget is re-checked
between windows so it cannot compound.
test_event_budget_is_rechecked_between_windowsasserts the bound explicitly (≤ 4 calls, not the full 12-chunk walk).
Peak in-flight billed calls rises from 1 to at most 4 per request, which is why the fan-out
is windowed rather than a single unbounded
gatherover every chunk.Verification
New tests: 6 passed.
Pre-existing
extract_eventscoverage: 146 passed, unchanged.Prove-fail — new tests run against the pre-change source via
git stash push -- src/youtube_extension/backend/api/v1/router.py:3 failed, 3 passed.
The 3 failures are exactly the concurrency assertions the serial loop cannot satisfy:
test_chunks_are_extracted_concurrently,test_concurrency_is_bounded,test_events_are_merged_in_chunk_order.The 3 that pass are deliberate regression guards, not weak proof — they assert
behaviour the serial loop already had (budget respected, failures isolated, single-chunk
path unchanged) and exist to prove this PR does not break it.
ruff checkonrouter.pyreports 25 findings, identical toorigin/main— noneintroduced, none in the changed range.
ruff checkon the new test file: clean.Production evidence
src/youtube_extension/backend/api/v1/router.pyis on the live HTTP surface:backend/main.py:35importsv1_routerandmain.py:164callsapp.include_router(v1_router). The tests drive the real route throughTestClient(app)against the real FastAPI application — not the handler function inisolation — so the mounted path, request validation, and response envelope are all
exercised.
Concurrency is measured, not assumed: the stand-in
HybridProcessorService.processincrements an in-flight counter, records the peak, sleeps, and decrements in
finally.A serial loop can never record a peak above 1, so
max_inflight > 1is unambiguous.REAL_MODE_ONLY: the stand-in reports
backend="gemini"solely so the router's ownmock-rejection guard does not divert to the heuristic path. No assertion here concerns AI
output quality — every assertion is about the router's scheduling.
Agent handoff
Next in this perf series: batching per-metric SQLite writes in
ingest_performance_report_v1(PerformanceMonitor.record_metricopens, writes, commitsand closes one connection per metric, so one web-vitals report costs N fsyncs).