|
1 | 1 | import asyncio |
2 | 2 | import json |
3 | | -from datetime import datetime |
| 3 | +import logging |
4 | 4 | from functools import partial |
5 | 5 | from typing import Any |
6 | 6 | from typing import Dict |
7 | 7 | from typing import Optional |
| 8 | +from typing import Set |
8 | 9 |
|
| 10 | +from apscheduler.events import EVENT_JOB_ERROR # type: ignore |
| 11 | +from apscheduler.events import EVENT_JOB_EXECUTED |
| 12 | +from apscheduler.events import JobEvent # type: ignore |
| 13 | +from apscheduler.job import Job # type: ignore |
9 | 14 | from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore |
10 | 15 | from apscheduler.triggers.cron import CronTrigger # type: ignore |
11 | 16 | from apscheduler.triggers.interval import IntervalTrigger # type: ignore |
12 | | -from apscheduler.util import undefined # type: ignore |
13 | 17 |
|
14 | 18 | from dipdup.config import JobConfig |
15 | 19 | from dipdup.context import DipDupContext |
|
24 | 28 | } |
25 | 29 |
|
26 | 30 |
|
27 | | -def create_scheduler(config: Optional[Dict[str, Any]] = None) -> AsyncIOScheduler: |
28 | | - if not config: |
29 | | - return AsyncIOScheduler(DEFAULT_CONFIG) |
30 | | - |
| 31 | +def _verify_config(config: Dict[str, Any]) -> None: |
| 32 | + """Ensure that dict is a valid `apscheduler` config""" |
31 | 33 | json_config = json.dumps(config) |
32 | 34 | if 'apscheduler.executors.pool' in json_config: |
33 | 35 | raise ConfigurationError('`apscheduler.executors.pool` is not supported. If needed, create a pool inside a regular hook.') |
34 | 36 | for key in config: |
35 | 37 | if not key.startswith('apscheduler.'): |
36 | 38 | raise ConfigurationError('`advanced.scheduler` config keys must start with `apscheduler.`, see apscheduler library docs') |
37 | | - return AsyncIOScheduler(config) |
38 | 39 |
|
39 | 40 |
|
40 | | -def add_job(ctx: DipDupContext, scheduler: AsyncIOScheduler, job_config: JobConfig) -> None: |
41 | | - hook_config = job_config.hook_config |
| 41 | +class SchedulerManager: |
| 42 | + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: |
| 43 | + if config: |
| 44 | + _verify_config(config) |
| 45 | + self._logger = logging.getLogger('dipdup.jobs') |
| 46 | + self._scheduler = AsyncIOScheduler(config or DEFAULT_CONFIG) |
| 47 | + self._scheduler.add_listener(self._on_error, EVENT_JOB_ERROR) |
| 48 | + self._scheduler.add_listener(self._on_executed, EVENT_JOB_EXECUTED) |
| 49 | + self._exception: Optional[Exception] = None |
| 50 | + self._exception_event: asyncio.Event = asyncio.Event() |
| 51 | + self._daemons: Set[str] = set() |
42 | 52 |
|
43 | | - async def _job_wrapper(ctx: DipDupContext, *args, **kwargs) -> None: |
44 | | - nonlocal job_config, hook_config |
45 | | - job_ctx = HookContext( |
46 | | - config=ctx.config, |
47 | | - datasources=ctx.datasources, |
48 | | - callbacks=ctx.callbacks, |
49 | | - logger=logger, |
50 | | - hook_config=hook_config, |
51 | | - ) |
| 53 | + async def run(self, event: asyncio.Event) -> None: |
| 54 | + self._logger.info('Waiting for an event to start scheduler') |
| 55 | + await event.wait() |
| 56 | + |
| 57 | + self._logger.info('Starting scheduler') |
52 | 58 | try: |
53 | | - await job_ctx.fire_hook(hook_config.callback, *args, **kwargs) |
| 59 | + self._scheduler.start() |
| 60 | + await self._exception_event.wait() |
| 61 | + if self._exception is None: |
| 62 | + raise RuntimeError('Job has failed but exception is not set') |
| 63 | + raise self._exception |
54 | 64 | except asyncio.CancelledError: |
55 | 65 | pass |
| 66 | + finally: |
| 67 | + self._scheduler.shutdown() |
| 68 | + |
| 69 | + def add_job(self, ctx: DipDupContext, job_config: JobConfig) -> Job: |
| 70 | + if job_config.daemon: |
| 71 | + self._daemons.add(job_config.name) |
| 72 | + |
| 73 | + hook_config = job_config.hook_config |
| 74 | + |
| 75 | + logger = FormattedLogger( |
| 76 | + name=f'dipdup.hooks.{hook_config.callback}', |
| 77 | + fmt=job_config.name + ': {}', |
| 78 | + ) |
| 79 | + |
| 80 | + async def _job_wrapper(ctx: DipDupContext, *args, **kwargs) -> None: |
| 81 | + nonlocal job_config, hook_config |
| 82 | + job_ctx = HookContext( |
| 83 | + config=ctx.config, |
| 84 | + datasources=ctx.datasources, |
| 85 | + callbacks=ctx.callbacks, |
| 86 | + logger=logger, |
| 87 | + hook_config=hook_config, |
| 88 | + ) |
| 89 | + try: |
| 90 | + await job_ctx.fire_hook( |
| 91 | + hook_config.callback, |
| 92 | + *args, |
| 93 | + **kwargs, |
| 94 | + ) |
| 95 | + except asyncio.CancelledError: |
| 96 | + pass |
| 97 | + |
| 98 | + if job_config.crontab: |
| 99 | + trigger = CronTrigger.from_crontab(job_config.crontab) |
| 100 | + elif job_config.interval: |
| 101 | + trigger = IntervalTrigger(seconds=job_config.interval) |
| 102 | + elif job_config.daemon: |
| 103 | + trigger = None |
56 | 104 | else: |
57 | | - if job_config.daemon: |
58 | | - raise ConfigurationError('Daemon jobs are intended to run forever') |
59 | | - |
60 | | - logger = FormattedLogger( |
61 | | - name=f'dipdup.hooks.{hook_config.callback}', |
62 | | - fmt=job_config.name + ': {}', |
63 | | - ) |
64 | | - if job_config.crontab: |
65 | | - trigger, next_run_time = CronTrigger.from_crontab(job_config.crontab), undefined |
66 | | - elif job_config.interval: |
67 | | - trigger, next_run_time = IntervalTrigger(seconds=job_config.interval), undefined |
68 | | - elif job_config.daemon: |
69 | | - trigger, next_run_time = None, datetime.now() |
70 | | - else: |
71 | | - raise RuntimeError |
72 | | - |
73 | | - scheduler.add_job( |
74 | | - func=partial(_job_wrapper, ctx=ctx), |
75 | | - id=job_config.name, |
76 | | - name=job_config.name, |
77 | | - trigger=trigger, |
78 | | - next_run_time=next_run_time, |
79 | | - kwargs=job_config.args, |
80 | | - ) |
| 105 | + raise RuntimeError |
| 106 | + |
| 107 | + return self._scheduler.add_job( |
| 108 | + func=partial(_job_wrapper, ctx=ctx), |
| 109 | + id=job_config.name, |
| 110 | + name=job_config.name, |
| 111 | + trigger=trigger, |
| 112 | + kwargs=job_config.args, |
| 113 | + ) |
| 114 | + |
| 115 | + def _on_error(self, event: JobEvent) -> None: |
| 116 | + self._exception = event.exception |
| 117 | + self._exception_event.set() |
| 118 | + |
| 119 | + def _on_executed(self, event: JobEvent) -> None: |
| 120 | + if event.job_id in self._daemons: |
| 121 | + event.exception = ConfigurationError('Daemon jobs are intended to run forever') |
| 122 | + self._on_error(event) |
0 commit comments