Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 56 additions & 13 deletions src/vorta/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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}")

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/vorta/store/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ExclusionModel,
RepoModel,
RepoPassword,
SchedulerPauseModel,
SchemaVersion,
SettingsModel,
SourceFileModel,
Expand Down Expand Up @@ -57,6 +58,7 @@ def init_db(con: pw.SqliteDatabase | None = None) -> None:
ArchiveModel,
WifiSettingModel,
EventLogModel,
SchedulerPauseModel,
SchemaVersion,
ExclusionModel,
]
Expand Down
10 changes: 10 additions & 0 deletions src/vorta/store/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
1 change: 1 addition & 0 deletions src/vorta/views/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
3 changes: 3 additions & 0 deletions src/vorta/views/schedule_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
95 changes: 94 additions & 1 deletion tests/unit/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
Loading