perf(metrics): move MetricsService disk I/O off the event loop - #1194
Conversation
MetricsService.record_metric persists the entire metrics file every 10th
data point, and it does so with a synchronous open()/write(). That runs on
the same event loop that is serving HTTP requests, so every other task is
stalled for the duration of the disk write.
This is on a live request path:
router.py:467 POST transcript-action (Depends(get_metrics_service))
-> TranscriptActionWorkflow(metrics_service=...)
-> workflow._record_metric (9 call sites)
-> MetricsService.record_metric
-> every 10th point: _persist_metrics -> open()/write()
load_persisted_metrics had the same problem for the read path, where both
the open() and the json.load() block and scale with file size.
Both file operations now run via asyncio.to_thread through small static
helpers (_write_text_file / _read_json_file), which also makes them
patchable in tests. No behaviour change: same payload written, same return
values, same exception swallowing.
Verification:
- 65 pre-existing tests pass with zero test edits
- 7 new tests (72 total); 3 measure event-loop responsiveness directly
- non-vacuity proven by behavioural mutation (call helpers directly instead
of via to_thread): exactly those 3 fail with "heartbeat ticked 0 times",
while the 4 preserved-behaviour guards still pass
- ruff: exact parity with origin/main on both files
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 |
|
@coderabbitai review Specific challenges rather than a general pass:
|
Agent Completion Truth Gate: NOT_APPLICABLEEvidence agrees. Machine-readable verdict{
"details": {},
"reasons": [],
"verdict": "not_applicable"
} |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== changed files =='
git diff --name-status HEAD^ HEAD || true
echo '== metrics-service candidates =='
fd -i 'metrics_service.py' .
fd -i '*metrics*' . | head -80
echo '== outlines =='
while IFS= read -r f; do
ast-grep outline "$f" --items all --type function,method,class --match 'MetricsService|record_metric|_persist_metrics|load_persisted_metrics|export_metrics|_write_text_file|_read_json_file|TestDiskIoDoesNotBlockEventLoop' || true
done < <(fd -i 'metrics_service.py' .; fd -i '*metrics*test*.py' .; fd -i 'test*metrics*.py' .)
echo '== implementation and test references =='
rg -n -C 5 -g '*.py' \
'class MetricsService\b|async def _persist_metrics\b|def _write_text_file\b|def _read_json_file\b|async def load_persisted_metrics\b|async def record_metric\b|def export_metrics\b|TestDiskIoDoesNotBlockEventLoop|heartbeat|chdir\(|metrics_file' .
echo '== PR diff for relevant Python files =='
git diff HEAD^ HEAD -- '*.py'Length of output: 1781 🏁 Script executed: #!/bin/bash
set -euo pipefail
impl='src/youtube_extension/backend/services/metrics_service.py'
tests='tests/unit/test_metrics_service.py'
echo '== implementation outline =='
ast-grep outline "$impl" --items all --view expanded
echo '== implementation: relevant methods and imports =='
rg -n -C 12 \
'^(import|from) |class MetricsService\b|async def record_metric\b|async def _persist_metrics\b|def _write_text_file\b|def _read_json_file\b|async def load_persisted_metrics\b|def export_metrics\b|metrics_file|self\.metrics' \
"$impl"
echo '== test outline =='
ast-grep outline "$tests" --items all --view expanded
echo '== test: heartbeat and persistence tests =='
rg -n -C 18 \
'class TestDiskIoDoesNotBlockEventLoop\b|async def test_.*(block|persist|record|load)|heartbeat|sleep\(|_write_text_file|_read_json_file|record_metric|load_persisted_metrics' \
"$tests"
echo '== PR diff =='
git diff HEAD^ HEAD -- "$impl" "$tests"Length of output: 16007 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== tracked metrics tests =='
git ls-files | rg -i '(^|/)(test_.*metrics.*|.*metrics.*test)\.py$' || true
echo '== filesystem metrics tests =='
fd -i --glob '*metrics*.py' . || true
echo '== heartbeat test definitions and bodies =='
rg -n -C 25 -g '*.py' \
'TestDiskIoDoesNotBlockEventLoop|test_persist_does_not_block_event_loop|test_record_metric_flush_does_not_block|test_load_does_not_block_event_loop|heartbeat' . || true
echo '== export_metrics definition and call sites =='
rg -n -C 4 -g '*.py' \
'\bexport_metrics\s*\(' . || true
echo '== runtime current-working-directory changes =='
rg -n -C 4 -g '*.py' \
'\b(os\.)?chdir\s*\(' . || true
echo '== exact PR patch (no test path assumption) =='
git diff HEAD^ HEAD --name-only | while IFS= read -r f; do
case "$f" in
*.py) git diff HEAD^ HEAD -- "$f" ;;
esac
doneLength of output: 48578
I would address the write/load race before merge. The path binding is a low-cost hardening fix for the new worker-thread timing window. You are interacting with an AI system. |
There was a problem hiding this comment.
Pull request overview
Moves MetricsService file reads and writes to worker threads to reduce event-loop blocking.
Changes:
- Adds threaded file I/O helpers.
- Adds responsiveness and behavior-regression tests.
- Preserves existing persistence formats and APIs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
metrics_service.py |
Offloads persistence reads and writes. |
test_metrics_service.py |
Tests event-loop responsiveness and persistence behavior. |
groupthinking
left a comment
There was a problem hiding this comment.
Reviewed at head 10542451d. Clean, well-scoped change, and the test suite is unusually rigorous — the heartbeat loop-responsiveness tests with behavioral-mutation proof (swapping to_thread for a direct call flips exactly the 3 discriminating tests while the 4 guards hold) genuinely demonstrate the property rather than just structure. to_thread over aiofiles is the right call for a single write, and the @staticmethod helpers correctly avoid capturing self in the worker thread.
One substantive point — non-blocking: concurrent flushes can now interleave on disk (a new failure mode).
Previously the write ran synchronously on the single-threaded loop, so two overlapping _persist_metrics calls serialized — each open('w')+write() completed atomically with respect to every other task, and the file always held one complete JSON document. After this change both writes are dispatched to the default thread-pool executor and can run in two worker threads simultaneously. I confirmed there is no asyncio.Lock in the class, and record_metric is reached from concurrent request paths plus get_system_metrics (which records three metrics per tick), so two points crossing the % 10 == 0 boundary close together will both flush. Two concurrent open(path, 'w') → truncate+write can leave a torn / partially-written metrics.json.
Impact is low, which is why I wouldn't block: persistence is best-effort (errors are swallowed), load_persisted_metrics discards the parsed result and catches JSONDecodeError → returns False, so a corrupt file is self-healing with no functional effect today. But since the point of the PR is hardening exactly this path, an atomic write closes it cleanly and also makes the write crash-safe (the current inline write isn't):
@staticmethod
def _write_text_file(path: Path, contents: str) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w") as f:
f.write(contents)
os.replace(tmp, path) # atomic on POSIX & Windows (needs `import os`)An asyncio.Lock around _persist_metrics would also serialize it, but temp-file + os.replace additionally survives a mid-write crash, so it's the stronger fix for the same line count.
Minor / pre-existing (not introduced here):
load_persisted_metricsstill discards the parsed JSON ("simplified … full restoration") — unchanged by this PR; just noting the read validates but doesn't apply the data.export_metrics("json")'sjson.dumpsstaying on the loop is correctly called out as an intentional out-of-scope follow-up; agreed that folding an API break into a perf PR would be the wrong move.
CI: the code-quality checks are green (CodeQL, bandit, python-safety, trivy, npm-audit, dependency-review, validate, agent-completion/truth-gate, copilot reviewer). The red checks — Agent completion enforcement, PR Governance, Canonical issue and evidence, and gitleaks (working tree) — are the repo-wide governance/infra gates rather than failures of this diff (no secrets are added here; the gitleaks job scans the whole working tree, not the PR delta). These are the same gates that were red on #1108 / #1103 / #1098.
Net: LGTM on the mechanism. The concurrent-write hardening above is worth a look but is non-blocking.
Generated by Claude Code
Automated review — code is sound; the only real-CI red is an unrelated false positiveChange (verified against the GitHub PR diff, which is authoritative here — two-dot
The PR's own mutation-based non-vacuity argument checks out: replacing the CI status: all real code checks are green — The one red real-CI check, Remaining blockers to merge are not code: the Generated by Claude Code |
Two regressions introduced by moving the metrics-file write off the event loop, both reported in review on #1194. 1. Lost serialisation. Running the write in a worker thread removed the event loop's implicit serialisation. `MetricsService` is a process-wide singleton, so two requests could enter `_persist_metrics` concurrently and both open the same path with "w", interleaving their output or letting a stale snapshot land after a newer one. Persistence now runs under an `asyncio.Lock`, created lazily and keyed to the running loop so a singleton constructed on one loop cannot bind a lock to a dead one. 2. Non-atomic write. `open(path, "w")` truncates before it writes, so a concurrent reader could observe an empty or partial file. The write now goes to a temporary file in the same directory and is moved into place with `os.replace`, which is atomic; a reader sees either the complete previous file or the complete new one. The temporary file is removed if the write fails. Also drops the `Path.exists()` pre-flight check in `load_persisted_metrics`. It was a synchronous `stat()` on the event loop -- the exact defect this PR set out to remove -- and it opened a TOCTOU window between the check and the read. A missing file is now signalled by `FileNotFoundError` from the threaded open and mapped to `False`, which is the same observable result. Tests: 72 pre-existing pass with zero edits, plus 6 new. Non-vacuity was proven by reverting all three changes at once; exactly four tests fail, one per dimension: FAILED test_concurrent_persists_never_overlap (lock) FAILED test_reader_never_sees_a_truncated_file (atomic write) FAILED test_load_does_not_stat_the_path_on_the_loop (exists check) FAILED test_write_cleans_up_temp_file_on_failure (temp cleanup) 4 failed, 74 passed Both findings were raised by @Copilot and both were correct. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@copilot Both findings were correct and both are fixed in
On finding 1 — you were right that it contradicted the stated outcome, and right that dropping it also closes a TOCTOU window. The observable contract is unchanged: On finding 2 — this is the more serious one and I want to be explicit that it was a regression this PR created. Before the change the write was synchronous, so the event loop serialised it for free; moving it to a worker thread silently removed that guarantee for a singleton service. I implemented both halves of your suggestion:
Proof is a mutation test that reverts all three changes simultaneously and produces exactly four targeted failures, one per dimension — see the Verification section. One thing I did not do: I did not add a queue or coalescing. @coderabbitai review — please re-review at |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
impl=$(fd -i -t f '^metrics_service\.py$' . | head -1)
tests=$(fd -i -t f '^test_metrics_service\.py$' . | head -1)
printf '== revision ==\n'
git rev-parse HEAD
printf '\n== files ==\n%s\n%s\n' "$impl" "$tests"
printf '\n== implementation outline ==\n'
ast-grep outline "$impl" --items all --view expanded
printf '\n== lock, persistence, and export implementation ==\n'
rg -n -C 18 \
'^(import|from) |class MetricsService\b|async def record_metric\b|async def _persist_metrics\b|async def load_persisted_metrics\b|async def export_metrics\b|def _write_text_file\b|def _read_json_file\b|_persist|_lock|Lock\(|get_running_loop|metrics_file|self\.metrics' \
"$impl"
printf '\n== relevant tests ==\n'
rg -n -C 16 \
'class Test.*(Persistence|DiskIo)|async def test_.*(persist|load|concurrent|truncated|temp|lock)|_persist_metrics|_persist_lock|asyncio\.Lock|export_metrics|to_thread|heartbeat' \
"$tests"
printf '\n== exact relevant diff ==\n'
git diff HEAD^ HEAD -- "$impl" "$tests"Length of output: 19643 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '== metrics-related test files ==\n'
fd -i -t f '*metrics*.py' . | sort
printf '\n== persistence and lock tests ==\n'
rg -n -C 14 -g '*.py' \
'TestPersistenceIsSerialisedAndAtomic|TestDiskIoDoesNotBlockEventLoop|test_concurrent_persists_never_overlap|test_reader_never_sees_a_truncated_file|test_load_does_not_stat_the_path_on_the_loop|test_write_cleans_up_temp_file_on_failure|_get_persist_lock|_persist_lock_loop|new_event_loop|set_event_loop' \
.
printf '\n== MetricsService construction and service-provider sites ==\n'
rg -n -C 7 -g '*.py' \
'MetricsService\s*\(|get_metrics_service\s*\(|metrics_service\s*=' \
src tests
printf '\n== event-loop and thread entry points near service use ==\n'
rg -n -C 5 -g '*.py' \
'new_event_loop\s*\(|asyncio\.run\s*\(|run_coroutine_threadsafe\s*\(|Thread\s*\(' \
src testsLength of output: 1499 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '== metrics-related test files ==\n'
fd -i -t f --glob '*metrics*.py' . | sort
printf '\n== persistence and lock tests ==\n'
rg -n -C 14 -g '*.py' \
'TestPersistenceIsSerialisedAndAtomic|TestDiskIoDoesNotBlockEventLoop|test_concurrent_persists_never_overlap|test_reader_never_sees_a_truncated_file|test_load_does_not_stat_the_path_on_the_loop|test_write_cleans_up_temp_file_on_failure|_get_persist_lock|_persist_lock_loop|new_event_loop|set_event_loop' \
. || true
printf '\n== MetricsService construction and service-provider sites ==\n'
rg -n -C 7 -g '*.py' \
'MetricsService\s*\(|get_metrics_service\s*\(|metrics_service\s*=' \
src tests || true
printf '\n== event-loop and thread entry points near service use ==\n'
rg -n -C 5 -g '*.py' \
'new_event_loop\s*\(|asyncio\.run\s*\(|run_coroutine_threadsafe\s*\(|Thread\s*\(' \
src tests || trueLength of output: 51731 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '== commit files ==\n'
git diff-tree --no-commit-id --name-status -r HEAD
printf '\n== tracked test paths ==\n'
git ls-tree -r --name-only HEAD | rg -i '(^|/)(test|tests)(/|_)|metrics.*test|test.*metrics' | head -250 || true
printf '\n== service container structure and metrics registration ==\n'
container='src/youtube_extension/backend/containers/service_container.py'
ast-grep outline "$container" --items all --view expanded
rg -n -C 14 \
'class ServiceContainer\b|def get_service\b|def _create_metrics_service\b|metrics_service|singleton|self\._services|self\.services' \
"$container"
printf '\n== deployed application entrypoint and container initialization ==\n'
rg -n -C 10 -g '*.py' \
'FastAPI\s*\(|get_service_container\s*\(|ServiceContainer\s*\(|initialize.*container|container\.(initialize|register)' \
src/youtube_extension/main.py src/youtube_extension/backend 2>/dev/null || true
printf '\n== persisted-metrics test content from commit, if present ==\n'
git ls-tree -r --name-only HEAD | rg -i 'metrics.*(test|spec)|test.*metrics' | while IFS= read -r f; do
printf '\n--- %s ---\n' "$f"
git show "HEAD:$f" | rg -n -C 10 \
'TestPersistenceIsSerialisedAndAtomic|TestDiskIoDoesNotBlockEventLoop|concurrent_persists|truncated_file|does_not_stat|temp_file|persist_lock' || true
doneLength of output: 46234
The persistence fix is sound for the deployed FastAPI path.
The lock must remain held through If the lock releases before the write completes, this order can occur:
The file then regresses to the older snapshot. The current critical section prevents that result. Holding the lock during There is one boundary to document or harden:
I found no evidence that the primary FastAPI deployment shares this If multi-loop or cross-thread access becomes supported, do not rely on replacing one A queue or coalescing mechanism is not required for correctness here. It is a valid future throughput improvement if persistence frequency becomes a production bottleneck. 🐇 You are interacting with an AI system. |
* perf(video): run enhanced-result persistence off the event loop
`EnhancedVideoProcessor._save_enhanced_result` is `async def`, but every
byte of work inside it was synchronous and executed on the event loop:
1. `save_dir.mkdir(parents=True, exist_ok=True)` - directory syscalls
2. `open(filepath,'w') / f.write(markdown)` - the full analysis doc
3. `json.dump(metadata, f, indent=2)` - serialises AND writes
incrementally, so a large metadata dict became many small `write()`
syscalls rather than one
It is called from `process_video` (line 189), which is reached in
production via `service_container.py:253` -> `video_processor_factory
.get_video_processor()` -> `EnhancedVideoProcessor()`. While a video's
results were being saved, every other in-flight request on that worker
was stalled.
This change hands the whole group - mkdir, both writes, and the
`json.dumps` - to a worker thread in a *single* `asyncio.to_thread`
dispatch, so the save costs one thread hop rather than one per syscall,
and the serialisation cost is paid off-loop too.
Writes are also made atomic. `open(path,'w')` truncates before it
writes, so a crash or a concurrent reader can leave/observe a
half-written analysis on disk. `_atomic_write_text` writes a sibling
temp file and `os.replace`s it into place; the temp name carries the pid
and thread id so two writers cannot collide, and it is unlinked if the
write fails. This pre-empts the lost-serialisation class of bug that
review caught on #1194: moving a write off-loop removes the event
loop's implicit serialisation, so the write must become atomic.
Honest framing: this does NOT make saving faster. It stops saving from
stalling the event loop, and it stops partial files from being visible.
Tests: 104 passed = 98 pre-existing (zero edits) + 6 new.
Non-vacuity proven by reverting both dimensions simultaneously
(atomic write -> plain open, to_thread -> direct call): exactly 4
targeted failures / 100 passed, restored -> 104. 4 of the 6 new tests
discriminate; 2 are guards.
ruff: exact parity with origin/main (identical 6 pre-existing findings).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* fix(video): write the result markdown/metadata pair atomically as a pair
Review finding (@Copilot, enhanced_video_processor.py:772): the two writes
in `_write_result_files` are individually atomic but were not held together.
Result filenames carry only one-second precision, so two saves of the same
video inside the same second resolve to the same two paths and can interleave
as A-markdown, B-markdown, B-metadata, A-metadata -- leaving B's markdown
paired with A's metadata.
Hold both writes under a module-level `_RESULT_WRITE_LOCK`. The lock is a
`threading.Lock` because the writes execute in the worker thread, and it is
held only across two file writes, so it never blocks the event loop. Saves
are a once-per-video-completion operation, so global serialisation of the
write pair costs nothing measurable.
- 106 tests pass (104 + 2 new in `TestConcurrentSavesWriteMatchedPairs`)
- Non-vacuity: removing the lock yields exactly 1 targeted failure /
105 passed; the single-writer guard test still passes
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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 #1193
Outcome
MetricsServiceno longer performs disk I/O on the event loop.record_metricflushes the whole metric store to disk every tenth point (metrics_service.py:145-146), and that write ran as a synchronousopen()/write()directly on the loop — on a request-serving path.load_persisted_metricshad the same problem on the read side. Both now go throughawait asyncio.to_thread(...).What this does and does not change
json.dumpsof the store runs on the loopThis is not a throughput win. The write costs the same wall-clock time; it simply stops stalling every other coroutine while it happens.
Scope
_persist_metrics— write path moved off-loop via a_write_text_filestatic helperload_persisted_metrics— read + parse moved off-loop via a_read_json_filestatic helperopen(sites in the moduleDeliberately out of scope:
export_metrics("json")(L264) still runsjson.dumps(..., indent=2)on the loop. It is a public method with its own callers and its own synchronous contract; converting it would be a breaking API change. That is the larger remaining cost for a big metric store and is worth a follow-up issue — I did not want to smuggle an API break into a perf PR.Design notes
Why
to_threadand notaiofiles?aiofilesis itself a thread-pool shim — every method isawait loop.run_in_executor(...), so it would cost one executor round-trip per call. Here there is a single write, soto_threadis the same mechanism with less indirection and no new dependency.Why static helpers?
asyncio.to_thread(self._write_text_file, path, text)needs a plain callable. Keeping them@staticmethodavoids capturingselfin the worker thread, so the thread cannot observe a half-mutated instance.Risk
Low. No signature, format or behaviour change. The failure mode of
to_threadis that exceptions surface identically at the await point — covered by the preserved-behaviour guards below.One genuine hazard was checked explicitly: converting a sync call to
to_threadinside an object that a caller tears down in afinallycreates a use-after-close race.MetricsServicehas noclose()/teardown method and no caller disposes of it in afinally, so that pattern does not apply here.Verification
All results below are at head
b55b6e555.TestPersistenceIsSerialisedAndAtomic(this round) on top of the 7 inTestDiskIoDoesNotBlockEventLoopRound 2 non-vacuity, by behavioural mutation. All three round-2 changes were reverted at once — lock removed,
Path.exists()restored, atomic write reverted to a direct truncatingopen(). Exactly four tests fail, one per dimension:Restoring gives
78 passed. Targeted rather than uniform failures — a uniform "everything failed" would have meant the proof was structural, not behavioural.Round 1 non-vacuity (the original off-loop change) is unchanged: replacing
await asyncio.to_thread(self._write_text_file, ...)with a direct call fails exactly the 3 heartbeat tests.Honest note: of the 13 tests in this PR, 6 discriminate (4 above + the round-1 heartbeats) and the rest are preserved-behaviour guards that pass under both old and new code by design.
ruff:
All checks passed!on both touched files, exact parity withmain. Checked in-repo, not from/tmp— this repo's per-file-ignores are path-relative and exempttests/fromE402.Production evidence
Reachable from the primary production entrypoint (
youtube_extension.main:app, rootDockerfile:93):router.py:68importsMetricsService→router.py:194-196get_metrics_service()→router.py:467Depends(...)on a live endpoint →router.py:478TranscriptActionWorkflow→transcript_action_workflow.py:870-882_record_metric, called at L167, 641, 646, 659, 664, 787, 792, 828, 833.Confirmed against a transitive import-closure audit of all 7 deployed entrypoints;
backend.services.metrics_serviceis in the closure.Agent handoff
Follow-up worth filing: move
export_metrics'sjson.dumpsoff the loop behind a new async method, leaving the existing sync one intact for current callers.