Skip to content

Commit 0ef1bcb

Browse files
authored
Feature: Multiple Schedulers (#212)
* Multiple Scheduler locks working * Make theschedulder instance name truly unique * Added Tests
1 parent 8dee4ab commit 0ef1bcb

File tree

2 files changed

+84
-17
lines changed

2 files changed

+84
-17
lines changed

rq_scheduler/scheduler.py

+39-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import logging
22
import signal
33
import time
4+
import os
5+
import socket
6+
from uuid import uuid4
47

58
from datetime import datetime
69
from itertools import repeat
@@ -18,13 +21,15 @@
1821

1922

2023
class Scheduler(object):
24+
redis_scheduler_namespace_prefix = 'rq:scheduler_instance:'
2125
scheduler_key = 'rq:scheduler'
26+
scheduler_lock_key = 'rq:scheduler_lock'
2227
scheduled_jobs_key = 'rq:scheduler:scheduled_jobs'
2328
queue_class = Queue
2429
job_class = Job
2530

2631
def __init__(self, queue_name='default', queue=None, interval=60, connection=None,
27-
job_class=None, queue_class=None):
32+
job_class=None, queue_class=None, name=None):
2833
from rq.connections import resolve_connection
2934
self.connection = resolve_connection(connection)
3035
self._queue = queue
@@ -38,14 +43,25 @@ def __init__(self, queue_name='default', queue=None, interval=60, connection=Non
3843
self.job_class = backend_class(self, 'job_class', override=job_class)
3944
self.queue_class = backend_class(self, 'queue_class',
4045
override=queue_class)
46+
self.name = name or uuid4().hex
47+
48+
@property
49+
def key(self):
50+
"""Returns the schedulers Redis hash key."""
51+
return self.redis_scheduler_namespace_prefix + self.name
52+
53+
@property
54+
def pid(self):
55+
"""The current process ID."""
56+
return os.getpid()
4157

4258
def register_birth(self):
4359
self.log.info('Registering birth')
44-
if self.connection.exists(self.scheduler_key) and \
45-
not self.connection.hexists(self.scheduler_key, 'death'):
46-
raise ValueError("There's already an active RQ scheduler")
60+
if self.connection.exists(self.key) and \
61+
not self.connection.hexists(self.key, 'death'):
62+
raise ValueError("There's already an active RQ scheduler named: {0!r}".format(self.name))
4763

48-
key = self.scheduler_key
64+
key = self.key
4965
now = time.time()
5066

5167
with self.connection.pipeline() as p:
@@ -61,8 +77,8 @@ def register_death(self):
6177
"""Registers its own death."""
6278
self.log.info('Registering death')
6379
with self.connection.pipeline() as p:
64-
p.hset(self.scheduler_key, 'death', time.time())
65-
p.expire(self.scheduler_key, 60)
80+
p.hset(self.key, 'death', time.time())
81+
p.expire(self.key, 60)
6682
p.execute()
6783

6884
def acquire_lock(self):
@@ -72,7 +88,7 @@ def acquire_lock(self):
7288
7389
This function returns True if a lock is acquired. False otherwise.
7490
"""
75-
key = '%s_lock' % self.scheduler_key
91+
key = self.scheduler_lock_key
7692
now = time.time()
7793
expires = int(self._interval) + 10
7894
self._lock_acquired = self.connection.set(
@@ -83,10 +99,12 @@ def remove_lock(self):
8399
"""
84100
Remove acquired lock.
85101
"""
86-
key = '%s_lock' % self.scheduler_key
102+
key = self.scheduler_lock_key
87103

88104
if self._lock_acquired:
89105
self.connection.delete(key)
106+
self._lock_acquired = False
107+
self.log.debug('{}: Lock Removed'.format(self.key))
90108

91109
def _install_signal_handlers(self):
92110
"""
@@ -397,11 +415,17 @@ def enqueue_jobs(self):
397415
jobs = self.get_jobs_to_queue()
398416
for job in jobs:
399417
self.enqueue_job(job)
400-
401-
# Refresh scheduler key's expiry
402-
self.connection.expire(self.scheduler_key, int(self._interval) + 10)
418+
403419
return jobs
404420

421+
def heartbeat(self):
422+
"""Refreshes schedulers key, typically by extending the
423+
expiration time of the scheduler, effectively making this a "heartbeat"
424+
to not expire the scheduler until the timeout passes.
425+
"""
426+
self.log.debug('{}: Sending a HeartBeat'.format(self.key))
427+
self.connection.expire(self.key, int(self._interval) + 10)
428+
405429
def run(self, burst=False):
406430
"""
407431
Periodically check whether there's any job that should be put in the queue (score
@@ -414,10 +438,13 @@ def run(self, burst=False):
414438
try:
415439
while True:
416440
self.log.debug("Entering run loop")
441+
self.heartbeat()
417442

418443
start_time = time.time()
419444
if self.acquire_lock():
445+
self.log.debug('{}: Acquired Lock'.format(self.key))
420446
self.enqueue_jobs()
447+
self.heartbeat()
421448
self.remove_lock()
422449

423450
if burst:

tests/test_scheduler.py

+45-5
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_acquire_lock(self):
4141
interval so it automatically expires if scheduler is unexpectedly
4242
terminated.
4343
"""
44-
key = '%s_lock' % Scheduler.scheduler_key
44+
key = Scheduler.scheduler_lock_key
4545
self.assertNotIn(key, tl(self.testconn.keys('*')))
4646
scheduler = Scheduler(connection=self.testconn, interval=20)
4747
self.assertTrue(scheduler.acquire_lock())
@@ -56,7 +56,7 @@ def test_no_two_schedulers_acquire_lock(self):
5656
same time. When removing the lock, only the scheduler which
5757
originally acquired the lock can remove the lock.
5858
"""
59-
key = '%s_lock' % Scheduler.scheduler_key
59+
key = Scheduler.scheduler_lock_key
6060
self.assertNotIn(key, tl(self.testconn.keys('*')))
6161
scheduler1 = Scheduler(connection=self.testconn, interval=20)
6262
scheduler2 = Scheduler(connection=self.testconn, interval=20)
@@ -68,6 +68,48 @@ def test_no_two_schedulers_acquire_lock(self):
6868
scheduler1.remove_lock()
6969
self.assertNotIn(key, tl(self.testconn.keys('*')))
7070

71+
def test_multiple_schedulers_are_running_simultaneously(self):
72+
"""
73+
Even though only 1 Schedulder holds the lock and performs the scheduling.
74+
Multiple schedulders are still registered to take over in case the original
75+
scheduler goes down.
76+
"""
77+
lock_key = Scheduler.scheduler_lock_key
78+
self.assertNotIn(lock_key, tl(self.testconn.keys('*')))
79+
scheduler1 = Scheduler(connection=self.testconn, interval=20)
80+
scheduler2 = Scheduler(connection=self.testconn, interval=20)
81+
scheduler1.register_birth()
82+
self.assertIn(scheduler1.key, tl(self.testconn.keys('*')))
83+
scheduler2.register_birth()
84+
self.assertIn(scheduler2.key, tl(self.testconn.keys('*')))
85+
scheduler1.acquire_lock()
86+
scheduler2.acquire_lock()
87+
self.assertIn(scheduler1.key, tl(self.testconn.keys('*')))
88+
self.assertIn(scheduler2.key, tl(self.testconn.keys('*')))
89+
90+
def test_lock_handover_between_multiple_schedulers(self):
91+
lock_key = Scheduler.scheduler_lock_key
92+
self.assertNotIn(lock_key, tl(self.testconn.keys('*')))
93+
scheduler1 = Scheduler(connection=self.testconn, interval=20)
94+
scheduler2 = Scheduler(connection=self.testconn, interval=20)
95+
scheduler1.register_birth()
96+
scheduler1.acquire_lock()
97+
scheduler2.register_birth()
98+
scheduler2.acquire_lock()
99+
# Both schedulers are still active/registered
100+
self.assertIn(scheduler1.key, tl(self.testconn.keys('*')))
101+
self.assertIn(scheduler2.key, tl(self.testconn.keys('*')))
102+
scheduler1.remove_lock()
103+
self.assertNotIn(lock_key, tl(self.testconn.keys('*')))
104+
scheduler2.acquire_lock()
105+
self.assertIn(lock_key, tl(self.testconn.keys('*')))
106+
107+
def test_same_scheduler_cant_register_multiple_times(self):
108+
scheduler1 = Scheduler(connection=self.testconn, interval=20)
109+
scheduler1.register_birth()
110+
self.assertIn(scheduler1.key, tl(self.testconn.keys('*')))
111+
self.assertRaises(ValueError, scheduler1.register_birth)
112+
71113
def test_create_job(self):
72114
"""
73115
Ensure that jobs are created properly.
@@ -703,9 +745,7 @@ def test_small_float_interval(self):
703745
"""
704746
Test that scheduler accepts 'interval' of type float, less than 1 second.
705747
"""
706-
key = Scheduler.scheduler_key
707-
lock_key = '%s_lock' % Scheduler.scheduler_key
708-
self.assertNotIn(key, tl(self.testconn.keys('*')))
748+
lock_key = Scheduler.scheduler_lock_key
709749
scheduler = Scheduler(connection=self.testconn, interval=0.1) # testing interval = 0.1 second
710750
self.assertEqual(scheduler._interval, 0.1)
711751

0 commit comments

Comments
 (0)