Files
siftlode/backend/app/downloads/runner.py
T
peter 6c63df6704 feat: one structured log for every process, with a request id
A deployment printed four different log formats and none of them could be
joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in
main.py for anything not started by uvicorn, logging.basicConfig() in the worker
and in the download child (no timestamp at all), and alembic's fileConfig.
app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log
data model, no SDK): ts, level, severity, source, logger, event code, message,
attributes, plus the correlation the context carries — request id, account,
download job.

Redaction is applied at the HANDLER, not at the call sites: every message, every
attribute and every traceback is scrubbed on the way out, so a secret cannot
leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies,
secret config values and email addresses never reach a line.

The access log is ours now (`http.request`): the route template instead of the
concrete path, the status, the duration and the account — and never the query
string, which uvicorn's access log printed on every single request (`?account=`,
`?source=`, the `?token=` of a share link). A well-formed client request id is
accepted and echoed in X-Request-Id, so a browser line and a server line join up.
The account comes from `request.state`, filled by the auth dependency: the
middleware is the outermost one, so it runs outside SessionMiddleware and in a
different task context.

`log_level` becomes an admin knob (sysconfig), applied without a restart and
re-applied at startup — best-effort on purpose, since a logging knob must never
be what stops the app from booting. Every compose service gets bounded json-file
logs (3×50 MB): Docker keeps every line forever by default, and the API now
writes one line per request.

Verified on the localdev stack: JSON everywhere including alembic and the
worker, the query strings gone, a supplied X-Request-Id echoed and logged, and
three /healthz hits of which only the one made while the level was DEBUG
produced a line.
2026-09-16 01:37:02 +02:00

90 lines
3.7 KiB
Python

"""Download/edit child process — runs ONE job's heavy work (yt-dlp download or ffmpeg edit) in its
own process, launched and supervised by the worker (`app.worker._run_supervised`).
Why a separate process: a worker THREAD can't be killed, a C-level spin (a catastrophic regex, a
tight yt-dlp retry loop) can't even be cooperatively cancelled, and one such thread starves the
others via the GIL. Running the work in a child means the supervisor can SIGTERM/SIGKILL it (and its
process group, so ffmpeg grandchildren die too) on a stall / pause / wall-clock breach.
Contract: the asset is already claimed ('downloading') by the parent. This process does the work and
writes the terminal job/asset state itself (done / error, or a cooperative pause via the in-flight
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
"""
import logging
import os
import shutil
import subprocess
import sys
from app import logs
from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
from app.models import DownloadJob
log = logging.getLogger("siftlode.worker.runner")
def _deprioritize() -> None:
"""Lower THIS child's CPU + IO priority so its yt-dlp/ffmpeg work — every descendant inherits the
handicap — can never starve the co-located API, Postgres, or Plex playback. Run once at startup,
before any heavy work. Every step is best-effort: a failed renice must never fail a download."""
nice_inc = settings.download_child_nice
if nice_inc > 0:
try:
os.nice(nice_inc) # relative increment; the child starts at the worker's nice (0)
except OSError as exc:
log.info("could not renice download child: %s", str(exc)[:120])
cls = settings.download_child_ionice_class
if cls > 0 and shutil.which("ionice"): # util-linux; absent on some minimal images
try:
subprocess.run(
["ionice", "-c", str(cls), "-p", str(os.getpid())],
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
log.info("could not ionice download child: %s", str(exc)[:120])
# Cap OpenMP-parallel ffmpeg filters to the same budget as the encoder threads, so a filter
# graph can't fan out across every core behind ffmpeg's own `-threads` cap.
os.environ.setdefault("OMP_NUM_THREADS", str(editmod.encode_thread_cap()))
def main() -> None:
logs.configure_logging("runner")
_deprioritize()
if len(sys.argv) != 2:
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr)
raise SystemExit(2)
job_id = int(sys.argv[1])
# This whole process exists to run ONE job — tag every line it prints with it, so the child's
# output joins up with the worker's and the API's view of the same download.
logs.set_job(job_id)
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
if job is None:
return
asset_id = job.asset_id
job_kind = job.job_kind
spec = dict(job.profile_snapshot or {})
source_kind, source_ref = job.source_kind, job.source_ref
user_id = job.user_id
if asset_id is None:
return
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
from app.downloads.pipeline import _download, _process_edit
if job_kind == "edit":
_process_edit(job_id, asset_id)
else:
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
if __name__ == "__main__":
main()