perf: offload transcript download cleanup to a worker thread - #1245
Conversation
The `finally` block in `_fallback_transcript_with_gemini` deleted the downloaded video and recursively removed its temp tree directly on the event loop. `Path.exists`, `Path.unlink` and `shutil.rmtree` are all blocking syscalls, and the temp tree can hold a merged mp4 plus unmerged `.fNNN` fragments, so every request that reaches the Gemini video fallback stalls the loop for the duration of the delete. Move the cleanup into `_cleanup_download_artifacts`, a static helper that runs the same logic under `asyncio.to_thread`. The call is wrapped in `asyncio.shield` because the original inline code was uncancellable: a bare `await` in a `finally` can be interrupted by a second cancellation, which would turn a loop stall into a disk leak. Filesystem semantics are preserved verbatim, including the `exists()` guards, `except OSError` and `ignore_errors=True`. 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:
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe Gemini transcript fallback now delegates downloaded video and temporary-directory cleanup to a worker thread. Cancellation shielding allows cleanup to finish while the original cancellation is re-raised. ChangesTranscript fallback cleanup
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 6 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (6 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Warning Review ran into problems🔥 ProblemsThese MCP integrations need to be re-authenticated in the Integrations settings: Sentry Linked repositories: Public OSS repositories can only analyze public repositories installed in this organization. No linked repositories were analyzed; skipped 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 |
|
@linear Please review this one. Context: fourth in a series of event-loop offload changes (#1228, #1233, #1240 all merged). Same shape as #1240 but a narrower trigger surface. Three things I would specifically like challenged, because they are the parts where I made a judgement call rather than a mechanical transformation:
Verification summary: 113 passed. The 5 new tests were proved to fail against the previous behaviour by reducing the helper to a direct |
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"
} |
|
I don’t see a blocking issue here.
If you want to tighten the body, I’d phrase the claim as a moderate improvement to responsiveness on the Gemini file-fallback path rather than a general performance improvement. |
|
@coderabbitai review Please be adversarial. Specific attack surfaces I want probed:
|
|
I pushed on the failure modes you called out and don’t see a correctness bug in the production change.
The only thing I’d flag is a test nit: |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
src=$(fd -a '^transcript_action_workflow\.py$' . | head -n1)
test=$(fd -a '^test_transcript_action_workflow\.py$' tests | head -n1)
printf '%s\n' "SOURCE=$src" "TEST=$test"
ast-grep outline "$src" --items all --type function --match 'fallback_transcript_with_gemini|cleanup_download_artifacts'
ast-grep outline "$test" --items all --type function --match 'test_cleanup'
printf '\n--- changed files and focused diff ---\n'
git diff --find-renames origin/main...HEAD -- "$src" "$test"
printf '\n--- source workflow context ---\n'
nl -ba "$src" | sed -n '300,380p;680,890p'
printf '\n--- cleanup test context ---\n'
rg -n -A100 -B15 'def test_cleanup_(runs_off_event_loop|does_not_block_event_loop|removes_artifacts|survives_missing_paths|completes_when_task_cancelled)' "$test"Length of output: 1211 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- candidate files ---'
fd -a 'transcript_action_workflow\.py$' .
fd -a 'test_transcript_action_workflow\.py$' .
src=$(fd -a 'transcript_action_workflow\.py$' . | head -n1)
test=$(fd -a 'test_transcript_action_workflow\.py$' . | head -n1)
if [ -z "$src" ] || [ -z "$test" ]; then
echo "Could not locate both required files." >&2
exit 2
fi
printf '%s\n' "SOURCE=$src" "TEST=$test"
printf '\n--- source symbols ---\n'
ast-grep outline "$src" --items all --type function --match 'fallback_transcript_with_gemini|cleanup_download_artifacts' || true
printf '\n--- test symbols ---\n'
ast-grep outline "$test" --items all --type function --match 'test_cleanup' || true
printf '\n--- focused diff ---\n'
git diff --find-renames origin/main...HEAD -- "$src" "$test"
printf '\n--- source workflow context ---\n'
nl -ba "$src" | sed -n '300,380p;680,890p'
printf '\n--- cleanup test context ---\n'
rg -n -A100 -B15 'def test_cleanup_(runs_off_event_loop|does_not_block_event_loop|removes_artifacts|survives_missing_paths|completes_when_task_cancelled)' "$test"Length of output: 1569 🏁 Script executed: #!/bin/bash
set -euo pipefail
src='src/youtube_extension/services/workflows/transcript_action_workflow.py'
printf '%s\n' '--- changed paths ---'
git diff --name-status origin/main...HEAD
printf '\n%s\n' '--- test paths in the PR diff ---'
git diff --name-only origin/main...HEAD | rg -n '(^|/)(test_|.*_test)\w*\.py$|/tests?/' || true
printf '\n%s\n' '--- focused source diff ---'
git diff --find-renames origin/main...HEAD -- "$src"
printf '\n%s\n' '--- workflow source context ---'
nl -ba "$src" | sed -n '80,150p;300,365p;690,875p'
printf '\n%s\n' '--- relevant imports and direct helper references ---'
rg -n -C3 '^(import asyncio|from asyncio)|_cleanup_download_artifacts|_fallback_transcript_with_gemini|except Exception' "$src"Length of output: 4126 🏁 Script executed: #!/bin/bash
set -euo pipefail
src='src/youtube_extension/services/workflows/transcript_action_workflow.py'
test='tests/unit/test_transcript_action_workflow.py'
printf '%s\n' '--- source context from HEAD ---'
git show "HEAD:$src" | sed -n '300,365p;690,900p'
printf '\n%s\n' '--- cleanup tests from HEAD ---'
git show "HEAD:$test" | sed -n '/def test_cleanup_runs_off_event_loop/,/^[[:space:]]*def /p'
git show "HEAD:$test" | sed -n '/def test_cleanup_does_not_block_event_loop/,/^[[:space:]]*def /p'
git show "HEAD:$test" | sed -n '/def test_cleanup_removes_artifacts/,/^[[:space:]]*def /p'
git show "HEAD:$test" | sed -n '/def test_cleanup_survives_missing_paths/,/^[[:space:]]*def /p'
git show "HEAD:$test" | sed -n '/def test_cleanup_completes_when_task_cancelled/,/^[[:space:]]*def /p'
printf '\n%s\n' '--- Python version constraints ---'
fd -a -i 'pyproject.toml|setup.cfg|setup.py|tox.ini|\\.python-version|Pipfile' . -x sh -c '
echo "--- $1"
grep -nE "requires-python|python_requires|target-version|python_version|python =" "$1" || true
' sh {}
printf '\n%s\n' '--- source imports and local assignments in fallback ---'
git show "HEAD:$src" | grep -nE '^(import asyncio|from asyncio)|video_path[[:space:]]*=|temp_root[[:space:]]*=|except Exception|finally:' | head -n 100Length of output: 16968 🏁 Script executed: #!/bin/bash
set -euo pipefail
test='tests/unit/test_transcript_action_workflow.py'
printf '%s\n' '--- complete test diff ---'
git diff --find-renames origin/main...HEAD -- "$test"
printf '\n%s\n' '--- line numbers for cleanup tests ---'
git show "HEAD:$test" | grep -nE '^[[:space:]]*async def test_cleanup_(runs_off_event_loop|does_not_block_event_loop|removes_artifacts|survives_missing_paths|completes_when_task_cancelled)'
printf '\n%s\n' '--- numbered cleanup-test section ---'
git show "HEAD:$test" | awk '
/^[[:space:]]*async def test_cleanup_runs_off_event_loop/ {show=1}
show {printf "%6d %s\n", NR, $0}
show && /^[[:space:]]*async def / && $0 !~ /test_cleanup_(runs_off_event_loop|does_not_block_event_loop|removes_artifacts|survives_missing_paths|completes_when_task_cancelled)/ && NR > 1 {exit}
'
printf '\n%s\n' '--- imports used by cleanup tests ---'
git show "HEAD:$test" | sed -n '1,110p' | grep -nE 'asyncio|threading|pathlib|shutil|pytest'Length of output: 13599
High — the new The previous inline cleanup had no await point. The event loop could not deliver that cancellation until cleanup returned and the original exception had propagated. This conflicts with the stated requirement that raised-exception behavior does not change. If exact equivalence is required, catch cancellation around the shielded await, wait for the cleanup task to finish, and then restore the required exception precedence. Add a test where The remaining checks are sound:
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Pull request overview
Offloads Gemini transcript artifact cleanup from the event loop.
Changes:
- Adds an asynchronous cleanup helper using worker-thread execution.
- Adds cleanup, responsiveness, cancellation, and filesystem tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
transcript_action_workflow.py |
Moves artifact deletion off-loop. |
test_transcript_action_workflow.py |
Tests cleanup behavior and threading. |
groupthinking
left a comment
There was a problem hiding this comment.
Review — verified against source at 94329ada, not just the PR description.
Correct and safe. The change moves the finally-block cleanup in _fallback_transcript_with_gemini into _cleanup_download_artifacts, running the identical unlink/rmtree logic under asyncio.shield(asyncio.to_thread(...)). I checked the three things that could have made this subtly wrong:
CancelledErroris not swallowed. The upstream handler wrapping this call in_extract_transcriptisexcept Exception(~line 336), notexcept BaseException/bare — so the shieldedCancelledErrorpropagates to callers unchanged. ✔- The shield is load-bearing, not cargo-culted.
to_threadsubmits viarun_in_executor; if the thread pool is saturated the_cleanupjob sits queued, and an unshielded cancellation wouldconcurrent.futures.Future.cancel()it before a worker picks it up — leaking the multi-hundred-MB tree.shieldkeeps the queued job alive so cleanup still runs. The "always cleans up" guarantee genuinely holds. ✔ - Filesystem semantics preserved. The
exists()guards,except OSError: passonunlink, andignore_errors=Trueonrmtreeare all carried over verbatim; the addedis not Noneguards are strictly safer and unreachable on the real call path (video_pathis already guarded byif video_path:upstream). ✔
The new tests are non-vacuous — explicit call-count assertions and proved-to-fail against the old inline behaviour. Truth-gate and Vercel are green.
Not merging from automation: CodeRabbit's review is still in progress and merge to protected main is human-gated. Staged for a maintainer once CodeRabbit settles and the checklist's final human review is done:
gh pr merge 1245 --squash --repo groupthinking/EventRelay
No changes requested.
Generated by Claude Code
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/youtube_extension/services/workflows/transcript_action_workflow.py`:
- Line 852: Update the workflow’s file-result cleanup flow around
_cleanup_download_artifacts to track the active process_video exception before
processing and retain it until cleanup completes. When cleanup receives
asyncio.CancelledError, suppress it only if no earlier exception is active;
otherwise preserve and re-raise the original process_video exception. Add
coverage for process_video failing while blocked cleanup is cancelled, asserting
the process_video exception remains primary.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 5ab4496c-132e-4a2c-af97-21b86f8165f2
⛔ Files ignored due to path filters (1)
tests/unit/test_transcript_action_workflow.pyis excluded by!tests/**
📒 Files selected for processing (1)
src/youtube_extension/services/workflows/transcript_action_workflow.py
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
- GitHub Check: Generate and Upload Coverage
- GitHub Check: test
🧰 Additional context used
📓 Path-based instructions (10)
**/*.{py,js,jsx,ts,tsx}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.{py,js,jsx,ts,tsx}: Use Python 3.9+ and Node 18+ for development
Never hardcode API keys, database URLs, or secrets in code
Make minimal, surgical changes and avoid deleting working code unless fixing security issues
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.py
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.py: Always use type hints for Python functions
Use Black formatter with 88 character line length for Python code
Follow PEP 8 conventions for Python code
Use descriptive variable names and add docstrings to all public functions in Python
Group imports in Python: standard library, third-party, local
Use SQLAlchemy ORM and never write raw SQL queries
Use environment variables via os.getenv() or pydantic-settings to access configuration
Wrap database operations in try-except blocks and use context managers or FastAPI dependencies for connection cleanup
Implement comprehensive error handling with proper logging in all functions
Version APIs using /api/v1/ prefix for stability
Use JSON-RPC 2.0 protocol for all MCP communication
Follow the single-flow workflow: YouTube link → context extraction → agent dispatch → outputs
Use context managers for resource management in Python code
Implement comprehensive input validation and sanitize outputs for security
Use parameterized queries and SQLAlchemy ORM to prevent SQL injection
Define API request/response models using Pydantic for FastAPI endpoints
Store the single unified workflow as the only workflow; never introduce alternate flows or manual triggers
Use SQLAlchemy with connection pooling for database connections and manage sessions with context managers
Provide sensible defaults for non-sensitive configuration in Python settings
Maintain backward compatibility and do not break existing API endpoints
Include comprehensive logging for debugging in MCP implementations
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
⚙️ CodeRabbit configuration file
Python backend code. Check for type hints, proper exception handling, async context manager usage, and potential blocking calls in async functions. Flag any bare except clauses or missing timeout parameters on network calls. CRITICAL: Flag any file that contains placeholder/stub implementations — especially in unified_ai_sdk. Any class or function that says "TODO: Replace with production implementation" or returns mock/fake data must be flagged as a blocking issue. Flag any code generation output that reaches users without AST validation or syntax checking.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.{py,js,ts,tsx}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Maintain >80% code coverage for new features
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.{py,ts,tsx}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.{py,ts,tsx}: Keep frontend and backend data models synchronized using matching Pydantic (backend) and TypeScript (frontend) interfaces
Use type-safe interfaces for backend-frontend data exchange
**/*.{py,ts,tsx}: Production code must use real behavior only: no mock delays, fake data, or simulated responses.
Maintain strict type safety: mypy strict mode for Python and TypeScript strict mode for the frontend.
Name events using the<domain>.<entity>.<action>format.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*
📄 CodeRabbit inference engine (Custom checks)
**/*: Strictly verify that GitHub Copilot has explicitly reviewed and approved the pull request; human approvals alone must not satisfy this check.
Before allowing a merge, require thecopilot-rabbitlabel and AI-generated unit tests committed alongside the code changes; fail the check if either is missing.For Vercel-specific work, include
https://vercel.com/docs/llms-full.txtin the AI assistant context set.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.{py,pyw}
📄 CodeRabbit inference engine (AGENTS.md)
Write Python code to remain compatible with Linux and Windows where possible, including correct handling of
asyncioevent loops.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
src/**/*.py
📄 CodeRabbit inference engine (CLAUDE.md)
src/**/*.py: Format Python code with Black using an 88-character line length.
Sort Python imports with isort using the Black profile.
Run Ruff with E, W, F, I, B, C4, and UP rules; E501 is ignored.
Use mypy strict mode; untyped function definitions are disallowed.
Target Python 3.9 or newer.
Validate backend inputs with Pydantic and sanitize subprocess arguments.
Use Anthropic SDK featuresthinking={"type": "adaptive"}andoutput_config={"effort": "..."}withanthropic>=0.105.0; do not addTypeErrorfallbacks for these parameters.Use the service container dependency-injection pattern in
backend/containers/.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.{py,ts,tsx,js,jsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Do not commit secrets; store keys and credentials in gitignored
.envfiles.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.{py,pyi}
📄 CodeRabbit inference engine (GEMINI.md)
**/*.{py,pyi}: Use Black formatting with an 88-character line length for Python code.
Use Ruff with rules E, W, F, I, B, C4, and UP for Python linting.
Use strict mypy type checking; do not define untyped functions.
Target Python 3.9 or newer.
Validate Python inputs with Pydantic.
Sanitize subprocess arguments before execution.
Do not add mock delays or fake data to production Python code; production runs in REAL_MODE_ONLY.
Keep secrets out of Python source code; load keys and credentials from environment variables instead.
Use absolute imports that resolve withPYTHONPATH=srcin the Python backend.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
**/*.{py,pyi,ts,tsx}
📄 CodeRabbit inference engine (GEMINI.md)
**/*.{py,pyi,ts,tsx}: Preserve the single workflow: YouTube link → transcript → events → agents → outputs; do not introduce alternative flows or manual triggers that bypass it.
Use event names following<domain>.<entity>.<action>, such asyoutube.video.captured.
Make surgical, precise changes and do not delete working code without justification.
Files:
src/youtube_extension/services/workflows/transcript_action_workflow.py
🔍 Remote MCP GitHub Copilot
Relevant review context
- PR
#1245changesgroupthinking/EventRelayin one commit: production cleanup is moved toasyncio.to_thread, wrapped withasyncio.shield; five regression tests were added. The PR reports 113 focused tests passing, but the repository’s main test and coverage checks were still running when queried. - Issue
#1244explicitly requires off-loop cleanup, artifact removal despite cancellation, preserved behavior, and responsiveness tests. - Main correctness concern:
process_video()runs inside atry/finally. If it raises and cancellation arrives while the new shielded cleanup await is pending,CancelledErrorcan replace the originalprocess_videoexception. This differs from the prior synchronousfinally, which had no cancellation point. The PR currently has no test for this exception-precedence case. - The repository already contains
_run_sync_rpc, which creates an explicitto_threadtask, continues waiting after cancellation, and then re-propagates cancellation; its tests cover repeated cancellation and worker failures. This is a useful implementation pattern, though the desired precedence between cancellation and an already-unwinding exception should be tested explicitly here. - The event-loop responsiveness test polls for only five seconds before declaring that the worker never started. Since
asyncio.to_threaduses the shared default executor, executor saturation can make this flaky. - Preserving the
exists()guards,OSErrorhandling, static-method conversion, andNonehandling is consistent with the current implementation and linked issue’s stated behavior-preservation goal.
The event-loop liveness test polled a fixed 500 iterations while rmtree was parked on an unset Event. That is a ~5s budget expressed as an iteration count, so a loaded CI box that is slow to hand asyncio.to_thread a worker would fail the test for scheduling reasons rather than for the behaviour under test. Poll against time.monotonic() with a 30s deadline instead. The loop still exits as soon as the worker starts (milliseconds in practice), so the test is not slower; it simply stops being brittle under load. Also assert the tick count directly. Each completed tick is one turn of the event loop taken while the deletion was in flight, which is precisely the property being proven, and it was previously only implied by reaching the assertion at all. The polling is deliberately retained rather than replaced with a blocking wait on the Event: blocking the loop to wait for proof that the loop is not blocked would invert the test. Re-verified against pre-change semantics -- reducing the helper to a direct _cleanup() call still yields 3 failed, 2 passed, so this test is no weaker than before. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@linear Thanks — both points actioned. 1. Magnitude wording — taken verbatim. Your framing is more accurate than mine, so the I also adopted your 2. Test flakiness — fixed, but not the way you suggested, and I want to flag why. Your diagnosis is right: I did not take the literal suggestion of waiting on So I kept the polling and removed the brittleness instead: deadline = time.monotonic() + 30.0
ticks = 0
while not started.is_set():
assert time.monotonic() < deadline, "cleanup never started"
await asyncio.sleep(0.01)
ticks += 1
assert ticks >= 1, "event loop never yielded while cleanup was running"
assert not cleanup.done()A wall-clock deadline means a slow runner polls more times rather than giving up. It is not slower in practice — the loop still exits within milliseconds once the worker starts — it just stops failing under load. I also promoted the tick count to an explicit assertion; previously loop liveness was only implied by reaching the next line. Verified the hardening did not weaken the test. Re-ran the control experiment — patch the helper body from Same 3/2 split as before the change, with this test still among the failures. Full file If you still prefer the blocking-wait shape knowing it would no longer distinguish off-loop from on-loop execution, say so and I will reconsider — but I think the tick assertion is what makes this test worth having. |
|
I agree with the change you made. Keeping the polling loop and making The wording fixes also look right to me. No further changes from me. |
|
Independent verification of CodeRabbit's exception-precedence finding — it is valid; hold merge until addressed. I re-checked the actual try:
file_result = await gemini_service.process_video(...) # can raise E
...
finally:
await self._cleanup_download_artifacts(video_path, temp_root) # awaits asyncio.shield(to_thread(_cleanup))If Narrow trigger (requires both a raise from Note this supersedes the earlier "no changes requested" review on the same head — that pass verified Not merging from automation: Generated by Claude Code |
The cleanup helper runs from a `finally` block. Its `Path.exists()` probes performed a stat that can itself raise `OSError`, which would propagate out of the `finally` and replace the exception already in flight. Remove both probes. `unlink()` raises `FileNotFoundError` (an `OSError`, already caught) for absent paths and `rmtree(ignore_errors=True)` is a no-op, so the guards were redundant as well as unsafe. Also harden the cancellation test's poll loop to a wall-clock deadline instead of a fixed iteration budget, matching the sibling test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@coderabbitai Thanks — this was worth chasing down. I've shipped a fix for the masking vector, but via a different mechanism than you prescribed, because I could not reproduce a benefit from the prescribed one and measured concrete harm from it. Evidence first, verdict second. 1. The premise is off by one function
Zero The scenario is reachable, just not through the function you named — 2. The real masking vector was
|
| variant | cancelled during cleanup | caller sees | task.cancelled() |
|---|---|---|---|
| current | yes | CancelledError, __context__=Boom |
True |
| suppressed | yes | Boom |
False |
A genuinely cancelled task reporting cancelled() is False is the documented asyncio anti-pattern. Note also that no information is lost today: Python auto-chains the in-flight exception as __context__.
(b) It silently swallows asyncio.timeout.
| scenario | current | suppressed |
|---|---|---|
async with asyncio.timeout(0.1) |
TimeoutError ✅ |
Boom — timeout vanishes |
TaskGroup, sibling fails |
ExceptionGroup['ValueError'] |
ExceptionGroup['Boom','ValueError'] |
asyncio.timeout works by cancelling and converting CancelledError to TimeoutError. Swallow the cancellation and the timeout is gone.
(c) The production consequence. The caller is _extract_transcript:
# transcript_action_workflow.py:340
except Exception as exc: # noqa: BLE001 - resilient multi-source fallbackIt records the error and continues to the next transcript source. CancelledError is a BaseException, so today it escapes that handler and correctly aborts. Downgrade it to Boom (an Exception) and it is caught at L340 — the workflow keeps issuing network calls for a request the client already abandoned. That converts a clean abort into wasted quota.
4. On the reproducer
Three issues meant it wasn't exercising the claim:
try/except, nottry/finally— cleanup wasn't in the path under discussion.- The exception was swallowed before the cleanup await, so there was nothing left to mask.
- A coroutine function was passed to
asyncio.to_thread, so the "blocking cleanup" returned a coroutine instantly and never blocked.
Fixing those three flips the result. Happy to attach the corrected script if useful.
5. Summary
- ✅ Conceded and fixed: cleanup could mask an in-flight exception — via
exists(), now removed, with a prove-failed regression test. - ❌ Not adopted: suppressing
CancelledError. It breakstask.cancelled(), defeatsasyncio.timeout, pollutesTaskGroup, and would keep the pipeline running after cancellation. - Rationale is now recorded in the helper's docstring so this doesn't get re-litigated.
Cancellation semantics are unchanged from main; the only behavioural delta is that cleanup can no longer raise. Please re-review.
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
src='src/youtube_extension/services/workflows/transcript_action_workflow.py'
svc='src/youtube_extension/services/gemini_service.py'
test='tests/unit/test_transcript_action_workflow.py'
printf '%s\n' '--- changed files ---'
git diff --name-status origin/main...HEAD
printf '\n%s\n' '--- updated source and test diff ---'
git diff --find-renames origin/main...HEAD -- "$src" "$test"
printf '\n%s\n' '--- workflow symbols ---'
ast-grep outline "$src" --items all --type function --match 'fallback_transcript_with_gemini|cleanup_download_artifacts|parse_gemini_transcript_payload|extract_transcript'
printf '\n%s\n' '--- cleanup helper and fallback context ---'
nl -ba "$src" | sed -n '315,355p;800,985p'
printf '\n%s\n' '--- process_video context ---'
ast-grep outline "$svc" --items all --type function --match 'process_video'
nl -ba "$svc" | sed -n '720,850p'
printf '\n%s\n' '--- cleanup tests ---'
rg -n -A110 -B12 'async def test_cleanup_(runs_off_event_loop|does_not_block_event_loop|removes_artifacts|survives_missing_paths|completes_when_task_cancelled|does_not_mask_in_flight_exception)' "$test"Length of output: 14337 🏁 Script executed: #!/bin/bash
set -euo pipefail
src='src/youtube_extension/services/workflows/transcript_action_workflow.py'
svc='src/youtube_extension/services/gemini_service.py'
printf '%s\n' '--- _extract_transcript handler and Gemini fallback ---'
sed -n '307,355p;708,930p' "$src"
printf '\n%s\n' '--- transcript payload parser ---'
sed -n '927,985p' "$src"
printf '\n%s\n' '--- GeminiService.process_video ---'
sed -n '729,845p' "$svc"
printf '\n%s\n' '--- async test configuration ---'
fd -a -i 'pyproject.toml|pytest.ini|setup.cfg|tox.ini' . -x sh -c '
printf "\n--- %s ---\n" "$1"
grep -nE "asyncio_mode|pytest|pythonpath|testpaths" "$1" || true
' sh {}
printf '\n%s\n' '--- direct parser call and fallback call sites ---'
rg -n -C3 '\b_parse_gemini_transcript_payload\s*\(|\b_fallback_transcript_with_gemini\s*\(' "$src"Length of output: 15093 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- locate Gemini service and test configuration ---'
fd -a -i 'gemini_service.py|pyproject.toml|pytest.ini|setup.cfg|tox.ini' .
svc=$(fd -a -i '^gemini_service\.py$' . | head -n 1 || true)
if [ -n "$svc" ]; then
printf '\n--- GeminiService.process_video in %s ---\n' "$svc"
ast-grep outline "$svc" --items all --type function --match 'process_video'
grep -n -A130 -B8 'async def process_video' "$svc" || true
fi
printf '\n%s\n' '--- Python and pytest settings ---'
fd -a -i 'pyproject.toml|pytest.ini|setup.cfg|tox.ini' . -x sh -c '
printf "\n--- %s ---\n" "$1"
grep -nE "requires-python|python_requires|target-version|asyncio_mode|pytest" "$1" || true
' sh {}Length of output: 7668 🤖 web_search executed:
Length of output: 13314
Removing I also agree with the cancellation decision. The new await adds a cancellation-delivery point that the old synchronous cleanup did not have. This is an intentional and necessary consequence of moving cleanup off the event loop. It does not justify suppressing cancellation. No remaining production correctness issue found in this update. The updated test polling uses a wall-clock deadline and an explicit tick assertion. That removes the earlier fixed-iteration flake risk while preserving the event-loop responsiveness assertion. 🐇✅ You are interacting with an AI system. |
|
@linear-code Heads-up on a substantive change since your review — CodeRabbit flagged that the cleanup helper runs from a But the underlying concern was legitimate, just via a path CodeRabbit did not name: the two Both guards are removed. They were redundant anyway — Added Full file 114 passed; off-loop control experiment still gives the designed 3 failed / 3 passed; ruff parity clean on both files. Cancellation semantics are unchanged from The specific thing worth a second pair of eyes: is removing the |
|
@coderabbitai full review |
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 35 minutes. |
GREEN GATE cleared at
|
|
Exception-precedence finding — fix ready in #1247. The confirmed HIGH finding (a cancellation during the shielded cleanup Rather than push to this branch or open a competing PR against The fix captures any in-flight exception with This resolves the item the earlier "hold merge until addressed" note was waiting on. Merge remains a human decision. Generated by Claude Code |
The cleanup helper runs from a `finally`, so anything it raises replaces the exception already propagating. Its guards caught `OSError` only, but neither call is OSError-total: a NUL byte in a path makes `Path.unlink` raise `ValueError: embedded null character`, and makes `shutil.rmtree` raise the same from its internal `lstat` despite `ignore_errors=True` -- that flag suppresses `OSError` alone. Both branches now catch `Exception` and log at debug with `exc_info`. `CancelledError` is a `BaseException`, so cancellation still propagates. Not reachable in production today: `temp_root` comes from `mkdtemp` and a NUL `video_path` is already rejected by the `exists()` guard in `_download`. This corrects a false totality claim in the contract. Verified: reverting to the `OSError`-only guards fails the new test with the escaping `ValueError` (1 failed, 6 passed); restored 115 passed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
New commit The helper carried an inline comment asserting "Both branches are total." That assertion was false, so I tested it instead of trusting it:
Both branches now catch I want to be precise about what this is not. It is contract correctness, not a live bug fix. A NUL-byte path cannot reach this helper today: Regression test 7 uses no mocking — real NUL-byte paths, asserting the stdlib premise first so it fails if CPython ever changes. Prove-fail against the previous guards: Full file 115 passed. Off-loop control still @linear-code @coderabbitai — the PR body Outcome section was stale and is now rewritten; it had claimed the |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
src='src/youtube_extension/services/workflows/transcript_action_workflow.py'
test='tests/unit/test_transcript_action_workflow.py'
printf '%s\n' '--- latest commit ---'
git show --no-patch --format='%H%n%s%n%b' HEAD
printf '\n%s\n' '--- latest source and test diff ---'
git diff --find-renames HEAD^..HEAD -- "$src" "$test"
printf '\n%s\n' '--- cleanup helper and caller context ---'
ast-grep outline "$src" --items all --type function --match 'cleanup_download_artifacts|fallback_transcript_with_gemini'
nl -ba "$src" | sed -n '805,925p'
printf '\n%s\n' '--- null-byte regression test context ---'
rg -n -A120 -B15 'test_cleanup_.*(null|mask)|embedded null|exc_info|_cleanup_download_artifacts' "$test"
printf '\n%s\n' '--- cleanup-helper call sites ---'
rg -n -C3 '\b_cleanup_download_artifacts\s*\(' "$src"Length of output: 6573 🏁 Script executed: #!/bin/bash
set -euo pipefail
src='src/youtube_extension/services/workflows/transcript_action_workflow.py'
test='tests/unit/test_transcript_action_workflow.py'
printf '%s\n' '--- cleanup helper and fallback ---'
sed -n '850,925p' "$src"
printf '\n%s\n' '--- cleanup test class ---'
sed -n '1200,1435p' "$test"
printf '\n%s\n' '--- logger declaration and imports ---'
rg -n -C2 '(^logger\s*=|getLogger\(|^import logging|^from logging)' "$src"
printf '\n%s\n' '--- helper definition and all invocations ---'
rg -n -C4 'async def _cleanup_download_artifacts|_cleanup_download_artifacts\s*\(' "$src" "$test"Length of output: 4935 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- locate cleanup regression test ---'
rg -n -C25 'test_cleanup_is_total_for_non_oserror_failures|test_cleanup_does_not_mask_in_flight_exception' .
printf '\n%s\n' '--- locate test file candidates ---'
fd -a -i 'transcript.*workflow.*test|test.*transcript.*workflow' . || true
printf '\n%s\n' '--- cleanup-helper assertions and direct calls ---'
rg -n -C8 '_cleanup_download_artifacts\s*\(' . -g '*.py'Length of output: 798
The test uses real I found no production blocker in this change. One documentation detail remains. The helper docstring still says shielding preserves an “always cleans up” property. This is stronger than the corrected PR risk statement. Use “cleanup continues unless the process exits” if you want the source documentation to match the stated process-lifetime bound. ✏️ Learnings added
You are interacting with an AI system. |
The `_cleanup_download_artifacts` docstring asserted that shielding preserves an "always cleans up" property. That is stronger than the mechanism actually provides and stronger than the PR's own risk statement, which already bounds the guarantee at process lifetime. `asyncio.shield` prevents a cancellation delivered during the enclosing `finally` from skipping the cleanup await. It does nothing about SIGKILL, a hard crash, or interpreter shutdown landing before the worker thread finishes -- in any of those cases the temporary tree survives. No in-process mechanism can prevent that, so the docstring should not imply one exists. Reworded to "ran to completion once entered" and added an explicit paragraph naming the process-lifetime bound and the three cases that defeat it. No behaviour change: docstring text only. Raised by CodeRabbit on #1245 after it cleared the change of production blockers. Verified: 115 passed, ruff parity clean against origin/main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Dismissing review
|
|
Your plan includes PR reviews subject to rate limits. More reviews will be available in 16 minutes. |
Withdrawn by the reviewer: 'No remaining production correctness issue found in this update' (comment 5159325350) and 'I found no production blocker in this change' (comment 5159423771). Reviewer is rate-limited under its Fair Usage Limits Policy and cannot post a superseding review. Full rationale in comment 5159440862.
* perf: scan processed-video cache off the event loop GET /api/v2/videos/list is declared async but its whole body was blocking filesystem work: a stat, a directory glob, and one open()+json.load() per cached video, with no bound on entry count. The handler never awaited, so the loop was stalled for the full scan and no other request could be served. Extract the scan into a module-level _collect_processed_videos_sync() helper and dispatch it with asyncio.to_thread(), matching the pattern used in #1194, #1196, #1228, #1233, #1240, #1245 and #1251. The scan logic is moved verbatim, so the response payload, newest-first ordering, per-entry corrupt-file skip and empty-list fallbacks are unchanged. Measured on a 2,000-entry cache, peak event-loop stall drops from ~193 ms to ~2 ms. Scan wall time is unchanged: this is a latency and fairness fix, not a throughput one. Closes #1287 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * style: Black-format _collect_processed_videos_sync helper Normalize string quotes to double and wrap the dict-append and sort call in _collect_processed_videos_sync to satisfy the 88-char limit, addressing the CodeRabbit review on #1288. Behaviour-preserving: diff is confined to the new helper and the reformat is Black's own AST-equivalent output (verified with --target-version py311). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rG7vUAn6z9tXoEuA3dAqz * test: prove per-file cache read is off the event loop The thread-recording cache directory previously asserted only that exists()/glob() ran off-loop, and relied on the helper extraction to imply the per-entry open()/json.load() moved with them. glob() now yields path-like proxies whose __fspath__ records the calling thread. Because open() resolves a non-str argument through __fspath__, this captures the thread at the exact moment each blocking read starts, so the read is proven off-loop rather than inferred. Verified by reverting only the handler call site to the inline form: the new assertion fails independently with "blocking cache entry read ran on the event loop thread". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Head sha:
627ebe20ef504db3b760b4b8b03fc12657fe75cbCanonical issue
Closes #1244.
TranscriptActionWorkflow._fallback_transcript_with_geminiperforms its downloadcleanup inline in a
finallyblock.Path.exists,Path.unlinkandshutil.rmtreeare blocking syscalls, so the entire deletion runs on the eventloop and stalls every other coroutine in the process for its duration.
The tree being removed is not small.
_download_video_filerequestsbest[ext=mp4]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestwithmerge_output_format: mp4, so at cleanup time it can hold the merged outputplus unmerged
.fNNNfragments — potentially hundreds of megabytes spreadover several files, all unlinked while the loop is held.
Outcome
_cleanup_download_artifacts, a static helper that runs the identical logic insideasyncio.to_thread, so the loop stays free while the filesystem work proceeds.finally, so anything it raises would displace the exception already propagating. Two changes were needed, both found in review and both covered by regression tests.exists()probes.Path.exists()isstat()under a filter that re-raises any errno outside ENOENT/ENOTDIR/EBADF/ELOOP, so the probe itself could raise from inside thefinally.unlinkalready reports absent paths andrmtreealready tolerates them, so the probes bought nothing and cost totality.Exceptionrather thanOSErrorin both guards, logging atdebugwithexc_info. Neither call is OSError-total: a NUL byte in a path makesPath.unlinkraiseValueError: embedded null character, and makesshutil.rmtreeraise the same from its internallstatdespiteignore_errors=True— that flag suppressesOSErroralone.asyncio.shieldso cleanup still completes if the surrounding task is cancelled, matching the uncancellable behaviour of the code it replaces._download_video_file, which already does its own cleanup inside a worker thread.On removing the
exists()guardsMy first draft kept them, on the reasoning that dropping them is a behaviour change
rather than a performance change. Review showed that reasoning was wrong in one
specific way, and the correction is worth stating plainly.
The guards are not neutral inside a
finally.Path.exists()callsstat()andre-raises any errno outside the ignored set, so on a permission or I/O error the
probe raises from the cleanup path and replaces whatever exception was already
travelling. That is a correctness bug, and it was pre-existing — moving the block
off-loop neither introduced nor fixed it, but it sits inside the code this PR is
rewriting, so it is fixed here rather than left behind.
One real behaviour difference follows, and it is intentional: a broken symlink
reports
exists() is Falseand survives today, but is now unlinked. That is thecorrect outcome for a routine that exists to delete the download tree, and the
symlink lives inside
temp_root, whichrmtreeremoves moments later regardless.video_pathliving insidetemp_rootstill makes the explicitunlink()redundant —
_download_video_filebuilds it astemp_dir / "%(id)s.%(ext)s". Thatredundancy is left exactly as it is, since removing it is a behaviour change
with no performance benefit.
Scope of the totality fix
The
Exception-vs-OSErrorchange is contract correctness, not a live bug fix.No claim is made that a NUL-byte path reaches this helper in production today:
temp_rootcomes fromtempfile.mkdtemp(prefix="gemini_video_"), and a NULvideo_pathis rejected earlier by thefilename.exists()guard in_download,whose
except Exceptionhandler cleans up and re-raises. The code carried an inlinecomment asserting both branches were total; that assertion was false, and it is the
kind of claim later changes get built on.
Risk
Low, with one deliberate design decision worth stating plainly.
The one semantic difference between an inline call and an
awaitiscancellability. The code being replaced is straight-line synchronous, so once the
finallyis entered the deletion always runs to completion. A bareawaitin afinallydoes not have that property: if the task is cancelled asecond time — a client disconnect followed by a server shutdown, say — the await
raises immediately and the tree is never removed. That converts a loop stall into a
disk leak of a multi-hundred-megabyte video plus its temp tree, which is a strictly
worse failure than the one being fixed.
asyncio.shieldrestores the original guarantee: the worker keeps running tocompletion while
CancelledErrorstill propagates to the caller. Stated precisely,shielddoes not make cleanup unconditional — the accurate claim is that cleanupkeeps running unless the process itself dies, since a
SIGKILLor interpreter exittakes the worker thread with it. That is the same bound the current inline code has.
Because
CancelledErroris aBaseException, theexcept Exceptionhandler upstream atline 336 does not swallow it, so cancellation semantics as seen by callers are
unchanged. On the normal path
shieldis behaviourally identical to a bare await.Shielding is only safe if the shielded coroutine cannot itself hang or raise.
_cleanupis total:unlinkis wrapped inexcept OSErrorandrmtreeusesignore_errors=True, and it is a bounded filesystem walk. The two.exists()guards the original code used were removed rather than carried over. Because
this runs from a
finally, anything it raises replaces the exception alreadypropagating — and
Path.exists()is not safe there: it performs astat, andCPython re-raises any
OSErrorwhose errno is outsideENOENT/ENOTDIR/EBADF/ELOOP, so anEIOorEACCESwould surface as a spuriousfilesystem fault in place of the real error. The guards were redundant as well as
unsafe —
unlinkalready raisesFileNotFoundErrorfor absent paths andrmtreeis a no-op — so dropping them makes this helper strictly safer than the inline code
it replaces. The residual cost is
that a cancellation during cleanup may leave a worker thread running briefly past the
caller's return, which can surface as a "Task was destroyed but it is pending" warning
at interpreter shutdown. That is cosmetic; this repository does not configure
filterwarnings = error, so it cannot fail a run.No caller signature, return type or exception contract changes, so there is nothing for
callers to adapt to.
Verification
108 of those existed before this change and still pass unmodified. Seven are new.
The new tests were proved to fail against the previous behaviour. Rather than
reverting the whole file — which would only produce an
AttributeErroron amissing method and prove nothing about behaviour — the helper body was reduced to a
direct
_cleanup()call, which is exactly the code path this PR replaces:The other four pass under both variants, which is correct — they assert filesystem
outcomes and totality, which off-loading does not affect.
The three failures are the three tests that assert the off-loop property. The three
that pass under both versions are the behaviour-preservation tests, which is the result they are
designed to produce — they exist to catch a semantic regression, so passing on both
sides is the signal, not a gap.
What each test pins down:
test_cleanup_runs_off_event_loop— wrapsPath.unlinkandshutil.rmtreein recorders that capture
threading.get_ident()and delegate to the realimplementation, then asserts both idents differ from the loop thread. It first
asserts a call count of exactly one each, so the test cannot pass vacuously by
never reaching the instrumented calls.
test_cleanup_does_not_block_event_loop— parksrmtreeon an unsetthreading.Eventand shows the loop still makes progress while it is parked,which is the property that distinguishes off-loop work from a fast on-loop delete.
It polls against a
time.monotonic()deadline rather than a fixed iteration count,so a loaded runner that is slow to hand
to_threada worker polls more timesinstead of failing, and it asserts the tick count directly. The polling is
deliberate and is not replaced by a blocking wait on the
Event: blocking theloop to prove the loop is not blocked would invert the test.
test_cleanup_removes_artifacts— real files undertmp_path, including anauJzb1D-fag.f140.m4afragment, asserting the video, the fragment and thedirectory are all gone.
test_cleanup_survives_missing_paths— nonexistent paths and(None, None)must not raise.
test_cleanup_completes_when_task_cancelled— the shield property: cancel midcleanup, expect
CancelledErrorat the caller, and assert the tree is stillremoved. Uses the same
time.monotonic()deadline as the test above.test_cleanup_does_not_mask_in_flight_exception— patchesPath.statto raiseand
Path.unlinkto raisePermissionError, then calls the helper from afinallywhile a different exception is propagating, asserting the originalexception is what reaches the caller.
shutil.rmtreeis deliberately leftunpatched:
ignore_errors=Trueis the mechanism that makes directory removaltotal, so patching it away would test a guarantee the code never made.
That last test carries its own prove-fail. Restoring only the two
.exists()guards, with everything else untouched:
OSErrorhad replaced the in-flight exception. The other five are unaffected,which is correct — masking is orthogonal to off-loading.
The seventh test,
test_cleanup_is_total_for_non_oserror_failures, covers theException-vs-OSErrorguards. It uses no mocking at all: it passes real pathscontaining a NUL byte and first asserts the premise against the bare stdlib calls,
so the test would start failing if CPython ever made these calls total.
Its prove-fail restores only the previous guards, everything else untouched:
After each prove-fail the file was restored from a pre-image and confirmed byte
identical with
diff, plusgrep -con the three invariants(
asyncio.shield(asyncio.to_thread(...))= 1,video_path.exists()= 0,except OSError:= 0).Lint parity against
origin/main, comparing the pristine file through stdin so theworking tree is never disturbed:
Wider sweep —
grep -rln --include="*.py" "transcript_action_workflow\|TranscriptActionWorkflow" tests/unit/returns one other file,
tests/unit/test_v1_router_extended.py: 121 passedstandalone. Running that file together with this one hits a collection error, and that
is pre-existing: checking out
origin/main's copy of the test file reproduces theidentical
ModuleNotFoundErroron line 19, an import this PR does not touch. It is aninstance of the known cross-module
sys.modulesinterference in this suite, not aregression introduced here.
Production evidence
This path is reachable from two HTTP endpoints. Every hop below was confirmed at call
level, not merely by import graph:
transcript_action_workflowis imported atrouter.py:49, the only import site insrc/, so this is the whole production surface.Honest framing of the magnitude: this is best described as a moderate improvement to
responsiveness on the Gemini file-fallback path, not a general performance
improvement. It is narrower than the subprocess offload in #1240 because it only
triggers on the Gemini video fallback rather than on every request, and a delete on
warm cache is fast. Nothing here makes the endpoint itself faster — the request does
the same work in the same order and returns at the same time. What changes is that
other coroutines are no longer held hostage while it happens. It matters because the
fallback is exactly the slow, large-file path — it only runs after a full video
download — so the tree being removed is at its largest precisely when this code runs,
and the loop is held for all of it.