perf: read local image bytes off the event loop in vision providers - #1233
Conversation
Azure `_prepare_image_input` and Google `analyze_image` read local image files with a synchronous `open().read()` inside `async def`, blocking the event loop for the duration of the disk read. Every other coroutine on the loop stalls until the read completes. Both now delegate to a module-level `_read_file_bytes` helper via `asyncio.to_thread`, matching the fix already merged for the AWS Rekognition sibling in #1205. This completes that cross-provider work so all three providers share one contract. The URL branches are untouched: Azure returns None so the SDK fetches the URL itself, and Google still sets `image.source.image_uri`. Also drops three now-redundant function-local `import asyncio` statements in azure_vision.py, made dead by the new module-level import. The lazy Azure SDK imports beside them are left in place. Regression tests assert off-loop execution by thread identity rather than wall-clock timing, which is flaky under CI load. Both new tests fail against the unpatched sources with "read on the event loop thread". Refs #1232 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 |
|
This PR closes #1232 — please link it to the corresponding Linear issue and update its status. Summary for tracking: Azure Two things worth recording on the Linear issue, because they qualify the impact:
Verification: 185 targeted tests pass; both new off-loop tests fail against the unpatched sources; ruff parity 15 -> 15. |
Agent Completion Truth Gate: NOT_APPLICABLEEvidence agrees. Machine-readable verdict{
"details": {},
"reasons": [],
"verdict": "not_applicable"
} |
|
@coderabbitai review Please review adversarially rather than confirmatorily. Three specific areas where I think this PR is most likely to be wrong, and I would rather you find it than I ship it: 1. Is the no-timeout decision defensible? 2. Does the redundant-import removal belong here? I deleted three function-local 3. Is the Google test actually sound? Also please confirm the URL branches are genuinely untouched in behaviour, and that nothing in this diff changes the exception contract. |
|
I see one substantive problem: the no-timeout rationale is weaker than it needs to be.
On your other points:
If you want the review distilled to ship/no-ship: I’d fix or explicitly mitigate the shared-executor hang risk before merging. |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== Changed files =='
git diff --name-status HEAD^ HEAD || true
printf '%s\n' '== Candidate file locations =='
fd -a -t f '^(aws_rekognition|azure_vision|google_cloud)\.py$|^(test_azure_vision_provider|test_google_cloud_provider)\.py$' .
printf '%s\n' '== Focused outlines =='
for f in $(fd -a -t f '^(aws_rekognition|azure_vision|google_cloud)\.py$|^(test_azure_vision_provider|test_google_cloud_provider)\.py$' .); do
echo "-- $f"
ast-grep outline "$f" --items all --type function,class 2>/dev/null || true
done
printf '%s\n' '== PR diff =='
git diff --unified=80 HEAD^ HEAD -- \
$(fd -a -t f '^(aws_rekognition|azure_vision|google_cloud)\.py$|^(test_azure_vision_provider|test_google_cloud_provider)\.py$' .)Length of output: 27686 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== Locate changed test files and test classes =='
fd -a -t f . tests | rg 'test_(azure_vision|google_cloud).*\.py$' || true
rg -n -C 12 \
'TestGoogleCloudImageReadOffEventLoop|TestAzureVisionImageReadOffEventLoop|_ThreadRecordingOpen|patch\.dict\(\s*["'\'']sys\.modules|builtins\.open|worker_thread|FileNotFoundError|URL' \
tests || true
printf '%s\n' '== Changed test diff, independent of filename assumption =='
git diff --unified=100 HEAD^ HEAD -- tests
printf '%s\n' '== AWS timeout and image-input implementation =='
aws_file='src/youtube_extension/integrations/cloud_ai/providers/aws_rekognition.py'
sed -n '1,145p' "$aws_file"
rg -n -C 25 '_prepare_image_input|asyncio\.to_thread|wait_for|_read_file_bytes' "$aws_file"
printf '%s\n' '== Full changed provider diff with line numbers =='
git diff --unified=30 HEAD^ HEAD -- \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.pyLength of output: 57893
Shared default-executor starvation remains possible. Do not add If the supported input contract permits network-backed local paths, use a provider-owned executor with bounded submission capacity, or reject unsupported path types before scheduling the read. A bounded provider-owned executor contains this failure and protects unrelated default-executor users. If the contract guarantees ordinary local files only, document that constraint and accept the residual filesystem failure mode explicitly. The three Azure import removals belong in this PR. The new module-level The Google test is sound for its stated purpose:
The URL branches are unchanged in behavior. Azure still returns The exception behavior is unchanged by the implementation. ✅ Action performedReview finished.
|
There was a problem hiding this comment.
Pull request overview
Offloads Azure and Google local image file reads from the event loop, completing the cross-provider fix from #1205.
Changes:
- Adds worker-thread file reads via
asyncio.to_thread. - Adds regression and behavior-preservation tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
azure_vision.py |
Offloads Azure local image reads. |
google_cloud.py |
Offloads Google local image reads. |
test_azure_vision_provider.py |
Tests Azure threading and preserved behavior. |
test_google_cloud_provider.py |
Tests Google threading and URL behavior. |
groupthinking
left a comment
There was a problem hiding this comment.
Reviewed at head efc0e53. The change is correct, minimal, and behaviour-preserving — no blocking findings. (Posting as a comment rather than a formal approval: GitHub blocks self-approval since this review runs under the PR-author identity.)
Correctness (verified against source, not just the diff):
google_cloud.pyaddsasyncio.to_thread(...)without adding animport asyncio, which is fine only because the module already imports it at line 12 and uses it (wait_for,TimeoutError) — so noNameError. Confirmed.azure_vision.pyremoves the three function-localimport asynciofrom_await_ocr_call/_perform_ocr/_perform_ocr_stream; those functions still referenceasyncio(lines ~272–350) and now resolve it via the new module-level import at line 11. Behaviour-neutral, as the description states._read_file_bytesmatches the mergedaws_rekognition.py:109helper exactly, so the three providers are now consistent.- Exception contract preserved:
to_threadre-raisesFileNotFoundErrorfrom the worker, andtest_missing_file_still_raises_file_not_foundguards it.
Tests: the thread-identity approach (over wall-clock timing), the target-path filtering in _ThreadRecordingOpen, and the non-empty recorder.threads assertion together avoid both flakiness and vacuous passes. The URL-branch negative controls confirm no disk read on the pass-through path. Good.
Risk assessment concur: for a single bounded local-file read, the shared-executor-starvation concern is negligible and no timeout is warranted (no unbounded-wait failure mode). Agreed with the reasoning as written.
Non-blocking (optional follow-up, not for this PR): _read_file_bytes is now defined identically in three provider modules (aws_rekognition, azure_vision, google_cloud). A future cleanup could hoist it to a shared cloud_ai util. Keeping it inline here is the right call for a minimal diff that mirrors the merged precedent — flagging only so the duplication is tracked.
CI is green at this head (truth-gate success, Vercel success; CodeRabbit shows only "review rate limited", not a failure). This is a clean, ready-to-merge change pending your sign-off.
Generated by Claude Code
Copilot review on #1233 flagged that _ThreadRecordingOpen recorded the thread that called open(), not the thread that performed handle.read(). A regression offloading only open() while reading bytes back on the event loop would still pass, so the test did not prove #1232's required property. Wrap the returned handle in _ThreadRecordingHandle and record the calling thread on read() instead. Behaviour-preservation tests (URL branch, missing file) are unchanged; all off-loop guards still pass, and the recorder now fails on an open-offloaded/read-on-loop regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6r6TyHudJkb6mn4HUNF5Y
|
@linear — conceded, you were right, and the claim is now corrected in the PR body. You were correct and I was wrong. The I measured your claim rather than just agreeing with it, and it holds — including the part that makes the obvious fix useless: Cancelling the awaiting coroutine frees the caller but leaves the worker stuck forever. Default pool on a 12-core host is So I did not add What I did instead:
Why I still think this should merge as-is: it strictly reduces the blast radius of the exact failure you identified. Before, an NFS stall blocks the entire event loop and every request in the process stops. After, it blocks one of 16 pool workers and the loop keeps serving. Never worse on this axis. It also matches the already-merged Thanks for the other four verdicts — agreed on all of them. Your note that the Azure test exercises the private helper rather than the public API is fair; the Google tests cover the public If you still consider the residual risk merge-blocking even with #1234 tracking it, say so and I will move the executor work into this PR instead. |
|
I don’t consider the residual risk merge-blocking with the claim corrected and #1234 tracking the actual fix. The change still improves the failure mode materially by moving the stall off the event loop, so I’m comfortable with this landing as-is. |
|
@coderabbitai review Re-requesting — the earlier attempt hit the review rate limit. Since then the Please focus on:
|
|
I agree with the current framing.
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== Changed files =='
git diff --name-status HEAD^ HEAD
printf '%s\n' '== Locate candidate files =='
fd -a -t f '^(azure_vision|google_cloud|aws_rekognition)\.py$|^(test_azure_vision_provider|test_google_cloud_provider)\.py$' .
printf '%s\n' '== Source and test outlines =='
while IFS= read -r f; do
printf '\n-- %s --\n' "$f"
ast-grep outline "$f" --items all --type class,function 2>/dev/null || true
done < <(fd -a -t f '^(azure_vision|google_cloud|aws_rekognition)\.py$|^(test_azure_vision_provider|test_google_cloud_provider)\.py$' .)
printf '%s\n' '== Relevant PR diff =='
git diff --unified=50 HEAD^ HEAD -- \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py \
tests/unit/test_azure_vision_provider.py \
tests/unit/test_google_cloud_provider.py
printf '%s\n' '== Relevant implementation and test contexts =='
rg -n -C 20 \
'(^import asyncio$|def _read_file_bytes|async def _prepare_image_input|async def analyze_image|async def _await_ocr_call|async def _perform_ocr|class _ThreadRecordingOpen|TestAzureVisionImageReadOffEventLoop|TestGoogleCloudImageReadOffEventLoop|builtins\.open|patch\.dict\()' \
src/youtube_extension/integrations/cloud_ai/providers \
tests/unitLength of output: 2131 🏁 Script executed: #!/bin/bash
set -euo pipefail
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main 2>/dev/null || git rev-parse HEAD^)"
printf '== Review base: %s ==\n' "$base"
printf '%s\n' '== Files changed across the PR branch =='
git diff --name-status "$base" HEAD
printf '%s\n' '== Relevant outlines =='
fd -a -t f '^(azure_vision|google_cloud|aws_rekognition)\.py$|^(test_azure_vision_provider|test_google_cloud_provider)\.py$' . \
| while IFS= read -r f; do
printf '\n-- %s --\n' "$f"
ast-grep outline "$f" --items all --type class,function 2>/dev/null || true
done
printf '%s\n' '== PR diff for relevant files =='
git diff --unified=70 "$base" HEAD -- \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py \
src/youtube_extension/integrations/cloud_ai/providers/aws_rekognition.py \
tests/unit/test_azure_vision_provider.py \
tests/unit/test_google_cloud_provider.py
printf '%s\n' '== Current source and test contexts =='
rg -n -C 25 \
'(^import asyncio$|def _read_file_bytes|async def _prepare_image_input|async def analyze_image|async def _await_ocr_call|async def _perform_ocr|class _ThreadRecordingOpen|TestAzureVisionImageReadOffEventLoop|TestGoogleCloudImageReadOffEventLoop|builtins\.open|patch\.dict\()' \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py \
src/youtube_extension/integrations/cloud_ai/providers/aws_rekognition.py \
tests/unit/test_azure_vision_provider.py \
tests/unit/test_google_cloud_provider.pyLength of output: 103930 🏁 Script executed: #!/bin/bash
set -euo pipefail
azure_test="$(fd -a -t f '^test_azure_vision_provider\.py$' . | head -n 1)"
google_test="$(fd -a -t f '^test_google_cloud_provider\.py$' . | head -n 1)"
printf '%s\n' '== Complete new test sections =='
printf '\n-- %s --\n' "$azure_test"
sed -n '/^# Local image reads must not block the event loop/,$p' "$azure_test"
printf '\n-- %s --\n' "$google_test"
sed -n '/^# Local image reads must not block the event loop/,$p' "$google_test"
printf '%s\n' '== All asyncio bindings and references in Azure provider =='
rg -n -C 3 \
'(^\s*(import|from)\s+asyncio\b|^\s*asyncio\s*=|\basyncio\.)' \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py
printf '%s\n' '== All local-file read implementations in the reviewed providers =='
rg -n -C 6 \
'def _read_file_bytes|asyncio\.to_thread\(_read_file_bytes|with open\(.*rb' \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py \
src/youtube_extension/integrations/cloud_ai/providers/aws_rekognition.py
printf '%s\n' '== Module dependencies that could make Azure module import cyclic =='
rg -n -C 2 \
'azure_vision|from \.\.? import|from \.\.[a-z_]+ import' \
src/youtube_extension/integrations/cloud_ai/base.py \
src/youtube_extension/integrations/cloud_ai/exceptions.py \
src/youtube_extension/integrations/cloud_ai/providers/__init__.py 2>/dev/null || trueLength of output: 1853 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== Tracked matching test paths =='
git ls-files | rg '(^|/)(test_azure_vision_provider|test_google_cloud_provider)\.py$' || true
printf '%s\n' '== Complete relevant test classes =='
git ls-files | rg '(^|/)(test_azure_vision_provider|test_google_cloud_provider)\.py$' \
| while IFS= read -r f; do
printf '\n-- %s --\n' "$f"
sed -n '/^# Local image reads must not block the event loop/,$p' "$f"
done
printf '%s\n' '== All asyncio bindings and references in Azure provider =='
rg -n -C 3 \
'(^\s*(import|from)\s+asyncio\b|^\s*asyncio\s*=|\basyncio\.)' \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py
printf '%s\n' '== Azure local import and module import history in this PR =='
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main 2>/dev/null || git rev-parse HEAD^)"
git diff --unified=8 "$base" HEAD -- \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py
printf '%s\n' '== Direct provider-package imports of Azure provider =='
rg -n -C 2 \
'(from .*azure_vision import|import .*azure_vision|from \.)' \
src/youtube_extension/integrations/cloud_ai/providers \
src/youtube_extension/integrations/cloud_ai/base.py \
src/youtube_extension/integrations/cloud_ai/exceptions.py \
2>/dev/null || trueLength of output: 1940 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== Complete new test sections from committed files =='
for f in \
tests/unit/test_azure_vision_provider.py \
tests/unit/test_google_cloud_provider.py
do
printf '\n-- %s --\n' "$f"
git show "HEAD:$f" | sed -n '/^# Local image reads must not block the event loop/,$p'
done
printf '%s\n' '== All asyncio bindings and references in Azure provider =='
rg -n -C 3 \
'(^\s*(import|from)\s+asyncio\b|^\s*asyncio\s*=|\basyncio\.)' \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py
printf '%s\n' '== Azure imports and references from the committed source =='
git show HEAD:src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
| rg -n -C 3 \
'(^\s*(import|from)\s+asyncio\b|^\s*asyncio\s*=|\basyncio\.)'
printf '%s\n' '== Provider package initializer and direct imports =='
if git cat-file -e HEAD:src/youtube_extension/integrations/cloud_ai/providers/__init__.py 2>/dev/null; then
git show HEAD:src/youtube_extension/integrations/cloud_ai/providers/__init__.py
fi
rg -n -C 2 \
'(from .*azure_vision import|import .*azure_vision)' \
src/youtube_extension/integrations/cloud_ai \
2>/dev/null || trueLength of output: 16550
The URL branches remain unchanged. Azure returns You are interacting with an AI system. |
CodeRabbit review on #1233 flagged that TestGoogleCloudImageReadOffEventLoop had no missing-file test, while GoogleCloudAI.analyze_image catches FileNotFoundError in its broad `except Exception` and re-raises CloudAIError — unlike Azure's private _prepare_image_input, which propagates FileNotFoundError. Add a Google test asserting the CloudAIError wrapper so moving the read off the event loop cannot silently change how a missing local image is reported. Closes the coverage gap; documents that the two providers differ at the tested surface (public analyze_image vs private helper). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6r6TyHudJkb6mn4HUNF5Y
|
Addressed the actionable half of CodeRabbit's finding #4 (missing-file coverage) in
On the prose half — the No change needed to Generated by Claude Code |
|
Publish gate — All required checks on head Nothing else is blocking. This is held at the publish gate for human sign-off, not auto-merged: Generated by Claude Code |
Strengthen the wrapper-contract guard added in e0961b2: asserting only pytest.raises(CloudAIError) would still pass if the underlying cause were swallowed or the message went generic. Also assert the original FileNotFoundError is preserved on __context__ (the provider re-raises without 'from e', so chaining is implicit) and that the path error text reaches the caller. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both review findings resolved@coderabbitai — your finding was correct and I've applied both remedies you suggested, not just one. You flagged that the PR body claimed
@linear — your You were right that recording the thread at Confirmed: the old harness recorded the worker thread and let it through. Your fix makes the guards strictly stronger than what I originally submitted. I took your branch as the base rather than force-pushing over it, and layered only the extra assertions on top as Re-verification after both corrections
One process note on my own verification, since it nearly produced a false result: my first pre-change replay used Both findings are closed. Requesting a final look before merge. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main 2>/dev/null || git rev-parse HEAD^)"
printf '== Base: %s ==\n' "$base"
printf '%s\n' '== Changed files =='
git diff --name-status "$base" HEAD
printf '%s\n' '== Focused final diff =='
git diff --unified=80 "$base" HEAD -- \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py \
tests/unit/test_azure_vision_provider.py \
tests/unit/test_google_cloud_provider.py
printf '%s\n' '== Current implementation and test evidence =='
rg -n -C 20 \
'(^import asyncio$|def _read_file_bytes|async def _prepare_image_input|async def analyze_image|async def _await_ocr_call|async def _perform_ocr|class _ThreadRecordingOpen|def read\(|TestAzureVisionImageReadOffEventLoop|TestGoogleCloudImageReadOffEventLoop|test_missing_local_file_wrapped_in_cloud_ai_error|builtins\.open|asyncio\.to_thread|image_uri|https?://)' \
src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py \
src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py \
tests/unit/test_azure_vision_provider.py \
tests/unit/test_google_cloud_provider.py
printf '%s\n' '== Current PR metadata =='
gh pr view 1233 --repo groupthinking/EventRelay --json headRefOid,title,body,mergeStateStatus,statusCheckRollupLength of output: 86765
The final diff correctly preserves the provider-specific error behavior:
The thread test is now strong enough for the intended regression. It records the thread that calls The URL branches remain unchanged. Azure returns The Azure The residual shared-default-executor risk remains real, but The shell inspection could not execute the test suite in this environment. The reported targeted test and lint results provide the available execution evidence. 🐇 ✅ ✏️ Learnings added
You are interacting with an AI system. |
|
Independent verification (automated remediation run) — merge-ready, awaiting human approval. Re-checked at head
This run does not auto-merge to protected Generated by Claude Code |
* 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>
Canonical issue
Closes #1232
This completes the cross-provider work started in #1205, which fixed this identical defect in the
AWSRekognitionsibling. Azure and Google were left blocking.Outcome
Azure
AzureVision._prepare_image_inputand GoogleGoogleCloudAI.analyze_imageread local image files with a synchronousopen().read()insideasync def. That call blocks the event loop thread for the whole duration of the disk read, stalling every other coroutine scheduled on that loop.Both now route the read through a module-level
_read_file_byteshelper viaasyncio.to_thread, mirroring the mergedaws_rekognition.pyimplementation exactly.Noneso the SDK fetches the URL itself; Google still setsimage.source.image_uri_prepare_image_inputhas notry/exceptsoFileNotFoundErrorpropagates raw; Google'sanalyze_imagewraps it inCloudAIErrorvia its pre-existing broadexcept(unchanged by this PR, now pinned by a test)import asynciostatements inazure_vision.pythat the new module-level import makes redundantanalyze_videoon either providerRisk
Low, with two risks stated explicitly rather than waved away.
Shared default executor — residual, tracked in Blocking I/O offloads share the default executor, so one stalled read can starve the process #1234. An earlier draft of this section claimed a local read "has no analogous unbounded-wait failure mode". That was wrong, and the review challenge that caught it was correct: a path passed to
open()may resolve to NFS/FUSE/remote-backed storage and stall without limit. Three facts, measured rather than asserted:min(32, cpu_count + 4), i.e. 16 workers on a 12-core host.to_threadtask whose function is blocked leaves the thread alive and stuck (non-main threads AFTER cancel : 1 ['asyncio_0']), so the slot leaks permanently.asyncio.to_threadcall sites and 1 is bounded by a caller-sidewait_for.No timeout is added here, and that is a deliberate choice rather than an oversight.
asyncio.wait_forbounds the caller, not the worker; the measurement above shows the thread stays stuck after the caller is already free. Adding one would improve caller responsiveness but would not return the slot to the pool, so presenting it as a starvation mitigation would be false. The real fix is executor isolation, which is repo-wide (all 66 sites), architectural, and out of scope for a change whose canonical issue is scoped to moving one read off the loop — hence Blocking I/O offloads share the default executor, so one stalled read can starve the process #1234 rather than scope creep here.This PR strictly reduces the blast radius of exactly that failure mode. Before: a stalled read blocks the entire event loop, so every request in the process stops. After: it blocks one of 16 pool workers and the loop keeps serving. It is never worse on this axis, which is also why it matches the already-merged
aws_rekognition.pysibling (perf(cloud-ai): run AWS Rekognition boto3 calls off the event loop #1205), whose_read_file_bytesconsumption is likewise an unboundedto_thread.Redundant-import removal widens the diff. The three deleted
import asynciolines sit in_await_ocr_call,_perform_ocr, and_perform_ocr_stream— functions this PR does not otherwise change. They are removed because this PR is what made them dead, by adding the module-level import. The deletion is behaviour-neutral:asyncioresolves to the same module either way. The adjacent lazyazure.cognitiveservices...imports are deliberately left in place, since those defer an optional dependency.Rollback is a clean revert; there is no data migration, persisted state, or wire-format change.
Verification
Head sha:
efc0e5355ac22129e6b2f9c3ca5ee6abf388df9b1. Targeted suites pass.
2. The new tests fail against the unpatched sources. Both source files were stashed while keeping the tests, which is the check that the tests actually bind to the defect:
with the diagnostic
AssertionError: local image bytes were read on the event loop threadandassert 8284626624 not in [8284626624]. They fail, they do not error — the tests patchbuiltins.openrather than the new helper, so they are runnable against both the old and new source and are not coupled to the helper's name.The 4 that pass pre-change are behaviour-preservation guards (URL branch performs no disk read; Azure missing file raises raw
FileNotFoundError; Google missing file raisesCloudAIErrorwith the originalFileNotFoundErrorpreserved on__context__). Those are meant to pass on both sides.3. Off-loop is proven by thread identity, not timing. A
_ThreadRecordingOpenwrapper recordsthreading.get_ident()for opens of the target path only, so unrelatedopentraffic from logging or coverage cannot contaminate the result. Wall-clock assertions were rejected as CI-flaky. Each test also asserts the recorder is non-empty, so a silently-skipped read cannot pass vacuously.4. Ruff parity — 15 findings before, 15 after, zero diff.
Measured by stashing the change, re-running, and diffing normalised output. All 15 are pre-existing.
5. Wider sweep. All
tests/unit/files referencingazure_vision,google_cloud, orcloud_ai:65 failed, 569 passed. The same batch on cleanmaingives65 failed, 564 passed— the identical 65 failures, plus exactly the 5 tests this PR adds. Those 65 are pre-existing cross-module pollution intest_service_container.pywhen these files share one process; that file passes58 passedin isolation with this change applied. Not caused by, and not worsened by, this PR.tests/load/locustfile.pywas excluded after confirming itsRecursionErrorcollection failure reproduces on cleanmain(a gevent/greenlet finalisation issue).Production evidence
No production latency improvement is claimed, and the reachability limits are stated up front.
analyze_imageis not reachable from any HTTP route.cloud_ai_routes.pyis mounted atmain.py:173but exposes only/providers/status,/analyze/video,/analyze/batch,/analyze/multi-provider,/analysis-types, and/providers. There is no image endpoint.grep -rn "analyze_image" src/returns only the three provider implementations plus the abstract declaration atbase.py:108. There are no in-tree callers.What does justify merging:
analyze_imageis part of theBaseCloudAIpublic contract atbase.py:108and is callable by any consumer of the integration package.AWSRekognition, whose_read_file_byteshelper ataws_rekognition.py:109-112this PR copies. Leaving two of three implementations blocking makes the contract inconsistent: the same call has different event-loop safety depending only on which provider is configured. That inconsistency is the concrete defect being closed.Agent handoff
Adversarial review requested from
@coderabbitai. Reviewers should specifically probe: whether the executor-starvation reasoning in Risk holds for a bounded local read; whether removing the three redundantimport asynciolines belongs in this PR or a follow-up; and whether patchingbuiltins.openinside the Google test'spatch.dict("sys.modules", ...)block is sound given that provider's lazy-import path.Review adjudication
Two corrections were made in response to agent review, both verified before landing:
CodeRabbit (non-blocking finding) correctly identified that the original
**Does**rowabove overstated the error contract.
FileNotFoundErrorpropagates raw only from Azure;Google wraps it in
CloudAIError. The row is corrected, and the Google contract is nowpinned by
test_missing_local_file_wrapped_in_cloud_ai_error, which asserts both theCloudAIErrortype and that the originalFileNotFoundErrorsurvives on__context__.Linear (
86371fc43) correctly identified that_ThreadRecordingOpenrecorded the threadcalling
open()rather than the thread performinghandle.read(), so a regression thatoffloaded
open()but read bytes back on the event loop would still pass. I verified thisempirically by simulating exactly that regression against both harnesses:
The old harness recorded the worker thread and let the regression through. The fix is real
and the guards are now strictly stronger than as originally submitted.
Re-verified after both corrections: 186 passed; pre-change replay against
origin/mainsources still yields 2 failed, 4 passed (only the two off-loop assertions fail, as intended);
ruff checkclean on all touched files.