The download/edit child (yt-dlp + ffmpeg) shares one LAN box with the API, Postgres, and Plex playback. A CPU re-encode pegged every core for ~30s and starved web requests + playback until it finished. Full-scope S4 mitigations: - Child self-de-prioritizes at startup (inherited by every ffmpeg/yt-dlp descendant): CPU nice increment + best-effort idle ionice class + an OMP_NUM_THREADS budget, all config-gated and independently disable-able. - Cap the browser-safe re-encode's encoder threads (`-threads`) so a transcode reserves a core for foreground work instead of claiming all of them. - Throttle the retention GC's whole-tree orphan sweep (yield IO after each batch of files) so its rglob can't monopolize disk IO in the API process. - Idle-back-off the Download Center (2s→15s when nothing queued/running) and UsageBar (2s→15s when running_jobs is 0) polls; both snap back to 2s on the next fetch after an enqueue (invalidateDownloads refetches immediately). Tests: encode_thread_cap (pin/reserve-core/edge), -threads flag presence, _deprioritize (nice/ionice/OMP branches), GC sweep throttle batching.
104 lines
4.4 KiB
Python
104 lines
4.4 KiB
Python
"""Worker-child resource de-prioritization (S4): the encoder thread cap, the `-threads` flag on the
|
|
browser-safe re-encode, and the child's startup renice/ionice/OMP handicap. These keep a CPU-heavy
|
|
transcode from pegging every core on the box it shares with the API, Postgres, and Plex playback."""
|
|
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.downloads import edit as editmod
|
|
from app.downloads import runner
|
|
|
|
|
|
class TestEncodeThreadCap:
|
|
def test_pinned_setting_wins(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 3)
|
|
assert editmod.encode_thread_cap() == 3
|
|
|
|
def test_zero_reserves_one_core(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: 8)
|
|
assert editmod.encode_thread_cap() == 7
|
|
|
|
def test_single_core_box_never_caps_below_one(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: 1)
|
|
assert editmod.encode_thread_cap() == 1
|
|
|
|
def test_unknown_cpu_count_falls_back(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 0)
|
|
monkeypatch.setattr(editmod.os, "cpu_count", lambda: None)
|
|
assert editmod.encode_thread_cap() == 1 # max(1, 2 - 1)
|
|
|
|
|
|
class TestBrowserSafeThreads:
|
|
def test_threads_flag_added_when_capped(self):
|
|
cmd = editmod.build_browser_safe_cmd(
|
|
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=2
|
|
)
|
|
assert "-threads" in cmd
|
|
assert cmd[cmd.index("-threads") + 1] == "2"
|
|
# The cap precedes the output file, so it binds the encoder (not input decoding).
|
|
assert cmd.index("-threads") < cmd.index("/out.mp4")
|
|
|
|
@pytest.mark.parametrize("threads", [0, None])
|
|
def test_no_threads_flag_when_uncapped(self, threads):
|
|
cmd = editmod.build_browser_safe_cmd(
|
|
"/in.mkv", "/out.mp4", reencode_video=True, reencode_audio=False, threads=threads
|
|
)
|
|
assert "-threads" not in cmd
|
|
|
|
|
|
class TestDeprioritize:
|
|
def test_renices_by_configured_increment(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_child_nice", 12)
|
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0) # skip ionice branch
|
|
calls = []
|
|
monkeypatch.setattr(runner.os, "nice", lambda n: calls.append(n))
|
|
monkeypatch.setattr(runner.os, "environ", {})
|
|
runner._deprioritize()
|
|
assert calls == [12]
|
|
|
|
def test_nice_zero_does_not_renice(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0)
|
|
calls = []
|
|
monkeypatch.setattr(runner.os, "nice", lambda n: calls.append(n))
|
|
monkeypatch.setattr(runner.os, "environ", {})
|
|
runner._deprioritize()
|
|
assert calls == []
|
|
|
|
def test_sets_omp_budget_to_thread_cap(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
|
monkeypatch.setattr(settings, "download_child_ionice_class", 0)
|
|
monkeypatch.setattr(settings, "download_ffmpeg_threads", 4)
|
|
env: dict = {}
|
|
monkeypatch.setattr(runner.os, "environ", env)
|
|
runner._deprioritize()
|
|
assert env["OMP_NUM_THREADS"] == "4"
|
|
|
|
def test_ionice_invoked_when_binary_present(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
|
monkeypatch.setattr(settings, "download_child_ionice_class", 3)
|
|
monkeypatch.setattr(runner.os, "environ", {})
|
|
monkeypatch.setattr(runner.shutil, "which", lambda name: "/usr/bin/ionice")
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
runner.subprocess,
|
|
"run",
|
|
lambda cmd, **kw: ran.append(cmd) or subprocess.CompletedProcess(cmd, 0),
|
|
)
|
|
runner._deprioritize()
|
|
assert ran and ran[0][:3] == ["ionice", "-c", "3"]
|
|
|
|
def test_ionice_skipped_when_binary_absent(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_child_nice", 0)
|
|
monkeypatch.setattr(settings, "download_child_ionice_class", 3)
|
|
monkeypatch.setattr(runner.os, "environ", {})
|
|
monkeypatch.setattr(runner.shutil, "which", lambda name: None)
|
|
ran = []
|
|
monkeypatch.setattr(runner.subprocess, "run", lambda cmd, **kw: ran.append(cmd))
|
|
runner._deprioritize()
|
|
assert ran == []
|