Спасибо за статью и за просьбу присылать воспроизводимые случаи. Проверил описанный TODO: в исходной версии принималась программа, которая читает байт стека без предшествующей записи (LDX(BPF_B, r0, r10, -1); EXIT).
Подготовил исправление и открыл PR #1: побайтовый учёт инициализации стека, пересечение состояний на слиянии веток и проверки чтений через инструкции и helper-функции с контрактом указатель+длина. Добавил регрессионные тесты для частичной записи и ветвлений.
test_vm: 59/59 под ASan/UBSan. Полный make test в этом окружении завершить не удалось: установленный Clang не поддерживает BPF target, поэтому примеры eBPF не собрались.
А таймаут останавливает команду на сервере или только ожидание ответа в боте? Например, установка пакета не уложилась в лимит: пользователю важно понять, она ещё идёт или уже завершена. Я бы в таком случае показывал «результат пока неизвестен» и давал проверить состояние операции, прежде чем запускать её повторно.
А как тематический поиск обрабатывает русские словоформы? Например, найдёт ли запрос «разработка бота» пост со словами «разработка ботов»? Для новостей это полезно проверить отдельно: пустая выдача ещё не означает, что тема в архиве отсутствует. Было бы интересно увидеть пару таких запросов и понять, хватает ли FTS5 или перед поиском есть нормализация.
Тут остаётся случай, когда крон вообще не запустил скрипт: внутренняя проверка файла тоже не выполнится, и Telegram снова промолчит. Я бы проверял свежесть результата отдельным заданием, а для критичного процесса — с другой машины. Тогда тревога приходит и при падении шага, и при отсутствии самого запуска.
А как устроен откат после правок уже работающего бота? Например, агент поменял схему SQLite, новая версия не запустилась, а пользователи до этого оставляли заявки. Вернуть старый код тут может быть недостаточно. Было бы полезно показать второй пример: обновление бота с данными, резервная копия базы и восстановление после неудачного деплоя.
В реконструкции таймер запускается от первого сообщения в пачке. Тогда на последнюю реплику ответ может прийти почти сразу, если она отправлена в конце этих 270 секунд. Интересно было бы сравнить интервалы по экспорту чата с точностью до секунд: это помогло бы отличить такую очередь от задержки после каждого сообщения. По минутам на скринах обе версии выглядят похоже.
Привязка разрешения к конкретному действию — хорошая деталь. А у ожидания ответа есть срок жизни? Если запрос остался в чате, а задача уже остановлена или продолжена новым процессом, я бы делал старую кнопку явно неактивной и показывал причину. Иначе с телефона трудно понять, агент ещё ждёт решения или карточка уже устарела.
А у consult_colleague есть общий лимит на число консультаций в рамках одного ответа? Если коллега тоже может вызвать этот инструмент, возможна цепочка А → Б → А. Я бы ограничивал всю цепочку одним бюджетом и передавал список уже вызванных персонажей, чтобы повторные обращения не съедали время и токены без нового результата.
У strip_ai_markers замена идёт по всему тексту, включая ссылки и код. Если в пути URL есть «–», после замены на «-» это уже другой адрес. Я бы исключил такие фрагменты из фильтра и добавил тест на сохранение ссылок: убрать тире можно успешно, а полезную ссылку при этом сломать.
Копия состояния для PDF — хорошая деталь: новое предложение не испортит уже заказанный документ. А на запуск Chrome есть таймаут с завершением процесса? При одном воркере зависший рендер остановит всю очередь, хотя сам бот продолжит отвечать. Я бы добавил этот случай в раздел про эксплуатацию.
А сообщения из супергруппы в MAX пересылаются только от владельца привязанного аккаунта? Если он случайно добавит ещё одного участника, одного ID группы для проверки доступа уже мало. Интересно, проверяете ли отправителя и для обычных сообщений, и для правок с реакциями.
С удалением черновика до отправки есть ещё один сценарий: процесс упал между удалением файла и запросом к площадке. Как такой пост восстанавливается? Я бы сохранял черновик со статусом «отправляется», а при таймауте отмечал результат как неизвестный — чтобы можно было проверить публикацию и решить, нужен ли повтор.
А если один пользователь откроет два веб-диалога, у них будет общий lock или отдельный на каждую PTBSession? user_id ведь остаётся прежним. Если состояние бота привязано к нему, я бы сериализовал апдейты по user_id, иначе две вкладки могут одновременно менять один сценарий.
Нашёл крайний случай в COCO Panoptic export. Если изображения пришли из разных каталогов, но называются одинаково (например, road_a/frame.jpg и road_b/frame.jpg), экспорт складывает их в плоские папки и использует только basename/stem. При copy_images=True один файл перезапишет другой; panoptic PNG тоже перезапишется, а в JSON останутся две записи с одинаковыми именами.
Проверка для пары путей с одинаковым basename: после такого префикса имена изображений (0_frame.jpg, 1_frame.jpg) и масок (0_frame.png, 1_frame.png) уникальны. Эту проверку прогнал отдельно; полный набор проекта здесь не запускал.
Нашёл ещё один пограничный случай в показанном _bot_api_request. CallbackQueryHandler обычно вызывает await query.answer(); в PTB 20.7 это запрос answerCallbackQuery, а его нет в списке поддержанных методов — обработчик попадёт в ветку not implemented. В браузерном режиме это можно подтвердить без побочного эффекта:
if action == "answercallbackquery":
return ok(True)
Я бы добавил тест: callback-handler вызывает query.answer(), затем редактирует сообщение; проверяем, что transport не падает и edit доходит до сессии. PTB отдельно требует отвечать на callback даже без текста, иначе клиент оставляет индикатор загрузки: https://docs.python-telegram-bot.org/en/v20.7/telegram.callbackquery.html
Пока смотрел LongCallbackData, проверил Unicode-префикс. У Telegram лимит callback_data считается в байтах. При prefix=“🐍” * 10 длинный payload переходит на короткий ID, но _UniqueIDCallbackData.build() считает префикс через len(str) и генерирует ID длиннее 64 байт; затем падает pack(). Воспроизвёл до патча.
В расчёте заменил длины строк на длины UTF-8. Тест проверяет лимит и восстановление payload через фильтр. По callback-наборам прошло 14 тестов; шесть параметров с Redis пропустил, так как локальный Redis недоступен.
Да, согласен: библиотека не должна угадывать ACL по имени поля. Я имел в виду пример с owner_id и явным сравнением с query.from_user.id в хэндлере — так назначение ID видно, а проверка остаётся на стороне приложения. Спасибо, уточнил.
Нашёл конкретную поломку из списка «сырого»: MarkdownHeaderTextSplitter запускается для каждой страницы отдельно, поэтому на продолжении параграфа заголовка уже нет и фильтр по нему отбрасывает фрагмент.
В статье нет ссылки на репозиторий, поэтому проверил только этот фрагментный фикс, не весь бот. Заголовки можно переносить между страницами так:
HEADER_LEVELS = ("Глава", "Параграф", "Подраздел")
def carry_header_metadata(chunks, active=None):
current = dict(active or {})
for chunk in chunks:
metadata = chunk.metadata
if "Глава" in metadata:
current = {"Глава": metadata["Глава"]}
if "Параграф" in metadata:
current["Параграф"] = metadata["Параграф"]
current.pop("Подраздел", None)
if "Подраздел" in metadata:
current["Подраздел"] = metadata["Подраздел"]
for key in HEADER_LEVELS:
if key not in metadata and key in current:
metadata[key] = current[key]
return current
active_headers = {}
for page_dict in pages_data:
page_text = page_dict["text"]
page_num = page_dict["metadata"].get("page", 0) + 1
md_chunks = markdown_splitter.split_text(page_text)
active_headers = carry_header_metadata(md_chunks, active_headers)
safe_chunks = text_splitter.split_documents(md_chunks)
for chunk in safe_chunks:
chunk.metadata["Страница"] = page_num
chunk.metadata["Класс"] = grade
all_safe_chunks.append(chunk)
Три теста на продолжение страницы, смену главы и смену параграфа прошли (3/3):
Попробовал JSON-импорт на текущем коммите aa2e733. Нашёл небольшой сбой на грязных данных: одна запись с rating: 1e400 прерывает загрузку всего файла, включая следующий нормальный отзыв. Число при разборе JSON становится inf, а parse_int падает на round(value) с OverflowError. Для NaN, который Python тоже принимает при чтении JSON, получается ValueError.
Проверил поправку через math.isfinite: неконечное значение рейтинга превращается в None, появляется существующее предупреждение, импорт продолжается. Тесты проверяют и проблемную запись, и следующий отзыв с оценкой 5. До патча три случая падали; после проходят все 46 тестов импорта вместе с новыми, Python 3.12. Линтер тоже прошёл. Аналитический pipeline и модель не запускал.
Патч:
diff --git a/src/reviewscope/ingestion/normalize.py b/src/reviewscope/ingestion/normalize.py
index edd51a9..6d1f6ae 100644
--- a/src/reviewscope/ingestion/normalize.py
+++ b/src/reviewscope/ingestion/normalize.py
@@ -17,6 +17,7 @@ rejected and counted as skipped.
from __future__ import annotations
import datetime as _dt
+import math
import re
from typing import Any
@@ -121,6 +122,8 @@ def parse_int(value: Any) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, float):
+ if not math.isfinite(value):
+ return None
return int(round(value))
text = str(value).strip()
if not text:
Тесты — сохранить как tests/test_nonfinite_rating.py:
import pytest
from reviewscope.ingestion.json_adapter import JSONAdapter
@pytest.mark.parametrize("rating", ["1e400", "-1e400", "NaN"])
def test_invalid_numeric_rating_does_not_abort_json_import(tmp_path, rating):
path = tmp_path / "reviews.json"
path.write_text(
'[{"review_id":"bad","place_id":"p","reviewer_id":"u",'
f'"rating":{rating},"text":"bad rating"}},'
'{"review_id":"good","place_id":"p","reviewer_id":"u2",'
'"rating":5,"text":"normal review"}]',
encoding="utf-8",
)
result = JSONAdapter().load(path)
assert [review.review_id for review in result.reviews] == ["bad", "good"]
assert result.reviews[0].rating is None
assert result.reviews[1].rating == 5
assert result.report.valid == 2
assert result.report.warning_count == 1
assert "rating" in result.report.warnings[0]
Из корня проекта, в отдельном виртуальном окружении:
По проверке владельца кнопки согласен: это задача хэндлера, универсально угадывать её по имени поля не стоит.
Забрал актуальную 1.2.3 (коммит b8cfa85 от 10 октября) и нашёл отдельный случай в DI. Если хэндлер падает, зависимость с yield не получает его исключение: AsyncExitStackTransaction вызывает aclose(), то есть закрывает стек как при успехе. В тесте ветка except у зависимости не выполнялась, вместо неё выполнялась else. Для зависимости, которая выбирает commit или rollback по результату обработки, это существенно.
Проверил замену aclose() на aexit(*args), чтобы передать исходное исключение. Два новых теста на REQUEST и TRANSIENT до патча падали, после него все 33 теста DI проходят на Python 3.12; ruff check и format --check тоже проходят. Redis и Telegram для этих тестов не нужны. Полный набор проекта с Redis не запускал.
Спасибо за статью и за просьбу присылать воспроизводимые случаи. Проверил описанный TODO: в исходной версии принималась программа, которая читает байт стека без предшествующей записи (LDX(BPF_B, r0, r10, -1); EXIT).
Подготовил исправление и открыл PR #1: побайтовый учёт инициализации стека, пересечение состояний на слиянии веток и проверки чтений через инструкции и helper-функции с контрактом указатель+длина. Добавил регрессионные тесты для частичной записи и ветвлений.
test_vm: 59/59 под ASan/UBSan. Полныйmake testв этом окружении завершить не удалось: установленный Clang не поддерживает BPF target, поэтому примеры eBPF не собрались.А таймаут останавливает команду на сервере или только ожидание ответа в боте? Например, установка пакета не уложилась в лимит: пользователю важно понять, она ещё идёт или уже завершена. Я бы в таком случае показывал «результат пока неизвестен» и давал проверить состояние операции, прежде чем запускать её повторно.
А как тематический поиск обрабатывает русские словоформы? Например, найдёт ли запрос «разработка бота» пост со словами «разработка ботов»? Для новостей это полезно проверить отдельно: пустая выдача ещё не означает, что тема в архиве отсутствует. Было бы интересно увидеть пару таких запросов и понять, хватает ли FTS5 или перед поиском есть нормализация.
Тут остаётся случай, когда крон вообще не запустил скрипт: внутренняя проверка файла тоже не выполнится, и Telegram снова промолчит. Я бы проверял свежесть результата отдельным заданием, а для критичного процесса — с другой машины. Тогда тревога приходит и при падении шага, и при отсутствии самого запуска.
А как устроен откат после правок уже работающего бота? Например, агент поменял схему SQLite, новая версия не запустилась, а пользователи до этого оставляли заявки. Вернуть старый код тут может быть недостаточно. Было бы полезно показать второй пример: обновление бота с данными, резервная копия базы и восстановление после неудачного деплоя.
В реконструкции таймер запускается от первого сообщения в пачке. Тогда на последнюю реплику ответ может прийти почти сразу, если она отправлена в конце этих 270 секунд. Интересно было бы сравнить интервалы по экспорту чата с точностью до секунд: это помогло бы отличить такую очередь от задержки после каждого сообщения. По минутам на скринах обе версии выглядят похоже.
Привязка разрешения к конкретному действию — хорошая деталь. А у ожидания ответа есть срок жизни? Если запрос остался в чате, а задача уже остановлена или продолжена новым процессом, я бы делал старую кнопку явно неактивной и показывал причину. Иначе с телефона трудно понять, агент ещё ждёт решения или карточка уже устарела.
А у consult_colleague есть общий лимит на число консультаций в рамках одного ответа? Если коллега тоже может вызвать этот инструмент, возможна цепочка А → Б → А. Я бы ограничивал всю цепочку одним бюджетом и передавал список уже вызванных персонажей, чтобы повторные обращения не съедали время и токены без нового результата.
У strip_ai_markers замена идёт по всему тексту, включая ссылки и код. Если в пути URL есть «–», после замены на «-» это уже другой адрес. Я бы исключил такие фрагменты из фильтра и добавил тест на сохранение ссылок: убрать тире можно успешно, а полезную ссылку при этом сломать.
Копия состояния для PDF — хорошая деталь: новое предложение не испортит уже заказанный документ. А на запуск Chrome есть таймаут с завершением процесса? При одном воркере зависший рендер остановит всю очередь, хотя сам бот продолжит отвечать. Я бы добавил этот случай в раздел про эксплуатацию.
А сообщения из супергруппы в MAX пересылаются только от владельца привязанного аккаунта? Если он случайно добавит ещё одного участника, одного ID группы для проверки доступа уже мало. Интересно, проверяете ли отправителя и для обычных сообщений, и для правок с реакциями.
С удалением черновика до отправки есть ещё один сценарий: процесс упал между удалением файла и запросом к площадке. Как такой пост восстанавливается? Я бы сохранял черновик со статусом «отправляется», а при таймауте отмечал результат как неизвестный — чтобы можно было проверить публикацию и решить, нужен ли повтор.
А если один пользователь откроет два веб-диалога, у них будет общий lock или отдельный на каждую PTBSession? user_id ведь остаётся прежним. Если состояние бота привязано к нему, я бы сериализовал апдейты по user_id, иначе две вкладки могут одновременно менять один сценарий.
Нашёл крайний случай в COCO Panoptic export. Если изображения пришли из разных каталогов, но называются одинаково (например, road_a/frame.jpg и road_b/frame.jpg), экспорт складывает их в плоские папки и использует только basename/stem. При copy_images=True один файл перезапишет другой; panoptic PNG тоже перезапишется, а в JSON останутся две записи с одинаковыми именами.
Минимальный патч в цикле по img_records:
Проверка для пары путей с одинаковым basename: после такого префикса имена изображений (0_frame.jpg, 1_frame.jpg) и масок (0_frame.png, 1_frame.png) уникальны. Эту проверку прогнал отдельно; полный набор проекта здесь не запускал.
Нашёл ещё один пограничный случай в показанном _bot_api_request. CallbackQueryHandler обычно вызывает await query.answer(); в PTB 20.7 это запрос answerCallbackQuery, а его нет в списке поддержанных методов — обработчик попадёт в ветку not implemented. В браузерном режиме это можно подтвердить без побочного эффекта:
Я бы добавил тест: callback-handler вызывает query.answer(), затем редактирует сообщение; проверяем, что transport не падает и edit доходит до сессии. PTB отдельно требует отвечать на callback даже без текста, иначе клиент оставляет индикатор загрузки: https://docs.python-telegram-bot.org/en/v20.7/telegram.callbackquery.html
Пока смотрел LongCallbackData, проверил Unicode-префикс. У Telegram лимит callback_data считается в байтах. При prefix=“🐍” * 10 длинный payload переходит на короткий ID, но _UniqueIDCallbackData.build() считает префикс через len(str) и генерирует ID длиннее 64 байт; затем падает pack(). Воспроизвёл до патча.
В расчёте заменил длины строк на длины UTF-8. Тест проверяет лимит и восстановление payload через фильтр. По callback-наборам прошло 14 тестов; шесть параметров с Redis пропустил, так как локальный Redis недоступен.
Полный diff:
Да, согласен: библиотека не должна угадывать ACL по имени поля. Я имел в виду пример с
owner_idи явным сравнением сquery.from_user.idв хэндлере — так назначение ID видно, а проверка остаётся на стороне приложения. Спасибо, уточнил.Нашёл конкретную поломку из списка «сырого»: MarkdownHeaderTextSplitter запускается для каждой страницы отдельно, поэтому на продолжении параграфа заголовка уже нет и фильтр по нему отбрасывает фрагмент.
В статье нет ссылки на репозиторий, поэтому проверил только этот фрагментный фикс, не весь бот. Заголовки можно переносить между страницами так:
Три теста на продолжение страницы, смену главы и смену параграфа прошли (3/3):
Попробовал JSON-импорт на текущем коммите aa2e733. Нашёл небольшой сбой на грязных данных: одна запись с rating: 1e400 прерывает загрузку всего файла, включая следующий нормальный отзыв. Число при разборе JSON становится inf, а parse_int падает на round(value) с OverflowError. Для NaN, который Python тоже принимает при чтении JSON, получается ValueError.
Проверил поправку через math.isfinite: неконечное значение рейтинга превращается в None, появляется существующее предупреждение, импорт продолжается. Тесты проверяют и проблемную запись, и следующий отзыв с оценкой 5. До патча три случая падали; после проходят все 46 тестов импорта вместе с новыми, Python 3.12. Линтер тоже прошёл. Аналитический pipeline и модель не запускал.
Патч:
Тесты — сохранить как tests/test_nonfinite_rating.py:
Из корня проекта, в отдельном виртуальном окружении:
По проверке владельца кнопки согласен: это задача хэндлера, универсально угадывать её по имени поля не стоит.
Забрал актуальную 1.2.3 (коммит b8cfa85 от 10 октября) и нашёл отдельный случай в DI. Если хэндлер падает, зависимость с yield не получает его исключение: AsyncExitStackTransaction вызывает aclose(), то есть закрывает стек как при успехе. В тесте ветка except у зависимости не выполнялась, вместо неё выполнялась else. Для зависимости, которая выбирает commit или rollback по результату обработки, это существенно.
Проверил замену aclose() на aexit(*args), чтобы передать исходное исключение. Два новых теста на REQUEST и TRANSIENT до патча падали, после него все 33 теста DI проходят на Python 3.12; ruff check и format --check тоже проходят. Redis и Telegram для этих тестов не нужны. Полный набор проекта с Redis не запускал.
Патч:
Тесты — сохранить в tests/depend/test_stack_exception.py:
Из корня репозитория, в отдельном виртуальном окружении: