Привет, Хабр!

Мы тут в свободное время пилим проект, который должен решить боль многих айтишников, — автоматизировать рутинный поиск работы. Идея выросла в Telegram-бота «Аврора» , который на "автопилоте" ищет вакансии на hh.ru и откликается на них.

Но чтобы "автопилот" был полезным, он должен быть надежным. Никому не нужен ассистент, который при первом же деплое новой версии или падении сервера забывает, что он делал, и какие вакансии уже отправил.

Сегодня я хочу рассказать не столько о самом боте, сколько о конкретной инженерной задаче, с которой мы столкнулись: как обеспечить персистентность и "бесшовное" возобновление длительных пользовательских задач при перезапуске сервиса.

Под катом — наш подход к Graceful Shutdown, восстановлению сессий и немного про то, как LLM (в нашем случае Gemini) генерирует поисковые запросы.

Проблема: "Автопилот" — это stateful-процесс

Сначала кратко о том, как работает основная логика, чтобы был понятен масштаб проблемы.

  1. Пользователь логинится через OAuth hh.ru и выбирает свое резюме.

  2. Дальше начинается магия. Мы не просим пользователя вводить python AND (django OR flask) NOT (bitrix). Вместо этого мы берем текст его резюме, скармливаем его LLM (Gemini) и просим: "На основе этого резюме, сгенерируй оптимальный поисковый custom_query для API hh.ru".

  3. Этот запрос (например, (Python OR Go) AND (backend OR developer) AND (PostgreSQL OR ClickHouse)) сохраняется в нашей PostgreSQL.

  4. Пользователь нажимает "Запустить автопилот".

И вот тут начинается самое интересное. "Автопилот" — это не разовая функция. Это длительный асинхронный процесс, который должен:

  • Периодически (раз в N минут) ходить в API hh.ru с тем самым custom_query.

  • Для каждой найденной вакансии снова обращаться к LLM, передавая ей резюме пользователя и текст вакансии, с задачей "Напиши персонализированное сопроводительное письмо".

  • Автоматически откликаться на вакансию с этим письмом.

  • Вести учет откликов, чтобы не спамить.

Этот процесс может работать часами и днями. А теперь представим, что нам нужно выкатить новую версию бота. Мы гасим systemd юнит, Docker-контейнер останавливается... и все активные поиски пользователей прерываются. При следующем запуске бот не помнит, кто что искал и на ��ем остановился. Это фиаско.

Решение: State-машина в PostgreSQL и Graceful Shutdown

Проблема очевидна: состояние "автопилота" должно жить не в оперативной памяти бота, а во внешней базе данных. Мы используем PostgreSQL.

1. Модель данных (упрощенно)

Нам потребовалось несколько таблиц, но ключевая — это user_sessions (или autopilot_state).

SQL

-- Упрощенная схема для понимания
CREATE TABLE user_sessions (
    user_id BIGINT PRIMARY KEY,
    hh_resume_id VARCHAR(255),
    custom_query TEXT, -- Тот самый, что сгенерил LLM
    session_status VARCHAR(50) DEFAULT 'stopped', -- 'running', 'paused', 'stopped'
    last_run_time TIMESTAMP,
    last_processed_vacancy_id VARCHAR(100), -- Для пагинации и исключения дублей
    -- ... другие поля, токены и т.д.
);

Когда пользователь запускает "автопилот", мы не просто запускаем asyncio таску. Мы меняем session_status в БД на 'running'.

2. Механизм "Graceful Shutdown" (Изящной остановки)

Мы используем aiogram 3.x. Нам нужно отловить сигнал об остановке процесса (например, SIGTERM, который посылает docker stop или systemd).

В главном файле запуска бота (__main__.py или app.py) мы вешаем обработчики на эти сигналы.

Python

import asyncio
import signal
from aiogram import Bot, Dispatcher
# ... импорты наших сервисов (db_service, autopilot_service)

async def on_shutdown(dp: Dispatcher, bot: Bot):
    """
    Вызывается при получении сигнала SIGTERM или SIGINT.
    """
    logging.warning("Получен сигнал остановки. Переводим активные сессии в 'paused'...")
    
    # Здесь мы идем в нашу БД и все сессии со статусом 'running'
    # атомарно переводим в статус 'paused'.
    
    await db_service.pause_all_active_autopilots()
    
    logging.warning("Все активные задачи приостановлены. Завершение работы...")
    # Даем aiogram штат��о завершить обработку текущих апдейтов
    await dp.storage.close()
    await bot.session.close()

async def main():
    # ... инициализация бота, диспатчера, роутеров ...

    # Добавляем обработчики сигналов
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGINT, signal.SIGTERM):
        loop.add_signal_handler(sig, lambda: asyncio.create_task(on_shutdown(dp, bot)))

    # Запускаем автопилот для тех, кто был 'paused'
    await autopilot_service.resume_paused_sessions()

    # Запускаем поллинг
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())

Что здесь происходит:

  1. При получении SIGTERM, asyncio создает задачу on_shutdown.

  2. on_shutdown не останавливает поиск, а просто идет в PostgreSQL и всем, у кого session_status = 'running', ставит session_status = 'paused'.

  3. После этого бот штатно завершает работу.

Сам воркер "автопилота" в своем цикле while True должен после каждой итерации (например, после обработки одной вакансии) проверять свой статус в БД. Если он стал 'paused' или 'stopped', воркер должен корректно завершить свою работу (return или break).

3. Механизм "Auto-Resume" (Авто-возобновления)

Теперь самое главное. Когда бот стартует, нам нужно поднять все "уснувшие" задачи. Для этого в main() перед запуском поллинга мы вызываем специальную функцию resume_paused_sessions().

Python

# Внутри нашего autopilot_service
async def resume_paused_sessions(self):
    """
    Вызывается один раз при старте бота.
    Ищет всех пользователей со статусом 'paused' и запускает для них воркеры.
    """
    paused_users = await self.db.get_users_by_status('paused')
    if not paused_users:
        logging.info("Приостановленных сессий не найдено.")
        return

    logging.info(f"Найдено {len(paused_users)} приостановленных сессий. Возобновление...")
    
    tasks = []
    for user_session in paused_users:
        # Важно: Сначала меняем статус в БД на 'running'
        await self.db.update_session_status(user_session.user_id, 'running')
        # И только потом создаем асинхронную задачу на запуск
        tasks.append(
            asyncio.create_task(
                self.run_autopilot_for_user(user_session.user_id)
            )
        )
    
    await asyncio.gather(*tasks)
    logging.info("Все приостановленные сессии успешно возобновлены.")

Итог этого блока

Мы получили "пуленепробиваемую" систему:

  • Деплой: Мы спокойно перезапускаем бота. SIGTERM -> задачи в БД переводятся в 'paused' -> бот гаснет.

  • Старт: Бот запускается -> читает БД -> видит все 'paused' задачи -> меняет их на 'running' и запускает воркеры.

  • Крэш: Если бот упал без Graceful Shutdown (OOM Killer, kill -9), задачи остаются в БД со статусом 'running'. Нам нужен отдельный watchdog-механизм, который при старте проверяет задачи 'running' и, если они "подвисли", тоже их перезапускает (например, по last_run_time).

Что еще под капотом (и над чем работаем)

Описанный механизм — это лишь один из кубиков. Вокруг него построено много всего:

  • Keep-Alive: Отдельный механизм, который восстанавливает сессию пользователя (токены hh.ru), если его почему-то нет в нашей БД, но он пишет боту (например, если мы чистили кэши).

  • Динамические ReplyKeyboardMarkup: Чтобы не просить пользователей писать /start после каждого обновления, мы сделали механизм, который при изменении "версии" кнопок в коде автоматически обновляет клавиатуру всем пользователям при их следующем сообщении.

  • Роли Admin/User: Админы видят расширенный интерфейс прямо в Telegram, могут смотреть статистику, статусы сессий и т.д.

Текущая задача: Мы продолжаем отладку механизма Graceful Shutdown и авто-возобновления, так как при больших нагрузках всплывают нюансы с гонкой состояний (race conditions) в asyncio. Параллельно добавляем новые админские функции для управления пользователями.

Заключение

Создание stateful-бота, который выполняет длительные фоновые задачи, — это всегда вызов. Нельзя просто положить все в RAM и надеяться на лучшее. Использование PostgreSQL как "единого источника правды" о состоянии сессий и реализация механизмов GraceEOF

  • Название: "Аврора"

  • Логин в Telegram: @AuroraCareer

  • Стек: Python, aiogram, PostgreSQL, Gemini API, hh.ru API.

Буду рад услышать в комментариях, с какими проблемами вы сталкивались при создании подобных "долгоживущих" ботов. Как вы решаете задачи персистентности? Может, мы где-то изобрели велосипед?

Спасибо за внимание!