Upgrade coordinator to least-loaded routing (checks active torrent count per worker)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user