perf(qbittorrent): rid incremental sync + shared cache + backoff (stop hanging qBittorrent)
The app was saturating qBittorrent's single-threaded web server and causing
its own Web UI (and the reverse proxy) to hang/504: each of the 3 qBittorrent
widgets fetched /sync/maindata independently, every call was a FULL snapshot
(no rid), and polling was aggressive (5s for speed). For large torrent lists
each snapshot is heavy, so the server queued and Traefik timed out.
QbittorrentClient.maindata now:
- Uses the incremental rid protocol: the first call is a full_update;
subsequent calls send the last rid and get a small diff that is merged into
a cached snapshot (full_update replaces; partial_update merges server_state,
torrents {added/None-removed/..._removed}, categories, tags, trackers).
Payloads shrink dramatically for large libraries.
- Serves a short-TTL (3s) cached snapshot under a lock, so concurrent widget
polls collapse onto a single HTTP fetch instead of N.
- Backs off exponentially (capped 30s) on repeated failure, serving the last
good snapshot when available, so a struggling qBittorrent isn't hammered
further. Returns a shallow race-safe copy of the snapshot per call.
Also slow the speed widget poll from 5s -> 15s (backend widget-kind +
frontend registry) for ~3x fewer calls.
Tests: rid full+partial merge, cache collapses within-TTL calls, backoff
skips the network after failure and serves stale. 393/393 backend + 180/180
frontend tests pass; ruff + tsc + ESLint clean.
This commit is contained in:
@@ -8,6 +8,8 @@ SID cookie in the requests session. The client re-logins transparently on 403.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -16,6 +18,11 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT,
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# qBittorrent's built-in web server is effectively single-threaded; collapse
|
||||
# concurrent widget polls onto one fetch and back off when it struggles.
|
||||
MAINDATA_CACHE_TTL = 3.0 # seconds a snapshot is served without re-hitting qBittorrent
|
||||
MAINDATA_BACKOFF_MAX = 30.0 # cap exponential backoff after repeated failures
|
||||
|
||||
|
||||
class QbittorrentClient:
|
||||
"""Small wrapper around the qBittorrent Web API.
|
||||
@@ -40,6 +47,24 @@ class QbittorrentClient:
|
||||
self.timeout = http_timeout(timeout)
|
||||
self._session = requests.Session()
|
||||
self._logged_in = False
|
||||
# /sync/maindata is the only hot endpoint. Maintain a rid-merged
|
||||
# snapshot (incremental updates -> small payloads), a short-TTL cache
|
||||
# + lock so concurrent widgets share one fetch, and back off when
|
||||
# qBittorrent is struggling rather than piling on (its web server is
|
||||
# single-threaded and otherwise hangs the Web UI for everyone).
|
||||
self._rid: int | None = None
|
||||
self._snapshot: dict[str, Any] = {
|
||||
"server_state": {},
|
||||
"torrents": {},
|
||||
"categories": {},
|
||||
"tags": [],
|
||||
"trackers": [],
|
||||
}
|
||||
self._maindata_lock = threading.Lock()
|
||||
self._maindata_fetched_at: float = 0.0
|
||||
self._maindata_ttl: float = MAINDATA_CACHE_TTL
|
||||
self._backoff_until: float = 0.0
|
||||
self._consecutive_failures = 0
|
||||
|
||||
def _login(self) -> None:
|
||||
"""POST username/password to ``/auth/login``; store the SID cookie.
|
||||
@@ -74,6 +99,7 @@ class QbittorrentClient:
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.text.strip()
|
||||
|
||||
# qBittorrent signals a successful login with the body "Ok." and/or by
|
||||
# setting a session cookie. The cookie is named "SID" in older versions
|
||||
# and "QBT_SID" / "QBT_SID_<port>" in newer ones. Some setups return 204
|
||||
@@ -119,10 +145,102 @@ class QbittorrentClient:
|
||||
return resp.json()
|
||||
|
||||
def maindata(self) -> dict[str, Any]:
|
||||
"""Fetch ``/sync/maindata``.
|
||||
"""Return the current ``/sync/maindata`` snapshot.
|
||||
|
||||
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
|
||||
``up_info_speed``, etc.) and ``torrents`` (a dict of
|
||||
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
|
||||
Uses qBittorrent's incremental ``rid`` protocol (first call is a full
|
||||
update, subsequent calls send the last rid and get a small diff that is
|
||||
merged into the cached snapshot), so payloads stay small. A short-TTL
|
||||
cache + lock collapses concurrent widget polls onto a single fetch, and
|
||||
on repeated failures the client backs off instead of hammering
|
||||
qBittorrent's single-threaded web server (serving the last good
|
||||
snapshot when available).
|
||||
|
||||
Returns a dict with ``server_state`` and ``torrents``.
|
||||
"""
|
||||
return self._get("/sync/maindata")
|
||||
now = time.time()
|
||||
with self._maindata_lock:
|
||||
# Serve a fresh-enough cached snapshot without re-hitting qBittorrent.
|
||||
if self._snapshot.get("torrents") and (now - self._maindata_fetched_at) < self._maindata_ttl:
|
||||
return self._copy_snapshot()
|
||||
# While backing off, don't pile on; serve stale or raise.
|
||||
if now < self._backoff_until:
|
||||
if self._snapshot.get("torrents"):
|
||||
return self._copy_snapshot()
|
||||
raise RuntimeError(
|
||||
"qBittorrent maindata unavailable (backing off after repeated failures)"
|
||||
)
|
||||
try:
|
||||
update = self._fetch_maindata_incremental()
|
||||
self._apply_update(update)
|
||||
except Exception as exc:
|
||||
self._consecutive_failures += 1
|
||||
delay = min(2 ** self._consecutive_failures, MAINDATA_BACKOFF_MAX)
|
||||
self._backoff_until = time.time() + delay
|
||||
logger.warning(
|
||||
"qBittorrent maindata fetch failed (#%s); backing off %.0fs: %s",
|
||||
self._consecutive_failures,
|
||||
delay,
|
||||
exc,
|
||||
)
|
||||
if self._snapshot.get("torrents"):
|
||||
return self._copy_snapshot()
|
||||
raise RuntimeError(f"qBittorrent maindata failed: {exc}") from exc
|
||||
self._maindata_fetched_at = time.time()
|
||||
self._consecutive_failures = 0
|
||||
self._backoff_until = 0.0
|
||||
return self._copy_snapshot()
|
||||
|
||||
def _fetch_maindata_incremental(self) -> dict[str, Any]:
|
||||
"""GET /sync/maindata, sending the last rid for an incremental update."""
|
||||
params: dict[str, Any] = {}
|
||||
if self._rid is not None:
|
||||
params["rid"] = self._rid
|
||||
return self._get("/sync/maindata", **params)
|
||||
|
||||
def _apply_update(self, update: dict[str, Any]) -> None:
|
||||
"""Merge a full or partial maindata update into the cached snapshot."""
|
||||
is_full = bool(update.get("full_update")) or self._rid is None
|
||||
self._rid = update.get("rid", self._rid)
|
||||
snap = self._snapshot
|
||||
if is_full:
|
||||
snap.clear()
|
||||
snap["server_state"] = dict(update.get("server_state") or {})
|
||||
snap["torrents"] = dict(update.get("torrents") or {})
|
||||
snap["categories"] = dict(update.get("categories") or {})
|
||||
snap["tags"] = list(update.get("tags") or [])
|
||||
snap["trackers"] = list(update.get("trackers") or [])
|
||||
return
|
||||
# Partial update — merge the diff.
|
||||
server_state = update.get("server_state")
|
||||
if isinstance(server_state, dict):
|
||||
snap["server_state"].update(server_state)
|
||||
changed = update.get("torrents")
|
||||
if isinstance(changed, dict):
|
||||
for hash_, fields in changed.items():
|
||||
if fields is None:
|
||||
snap["torrents"].pop(hash_, None)
|
||||
else:
|
||||
snap["torrents"][hash_] = fields
|
||||
for hash_ in update.get("torrents_removed") or []:
|
||||
snap["torrents"].pop(hash_, None)
|
||||
categories = update.get("categories")
|
||||
if isinstance(categories, dict):
|
||||
snap["categories"].update(categories)
|
||||
for name in update.get("categories_removed") or []:
|
||||
snap["categories"].pop(name, None)
|
||||
if "tags" in update:
|
||||
snap["tags"] = list(update.get("tags") or [])
|
||||
if "trackers" in update:
|
||||
snap["trackers"] = list(update.get("trackers") or [])
|
||||
|
||||
def _copy_snapshot(self) -> dict[str, Any]:
|
||||
"""Return a shallow, race-safe copy of the current snapshot."""
|
||||
snap = self._snapshot
|
||||
return {
|
||||
"rid": self._rid,
|
||||
"server_state": dict(snap.get("server_state") or {}),
|
||||
"torrents": dict(snap.get("torrents") or {}),
|
||||
"categories": dict(snap.get("categories") or {}),
|
||||
"tags": list(snap.get("tags") or []),
|
||||
"trackers": list(snap.get("trackers") or []),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user