Исходный код main

"""Точка входа клиента.

Поднимает планировщик, регистрирует постоянные задания и следит за ними:
при исключении планировщик перезапускается, чтобы сломанный проход не
остановил станцию."""

import signal
import sys
import time
from collections.abc import Callable

from apscheduler.schedulers.base import BaseScheduler

from core.configs import configure_runtime, logger
from soniks_client.health import start_health_server
from soniks_client.jobs import (
    register_observation_jobs,
    register_resending_jobs,
    register_sat_data_jobs,
)
from soniks_client.mqtt import start_mqtt
from soniks_client.remote_config import bootstrap as bootstrap_remote_config
from soniks_client.sat_data import sync_sat_data
from soniks_client.scheduler import create_scheduler


[документация] class Application: """Супервизор планировщика. Ловит исключения планировщика и перезапускает его, чтобы сломанный проход не остановил станцию. Завершается по ``SIGINT``/``SIGTERM``. """ def __init__(self, scheduler_factory: Callable[[], BaseScheduler]): self._scheduler_factory = scheduler_factory self.scheduler = scheduler_factory() self._running = False self._setup_signals()
[документация] def run(self) -> None: """Запустить планировщик и держать его живым до остановки.""" self._running = True logger.info("Запуск приложения") while self._running: try: self._run_scheduler() except Exception as e: logger.exception("Ошибка в планировщике: %s", e) self._restart_scheduler() time.sleep(5)
[документация] def stop(self) -> None: """Остановить планировщик и завершить работу приложения.""" self._running = False if self.scheduler.running: # wait=False: с wait=True обработчик сигнала блокировался до конца # текущего прохода, а Docker добивает контейнер SIGKILL по истечении # stop_grace_period (15 с в обоих compose) — то есть корректное # завершение всё равно не успевало произойти. self.scheduler.shutdown(wait=False) logger.info("Работа приложения завершена")
def _setup_signals(self) -> None: signal.signal(signal.SIGINT, self._handle_signal) signal.signal(signal.SIGTERM, self._handle_signal) def _handle_signal(self, signum, frame) -> None: logger.info("Получен сигнал %d, завершение работы", signum) self.stop() def _run_scheduler(self) -> None: if not self.scheduler.running: self.scheduler.start() register_observation_jobs(self.scheduler) register_resending_jobs(self.scheduler) register_sat_data_jobs(self.scheduler) while self._running and self.scheduler.running: time.sleep(1) def _restart_scheduler(self) -> None: max_attempts = 3 for attempt in range(1, max_attempts + 1): try: if self.scheduler.running: logger.info( "Остановка текущего планировщика перед перезапуском (попытка %d)", attempt, ) self.scheduler.shutdown(wait=False) logger.info("Запуск планировщика (попытка %d)", attempt) # Именно новый экземпляр: APScheduler после shutdown() навсегда # гасит пул потоков, и перезапущенный старый объект принимает # задания, но не выполняет их. self.scheduler = self._scheduler_factory() self.scheduler.start() register_observation_jobs(self.scheduler) register_resending_jobs(self.scheduler) register_sat_data_jobs(self.scheduler) logger.info("Планировщик успешно перезапущен (попытка %d)", attempt) return except Exception as e: logger.error( "Не удалось перезапустить планировщик, попытка %d: %s", attempt, e, ) time.sleep(1) # Дальше работать нечем: планировщик мёртв, проходы не проводятся, а # приложение продолжало жить и Docker не видел повода перезапустить # контейнер. SystemExit — не Exception, цикл run() его не ловит. logger.error("Перезапуск планировщика не удался после %d попыток", max_attempts) sys.exit(1)
[документация] def main() -> None: # До этого вызова логи уходят без обработчиков, а рабочих директорий нет. configure_runtime() # Конфигурация с портала: сначала сохранённая на диске, потом свежая. # Ошибки гасятся — без портала станция стартует на .env и на последнем # применённом состоянии. bootstrap_remote_config() # Определения спутников с портала вместо клонов из git на каждом старте. # Ошибки внутри гасятся: станция без bundle работает на данных из образа, # и уж точно не должна из-за него не подняться. sync_sat_data() app = Application(create_scheduler) # Колбэк, а не app.scheduler: супервизор подменяет экземпляр планировщика # при перезапуске, и захваченная ссылка показывала бы мёртвый объект. start_health_server(lambda: app.scheduler) # Живой канал: конфигурация применяется в момент сохранения на портале. start_mqtt() app.run()
if __name__ == "__main__": main()