Skip to content

fix: stop daily OOM crash loop and hours-long post-Phase-1 refilter - #12

Merged
yidakra merged 2 commits into
mainfrom
fix/phase2-oom-crash-loop
Aug 1, 2026
Merged

fix: stop daily OOM crash loop and hours-long post-Phase-1 refilter#12
yidakra merged 2 commits into
mainfrom
fix/phase2-oom-crash-loop

Conversation

@yidakra

@yidakra yidakra commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Problem

The archive-transcriber service has been in a daily OOM crash loop since ~Jul 16 and completed almost no translations in two weeks (journal shows kernel OOM kills on Jul 28, 29×2, 30×2, 31×2, Aug 1). Each cycle:

  1. Startup filtering (~20–40 min since perf: cut startup job discovery from ~20h to ~2h on NFS #11) — fine.
  2. Phase 1: 0 successes, 93 failures — the only pending jobs are pre-existing corrupt videos that fail identically every cycle. The logged error ('utf-8' codec can't decode byte 0x8e...) was Python failing to decode FFmpeg's stderr, masking the real extraction error.
  3. Hidden second bottleneck missed by perf: cut startup job discovery from ~20h to ~2h on NFS #11: after Phase 1, run_two_phase re-filtered translations by re-statting all 143,384 jobs sequentially on NFS — 6–12 h of dead time per cycle.
  4. Phase 2 starts and picks the same four multi-hour videos at the head of the queue (57 min, 57 min, 1:20 h, 6:29 h). Peak RSS during model.transcribe() grows with audio duration (~3 GB measured for a 27-minute video on faster-whisper 1.2.1); four concurrent long transcribes exceed the box's 31 GB (no swap) within minutes → SIGKILL → systemd restarts → repeat.

Fix

  • Post-Phase-1 refilter: only re-check the jobs Phase 1 actually touched (a translation becomes newly needed only when a fresh ru.vtt appears), in parallel with the existing STARTUP_STAT_THREADS pool, deduped against the already-queued translation jobs. Hours → seconds.
  • Sort both phase queues smallest-video-first (file size as an NFS-cheap duration proxy). Concurrent workers then always hold similarly-sized jobs, which bounds their combined transcribe peak, and steady progress happens up front instead of the giants monopolizing the head of every cycle.
  • Serialize long videos: anything over --long-video-minutes (default 45, 0 disables) takes an exclusive semaphore before transcribing, in both phases — at most one long video in memory at a time, worst case ≈ 3 small workers + 1 long one.
  • Stop retrying the 93 permanently broken videos: Phase 1 error records now go to the manifest with error_type and video_mtime; audio-extraction failures (corrupt containers) are excluded from later cycles until the video file's mtime changes.
  • errors="replace" on ffmpeg/ffprobe output decoding so corrupt containers surface the real FFmpeg error instead of a UnicodeDecodeError.
  • Robustness: probe_video_metadata moved inside the Phase 2 try block — a corrupt video now yields an error record instead of an exception escaping the worker thread and killing the whole run.

Caveat

If the 6.5-hour video still exceeds 31 GB even running alone, it will fail at the very end of the queue after everything else has completed — not block the whole archive as it does today. Chunked transcription for such outliers can be a follow-up if it proves necessary.

Testing

  • 116 tests pass (pytest tests/), including new coverage: known_permanent_failure (skip/retry semantics incl. mtime change), sort_jobs_by_size, acquire_long_video_slot (threshold/disable/exclusivity), and error-record classification for FFmpeg vs transient failures.
  • ruff and mypy clean.
  • Smoke-tested --two-phase end-to-end on an empty archive; --long-video-minutes visible in --help.

The service has been OOM-killed daily since Jul 16: every cycle Phase 2
picked the same four multi-hour videos first, four concurrent
model.transcribe() calls grew past 31 GB RSS (no swap), and the kernel
killed the process before any translations completed. Between Phase 1
and Phase 2 the job list was also re-filtered by re-statting all 143k
jobs sequentially on NFS, adding 6-12 h of dead time per cycle.

- Re-filter only the jobs Phase 1 touched (a translation becomes newly
  needed only when a fresh ru.vtt appears), in parallel, deduped against
  the already-queued translation jobs
- Sort both phase queues smallest video first: concurrent workers hold
  similarly-sized jobs (bounded combined peak RSS) and steady progress
  happens before the multi-hour videos
- Serialize long videos: anything over --long-video-minutes (default 45)
  takes an exclusive semaphore so at most one long transcribe runs at a
  time, in both phases
- Record Phase 1 failures in the manifest with video_mtime; audio
  extraction failures (corrupt containers) are permanent and skipped on
  later cycles until the video file changes - the 93 known-broken videos
  stop retrying every cycle
- Decode ffmpeg/ffprobe output with errors=replace: corrupt containers
  emit non-UTF-8 bytes on stderr and the UnicodeDecodeError was masking
  the real extraction error
- Probe video metadata inside the Phase 2 try block so a corrupt video
  yields an error record instead of an exception escaping the worker
Copilot AI review requested due to automatic review settings August 1, 2026 08:31
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@yidakra, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 585cb8e5-6b46-4c12-b13e-64227d904681

📥 Commits

Reviewing files that changed from the base of the PR and between ab8ad7a and d23e078.

📒 Files selected for processing (2)
  • src/python/tools/archive_transcriber.py
  • tests/test_archive_transcriber.py
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added configurable long-video processing limits with a 45-minute default threshold.
    • Jobs are prioritized by video size for more efficient processing.
    • Previously recorded permanent audio-extraction failures are skipped when inputs are unchanged.
  • Bug Fixes

    • Improved handling of invalid FFmpeg output characters.
    • Long-video processing slots are reliably released after errors.
    • Translation checks now focus only on jobs that require them.
    • Transcription failures are classified and retained for more reliable retries.

Walkthrough

The archive transcriber now records permanent FFmpeg extraction failures, limits concurrent long-video processing, orders jobs by file size, and performs targeted translation rechecks in two-phase runs.

Changes

Archive transcription

Layer / File(s) Summary
Permanent extraction failure tracking
src/python/tools/archive_transcriber.py, tests/test_archive_transcriber.py
Manifest records retain video mtimes. FFmpeg output decoding tolerates invalid bytes. Extraction failures are classified, persisted, and skipped when the source is unchanged.
Long-video concurrency control
src/python/tools/archive_transcriber.py, tests/test_archive_transcriber.py
Transcription and translation use an exclusive semaphore for videos above the configurable duration threshold. Cleanup releases the semaphore.
Size-ordered two-phase scheduling
src/python/tools/archive_transcriber.py, tests/test_archive_transcriber.py
Transcription and translation jobs are sorted by file size. Translation rechecks only relevant transcription jobs. Tests cover filtering, ordering, and error records.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ArchiveTranscriber
  participant Manifest
  participant TranscriptionJobs
  participant TranslationJobs
  ArchiveTranscriber->>Manifest: read failure and mtime records
  ArchiveTranscriber->>TranscriptionJobs: filter and sort transcription jobs
  ArchiveTranscriber->>TranslationJobs: recheck relevant jobs
  ArchiveTranscriber->>TranslationJobs: sort translation jobs
Loading

Possibly related PRs

  • yidakra/livevtt#9: Both changes modify archive_transcriber.py processing phases and manifest-based job filtering.
  • yidakra/livevtt#11: Both changes modify two-phase scheduling, phase-needs evaluation, and job ordering.

Suggested reviewers: copilot

Poem

A rabbit queued the long videos bright,
Sorted small files from left to right.
Failed audio stayed marked in the log,
While invalid bytes hopped through the fog.
Two phases now follow a tidier track.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the two primary fixes: preventing repeated OOM crashes and reducing post-Phase-1 refiltering time.
Description check ✅ Passed The description directly explains the OOM loop, refiltering delay, implemented fixes, caveat, and validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses stability and throughput issues in the archive transcriber’s --two-phase workflow by reducing unnecessary post-Phase-1 refilter work, prioritizing smaller videos to avoid OOM-prone queue behavior, adding long-video concurrency gating, and improving handling/recording of permanent failures from corrupt inputs.

Changes:

  • Add long-video exclusivity gating (--long-video-minutes) and sort both phase queues smallest-first to reduce OOM risk from concurrent multi-hour transcribes.
  • Record and skip permanently broken inputs (audio extraction failures) across cycles using manifest metadata (error_type, video_mtime), and decode ffmpeg/ffprobe output with errors="replace".
  • Avoid hours-long post-Phase-1 refilter by only re-checking translation needs for jobs touched by Phase 1, in parallel.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/python/tools/archive_transcriber.py Adds long-video gating, permanent-failure tracking, errors="replace" for ffmpeg/ffprobe decoding, queue sorting, and targeted post-Phase-1 translation recheck.
tests/test_archive_transcriber.py Adds tests for permanent-failure skip semantics, size-based ordering, long-video gate behavior, and transcription error record classification.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/python/tools/archive_transcriber.py Outdated
Comment thread src/python/tools/archive_transcriber.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/python/tools/archive_transcriber.py (1)

1287-1297: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the FFmpeg extraction subprocess.

extract_audio now runs under LONG_VIDEO_GATE in process_transcription_only and process_translation_only. The current subprocess.run has no timeout; if FFmpeg hangs, the worker still enters except Exception, but the exclusive gate remains held until the process is killed, blocking all subsequent long-video jobs. Add a finite timeout here and keep the existing except Exception block so this is emitted as a retryable "processing" error rather than a permanent audio_extraction failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/python/tools/archive_transcriber.py` around lines 1287 - 1297, Add a
finite timeout to the FFmpeg subprocess invocation in extract_audio while
preserving the existing RuntimeError handling and surrounding except Exception
flow. Ensure subprocess timeout failures propagate through the existing
retryable “processing” error path rather than being converted into a permanent
audio_extraction failure.
🧹 Nitpick comments (3)
src/python/tools/archive_transcriber.py (3)

1642-1652: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider avoiding the extra ffprobe call for videos that are clearly short.

This adds an unconditional probe_video_metadata call (spawns ffprobe) to every Phase 1 job whenever long_video_minutes > 0, which is the default. On a 143k-video NFS archive, this reintroduces one extra subprocess round trip per video, the same category of overhead this PR's targeted recheck (Line 2333-2358) works to eliminate elsewhere.

sort_jobs_by_size already stats file size cheaply for every job. Consider using a size-based lower bound (a size clearly too small to reach the duration threshold) to skip the ffprobe call for the bulk of short videos, only probing borderline-sized files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/python/tools/archive_transcriber.py` around lines 1642 - 1652, Update the
long-video slot logic near acquire_long_video_slot to use each job’s
already-available file size from sort_jobs_by_size and establish a conservative
size lower bound for long_video_minutes; skip probe_video_metadata for files
clearly below that bound, while probing only borderline or larger files and
preserving the existing fallback behavior for probe failures.

2290-2293: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Permanent-failure tracking is asymmetric between phases.

Only process_transcription_only's error records carry error_type/video_mtime (Line 1707-1717), and only those are persisted here for known_permanent_failure to consume. process_translation_only's error record (Line 1858-1872) has no error_type or video_mtime, even though its own extract_audio call (Line 1758) can fail with the exact same unfixable corrupt-container error. A video needing only translation whose extraction permanently fails will be retried every Phase 2 cycle indefinitely, unlike the Phase 1 case this PR fixes.

Consider extending the same classification and persistence to process_translation_only's error path for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/python/tools/archive_transcriber.py` around lines 2290 - 2293, Extend
process_translation_only’s error-record path to classify extract_audio failures
using the same error_type and video_mtime fields produced by
process_transcription_only, including permanent corrupt-container failures.
Ensure the resulting status="error" record is persisted by the manifest.append
logic so known_permanent_failure() excludes these inputs on later Phase 2
cycles.

1699-1721: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fragile string-based error classification for permanent failures.

is_extract_failure classifies the exception by checking isinstance(exc, RuntimeError) and str(exc).startswith("FFmpeg failed"). This string prefix is only defined by convention in extract_audio's own raise RuntimeError(f"FFmpeg failed for {video_path}: ...") (Line 1293-1295). A future change to that message text silently breaks known_permanent_failure detection, since nothing enforces the coupling between the two locations. test_ffmpeg_failure_marked_permanent in tests/test_archive_transcriber.py (Line 756-757) reinforces this risk: it hardcodes the same string independently in its boom stub rather than deriving it from extract_audio, so a drift in the real message would not be caught by the test.

Define a dedicated exception type (for example AudioExtractionError) that extract_audio raises on failure, and check isinstance(exc, AudioExtractionError) instead of matching on the message text.

♻️ Proposed refactor using a dedicated exception type
+class AudioExtractionError(RuntimeError):
+    """Raised when FFmpeg fails to extract audio from a video."""
+
+
 def extract_audio(video_path: Path, sample_rate: int) -> Path:
     ...
     if result.returncode != 0:
         stderr_preview = (result.stderr or "").splitlines()[-5:]
-        raise RuntimeError(
+        raise AudioExtractionError(
             f"FFmpeg failed for {video_path}: return code {result.returncode}\n" + "\n".join(stderr_preview)
         )
-        is_extract_failure = isinstance(exc, RuntimeError) and str(exc).startswith("FFmpeg failed")
+        is_extract_failure = isinstance(exc, AudioExtractionError)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/python/tools/archive_transcriber.py` around lines 1699 - 1721, Define a
dedicated AudioExtractionError near the audio-extraction code, update
extract_audio to raise it for FFmpeg failures while preserving the existing
failure message, and change is_extract_failure in the error-handling flow to use
isinstance(exc, AudioExtractionError) instead of RuntimeError string-prefix
matching. Update test_ffmpeg_failure_marked_permanent and its boom stub to raise
the dedicated exception so permanent-failure classification remains covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/python/tools/archive_transcriber.py`:
- Around line 1287-1297: Add a finite timeout to the FFmpeg subprocess
invocation in extract_audio while preserving the existing RuntimeError handling
and surrounding except Exception flow. Ensure subprocess timeout failures
propagate through the existing retryable “processing” error path rather than
being converted into a permanent audio_extraction failure.

---

Nitpick comments:
In `@src/python/tools/archive_transcriber.py`:
- Around line 1642-1652: Update the long-video slot logic near
acquire_long_video_slot to use each job’s already-available file size from
sort_jobs_by_size and establish a conservative size lower bound for
long_video_minutes; skip probe_video_metadata for files clearly below that
bound, while probing only borderline or larger files and preserving the existing
fallback behavior for probe failures.
- Around line 2290-2293: Extend process_translation_only’s error-record path to
classify extract_audio failures using the same error_type and video_mtime fields
produced by process_transcription_only, including permanent corrupt-container
failures. Ensure the resulting status="error" record is persisted by the
manifest.append logic so known_permanent_failure() excludes these inputs on
later Phase 2 cycles.
- Around line 1699-1721: Define a dedicated AudioExtractionError near the
audio-extraction code, update extract_audio to raise it for FFmpeg failures
while preserving the existing failure message, and change is_extract_failure in
the error-handling flow to use isinstance(exc, AudioExtractionError) instead of
RuntimeError string-prefix matching. Update test_ffmpeg_failure_marked_permanent
and its boom stub to raise the dedicated exception so permanent-failure
classification remains covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01068980-4c0c-4917-9d1a-a018f1a2f4dc

📥 Commits

Reviewing files that changed from the base of the PR and between 3fb2d99 and ab8ad7a.

📒 Files selected for processing (2)
  • src/python/tools/archive_transcriber.py
  • tests/test_archive_transcriber.py

…lter dedup

- LONG_VIDEO_GATE is a BoundedSemaphore so an unbalanced release raises
  instead of silently allowing two concurrent long videos
- Persist every non-success Phase 1 record, not just errors: invalid-SMIL
  skips now survive restarts as skip_record_for_invalid_smil documents
- Add a run_two_phase integration test covering the post-Phase-1
  re-filter: a video needing both phases must not be queued for
  translation twice (verified to fail without the dedup guard)
@yidakra yidakra self-assigned this Aug 1, 2026
@yidakra
yidakra merged commit 437c0c6 into main Aug 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants