light poller commit1

This commit is contained in:
Mateusz Gruszczyński
2026-05-27 14:58:26 +02:00
parent 4075e934eb
commit 054c9122f8
8 changed files with 210 additions and 36 deletions

View File

@@ -11,10 +11,11 @@ from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
DEFAULTS = {
"adaptive_enabled": True,
"safe_fallback_enabled": True,
"active_interval_seconds": 5.0,
"active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0,
"torrent_list_interval_seconds": 5.0,
"live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0,
@@ -52,6 +53,7 @@ def normalize_settings(data: dict | None) -> dict:
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
"live_stats_interval_seconds": _coerce_float(raw.get("live_stats_interval_seconds"), DEFAULTS["live_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 60.0),
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
@@ -65,7 +67,7 @@ def normalize_settings(data: dict | None) -> dict:
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
}
if settings["safe_fallback_enabled"]:
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "live_stats_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"):
if settings[key] <= 0:
settings[key] = DEFAULTS[key]
return settings
@@ -102,6 +104,8 @@ def save_settings(profile_id: int, data: dict) -> dict:
class ProfilePollState:
profile_id: int
last_fast_at: float = 0.0
last_live_at: float = 0.0
last_list_at: float = 0.0
last_system_at: float = 0.0
last_slow_at: float = 0.0
last_tracker_at: float = 0.0
@@ -151,12 +155,29 @@ def interval_for(settings: dict, state: ProfilePollState) -> float:
return base
def effective_live_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("live_stats_interval_seconds") or DEFAULTS["live_stats_interval_seconds"]))
def effective_list_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
# Note: Kept for compatibility with older diagnostics; the fast interval now means lightweight live stats.
return effective_live_interval(settings, state)
def should_live_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_live_at) >= effective_live_interval(settings, state)
def should_list_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_list_at) >= effective_list_interval(settings, state)
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_fast_at) >= effective_fast_interval(settings, state)
return should_live_poll(now, settings, state)
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
@@ -194,7 +215,7 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
state.last_tick_started_at = started_at
state.last_active = bool(active)
state.effective_interval_seconds = effective_fast_interval(effective_settings, state)
state.effective_interval_seconds = effective_live_interval(effective_settings, state)
state.last_ok = bool(ok)
state.last_error = str(error or "")
state.emitted_payload_size = int(emitted_payload_size or 0)
@@ -234,6 +255,8 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"last_ok": state.last_ok,
"last_tick_gap_ms": state.last_tick_gap_ms,
"effective_interval_seconds": state.effective_interval_seconds,
"live_stats_interval_seconds": effective_live_interval(effective_settings, state),
"torrent_list_interval_seconds": effective_list_interval(effective_settings, state),
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
"last_error": state.last_error,
"duration_ms": state.last_tick_ms,

View File

@@ -217,6 +217,13 @@ TORRENT_OPTIONAL_FIELDS = [
"d.timestamp.finished=",
]
LIVE_TORRENT_FIELDS = [
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
"d.custom1=",
]
def human_duration(seconds: int) -> str:
# Note: Download ETA is derived locally from remaining bytes and current download speed.
@@ -313,6 +320,65 @@ def normalize_row(row: list) -> dict:
}
def normalize_live_row(row: list) -> dict:
"""Normalize the small row used by the fast live stats poller."""
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
size = int(row[3] or 0)
completed = int(row[4] or 0)
complete = int(row[2] or 0)
state = int(row[1] or 0)
down_rate = int(row[7] or 0)
up_rate = int(row[6] or 0)
ratio_raw = int(row[5] or 0)
remaining_bytes = max(0, size - completed)
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not complete else 0
msg = str(row[12] or "")
hashing = int(row[13] or 0)
is_active = int(row[14] or 0)
labels = str(row[15] or "")
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
to_download_bytes = remaining_bytes if not complete else 0
return {
"hash": str(row[0] or ""),
"state": state,
"active": is_active,
"paused": is_paused,
"complete": complete,
"completed_bytes": completed,
"progress": progress,
"ratio": round(ratio_raw / 1000, 3),
"up_rate": up_rate,
"up_rate_h": human_rate(up_rate),
"down_rate": down_rate,
"down_rate_h": human_rate(down_rate),
"eta_seconds": eta_seconds,
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
"up_total": int(row[8] or 0),
"up_total_h": human_size(row[8] or 0),
"down_total": int(row[9] or 0),
"down_total_h": human_size(row[9] or 0),
"to_download": to_download_bytes,
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
"peers": int(row[10] or 0),
"seeds": int(row[11] or 0),
"message": msg,
"status": status,
"post_check": post_check,
"hashing": hashing,
}
def list_torrent_live_stats(profile: dict) -> list[dict]:
"""Return lightweight live torrent stats for the fast poller."""
# Note: This avoids the full torrent row multicall on every speed/status tick.
rows = client_for(profile).d.multicall2("", "main", *LIVE_TORRENT_FIELDS)
return [normalize_live_row(list(row)) for row in rows]
def list_torrents(profile: dict) -> list[dict]:
c = client_for(profile)
try:

View File

@@ -4,6 +4,8 @@ from threading import RLock
from time import time
from . import rtorrent, operation_logs
_LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"}
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
@@ -33,6 +35,42 @@ class TorrentCache:
self._updated_at.pop(profile_id, None)
return removed
def refresh_live(self, profile: dict) -> dict:
"""Refresh only volatile live fields without replacing the full cached torrent rows."""
# Note: The fast poller uses this lightweight path so speeds/statuses can update often while the full list poller stays slower.
profile_id = int(profile["id"])
try:
rows = rtorrent.list_torrent_live_stats(profile)
live = {t["hash"]: t for t in rows if t.get("hash")}
with self._lock:
old = dict(self._data.get(profile_id, {}))
if not old:
self._errors[profile_id] = ""
return {"ok": True, "profile_id": profile_id, "updated": [], "missing": [], "unknown": list(live.keys()), "requires_full_refresh": bool(live)}
updated = []
for h, live_row in live.items():
current = old.get(h)
if not current:
continue
patch = {"hash": h}
for key in _LIVE_KEYS:
if key in live_row and current.get(key) != live_row.get(key):
patch[key] = live_row.get(key)
if len(patch) > 1:
current.update({k: v for k, v in patch.items() if k != "hash"})
updated.append(patch)
missing = [h for h in old.keys() if h not in live]
unknown = [h for h in live.keys() if h not in old]
self._data[profile_id] = old
self._errors[profile_id] = ""
self._updated_at[profile_id] = time()
return {"ok": True, "profile_id": profile_id, "updated": updated, "missing": missing, "unknown": unknown, "requires_full_refresh": bool(missing or unknown)}
except Exception as exc:
with self._lock:
self._errors[profile_id] = str(exc)
return {"ok": False, "profile_id": profile_id, "error": str(exc), "updated": [], "missing": [], "unknown": [], "requires_full_refresh": False}
def refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"])
try:

View File

@@ -106,7 +106,7 @@ def register_socketio_handlers(socketio):
def poller():
while True:
loop_started = time.monotonic()
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS
next_sleep = 10.0
for profile in _poller_profiles():
if not profile:
continue
@@ -114,47 +114,92 @@ def register_socketio_handlers(socketio):
settings = poller_control.get_settings(pid)
state = poller_control.state_for(pid)
now = time.monotonic()
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state))
if not poller_control.should_fast_poll(now, settings, state):
live_interval = poller_control.effective_live_interval(settings, state)
list_interval = poller_control.effective_list_interval(settings, state)
next_sleep = min(
next_sleep,
max(poller_control.MIN_POLL_INTERVAL_SECONDS, live_interval - (now - state.last_live_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, list_interval - (now - state.last_list_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["system_stats_interval_seconds"]) - (now - state.last_system_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["slow_stats_interval_seconds"]) - (now - state.last_slow_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["queue_stats_interval_seconds"]) - (now - state.last_queue_at)),
)
run_live = poller_control.should_live_poll(now, settings, state)
run_list = poller_control.should_list_poll(now, settings, state)
run_system = poller_control.should_system_poll(now, settings, state)
run_slow = poller_control.should_slow_poll(now, settings, state)
run_queue = poller_control.should_queue_poll(now, settings, state)
if not (run_live or run_list or run_system or run_slow or run_queue):
continue
tick_started = time.monotonic()
changed = False
ok = True
error = ""
active = False
active = state.last_active
emitted_payload_size = 0
rtorrent_call_count = 0
skipped_emissions = 0
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
try:
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_fast_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else:
# Note: Speeds and peak records may change even when no torrent rows need repainting.
if speed_status:
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status}
speed_status = _speed_status_from_rows(pid, rows)
if run_live:
live = torrent_cache.refresh_live(profile)
rtorrent_call_count += 1
state.last_live_at = now
state.last_fast_at = now
ok = bool(live.get("ok"))
error = str(live.get("error") or "")
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if live.get("ok") else speed_status
if live.get("ok"):
if live.get("updated") or speed_status:
changed = changed or bool(live.get("updated"))
payload = {
"ok": True,
"profile_id": pid,
"updated": live.get("updated") or [],
"speed_status": speed_status,
"requires_full_refresh": bool(live.get("requires_full_refresh")),
}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_live_patch", payload, pid)
else:
skipped_emissions += 1
if live.get("requires_full_refresh"):
# Note: Missing or unknown hashes mean the next slow list tick must reconcile rows.
state.last_list_at = 0.0
run_list = True
else:
_emit_profile(socketio, "rtorrent_error", live, pid)
if run_list:
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_list_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else speed_status
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else:
skipped_emissions += 1
if poller_control.should_system_poll(now, settings, state):
if run_system:
state.last_system_at = now
rows = torrent_cache.snapshot(pid)
status = rtorrent.system_status(profile, rows)
rtorrent_call_count += 1
if bool(profile.get("is_remote")):
@@ -185,9 +230,11 @@ def register_socketio_handlers(socketio):
if poller_control.should_tracker_poll(now, settings, state):
state.last_tracker_at = now
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state):
state.last_slow_at = now
state.last_queue_at = now
if run_slow or run_queue:
if run_slow:
state.last_slow_at = now
if run_queue:
state.last_queue_at = now
if state.slow_task_running:
skipped_emissions += 1
else: