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: |
#!/usr/bin/env python3
"""
Fornax coordinator — round-robin proxy for qBittorrent API.
- POST /api/v2/torrents/add → round-robins across all backends
Fornax coordinator — least-loaded proxy for qBittorrent API.
- POST /api/v2/torrents/add → routed to worker with fewest active torrents
- Everything else → proxied to primary backend (qbittorrent)
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
BACKENDS = [b.strip() for b in os.environ.get("BACKENDS", "").split(",") if b.strip()]
USER = os.environ.get("QBT_USER", "admin")
PASS = os.environ.get("QBT_PASS", "adminadmin")
PRIMARY = BACKENDS[0] if BACKENDS else ""
pool = itertools.cycle(BACKENDS)
sids = {}
def fetch_sid(backend):
@@ -44,9 +43,26 @@ data:
sids[backend] = fetch_sid(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):
cookie = f"SID={sid(backend)}"
headers = {"Cookie": cookie}
headers = {"Cookie": f"SID={sid(backend)}"}
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(
@@ -57,8 +73,7 @@ data:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read(), list(r.getheaders())
except urllib.error.HTTPError as e:
# SID expired — refresh once and retry
if e.code == 403:
if e.code == 403: # SID expired — refresh once and retry
sids[backend] = fetch_sid(backend)
headers["Cookie"] = f"SID={sids[backend]}"
req2 = urllib.request.Request(
@@ -76,24 +91,19 @@ data:
class Handler(BaseHTTPRequestHandler):
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):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else None
ct = self.headers.get("Content-Type", "")
if method == "POST" and "/api/v2/torrents/add" in self.path:
backend = next(pool)
print(f" routing add → {backend}")
else:
backend = PRIMARY
backend = least_loaded() if (method == "POST" and "/api/v2/torrents/add" in self.path) else PRIMARY
status, resp_body, resp_headers = forward(backend, method, self.path, body, ct)
self.send_response(status)
skip = {"transfer-encoding", "connection"}
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.end_headers()
self.wfile.write(resp_body)