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 = { DEFAULTS = {
"adaptive_enabled": True, "adaptive_enabled": True,
"safe_fallback_enabled": True, "safe_fallback_enabled": True,
"active_interval_seconds": 5.0, "active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0, "idle_interval_seconds": 15.0,
"error_interval_seconds": 30.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, "system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0, "tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.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), "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), "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), "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), "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), "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), "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)), "recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
} }
if settings["safe_fallback_enabled"]: 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: if settings[key] <= 0:
settings[key] = DEFAULTS[key] settings[key] = DEFAULTS[key]
return settings return settings
@@ -102,6 +104,8 @@ def save_settings(profile_id: int, data: dict) -> dict:
class ProfilePollState: class ProfilePollState:
profile_id: int profile_id: int
last_fast_at: float = 0.0 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_system_at: float = 0.0
last_slow_at: float = 0.0 last_slow_at: float = 0.0
last_tracker_at: float = 0.0 last_tracker_at: float = 0.0
@@ -151,12 +155,29 @@ def interval_for(settings: dict, state: ProfilePollState) -> float:
return base 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: 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: 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: 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_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_tick_started_at = started_at
state.last_active = bool(active) 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_ok = bool(ok)
state.last_error = str(error or "") state.last_error = str(error or "")
state.emitted_payload_size = int(emitted_payload_size or 0) 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_ok": state.last_ok,
"last_tick_gap_ms": state.last_tick_gap_ms, "last_tick_gap_ms": state.last_tick_gap_ms,
"effective_interval_seconds": state.effective_interval_seconds, "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, "configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
"last_error": state.last_error, "last_error": state.last_error,
"duration_ms": state.last_tick_ms, "duration_ms": state.last_tick_ms,

View File

@@ -217,6 +217,13 @@ TORRENT_OPTIONAL_FIELDS = [
"d.timestamp.finished=", "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: def human_duration(seconds: int) -> str:
# Note: Download ETA is derived locally from remaining bytes and current download speed. # 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]: def list_torrents(profile: dict) -> list[dict]:
c = client_for(profile) c = client_for(profile)
try: try:

View File

@@ -4,6 +4,8 @@ from threading import RLock
from time import time from time import time
from . import rtorrent, operation_logs 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"} _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) self._updated_at.pop(profile_id, None)
return removed 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: def refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"]) profile_id = int(profile["id"])
try: try:

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long