Привет, Хабр!
Мы тут в свободное время пилим проект, который должен решить боль многих айтишников, — автоматизировать рутинный поиск работы. Идея выросла в Telegram-бота «Аврора» , который на "автопилоте" ищет вакансии на hh.ru и откликается на них.
Но чтобы "автопилот" был полезным, он должен быть надежным. Никому не нужен ассистент, который при первом же деплое новой версии или падении сервера забывает, что он делал, и какие вакансии уже отправил.
Сегодня я хочу рассказать не столько о самом боте, сколько о конкретной инженерной задаче, с которой мы столкнулись: как обеспечить персистентность и "бесшовное" возобновление длительных пользовательских задач при перезапуске сервиса.
Под катом — наш подход к Graceful Shutdown, восстановлению сессий и немного про то, как LLM (в нашем случае Gemini) генерирует поисковые запросы.
Проблема: "Автопилот" — это stateful-процесс
Сначала кратко о том, как работает основная логика, чтобы был понятен масштаб проблемы.
Пользователь логинится через OAuth hh.ru и выбирает свое резюме.
Дальше начинается магия. Мы не просим пользователя вводить
python AND (django OR flask) NOT (bitrix). Вместо этого мы берем текст его резюме, скармливаем его LLM (Gemini) и просим: "На основе этого резюме, сгенерируй оптимальный поисковыйcustom_queryдля API hh.ru".Этот запрос (например,
(Python OR Go) AND (backend OR developer) AND (PostgreSQL OR ClickHouse)) сохраняется в нашей PostgreSQL.Пользователь нажимает "Запустить автопилот".
И вот тут начинается самое интересное. "Автопилот" — это не разовая функция. Это длительный асинхронный процесс, который должен:
Периодически (раз в 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())
Что здесь происходит:
При получении
SIGTERM,asyncioсоздает задачуon_shutdown.on_shutdownне останавливает поиск, а просто идет в PostgreSQL и всем, у когоsession_status = 'running', ставитsession_status = 'paused'.После этого бот штатно завершает работу.
Сам воркер "автопилота" в своем цикле 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.
Буду рад услышать в комментариях, с какими проблемами вы сталкивались при создании подобных "долгоживущих" ботов. Как вы решаете задачи персистентности? Может, мы где-то изобрели велосипед?
Спасибо за внимание!
