fix(e3): address /code-review high findings (5)

Round-1 review fixes on the E3 branch:

- prune partial-fetch (correctness): `_paginate` now reports whether it reached
  the server's reported `total`; `_prune_stale` prunes only on a COMPLETE
  non-empty listing. A truncated fetch (a mid-sequence empty/short page before
  total — a transient Plex hiccup) no longer deletes the rows it merely failed
  to list. New test: a listing that claims 5 but returns 1 prunes nothing.
- `_prune_stale` (robustness): `stats.get("pruned", 0) + 1` — no KeyError when a
  caller's stats dict omits the key.
- service transitions (conventions): pause/resume/redownload use the JobStatus
  StrEnum instead of loose "queued"/"paused"/"error" literals, matching cancel.
- ffmpeg progress writer (correctness): a stuck-percent heartbeat now runs on its
  OWN 1/interval budget, so it can't consume the progress-write slot and delay a
  real percent change by up to an interval.
- backend/app/README.md (conventions): reword the transaction-ownership rule to
  quote and extend CLAUDE.md's canonical ("handler/job-wrapper commits; helpers
  flush; only quota owns its session") instead of stating a contradicting
  "helpers MAY commit" carve-out.

ruff clean, full DB-lane 364 pass.
This commit is contained in:
2026-08-18 22:47:25 +02:00
parent b02a9e37e8
commit bc79720e81
6 changed files with 96 additions and 39 deletions
+13 -8
View File
@@ -193,21 +193,26 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
"""`on_progress` for run_ffmpeg: persist at most one row write per interval, and only when the
whole-percent value actually changed (the bar shows integers)."""
allow = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
"""`on_progress` for run_ffmpeg: persist a progress row write at most once per interval and only
when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval
heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall)."""
# Two independent 1/interval budgets so a heartbeat never consumes the progress-write slot — a
# percent change right after a heartbeat must still write immediately, not wait out the interval.
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
allow_beat = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
state = {"pct": -1}
def write(pct: float) -> None:
if not allow():
return
whole = int(pct)
if whole == state["pct"]:
# Same integer percent, but ffmpeg IS still working — a slow re-encode (AV1/VP9 -> H.264
# or a crop on the CPU-only host) can sit on one percent for minutes. Heartbeat the row's
# updated_at so the stall watchdog doesn't kill a live job as "stalled" (B-sub-4), without
# rewriting the unchanged progress bar.
_touch_job(job_id)
# updated_at (on its OWN budget) so the stall watchdog doesn't kill a live job as "stalled"
# (B-sub-4), without rewriting the unchanged bar or delaying the next real write.
if allow_beat():
_touch_job(job_id)
return
if not allow_write():
return
state["pct"] = whole
_set_job(job_id, progress=whole, phase=phase)
+6 -6
View File
@@ -318,8 +318,8 @@ def release_asset(db: Session, job: DownloadJob) -> None:
def pause(db: Session, job: DownloadJob) -> None:
"""Pause a queued/running job (a running worker aborts cooperatively via the cancel poll); a no-op
in any other state."""
if job.status in ("queued", "running"):
job.status = "paused"
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING):
job.status = JobStatus.PAUSED
db.commit()
@@ -327,16 +327,16 @@ def resume(db: Session, job: DownloadJob) -> None:
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset)."""
if job.status in ("paused", "error"):
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
job.progress = 0
job.error = None
job.error_code = None
if job.asset_id:
asset = db.get(MediaAsset, job.asset_id)
if asset and asset.status == "error":
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
asset.status = "pending"
asset.error = None
job.status = "queued"
job.status = JobStatus.QUEUED
db.commit()
@@ -370,7 +370,7 @@ def redownload(db: Session, job: DownloadJob) -> None:
release_asset(db, job)
incr_ref_count(db, asset.id)
job.asset_id = asset.id
job.status = "queued"
job.status = JobStatus.QUEUED
job.progress = 0
job.phase = None
job.error = None