|
|
@@ -1,20 +1,25 @@
|
|
|
import uuid
|
|
|
+from contextlib import contextmanager
|
|
|
from datetime import timedelta
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
-from django.test import RequestFactory, TestCase
|
|
|
+from django.core.exceptions import ValidationError
|
|
|
+from django.test import RequestFactory, TestCase, tag
|
|
|
from django.urls import reverse
|
|
|
from django.utils import timezone
|
|
|
+from django_pg_utils import advisory_lock
|
|
|
from django_rq import get_queue
|
|
|
|
|
|
from core.choices import JobStatusChoices
|
|
|
from core.exceptions import JobFailed
|
|
|
from core.models import DataSource, Job
|
|
|
+from core.signals import job_end
|
|
|
from dcim.models import Site
|
|
|
from dcim.views import SiteBulkEditView
|
|
|
+from extras.models import Notification
|
|
|
from users.models import User
|
|
|
from utilities.request import copy_safe_request
|
|
|
-from utilities.testing import disable_warnings
|
|
|
+from utilities.testing import disable_logging, disable_warnings
|
|
|
from utilities.testing.mixins import RQQueueTestMixin
|
|
|
|
|
|
from ..jobs import *
|
|
|
@@ -104,6 +109,117 @@ class JobRunnerTestCase(BaseJobRunnerTestCase):
|
|
|
self.assertIn('Test error', tb_message)
|
|
|
self.assertNotIn(_INSTALL_ROOT, tb_message)
|
|
|
|
|
|
+ @tag('regression') # Ref: #23535
|
|
|
+ def test_handle_reschedules_before_terminating(self):
|
|
|
+ """A periodic job's successor exists when job_end is sent."""
|
|
|
+ outcomes = (
|
|
|
+ (None, JobStatusChoices.STATUS_COMPLETED),
|
|
|
+ (JobFailed(), JobStatusChoices.STATUS_FAILED),
|
|
|
+ (RuntimeError('Simulated run error'), JobStatusChoices.STATUS_ERRORED),
|
|
|
+ )
|
|
|
+ for run_error, status in outcomes:
|
|
|
+ with self.subTest(status=status):
|
|
|
+ instance = DataSource.objects.create(name=f'Source {status}', type='local')
|
|
|
+ job = TestJobRunner.enqueue(instance, interval=60, schedule_at=self.get_schedule_at())
|
|
|
+ observed = []
|
|
|
+
|
|
|
+ def record_end(sender, **kwargs):
|
|
|
+ waiting = TestJobRunner.get_jobs(instance).filter(
|
|
|
+ status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
|
|
|
+ )
|
|
|
+ observed.append((Job.objects.get(pk=sender.pk).status, list(waiting.values_list('pk', flat=True))))
|
|
|
+
|
|
|
+ job_end.connect(record_end, sender=job)
|
|
|
+ try:
|
|
|
+ with patch.object(TestJobRunner, 'run', side_effect=run_error), disable_warnings('netbox.jobs'):
|
|
|
+ TestJobRunner.handle(job)
|
|
|
+ finally:
|
|
|
+ job_end.disconnect(record_end, sender=job)
|
|
|
+
|
|
|
+ successor = TestJobRunner.get_jobs(instance).exclude(pk=job.pk).get()
|
|
|
+ self.assertEqual(observed, [(status, [successor.pk])])
|
|
|
+ self.assertEqual((successor.status, successor.interval), (JobStatusChoices.STATUS_SCHEDULED, 60))
|
|
|
+
|
|
|
+ def test_handle_keeps_run_outcome_when_reschedule_is_invalid(self):
|
|
|
+ """A reschedule rejected as invalid keeps the run's outcome and ends the job once."""
|
|
|
+ user = User.objects.create_user(username='job-owner')
|
|
|
+ outcomes = (
|
|
|
+ (None, JobStatusChoices.STATUS_COMPLETED),
|
|
|
+ (JobFailed(), JobStatusChoices.STATUS_FAILED),
|
|
|
+ (RuntimeError('Simulated run error'), JobStatusChoices.STATUS_ERRORED),
|
|
|
+ )
|
|
|
+ for run_error, status in outcomes:
|
|
|
+ with self.subTest(status=status):
|
|
|
+ instance = DataSource.objects.create(name=f'Source {status}', type='local')
|
|
|
+ job = TestJobRunner.enqueue(instance, interval=60, user=user)
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(TestJobRunner, 'run', side_effect=run_error),
|
|
|
+ patch.object(TestJobRunner, 'enqueue', side_effect=ValidationError('Simulated validation error')),
|
|
|
+ patch.object(job_end, 'send', wraps=job_end.send) as send_end,
|
|
|
+ disable_logging(),
|
|
|
+ ):
|
|
|
+ TestJobRunner.handle(job)
|
|
|
+
|
|
|
+ job.refresh_from_db()
|
|
|
+ self.assertEqual(job.status, status)
|
|
|
+ self.assertEqual(job.error, repr(run_error) if status == JobStatusChoices.STATUS_ERRORED else '')
|
|
|
+ self.assertEqual(job.log_entries[-1]['level'], 'error')
|
|
|
+ self.assertIn('Simulated validation error', job.log_entries[-1]['message'])
|
|
|
+ self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
|
|
|
+ send_end.assert_called_once_with(job)
|
|
|
+ self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
|
|
|
+
|
|
|
+ def test_handle_terminates_job_when_reschedule_fails(self):
|
|
|
+ """An unexpected reschedule failure still ends the job once, then propagates."""
|
|
|
+ user = User.objects.create_user(username='job-owner')
|
|
|
+ job = TestJobRunner.enqueue(interval=60, user=user)
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(TestJobRunner, 'run'),
|
|
|
+ patch.object(TestJobRunner, 'enqueue', side_effect=RuntimeError('Simulated queue error')),
|
|
|
+ patch.object(job_end, 'send', wraps=job_end.send) as send_end,
|
|
|
+ self.assertRaisesMessage(RuntimeError, 'Simulated queue error'),
|
|
|
+ ):
|
|
|
+ TestJobRunner.handle(job)
|
|
|
+
|
|
|
+ job.refresh_from_db()
|
|
|
+ self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
|
|
|
+ self.assertIsNotNone(job.completed)
|
|
|
+ send_end.assert_called_once_with(job)
|
|
|
+ self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
|
|
|
+
|
|
|
+ def test_handle_terminates_job_once_when_end_receiver_fails(self):
|
|
|
+ """A failing job_end receiver after a successful run leaves the job completed and propagates."""
|
|
|
+ user = User.objects.create_user(username='job-owner')
|
|
|
+ job = TestJobRunner.enqueue(user=user)
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(TestJobRunner, 'run'),
|
|
|
+ patch.object(job_end, 'send', side_effect=RuntimeError('Simulated receiver error')) as send_end,
|
|
|
+ self.assertRaisesMessage(RuntimeError, 'Simulated receiver error'),
|
|
|
+ ):
|
|
|
+ TestJobRunner.handle(job)
|
|
|
+
|
|
|
+ job.refresh_from_db()
|
|
|
+ self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
|
|
|
+ send_end.assert_called_once_with(job)
|
|
|
+ self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
|
|
|
+
|
|
|
+ def test_handle_propagates_interrupt_and_reschedules(self):
|
|
|
+ """An interrupted run propagates the interrupt, records no outcome and still schedules the successor."""
|
|
|
+ instance = DataSource.objects.create(name='Interrupted Source', type='local')
|
|
|
+ job = TestJobRunner.enqueue(instance, interval=60, schedule_at=self.get_schedule_at())
|
|
|
+
|
|
|
+ with patch.object(TestJobRunner, 'run', side_effect=SystemExit), self.assertRaises(SystemExit):
|
|
|
+ TestJobRunner.handle(job)
|
|
|
+
|
|
|
+ job.refresh_from_db()
|
|
|
+ self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
|
|
|
+ self.assertIsNone(job.completed)
|
|
|
+ successor = TestJobRunner.get_jobs(instance).exclude(pk=job.pk).get()
|
|
|
+ self.assertEqual((successor.status, successor.interval), (JobStatusChoices.STATUS_SCHEDULED, 60))
|
|
|
+
|
|
|
|
|
|
class EnqueueTestCase(BaseJobRunnerTestCase):
|
|
|
"""
|
|
|
@@ -352,9 +468,7 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
|
|
|
"""
|
|
|
When `handle()` finishes a periodic system job, it must not create a duplicate
|
|
|
scheduled job if a successor is already enqueued (issue #22232). This guards
|
|
|
- against the race where a worker starts up between `job.terminate()` and the
|
|
|
- finally block's reschedule, calling `enqueue_once()` which would create a parallel
|
|
|
- job.
|
|
|
+ against a successor that another occurrence or `enqueue_once()` already created.
|
|
|
"""
|
|
|
interval = 60
|
|
|
|
|
|
@@ -470,6 +584,44 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
|
|
|
)
|
|
|
self.assertEqual(enqueued.count(), 2)
|
|
|
|
|
|
+ def test_handle_keeps_one_successor_for_overlapping_jobs(self):
|
|
|
+ """Two occurrences of a system job ending back to back leave one successor."""
|
|
|
+ first = TestSystemJobRunner.enqueue(interval=60)
|
|
|
+ second = TestSystemJobRunner.enqueue(interval=60)
|
|
|
+ waiting_runs = [second]
|
|
|
+
|
|
|
+ @contextmanager
|
|
|
+ def end_second_after_unlock(*args, **kwargs):
|
|
|
+ with advisory_lock(*args, **kwargs):
|
|
|
+ yield
|
|
|
+ # Another worker ends the second occurrence as soon as the lock is free
|
|
|
+ while waiting_runs:
|
|
|
+ TestSystemJobRunner.handle(waiting_runs.pop())
|
|
|
+
|
|
|
+ with patch('netbox.jobs.advisory_lock', side_effect=end_second_after_unlock):
|
|
|
+ TestSystemJobRunner.handle(first)
|
|
|
+
|
|
|
+ first.refresh_from_db()
|
|
|
+ second.refresh_from_db()
|
|
|
+ self.assertEqual(first.status, JobStatusChoices.STATUS_COMPLETED)
|
|
|
+ self.assertEqual(second.status, JobStatusChoices.STATUS_COMPLETED)
|
|
|
+ waiting = TestSystemJobRunner.get_jobs().filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES)
|
|
|
+ self.assertEqual(list(waiting.values_list('status', 'interval')), [(JobStatusChoices.STATUS_SCHEDULED, 60)])
|
|
|
+
|
|
|
+ def test_handle_terminates_job_when_schedule_lock_fails(self):
|
|
|
+ """A failure to take the schedule lock still ends the job, then propagates."""
|
|
|
+ job = TestSystemJobRunner.enqueue(interval=60)
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch('netbox.jobs.advisory_lock') as lock,
|
|
|
+ self.assertRaisesMessage(RuntimeError, 'Simulated lock error'),
|
|
|
+ ):
|
|
|
+ lock.return_value.__enter__.side_effect = RuntimeError('Simulated lock error')
|
|
|
+ TestSystemJobRunner.handle(job)
|
|
|
+
|
|
|
+ job.refresh_from_db()
|
|
|
+ self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
|
|
|
+
|
|
|
|
|
|
class AsyncViewJobTestCase(TestCase):
|
|
|
"""
|