|
@@ -0,0 +1,237 @@
|
|
|
|
|
+from datetime import timedelta
|
|
|
|
|
+from unittest.mock import MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+from django.test import TestCase
|
|
|
|
|
+from django.utils.timezone import now
|
|
|
|
|
+
|
|
|
|
|
+from utilities.rqworker import (
|
|
|
|
|
+ DEFAULT_WORKER_TTL,
|
|
|
|
|
+ NetBoxRQWorker,
|
|
|
|
|
+ any_workers_for_queue,
|
|
|
|
|
+ get_workers_for_queue,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_worker(name='worker-1', queues=('default',), last_heartbeat_age_seconds=10, worker_ttl=DEFAULT_WORKER_TTL):
|
|
|
|
|
+ """
|
|
|
|
|
+ Build a MagicMock that mimics the rq.Worker attributes consumed by
|
|
|
|
|
+ get_workers_for_queue().
|
|
|
|
|
+ """
|
|
|
|
|
+ worker = MagicMock()
|
|
|
|
|
+ worker.name = name
|
|
|
|
|
+ worker.queue_names.return_value = list(queues)
|
|
|
|
|
+ worker.last_heartbeat = (
|
|
|
|
|
+ now() - timedelta(seconds=last_heartbeat_age_seconds)
|
|
|
|
|
+ if last_heartbeat_age_seconds is not None else None
|
|
|
|
|
+ )
|
|
|
|
|
+ worker.worker_ttl = worker_ttl
|
|
|
|
|
+ return worker
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class NetBoxRQWorkerHeartbeatTestCase(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ The overridden heartbeat() must call register_birth() iff the worker is
|
|
|
|
|
+ missing from the rq:workers registry set, and must always invoke
|
|
|
|
|
+ super().heartbeat().
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def _make_subject(self, is_member, hash_exists=False, marked_dead=False):
|
|
|
|
|
+ worker = NetBoxRQWorker.__new__(NetBoxRQWorker)
|
|
|
|
|
+ worker.name = 'test-worker'
|
|
|
|
|
+ worker.connection = MagicMock()
|
|
|
|
|
+ worker.connection.sismember.return_value = is_member
|
|
|
|
|
+ worker.connection.exists.return_value = hash_exists
|
|
|
|
|
+ worker.connection.hexists.return_value = marked_dead
|
|
|
|
|
+ worker.register_birth = MagicMock()
|
|
|
|
|
+ worker.log = MagicMock()
|
|
|
|
|
+ return worker
|
|
|
|
|
+
|
|
|
|
|
+ def test_heartbeat_skips_register_when_present(self):
|
|
|
|
|
+ worker = self._make_subject(is_member=True)
|
|
|
|
|
+ with patch('rq.Worker.heartbeat') as super_heartbeat:
|
|
|
|
|
+ NetBoxRQWorker.heartbeat(worker)
|
|
|
|
|
+ worker.register_birth.assert_not_called()
|
|
|
|
|
+ super_heartbeat.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ def test_heartbeat_calls_register_birth_when_hash_missing(self):
|
|
|
|
|
+ # Full data loss: set membership and hash both gone.
|
|
|
|
|
+ worker = self._make_subject(is_member=False, hash_exists=False)
|
|
|
|
|
+ with patch('rq.Worker.heartbeat') as super_heartbeat, \
|
|
|
|
|
+ patch('utilities.rqworker.register_worker') as register_set:
|
|
|
|
|
+ NetBoxRQWorker.heartbeat(worker)
|
|
|
|
|
+ worker.register_birth.assert_called_once()
|
|
|
|
|
+ register_set.assert_not_called()
|
|
|
|
|
+ super_heartbeat.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ def test_heartbeat_readds_to_set_when_hash_survives(self):
|
|
|
|
|
+ # Partial data loss: hash present (and not dead), set membership gone.
|
|
|
|
|
+ # register_birth() would raise here; we must re-add to the set instead.
|
|
|
|
|
+ worker = self._make_subject(is_member=False, hash_exists=True, marked_dead=False)
|
|
|
|
|
+ with patch('rq.Worker.heartbeat') as super_heartbeat, \
|
|
|
|
|
+ patch('utilities.rqworker.register_worker') as register_set:
|
|
|
|
|
+ NetBoxRQWorker.heartbeat(worker)
|
|
|
|
|
+ worker.register_birth.assert_not_called()
|
|
|
|
|
+ register_set.assert_called_once_with(worker, worker.connection)
|
|
|
|
|
+ # Liveness is gated on the 'death' hash field specifically; pin the
|
|
|
|
|
+ # field name so a typo can't silently fall through to register_birth().
|
|
|
|
|
+ worker.connection.hexists.assert_called_with(worker.key, 'death')
|
|
|
|
|
+ super_heartbeat.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ def test_heartbeat_calls_register_birth_when_hash_marked_dead(self):
|
|
|
|
|
+ # Hash exists but is marked dead -- treat as full recreate.
|
|
|
|
|
+ worker = self._make_subject(is_member=False, hash_exists=True, marked_dead=True)
|
|
|
|
|
+ with patch('rq.Worker.heartbeat') as super_heartbeat, \
|
|
|
|
|
+ patch('utilities.rqworker.register_worker') as register_set:
|
|
|
|
|
+ NetBoxRQWorker.heartbeat(worker)
|
|
|
|
|
+ worker.register_birth.assert_called_once()
|
|
|
|
|
+ register_set.assert_not_called()
|
|
|
|
|
+ super_heartbeat.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ def test_heartbeat_tolerates_redis_exception(self):
|
|
|
|
|
+ worker = self._make_subject(is_member=False)
|
|
|
|
|
+ worker.connection.sismember.side_effect = RuntimeError('redis down')
|
|
|
|
|
+ with patch('rq.Worker.heartbeat') as super_heartbeat:
|
|
|
|
|
+ # Must not raise
|
|
|
|
|
+ NetBoxRQWorker.heartbeat(worker)
|
|
|
|
|
+ worker.register_birth.assert_not_called()
|
|
|
|
|
+ super_heartbeat.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class GetWorkersForQueueTestCase(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ get_workers_for_queue() must:
|
|
|
|
|
+ * include workers servicing the queue whose last_heartbeat is fresh
|
|
|
|
|
+ * exclude workers whose last_heartbeat is stale
|
|
|
|
|
+ * exclude workers whose last_heartbeat is None
|
|
|
|
|
+ * exclude workers not listening on the requested queue
|
|
|
|
|
+ * return an empty set when no workers exist
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def _patch_worker_all(self, workers):
|
|
|
|
|
+ return patch('utilities.rqworker.Worker.all', return_value=workers)
|
|
|
|
|
+
|
|
|
|
|
+ def _patch_get_connection(self):
|
|
|
|
|
+ return patch('utilities.rqworker.get_connection', return_value=MagicMock())
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_fresh_worker_for_queue(self):
|
|
|
|
|
+ workers = [_make_worker(name='alive', last_heartbeat_age_seconds=10)]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, {'alive'})
|
|
|
|
|
+
|
|
|
|
|
+ def test_excludes_stale_worker(self):
|
|
|
|
|
+ # Heartbeat older than worker_ttl + 60
|
|
|
|
|
+ stale_age = DEFAULT_WORKER_TTL + 120
|
|
|
|
|
+ workers = [_make_worker(name='stale', last_heartbeat_age_seconds=stale_age)]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, set())
|
|
|
|
|
+
|
|
|
|
|
+ def test_excludes_worker_with_no_heartbeat(self):
|
|
|
|
|
+ workers = [_make_worker(name='cold', last_heartbeat_age_seconds=None)]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, set())
|
|
|
|
|
+
|
|
|
|
|
+ def test_excludes_worker_for_other_queue(self):
|
|
|
|
|
+ workers = [_make_worker(name='other', queues=('high',))]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, set())
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_empty_when_no_workers(self):
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all([]):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, set())
|
|
|
|
|
+
|
|
|
|
|
+ def test_includes_worker_listening_on_multiple_queues(self):
|
|
|
|
|
+ workers = [_make_worker(name='multi', queues=('high', 'default', 'low'))]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, {'multi'})
|
|
|
|
|
+
|
|
|
|
|
+ def test_includes_worker_with_null_ttl(self):
|
|
|
|
|
+ # When a worker reports worker_ttl=None, the freshness window must
|
|
|
|
|
+ # fall back to DEFAULT_WORKER_TTL rather than raising on `None + 60`.
|
|
|
|
|
+ workers = [_make_worker(name='nullttl', last_heartbeat_age_seconds=10, worker_ttl=None)]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, {'nullttl'})
|
|
|
|
|
+
|
|
|
|
|
+ def test_mixed_fresh_and_stale_workers(self):
|
|
|
|
|
+ workers = [
|
|
|
|
|
+ _make_worker(name='alive', last_heartbeat_age_seconds=10),
|
|
|
|
|
+ _make_worker(name='stale', last_heartbeat_age_seconds=DEFAULT_WORKER_TTL + 120),
|
|
|
|
|
+ _make_worker(name='other-queue', queues=('high',)),
|
|
|
|
|
+ ]
|
|
|
|
|
+ with self._patch_get_connection(), self._patch_worker_all(workers):
|
|
|
|
|
+ result = get_workers_for_queue('default')
|
|
|
|
|
+ self.assertEqual(result, {'alive'})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class AnyWorkersForQueueTestCase(TestCase):
|
|
|
|
|
+ """
|
|
|
|
|
+ any_workers_for_queue() must apply the same liveness filter as
|
|
|
|
|
+ get_workers_for_queue(), but short-circuit on the first live match.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def _patch_keys_and_lookup(self, workers):
|
|
|
|
|
+ keys = [f'rq:worker:{w.name}' for w in workers]
|
|
|
|
|
+ by_key = dict(zip(keys, workers))
|
|
|
|
|
+ return (
|
|
|
|
|
+ patch('utilities.rqworker.Worker.all_keys', return_value=keys),
|
|
|
|
|
+ patch('utilities.rqworker.Worker.find_by_key', side_effect=lambda key, connection=None: by_key.get(key)),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _patch_get_connection(self):
|
|
|
|
|
+ return patch('utilities.rqworker.get_connection', return_value=MagicMock())
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_true_when_fresh_worker_present(self):
|
|
|
|
|
+ workers = [_make_worker(name='alive', last_heartbeat_age_seconds=10)]
|
|
|
|
|
+ keys_patch, find_patch = self._patch_keys_and_lookup(workers)
|
|
|
|
|
+ with self._patch_get_connection(), keys_patch, find_patch:
|
|
|
|
|
+ self.assertTrue(any_workers_for_queue('default'))
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_false_when_only_stale_workers(self):
|
|
|
|
|
+ workers = [_make_worker(name='stale', last_heartbeat_age_seconds=DEFAULT_WORKER_TTL + 120)]
|
|
|
|
|
+ keys_patch, find_patch = self._patch_keys_and_lookup(workers)
|
|
|
|
|
+ with self._patch_get_connection(), keys_patch, find_patch:
|
|
|
|
|
+ self.assertFalse(any_workers_for_queue('default'))
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_false_when_no_workers(self):
|
|
|
|
|
+ keys_patch, find_patch = self._patch_keys_and_lookup([])
|
|
|
|
|
+ with self._patch_get_connection(), keys_patch, find_patch:
|
|
|
|
|
+ self.assertFalse(any_workers_for_queue('default'))
|
|
|
|
|
+
|
|
|
|
|
+ def test_returns_false_when_only_other_queue(self):
|
|
|
|
|
+ workers = [_make_worker(name='other', queues=('high',))]
|
|
|
|
|
+ keys_patch, find_patch = self._patch_keys_and_lookup(workers)
|
|
|
|
|
+ with self._patch_get_connection(), keys_patch, find_patch:
|
|
|
|
|
+ self.assertFalse(any_workers_for_queue('default'))
|
|
|
|
|
+
|
|
|
|
|
+ def test_short_circuits_on_first_live_worker(self):
|
|
|
|
|
+ # The first key resolves to a live worker; subsequent keys must not
|
|
|
|
|
+ # be fetched.
|
|
|
|
|
+ workers = [
|
|
|
|
|
+ _make_worker(name='alive', last_heartbeat_age_seconds=10),
|
|
|
|
|
+ _make_worker(name='other', last_heartbeat_age_seconds=10),
|
|
|
|
|
+ ]
|
|
|
|
|
+ keys = [f'rq:worker:{w.name}' for w in workers]
|
|
|
|
|
+ by_key = dict(zip(keys, workers))
|
|
|
|
|
+ find = MagicMock(side_effect=lambda key, connection=None: by_key.get(key))
|
|
|
|
|
+ with self._patch_get_connection(), \
|
|
|
|
|
+ patch('utilities.rqworker.Worker.all_keys', return_value=keys), \
|
|
|
|
|
+ patch('utilities.rqworker.Worker.find_by_key', find):
|
|
|
|
|
+ self.assertTrue(any_workers_for_queue('default'))
|
|
|
|
|
+ self.assertEqual(find.call_count, 1)
|
|
|
|
|
+
|
|
|
|
|
+ def test_skips_missing_workers(self):
|
|
|
|
|
+ # find_by_key returning None (stale registry entry pointing to a
|
|
|
|
|
+ # vanished hash) must not raise; iteration continues to the next key.
|
|
|
|
|
+ live = _make_worker(name='alive', last_heartbeat_age_seconds=10)
|
|
|
|
|
+ keys = ['rq:worker:ghost', 'rq:worker:alive']
|
|
|
|
|
+ find = MagicMock(side_effect=[None, live])
|
|
|
|
|
+ with self._patch_get_connection(), \
|
|
|
|
|
+ patch('utilities.rqworker.Worker.all_keys', return_value=keys), \
|
|
|
|
|
+ patch('utilities.rqworker.Worker.find_by_key', find):
|
|
|
|
|
+ self.assertTrue(any_workers_for_queue('default'))
|