rqworker.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import logging
  2. from django.urls import get_resolver
  3. from django_rq.queues import get_connection
  4. from django_rq.utils import reset_db_connections
  5. from rq import Retry, Worker
  6. from rq.worker_registration import REDIS_WORKER_KEYS
  7. from rq.worker_registration import register as register_worker
  8. from netbox.config import get_config
  9. from netbox.constants import RQ_QUEUE_DEFAULT
  10. __all__ = (
  11. 'NetBoxRQWorker',
  12. 'any_workers_for_queue',
  13. 'get_all_workers',
  14. 'get_queue_for_model',
  15. 'get_rq_retry',
  16. 'get_workers_for_queue',
  17. )
  18. logger = logging.getLogger('netbox.rqworker')
  19. class NetBoxRQWorker(Worker):
  20. """
  21. RQ worker subclass which self-heals its registration. If the worker's
  22. registration is missing from Redis (e.g. because the tasks Redis database
  23. was lost and rebuilt while the worker was running), the next heartbeat
  24. will re-register the worker so that Worker.all() / Worker.find_by_key()
  25. can locate it again.
  26. The URLconf (and with it the GraphQL schema) is loaded once on startup, so
  27. that forked work horses inherit it rather than each rebuilding it.
  28. """
  29. def bootstrap(self, *args, **kwargs):
  30. # Load the URLconf before registering the worker, so that a broken URLconf or GraphQL schema prevents the
  31. # worker from starting rather than failing every job.
  32. get_resolver().url_patterns
  33. # django-rq closes database connections before starting the worker, but loading the URLconf may have reopened
  34. # one (e.g. a plugin querying the database at import), which forked workers would then inherit.
  35. reset_db_connections()
  36. super().bootstrap(*args, **kwargs)
  37. def heartbeat(self, *args, **kwargs):
  38. try:
  39. if not self.connection.sismember(REDIS_WORKER_KEYS, self.key):
  40. logger.warning(f"Worker {self.name} not found in registry; re-registering.")
  41. # If the worker hash still exists (partial Redis data loss),
  42. # register_birth() would raise because rq treats an existing,
  43. # non-dead hash as an active worker. Re-add to the registry
  44. # sets directly in that case; the heartbeat below will refresh
  45. # the hash TTL.
  46. if self.connection.exists(self.key) and not self.connection.hexists(self.key, 'death'):
  47. register_worker(self, self.connection)
  48. else:
  49. self.register_birth()
  50. except Exception:
  51. logger.exception("Failed to verify worker registration.")
  52. super().heartbeat(*args, **kwargs)
  53. def get_queue_for_model(model):
  54. """
  55. Return the configured queue name for jobs associated with the given model.
  56. """
  57. return get_config().QUEUE_MAPPINGS.get(model, RQ_QUEUE_DEFAULT)
  58. def _is_live_worker(worker, queue_name):
  59. """
  60. Return True if the given Worker is currently servicing queue_name.
  61. Liveness itself is enforced by RQ: Worker.all() / Worker.find_by_key()
  62. only return workers whose Redis hash still exists, and RQ resets that
  63. hash's expiry to (worker_ttl + 60s) on every heartbeat. So any worker
  64. returned by RQ has heartbeat'd within its configured TTL -- we only need
  65. to confirm it's listening on the requested queue. (Reconstructing
  66. worker_ttl ourselves would be unsafe: RQ does not persist worker_ttl in
  67. the hash, so a worker started with a non-default --worker-ttl is
  68. reconstructed with DEFAULT_WORKER_TTL regardless of its real TTL.)
  69. """
  70. return queue_name in worker.queue_names()
  71. def get_workers_for_queue(queue_name):
  72. """
  73. Return the number of live workers currently servicing the given queue.
  74. """
  75. connection = get_connection(queue_name)
  76. return sum(
  77. 1 for worker in Worker.all(connection=connection)
  78. if _is_live_worker(worker, queue_name)
  79. )
  80. def get_all_workers():
  81. """
  82. Return the set of worker names currently registered on the tasks Redis
  83. connection, regardless of which queue(s) each worker is servicing. Stale
  84. registrations (workers whose Redis hash has expired) are filtered out by
  85. RQ via Worker.all() -- see _is_live_worker() for details.
  86. Used for system-wide worker counts (dashboard, status API), where the
  87. intent is "are any RQ workers running" rather than "are workers handling
  88. a specific queue."
  89. """
  90. connection = get_connection(RQ_QUEUE_DEFAULT)
  91. return {worker.name for worker in Worker.all(connection=connection)}
  92. def any_workers_for_queue(queue_name):
  93. """
  94. Return True if at least one live worker is currently servicing the given
  95. queue. Cheaper than get_workers_for_queue() when only a liveness check is
  96. needed: workers are fetched one at a time and iteration stops at the first
  97. live match.
  98. """
  99. connection = get_connection(queue_name)
  100. for key in Worker.all_keys(connection=connection):
  101. worker = Worker.find_by_key(key, connection=connection)
  102. if worker is None:
  103. continue
  104. if _is_live_worker(worker, queue_name):
  105. return True
  106. return False
  107. def get_rq_retry():
  108. """
  109. If RQ_RETRY_MAX is defined and greater than zero, instantiate and return a Retry object to be
  110. used when queuing a job. Otherwise, return None.
  111. """
  112. retry_max = get_config().RQ_RETRY_MAX
  113. retry_interval = get_config().RQ_RETRY_INTERVAL
  114. if retry_max:
  115. return Retry(max=retry_max, interval=retry_interval)
  116. return None