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
3 changes: 1 addition & 2 deletions src/vorta/borg/borg_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@
from vorta.i18n import trans_late, translate
from vorta.keyring.abc import VortaKeyring
from vorta.keyring.db import VortaDBKeyring
from vorta.store.models import EventLogModel
from vorta.store.models import EventLogModel, db_lock
from vorta.utils import borg_compat, pretty_bytes

keyring_lock = Lock()
db_lock = Lock()
logger = logging.getLogger(__name__)

FakeRepo = namedtuple('Repo', ['url', 'name', 'id', 'extra_borg_arguments', 'encryption'])
Expand Down
47 changes: 44 additions & 3 deletions src/vorta/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import timedelta
from typing import Any, NamedTuple

import peewee as pw
from packaging import version
from PyQt6 import QtCore, QtDBus
from PyQt6.QtCore import QTimer
Expand All @@ -20,7 +21,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, JobModel
from vorta.utils import borg_compat, get_network_status_monitor

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -323,13 +324,19 @@ def set_timer_for_profile(self, profile_id: int) -> None:
profile.name,
profile_id,
)
self.create_backup(profile_id)
self.create_backup(profile_id, trigger=JobModel.Trigger.CATCHUP.value)
finally:
self.lock.acquire() # with-statement will try to release

return # create_backup will lead to a call to this method
elif profile.schedule_make_up_missed and not self._net_up and needs_network:
logger.debug('Skipping catchup %s (%s), the network is not available', profile.name, profile.id)
self._record_skip(
profile,
JobModel.Trigger.CATCHUP.value,
'Network unavailable for catch-up.',
scheduled_at=next_time,
)

# calculate next time from now
if profile.schedule_mode == 'interval':
Expand Down Expand Up @@ -421,7 +428,37 @@ def next_job_for_profile(self, profile_id: int) -> ScheduleStatus:
return ScheduleStatus(ScheduleStatusType.UNSCHEDULED)
return ScheduleStatus(job['type'], time=job.get('dt')) # type: ignore[arg-type]

def create_backup(self, profile_id: int) -> None:
def _record_skip(
self,
profile: BackupProfileModel,
trigger: str,
reason: str,
status: str = JobModel.Status.SKIPPED.value,
scheduled_at: dt | None = None,
) -> None:
"""Record a job outcome, deduplicated on the occurrence when one is known."""
lookup = {
'profile': str(profile.id),
'trigger': trigger,
'status': status,
'scheduled_at': scheduled_at,
}
details = {
'profile_name': profile.name,
'repo_url': profile.repo.url if profile.repo else None,
'job_type': JobModel.Type.BACKUP.value,
'reason': reason,
}

try:
if scheduled_at is None:
JobModel.create(**lookup, **details)
else:
JobModel.get_or_create(**lookup, defaults=details)
except pw.PeeweeException:
logger.warning('Could not record job for profile %s.', profile.id, exc_info=True)

def create_backup(self, profile_id: int, trigger: str = JobModel.Trigger.SCHEDULED.value) -> None:
notifier = VortaNotifications.pick()
profile = BackupProfileModel.get_or_none(id=profile_id)

Expand All @@ -432,6 +469,7 @@ def create_backup(self, profile_id: int) -> None:
# Skip if a job for this profile (repo) is already in progress
if self.app.jobs_manager.is_worker_running(site=profile.repo.id):
logger.debug('A job for repo %s is already active.', profile.repo.id)
self._record_skip(profile, trigger, 'Repository is busy with another job.')
self.pause(profile_id)
return

Expand Down Expand Up @@ -461,8 +499,11 @@ def create_backup(self, profile_id: int) -> None:
translate('messages', msg['message']),
level='error',
)
status = JobModel.Status.FAILED.value
else:
logger.info('Backup skipped: %s', msg['message'])
status = JobModel.Status.SKIPPED.value
self._record_skip(profile, trigger, msg['message'], status=status)
self.pause(profile_id)

def notify(self, result: dict[str, Any]) -> None:
Expand Down
5 changes: 5 additions & 0 deletions src/vorta/store/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
BackupProfileModel,
EventLogModel,
ExclusionModel,
JobModel,
RepoModel,
RepoPassword,
SchemaVersion,
Expand Down Expand Up @@ -57,6 +58,7 @@ def init_db(con: pw.SqliteDatabase | None = None) -> None:
ArchiveModel,
WifiSettingModel,
EventLogModel,
JobModel,
SchemaVersion,
ExclusionModel,
]
Expand Down Expand Up @@ -84,6 +86,9 @@ def init_db(con: pw.SqliteDatabase | None = None) -> None:
entry.not_in(last_scheduled_backups_per_profile),
).execute()

# Delete old job records after 6 months. Nothing derives scheduling state from them.
JobModel.delete().where(JobModel.created_at < six_months_ago).execute()

# Migrations
current_schema, created = SchemaVersion.get_or_create(id=1, defaults={'version': SCHEMA_VERSION})
current_schema.save()
Expand Down
36 changes: 36 additions & 0 deletions src/vorta/store/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
from datetime import datetime
from enum import Enum
from threading import Lock
from typing import Any

import peewee as pw
Expand All @@ -19,6 +20,7 @@
from vorta.views.utils import get_exclusion_presets

DB = pw.Proxy()
db_lock = Lock()
logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -247,6 +249,40 @@ class Meta:
database = DB


class JobModel(BaseModel):
"""Lifecycle record of a scheduled background job."""

class Status(Enum):
SCHEDULED = 'scheduled'
RUNNING = 'running'
COMPLETED = 'completed'
FAILED = 'failed'
SKIPPED = 'skipped'
INTERRUPTED = 'interrupted'

class Type(Enum):
BACKUP = 'backup'

class Trigger(Enum):
SCHEDULED = 'scheduled'
CATCHUP = 'catchup'

profile = pw.CharField(null=True)
profile_name = pw.CharField(null=True)
repo_url = pw.CharField(null=True)
job_type = pw.CharField(default=Type.BACKUP.value)
status = pw.CharField(default=Status.SCHEDULED.value)
trigger = pw.CharField(null=True)
scheduled_at = pw.DateTimeField(null=True)
reason = pw.CharField(null=True)
event_log = pw.ForeignKeyField(EventLogModel, null=True, backref='jobs')
created_at = pw.DateTimeField(default=datetime.now)

class Meta:
database = DB
indexes = ((('profile', 'status', 'created_at'), False),)


class SchemaVersion(BaseModel):
"""Keep DB version to apply the correct migrations."""

Expand Down
2 changes: 2 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ArchiveModel,
BackupProfileModel,
EventLogModel,
JobModel,
RepoModel,
RepoPassword,
SchemaVersion,
Expand Down Expand Up @@ -59,6 +60,7 @@ def all_workers_finished(jobs_manager):
ArchiveModel,
WifiSettingModel,
EventLogModel,
JobModel,
SchemaVersion,
]

Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_job_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from datetime import datetime, timedelta

import vorta.store.connection
from vorta.store.models import EventLogModel, JobModel


def test_job_links_to_event_log():
"""A job's execution result is reached through the event_log relation."""
log = EventLogModel.create(category='scheduled', subcommand='create')
job = JobModel.create(profile=1, status=JobModel.Status.COMPLETED.value, event_log=log)

assert job.event_log.id == log.id
assert [j.id for j in log.jobs] == [job.id]


def test_old_jobs_are_purged_on_init():
"""Job records older than six months are dropped when the DB is opened."""
old = JobModel.create(profile=1, created_at=datetime.now() - timedelta(days=200))
recent = JobModel.create(profile=1, created_at=datetime.now() - timedelta(days=20))

vorta.store.connection.init_db()

assert JobModel.get_or_none(id=old.id) is None
assert JobModel.get_or_none(id=recent.id) is not None
106 changes: 105 additions & 1 deletion tests/unit/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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, JobModel

PROFILE_NAME = 'Default'
FIXED_SCHEDULE = 'fixed'
Expand Down Expand Up @@ -273,3 +273,107 @@ def test_create_backup_no_error_notification_on_info_level(qapp, qtbot, mocker,
# The error notification should be suppressed for an expected skip.
assert mock_notifier.deliver.call_count == 1
assert mock_notifier.deliver.call_args.kwargs.get('level') != 'error'


def test_create_backup_records_skip_reason(qapp, qtbot, mocker):
"""A skipped scheduled backup is recorded as a JobModel row with its reason."""
mocker.patch(
'vorta.scheduler.BorgCreateJob.prepare',
return_value={
'ok': False,
'message': 'Current Wifi is not allowed.',
'level': 'info',
},
)
jobs_before = JobModel.select().count()

qapp.scheduler.create_backup(1)

assert JobModel.select().count() == jobs_before + 1
job = JobModel.select().order_by(JobModel.id.desc()).get()
assert job.status == JobModel.Status.SKIPPED.value
assert job.reason == 'Current Wifi is not allowed.'
assert job.profile == '1'
assert job.profile_name == PROFILE_NAME


def test_create_backup_records_failure_not_skip(qapp, qtbot, mocker):
"""An unexpected prepare() failure is recorded as failed, not skipped."""
mocker.patch(
'vorta.scheduler.BorgCreateJob.prepare',
return_value={
'ok': False,
'message': 'Add a backup repository first.',
},
)
jobs_before = JobModel.select().count()

qapp.scheduler.create_backup(1)

assert JobModel.select().count() == jobs_before + 1
job = JobModel.select().order_by(JobModel.id.desc()).get()
assert job.status == JobModel.Status.FAILED.value
assert job.reason == 'Add a backup repository first.'


def test_create_backup_records_skip_when_repo_busy(qapp, mocker):
"""A scheduled run blocked by a busy repo is recorded as a skipped JobModel row."""
mocker.patch.object(qapp.jobs_manager, 'is_worker_running', return_value=True)
jobs_before = JobModel.select().count()

qapp.scheduler.create_backup(1)

assert JobModel.select().count() == jobs_before + 1
job = JobModel.select().order_by(JobModel.id.desc()).get()
assert job.status == JobModel.Status.SKIPPED.value
assert job.reason == 'Repository is busy with another job.'


def test_create_backup_keeps_the_catchup_trigger(qapp, mocker):
"""A catch-up run that gets skipped is not recorded as an ordinary scheduled run."""
mocker.patch.object(qapp.jobs_manager, 'is_worker_running', return_value=True)

qapp.scheduler.create_backup(1, trigger=JobModel.Trigger.CATCHUP.value)

job = JobModel.select().order_by(JobModel.id.desc()).get()
assert job.trigger == JobModel.Trigger.CATCHUP.value


def test_set_timer_records_skip_when_network_down_for_catchup(clockmock):
"""A catch-up run blocked by a down network is recorded as a skipped JobModel row."""
scheduler = VortaScheduler()
scheduler._net_up = False

time = dt(2020, 5, 6, 4, 30)
clockmock.now.return_value = time

profile = BackupProfileModel.get(name=PROFILE_NAME)
profile.schedule_make_up_missed = True
profile.schedule_mode = INTERVAL_SCHEDULE
profile.schedule_interval_unit = 'hours'
profile.schedule_interval_count = 3
profile.save()

last_run = time - td(hours=6)
EventLogModel.create(
subcommand='create',
profile=profile.id,
returncode=0,
category='scheduled',
start_time=last_run,
end_time=last_run,
)
jobs_before = JobModel.select().count()

scheduler.set_timer_for_profile(profile.id)

assert JobModel.select().count() == jobs_before + 1
job = JobModel.select().order_by(JobModel.id.desc()).get()
assert job.status == JobModel.Status.SKIPPED.value
assert job.trigger == JobModel.Trigger.CATCHUP.value
assert job.reason == 'Network unavailable for catch-up.'
assert job.scheduled_at == last_run + td(hours=3)

# Re-evaluating the same missed run must not add a second row.
scheduler.set_timer_for_profile(profile.id)
assert JobModel.select().count() == jobs_before + 1
Loading