Example #1
0
    def test_job_execution_monitoring(self):
        watcher = SchedulerWatcher(self.scheduler)

        self.scheduler.add_job(
            lambda: time.sleep(0.02),
            id='waiting_job',
            name='Waiting job',
            jobstore='in_memory',
            trigger='interval',
            seconds=0.2,
            next_run_time=datetime.now()
        )

        job_events = watcher.jobs['waiting_job']['events']

        self.assertEqual(1, len(job_events))
        self.assertEqual('job_added', job_events[0]['event_name'])
        time.sleep(0.05)
        self.assertEqual(3, len(job_events), 'Job execution needs to be tracked in job events')
        self.assertEqual(
            'job_submitted',
            job_events[1]['event_name'],
            'Job submision needs to be tracked in job events'
        )
        self.assertEqual('job_executed', job_events[2]['event_name'], 'Job execution needs to be tracked in job events')

        time.sleep(0.2)

        self.assertEqual(5, len(job_events), 'Subsequent executions get tracked')
Example #2
0
    def test_job_properties_on_add(self):
        watcher = SchedulerWatcher(self.scheduler)

        self.scheduler.add_job(
            lambda x, y: x + y,
            id='added_job',
            name='Added job',
            jobstore='in_memory',
            trigger='interval',
            minutes=60,
            args=(1,),
            kwargs={'y': 2}
        )

        self.assertIn('added_job', watcher.jobs)

        job_properties = watcher.jobs['added_job']['properties']

        self.assertEqual('added_job', job_properties['id'], 'Job properties should have the job id')
        self.assertEqual('Added job', job_properties['name'], 'Job properties should have the job name')
        self.assertIn('trigger', job_properties, 'Job properties should have a representation of the trigger')
        self.assertEqual('in_memory', job_properties['jobstore'], 'Job properties should have the jobstore name')
        self.assertEqual('default', job_properties['executor'], 'Job properties should have the executor name')
        self.assertIn('lambda', job_properties['func'], 'Job properties should have the function string repr')
        self.assertIn('func_ref', job_properties, 'Job properties should have the function reference')
        self.assertEqual('(1,)', job_properties['args'], 'Job properties should have the job arguments')
        self.assertEqual("{'y': 2}", job_properties['kwargs'], 'Job properties should have the job keyword arguments')
        self.assertIn('pending', job_properties, 'Job properties should have the job pending status')
        self.assertFalse(job_properties['pending'], 'Job status should not be pending')
        self.assertIn('coalesce', job_properties, 'Job properties should have the job coalesce configuration')
        self.assertIn('next_run_time', job_properties, 'Job properties should have the next run time calculated')
        self.assertIn('misfire_grace_time', job_properties, 'Job properties should have the misfire grace time')
        self.assertIn('max_instances', job_properties, 'Job properties should have the max instances configuration')
Example #3
0
    def __init__(self, scheduler, capabilities=None, operation_timeout=1):
        self.scheduler = scheduler
        patch_scheduler(scheduler)
        self.capabilities = {
            'pause_job': False,
            'remove_job': False,
            'pause_scheduler': False,
            'stop_scheduler': False,
            'run_job': False,
        }

        if not (isinstance(operation_timeout, int)
                or isinstance(operation_timeout, float)):
            raise TypeError(
                'operation_timeout should be either an int or a float')

        if operation_timeout <= 0:
            raise ValueError('operation_timeout should be a positive number')

        self.operation_timeout = operation_timeout

        if capabilities is not None:
            if isinstance(capabilities, dict):
                self.capabilities.update(capabilities)
            else:
                raise TypeError(
                    'capabilities should be a dict of str -> bool pairs')

        self._scheduler_listener = SchedulerWatcher(scheduler)

        self._web_server = flask.Flask(__name__)
        self._socket_io = None

        try:
            # TODO: see if we can support eventlet in the future.
            self._socket_io = flask_socketio.SocketIO(self._web_server,
                                                      async_mode='gevent')
        except ValueError:
            self._socket_io = flask_socketio.SocketIO(self._web_server,
                                                      async_mode='threading')

        self._init_endpoints()

        self._web_server_thread = None
        self._scheduler_lock = threading.Lock()
Example #4
0
    def test_watcher_injection(self):
        watcher = SchedulerWatcher(self.scheduler)

        self.assertEqual(watcher.scheduler, self.scheduler, 'Watcher should keep a reference to the scheduler')
        self.assertEqual(1, len(self.scheduler._listeners), 'Watcher should inject itself as a scheduler listener')

        self.assertEqual(
            self.scheduler._listeners[0][1], EVENT_ALL, 'Watcher should register iself to watch all events'
        )
Example #5
0
    def test_removed_jobs_are_only_flagged_as_removed(self):
        self.scheduler.add_job(lambda: 0, id='a_job')

        watcher = SchedulerWatcher(self.scheduler)

        self.assertIn('a_job', watcher.jobs)
        self.assertIsNone(watcher.jobs['a_job']['removed_time'])

        self.scheduler.remove_job('a_job')

        self.assertIn('a_job', watcher.jobs, 'removed jobs should be still tracked in the scheduler watcher')
        self.assertIsNotNone(watcher.jobs['a_job']['removed_time'], 'removed_time should be set')
Example #6
0
    def test_job_event_history_is_limited(self):
        watcher = SchedulerWatcher(self.scheduler, max_events_per_job=4)

        self.scheduler.add_job(lambda: 0, trigger='interval', seconds=0.01, id='recurrent_job')

        time.sleep(0.1)

        # recurrent_job should have been executed ~10 times now, generating ~20 events (submission + execution).
        self.assertEqual(
            watcher.max_events_per_job,
            len(watcher.jobs['recurrent_job']['events']),
            'job event history should be limited'
        )
Example #7
0
    def test_adding_and_removing_executors(self, mock_notify_executor_event):
        watcher = SchedulerWatcher(self.scheduler)

        self.scheduler.add_executor(ThreadPoolExecutor(), alias='new_executor')

        self.assertIn('new_executor', watcher.executors)
        mock_notify_executor_event.assert_called()

        mock_notify_executor_event.reset_mock()
        self.scheduler.remove_executor('new_executor')

        self.assertNotIn('new_executor', watcher.executors)
        mock_notify_executor_event.assert_called()
Example #8
0
    def test_scheduler_summary(self):
        watcher = SchedulerWatcher(self.scheduler)

        summary = watcher.scheduler_summary()

        self.assertEqual(sorted(['scheduler', 'jobs', 'executors', 'jobstores']), sorted(summary.keys()))

        self.assertEqual('running', summary['scheduler']['state'], 'scheduler_summary should have the scheduler status')
        self.assertEqual(2, len(summary['executors']), 'scheduler_summaru should have the two added executors')
        self.assertEqual(2, len(summary['jobstores']), 'scheduler_summary should have the two executors')
        self.assertEqual(0, len(summary['jobs']), 'scheduler_summary should have no jobs')

        self.scheduler.add_job(lambda: 0, id='job_1')

        summary = watcher.scheduler_summary()

        self.assertIn('job_1', summary['jobs'], 'scheduler_summary should have the added jobs in it')

        self.scheduler.remove_job('job_1')

        summary = watcher.scheduler_summary()
        self.assertIn('job_1', summary['jobs'], 'scheduler_summary should have all jobs in it, even if job was removed')
Example #9
0
    def test_removing_all_jobs_flags_all_as_removed(self, mock_notify_job_event):
        watcher = SchedulerWatcher(self.scheduler)

        self.scheduler.add_job(lambda: 0, id='job_1', jobstore='default', trigger='interval', minutes=60)
        self.scheduler.add_job(lambda: 0, id='job_2', jobstore='in_memory', trigger='interval', minutes=60)

        self.assertEqual(2, len(watcher.jobs))
        self.assertEqual(2, mock_notify_job_event.call_count)

        mock_notify_job_event.reset_mock()

        self.scheduler.remove_all_jobs()

        self.assertEqual(2, len(watcher.jobs), 'job count should not change after removing all jobs')
        self.assertEqual(2, mock_notify_job_event.call_count)
Example #10
0
    def test_adding_a_jobstore_adds_all_jobs_in_it(self, mock_notify_jobstore_event, mock_notify_job_event, _):
        watcher = SchedulerWatcher(self.scheduler)

        jobstore = MemoryJobStore()

        jobstore.add_job(Job(scheduler=self.scheduler, id='job_1', next_run_time=datetime.now() + timedelta(days=1)))
        jobstore.add_job(Job(scheduler=self.scheduler, id='job_2', next_run_time=datetime.now() + timedelta(days=2)))

        self.assertEqual(0, len(watcher.jobs))

        self.scheduler.add_jobstore(jobstore, alias='in_memory_2')

        self.assertIn('in_memory_2', watcher.jobstores, 'Watcher should have the new jobstore tracked')
        self.assertEqual(2, len(watcher.jobs), 'Watcher should add all jobs in the newly added jobstore')
        self.assertTrue(all([job_id in watcher.jobs for job_id in ['job_1', 'job_2']]))
        self.assertEqual(2, mock_notify_job_event.call_count)
        mock_notify_jobstore_event.assert_called_once()
Example #11
0
    def test_removing_a_jobstore_removes_all_jobs(self, mock_notify_jobstore_event):
        watcher = SchedulerWatcher(self.scheduler)

        self.scheduler.add_job(lambda: 0, id='job_1', jobstore='in_memory', trigger='interval', minutes=60)
        self.scheduler.add_job(lambda: 0, id='job_2', jobstore='in_memory', trigger='interval', minutes=60)

        self.assertEqual(2, len(watcher.jobs))
        self.assertIsNone(watcher.jobs['job_1']['removed_time'], 'job_1 removed time should be None')
        self.assertEqual('in_memory', watcher.jobs['job_1']['properties']['jobstore'])

        self.scheduler.remove_jobstore('in_memory')

        mock_notify_jobstore_event.assert_called()

        self.assertEqual(2, len(watcher.jobs), 'The amount of jobs after removing a jobstore should not change')
        self.assertIsNotNone(watcher.jobs['job_1']['removed_time'], 'job_1 removed time should be set')
        self.assertIsNotNone(watcher.jobs['job_2']['removed_time'], 'job_2 removed time should be set')
Example #12
0
    def test_job_inspection_matches_job_added_event(self):
        # We're going to add two jobs that should have the exact same properties, except for the id, in two different
        # stages of the usage: before the watcher is created and after we start watching for events.
        def job_function(x, y):
            return x + y
        next_run_time = datetime.now() + timedelta(hours=1)

        # Job that is added before the user calls us.
        self.scheduler.add_job(
            job_function,
            id='job_1',
            name='Added job',
            jobstore='in_memory',
            trigger='interval',
            minutes=60,
            args=(1,),
            kwargs={'y': 2},
            next_run_time=next_run_time
        )

        watcher = SchedulerWatcher(self.scheduler)

        # Job that gets added after we start watching.
        self.scheduler.add_job(
            job_function,
            id='job_2',
            name='Added job',
            jobstore='in_memory',
            trigger='interval',
            minutes=60,
            args=(1,),
            kwargs={'y': 2},
            next_run_time=next_run_time
        )

        self.assertEqual(2, len(watcher.jobs))

        job_1 = watcher.jobs['job_1']
        job_2 = watcher.jobs['job_2']

        for property_name in job_1['properties'].keys():
            # All properties, except the id, should match.
            if property_name == 'id':
                continue
            self.assertEqual(job_1['properties'][property_name], job_2['properties'][property_name])
Example #13
0
    def test_job_failure_monitoring(self):
        watcher = SchedulerWatcher(self.scheduler)

        def fail():
            time.sleep(0.02)
            return 0 / 0

        self.scheduler.add_job(
            fail,
            id='failing_job',
            name='Failing job',
            jobstore='in_memory',
            trigger='interval',
            next_run_time=datetime.now(),
            minutes=60
        )

        failing_job_events = watcher.jobs['failing_job']['events']

        time.sleep(0.05)
        self.assertEqual(3, len(failing_job_events))
        self.assertEqual('job_error', failing_job_events[2]['event_name'])
Example #14
0
    def test_scheduler_inspection(self):
        self.scheduler.add_job(lambda: 0, jobstore='in_memory', trigger='interval', minutes=60, id='test_job')

        watcher = SchedulerWatcher(self.scheduler)

        self.assertEqual('running', watcher.scheduler_info['state'], 'Watcher should inspect scheduler status')
        self.assertEqual(
            str(self.scheduler.timezone),
            watcher.scheduler_info['timezone'],
            'Watcher should inspect scheduler timezone'
        )
        self.assertEqual(
            'BackgroundScheduler', watcher.scheduler_info['class'], 'Watcher should inspect scheduler class'
        )

        self.assertEqual(2, len(watcher.jobstores), 'Watcher should inspect all scheduler jobstores')
        self.assertIn('in_memory', watcher.jobstores, 'Watcher should have inspected the in_memory jobstore')

        self.assertEqual(2, len(watcher.executors), 'Watcher should inspect all scheduler executors')
        self.assertIn('secondary_executor', watcher.executors, 'Watcher should have inspected the secondary_executor')

        self.assertEqual(1, len(watcher.jobs), 'Watcher should inspect all jobs in scheduler on init')
        self.assertIn('test_job', watcher.jobs, 'Watcher should index jobs by id')
Example #15
0
    def test_modified_job_properties_are_tracked(self):
        self.scheduler.add_job(
            lambda x, y: x + y,
            id='a_job',
            name='A job',
            jobstore='in_memory',
            trigger='interval',
            minutes=60,
            args=(1,),
            kwargs={'y': 2}
        )

        watcher = SchedulerWatcher(self.scheduler)

        self.assertEqual(watcher.jobs['a_job']['modified_time'], watcher.jobs['a_job']['added_time'])

        next_run_time = watcher.jobs['a_job']['properties']['next_run_time'][0]

        self.scheduler.modify_job('a_job', name='A modified job', next_run_time=datetime.now() + timedelta(days=1))

        self.assertGreater(watcher.jobs['a_job']['modified_time'], watcher.jobs['a_job']['added_time'])
        self.assertEqual('A modified job', watcher.jobs['a_job']['properties']['name'])
        self.assertGreater(watcher.jobs['a_job']['properties']['next_run_time'][0], next_run_time)
Example #16
0
class SchedulerUI(SchedulerEventsListener):
    """
    A web server that monitors your scheduler and serves a web application to visualize events.

    By the default the server web application served is a view-only UI, but by enabling capabilities it may be allowed
    to control the scheduler and its jobs.

    Args:
        scheduler (apscheduler.schedulers.base.BaseScheduler):
            The scheduler to monitor.

        capabilities (dict):
            (Optional)
            A dictionary of the capabilities to enable in the server and client. By default the UI is view-only.

            Supported capabilities:
                * Pause/Resume Scheduler: set `pause_scheduler` to :data:`True`.
                * Pause/Resume Jobs: set `pause_job` to :data:`True`.
                * Remove Jobs: set `remove_job` to :data:True.

        operation_timeout (float):
            (Optional) The amount of seconds to wait for the serializing lock when performing actions on the
            scheduler or on its jobs from the UI.

    Basic Usage:
      >>> from apscheduler.schedulers.background import BackgroundScheduler
      >>> from apschedulerui.web import SchedulerUI
      >>> scheduler = BackgroundScheduler()
      >>> ui = SchedulerUI(scheduler)
      >>> ui.start()  # Server available at localhost:5000.

    Configuring capabilities:
      >>> ui = SchedulerUI(scheduler, capabilities={'pause_scheduler': True})  # All omitted capabilities are False.
      >>> ui = SchedulerUI(scheduler, capabilities={'pause_job': True, 'remove_job': True})

    """
    def __init__(self, scheduler, capabilities=None, operation_timeout=1):
        self.scheduler = scheduler
        patch_scheduler(scheduler)
        self.capabilities = {
            'pause_job': False,
            'remove_job': False,
            'pause_scheduler': False,
            'stop_scheduler': False,
            'run_job': False,
        }

        if not (isinstance(operation_timeout, int)
                or isinstance(operation_timeout, float)):
            raise TypeError(
                'operation_timeout should be either an int or a float')

        if operation_timeout <= 0:
            raise ValueError('operation_timeout should be a positive number')

        self.operation_timeout = operation_timeout

        if capabilities is not None:
            if isinstance(capabilities, dict):
                self.capabilities.update(capabilities)
            else:
                raise TypeError(
                    'capabilities should be a dict of str -> bool pairs')

        self._scheduler_listener = SchedulerWatcher(scheduler)

        self._web_server = flask.Flask(__name__)
        self._socket_io = None

        try:
            # TODO: see if we can support eventlet in the future.
            self._socket_io = flask_socketio.SocketIO(self._web_server,
                                                      async_mode='gevent')
        except ValueError:
            self._socket_io = flask_socketio.SocketIO(self._web_server,
                                                      async_mode='threading')

        self._init_endpoints()

        self._web_server_thread = None
        self._scheduler_lock = threading.Lock()

    def start(self, host='0.0.0.0', port=5000, daemon=True):
        """
        Starts listening for events from the scheduler and starts the web server that serves the UI in a new thread.

        Args:
            host (str):
                (Optional) The address to bind the web server to. Default `0.0.0.0`.
            port (int):
                (Optional) The port to which the web server will bind. Defaults to :data:`5000`.
            daemon (bool):
                (Optional) If :data:`True` (default) starts the server as daemon.

        """
        self._scheduler_listener.add_listener(self)
        self._web_server_thread = threading.Thread(target=self._start,
                                                   name='apscheduler-ui',
                                                   args=(host, port))
        self._web_server_thread.daemon = daemon
        self._web_server_thread.start()

    def _init_endpoints(self):
        if self.capabilities.get('pause_scheduler', False):
            self._web_server.add_url_rule('/api/scheduler/pause',
                                          'pause_scheduler',
                                          self._pause_scheduler,
                                          methods=['POST'])
            self._web_server.add_url_rule('/api/scheduler/resume',
                                          'resume_scheduler',
                                          self._resume_scheduler,
                                          methods=['POST'])

        if self.capabilities.get('stop_scheduler', False):
            self._web_server.add_url_rule('/api/scheduler/stop',
                                          'stop_scheduler',
                                          self._stop_scheduler,
                                          methods=['POST'])
            self._web_server.add_url_rule('/api/scheduler/start',
                                          'start_scheduler',
                                          self._start_scheduler,
                                          methods=['POST'])

        if self.capabilities.get('remove_job', False):
            self._web_server.add_url_rule('/api/job/<job_id>/remove',
                                          'remove_job',
                                          self._remove_job,
                                          methods=['POST'])

        if self.capabilities.get('pause_job', False):
            self._web_server.add_url_rule('/api/job/<job_id>/pause',
                                          'pause_job',
                                          self._pause_job,
                                          methods=['POST'])
            self._web_server.add_url_rule('/api/job/<job_id>/resume',
                                          'resume_job',
                                          self._resume_job,
                                          methods=['POST'])

        if self.capabilities.get('run_job', False):
            self._web_server.add_url_rule('/api/job/<job_id>/run_now',
                                          'run_job',
                                          self._run_job,
                                          methods=['POST'])

        self._web_server.add_url_rule('/',
                                      'index',
                                      self._index,
                                      defaults={'path': ''})
        self._web_server.add_url_rule('/<path:path>', 'index', self._index)

        self._socket_io.on_event('connected', self._client_connected)

    def _index(self, path):
        return self._web_server.send_static_file('index.html')

    def _exec_scheduler_command(self, func, *args, **kwargs):
        if self._scheduler_lock.acquire(timeout=self.operation_timeout):
            try:
                func(*args, **kwargs)
                return 'ok'
            except JobLookupError:
                flask.abort(404, description="Job not found")
            finally:
                self._scheduler_lock.release()
        else:
            flask.abort(
                408,
                description=
                "Failed to acquire scheduler lock to perform operation")

    def _pause_scheduler(self):
        return self._exec_scheduler_command(self.scheduler.pause)

    def _resume_scheduler(self):
        return self._exec_scheduler_command(self.scheduler.resume)

    def _stop_scheduler(self):
        return self._exec_scheduler_command(self.scheduler.shutdown,
                                            wait=False)

    def _start_scheduler(self):
        return self._exec_scheduler_command(self.scheduler.start)

    def _pause_job(self, job_id):
        return self._exec_scheduler_command(self.scheduler.pause_job, job_id)

    def _resume_job(self, job_id):
        return self._exec_scheduler_command(self.scheduler.resume_job, job_id)

    def _run_job(self, job_id, next_run_time=None):
        logging.getLogger('apschedulerui').info('Running job %s' % job_id)
        if not job_id:
            return Response(status=404)

        if not next_run_time:
            next_run_time = datetime.now()

        def _run_job_impl():
            job = self.scheduler.get_job(job_id)

            if not job:
                raise JobLookupError(job_id)

            # If a job is periodic (has an interval trigger) it should be triggered by modifying the trigger it already
            # has. Otherwise, it can be rescheduled to be ran now.
            if isinstance(job.trigger, IntervalTrigger) or isinstance(
                    job.trigger, CronTrigger):
                self.scheduler.modify_job(job_id, next_run_time=next_run_time)
            else:
                job.reschedule(trigger='date', run_date=next_run_time)

        return self._exec_scheduler_command(_run_job_impl)

    def _remove_job(self, job_id):
        return self._exec_scheduler_command(self.scheduler.remove_job, job_id)

    def _client_connected(self):
        logging.getLogger('apschedulerui').debug('Client connected')
        flask_socketio.emit('init_jobs',
                            self._scheduler_listener.scheduler_summary())
        flask_socketio.emit('init_capabilities', self.capabilities)

    def _job_event(self, event):
        self._socket_io.emit('job_event', event)

    def _scheduler_event(self, event):
        self._socket_io.emit('scheduler_event', event)

    def _jobstore_event(self, event):
        self._socket_io.emit('jobstore_event', event)

    def _executor_event(self, event):
        self._socket_io.emit('executor_event', event)

    def _start(self, host, port):
        self._socket_io.run(self._web_server, host=host, port=port)