mirror of https://github.com/peter4431/rq.git
Job scheduling (#1163)
* First RQScheduler prototype * WIP job scheduling * Fixed Python 2.7 tests * Added ScheduledJobRegistry.get_scheduled_time(job) * WIP on scheduler's threading mechanism * Fixed test errors * Changed scheduler.acquire_locks() to instance method * Added scheduler.prepare_registries() * Somewhat working implementation of RQ scheduler * Only call stop_scheduler if there's a scheduler present * Use OSError rather than ProcessLookupError for PyPy compatibility * Added `auto_start` argument to scheduler.acquire_locks() * Make RQScheduler play better with timezone * Fixed test error * Added --with-scheduler flag to rq worker CLI * Fix tests on Python 2.x * More Python 2 fixes * Only call `scheduler.start` if worker is run in non burst mode * Fixed an issue where running worker with scheduler would fail sometimes * Make `worker.stop_scheduler()` more resilient to errors * worker.dequeue_job_and_maintain_ttl() should also periodically run maintenance tasks * Scheduler can now work with worker in both burst and non burst mode * Fixed scheduler logging message * Always log scheduler errors when running * Improve scheduler error logging message * Removed testing code * Scheduler should periodically try to acquire locks for other queues it doesn't have * Added tests for scheduler.should_reacquire_locks * Added queue.enqueue_in() * Fixes queue.enqueue_in() in Python 2.7 * First stab at documenting job scheduling * Remove unused methods * Remove Python 2.6 logging compatibility code * Remove more unused imports * Added convenience methods to access job registries from queue * Added test for worker.run_maintenance_tasks() * Simplify worker.queue_names() and worker.queue_keys() * Updated changelog to mention RQ's new job scheduling mechanism.main
parent
f09d4db080
commit
baa0cc268a
@ -1,2 +1,2 @@
|
|||||||
mock
|
mock
|
||||||
pytest
|
pytest
|
@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
title: "RQ: Scheduling Jobs"
|
||||||
|
layout: docs
|
||||||
|
---
|
||||||
|
|
||||||
|
_New in version 1.2.0._
|
||||||
|
|
||||||
|
This builtin version of `RQScheduler` is still in alpha, use at your own risk!
|
||||||
|
|
||||||
|
If you need a battle tested version of RQ job scheduling, please take a look at
|
||||||
|
https://github.com/rq/rq-scheduler instead.
|
||||||
|
|
||||||
|
New in RQ 1.2.0 is `RQScheduler`, a built-in component that allows you to schedule jobs
|
||||||
|
for future execution.
|
||||||
|
|
||||||
|
This component is developed based on prior experience of developing the external
|
||||||
|
`rq-scheduler` library. The goal of taking this component in house is to allow
|
||||||
|
RQ to have job scheduling capabilities without:
|
||||||
|
1. Running a separate `rqscheduler` CLI command.
|
||||||
|
2. Worrying about a separate `Scheduler` class.
|
||||||
|
|
||||||
|
|
||||||
|
# Scheduling Jobs for Execution
|
||||||
|
|
||||||
|
There are two main APIs to schedule jobs for execution, `enqueue_at()` and `enqueue_in()`.
|
||||||
|
|
||||||
|
`queue.enqueue_at()` works almost like `queue.enqueue()`, except that it expects a datetime
|
||||||
|
for its first argument.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime
|
||||||
|
from rq import Queue
|
||||||
|
from redis import Redis
|
||||||
|
from somewhere import say_hello
|
||||||
|
|
||||||
|
queue = Queue(name='default', connection=Redis())
|
||||||
|
|
||||||
|
# Schedules job to be run at 9:15, October 10th in the local timezone
|
||||||
|
job = queue.enqueue_at(datetime(2019, 10, 8, 9, 15), say_hello)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that if you pass in a naive datetime object, RQ will automatically convert it
|
||||||
|
to the local timezone.
|
||||||
|
|
||||||
|
`queue.enqueue_in()` accepts a `timedelta` as its first argument.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import timedelta
|
||||||
|
from rq import Queue
|
||||||
|
from redis import Redis
|
||||||
|
from somewhere import say_hello
|
||||||
|
|
||||||
|
queue = Queue(name='default', connection=Redis())
|
||||||
|
|
||||||
|
# Schedules job to be run in 10 seconds
|
||||||
|
job = queue.enqueue_at(timedelta(seconds=10), say_hello)
|
||||||
|
```
|
||||||
|
|
||||||
|
Jobs that are scheduled for execution are not placed in the queue, but they are
|
||||||
|
stored in `ScheduledJobRegistry`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import timedelta
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
from rq import Queue
|
||||||
|
from rq.registry import ScheduledJobRegistry
|
||||||
|
|
||||||
|
redis = Redis()
|
||||||
|
|
||||||
|
queue = Queue(name='default', connection=redis)
|
||||||
|
job = queue.enqueue_in(timedelta(seconds=10), say_nothing)
|
||||||
|
print(job in queue) # Outputs False as job is not enqueued
|
||||||
|
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
print(job in registry) # Outputs True as job is placed in ScheduledJobRegistry
|
||||||
|
```
|
||||||
|
|
||||||
|
# Running the Scheduler
|
||||||
|
|
||||||
|
If you use RQ's scheduling features, you need to run RQ workers with the
|
||||||
|
scheduler component enabled.
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ rq worker --with-scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also run a worker with scheduler enabled in a programmatic way.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rq import Worker, Queue
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
redis = Redis()
|
||||||
|
|
||||||
|
queue = Queue(connection=redis)
|
||||||
|
worker = Worker(queues=[queue], connection=redis)
|
||||||
|
worker.work(with_scheduler=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only a single scheduler can run for a specific queue at any one time. If you run multiple
|
||||||
|
workers with scheduler enabled, only one scheduler will be actively working for a given queue.
|
||||||
|
|
||||||
|
Active schedulers are responsible for enqueueing scheduled jobs. Active schedulers will check for
|
||||||
|
scheduled jobs once every second.
|
||||||
|
|
||||||
|
Idle schedulers will periodically (every 15 minutes) check whether the queues they're
|
||||||
|
responsible for have active schedulers. If they don't, one of the idle schedulers will start
|
||||||
|
working. This way, if a worker with active scheduler dies, the scheduling work will be picked
|
||||||
|
up by other workers with the scheduling component enabled.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from multiprocessing import Process
|
||||||
|
|
||||||
|
from .job import Job
|
||||||
|
from .queue import Queue
|
||||||
|
from .registry import ScheduledJobRegistry
|
||||||
|
from .utils import current_timestamp, enum
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULER_KEY_TEMPLATE = 'rq:scheduler:%s'
|
||||||
|
SCHEDULER_LOCKING_KEY_TEMPLATE = 'rq:scheduler-lock:%s'
|
||||||
|
|
||||||
|
format = "%(asctime)s: %(message)s"
|
||||||
|
logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
class RQScheduler(object):
|
||||||
|
|
||||||
|
# STARTED: scheduler has been started but sleeping
|
||||||
|
# WORKING: scheduler is in the midst of scheduling jobs
|
||||||
|
# STOPPED: scheduler is in stopped condition
|
||||||
|
|
||||||
|
Status = enum(
|
||||||
|
'SchedulerStatus',
|
||||||
|
STARTED='started',
|
||||||
|
WORKING='working',
|
||||||
|
STOPPED='stopped'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, queues, connection, interval=1):
|
||||||
|
self._queue_names = set(parse_names(queues))
|
||||||
|
self._acquired_locks = set([])
|
||||||
|
self._scheduled_job_registries = []
|
||||||
|
self.lock_acquisition_time = None
|
||||||
|
self.connection = connection
|
||||||
|
self.interval = interval
|
||||||
|
self._stop_requested = False
|
||||||
|
self._status = self.Status.STOPPED
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def acquired_locks(self):
|
||||||
|
return self._acquired_locks
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_reacquire_locks(self):
|
||||||
|
"""Returns True if lock_acquisition_time is longer than 15 minutes ago"""
|
||||||
|
if self._queue_names == self.acquired_locks:
|
||||||
|
return False
|
||||||
|
if not self.lock_acquisition_time:
|
||||||
|
return True
|
||||||
|
return (datetime.now() - self.lock_acquisition_time).total_seconds() > 900
|
||||||
|
|
||||||
|
def acquire_locks(self, auto_start=False):
|
||||||
|
"""Returns names of queue it successfully acquires lock on"""
|
||||||
|
successful_locks = set([])
|
||||||
|
pid = os.getpid()
|
||||||
|
logging.info("Trying to acquire locks for %s", ", ".join(self._queue_names))
|
||||||
|
for name in self._queue_names:
|
||||||
|
if self.connection.set(self.get_locking_key(name), pid, nx=True, ex=5):
|
||||||
|
successful_locks.add(name)
|
||||||
|
self._acquired_locks = self._acquired_locks.union(successful_locks)
|
||||||
|
if self._acquired_locks:
|
||||||
|
self.prepare_registries(self._acquired_locks)
|
||||||
|
|
||||||
|
self.lock_acquisition_time = datetime.now()
|
||||||
|
|
||||||
|
# If auto_start is requested and scheduler is not started,
|
||||||
|
# run self.start()
|
||||||
|
if self._acquired_locks and auto_start:
|
||||||
|
if not self._process:
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
return successful_locks
|
||||||
|
|
||||||
|
def prepare_registries(self, queue_names):
|
||||||
|
"""Prepare scheduled job registries for use"""
|
||||||
|
self._scheduled_job_registries = []
|
||||||
|
for name in queue_names:
|
||||||
|
self._scheduled_job_registries.append(
|
||||||
|
ScheduledJobRegistry(name, connection=self.connection)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_locking_key(self, name):
|
||||||
|
"""Returns scheduler key for a given queue name"""
|
||||||
|
return SCHEDULER_LOCKING_KEY_TEMPLATE % name
|
||||||
|
|
||||||
|
def enqueue_scheduled_jobs(self):
|
||||||
|
"""Enqueue jobs whose timestamp is in the past"""
|
||||||
|
self._status = self.Status.WORKING
|
||||||
|
for registry in self._scheduled_job_registries:
|
||||||
|
timestamp = current_timestamp()
|
||||||
|
|
||||||
|
# TODO: try to use Lua script to make get_jobs_to_schedule()
|
||||||
|
# and remove_jobs() atomic
|
||||||
|
job_ids = registry.get_jobs_to_schedule(timestamp)
|
||||||
|
|
||||||
|
if not job_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
queue = Queue(registry.name, connection=self.connection)
|
||||||
|
|
||||||
|
with self.connection.pipeline() as pipeline:
|
||||||
|
# This should be done in bulk
|
||||||
|
for job_id in job_ids:
|
||||||
|
job = Job.fetch(job_id, connection=self.connection)
|
||||||
|
queue.enqueue_job(job, pipeline=pipeline)
|
||||||
|
registry.remove_jobs(timestamp)
|
||||||
|
pipeline.execute()
|
||||||
|
self._status = self.Status.STARTED
|
||||||
|
|
||||||
|
def _install_signal_handlers(self):
|
||||||
|
"""Installs signal handlers for handling SIGINT and SIGTERM
|
||||||
|
gracefully.
|
||||||
|
"""
|
||||||
|
signal.signal(signal.SIGINT, self.request_stop)
|
||||||
|
signal.signal(signal.SIGTERM, self.request_stop)
|
||||||
|
|
||||||
|
def request_stop(self, signum=None, frame=None):
|
||||||
|
"""Toggle self._stop_requested that's checked on every loop"""
|
||||||
|
self._stop_requested = True
|
||||||
|
|
||||||
|
def heartbeat(self):
|
||||||
|
"""Updates the TTL on scheduler keys and the locks"""
|
||||||
|
logging.info("Scheduler sending heartbeat to %s", ", ".join(self.acquired_locks))
|
||||||
|
if len(self._queue_names) > 1:
|
||||||
|
with self.connection.pipeline() as pipeline:
|
||||||
|
for name in self._queue_names:
|
||||||
|
key = self.get_locking_key(name)
|
||||||
|
pipeline.expire(key, self.interval + 5)
|
||||||
|
pipeline.execute()
|
||||||
|
else:
|
||||||
|
key = self.get_locking_key(next(iter(self._queue_names)))
|
||||||
|
self.connection.expire(key, self.interval + 5)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
logging.info("Scheduler stopping, releasing locks for %s...",
|
||||||
|
','.join(self._queue_names))
|
||||||
|
keys = [self.get_locking_key(name) for name in self._queue_names]
|
||||||
|
self.connection.delete(*keys)
|
||||||
|
self._status = self.Status.STOPPED
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._status = self.Status.STARTED
|
||||||
|
self._process = Process(target=run, args=(self,), name='Scheduler')
|
||||||
|
self._process.start()
|
||||||
|
return self._process
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
self._install_signal_handlers()
|
||||||
|
while True:
|
||||||
|
if self._stop_requested:
|
||||||
|
self.stop()
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.should_reacquire_locks:
|
||||||
|
self.acquire_locks()
|
||||||
|
|
||||||
|
self.enqueue_scheduled_jobs()
|
||||||
|
self.heartbeat()
|
||||||
|
time.sleep(self.interval)
|
||||||
|
|
||||||
|
|
||||||
|
def run(scheduler):
|
||||||
|
logging.info("Scheduler for %s started with PID %s",
|
||||||
|
','.join(scheduler._queue_names), os.getpid())
|
||||||
|
try:
|
||||||
|
scheduler.work()
|
||||||
|
except: # noqa
|
||||||
|
logging.error(
|
||||||
|
'Scheduler [PID %s] raised an exception.\n%s',
|
||||||
|
os.getpid(), traceback.format_exc()
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
logging.info("Scheduler with PID %s has stopped", os.getpid())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_names(queues_or_names):
|
||||||
|
"""Given a list of strings or queues, returns queue names"""
|
||||||
|
names = []
|
||||||
|
for queue_or_name in queues_or_names:
|
||||||
|
if isinstance(queue_or_name, Queue):
|
||||||
|
names.append(queue_or_name.name)
|
||||||
|
else:
|
||||||
|
names.append(str(queue_or_name))
|
||||||
|
return names
|
@ -0,0 +1,291 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from multiprocessing import Process
|
||||||
|
|
||||||
|
from rq import Queue
|
||||||
|
from rq.compat import utc, PY2
|
||||||
|
from rq.exceptions import NoSuchJobError
|
||||||
|
from rq.job import Job
|
||||||
|
from rq.registry import FinishedJobRegistry, ScheduledJobRegistry
|
||||||
|
from rq.scheduler import RQScheduler
|
||||||
|
from rq.utils import current_timestamp
|
||||||
|
from rq.worker import Worker
|
||||||
|
|
||||||
|
from .fixtures import kill_worker, say_hello
|
||||||
|
from tests import RQTestCase
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduledJobRegistry(RQTestCase):
|
||||||
|
|
||||||
|
def test_get_jobs_to_enqueue(self):
|
||||||
|
"""Getting job ids to enqueue from ScheduledJobRegistry."""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
timestamp = current_timestamp()
|
||||||
|
|
||||||
|
self.testconn.zadd(registry.key, {'foo': 1})
|
||||||
|
self.testconn.zadd(registry.key, {'bar': timestamp + 10})
|
||||||
|
self.testconn.zadd(registry.key, {'baz': timestamp + 30})
|
||||||
|
|
||||||
|
self.assertEqual(registry.get_jobs_to_enqueue(), ['foo'])
|
||||||
|
self.assertEqual(registry.get_jobs_to_enqueue(timestamp + 20),
|
||||||
|
['foo', 'bar'])
|
||||||
|
|
||||||
|
def test_get_scheduled_time(self):
|
||||||
|
"""get_scheduled_time() returns job's scheduled datetime"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
|
||||||
|
job = Job.create('myfunc', connection=self.testconn)
|
||||||
|
job.save()
|
||||||
|
dt = datetime(2019, 1, 1, tzinfo=utc)
|
||||||
|
registry.schedule(job, datetime(2019, 1, 1, tzinfo=utc))
|
||||||
|
self.assertEqual(registry.get_scheduled_time(job), dt)
|
||||||
|
# get_scheduled_time() should also work with job ID
|
||||||
|
self.assertEqual(registry.get_scheduled_time(job.id), dt)
|
||||||
|
|
||||||
|
# registry.get_scheduled_time() raises NoSuchJobError if
|
||||||
|
# job.id is not found
|
||||||
|
self.assertRaises(NoSuchJobError, registry.get_scheduled_time, '123')
|
||||||
|
|
||||||
|
def test_schedule(self):
|
||||||
|
"""Adding job with the correct score to ScheduledJobRegistry"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
job = Job.create('myfunc', connection=self.testconn)
|
||||||
|
job.save()
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
|
||||||
|
if PY2:
|
||||||
|
# On Python 2, datetime needs to have timezone
|
||||||
|
self.assertRaises(ValueError, registry.schedule, job, datetime(2019, 1, 1))
|
||||||
|
registry.schedule(job, datetime(2019, 1, 1, tzinfo=utc))
|
||||||
|
self.assertEqual(self.testconn.zscore(registry.key, job.id),
|
||||||
|
1546300800) # 2019-01-01 UTC in Unix timestamp
|
||||||
|
else:
|
||||||
|
from datetime import timezone
|
||||||
|
# If we pass in a datetime with no timezone, `schedule()`
|
||||||
|
# assumes local timezone so depending on your local timezone,
|
||||||
|
# the timestamp maybe different
|
||||||
|
registry.schedule(job, datetime(2019, 1, 1))
|
||||||
|
self.assertEqual(self.testconn.zscore(registry.key, job.id),
|
||||||
|
1546300800 + time.timezone) # 2019-01-01 UTC in Unix timestamp
|
||||||
|
|
||||||
|
# Score is always stored in UTC even if datetime is in a different tz
|
||||||
|
tz = timezone(timedelta(hours=7))
|
||||||
|
job = Job.create('myfunc', connection=self.testconn)
|
||||||
|
job.save()
|
||||||
|
registry.schedule(job, datetime(2019, 1, 1, 7, tzinfo=tz))
|
||||||
|
self.assertEqual(self.testconn.zscore(registry.key, job.id),
|
||||||
|
1546300800) # 2019-01-01 UTC in Unix timestamp
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduler(RQTestCase):
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""Scheduler can be instantiated with queues or queue names"""
|
||||||
|
foo_queue = Queue('foo', connection=self.testconn)
|
||||||
|
scheduler = RQScheduler([foo_queue, 'bar'], connection=self.testconn)
|
||||||
|
self.assertEqual(scheduler._queue_names, {'foo', 'bar'})
|
||||||
|
self.assertEqual(scheduler.status, RQScheduler.Status.STOPPED)
|
||||||
|
|
||||||
|
def test_should_reacquire_locks(self):
|
||||||
|
"""scheduler.should_reacquire_locks works properly"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
scheduler = RQScheduler([queue], connection=self.testconn)
|
||||||
|
self.assertTrue(scheduler.should_reacquire_locks)
|
||||||
|
scheduler.acquire_locks()
|
||||||
|
self.assertIsNotNone(scheduler.lock_acquisition_time)
|
||||||
|
|
||||||
|
# scheduler.should_reacquire_locks always returns False if
|
||||||
|
# scheduler.acquired_locks and scheduler._queue_names are the same
|
||||||
|
self.assertFalse(scheduler.should_reacquire_locks)
|
||||||
|
scheduler.lock_acquisition_time = datetime.now() - timedelta(minutes=16)
|
||||||
|
self.assertFalse(scheduler.should_reacquire_locks)
|
||||||
|
|
||||||
|
scheduler._queue_names = set(['default', 'foo'])
|
||||||
|
self.assertTrue(scheduler.should_reacquire_locks)
|
||||||
|
scheduler.acquire_locks()
|
||||||
|
self.assertFalse(scheduler.should_reacquire_locks)
|
||||||
|
|
||||||
|
def test_lock_acquisition(self):
|
||||||
|
"""Test lock acquisition"""
|
||||||
|
name_1 = 'lock-test-1'
|
||||||
|
name_2 = 'lock-test-2'
|
||||||
|
name_3 = 'lock-test-3'
|
||||||
|
scheduler = RQScheduler([name_1], self.testconn)
|
||||||
|
|
||||||
|
self.assertEqual(scheduler.acquire_locks(), {name_1})
|
||||||
|
self.assertEqual(scheduler._acquired_locks, {name_1})
|
||||||
|
self.assertEqual(scheduler.acquire_locks(), set([]))
|
||||||
|
|
||||||
|
# Only name_2 is returned since name_1 is already locked
|
||||||
|
scheduler = RQScheduler([name_1, name_2], self.testconn)
|
||||||
|
self.assertEqual(scheduler.acquire_locks(), {name_2})
|
||||||
|
self.assertEqual(scheduler._acquired_locks, {name_2})
|
||||||
|
|
||||||
|
# When a new lock is successfully acquired, _acquired_locks is added
|
||||||
|
scheduler._queue_names.add(name_3)
|
||||||
|
self.assertEqual(scheduler.acquire_locks(), {name_3})
|
||||||
|
self.assertEqual(scheduler._acquired_locks, {name_2, name_3})
|
||||||
|
|
||||||
|
def test_lock_acquisition_with_auto_start(self):
|
||||||
|
"""Test lock acquisition with auto_start=True"""
|
||||||
|
scheduler = RQScheduler(['auto-start'], self.testconn)
|
||||||
|
with mock.patch.object(scheduler, 'start') as mocked:
|
||||||
|
scheduler.acquire_locks(auto_start=True)
|
||||||
|
self.assertEqual(mocked.call_count, 1)
|
||||||
|
|
||||||
|
# If process has started, scheduler.start() won't be called
|
||||||
|
scheduler = RQScheduler(['auto-start2'], self.testconn)
|
||||||
|
scheduler._process = 1
|
||||||
|
with mock.patch.object(scheduler, 'start') as mocked:
|
||||||
|
scheduler.acquire_locks(auto_start=True)
|
||||||
|
self.assertEqual(mocked.call_count, 0)
|
||||||
|
|
||||||
|
def test_heartbeat(self):
|
||||||
|
"""Test that heartbeat updates locking keys TTL"""
|
||||||
|
name_1 = 'lock-test-1'
|
||||||
|
name_2 = 'lock-test-2'
|
||||||
|
scheduler = RQScheduler([name_1, name_2], self.testconn)
|
||||||
|
scheduler.acquire_locks()
|
||||||
|
|
||||||
|
locking_key_1 = RQScheduler.get_locking_key(name_1)
|
||||||
|
locking_key_2 = RQScheduler.get_locking_key(name_2)
|
||||||
|
|
||||||
|
with self.testconn.pipeline() as pipeline:
|
||||||
|
pipeline.expire(locking_key_1, 1000)
|
||||||
|
pipeline.expire(locking_key_2, 1000)
|
||||||
|
|
||||||
|
scheduler.heartbeat()
|
||||||
|
self.assertEqual(self.testconn.ttl(locking_key_1), 6)
|
||||||
|
self.assertEqual(self.testconn.ttl(locking_key_1), 6)
|
||||||
|
|
||||||
|
# scheduler.stop() releases locks and sets status to STOPPED
|
||||||
|
scheduler._status = scheduler.Status.WORKING
|
||||||
|
scheduler.stop()
|
||||||
|
self.assertFalse(self.testconn.exists(locking_key_1))
|
||||||
|
self.assertFalse(self.testconn.exists(locking_key_2))
|
||||||
|
self.assertEqual(scheduler.status, scheduler.Status.STOPPED)
|
||||||
|
|
||||||
|
# Heartbeat also works properly for schedulers with a single queue
|
||||||
|
scheduler = RQScheduler([name_1], self.testconn)
|
||||||
|
scheduler.acquire_locks()
|
||||||
|
self.testconn.expire(locking_key_1, 1000)
|
||||||
|
scheduler.heartbeat()
|
||||||
|
self.assertEqual(self.testconn.ttl(locking_key_1), 6)
|
||||||
|
|
||||||
|
def test_enqueue_scheduled_jobs(self):
|
||||||
|
"""Scheduler can enqueue scheduled jobs"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
job = Job.create('myfunc', connection=self.testconn)
|
||||||
|
job.save()
|
||||||
|
registry.schedule(job, datetime(2019, 1, 1, tzinfo=utc))
|
||||||
|
scheduler = RQScheduler([queue], connection=self.testconn)
|
||||||
|
scheduler.acquire_locks()
|
||||||
|
scheduler.enqueue_scheduled_jobs()
|
||||||
|
self.assertEqual(len(queue), 1)
|
||||||
|
|
||||||
|
# After job is scheduled, registry should be empty
|
||||||
|
self.assertEqual(len(registry), 0)
|
||||||
|
|
||||||
|
# Jobs scheduled in the far future should not be affected
|
||||||
|
registry.schedule(job, datetime(2100, 1, 1, tzinfo=utc))
|
||||||
|
scheduler.enqueue_scheduled_jobs()
|
||||||
|
self.assertEqual(len(queue), 1)
|
||||||
|
|
||||||
|
def test_prepare_registries(self):
|
||||||
|
"""prepare_registries() creates self._scheduled_job_registries"""
|
||||||
|
foo_queue = Queue('foo', connection=self.testconn)
|
||||||
|
bar_queue = Queue('bar', connection=self.testconn)
|
||||||
|
scheduler = RQScheduler([foo_queue, bar_queue], connection=self.testconn)
|
||||||
|
self.assertEqual(scheduler._scheduled_job_registries, [])
|
||||||
|
scheduler.prepare_registries([foo_queue.name])
|
||||||
|
self.assertEqual(scheduler._scheduled_job_registries, [ScheduledJobRegistry(queue=foo_queue)])
|
||||||
|
scheduler.prepare_registries([foo_queue.name, bar_queue.name])
|
||||||
|
self.assertEqual(
|
||||||
|
scheduler._scheduled_job_registries,
|
||||||
|
[ScheduledJobRegistry(queue=foo_queue), ScheduledJobRegistry(queue=bar_queue)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorker(RQTestCase):
|
||||||
|
|
||||||
|
def test_work_burst(self):
|
||||||
|
"""worker.work() with scheduler enabled works properly"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
worker = Worker(queues=[queue], connection=self.testconn)
|
||||||
|
worker.work(burst=True, with_scheduler=False)
|
||||||
|
self.assertIsNone(worker.scheduler)
|
||||||
|
|
||||||
|
worker = Worker(queues=[queue], connection=self.testconn)
|
||||||
|
worker.work(burst=True, with_scheduler=True)
|
||||||
|
self.assertIsNotNone(worker.scheduler)
|
||||||
|
|
||||||
|
@mock.patch.object(RQScheduler, 'acquire_locks')
|
||||||
|
def test_run_maintenance_tasks(self, mocked):
|
||||||
|
"""scheduler.acquire_locks() is called only when scheduled is enabled"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
worker = Worker(queues=[queue], connection=self.testconn)
|
||||||
|
|
||||||
|
worker.run_maintenance_tasks()
|
||||||
|
self.assertEqual(mocked.call_count, 0)
|
||||||
|
|
||||||
|
worker.last_cleaned_at = None
|
||||||
|
worker.scheduler = RQScheduler([queue], connection=self.testconn)
|
||||||
|
worker.run_maintenance_tasks()
|
||||||
|
self.assertEqual(mocked.call_count, 0)
|
||||||
|
|
||||||
|
worker.last_cleaned_at = datetime.now()
|
||||||
|
worker.run_maintenance_tasks()
|
||||||
|
self.assertEqual(mocked.call_count, 1)
|
||||||
|
|
||||||
|
def test_work(self):
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
worker = Worker(queues=[queue], connection=self.testconn)
|
||||||
|
p = Process(target=kill_worker, args=(os.getpid(), False, 5))
|
||||||
|
|
||||||
|
p.start()
|
||||||
|
queue.enqueue_at(datetime(2019, 1, 1, tzinfo=utc), say_hello)
|
||||||
|
worker.work(burst=False, with_scheduler=True)
|
||||||
|
p.join(1)
|
||||||
|
self.assertIsNotNone(worker.scheduler)
|
||||||
|
registry = FinishedJobRegistry(queue=queue)
|
||||||
|
self.assertEqual(len(registry), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueue(RQTestCase):
|
||||||
|
|
||||||
|
def test_enqueue_at(self):
|
||||||
|
"""queue.enqueue_at() puts job in the scheduled"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
scheduler = RQScheduler([queue], connection=self.testconn)
|
||||||
|
scheduler.acquire_locks()
|
||||||
|
# Jobs created using enqueue_at is put in the ScheduledJobRegistry
|
||||||
|
queue.enqueue_at(datetime(2019, 1, 1, tzinfo=utc), say_hello)
|
||||||
|
self.assertEqual(len(queue), 0)
|
||||||
|
self.assertEqual(len(registry), 1)
|
||||||
|
|
||||||
|
# After enqueue_scheduled_jobs() is called, the registry is empty
|
||||||
|
# and job is enqueued
|
||||||
|
scheduler.enqueue_scheduled_jobs()
|
||||||
|
self.assertEqual(len(queue), 1)
|
||||||
|
self.assertEqual(len(registry), 0)
|
||||||
|
|
||||||
|
def test_enqueue_in(self):
|
||||||
|
"""queue.enqueue_in() schedules job correctly"""
|
||||||
|
queue = Queue(connection=self.testconn)
|
||||||
|
registry = ScheduledJobRegistry(queue=queue)
|
||||||
|
|
||||||
|
job = queue.enqueue_in(timedelta(seconds=30), say_hello)
|
||||||
|
now = datetime.now(utc)
|
||||||
|
scheduled_time = registry.get_scheduled_time(job)
|
||||||
|
# Ensure that job is scheduled roughly 30 seconds from now
|
||||||
|
self.assertTrue(
|
||||||
|
now + timedelta(seconds=28) < scheduled_time < now + timedelta(seconds=32)
|
||||||
|
)
|
Loading…
Reference in New Issue