Skip to content

Commit 4bba926

Browse files
Closes #16971: Add system jobs (#17716)
* Fix check for existing jobs If a job is to be enqueued once and no specific scheduled time is specified, any scheduled time of existing jobs will be valid. Only if a specific scheduled time is specified for 'enqueue_once()' can it be evaluated. * Allow system jobs to be registered A new registry key allows background system jobs to be registered and automatically scheduled when rqworker starts. * Test scheduling of system jobs * Fix plugins scheduled job documentation The documentation reflected a non-production state of the JobRunner framework left over from development. Now a more practical example demonstrates the usage. * Allow plugins to register system jobs * Rename system job metadata To clarify which meta-attributes belong to system jobs, each of them is now prefixed with 'system_'. * Add predefined job interval choices * Remove 'system_enabled' JobRunner attribute Previously, the 'system_enabled' attribute was used to control whether a job should run or not. However, this can also be accomplished by evaluating the job's interval. * Fix test * Use a decorator to register system jobs * Specify interval when registering system job * Update documentation --------- Co-authored-by: Jeremy Stretch <[email protected]>
1 parent 6dc75d8 commit 4bba926

File tree

10 files changed

+147
-14
lines changed

10 files changed

+147
-14
lines changed

docs/plugins/development/background-jobs.md

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ class MyTestJob(JobRunner):
2929

3030
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
3131

32+
!!! tip
33+
A set of predefined intervals is available at `core.choices.JobIntervalChoices` for convenience.
34+
3235
### Attributes
3336

3437
`JobRunner` attributes are defined under a class named `Meta` within the job. These are optional, but encouraged.
@@ -46,27 +49,53 @@ As described above, jobs can be scheduled for immediate execution or at any late
4649

4750
#### Example
4851

49-
```python title="jobs.py"
50-
from netbox.jobs import JobRunner
52+
```python title="models.py"
53+
from django.db import models
54+
from core.choices import JobIntervalChoices
55+
from netbox.models import NetBoxModel
56+
from .jobs import MyTestJob
57+
58+
class MyModel(NetBoxModel):
59+
foo = models.CharField()
60+
61+
def save(self, *args, **kwargs):
62+
MyTestJob.enqueue_once(instance=self, interval=JobIntervalChoices.INTERVAL_HOURLY)
63+
return super().save(*args, **kwargs)
64+
65+
def sync(self):
66+
MyTestJob.enqueue(instance=self)
67+
```
68+
69+
70+
### System Jobs
5171

72+
Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run.
5273

74+
#### Example
75+
76+
```python title="jobs.py"
77+
from core.choices import JobIntervalChoices
78+
from netbox.jobs import JobRunner, system_job
79+
from .models import MyModel
80+
81+
# Specify a predefined choice or an integer indicating
82+
# the number of minutes between job executions
83+
@system_job(interval=JobIntervalChoices.INTERVAL_HOURLY)
5384
class MyHousekeepingJob(JobRunner):
5485
class Meta:
55-
name = "Housekeeping"
86+
name = "My Housekeeping Job"
5687

5788
def run(self, *args, **kwargs):
58-
# your logic goes here
59-
```
60-
61-
```python title="__init__.py"
62-
from netbox.plugins import PluginConfig
89+
MyModel.objects.filter(foo='bar').delete()
6390

64-
class MyPluginConfig(PluginConfig):
65-
def ready(self):
66-
from .jobs import MyHousekeepingJob
67-
MyHousekeepingJob.setup(interval=60)
91+
system_jobs = (
92+
MyHousekeepingJob,
93+
)
6894
```
6995

96+
!!! note
97+
Ensure that any system jobs are imported on initialization. Otherwise, they won't be registered. This can be achieved by extending the PluginConfig's `ready()` method.
98+
7099
## Task queues
71100

72101
Three task queues of differing priority are defined by default:

docs/plugins/development/data-backends.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ backends = [MyDataBackend]
1818
```
1919

2020
!!! tip
21-
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
21+
The path to the list of data backends can be modified by setting `data_backends` in the PluginConfig instance.
2222

2323
::: netbox.data_backends.DataBackend

netbox/core/choices.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ class JobStatusChoices(ChoiceSet):
7272
)
7373

7474

75+
class JobIntervalChoices(ChoiceSet):
76+
INTERVAL_MINUTELY = 1
77+
INTERVAL_HOURLY = 60
78+
INTERVAL_DAILY = 60 * 24
79+
INTERVAL_WEEKLY = 60 * 24 * 7
80+
81+
CHOICES = (
82+
(INTERVAL_MINUTELY, _('Minutely')),
83+
(INTERVAL_HOURLY, _('Hourly')),
84+
(INTERVAL_DAILY, _('Daily')),
85+
(INTERVAL_WEEKLY, _('Weekly')),
86+
)
87+
88+
7589
#
7690
# ObjectChanges
7791
#

netbox/core/management/commands/rqworker.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from django_rq.management.commands.rqworker import Command as _Command
44

5+
from netbox.registry import registry
6+
57

68
DEFAULT_QUEUES = ('high', 'default', 'low')
79

@@ -14,6 +16,15 @@ class Command(_Command):
1416
of only the 'default' queue).
1517
"""
1618
def handle(self, *args, **options):
19+
# Setup system jobs.
20+
for job, kwargs in registry['system_jobs'].items():
21+
try:
22+
interval = kwargs['interval']
23+
except KeyError:
24+
raise TypeError("System job must specify an interval (in minutes).")
25+
logger.debug(f"Scheduling system job {job.name} (interval={interval})")
26+
job.enqueue_once(**kwargs)
27+
1728
# Run the worker with scheduler functionality
1829
options['with_scheduler'] = True
1930

netbox/netbox/jobs.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,38 @@
22
from abc import ABC, abstractmethod
33
from datetime import timedelta
44

5+
from django.core.exceptions import ImproperlyConfigured
56
from django.utils.functional import classproperty
67
from django_pglocks import advisory_lock
78
from rq.timeouts import JobTimeoutException
89

910
from core.choices import JobStatusChoices
1011
from core.models import Job, ObjectType
1112
from netbox.constants import ADVISORY_LOCK_KEYS
13+
from netbox.registry import registry
1214

1315
__all__ = (
1416
'JobRunner',
17+
'system_job',
1518
)
1619

1720

21+
def system_job(interval):
22+
"""
23+
Decorator for registering a `JobRunner` class as system background job.
24+
"""
25+
if type(interval) is not int:
26+
raise ImproperlyConfigured("System job interval must be an integer (minutes).")
27+
28+
def _wrapper(cls):
29+
registry['system_jobs'][cls] = {
30+
'interval': interval
31+
}
32+
return cls
33+
34+
return _wrapper
35+
36+
1837
class JobRunner(ABC):
1938
"""
2039
Background Job helper class.
@@ -129,7 +148,7 @@ class scheduled for `instance`, the existing job will be updated if necessary. T
129148
if job:
130149
# If the job parameters haven't changed, don't schedule a new job and keep the current schedule. Otherwise,
131150
# delete the existing job and schedule a new job instead.
132-
if (schedule_at and job.scheduled == schedule_at) and (job.interval == interval):
151+
if (not schedule_at or job.scheduled == schedule_at) and (job.interval == interval):
133152
return job
134153
job.delete()
135154

netbox/netbox/registry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def __delitem__(self, key):
3030
'models': collections.defaultdict(set),
3131
'plugins': dict(),
3232
'search': dict(),
33+
'system_jobs': dict(),
3334
'tables': collections.defaultdict(dict),
3435
'views': collections.defaultdict(dict),
3536
'widgets': dict(),

netbox/netbox/tests/dummy_plugin/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,10 @@ class DummyPluginConfig(PluginConfig):
2121
'netbox.tests.dummy_plugin.events.process_events_queue'
2222
]
2323

24+
def ready(self):
25+
super().ready()
26+
27+
from . import jobs # noqa: F401
28+
2429

2530
config = DummyPluginConfig
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from core.choices import JobIntervalChoices
2+
from netbox.jobs import JobRunner, system_job
3+
4+
5+
@system_job(interval=JobIntervalChoices.INTERVAL_HOURLY)
6+
class DummySystemJob(JobRunner):
7+
8+
def run(self, *args, **kwargs):
9+
pass

netbox/netbox/tests/test_jobs.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ def test_enqueue_once_twice_same(self):
9090
self.assertEqual(job1, job2)
9191
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
9292

93+
def test_enqueue_once_twice_same_no_schedule_at(self):
94+
instance = DataSource()
95+
schedule_at = self.get_schedule_at()
96+
job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at)
97+
job2 = TestJobRunner.enqueue_once(instance)
98+
99+
self.assertEqual(job1, job2)
100+
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
101+
93102
def test_enqueue_once_twice_different_schedule_at(self):
94103
instance = DataSource()
95104
job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at())
@@ -127,3 +136,30 @@ def test_enqueue_once_after_enqueue(self):
127136
self.assertNotEqual(job1, job2)
128137
self.assertRaises(Job.DoesNotExist, job1.refresh_from_db)
129138
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
139+
140+
141+
class SystemJobTest(JobRunnerTestCase):
142+
"""
143+
Test that system jobs can be scheduled.
144+
145+
General functionality already tested by `JobRunnerTest` and `EnqueueTest`.
146+
"""
147+
148+
def test_scheduling(self):
149+
# Can job be enqueued?
150+
job = TestJobRunner.enqueue(schedule_at=self.get_schedule_at())
151+
self.assertIsInstance(job, Job)
152+
self.assertEqual(TestJobRunner.get_jobs().count(), 1)
153+
154+
# Can job be deleted again?
155+
job.delete()
156+
self.assertRaises(Job.DoesNotExist, job.refresh_from_db)
157+
self.assertEqual(TestJobRunner.get_jobs().count(), 0)
158+
159+
def test_enqueue_once(self):
160+
schedule_at = self.get_schedule_at()
161+
job1 = TestJobRunner.enqueue_once(schedule_at=schedule_at)
162+
job2 = TestJobRunner.enqueue_once(schedule_at=schedule_at)
163+
164+
self.assertEqual(job1, job2)
165+
self.assertEqual(TestJobRunner.get_jobs().count(), 1)

netbox/netbox/tests/test_plugins.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from django.test import Client, TestCase, override_settings
66
from django.urls import reverse
77

8+
from core.choices import JobIntervalChoices
89
from netbox.tests.dummy_plugin import config as dummy_config
910
from netbox.tests.dummy_plugin.data_backends import DummyBackend
11+
from netbox.tests.dummy_plugin.jobs import DummySystemJob
1012
from netbox.plugins.navigation import PluginMenu
1113
from netbox.plugins.utils import get_plugin_config
1214
from netbox.graphql.schema import Query
@@ -130,6 +132,13 @@ def test_data_backends(self):
130132
self.assertIn('dummy', registry['data_backends'])
131133
self.assertIs(registry['data_backends']['dummy'], DummyBackend)
132134

135+
def test_system_jobs(self):
136+
"""
137+
Check registered system jobs.
138+
"""
139+
self.assertIn(DummySystemJob, registry['system_jobs'])
140+
self.assertEqual(registry['system_jobs'][DummySystemJob]['interval'], JobIntervalChoices.INTERVAL_HOURLY)
141+
133142
def test_queues(self):
134143
"""
135144
Check that plugin queues are registered with the accurate name.

0 commit comments

Comments
 (0)