10 дней спустя: как мой бот дважды умирал незаметно, а метрика релевантности мне врала

TL;DR — продолжение прошлого поста про @futur_e_news_bot (двуязычная лента новостей на sqlite-vec за ~$5/мес). За 10 дней в проде: два тихих многодневных простоя (бот поллил Telegram и казался живым, но не создавал ни одной новости), метрика релевантности, которую отравил один человек 106 дизлайками, и сигнал от реакций 78 юзеров, который заставил выкинуть половину источников. Плюс — я открыл код (MIT). Пост про надёжность, наблюдаемость и data-driven решения на маленьком проекте, где нет ни SRE, ни аналитика — только ты и логи.

В прошлый раз я собрал Telegram-бота с персональной лентой новостей: дедуп через sqlite-vec, локальные эмбеддинги, бесплатные LLM, «хорошие новости по умолчанию», одна машина на Fly.io. Пост на Habr привёл первых живых пользователей — и тут началось самое интересное. Не код. Эксплуатация.


Глава 1. Бот, который поллит, но мёртв

Самый коварный класс багов в боте-воркере: он выглядит живым. Telegram-бот на long-polling отвечает на сообщения, нажатия кнопок работают, /start работает. А фоновый пайплайн при этом стоит колом уже несколько дней. Я ловил это дважды за эти полторы недели, по-разному.

OOM на загрузке модели — crash loop на пять дней

Бот стартовал на машине 512 МБ. Этого хватало… пока пайплайн не доходил до первой обработки новости, где лениво грузится ONNX-модель эмбеддингов (fastembed). Загрузка пушит RSS до ~400 МБ, и на 512 МБ ядро убивало процесс прямо во время загрузки модели:

Out of memory: Killed process 644 (python) ... anon-rss:406396kB
Process appears to have been OOM killed!
reboot: Restarting system

Дальше — петля: Fly рестартит машину → бот стартует → начинает поллить (выглядит живым!) → доходит до обработки → OOM → рестарт. Между рестартами _collect_sources успевал зафетчить RSS, и raw-новости копились сотнями, но ни одна не превращалась в Story.

Я заметил это только потому, что дневной дайджест пришёл пустым. Полез смотреть — последняя новость в базе была пятидневной давности. Пять дней бот «работал» и молчал.

Своп в fly.toml (swap_size_mb = 512) на Fly так и не активировался — SwapTotal: 0. Фикс банальный: 1 ГБ, а после наплыва с Habr — 2 ГБ.

И сразу про честность с ценой. В прошлом посте я писал «5/мес». После фикса OOM и под нагрузкой это **2 ГБ + 2 vCPU ≈ $15/мес**. Хобби-инстанс на 512 МБ всё ещё стоит ~5, но честная цифра «под живым трафиком» — три чашки кофе, а не одна.

feedparser без таймаута заморозил пайплайн на четыре дня

Второй простой был хитрее и не имел отношения к памяти. В коде сбора источников был невинно выглядящий вызов:

feed = feedparser.parse(source.url)

feedparser.parse(url) делает блокирующий HTTP-запрос вообще без таймаута. Один мёртвый/медленный RSS-источник (или затормозивший self-hosted RSSHub) — и весь прогон пайплайна виснет навсегда. А поскольку у джобы стоял max_instances=1, каждый следующий запланированный прогон просто пропускался:

WARNING apscheduler: Execution of job "run_pipeline" skipped:
                     maximum number of running instances reached (1)

Эта строчка повторялась в логах четыре дня. Бот всё это время отвечал на сообщения.

Фикс — два слоя. Первый: фетчим через httpx с жёстким таймаутом, а feedparser кормим уже готовыми байтами (он тогда только парсит, без сети):

async def _download(url: str) -> bytes:
    async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
        resp = await client.get(url)
        resp.raise_for_status()
        return resp.content

# в fetch():
content = await _download(source.url)
feed = await asyncio.to_thread(feedparser.parse, content)

Второй слой — пояс поверх подтяжек: оборачиваю весь прогон в таймаут, чтобы зависший прогон в принципе не мог держать единственный слот планировщика дольше 10 минут:

async def run_pipeline_guarded() -> None:
    try:
        await asyncio.wait_for(run_pipeline(), timeout=600)
    except asyncio.TimeoutError:
        logger.error("run_pipeline exceeded 600s and was cancelled (stuck fetch?)")

Урок: воркер не умеет жаловаться

Общая нить у обоих простоев: процесс, который поллит, выглядит здоровым, даже когда функционально мёртв. У веб-сервиса упал бы хелсчек. У воркера хелсчека нет.

Поэтому я добавил watchdog — джобу, которая раз в 30 минут смотрит свежесть пайплайна и пишет мне в личку при простое:

async def check_pipeline_health(bot) -> None:
    age_h, pending = await _story_age_hours()
    stale = age_h is None or age_h >= 3.0
    if stale and not _alerted:        # edge-triggered, не спамит
        await _notify_admins(bot, f"🔴 Пайплайн молчит: последняя новость {age_h:.1f}ч назад")

Плюс ежедневный бэкап SQLite (онлайн-бэкап через sqlite3-API, ротация 7 дней) — потому что всё состояние в одном файле, и до этого момента у меня не было ни одной резервной копии. Оба фикса вместе поймали бы оба простоя за минуты, а не за дни.


Глава 2. Чтобы чинить релевантность, её сначала надо увидеть

Когда пайплайн стабилизировался и пошёл живой трафик, я уперся в вопрос поинтереснее: а лента-то хорошая? Глазами — вроде да. Но «вроде» — это не метрика.

Я сделал так, что кнопка «📊 Статистика» в боте присылает .md-файл с обширным отчётом: воронка активации, DAU/WAU, удержание, сентимент, топ-категории — с ASCII-барами и mermaid-графиками (рендерятся в GitHub/Obsidian). Почему файл, а не сообщение: его удобно открыть в нормальном просмотрщике, заархивировать, сравнить с прошлым.

Цифры через 10 дней (78 юзеров — масштаб маленький, но тренды читаются):

Метрика

Значение

Активация (хоть одно действие)

87%

Глубина

~18 действий на активного

Удержание (вернулись в другой день)

~29%

WAU / всего

63 / 78

Для холодного старта — живо. Но был один показатель, который упрямо не двигался: отношение лайков к дизлайкам держалось ~50/50. Половина оценённых новостей отвергается. Вот его и пошёл чинить — и нарвался на два отдельных урока.


Глава 3. Аудитория сама сказала, что читать (а я не слышал)

Сначала я сделал «очевидное»: покрутил веса в скоринге, снизил долю серендипности (анти-бабла) с 35% до 15%, сделал так, что новичкам без сформированного вкуса показывается только «в точку», без случайных подмешиваний. Задеплоил. Через пару дней смотрю отчёт — 50/50 как стояло, так и стоит.

Проблема была в том, что я смотрел не туда. Общий лайк/дизлайк за всё время — слишком тупая линза. Я добавил в отчёт разрез по источникам — и картина стала очевидной:

RBC          70% 🙈   Habr         +0.30 👍
Lenta.ru     61% 🙈   TechCrunch   +0.33 👍
                      Ars Technica +0.33 👍

Tech-аудитория (а пришли ко мне в основном с Habr — то есть разработчики) любит tech-источники и отвергает русские общие новости. А Lenta + RBC были двумя самыми крупными производителями — больше половины всего потока. То есть лента на 50% состояла из контента, который этой аудитории просто не нужен.

Тюнинг весов это не мог починить в принципе: у рекомендателя был affinity по категориям и тегам, но не было сигнала на уровне источника. Добавил — ровно по аналогии с категориями, только per-source:

# реакция двигает вес источника в -1..1
if kind in ("like", "open"):
    source_interests[sid] = min(1.0, source_interests.get(sid, 0.0) + 0.3)
elif kind == "dislike":
    source_interests[sid] = max(-1.0, source_interests.get(sid, 0.0) - 0.35)

И добавил в скоринг член + 0.15 * source_affinity. Плюс глобальный приор: для юзера, который ещё не оценивал источник, берём краудовый сигнал — чтобы даже новичок сразу видел меньше широко-нелюбимых источников.

Казалось бы, победа. Деплою, открываю отчёт через день — и…


Глава 4. Метрика, которую отравил один человек

…релевантность рухнула с 50% до 30%. Дизлайки взлетели с 62 до 228. В списке «худших источников» оказались вообще все, включая хорошие: dev.to 91% 🙈, Hacker News 80% 🙈, даже только что захваленный Habr.

Первая мысль — «я что-то сломал своим source-affinity, надо откатывать». Но прежде чем паниковать и откатывать, я сделал то, что должен был сделать раньше: посмотрел в данные.

SELECT user_id, count(*) FROM interactions WHERE kind='dislike'
GROUP BY user_id ORDER BY 2 DESC LIMIT 3;
  user 66  ->  106 дизлайков
  user 73  ->   31 дизлайк
  остальные -> единицы

Один пользователь поставил 106 дизлайков из 228 — почти половину. Вдвоём с вторым — 60%. Если их исключить, отношение возвращается к ~50%.

И вот в чём была настоящая ошибка: и метрика, и глобальный приор источников считали сырые реакции. Один масс-дизлайкер размазал свои 106 🙈 по всем источникам — и отравил оба: метрика показала 30% вместо реальных ~42%, а приор потерял всякую различимость (всё стало выглядеть «на 80-100% нелюбимым»).

Фикс — считать по уникальным юзерам, каждый вносит не больше ±1 на источник:

WITH per_user_source AS (
  SELECT i.user_id, st.source_id,
         SUM(i.kind='like') - SUM(i.kind='dislike') AS net
  FROM interactions i JOIN stories st ON st.id = i.story_id
  WHERE i.kind IN ('like','dislike')
  GROUP BY i.user_id, st.source_id)
SELECT source_id,
       SUM(CASE WHEN net>0 THEN 1 WHEN net<0 THEN -1 ELSE 0 END) AS net_users,
       COUNT(*) AS raters
FROM per_user_source GROUP BY source_id HAVING raters >= 3;

После этого правда проявилась. Релевантность по юзерам — 42% (а не 30%). И приор источников снова стал различимым:

ХОРОШО:  TechCrunch +0.33 · Ars Technica +0.33 · Habr +0.11
ПЛОХО:   Habr Best -1.00 · BBC -0.50 · Hacker News -0.50 · RBC/Lenta -0.31

Урок дороже самой фичи: никогда не доверяй метрике, которую может перекосить один человек. На масштабе 78 юзеров один энтузиаст с 🙈 — это уже 46% сигнала. Робастность к выбросам важнее точности. И — диагностируй, прежде чем откатывать: я был в шаге от того, чтобы выкинуть рабочую фичу из-за артефакта измерения.


Глава 5. Меньше, но лучше

42% — честно, но всё ещё посредственно (нетто-юзер скорее недоволен). Зато сигнал по источникам теперь был кристально чист:

  • Habr Best — −1.00: 4 из 4 оценивших дизлайкнули. Он, к тому же, дублировал обычный Habr.

  • dev.to, Lobsters — комьюнити-файрхоузы: много, шумно, низкий сигнал.

  • TechCrunch, Ars Technica, Habr — курируемый tech, всем заходит.

Вывод напрашивался сам: аудитория предпочитает курируемое — файрхоузу. Я выключил три худших источника (Habr Best, dev.to, Lobsters) — прямо в коде, списком «retired», который отключает их на старте, без ручного лезанья в прод-базу:

RETIRED_SOURCE_URLS = [
    "https://habr.com/ru/rss/best/daily/?fl=ru",  # -1.00, дублирует Habr
    "https://dev.to/feed",                         # файрхоуз
    "https://lobste.rs/rss",                        # ниша
]

Бонусом это срезало объём пайплайна — файрхоузы давали заметную долю от ~1900 новостей/день. Меньше пресного контента → меньше 🙈. Глобальный приор + персональные веса добьют остальное, а отчёт покажет, поползла ли релевантность вверх.


Что я понял за полторы недели

  • Воркер, который поллит, может быть мёртв и выглядеть живым. Меряй свежесть результата (последняя обработанная единица), а не «процесс жив». Watchdog на 30 строк окупил бы себя дважды за полторы недели.

  • Любой блокирующий вызов без таймаута — это бомба замедленного действия. feedparser.parse(url), я смотрю на тебя.

  • Сначала измеряй, потом тюни. Я потерял пару дней на кручение весов, потому что смотрел в слишком общую метрику. Разрез по источникам сразу показал причину.

  • Метрику должно быть невозможно перекосить одним человеком. Считай по уникальным сущностям, а не по сырым событиям. На маленьком масштабе один выброс = катастрофа в цифрах.

  • Реакции — это редакторский сигнал. Аудитория сама курирует твой список источников, если её слушать. Мне не нужно было гадать, какие RSS оставить — пользователи проголосовали 🙈.

  • Честная цена под нагрузкой ≠ цена на лендинге. 5 идл → ~15 живой. Всё ещё дёшево, но цифру стоит называть настоящую.


Попробовать

Бот живой: @futur_e_news_bot/start, выбираешь язык, реагируешь на пару новостей, и лента начинает подстраиваться. По умолчанию — только хорошие новости, негатив включается тумблером.

Сейчас самое слабое место — релевантность на 42%, и я открыто за ней слежу по отчёту: source-affinity и курирование источников должны её поднять. Если интересно, во что это выльется — приходите через пару недель, выложу третий пост уже с цифрами «до/после». А в комментах с радостью обсужу sqlite-vec, робастные метрики или почему ваш воркер прямо сейчас, возможно, тоже мёртв и просто не сказал вам об этом.