perf(video): run enhanced-result persistence off the event loop - #1203
Conversation
`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>
|
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 |
|
@coderabbitai review Please review head
Context: this is the 9th in a series of event-loop-blocking fixes. Reviewers have caught real correctness bugs in #1186, #1188, #1190 and #1194 — including two regressions I introduced myself — so please be adversarial. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== changed paths and diff ==='
git diff --find-renames origin/main...HEAD -- \
src/youtube_extension/backend/enhanced_video_processor.py \
tests/unit/test_enhanced_video_processor.py
echo '=== enhanced processor outline ==='
ast-grep outline src/youtube_extension/backend/enhanced_video_processor.py \
--items all --view expanded
echo '=== focused enhanced processor source ==='
nl -ba src/youtube_extension/backend/enhanced_video_processor.py | sed -n '130,230p'
nl -ba src/youtube_extension/backend/enhanced_video_processor.py | sed -n '1,80p'
echo '=== save and process callers/usages ==='
rg -n -C 5 --glob '*.py' \
'(?:\._save_enhanced_result\s*\(|\bprocess_video\s*\(|get_video_processor\s*\()' \
src tests
echo '=== new test class and nearby fixtures/helpers ==='
rg -n -C 8 --glob '*.py' \
'(?:class TestSaveDoesNotBlockEventLoop\b|test_reader_never_observes_a_partially_written_file\b|test_save_does_not_stall_the_event_loop\b|test_json_serialisation_also_runs_off_the_loop\b)' \
tests/unit/test_enhanced_video_processor.pyLength of output: 14841 The first inspection reached the diff, but the environment lacks You are interacting with an AI system. |
There was a problem hiding this comment.
Pull request overview
Moves enhanced-result serialization and atomic file persistence off the event loop.
Changes:
- Dispatches directory creation, JSON serialization, and writes through one worker thread.
- Adds atomic temporary-file replacement and cleanup.
- Adds six persistence and responsiveness tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/youtube_extension/backend/enhanced_video_processor.py |
Adds off-loop, atomic result persistence. |
tests/unit/test_enhanced_video_processor.py |
Tests responsiveness, content, cleanup, and atomic reads. |
| EnhancedVideoProcessor._atomic_write_text(filepath, markdown) | ||
| EnhancedVideoProcessor._atomic_write_text( | ||
| metadata_file, json.dumps(metadata, indent=2, default=str) | ||
| ) |
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>
|
@copilot — valid finding, accepted and fixed in You were right that atomicity per-file is not the same as atomicity of the pair. With one-second filename precision, two saves of the same video in the same second resolve to the same two paths, and the interleaving you described (A-markdown, B-markdown, B-metadata, A-metadata) leaves B's markdown next to A's metadata. Fix: both writes are now held together under a module-level
Proof it discriminates — new test
A second test ( Thank you — this is the second correctness bug review has caught in my |
groupthinking
left a comment
There was a problem hiding this comment.
Independent review of 4d8d250e5 — verified against the 5 challenges
Re-ran the branch locally (PYTHONPATH=src): 104 passed in 3.08s, matching the claim. I attacked each numbered point rather than restating the PR body.
1. Atomicity / temp-name collision — holds per file; one honest gap on the pair.
- Temp-name collision is genuinely prevented:
to_threadruns each_write_result_fileson a distinct pool thread, and a thread executes only one write at a time, so two concurrent saves always have distinctthreading.get_ident()→ distinct.{pid}.{tid}.tmpnames. No collision even for the samevideo_id/timestamp. - Each individual file is atomic (
os.replace), so no reader ever observes a partial.mdor.json. Solid. - The gap: the
.mdand the.jsonare two independentos.replaces, not one atomic unit. Two saves for the samevideo_idthat land in the same one-second timestamp bucket and truly overlap can interleave asA.md → B.md → B.json → A.json, leaving markdown from generation B beside metadata from generation A — each file internally complete, but the pair mismatched. This is newly possible because the previous on-loop version was implicitly serialized by the event loop; moving toto_threadremoves that serialization (the same reasoning that forced the atomic-write change here). It sits inside the stated non-goal ("does NOT make concurrent saves parallel") and is low-severity (needs same video, same second, real overlap), so not a blocker — but it's the one property this change regresses versus the serialized original. Worth a one-line comment in_save_enhanced_resultnaming it as accepted, or bumping timestamp granularity if you want to close it.
2. Cancellation — claim verified. The surrounding handler is except Exception as e (line 803). asyncio.CancelledError is a BaseException, not Exception, so a cancelled await asyncio.to_thread(...) is not swallowed into a "" return — it propagates. The worker thread finishes its write (a completed, atomic save the caller no longer awaits — benign), and no instance resource is held across the await, so there is no finally: close() hazard like #1190.
3. except BaseException + suppress(OSError) — right width. Catching BaseException in the cleanup unlinks the temp file even on KeyboardInterrupt/CancelledError mid-write, and re-raising preserves the original signal. unlink can only raise OSError-family, so suppress(OSError) is exactly wide enough to stop a cleanup failure from masking the real error, and no wider. No change needed.
4. Test determinism — not flaky. Ran the three timing-sensitive tests (..._partially_written_file, ..._does_not_stall_the_event_loop, ..._json_serialisation_also_runs_off_the_loop) 25× in a loop: 0 failures. The partial-file assertion is not timing-dependent — os.replace guarantees every observation is a complete generation regardless of scheduling; the only timing-sensitive part (reader sampled ≥ once) is asserted separately with wide headroom (sleep(0) reader vs. 12 × to_thread writer). The heartbeat tests carry ~20× margin (0.10s block vs. 0.005s ticks). CI-safe.
5. Residual on-loop work — negligible. datetime.now().strftime and the Path joins are O(1) microsecond-scale string ops; they don't scale with payload size the way json.dumps + the writes do. Leaving them on the loop is correct.
Net: no blocking findings. Core CI is green (test, lint-python, build, bandit, guards). The red checks are the repo-wide governance/scan gates (truth-gate, gitleaks working-tree, trivy, Security Scan, validate, dependency-review, label, Agent completion enforcement) and a canceled Vercel deploy — none implicate this diff. The only actionable item is the optional cross-file pair-atomicity note in (1).
(Review posted from the author account, so this registers as a comment rather than a formal approval.)
Generated by Claude Code
Agent Completion Truth Gate: NOT_APPLICABLEEvidence agrees. Machine-readable verdict{
"details": {},
"reasons": [],
"verdict": "not_applicable"
} |
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>
Head:
fd5f49edeCanonical issue
Closes #1202
Outcome
EnhancedVideoProcessor._save_enhanced_resultwasasync defwith noawaitin its body — mkdir, both file writes, andjson.dumpall ran on the event loop. It is reached in production fromprocess_video(line 189) viaservice_container.py:253→video_processor_factory.get_video_processor().json.dumpsoff-loop as well as the writesjson.dump(obj, f)writes to the file object incrementally, so a large metadata dict became many smallwrite()syscalls. It is now a singlejson.dumps+ one write, performed in the worker thread.Scope
src/youtube_extension/backend/enhanced_video_processor.py— two new static helpers (_atomic_write_text,_write_result_files);_save_enhanced_resultnow issues oneasyncio.to_threaddispatch. Addedcontextlibandthreadingimports.tests/unit/test_enhanced_video_processor.py— one new test class, 6 tests. No existing test was modified.Explicitly out of scope: the other
json.loadssites in this module (lines 476/484/547/553/704) parse small model responses on the loop; andenhanced_video_processor.py:329'sopen()is already inside a nesteddef _transcribe()dispatched viaasyncio.to_thread— correctly off-loop, deliberately untouched.Design notes
Why one dispatch, not three.
to_threadper syscall would cost three thread hops._write_result_filesgroups mkdir + both writes so the save costs one.Why the writes had to become atomic in the same change. Review on #1194 established the rule: moving a write off the event loop removes the loop's implicit serialisation.
open(path,'w')truncates first, so once two saves can genuinely overlap, a reader can observe an empty or short file._atomic_write_textwrites"{path}.{pid}.{tid}.tmp"andos.replaces it into place — atomic on POSIX — and unlinks the temp file on any failure. The pid/tid suffix means two writers cannot collide on the temp name.No caller teardown hazard. Unlike #1190,
_save_enhanced_resultholds no instance resource across the await; it opens and closes files entirely inside the worker thread, so there is nofinally: close()race in any caller.Risk
Low. The 98 pre-existing tests in this file pass unmodified, including the three that already exercise
_save_enhanced_resultby patchingbuiltins.openandpathlib.Path.mkdir. The failure contract is unchanged: any exception is still logged and still returns"".The one behavioural difference a caller could observe: the target file now appears atomically rather than growing incrementally. Nothing in the repo tails these files while they are written.
Verification
= 98 pre-existing (zero edits) + 6 new in
TestSaveDoesNotBlockEventLoop.Non-vacuity — both dimensions mutated simultaneously (atomic write → plain truncating
open, andto_thread→ direct on-loop call):Restoring the source →
104 passed. One failure per dimension, so no new test duplicates another.Honest split: 4 of the 6 new tests discriminate; 2 are guards (
test_save_writes_readable_markdown_and_metadata,test_no_temp_files_remain_after_a_successful_save) that pass under both variants and exist to pin content correctness and cleanliness.ruff: exact parity with
origin/main— the same 6 pre-existing findings (1 ×E402, 5 ×F841) before and after; zero introduced.Review round 2 — pair atomicity (
fd5f49ede)@Copilotcorrectly found that the two writes were individually atomic but not atomic as a pair: with one-second filename precision two saves of the same video in the same second target the same paths and can interleave, pairing one save's markdown with another's metadata. Both writes are now held together under a module-levelthreading.Lock(_RESULT_WRITE_LOCK), taken inside the worker thread so the event loop is still never blocked.Non-vacuity — removing the lock:
The single-writer guard test passes under both variants, as a guard should.
Production evidence
enhanced_video_processorandvideo_processor_factoryare both in the transitive import closure of the deployed entrypoints (youtube_extension.main:app, rootDockerfile:93). Call chain:service_container.py:253→video_processor_factory.py:63-65→EnhancedVideoProcessor()→process_video(L140) →_save_enhanced_result(L189).Agent handoff
Known coverage gap, named deliberately: the heartbeat tests prove the loop keeps running for
_save_enhanced_resultspecifically. They do not prove anything about thejson.loadssites elsewhere in this module, which remain on-loop by design.