LinkTracker
Сервис отслеживания обновлений GitHub и Stack Overflow: три асинхронных Python-компонента, доставка уведомлений через Kafka с запасным HTTP-путём, двухуровневый кеш на Valkey и метрики в Prometheus и Grafana.
Стек
- Python
- FastAPI
- Apache Kafka
- PostgreSQL
- gRPC
- asyncio
- aiogram
- Avro
- Valkey
- Prometheus
- Grafana
- Docker
- Testcontainers
- GitHub Actions
Задача
Пользователю нужно следить за обновлениями в репозиториях GitHub и вопросах Stack Overflow и получать уведомления в Telegram. Наивное решение — опрашивать все ссылки подряд из того же процесса, что отвечает пользователю. Оно упирается во внешние API, теряет уведомления при сбоях и не даёт понять, что именно происходит внутри.
Решение
Система разделена на три асинхронных компонента. Bot Service отвечает за диалог с пользователем и команды подписки, Scrapper Service — за опрос внешних источников, а между ними стоит слой обработки, который отбирает, приоритизирует и группирует обновления. Обновления доставляются через Kafka, а на случай её недоступности остаётся прямой HTTP-путь. Вызовы между сервисами идут отдельно, по gRPC и REST.
Архитектура
Ключевые технические решения
- Разделение на Bot Service и Scrapper Service вместо одного процесса: опрос внешних API не блокирует ответы пользователю.
- Транспорт уведомлений задаётся конфигурацией: по умолчанию используется Kafka, при этом каждый из двух транспортов может выступать запасным для другого. Составной отправитель переключается только при ошибке доставки; ошибки в самом коде он пробрасывает наружу, а не маскирует переключением.
- Сообщения в Kafka — JSON или Avro со Schema Registry: схема сообщения фиксируется, а не подразумевается.
- Сообщения, которые не удалось обработать, уходят в dead letter queue, а не теряются молча.
- Timeout, retry с экспоненциальной задержкой и circuit breaker на всех обращениях к внешним API, чтобы деградация GitHub или Stack Overflow не выводила из строя весь сервис.
- Двухуровневый кеш собственного
GET /links: кластер Valkey из трёх узлов, а перед ним — необязательный внутрипроцессный LRU+TTL. Ключи хешируются по идентификатору чата, поэтому все страницы одного чата попадают в один слот кластера и инвалидация при записи остаётся однопроходной. - REST и gRPC между сервисами: gRPC для внутреннего трафика, REST — как понятная внешняя граница.
- Отбор, приоритеты и группировка вынесены в отдельный слой между сервисами, а не в Scrapper: бот получает уже отфильтрованное и сгруппированное.
Как это работает
Пользователь добавляет ссылку на репозиторий GitHub или вопрос Stack Overflow через Telegram-бота. Bot Service принимает команды пользователя и передаёт подписки в Scrapper Service по REST и gRPC. Scrapper по расписанию опрашивает внешние API, сравнивает состояние и публикует найденные обновления в Kafka. Оттуда их забирает слой обработки, отбирает нужное и группирует, а Bot Service читает уже готовое и отправляет пользователю. Scrapper обрабатывает ссылки батчами в нескольких параллельных задачах. Ошибка на одной ссылке не останавливает остальные и попадает в итоговый отчёт.
Если Kafka недоступна, Scrapper отправляет обновление напрямую по HTTP — маршрут другой, результат для пользователя тот же. Сообщения, которые не удалось обработать, уходят в dead letter queue, а не исчезают. Публичный REST ограничен по частоте запросов на IP, с отдельным бакетом на эндпоинт.
Устойчивость к внешним сбоям
Вся работа с чужими API обёрнута в таймауты, повторы и circuit breaker. Когда GitHub или Stack Overflow отвечают медленно или не отвечают вовсе, запросы к ним обрываются по таймауту, повторяются с задержкой, а при устойчивых ошибках цепь размыкается. Сервис перестаёт ломиться в неработающий источник и продолжает обслуживать остальные.
Отбор и группировка обновлений
Между Scrapper и ботом стоит отдельный слой обработки, тоже консьюмер Kafka. Он читает сырые обновления из одного топика, отбрасывает те, что попали под правила фильтрации, назначает остальным приоритет по ключевым словам, укорачивает слишком длинные тексты, группирует всё адресованное одному чату в коротком окне и публикует результат в другой топик. Бот читает уже готовое.
Слой намеренно детерминированный: фильтрация, приоритеты и сокращение текста — это правила, а не модель. Никакого LLM внутри нет. При этом сокращение вынесено за интерфейс, поэтому заменить правило на модель можно, не трогая сам конвейер. Код слоя ничего не импортирует из Scrapper и живёт отдельным пакетом. Чтобы вынести его в самостоятельный сервис, менять ничего не придётся.
Кеш и что он дал
Список отслеживаемых ссылок отдаётся через двухуровневый кеш: кластер Valkey из трёх узлов, а перед ним — необязательный внутрипроцессный LRU+TTL. Ключи хешируются по идентификатору чата, поэтому все страницы одного чата попадают в один слот кластера, и запись инвалидирует их одним проходом, не задевая соседей.
Нагрузочный тест прогонялся в трёх конфигурациях подряд. Это и есть ответ на вопрос, стоил ли кеш усилий:
| Конфигурация | RPS | p50 | p99 |
|---|---|---|---|
| без кеша | 146 | 250 мс | 690 мс |
| Valkey | 710 | 22 мс | 250 мс |
| Valkey + внутрипроцессный | 876 | 11 мс | 230 мс |
Основной выигрыш даёт первый уровень: почти впятеро по пропускной способности и на порядок по медиане. Второй добавляет ещё четверть и убирает половину оставшейся задержки. Выигрыш небольшой, потому что Valkey и так отвечает быстро по локальной петле.
Запись при этом упирается в PostgreSQL и от кеша не ускоряется: POST и DELETE держатся в пределах 15% между вторым и третьим сценариями. Их видимое улучшение относительно первого даёт не кеш сам по себе, а то, что чтение перестало отбирать ресурсы у записи.
Наблюдаемость
Оба сервиса отдают /metrics в формате Prometheus: RED-метрики (частота запросов, доля
ошибок, длительность) плюс прикладной счётчик links_on_track_total, который
показывает, сколько ссылок сейчас отслеживается. Дашборды собраны в Grafana,
так что состояние системы видно не по логам, а по графикам.
Тесты и сборка
Тесты идут в четыре слоя: unit, интеграционные, e2e и нагрузочные. Интеграционные поднимают настоящие Kafka, PostgreSQL и Valkey через Testcontainers, поэтому проверяется реальное поведение, а не поведение моков. Нагрузочные дали цифры, которые стоят в метриках выше. Всё это прогоняется в GitHub Actions на каждый push вместе с линтерами, сервисы упакованы в Docker.
Результаты
- Нагрузочный тест в трёх конфигурациях показал, что дал каждый уровень кеша: без кеша 146 RPS при p50 250 мс, с Valkey — 710 RPS при p50 22 мс, с Valkey и внутрипроцессным кешем — 876 RPS при p50 11 мс и p99 230 мс.
- Все три прогона закончились без единого отказа: 314 045 запросов в лучшем сценарии, ноль ошибок и ноль исключений.
- Запись упирается в PostgreSQL и кешем не ускоряется, это видно по цифрам.
- Доставка уведомлений переживает отказ основного транспорта за счёт запасного.
Метрики
- Нагрузочный тест
- 876 RPS
- Медиана задержки
- 11 ms
- 99-й перцентиль
- 230 ms