base.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import logging
  2. import sys
  3. import traceback
  4. from abc import ABCMeta, abstractmethod
  5. from collections import defaultdict
  6. from datetime import datetime, timedelta, timezone
  7. from traceback import format_tb
  8. from apscheduler.events import (
  9. EVENT_JOB_ERROR,
  10. EVENT_JOB_EXECUTED,
  11. EVENT_JOB_MISSED,
  12. JobExecutionEvent,
  13. )
  14. class MaxInstancesReachedError(Exception):
  15. def __init__(self, job):
  16. super().__init__(
  17. f'Job "{job.id}" has already reached its maximum number of instances '
  18. f"({job.max_instances})"
  19. )
  20. class BaseExecutor(metaclass=ABCMeta):
  21. """Abstract base class that defines the interface that every executor must implement."""
  22. _scheduler = None
  23. _lock = None
  24. _logger = logging.getLogger("apscheduler.executors")
  25. def __init__(self):
  26. super().__init__()
  27. self._instances = defaultdict(lambda: 0)
  28. def start(self, scheduler, alias):
  29. """
  30. Called by the scheduler when the scheduler is being started or when the executor is being
  31. added to an already running scheduler.
  32. :param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
  33. this executor
  34. :param str|unicode alias: alias of this executor as it was assigned to the scheduler
  35. """
  36. self._scheduler = scheduler
  37. self._lock = scheduler._create_lock()
  38. self._logger = logging.getLogger(f"apscheduler.executors.{alias}")
  39. def shutdown(self, wait=True):
  40. """
  41. Shuts down this executor.
  42. :param bool wait: ``True`` to wait until all submitted jobs
  43. have been executed
  44. """
  45. def submit_job(self, job, run_times):
  46. """
  47. Submits job for execution.
  48. :param Job job: job to execute
  49. :param list[datetime] run_times: list of datetimes specifying
  50. when the job should have been run
  51. :raises MaxInstancesReachedError: if the maximum number of
  52. allowed instances for this job has been reached
  53. """
  54. assert self._lock is not None, "This executor has not been started yet"
  55. with self._lock:
  56. if self._instances[job.id] >= job.max_instances:
  57. raise MaxInstancesReachedError(job)
  58. self._do_submit_job(job, run_times)
  59. self._instances[job.id] += 1
  60. @abstractmethod
  61. def _do_submit_job(self, job, run_times):
  62. """Performs the actual task of scheduling `run_job` to be called."""
  63. def _run_job_success(self, job_id, events):
  64. """
  65. Called by the executor with the list of generated events when :func:`run_job` has been
  66. successfully called.
  67. """
  68. with self._lock:
  69. self._instances[job_id] -= 1
  70. if self._instances[job_id] == 0:
  71. del self._instances[job_id]
  72. for event in events:
  73. self._scheduler._dispatch_event(event)
  74. def _run_job_error(self, job_id, exc, traceback=None):
  75. """Called by the executor with the exception if there is an error calling `run_job`."""
  76. with self._lock:
  77. self._instances[job_id] -= 1
  78. if self._instances[job_id] == 0:
  79. del self._instances[job_id]
  80. exc_info = (exc.__class__, exc, traceback)
  81. self._logger.error("Error running job %s", job_id, exc_info=exc_info)
  82. def run_job(job, jobstore_alias, run_times, logger_name):
  83. """
  84. Called by executors to run the job. Returns a list of scheduler events to be dispatched by the
  85. scheduler.
  86. """
  87. events = []
  88. logger = logging.getLogger(logger_name)
  89. for run_time in run_times:
  90. # See if the job missed its run time window, and handle
  91. # possible misfires accordingly
  92. if job.misfire_grace_time is not None:
  93. difference = datetime.now(timezone.utc) - run_time
  94. grace_time = timedelta(seconds=job.misfire_grace_time)
  95. if difference > grace_time:
  96. events.append(
  97. JobExecutionEvent(
  98. EVENT_JOB_MISSED, job.id, jobstore_alias, run_time
  99. )
  100. )
  101. logger.warning('Run time of job "%s" was missed by %s', job, difference)
  102. continue
  103. logger.info('Running job "%s" (scheduled at %s)', job, run_time)
  104. try:
  105. retval = job.func(*job.args, **job.kwargs)
  106. except BaseException:
  107. exc, tb = sys.exc_info()[1:]
  108. formatted_tb = "".join(format_tb(tb))
  109. events.append(
  110. JobExecutionEvent(
  111. EVENT_JOB_ERROR,
  112. job.id,
  113. jobstore_alias,
  114. run_time,
  115. exception=exc,
  116. traceback=formatted_tb,
  117. )
  118. )
  119. logger.exception('Job "%s" raised an exception', job)
  120. # This is to prevent cyclic references that would lead to memory leaks
  121. traceback.clear_frames(tb)
  122. del tb
  123. else:
  124. events.append(
  125. JobExecutionEvent(
  126. EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval
  127. )
  128. )
  129. logger.info('Job "%s" executed successfully', job)
  130. return events
  131. async def run_coroutine_job(job, jobstore_alias, run_times, logger_name):
  132. """Coroutine version of run_job()."""
  133. events = []
  134. logger = logging.getLogger(logger_name)
  135. for run_time in run_times:
  136. # See if the job missed its run time window, and handle possible misfires accordingly
  137. if job.misfire_grace_time is not None:
  138. difference = datetime.now(timezone.utc) - run_time
  139. grace_time = timedelta(seconds=job.misfire_grace_time)
  140. if difference > grace_time:
  141. events.append(
  142. JobExecutionEvent(
  143. EVENT_JOB_MISSED, job.id, jobstore_alias, run_time
  144. )
  145. )
  146. logger.warning('Run time of job "%s" was missed by %s', job, difference)
  147. continue
  148. logger.info('Running job "%s" (scheduled at %s)', job, run_time)
  149. try:
  150. retval = await job.func(*job.args, **job.kwargs)
  151. except BaseException:
  152. exc, tb = sys.exc_info()[1:]
  153. formatted_tb = "".join(format_tb(tb))
  154. events.append(
  155. JobExecutionEvent(
  156. EVENT_JOB_ERROR,
  157. job.id,
  158. jobstore_alias,
  159. run_time,
  160. exception=exc,
  161. traceback=formatted_tb,
  162. )
  163. )
  164. logger.exception('Job "%s" raised an exception', job)
  165. traceback.clear_frames(tb)
  166. else:
  167. events.append(
  168. JobExecutionEvent(
  169. EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval
  170. )
  171. )
  172. logger.info('Job "%s" executed successfully', job)
  173. return events