Upgrade coordinator to least-loaded routing (checks active torrent count per worker)

This commit is contained in:
Will Anderson
2026-04-11 10:02:45 -05:00
parent ff6c1f1ee4
commit 0aff4523af
@@ -12,19 +12,18 @@ data:
coordinator.py: | coordinator.py: |
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Fornax coordinator — round-robin proxy for qBittorrent API. Fornax coordinator — least-loaded proxy for qBittorrent API.
- POST /api/v2/torrents/add → round-robins across all backends - POST /api/v2/torrents/add → routed to worker with fewest active torrents
- Everything else → proxied to primary backend (qbittorrent) - Everything else → proxied to primary backend (qbittorrent)
Manages its own SID sessions to each backend so auth is transparent. Manages its own SID sessions to each backend so auth is transparent.
""" """
import itertools, os, urllib.request, urllib.parse import json, os, urllib.request, urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
BACKENDS = [b.strip() for b in os.environ.get("BACKENDS", "").split(",") if b.strip()] BACKENDS = [b.strip() for b in os.environ.get("BACKENDS", "").split(",") if b.strip()]
USER = os.environ.get("QBT_USER", "admin") USER = os.environ.get("QBT_USER", "admin")
PASS = os.environ.get("QBT_PASS", "adminadmin") PASS = os.environ.get("QBT_PASS", "adminadmin")
PRIMARY = BACKENDS[0] if BACKENDS else "" PRIMARY = BACKENDS[0] if BACKENDS else ""
pool = itertools.cycle(BACKENDS)
sids = {} sids = {}
def fetch_sid(backend): def fetch_sid(backend):
@@ -44,9 +43,26 @@ data:
sids[backend] = fetch_sid(backend) sids[backend] = fetch_sid(backend)
return sids.get(backend, "") return sids.get(backend, "")
def active_count(backend):
"""Return number of downloading torrents on this backend, or large int on error."""
try:
req = urllib.request.Request(
f"{backend}/api/v2/torrents/info?filter=downloading",
headers={"Cookie": f"SID={sid(backend)}"},
)
with urllib.request.urlopen(req, timeout=3) as r:
return len(json.loads(r.read()))
except Exception:
return 9999
def least_loaded():
counts = {b: active_count(b) for b in BACKENDS}
chosen = min(counts, key=counts.get)
print(f" load: {counts} → routing to {chosen}")
return chosen
def forward(backend, method, path, body, content_type): def forward(backend, method, path, body, content_type):
cookie = f"SID={sid(backend)}" headers = {"Cookie": f"SID={sid(backend)}"}
headers = {"Cookie": cookie}
if content_type: if content_type:
headers["Content-Type"] = content_type headers["Content-Type"] = content_type
req = urllib.request.Request( req = urllib.request.Request(
@@ -57,8 +73,7 @@ data:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read(), list(r.getheaders()) return r.status, r.read(), list(r.getheaders())
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
# SID expired — refresh once and retry if e.code == 403: # SID expired — refresh once and retry
if e.code == 403:
sids[backend] = fetch_sid(backend) sids[backend] = fetch_sid(backend)
headers["Cookie"] = f"SID={sids[backend]}" headers["Cookie"] = f"SID={sids[backend]}"
req2 = urllib.request.Request( req2 = urllib.request.Request(
@@ -76,24 +91,19 @@ data:
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
print(f"{self.command} {self.path} → {args[1] if len(args) > 1 else ''}") print(f"{self.command} {self.path}")
def _proxy(self, method): def _proxy(self, method):
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else None body = self.rfile.read(length) if length else None
ct = self.headers.get("Content-Type", "") ct = self.headers.get("Content-Type", "")
if method == "POST" and "/api/v2/torrents/add" in self.path: backend = least_loaded() if (method == "POST" and "/api/v2/torrents/add" in self.path) else PRIMARY
backend = next(pool)
print(f" routing add → {backend}")
else:
backend = PRIMARY
status, resp_body, resp_headers = forward(backend, method, self.path, body, ct) status, resp_body, resp_headers = forward(backend, method, self.path, body, ct)
self.send_response(status) self.send_response(status)
skip = {"transfer-encoding", "connection"}
for k, v in resp_headers: for k, v in resp_headers:
if k.lower() not in skip: if k.lower() not in {"transfer-encoding", "connection"}:
self.send_header(k, v) self.send_header(k, v)
self.end_headers() self.end_headers()
self.wfile.write(resp_body) self.wfile.write(resp_body)