Skip to content

perf: read local image bytes off the event loop in vision providers - #1233

Merged
groupthinking merged 4 commits into
mainfrom
perf/vision-provider-image-read-off-loop
Aug 2, 2026
Merged

perf: read local image bytes off the event loop in vision providers#1233
groupthinking merged 4 commits into
mainfrom
perf/vision-provider-image-read-off-loop

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1232

This completes the cross-provider work started in #1205, which fixed this identical defect in the AWSRekognition sibling. Azure and Google were left blocking.

Outcome

Azure AzureVision._prepare_image_input and Google GoogleCloudAI.analyze_image read local image files with a synchronous open().read() inside async 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_bytes helper via asyncio.to_thread, mirroring the merged aws_rekognition.py implementation exactly.

Does Move the local-file byte read off the event loop in both providers
Does Return byte-for-byte identical content to callers
Does Preserve the URL branches unchanged — Azure returns None so the SDK fetches the URL itself; Google still sets image.source.image_uri
Does Preserve each provider's existing error contract exactly: Azure's _prepare_image_input has no try/except so FileNotFoundError propagates raw; Google's analyze_image wraps it in CloudAIError via its pre-existing broad except (unchanged by this PR, now pinned by a test)
Does Drop three function-local import asyncio statements in azure_vision.py that the new module-level import makes redundant
Does not Change any public signature, return type, or exception contract
Does not Add a dependency, config key, or feature flag
Does not Touch analyze_video on either provider
Does not Introduce caching, batching, or retry behaviour
Does not Alter the lazy Azure SDK imports sitting beside the removed lines

Risk

Low, with two risks stated explicitly rather than waved away.

  1. 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:

    • The default executor is bounded — min(32, cpu_count + 4), i.e. 16 workers on a 12-core host.
    • Cancelling the awaiting coroutine does not reclaim the worker. Cancelling a to_thread task whose function is blocked leaves the thread alive and stuck (non-main threads AFTER cancel : 1 ['asyncio_0']), so the slot leaks permanently.
    • Repo-wide there are 66 asyncio.to_thread call sites and 1 is bounded by a caller-side wait_for.

    No timeout is added here, and that is a deliberate choice rather than an oversight. asyncio.wait_for bounds 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.py sibling (perf(cloud-ai): run AWS Rekognition boto3 calls off the event loop #1205), whose _read_file_bytes consumption is likewise an unbounded to_thread.

  2. Redundant-import removal widens the diff. The three deleted import asyncio lines 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: asyncio resolves to the same module either way. The adjacent lazy azure.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: efc0e5355ac22129e6b2f9c3ca5ee6abf388df9b

1. Targeted suites pass.

.venv/bin/python -m pytest tests/unit/test_azure_vision_provider.py \
  tests/unit/test_google_cloud_provider.py -p no:cacheprovider --no-cov -q
185 passed in 0.91s

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:

FAILED tests/unit/test_azure_vision_provider.py::TestAzureVisionImageReadOffEventLoop::test_local_file_read_runs_on_worker_thread
FAILED tests/unit/test_google_cloud_provider.py::TestGoogleCloudImageReadOffEventLoop::test_local_file_read_runs_on_worker_thread
2 failed, 3 passed

with the diagnostic AssertionError: local image bytes were read on the event loop thread and assert 8284626624 not in [8284626624]. They fail, they do not error — the tests patch builtins.open rather 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 raises CloudAIError with the original FileNotFoundError preserved on __context__). Those are meant to pass on both sides.

3. Off-loop is proven by thread identity, not timing. A _ThreadRecordingOpen wrapper records threading.get_ident() for opens of the target path only, so unrelated open traffic 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.

before(main)=15  final=15
PARITY OK — no new lint introduced

Measured by stashing the change, re-running, and diffing normalised output. All 15 are pre-existing.

5. Wider sweep. All tests/unit/ files referencing azure_vision, google_cloud, or cloud_ai: 65 failed, 569 passed. The same batch on clean main gives 65 failed, 564 passed — the identical 65 failures, plus exactly the 5 tests this PR adds. Those 65 are pre-existing cross-module pollution in test_service_container.py when these files share one process; that file passes 58 passed in isolation with this change applied. Not caused by, and not worsened by, this PR.

tests/load/locustfile.py was excluded after confirming its RecursionError collection failure reproduces on clean main (a gevent/greenlet finalisation issue).

Production evidence

No production latency improvement is claimed, and the reachability limits are stated up front.

  • analyze_image is not reachable from any HTTP route. cloud_ai_routes.py is mounted at main.py:173 but 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 at base.py:108. There are no in-tree callers.
  • So this is a library-surface defect on a public abstract-contract method, not a live hot path. Anyone reading this expecting a production latency graph should stop here — there isn't one.

What does justify merging:

  • analyze_image is part of the BaseCloudAI public contract at base.py:108 and is callable by any consumer of the integration package.
  • perf(cloud-ai): run AWS Rekognition boto3 calls off the event loop #1205 already settled that this exact defect in this exact method is worth fixing — it was merged for AWSRekognition, whose _read_file_bytes helper at aws_rekognition.py:109-112 this 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.
  • The change is mechanical, has a merged in-repo precedent, and carries no behavioural change.

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 redundant import asyncio lines belongs in this PR or a follow-up; and whether patching builtins.open inside the Google test's patch.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:

  1. CodeRabbit (non-blocking finding) correctly identified that the original **Does** row
    above overstated the error contract. FileNotFoundError propagates raw only from Azure;
    Google wraps it in CloudAIError. The row is corrected, and the Google contract is now
    pinned by test_missing_local_file_wrapped_in_cloud_ai_error, which asserts both the
    CloudAIError type and that the original FileNotFoundError survives on __context__.

  2. Linear (86371fc43) correctly identified that _ThreadRecordingOpen recorded the thread
    calling open() rather than the thread performing handle.read(), so a regression that
    offloaded open() but read bytes back on the event loop would still pass. I verified this
    empirically by simulating exactly that regression against both harnesses:

    harness records at open()  -> recorded=[6182580224]  loop_tid=8284626624  CATCHES REGRESSION: False
    harness records at read()  -> recorded=[8284626624]  loop_tid=8284626624  CATCHES REGRESSION: True
    

    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/main
sources still yields 2 failed, 4 passed (only the two off-loop assertions fail, as intended);
ruff check clean on all touched files.

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>
Copilot AI review requested due to automatic review settings August 2, 2026 14:42
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
v0-uvai Building Building Preview, v0 Aug 2, 2026 3:07pm

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

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

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

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

⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fe302015-2260-4ed2-9f47-2e23c849f437

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

❤️ Share

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

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Dependency Review

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

Snapshot Warnings

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

Scanned Files

None

@groupthinking

Copy link
Copy Markdown
Owner Author

@linear

This PR closes #1232 — please link it to the corresponding Linear issue and update its status.

Summary for tracking: Azure AzureVision._prepare_image_input and Google GoogleCloudAI.analyze_image performed a synchronous open().read() on local image files inside async def, blocking the event loop. Both now use asyncio.to_thread with a module-level _read_file_bytes helper, matching the AWSRekognition fix merged in #1205.

Two things worth recording on the Linear issue, because they qualify the impact:

  1. This is a library-surface fix, not a production hot path. analyze_image has no in-tree callers and no HTTP route reaches it. The justification is public-contract consistency across the three providers — perf(cloud-ai): run AWS Rekognition boto3 calls off the event loop #1205 already fixed the AWS one, leaving Azure and Google inconsistent.
  2. The change also removes three now-redundant function-local import asyncio statements in azure_vision.py that this PR made dead.

Verification: 185 targeted tests pass; both new off-loop tests fail against the unpatched sources; ruff parity 15 -> 15.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: NOT_APPLICABLE

Evidence agrees.

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

Workflow evidence

@groupthinking

Copy link
Copy Markdown
Owner Author

@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? aws_rekognition.py:36-42 documents a shared-default-executor starvation hazard and sets explicit timeouts. I deliberately did not add one, arguing a bounded local-file read has no unbounded-wait failure mode the way a network call does. Challenge that. If a local read on a stalled NFS/FUSE mount can hang indefinitely, my reasoning is wrong and the offload just moves the hang from the loop thread to a pool thread — which is arguably worse, since it silently consumes shared executor capacity.

2. Does the redundant-import removal belong here? I deleted three function-local import asyncio lines in _await_ocr_call, _perform_ocr, and _perform_ocr_stream — functions this PR does not otherwise touch. My argument is that adding the module-level import is what made them dead, so it is tightly coupled. If you think that widens the blast radius past what the canonical issue authorises, say so and I will split it out.

3. Is the Google test actually sound? TestGoogleCloudImageReadOffEventLoop patches builtins.open inside a patch.dict("sys.modules", ...) block that mocks the lazy google.cloud.vision import path. I have not proven those two patches cannot interact. If the mocked-module machinery can trigger an open that races the assertion, or if the ordering makes the test pass for the wrong reason, that is a real defect in my evidence.

Also please confirm the URL branches are genuinely untouched in behaviour, and that nothing in this diff changes the exception contract.

@linear-code

linear-code Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

I see one substantive problem: the no-timeout rationale is weaker than it needs to be.

  • Blocking concern — no-timeout isn’t really defensible as written. The executor-starvation argument in aws_rekognition.py applies to any to_thread work item that can block indefinitely, not just network SDK calls. A local open().read() on NFS/FUSE/remote-backed storage can hang for an unbounded time, and asyncio.to_thread(...) won’t let you cancel the underlying worker once it is stuck. So this change in azure_vision.py and google_cloud.py can indeed move the hang from the loop thread to the shared executor, which is better for loop responsiveness but still a real starvation risk.

On your other points:

  • Redundant-import removal: I think it belongs here. In azure_vision.py those local imports became dead specifically because this patch introduced the module-level import, so I wouldn’t call that an unjustified blast-radius increase.
  • Google test soundness: I think the test is basically sound. The sys.modules patch should short-circuit import resolution rather than trigger file reads, and your open recorder only records the specific temp-image path, so unrelated import-time opens wouldn’t satisfy the assertion anyway. I don’t see a meaningful interaction bug there in test_google_cloud_provider.py.
  • URL branches: They look behaviorally unchanged: Azure still returns None for URLs, Google still sets image.source.image_uri.
  • Exception contract: I don’t see a public-contract change in the diff. analyze_image() in both providers still wraps failures the same way as before. One caveat: the new Azure test exercises the private helper directly, so it proves helper behavior, not public API behavior.

If you want the review distilled to ship/no-ship: I’d fix or explicitly mitigate the shared-executor hang risk before merging.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor
🧩 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.py

Length of output: 57893


@groupthinking I found one production-risk concern.

Shared default-executor starvation remains possible.
A local open().read() can block indefinitely on a stalled NFS or FUSE mount. Each call to asyncio.to_thread(_read_file_bytes, ...) can then occupy a shared default-executor worker indefinitely. Enough concurrent reads can prevent unrelated asyncio.to_thread work from starting.

Do not add asyncio.wait_for as the fix. A timeout cancels the awaiter. It does not stop the worker thread blocked in kernel I/O. It therefore bounds caller latency but does not free executor capacity.

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 import asyncio makes the local imports redundant. All affected functions resolve the same module. The lazy Azure SDK imports remain untouched.

The Google test is sound for its stated purpose:

  • patch.dict("sys.modules", ...) enters before builtins.open is patched.
  • from google.cloud import vision runs only after both contexts are active.
  • patch.dict itself does not open the target image path.
  • _ThreadRecordingOpen records only the target path, so unrelated file opens cannot satisfy the assertion.
  • The test verifies both that a read occurred and that it occurred outside the event-loop thread.

The URL branches are unchanged in behavior. Azure still returns None for both http:// and https:// inputs. Google still sets image.source.image_uri for both schemes. The new tests exercise https://; the source condition still covers both schemes.

The exception behavior is unchanged by the implementation. AzureVision._prepare_image_input still exposes FileNotFoundError. GoogleCloudAI.analyze_image still catches FileNotFoundError and wraps it in CloudAIError, as it did before this change. The Google tests should add a missing-file case that asserts the existing CloudAIError behavior. This corrects the PR description’s statement that Google propagates FileNotFoundError unchanged.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Offloads 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.

Comment thread tests/unit/test_google_cloud_provider.py Outdated
Comment thread tests/unit/test_azure_vision_provider.py Outdated

@groupthinking groupthinking left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.py adds asyncio.to_thread(...) without adding an import asyncio, which is fine only because the module already imports it at line 12 and uses it (wait_for, TimeoutError) — so no NameError. Confirmed.
  • azure_vision.py removes the three function-local import asyncio from _await_ocr_call / _perform_ocr / _perform_ocr_stream; those functions still reference asyncio (lines ~272–350) and now resolve it via the new module-level import at line 11. Behaviour-neutral, as the description states.
  • _read_file_bytes matches the merged aws_rekognition.py:109 helper exactly, so the three providers are now consistent.
  • Exception contract preserved: to_thread re-raises FileNotFoundError from the worker, and test_missing_file_still_raises_file_not_found guards 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
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 2, 2026
@groupthinking

Copy link
Copy Markdown
Owner Author

@linear — conceded, you were right, and the claim is now corrected in the PR body.

You were correct and I was wrong. The ## Risk section previously asserted a local read "has no analogous unbounded-wait failure mode." A path handed to open() can resolve to NFS/FUSE/remote-backed storage and stall without limit, so that sentence was indefensible. It has been rewritten.

I measured your claim rather than just agreeing with it, and it holds — including the part that makes the obvious fix useless:

worker entered blocking call: True
non-main threads BEFORE cancel: 1
caller cancel raised CancelledError -> caller is free
non-main threads AFTER cancel : 1 [asyncio_0]
>>> worker STILL stuck (slot leaked): True

Cancelling the awaiting coroutine frees the caller but leaves the worker stuck forever. Default pool on a 12-core host is min(32, cpu_count+4) = 16 workers. Repo-wide: 66 to_thread call sites, 1 bounded by wait_for.

So I did not add asyncio.wait_for, and I want to be explicit that this is a decision, not an omission. A timeout bounds the caller, not the thread — the measurement above shows the slot still leaks after the caller is released. Shipping one and describing it as a starvation mitigation would be exactly the kind of overclaiming you just caught me doing. I would rather leave the risk visible and tracked than paper over it.

What I did instead:

  1. Corrected the false claim in ## Risk with the measurements above.
  2. Filed Blocking I/O offloads share the default executor, so one stalled read can starve the process #1234 for the actual fix — a dedicated bounded executor plus a run_blocking(...) helper, saturation metrics, and a regression test proving a saturated I/O pool cannot block unrelated to_thread work. Scoped repo-wide across all 66 sites, since fixing it only for image reads would be arbitrary.

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 aws_rekognition.py sibling from #1205, whose _read_file_bytes consumption is likewise an unbounded to_thread — so holding this one to a stricter standard would leave the three providers inconsistent, which is the thing this PR exists to fix.

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 analyze_image path, so the pair together do cover the public contract.

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.

@linear-code

linear-code Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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.

@groupthinking

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Re-requesting — the earlier attempt hit the review rate limit. Since then the ## Risk section was materially rewritten to retract a false claim (that a local file read has no unbounded-wait failure mode) and issue #1234 was filed for the residual shared-executor starvation risk.

Please focus on:

  1. The decision not to add asyncio.wait_for. I argue a timeout bounds the caller but not the worker thread, so it would not mitigate executor starvation and claiming otherwise would be misleading. Measured evidence is in the PR body. If you think a caller-side deadline is still worth having on its own merits (responsiveness, not starvation), say so.
  2. _ThreadRecordingOpen correctness. It patches builtins.open and filters by exact path. Can any code path read the target file without going through builtins.openpathlib.Path.read_bytes, io.open, a C-level open — such that the assertion passes vacuously?
  3. The removed function-local import asyncio statements in _await_ocr_call, _perform_ocr, _perform_ocr_stream. Confirm no shadowing, no conditional-import semantics, and no circular-import behaviour depended on their being function-local.

@linear-code

linear-code Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

I agree with the current framing.

  • On wait_for: I agree it is not an executor-starvation mitigation here. In src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py, asyncio.wait_for(asyncio.to_thread(...)) bounds the awaiting coroutine, but it does not reclaim a worker already stuck inside the blocking call. A caller-side deadline can still be worth having for responsiveness or API-level timeout semantics, but only if it’s described that way; I would not ask for it in this PR as a starvation fix.
  • On _ThreadRecordingOpen: the current tests are sound for the current implementation in src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py and src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py. _read_file_bytes uses bare open(...), which resolves to builtins.open, so the hook is exercised. Also, the non-empty recorder.threads assertion means a bypass would fail the test rather than pass vacuously. The only caveat is future coupling: a rewrite to pathlib.Path.read_bytes(), io.open(), or lower-level os.open/C-extension I/O would bypass this patch and make the test fail noisily.
  • On removing the function-local imports: I confirm that’s behavior-neutral. src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py now has a module-level import asyncio, and _await_ocr_call, _perform_ocr, and _perform_ocr_stream only read that name. I don’t see any shadowing, conditional-import dependency, or circular-import sensitivity that relied on those imports being function-local.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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/unit

Length 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.py

Length 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 || true

Length 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 || true

Length 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 || true

Length of output: 16550


@groupthinking I found no merge-blocking production defect in the revised implementation.

  1. Do not add asyncio.wait_for to these local-file reads.
    Your reasoning is correct. wait_for can release the awaiting coroutine. It cannot stop a worker thread blocked in open() or read(). It therefore does not mitigate default-executor starvation.

    A caller deadline can still be useful for request responsiveness. It would change the method’s timeout and exception behavior. This PR has no established image-read deadline or timeout configuration. Do not add one here without defining that contract separately. #1234 is the correct place for executor isolation and any related policy.

  2. _ThreadRecordingOpen is sound for this implementation.
    The helper under test calls builtins.open(...).read(). The recorder wraps exactly that path. It records the thread at read(), not at open(). The test also requires recorder.threads to be non-empty.

    pathlib.Path.read_bytes() and io.open() could bypass builtins.open. A future implementation that uses either API will make this test fail because the recorder remains empty. It cannot pass vacuously. A direct C-level read has the same result unless it also invokes the patched builtins.open for the target path.

    The Google lazy-import mock does not invalidate this evidence. The target-path filter prevents unrelated module-loading file activity from satisfying the assertion.

  3. The three Azure local-import removals are safe and belong in this PR.
    azure_vision.py now has one unconditional module-level import asyncio. No function assigns to or otherwise shadows asyncio. The removed imports did not defer an optional Azure dependency. asyncio is a standard-library module, and the provider package import path does not create a cycle through it. The affected functions resolve the same module object after this change.

  4. Non-blocking test coverage gap:
    TestGoogleCloudImageReadOffEventLoop has no missing-file test. GoogleCloudAI.analyze_image catches FileNotFoundError in its broad except Exception and raises CloudAIError. This behavior is unchanged, but the PR body must not state that Google propagates FileNotFoundError unchanged. Add a test that asserts the existing CloudAIError wrapper behavior, or remove that claim from the PR description.

The URL branches remain unchanged. Azure returns None for HTTP and HTTPS inputs. Google sets image.source.image_uri.

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

Copy link
Copy Markdown
Owner Author

Addressed the actionable half of CodeRabbit's finding #4 (missing-file coverage) in e0961b2:

  • Added TestGoogleCloudImageReadOffEventLoop::test_missing_local_file_wrapped_in_cloud_ai_error, asserting that a missing local image raised through the public analyze_image surfaces as CloudAIError — the real contract, since analyze_image catches it in the broad except Exception. This is the correct discriminator: the test fails if raw FileNotFoundError ever leaks out. Verified: 6/6 off-loop tests pass (3 Azure, 3 Google), ruff clean.
  • This documents the intended asymmetry the reviewers noted: Azure's test exercises the private _prepare_image_input (propagates FileNotFoundError), Google's exercises public analyze_image (wraps in CloudAIError).

On the prose half — the ## Outcome "Does" row "Propagate FileNotFoundError and other I/O errors unchanged" — that's accurate for Azure's helper but imprecise for Google's public path, which wraps. Leaving that wording to @groupthinking since it's the PR description; the new test makes the true Google behavior explicit either way.

No change needed to _ThreadRecordingOpen (findings #1#3): both CodeRabbit and linear-code independently confirmed it records at read(), the non-empty recorder.threads assertion prevents a vacuous pass, and _read_file_bytes uses bare builtins.open — confirmed here too (the only local-read path in both providers is _read_file_bytes).


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Publish gate — HALTED(awaiting_merge_approval).

All required checks on head e0961b2 are green: test, build, lint-python, lint-frontend, guards, CodeQL, agent-completion/truth-gate, and every security scan (bandit / trivy / gitleaks / python-safety / npm-audit / dependency-review). Both review threads are resolved. The only job still running is Generate and Upload Coverage — a non-required informational upload — which is why mergeable_state shows unstable rather than clean.

Nothing else is blocking. This is held at the publish gate for human sign-off, not auto-merged: main is protected, and the default auto_merge_policy: label:automerge does not apply (this PR carries only python). Staged merge command for when you approve:

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

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>
@groupthinking

Copy link
Copy Markdown
Owner Author

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 FileNotFoundError propagates unchanged, while analyze_image wraps it. I verified this directly against source: AzureVision._prepare_image_input has no try/except (raw propagation), but GoogleCloudAI.analyze_image catches via except Exception as e: raise CloudAIError(...). The claim was globally false. I have:

  1. Corrected the **Does** row to state the contract per provider rather than globally.
  2. Added test_missing_local_file_wrapped_in_cloud_ai_error, which pins the real contract — and goes further than a bare pytest.raises(CloudAIError), since that alone would still pass if the underlying cause were swallowed. It also asserts the original FileNotFoundError survives on __context__ (the provider re-raises without from e, so chaining is implicit) and that the path error text reaches the caller.

@linear — your 86371fc43 caught a real hole in my test harness, and I verified it empirically rather than taking it on faith.

You were right that recording the thread at open() rather than at handle.read() meant a regression which offloads open() but reads bytes back on the event loop would pass undetected. I simulated exactly that regression against both harnesses:

harness records at open()  -> recorded=[6182580224]  loop_tid=8284626624  CATCHES REGRESSION: False
harness records at read()  -> recorded=[8284626624]  loop_tid=8284626624  CATCHES REGRESSION: True

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 ff3bf2581.

Re-verification after both corrections

Check Result
Targeted run (both provider test files) 186 passed
Pre-change replay vs origin/main sources 2 failed, 4 passed — only the two off-loop assertions fail, as intended
ruff check on all touched files clean

One process note on my own verification, since it nearly produced a false result: my first pre-change replay used git stash push <source-paths>, which was a no-op because those sources were already committed — so it silently re-tested the post-change code and reported everything passing. I caught it because the result disagreed with the earlier known-good 2 failed baseline, and redid it with git checkout origin/main -- <sources>. The numbers above are from the corrected method.

Both findings are closed. Requesting a final look before merge.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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,statusCheckRollup

Length of output: 86765


@groupthinking Final review found no merge-blocking defect.

The final diff correctly preserves the provider-specific error behavior:

  • AzureVision._prepare_image_input still propagates FileNotFoundError.
  • GoogleCloudAI.analyze_image still wraps the error in CloudAIError.
  • The Google test now verifies the implicit FileNotFoundError exception context.

The thread test is now strong enough for the intended regression. It records the thread that calls handle.read(), requires at least one target-file read, and rejects reads on the event-loop thread. It would fail if a future implementation used an unpatched read API, because no target read would be recorded.

The URL branches remain unchanged. Azure returns None. Google sets image.source.image_uri.

The Azure asyncio import consolidation is safe. The module-level import has no optional-import or circular-import effect. The three removed local imports resolved the same standard-library module.

The residual shared-default-executor risk remains real, but #1234 is the correct scope for executor isolation. Adding asyncio.wait_for here would change caller behavior without reclaiming a worker blocked in filesystem I/O.

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
Learnt from: groupthinking
URL: https://github.com/groupthinking/EventRelay/pull/1233

Timestamp: 2026-08-02T15:08:55.952Z
Learning: In `src/youtube_extension/integrations/cloud_ai/providers/azure_vision.py`, `AzureVision._prepare_image_input` propagates local-file `FileNotFoundError` directly. In `src/youtube_extension/integrations/cloud_ai/providers/google_cloud.py`, `GoogleCloudAI.analyze_image` catches local-file `FileNotFoundError` through its broad `except Exception` and raises `CloudAIError` with the original exception available as implicit `__context__`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Copy link
Copy Markdown
Owner Author

Independent verification (automated remediation run) — merge-ready, awaiting human approval.

Re-checked at head ff3bf258:

  • Red-team of the source diff — no defects. The azure_vision.py _prepare_image_input and google_cloud.py analyze_image changes correctly route the local read through a module-level _read_file_bytes via asyncio.to_thread. asyncio resolves at module scope in both files (Azure adds the module-level import while dropping its three now-dead function-local ones; Google already imported it at module scope and uses it elsewhere), so there is no NameError risk. URL branches and per-provider error contracts are preserved as described.
  • All PR gates green: CI, Coverage, Security Scan, CodeQL, Secret Scan, Dependency Review, PR Checks, PR Governance, and the agent-completion/truth-gate/pr-1233 (all rules passed) all success; E2E skipped by repo config; Vercel green. No merge conflict.
  • One non-blocking red herring: the only failing run on this SHA is .github/workflows/branch-cleanup.yml on the push event — an unrelated repo-maintenance workflow, not a PR gate. It's what holds mergeable_state at unstable rather than clean; it does not gate this merge.

This run does not auto-merge to protected main. Staged command for a human to run once approved:

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

Generated by Claude Code

@groupthinking
groupthinking merged commit 97deed8 into main Aug 2, 2026
41 checks passed
@groupthinking
groupthinking deleted the perf/vision-provider-image-read-off-loop branch August 2, 2026 15:15
@linear-code

linear-code Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

GRV-246

groupthinking added a commit that referenced this pull request Aug 3, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: Azure and Google vision providers read local image bytes on the event loop

3 participants