perf: isolate blocking file I/O from the shared default executor (#1234) - #1241
perf: isolate blocking file I/O from the shared default executor (#1234)#1241groupthinking wants to merge 1 commit into
Conversation
`asyncio.to_thread` is `run_in_executor(None, ...)`, so every offload in the repo lands on one loop-wide `ThreadPoolExecutor` (16 workers here). A stalled read on NFS/FUSE/remote-backed storage permanently leaks its worker slot: cancelling the task releases the *caller*, but `concurrent.futures` cannot interrupt a thread parked in a syscall, so the worker stays stuck. Timeouts therefore bound the coroutine, never the thread. Enough concurrent stalls starve every other offload in the process. Add `utils/blocking_io.py`: a lazily-built, explicitly-sized, process-wide pool (`BLOCKING_IO_MAX_WORKERS`, default 8) plus `run_blocking()`. Blast radius is now contained to that pool instead of the shared default one. - `functools.partial` so kwargs work (`run_in_executor` accepts none). - In-flight accounting is attached to the *concurrent* future, not the asyncio one, so a timed-out caller keeps counting its still-running worker rather than hiding the leaked slot the instrumentation exists to reveal. - Throttled saturation warning (30s) with structured `extra`. - `reset_blocking_io_executor()` shuts down with `wait=False`; blocking on a stuck worker would reintroduce the very hang this contains. Migrate the three `_read_file_bytes` sites named in AC1 only (Azure, Google, AWS vision). The remaining ~93 offloads are left for incremental migration. Tests pin isolation in both directions: saturate the I/O pool and unrelated `to_thread` work still completes; saturate the *default* pool and the vision reads still complete. The second direction fails on `main`, so it is a real behavioral regression guard rather than an import smoke test. Verified: 16/16 new tests pass; 313/313 existing provider tests pass both with and without the change. Three negative controls each fail a distinct, targeted subset - routing offloads back to the default pool fails 5, leaving providers on `to_thread` fails only the 4 routing tests, and deleting the saturation warning fails only the AC3 test. Refs #1234 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🔍 PR Validation |
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 |
|
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 |
Agent Completion Truth Gate: BLOCKEDReasons: Machine-readable verdict{
"details": {
"collection_errors": [
"incomplete_linked_issue_contract",
"missing_intent_snapshot",
"missing_agent_run_id",
"missing_agent_login"
],
"invalid_fields": [
"policy.agent_login",
"policy.run_id"
]
},
"reasons": [
"invalid_payload"
],
"verdict": "blocked"
} |
|
@copilot resolve the merge conflicts on this branch. |
Fixes #1234
The defect
asyncio.to_thread(...)is literallyloop.run_in_executor(None, ...). Both resolve to the sameloop._default_executorobject — verified at runtime, not assumed:A blocking read handed a path on NFS/FUSE/remote-backed storage can stall without limit. When it does, the worker slot is leaked permanently:
concurrent.futurescannot interrupt a thread parked in a syscall. Cancellation frees the awaiting coroutine and nothing else. This is why timeouts are not the fix — a caller-side deadline bounds the coroutine, never the thread. Isolation is the only containment.Blast radius is ~50% larger than the issue states
The issue counts 66
to_threadcall sites. Measured onmain:asyncio.to_thread(...)run_in_executor(...)None-> defaultA helper that only replaces
to_threadwould leave 31 sites exposed. Worth scoping AC4's incremental migration to both families.(The issue's "1 site bounded by a caller-side
wait_for" claim is correct —azure_vision.py:272. It's only findable with a multiline grep, sinceasyncio.wait_for(andasyncio.to_thread(sit on separate lines. A single-line grep returns 0 and would wrongly suggest the issue was mistaken.)The fix
New
src/youtube_extension/utils/blocking_io.py:ThreadPoolExecutor(BLOCKING_IO_MAX_WORKERS, default 8). Invalid values log and fall back rather than raising at import time.async run_blocking(func, *args, timeout=None, **kwargs).functools.partialbinding, becauserun_in_executoraccepts no kwargs.extra— AC3.reset_blocking_io_executor()usesshutdown(wait=False); blocking on a genuinely stuck worker would reintroduce the exact hang this module exists to contain.One subtlety worth reviewing. In-flight accounting is attached to the concurrent future, not the asyncio one:
A naive
try/finally: _exit()decrements when the coroutine returns — so a caller released bytimeoutwould stop counting a worker that is still stuck, hiding precisely the leaked slots the instrumentation exists to reveal. Cancelling the asyncio side does not fire the concurrent callback, so_in_flightreflects true worker occupancy.Scope
Migrates only the three
_read_file_bytessites named in AC1 (Azure / Google / AWS vision). The remaining ~93 offloads are deliberately left for incremental migration — a repo-wide sweep would be unreviewable. Diff is +9 / −3 across the three providers.azure_vision.py::_await_ocr_callis intentionally untouched: it wraps SDK calls, not file reads.Verification
Tests pin isolation in both directions, because one direction alone is not enough:
to_threadwork still completes. Cannot fail onmain— there is no I/O pool there.main, so it is a genuine regression guard rather than an import smoke test.Existing provider tests only assert reads happen off-loop. Running off-loop but still on the shared pool is this issue's failure mode, so that coverage does not subsume these tests.
TimeoutError, 1 passedNegative controls — each fails a distinct, targeted subset, so the suite discriminates rather than merely reddening:
to_threadNC-2 is the important one: it proves the tests assert routing, not merely that a helper exists.
Note for anyone writing similar saturation tests
Two harness bugs produced false REDs here — reds that failed for the wrong reason:
asyncio.create_task(...)doesn't start the task until the loop regains control, so synchronously callingsemaphore.acquire(timeout=...)right after blocks the loop and no hog ever reaches the pool. Fix: non-blockingacquire(blocking=False)in a poll loop withawait asyncio.sleep(0.01). Runtime went 200s -> 0.3s.loop.set_default_executor(None)raisesTypeError. Teardown restoring a previously-Nonedefault executor crashes and masks the real assertion outcome. Fix: assignloop._default_executor = Nonedirectly in that case.Both files carry an explicit positive control (
assert_default_pool_is_blocked()) so they can never pass vacuously.