light poller commit1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user