perf(performance-monitor): move blocking sqlite3 I/O off the event loop - #1196
Conversation
`PerformanceMonitor` used the fully synchronous `sqlite3` driver directly inside six `async def` methods. Each call ran connect + statement + commit (which fsyncs) + close on the event loop, so nothing else on the loop could be scheduled for the duration of the disk write. This is on a live request path: `api/v1/router.py:69` imports the monitor and awaits `record_metric()` at `:1165` and `:1183`, and `record_metric` calls `_store_metric` unconditionally. Each method's database work is now a nested synchronous function dispatched via `await asyncio.to_thread(...)`. Statement text, transaction boundaries, return values and error handling are unchanged; only the thread the work runs on changes. Connections are created and closed inside each call, so there is no shared handle a caller could tear down mid-flight. Sites moved off-loop: _store_metric, _store_alert, _basic_cleanup, get_current_performance_summary, _get_recent_metrics_summary, _store_benchmark_result `_init_database` is left alone: it is a synchronous method called from `__init__`, so it never runs on the event loop. Tests: 113 pre-existing pass with zero edits, plus 6 new in `TestSqliteDoesNotBlockEventLoop`. Non-vacuity was proven by behavioural mutation: replacing all six `to_thread` dispatches with direct calls fails exactly the 3 heartbeat tests and no others. The other 3 new tests are preserved-behaviour guards -- they pass under both old and new code and exist to pin behaviour the change must not regress, not to prove the change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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 |
|
@coderabbitai review Please be adversarial. Five specific challenges I want pressure-tested:
Context: this is the 8th in a series of event-loop-blocking fixes. #1190 in the same series introduced a real cancellation regression that a reviewer caught, so I would rather over-scrutinise this one. |
Agent Completion Truth Gate: BLOCKEDReasons: Machine-readable verdict{
"details": {
"invalid_fields": [
"policy.agent_login",
"policy.run_id"
]
},
"reasons": [
"invalid_payload"
],
"verdict": "blocked"
} |
There was a problem hiding this comment.
🟢 Ready to approve
All scoped SQLite operations are safely offloaded with focused regression coverage.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Moves synchronous SQLite work off the event loop while preserving existing behavior.
Changes:
- Dispatches six database operations through
asyncio.to_thread. - Adds responsiveness and behavior-regression tests.
File summaries
| File | Description |
|---|---|
performance_monitor.py |
Offloads SQLite reads, writes, and cleanup. |
test_performance_monitor.py |
Verifies loop responsiveness and persistence behavior. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Adversarial review (automated, review-requested trigger)Reviewed the full diff at head Code correctness: no blocking defect. All six sites use the identical, correct transformation: the original body moves verbatim into a nested Answering the five challenges:
The actual merge blocker is not this diff. The failing/blocking checks are (a) Not merging: base is protected Generated by Claude Code |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- PR diff summary ---'
git diff --stat origin/main...HEAD || true
git diff --unified=50 origin/main...HEAD -- '*performance_monitor.py' '*test*performance*' || true
echo '--- candidate files ---'
fd -t f -e py | rg 'performance_monitor|router|memory_manager|load_balancer|database_optimizer|test'
echo '--- implementation outline ---'
pm_file="$(fd -t f -e py 'performance_monitor.py' | head -n 1)"
if [ -n "${pm_file:-}" ]; then
ast-grep outline "$pm_file" --items all
echo "--- $pm_file: sqlite/lifecycle/dispatch references ---"
rg -n -C 8 'sqlite3\.connect|to_thread|def (_store_metric|_store_alert|_basic_cleanup|get_current_performance_summary|_get_recent_metrics_summary|_store_benchmark_result|stop_monitoring)|\b(remove|unlink|rmtree)\b|database.*path|db_path' "$pm_file"
fi
echo '--- project-wide teardown and database deletion references ---'
rg -n -i -C 5 '(stop_monitoring\s*\(|performance_monitor|sqlite.*(remove|unlink)|\b(remove|unlink|rmtree)\s*\(|os\.remove|Path\(.*\)\.unlink)' --glob '*.py' .Length of output: 41757 🏁 Script executed: #!/bin/bash
set -euo pipefail
pm='src/youtube_extension/backend/services/performance_monitor.py'
tests='tests/unit/test_performance_monitor.py'
echo '--- PerformanceMonitor construction and lifecycle ---'
sed -n '1,250p' "$pm"
echo '--- all SQLite dispatch methods, with complete bodies ---'
sed -n '250,370p' "$pm"
sed -n '480,610p' "$pm"
sed -n '760,840p' "$pm"
echo '--- PerformanceMonitor teardown and database-path references ---'
rg -n -C 12 'def stop_monitoring|stop_monitoring\(|self\.db_path|db_path\s*=|os\.(remove|unlink)|Path\(.*\)\.(unlink|rm)|shutil\.rmtree|TemporaryDirectory' \
"$pm" tests src --glob '*.py'
echo '--- direct callers and their surrounding lifecycle context ---'
rg -n -C 10 'record_metric\s*\(|_cleanup_old_metrics\s*\(|get_current_performance_summary\s*\(|get_performance_dashboard\s*\(' \
src/youtube_extension/backend/api/v1/router.py \
src/youtube_extension/backend/services/memory_manager.py \
src/youtube_extension/backend/services/load_balancer.py \
src/youtube_extension/backend/services/database_optimizer.py 2>/dev/null || true
echo '--- full new test class ---'
sed -n '1030,1180p' "$tests"Length of output: 137893
My assessment of the five challenges:
Failed to handle agent chat message. Please try again. You are interacting with an AI system. |
Review round 4, finding 2 (CodeRabbit, Stability & Availability, Major). This PR moved 14 blocking boto3 calls onto the *shared* default asyncio executor via asyncio.to_thread. botocore's defaults leave a request effectively unbounded, so a stalled AWS call would now pin one of that pool's limited worker threads indefinitely and starve every other to_thread user in the process -- including the metrics persistence (#1194), sqlite access (#1196) and result writes (#1203) already merged onto that same pool. _wait_for_job_completion can issue up to 120 such calls per job, so the exposure is real rather than theoretical. Both the Rekognition and S3 clients are now constructed with an explicit botocore Config carrying connect_timeout, read_timeout and a bounded standard-mode retry policy. Values are overridable via AWS_REKOGNITION_CONNECT_TIMEOUT / _READ_TIMEOUT / _MAX_ATTEMPTS and are validated with math.isfinite, raising rather than clamping so that 'inf', 'nan', '0' and negatives are rejected outright. Parsing happens before initialize()'s try block: that method ends in a catch-all `except Exception -> CloudAIError`, which would otherwise bury a precise ConfigurationError message behind a generic init failure. Tests: 127 pass (105 pre-existing + 22 new). Non-vacuity proven by mutating both dimensions simultaneously -- making the env helpers ignore the environment and dropping `config=` from both client constructions yields exactly 18 targeted failures / 109 passed, matching the predicted count (1 client-config + 1 override + 12 timeout rejections + 4 max-attempts rejections). ruff parity with origin/main unchanged (8 = 8). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…1205) * perf(cloud-ai): run AWS Rekognition boto3 calls off the event loop boto3 is a synchronous SDK. Every Rekognition call in AWSRekognition was issued directly inside an `async def`, so each one blocked the event loop for a full network round-trip. `_wait_for_job_completion` is the worst case: it polls every 5s for up to 600s, so a single video analysis could stall the loop up to 120 times. All 14 boto3 calls now dispatch via `await asyncio.to_thread(...)`, and the local-image read in `_prepare_image_input` goes through a new module-level `_read_file_bytes` helper on the same path. - 89 pre-existing tests pass with zero edits - 6 new heartbeat tests (`TestRekognitionDoesNotBlockEventLoop`); 5 of the 6 discriminate, proven by reverting both dimensions simultaneously (5 targeted failures / 90 passed) - ruff: exact parity with origin/main (8 pre-existing findings, 0 added) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(rekognition): assert the local read leaves the loop thread, not elapsed ticks The heartbeat form of this one test failed on CI. Unlike the four boto3 tests, which drive a controllable 0.12s mock, the local file read is a few microseconds of real work, so "did the loop tick while it ran" is a load-sensitive proxy rather than a property. Assert the property directly instead: record `threading.get_ident()` inside `_read_file_bytes` and require it to differ from the thread running the event loop. That is exactly what "dispatched off the loop" means, needs no sleeps, and cannot flake under runner contention. - 95 tests pass (89 pre-existing, unmodified, + 6 new) - Non-vacuity: calling `_read_file_bytes` directly instead of via `asyncio.to_thread` yields exactly 1 targeted failure / 94 passed - Suite runtime for the file drops to 0.85s (the 0.12s sleep is gone) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(rekognition): cover every blocking SDK operation * test(rekognition): make off-loop read test robust to module eviction * perf(rekognition): bound AWS client requests with botocore timeouts Review round 4, finding 2 (CodeRabbit, Stability & Availability, Major). This PR moved 14 blocking boto3 calls onto the *shared* default asyncio executor via asyncio.to_thread. botocore's defaults leave a request effectively unbounded, so a stalled AWS call would now pin one of that pool's limited worker threads indefinitely and starve every other to_thread user in the process -- including the metrics persistence (#1194), sqlite access (#1196) and result writes (#1203) already merged onto that same pool. _wait_for_job_completion can issue up to 120 such calls per job, so the exposure is real rather than theoretical. Both the Rekognition and S3 clients are now constructed with an explicit botocore Config carrying connect_timeout, read_timeout and a bounded standard-mode retry policy. Values are overridable via AWS_REKOGNITION_CONNECT_TIMEOUT / _READ_TIMEOUT / _MAX_ATTEMPTS and are validated with math.isfinite, raising rather than clamping so that 'inf', 'nan', '0' and negatives are rejected outright. Parsing happens before initialize()'s try block: that method ends in a catch-all `except Exception -> CloudAIError`, which would otherwise bury a precise ConfigurationError message behind a generic init failure. Tests: 127 pass (105 pre-existing + 22 new). Non-vacuity proven by mutating both dimensions simultaneously -- making the env helpers ignore the environment and dropping `config=` from both client constructions yields exactly 18 targeted failures / 109 passed, matching the predicted count (1 client-config + 1 override + 12 timeout rejections + 4 max-attempts rejections). ruff parity with origin/main unchanged (8 = 8). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* 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 #1195
Outcome
PerformanceMonitorused the synchronoussqlite3driver directly inside sixasync defmethods. Every call ran connect → statement →commit()(which fsyncs) →close()on the event loop, so nothing else could be scheduled for the duration of the disk write.This is on a live request path —
router.py:1165and:1183await performance_monitor.record_metric(...), andrecord_metriccalls_store_metricunconditionally (no sampling, no enable flag).The honest claim is "stops stalling the event loop", not "faster". Under concurrent load that is the difference that matters: a request that records a metric no longer freezes every other in-flight request while the disk write completes.
Scope
Six sites, all in
performance_monitor.py, all the same defect and the same fix:_store_metric·_store_alert·_basic_cleanup·get_current_performance_summary·_get_recent_metrics_summary·_store_benchmark_resultEach body moved verbatim into a nested sync function dispatched with
await asyncio.to_thread(...). Because the original bodies were already indented insidetry:, they move into the nesteddefwithout re-indentation — so the diff is genuinely structural, not a rewrite.Design notes
Why not
aiosqlite? It is not a dependency of this project, and adding a driver is a much larger change than this defect warrants.asyncio.to_threaduses the default executor and needs no new dependency.Why nested functions rather than methods? They close over
selfand the local variables (cutoff_date,metric, …) that the original code already computed, so no signature or state has to be threaded through.No use-after-close hazard. Converting a synchronous call to
to_threadcan introduce a use-after-close race when a caller tears the resource down in afinally— that exact bug was found in #1190. It does not apply here: every connection is opened and closed inside the same call, so there is no shared handle for a caller to close underneath an in-flight statement.PerformanceMonitorhas noclose();stop_monitoring()only clears a task flag._init_databasedeliberately untouched. It is a synchronous method called from__init__— it never runs on the event loop, so wrapping it would add noise and no benefit.Risk
Low.
sqlite3connections are thread-affine (check_same_thread=Trueby default). That constraint is satisfied, not violated: each connection is created, used and closed entirely within a singleto_threadcall, so it never crosses threads.except Exceptionhandlers are unchanged.Verification
All results below are at head
b38b9fdb3.TestSqliteDoesNotBlockEventLoop: 3 loop-responsiveness tests and 3 preserved-behaviour guardsNon-vacuity, by behavioural mutation. Deleting the code would raise
AttributeErrors, which proves nothing. Instead all sixto_threaddispatches were replaced with direct synchronous calls, keeping everything else identical:Exactly the 3 responsiveness tests fail and nothing else. A uniform "everything failed" would have meant the proof was structural rather than behavioural.
The test measures whether an independent heartbeat coroutine keeps getting scheduled while
sqlite3.connectis artificially slowed:Blocking → 0 ticks. Off-loop → > 0.
Honest note: the 3 guards (
test_store_metric_still_writes_the_row,test_summary_reflects_stored_metrics,test_store_metric_swallows_database_errors) pass under both old and new code by design. They exist to pin behaviour the change must not regress, not to prove the change. Only the 3 heartbeat tests discriminate.ruff:
All checks passed!on both touched files, exact parity withmain.Production evidence
youtube_extension.backend.services.performance_monitoris in the transitive import closure of the production entrypointyoutube_extension.main:app(rootDockerfile:93).Other live callers:
memory_manager.py:472/475,load_balancer.py:381,database_optimizer.py:49.Agent handoff
Reviewers: the interesting questions are in Design notes and the Verification mutation output. Specific challenges are posted as a separate comment.