Files
SyncVito/src/syncvito/coordinator.py
T

300 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Serielle Hintergrundausführung, Zeitplanung und Retry-Steuerung."""
from __future__ import annotations
import traceback
from collections import deque
from datetime import datetime, timedelta
from pathlib import Path
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
from syncvito.config_store import ConfigStore
from syncvito.file_locks import is_file_locked
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode
from syncvito.realtime import RealtimeMonitor
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
__all__ = ["SyncCoordinator", "derive_overall_status"]
def derive_overall_status(
states: list[TaskRuntimeState], globally_paused: bool, running: bool = False
) -> tuple[str, str]:
"""Leitet den Tray-Zustand deterministisch aus allen Aufgaben ab."""
if TaskRuntimeState.ERROR in states:
return "error", "Fehler"
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
return "error", "Wartet auf Dateifreigabe"
if globally_paused:
return "paused", "Pausiert"
if TaskRuntimeState.ACTIVE not in states:
return "paused", "Inaktiv"
return "active", "Synchronisiert" if running else "Bereit"
class _WorkerSignals(QObject):
progress = pyqtSignal(int, str)
finished = pyqtSignal(str, object)
failed = pyqtSignal(str, str, bool)
class _SyncWorker(QRunnable):
def __init__(self, task: SyncTask, engine: SyncEngine) -> None:
super().__init__()
self.task = task
self.engine = engine
self.signals = _WorkerSignals()
def run(self) -> None:
try:
result = self.engine.synchronize(self.task, self.signals.progress.emit)
except UnavailablePathError as exc:
self.signals.failed.emit(self.task.id, str(exc), True)
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
detail = f"{exc}\n{traceback.format_exc(limit=4)}"
self.signals.failed.emit(self.task.id, detail, False)
else:
self.signals.finished.emit(self.task.id, result)
class _RealtimeBridge(QObject):
changed = pyqtSignal(str)
class SyncCoordinator(QObject):
"""Koordiniert genau einen sichtbaren Synchronisationslauf zur Zeit."""
progress_changed = pyqtSignal(int, str)
log_created = pyqtSignal(str, str, str)
overall_status_changed = pyqtSignal(str, str)
task_state_changed = pyqtSignal(str, object)
lock_changed = pyqtSignal(str)
idle_changed = pyqtSignal(bool)
def __init__(self, config: AppConfig, config_store: ConfigStore, parent: QObject | None = None) -> None:
super().__init__(parent)
self.config = config
self.config_store = config_store
self.engine = SyncEngine()
self.pool = QThreadPool.globalInstance()
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
self._queue: deque[str] = deque()
self._running_task_id: str | None = None
self._retry_tasks: dict[str, list[Path]] = {}
self._lock_messages: dict[str, str] = {}
self._task_states: dict[str, TaskRuntimeState] = {
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
}
self._last_realtime_event: dict[str, datetime] = {}
self._bridge = _RealtimeBridge()
self._bridge.changed.connect(self._on_realtime_event)
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
self._schedule_timer = QTimer(self)
self._schedule_timer.setInterval(30_000)
self._schedule_timer.timeout.connect(self._check_intervals)
self._schedule_timer.start()
self._retry_timer = QTimer(self)
self._retry_timer.setInterval(10_000)
self._retry_timer.timeout.connect(self._check_retries)
self._retry_timer.start()
self.rebuild_monitors()
def task_state(self, task_id: str) -> TaskRuntimeState:
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
def current_overall_status(self) -> tuple[str, str]:
"""Liefert den aktuell abgeleiteten Tray-Zustand."""
return derive_overall_status(list(self._task_states.values()), self.config.settings.paused, self.is_running)
def refresh_task_states(self) -> None:
"""Gleicht Laufzeitzustände nach Änderungen an der Aufgabenliste ab."""
known_ids = {task.id for task in self.config.tasks}
self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids}
self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
self._lock_messages = {
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
}
for task in self.config.tasks:
desired = TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED
current = self._task_states.get(task.id)
if current is None or current is TaskRuntimeState.PAUSED or not task.enabled:
self._set_task_state(task.id, desired)
if not task.enabled:
self._retry_tasks.pop(task.id, None)
self._lock_messages.pop(task.id, None)
enabled_ids = {task.id for task in self.config.tasks if task.enabled}
self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
self._emit_overall_status()
self._emit_lock_summary()
@property
def is_running(self) -> bool:
"""Zeigt an, ob momentan ein Worker aktiv ist."""
return self._running_task_id is not None
def enqueue(self, task_id: str) -> None:
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange."""
if self.config.settings.paused:
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert.")
return
task = self._task(task_id)
if task is None or not task.enabled:
return
if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id)
self._start_next()
def enqueue_all(self) -> None:
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
for task in self.config.tasks:
if task.enabled:
self.enqueue(task.id)
def set_paused(self, paused: bool) -> None:
"""Ändert den globalen Pausenzustand."""
self.config.settings.paused = paused
self.config_store.save(self.config)
if paused:
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.")
else:
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
self._start_next()
self._emit_overall_status()
def rebuild_monitors(self) -> None:
"""Aktualisiert alle Dateisystemüberwachungen."""
errors = self.monitor.rebuild(self.config.tasks)
for task_id, message in errors:
self._retry_tasks[task_id] = []
self._set_task_state(task_id, TaskRuntimeState.ERROR)
self.log_created.emit("Echtzeit", "Fehler", message)
self._emit_overall_status()
def stop(self) -> None:
"""Stoppt die Dateisystemüberwachung beim Beenden."""
self.monitor.stop()
def _start_next(self) -> None:
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
return
task_id = self._queue.popleft()
task = self._task(task_id)
if task is None or not task.enabled:
self._start_next()
return
self._running_task_id = task_id
self.idle_changed.emit(False)
self._emit_overall_status()
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
worker = _SyncWorker(task, self.engine)
worker.signals.progress.connect(self.progress_changed)
worker.signals.finished.connect(self._on_finished)
worker.signals.failed.connect(self._on_failed)
self.pool.start(worker)
def _on_finished(self, task_id: str, result: SyncResult) -> None:
task = self._task(task_id)
if task is None:
self._complete_current()
return
task.last_run = datetime.now().isoformat(timespec="seconds")
self.config_store.save(self.config)
if result.blocked:
paths = [blocked.path for blocked in result.blocked]
self._retry_tasks[task_id] = paths
descriptions: list[str] = []
for blocked in result.blocked:
names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes)
descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}")
message = "Zurückgestellt – " + "; ".join(descriptions)
self._lock_messages[task_id] = message
self._emit_lock_summary()
self.log_created.emit(task.name, "Blockiert", message)
self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE)
elif result.errors:
self._retry_tasks[task_id] = []
self._lock_messages.pop(task_id, None)
self._emit_lock_summary()
self.log_created.emit(task.name, "Fehler", " | ".join(result.errors[:5]))
self._set_task_state(task_id, TaskRuntimeState.ERROR)
else:
self._retry_tasks.pop(task_id, None)
self._lock_messages.pop(task_id, None)
self._emit_lock_summary()
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
self.log_created.emit(task.name, "Erfolg", summary)
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
self._complete_current()
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
task = self._task(task_id)
name = task.name if task else "Aufgabe"
self._retry_tasks[task_id] = []
self._lock_messages.pop(task_id, None)
self._emit_lock_summary()
self._set_task_state(task_id, TaskRuntimeState.ERROR)
self.log_created.emit(name, "Fehler", detail)
self._complete_current()
def _complete_current(self) -> None:
self._running_task_id = None
self.progress_changed.emit(100, "Abgeschlossen")
if not self._queue:
self.idle_changed.emit(True)
self._emit_overall_status()
QTimer.singleShot(200, self._start_next)
def _check_intervals(self) -> None:
if self.config.settings.paused:
return
now = datetime.now()
for task in self.config.tasks:
if not task.enabled or task.trigger is not TriggerMode.INTERVAL:
continue
try:
last_run = datetime.fromisoformat(task.last_run) if task.last_run else datetime.min
except ValueError:
last_run = datetime.min
if now - last_run >= timedelta(minutes=task.interval_minutes):
self.enqueue(task.id)
def _on_realtime_event(self, task_id: str) -> None:
now = datetime.now()
last = self._last_realtime_event.get(task_id)
self._last_realtime_event[task_id] = now
if last and now - last < timedelta(seconds=2):
return
QTimer.singleShot(1200, lambda: self.enqueue(task_id))
def _check_retries(self) -> None:
if self.config.settings.paused or self.is_running:
return
for task_id, paths in list(self._retry_tasks.items()):
if paths and any(is_file_locked(path) for path in paths):
continue
self.log_created.emit(
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
)
self.enqueue(task_id)
break
def _task(self, task_id: str) -> SyncTask | None:
return next((task for task in self.config.tasks if task.id == task_id), None)
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
if self._task_states.get(task_id) == state:
return
self._task_states[task_id] = state
self.task_state_changed.emit(task_id, state)
self._emit_overall_status()
def _emit_overall_status(self) -> None:
status, text = self.current_overall_status()
self.overall_status_changed.emit(status, text)
def _emit_lock_summary(self) -> None:
self.lock_changed.emit("\n".join(self._lock_messages.values()))