diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 372b29495..50cda7bee 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -20,7 +20,7 @@ from vorta.borg.prune import BorgPruneJob from vorta.i18n import translate from vorta.notifications import VortaNotifications -from vorta.store.models import BackupProfileModel, EventLogModel +from vorta.store.models import BackupProfileModel, EventLogModel, SchedulerPauseModel from vorta.utils import borg_compat, get_network_status_monitor logger = logging.getLogger(__name__) @@ -31,6 +31,7 @@ class ScheduleStatusType(enum.Enum): UNSCHEDULED = enum.auto() # Unknown TOO_FAR_AHEAD = enum.auto() # QTimer range exceeded, date provided NO_PREVIOUS_BACKUP = enum.auto() # run a manual backup first + PAUSED = enum.auto() # paused after a failed or skipped run, date provided class ScheduleStatus(NamedTuple): @@ -82,6 +83,8 @@ def __init__(self) -> None: else: logger.warning('Failed to connect to DBUS interface to detect sleep/resume events') + self._restore_pauses() + @QtCore.pyqtSlot(bool) def loginSuspendNotify(self, suspend: bool) -> None: if not suspend: @@ -146,21 +149,63 @@ def pause(self, profile_id: int, until: dt | None = None) -> None: # remove existing schedule self.remove_job(profile_id) + # set timeout/pause + other_pause = self.pauses.get(profile_id) + if other_pause is not None: + logger.debug(f"Override existing timeout for profile {profile_id}") + + self._set_pause(profile, until) + logger.debug(f"Paused {profile_id} until {until.strftime('%Y-%m-%d %H:%M:%S')}") + + def _set_pause(self, profile: BackupProfileModel, until: dt) -> None: + """Arm the reschedule timer for a pause and store it, so it outlives the process.""" + profile_id = profile.id # setting timer for reschedule is not possible if called # from a non-QThread - it won't fail but won't work timer_value = max(1, (until - dt.now()).total_seconds()) timer = QtCore.QTimer() - timer.setInterval(int(timer_value * 1000) + 100) + timer.setInterval(min(int(timer_value * 1000) + 100, 2**31 - 1)) timer.timeout.connect(lambda: self.set_timer_for_profile(profile_id)) timer.start() - # set timeout/pause - other_pause = self.pauses.get(profile_id) - if other_pause is not None: - logger.debug(f"Override existing timeout for profile {profile_id}") - self.pauses[profile_id] = (until, timer) - logger.debug(f"Paused {profile_id} until {until.strftime('%Y-%m-%d %H:%M:%S')}") + SchedulerPauseModel.replace(profile=profile_id, paused_until=until).execute() + self._mark_paused(profile, until) + + def _mark_paused(self, profile: BackupProfileModel, until: dt) -> None: + """Report the pause as the schedule status, unless the profile has no schedule to block.""" + if profile.repo is None or profile.schedule_mode == 'off': + return + + self.timers[profile.id] = {'type': ScheduleStatusType.PAUSED, 'dt': until} + self.schedule_changed.emit() + + def _clear_pause(self, profile_id: int) -> None: + """Drop a pause from memory, from the schedule status and from the database.""" + pause = self.pauses.pop(profile_id, None) + if pause is not None: + pause[1].stop() + + status = self.timers.get(profile_id) + if status is not None and status.get('type') is ScheduleStatusType.PAUSED: + del self.timers[profile_id] + + SchedulerPauseModel.delete().where(SchedulerPauseModel.profile == profile_id).execute() + + def _restore_pauses(self) -> None: + """Re-arm the pauses stored by a previous run, dropping the ones that already ran out.""" + now = dt.now() + + for stored in list(SchedulerPauseModel.select()): + profile_id = stored.profile_id + profile = BackupProfileModel.get_or_none(id=profile_id) + + if profile is None or stored.paused_until <= now: + self._clear_pause(profile_id) + continue + + self._set_pause(profile, stored.paused_until) + logger.debug(f"Restored pause for {profile_id} until {stored.paused_until:%Y-%m-%d %H:%M:%S}") def unpause(self, profile_id: int) -> None: """ @@ -179,9 +224,7 @@ def unpause(self, profile_id: int) -> None: if pause is None: # already unpaused return - dummy, timer = pause - timer.stop() - del self.pauses[profile_id] + self._clear_pause(profile_id) logger.debug(f"Unpaused {profile_id}") @@ -230,10 +273,10 @@ def set_timer_for_profile(self, profile_id: int) -> None: profile_id, pause[0].strftime('%Y-%m-%d %H:%M:%S'), ) + self._mark_paused(profile, pause_end) return else: - timer.stop() - del self.pauses[profile_id] + self._clear_pause(profile_id) if profile.repo is None: # No backups without repo set logger.debug( diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index e62dee943..5133e069e 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -20,6 +20,7 @@ ExclusionModel, RepoModel, RepoPassword, + SchedulerPauseModel, SchemaVersion, SettingsModel, SourceFileModel, @@ -57,6 +58,7 @@ def init_db(con: pw.SqliteDatabase | None = None) -> None: ArchiveModel, WifiSettingModel, EventLogModel, + SchedulerPauseModel, SchemaVersion, ExclusionModel, ] diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 0453a16e6..180fdb0a5 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -247,6 +247,16 @@ class Meta: database = DB +class SchedulerPauseModel(BaseModel): + """A scheduling pause for a profile, kept so it survives a restart.""" + + profile = pw.ForeignKeyField(BackupProfileModel, primary_key=True) + paused_until = pw.DateTimeField() + + class Meta: + database = DB + + class SchemaVersion(BaseModel): """Keep DB version to apply the correct migrations.""" diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index 9d5588362..bb75a535b 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -237,6 +237,7 @@ def profile_delete_action(self): ) if reply == QMessageBox.StandardButton.Yes: + self.app.scheduler.unpause(to_delete_id) # Drop a pause the deleted id could outlive to_delete.delete_instance(recursive=True) self.app.scheduler.remove_job(to_delete_id) # Remove pending jobs self.profileSelector.takeItem(self.profileSelector.currentRow()) diff --git a/src/vorta/views/schedule_page.py b/src/vorta/views/schedule_page.py index 19ce0567f..a05ace95d 100644 --- a/src/vorta/views/schedule_page.py +++ b/src/vorta/views/schedule_page.py @@ -115,6 +115,9 @@ def draw_next_scheduled_backup(self): ): time = QDateTime.fromMSecsSinceEpoch(int(status.time.timestamp() * 1000)) text = get_locale().toString(time, QLocale.FormatType.LongFormat) + elif status.type == ScheduleStatusType.PAUSED: + time = QDateTime.fromMSecsSinceEpoch(int(status.time.timestamp() * 1000)) + text = self.tr('Paused until %s') % get_locale().toString(time, QLocale.FormatType.LongFormat) elif status.type == ScheduleStatusType.NO_PREVIOUS_BACKUP: text = self.tr('Run a manual backup first') else: diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 53e54065c..4c1ca7023 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -4,12 +4,14 @@ from unittest.mock import MagicMock import pytest +from PyQt6 import QtCore +from PyQt6.QtWidgets import QDialogButtonBox, QMessageBox from pytest import mark import vorta.borg import vorta.scheduler from vorta.scheduler import ScheduleStatus, ScheduleStatusType, VortaScheduler -from vorta.store.models import BackupProfileModel, EventLogModel +from vorta.store.models import BackupProfileModel, EventLogModel, SchedulerPauseModel PROFILE_NAME = 'Default' FIXED_SCHEDULE = 'fixed' @@ -77,6 +79,97 @@ def test_set_timer_for_missing_profile(): assert len(scheduler.timers) == 0 +def test_pause_survives_restart(): + """A pause is restored when the scheduler is recreated, and unpause clears it.""" + profile = BackupProfileModel.get(name=PROFILE_NAME) + profile.schedule_mode = INTERVAL_SCHEDULE + profile.save() + + VortaScheduler().pause(profile.id) + stored = SchedulerPauseModel.get_or_none(profile=profile.id) + assert stored is not None + + restarted = VortaScheduler() + assert restarted.paused(profile.id) + assert restarted.next_job_for_profile(profile.id) == ScheduleStatus(ScheduleStatusType.PAUSED, stored.paused_until) + + restarted.unpause(profile.id) + assert restarted.next_job_for_profile(profile.id).type is not ScheduleStatusType.PAUSED + assert SchedulerPauseModel.get_or_none(profile=profile.id) is None + + +def test_expired_pause_dropped_on_restart(clockmock): + """A pause that ran out while Vorta was closed must not keep blocking the schedule.""" + profile = BackupProfileModel.get(name=PROFILE_NAME) + profile.schedule_mode = INTERVAL_SCHEDULE + profile.save() + + SchedulerPauseModel.replace(profile=profile.id, paused_until=dt(2020, 5, 6, 4, 30)).execute() + clockmock.now.return_value = dt(2020, 5, 6, 5, 30) + + scheduler = VortaScheduler() + + assert not scheduler.paused(profile.id) + assert SchedulerPauseModel.get_or_none(profile=profile.id) is None + + +def test_pause_cleared_when_it_runs_out(clockmock): + """A pause running out while Vorta stays open clears the row and reschedules.""" + profile = BackupProfileModel.get(name=PROFILE_NAME) + profile.schedule_mode = INTERVAL_SCHEDULE + profile.save() + + clockmock.now.return_value = dt(2020, 5, 6, 4, 30) + scheduler = VortaScheduler() + scheduler.pause(profile.id) + + clockmock.now.return_value = dt(2020, 5, 6, 6, 30) + scheduler.set_timer_for_profile(profile.id) + + assert not scheduler.paused(profile.id) + assert SchedulerPauseModel.get_or_none(profile=profile.id) is None + + +def test_deleting_a_paused_profile_clears_the_pause(qapp, qtbot, mocker): + """SQLite reuses ids, so a new profile must not inherit a deleted one's pause.""" + main = qapp.main_window + main.profile_add_action() + qtbot.keyClicks(main.window.profileNameField, 'Paused Profile') + qtbot.mouseClick( + main.window.buttonBox.button(QDialogButtonBox.StandardButton.Save), QtCore.Qt.MouseButton.LeftButton + ) + + profile = BackupProfileModel.get(name='Paused Profile') + profile.schedule_mode = INTERVAL_SCHEDULE + profile.save() + qapp.scheduler.pause(profile.id) + assert SchedulerPauseModel.get_or_none(profile=profile.id) is not None + + mocker.patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes) + qtbot.mouseClick(main.profileDeleteButton, QtCore.Qt.MouseButton.LeftButton) + + assert not qapp.scheduler.paused(profile.id) + assert SchedulerPauseModel.get_or_none(profile=profile.id) is None + + +def test_paused_manual_profile_reports_unscheduled(): + """Switching a paused profile to manual reports no schedule, not a pause.""" + profile = BackupProfileModel.get(name=PROFILE_NAME) + profile.schedule_mode = INTERVAL_SCHEDULE + profile.save() + + scheduler = VortaScheduler() + scheduler.pause(profile.id) + + profile.schedule_mode = MANUAL_SCHEDULE + profile.save() + scheduler.set_timer_for_profile(profile.id) + + assert scheduler.paused(profile.id) + assert scheduler.next_job_for_profile(profile.id).type is ScheduleStatusType.UNSCHEDULED + assert VortaScheduler().next_job_for_profile(profile.id).type is ScheduleStatusType.UNSCHEDULED + + def test_simple_schedule(clockmock): """Test a simple scheduling including `next_job` and `remove_job`.""" scheduler = VortaScheduler()