perf(downloads): de-prioritize worker transcodes + idle-back-off polls to stop LAN slowdown
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.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""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 == []
|
||||
@@ -5,6 +5,8 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import gc as gcmod
|
||||
from app.downloads.gc import _sweep_orphan_files
|
||||
|
||||
|
||||
@@ -69,3 +71,38 @@ def test_sweep_removes_orphans_but_keeps_owned_system_and_fresh(tmp_path: Path):
|
||||
assert not (root / "OldChan").exists()
|
||||
assert sysfile.exists() # system tree untouched
|
||||
assert fresh.exists() # too new to sweep
|
||||
|
||||
|
||||
def test_sweep_throttles_after_each_batch(tmp_path: Path, monkeypatch):
|
||||
"""The whole-tree walk yields the IO channel after every `batch` files so it can't monopolize
|
||||
disk IO in the API process. batch=2 over 5 touched files → 2 pauses (at file 2 and 4)."""
|
||||
root = tmp_path
|
||||
d = root / "Chan"
|
||||
d.mkdir()
|
||||
for i in range(5):
|
||||
(d / f"orphan_{i}.dat").write_bytes(b"x")
|
||||
for f in d.iterdir():
|
||||
_aged(f, 5) # old enough that every file is "touched" by the walk
|
||||
|
||||
monkeypatch.setattr(settings, "download_gc_sweep_pause_ms", 10)
|
||||
monkeypatch.setattr(settings, "download_gc_sweep_batch", 2)
|
||||
pauses = []
|
||||
monkeypatch.setattr(gcmod.time, "sleep", lambda s: pauses.append(s))
|
||||
|
||||
_sweep_orphan_files(_StubDB([]), str(root))
|
||||
|
||||
assert pauses == [0.01, 0.01] # 5 files, batch 2 → yield at #2 and #4
|
||||
|
||||
|
||||
def test_sweep_no_throttle_when_pause_disabled(tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "Chan").mkdir()
|
||||
f = tmp_path / "Chan" / "orphan.dat"
|
||||
f.write_bytes(b"x")
|
||||
_aged(f, 5)
|
||||
monkeypatch.setattr(settings, "download_gc_sweep_pause_ms", 0)
|
||||
pauses = []
|
||||
monkeypatch.setattr(gcmod.time, "sleep", lambda s: pauses.append(s))
|
||||
|
||||
_sweep_orphan_files(_StubDB([]), str(tmp_path))
|
||||
|
||||
assert pauses == []
|
||||
|
||||
Reference in New Issue
Block a user