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

Мы тут в свободное время пилим проект, который должен решить боль многих айтишников, — автоматизировать рутинный поиск работы. Идея выросла в 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.

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

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

Комментарии (4)


  1. sshmakov
    20.10.2025 15:32

    Может, мы где-то изобрели велосипед?

    Массовыми автоматическими откликами вы гробите весь смысл пользования HH. А так норм.


  1. Vindicar
    20.10.2025 15:32

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

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

    Т.е. если процесс или контейнер бота будет прибит (или физический сервак жёстко перезагрузится) всё равно всё сломается?

    ИМХО, напрочь не нужно отслеживать "кого мы начали обрабатывать". Если начали, но не закончили, и никаких ресурсов освобождать не нужно - значит, считаем, что не начали. А потому вполне достаточно знать, кого нужно обновлять в первую очередь - т.е. иметь очередь запросов (например, в порядке "кто давно не обновлялся" по метке времени последнего поиска).

    Единственный момент - нужно сделать так, чтобы запрос из этой очереди не мог быть взятым разными воркерами одновременно. Возможно, добавить отдельный столбец для ID worker-а, и пытаться атомарно извлечь и пометить строку через SELECT ... FOR UPDATE. Или иметь отдельный кэш (локальный или redis, смотря что у вас воркеры - потоки или процессы) IDшников запросов "сейчас в работе" и выбирать строки, пока не найдём строку не в кэше.


  1. fireSparrow
    20.10.2025 15:32

    Статья ни о чём. Использования БД для персистентного хранения стэйта - это настолько типовая практика, что про это стыдно писать как о каком-то интересном решении.

    Вся статья написана исключительно ради рекламы.


  1. gkaliostro8
    20.10.2025 15:32

    Прямо открыли Америку))). Уже все разработчики знают, что нужно использовать БД, чтобы при перезапуске бот подтягивал с нее все данные