From 87ea6f06d2b84ec24c7ca56528bf26cd6c6b31c3 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Sun, 19 Jul 2026 02:45:08 +0300 Subject: [PATCH 1/6] add JobModel for persistent scheduler job store (#2360) --- src/vorta/store/connection.py | 2 ++ src/vorta/store/models.py | 17 +++++++++++++++++ tests/unit/conftest.py | 2 ++ tests/unit/test_job_model.py | 10 ++++++++++ 4 files changed, 31 insertions(+) create mode 100644 tests/unit/test_job_model.py diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index e62dee943..a80549f72 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -18,6 +18,7 @@ BackupProfileModel, EventLogModel, ExclusionModel, + JobModel, RepoModel, RepoPassword, SchemaVersion, @@ -57,6 +58,7 @@ def init_db(con: pw.SqliteDatabase | None = None) -> None: ArchiveModel, WifiSettingModel, EventLogModel, + JobModel, SchemaVersion, ExclusionModel, ] diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index 0453a16e6..b3d69c02e 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -247,6 +247,23 @@ class Meta: database = DB +class JobModel(BaseModel): + """Lifecycle record of a scheduled background job.""" + + profile = pw.ForeignKeyField(BackupProfileModel, backref='jobs') + repo = pw.ForeignKeyField(RepoModel, null=True, backref='jobs') + job_type = pw.CharField(default='backup') + status = pw.CharField(default='scheduled') + trigger = pw.CharField(null=True) + scheduled_at = pw.DateTimeField(null=True) + skip_reason = pw.CharField(null=True) + event_log = pw.ForeignKeyField(EventLogModel, null=True, backref='job') + created_at = pw.DateTimeField(default=datetime.now) + + class Meta: + database = DB + + class SchemaVersion(BaseModel): """Keep DB version to apply the correct migrations.""" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 6e8ae8aed..7a4aea144 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -13,6 +13,7 @@ ArchiveModel, BackupProfileModel, EventLogModel, + JobModel, RepoModel, RepoPassword, SchemaVersion, @@ -59,6 +60,7 @@ def all_workers_finished(jobs_manager): ArchiveModel, WifiSettingModel, EventLogModel, + JobModel, SchemaVersion, ] diff --git a/tests/unit/test_job_model.py b/tests/unit/test_job_model.py new file mode 100644 index 000000000..d8f401613 --- /dev/null +++ b/tests/unit/test_job_model.py @@ -0,0 +1,10 @@ +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='done', event_log=log) + + assert job.event_log.id == log.id + assert list(log.job)[0].id == job.id From ee2dec70a817d79b1062134ef06d4d9648fe75b2 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Sat, 25 Jul 2026 01:01:13 +0300 Subject: [PATCH 2/6] refactor(scheduler): store JobModel profile/repo as strings like EventLogModel so history survives profile/repo deletion --- src/vorta/store/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index b3d69c02e..b70c5321b 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -250,8 +250,8 @@ class Meta: class JobModel(BaseModel): """Lifecycle record of a scheduled background job.""" - profile = pw.ForeignKeyField(BackupProfileModel, backref='jobs') - repo = pw.ForeignKeyField(RepoModel, null=True, backref='jobs') + profile = pw.CharField(null=True) + repo_url = pw.CharField(null=True) job_type = pw.CharField(default='backup') status = pw.CharField(default='scheduled') trigger = pw.CharField(null=True) From 299aafe293d1cd2e968c013b0adf463ed8a36fdb Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Sat, 25 Jul 2026 01:17:12 +0300 Subject: [PATCH 3/6] feat(scheduler): record skipped scheduled backups as JobModel rows with their reason, the first JobModel consumer --- src/vorta/scheduler.py | 12 +++++++++++- tests/unit/test_scheduler.py | 23 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 372b29495..850d07e34 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -13,6 +13,7 @@ from PyQt6.QtWidgets import QApplication from vorta import application +from vorta.borg.borg_job import db_lock from vorta.borg.check import BorgCheckJob from vorta.borg.compact import BorgCompactJob from vorta.borg.create import BorgCreateJob @@ -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__) @@ -463,6 +464,15 @@ def create_backup(self, profile_id: int) -> None: ) else: logger.info('Backup skipped: %s', msg['message']) + with db_lock: + JobModel.create( + profile=profile_id, + repo_url=profile.repo.url if profile.repo else None, + job_type='backup', + status='skipped', + trigger='scheduled', + skip_reason=msg['message'], + ) self.pause(profile_id) def notify(self, result: dict[str, Any]) -> None: diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 53e54065c..2a9e001e9 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -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' @@ -273,3 +273,24 @@ 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 == 'skipped' + assert job.skip_reason == 'Current Wifi is not allowed.' + assert str(job.profile) == '1' From a9e0cc4bdf6272bbbd19a23e319ea3357da96aec Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Sat, 25 Jul 2026 01:50:44 +0300 Subject: [PATCH 4/6] feat(scheduler): record skips at the repo-busy and network-down-catchup points as JobModel rows --- src/vorta/scheduler.py | 18 ++++++++++++++++++ tests/unit/test_scheduler.py | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 850d07e34..67515ec9c 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -331,6 +331,15 @@ def set_timer_for_profile(self, profile_id: int) -> None: 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) + with db_lock: + JobModel.create( + profile=profile_id, + repo_url=profile.repo.url if profile.repo else None, + job_type='backup', + status='skipped', + trigger='catchup', + skip_reason='Network unavailable for catch-up.', + ) # calculate next time from now if profile.schedule_mode == 'interval': @@ -433,6 +442,15 @@ 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) + with db_lock: + JobModel.create( + profile=profile_id, + repo_url=profile.repo.url if profile.repo else None, + job_type='backup', + status='skipped', + trigger='scheduled', + skip_reason='Repository is busy with another job.', + ) self.pause(profile_id) return diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 2a9e001e9..9c0e6894a 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -294,3 +294,16 @@ def test_create_backup_records_skip_reason(qapp, qtbot, mocker): assert job.status == 'skipped' assert job.skip_reason == 'Current Wifi is not allowed.' assert str(job.profile) == '1' + + +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 == 'skipped' + assert job.skip_reason == 'Repository is busy with another job.' From 3bedd9ae4853b68e9648204a7e8dfde3afe089ed Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Sat, 25 Jul 2026 13:41:33 +0300 Subject: [PATCH 5/6] Tighten JobModel schema and purge old job rows --- src/vorta/borg/borg_job.py | 3 +-- src/vorta/store/connection.py | 3 +++ src/vorta/store/models.py | 27 +++++++++++++++++++++++---- tests/unit/test_job_model.py | 18 ++++++++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/vorta/borg/borg_job.py b/src/vorta/borg/borg_job.py index ccb7f0db1..a946dc77b 100644 --- a/src/vorta/borg/borg_job.py +++ b/src/vorta/borg/borg_job.py @@ -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']) diff --git a/src/vorta/store/connection.py b/src/vorta/store/connection.py index a80549f72..5e1824b03 100644 --- a/src/vorta/store/connection.py +++ b/src/vorta/store/connection.py @@ -86,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() diff --git a/src/vorta/store/models.py b/src/vorta/store/models.py index b70c5321b..8f4be7a0d 100644 --- a/src/vorta/store/models.py +++ b/src/vorta/store/models.py @@ -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 @@ -19,6 +20,7 @@ from vorta.views.utils import get_exclusion_presets DB = pw.Proxy() +db_lock = Lock() logger = logging.getLogger(__name__) @@ -250,18 +252,35 @@ class Meta: 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='backup') - status = pw.CharField(default='scheduled') + 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) - skip_reason = pw.CharField(null=True) - event_log = pw.ForeignKeyField(EventLogModel, null=True, backref='job') + 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): diff --git a/tests/unit/test_job_model.py b/tests/unit/test_job_model.py index d8f401613..443fb9d03 100644 --- a/tests/unit/test_job_model.py +++ b/tests/unit/test_job_model.py @@ -1,10 +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='done', event_log=log) + job = JobModel.create(profile=1, status=JobModel.Status.COMPLETED.value, event_log=log) assert job.event_log.id == log.id - assert list(log.job)[0].id == job.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 From 43662e89a009e0713329f68b5cafe78ad78cadc3 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Sat, 25 Jul 2026 13:41:33 +0300 Subject: [PATCH 6/6] Record failures as failed and dedupe catch-up skips --- src/vorta/scheduler.py | 73 ++++++++++++++++++-------------- tests/unit/test_scheduler.py | 80 +++++++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 35 deletions(-) diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 67515ec9c..3c6ee4778 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -7,13 +7,13 @@ 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 from PyQt6.QtWidgets import QApplication from vorta import application -from vorta.borg.borg_job import db_lock from vorta.borg.check import BorgCheckJob from vorta.borg.compact import BorgCompactJob from vorta.borg.create import BorgCreateJob @@ -324,22 +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) - with db_lock: - JobModel.create( - profile=profile_id, - repo_url=profile.repo.url if profile.repo else None, - job_type='backup', - status='skipped', - trigger='catchup', - skip_reason='Network unavailable for catch-up.', - ) + 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': @@ -431,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) @@ -442,15 +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) - with db_lock: - JobModel.create( - profile=profile_id, - repo_url=profile.repo.url if profile.repo else None, - job_type='backup', - status='skipped', - trigger='scheduled', - skip_reason='Repository is busy with another job.', - ) + self._record_skip(profile, trigger, 'Repository is busy with another job.') self.pause(profile_id) return @@ -480,17 +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']) - with db_lock: - JobModel.create( - profile=profile_id, - repo_url=profile.repo.url if profile.repo else None, - job_type='backup', - status='skipped', - trigger='scheduled', - skip_reason=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: diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 9c0e6894a..f6b516eba 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -291,9 +291,29 @@ def test_create_backup_records_skip_reason(qapp, qtbot, mocker): assert JobModel.select().count() == jobs_before + 1 job = JobModel.select().order_by(JobModel.id.desc()).get() - assert job.status == 'skipped' - assert job.skip_reason == 'Current Wifi is not allowed.' - assert str(job.profile) == '1' + 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): @@ -305,5 +325,55 @@ def test_create_backup_records_skip_when_repo_busy(qapp, mocker): assert JobModel.select().count() == jobs_before + 1 job = JobModel.select().order_by(JobModel.id.desc()).get() - assert job.status == 'skipped' - assert job.skip_reason == 'Repository is busy with another job.' + 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