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:
2026-08-28 02:37:56 +02:00
parent b7a6451dba
commit 9ae6c47d6c
9 changed files with 235 additions and 6 deletions
+7 -1
View File
@@ -51,7 +51,13 @@ export default function DownloadCenter() {
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
// Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled
// (only done/error/paused rows left, which don't advance on their own) so an idle Download Center
// isn't hitting the API — and adding to the shared box's load — every 2s for nothing.
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, {
intervalMs: (data) =>
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
});
const sharedQ = useQuery({
queryKey: qk.downloadsShared(),
queryFn: api.downloadsShared,
+6 -2
View File
@@ -278,8 +278,12 @@ export function UsageBar() {
const { t } = useTranslation();
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
// only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise
// (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue).
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
});
const u = usage.data;
if (!u) return null;
const pct =