Исходный код 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()