Оптимизация Dispatcher#88
Merged
love-apples merged 4 commits intolove-apples:mainfrom Apr 6, 2026
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
Pull request overview
PR выполняет глубокую оптимизацию Dispatcher и enrich_event для снижения аллокаций/GC в hot path, ускорения поиска хендлеров и улучшения надёжности polling (в т.ч. фоновые задачи и graceful shutdown), с расширением тестового покрытия и настройкой линтерных правил.
Changes:
- Рефакторинг
Dispatcher: кеширование middleware-цепочек и обхода роутеров, индексhandlers_by_type, LRU-кеш контекстов, декомпозиция polling на_fetch_updates_once/_dispatch_fetched_events, трекинг фоновых задач. - Рефакторинг
utils/updates.enrich_eventна 3 хелпера (_resolve_chat/_resolve_from_user/_inject_bot) + расширение охвата событий. - Обновление/расширение тестов (включая aresponses) и конфигурации (ruff per-file ignores/правила).
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
maxapi/dispatcher.py |
Основные оптимизации диспетчера, кеши, LRU-контексты, новый polling-пайплайн, фоновые задачи. |
maxapi/utils/updates.py |
Декомпозиция enrich_event на отдельные шаги разрешения chat/from_user/bot. |
maxapi/filters/handler.py |
Добавлены поля для кеширования func_args и middleware-цепочки у Handler. |
maxapi/filters/__init__.py |
Экспорт filter_attrs и его реализация. |
maxapi/bot.py |
Добавлен setter для me (обход прямого доступа к _me). |
tests/test_dispatcher.py |
Существенное расширение тестов для новых веток/кешей/polling. |
tests/test_enrich_event.py |
Перестроение тестов под новые хелперы и расширенные сценарии. |
tests/test_integration.py |
Обновление проверки check_me() на bot.me. |
pyproject.toml |
Добавлен aresponses в dev-зависимости и ужесточены/упрощены правила ruff (убраны отдельные игноры). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Owner
|
🤯 |
love-apples
approved these changes
Apr 6, 2026
Collaborator
Author
Сам в шоке :))) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ниже идёт очень жирный отчёт, но я настоятельно рекомендую его прочитать. И в образовательных целях, и в целях упрощения проведения review
.
.
.
.
🔍 Точки оптимизации
1. ✅
handle()— двухуровневое вложение замыканий +nonlocalБыло:
Проблемы:
_process_handlers⊂_process_event⊂handle— три аллокации объектов замыканий на каждое событие.nonlocalна двух уровнях скрывает мутациюis_handled: читатель вынужден трассировать изменение переменной через вложенные функции.return— нарушение принципа наименьшего удивления._process_handlersзахватываетmatching_handlers,current_state,memory_context,router_id,process_infoиз внешней области — любая опечатка может привести к захвату не той переменной.Чем чревато отсутствие: При высоком RPS аллокации замыканий создают нагрузку на GC. При поддержке кода — риск ошибок из-за скрытых
nonlocal-мутаций: добавлениеbreakили раннегоreturnв цикле может сломать логику, которая работает через побочный эффект.Решение:
_process_eventи_process_handlersвыделены в полноценные методы (_process_event,_invoke_router_handlers,_run_router_handlers). Данные передаются черезdata-словарь с ключами_memory_context,_current_state,_process_info,_router_id,_is_handled.2. ✅
_dispatch_to_router()— мутируемыйlist[bool]вместо возвращаемого значенияБыло:
_process_handlers(замыкание из п. 1) сигнализировала об успехе через мутациюnonlocal is_handled. Никакого явногоreturn— результат читался из захваченной переменной внешней функции.Проблемы:
return— из сигнатуры нельзя понять, что функция возвращает результат обработки.Чем чревато отсутствие: Любой рефакторинг (например, добавление
await asyncio.sleep(0)внутри цикла) может нарушить последовательность мутацииnonlocal-переменной — одно событие будет обработано несколькими обработчиками, несмотря на задуманную логику «первый match останавливает цикл».Решение: выделен метод
_invoke_router_handlers(self, event, data, *, ...). Результат записывается вdata["_handled"] = Trueи извлекается черезdata.pop("_handled", False). Параметры передаются черезfunctools.partialс keyword-only args.3. ✅
build_middleware_chain— пересборка цепочки на каждое событиеБыло:
Проблема: три пересборки через
functools.partialна каждое событие. Middleware-списки стабильны после__ready()— пересборка бессмысленна.Чем чревато отсутствие: При M middleware × N роутерах каждое событие создаёт
M × Nобъектовfunctools.partial. При 10 роутерах × 3 middleware = 30 аллокаций на событие только на middleware-цепочки. Под нагрузкой это заметно в профилировщике как «hot path alloc».Решение:
self._global_mw_chainпри__ready().handler.mw_chainпри_prepare_handlers()._execute_handlerиспользуетhandler.mw_chainс fallback на пересборку.build_middleware_chainстал@staticmethod— не читает состояние экземпляра.4. ✅
_execute_handler+call_handler— двойное вычислениеkwargs_filteredБыло:
Плюс принудительная инъекция
contextдаже если обработчик его не объявляет:Чем чревато отсутствие: Если обработчик не принимает
context, он всё равно получал его в kwargs — молчаливое загрязнение интерфейса. При добавлении новых внутренних ключей вdata(например,_memory_context) риск «протечки» служебных ключей в аргументы пользовательского хендлера возрастает.Решение:
call_handlerупрощён — принимает уже отфильтрованные kwargs и передаёт их напрямую. Принудительная инъекцияcontextудалена — он попадает в хендлер только если явно объявлен в аннотациях.5. ✅
_check_handler_match— дублирование логики фильтрацииБыло:
Чем чревато отсутствие: При необходимости добавить новый тип фильтра или изменить порядок проверок — изменение нужно вносить в два места. История показывает: такие «двойники» расходятся — в одном месте баг исправляется, в другом нет.
Решение:
_check_handler_matchсначала проверяетstates(быстрый guard безawait), затем делегирует ВСЮ фильтрацию в_check_router_filters— единственный источник правды дляMagicFilter + BaseFilter.6. ✅
_iter_and_dispatch_routers—enumerate()дляrouter_idБыло:
Проблема:
index— позиция в плоском порядке обхода при конкретном вызове. При вложенных роутерах он не отражает структуру дерева, меняется при добавлении/удалении роутеров и непригоден для сопоставления с именами в логах.Чем чревато отсутствие: В логах вместо
router_id: payment_routerбудетrouter_id: 3— при отладке сложных деревьев роутеров бесполезно.Решение:
enumerateубран. Fallback:router.router_id or id(router)—id()гарантированно уникален в рамках процесса и однозначно идентифицирует объект.7. ✅
self.contexts— unbounded рост словаря (утечка памяти)Было:
Чем чревато отсутствие: В боте с тысячами пользователей словарь растёт линейно и никогда не очищается. При
MemoryContextэто пустые объекты состояния для давно ушедших пользователей — они занимают память, которая не освобождается без перезапуска. При 100 000 уникальных пользователях — реальная утечка памяти.Решение:
self.contextsзаменён наOrderedDictс LRU-логикой (stdlib, без внешних зависимостей):move_to_end(key)— помечает ключ как «недавно использованный».len >= CONTEXTS_MAX_SIZE (10_000)—popitem(last=False)вытесняет самый давний.8. ✅
process_base_filters— неоднозначный return typeБыло:
Проблема: три варианта типа усложняли все вызывающие места:
Пустой
{}— это falsy, поэтомуif not resultдля «успешно прошедшего» фильтра с пустым результатом ошибочно квалифицировал бы его как «не прошёл».Чем чревато отсутствие: Потенциально сложно воспроизводимые ошибки при написании пользовательских фильтров, возвращающих пустой dict — логически «прошёл», а диспетчер считает «не прошёл».
Решение: возвращает
dict[str, Any] | None.None= не прошёл,dict(в том числе пустой) = прошёл. Все вызывающие места проверяютif result is None. Тестtest_process_base_filters_falseобновлён:assert result is None.9. ✅
_prepare_handlers— отсутствие индексов и кешей при стартеБыло:
Метод только считал обработчики — никакие структуры для ускорения поиска не строились.
Чем чревато отсутствие: Три последствия, проявляющиеся на каждом событии:
update_type— O(n) переборevent_handlers.handler.func_event.__annotations__читается при каждом вызове хендлера.Решение:
_prepare_handlersстроит четыре кеша за один проход:10. ✅
_find_matching_handlers— линейный поиск O(n) → словарный индекс O(1)Было:
Чем чревато отсутствие: При роутере с 50 обработчиками и 10 роутерах — 500 сравнений строк на каждое входящее событие только для поиска подходящих хендлеров. С индексом — 10 dict-lookup'ов. Разница растёт линейно с ростом числа обработчиков.
Решение:
_prepare_handlersстроитrouter.handlers_by_type: dict[UpdateType, list[Handler]]. Метод использует индекс с fallback для случаев когда_prepare_handlersещё не вызван:11. ✅
_cached_router_entries— рекурсивный обход дерева на каждое событиеБыло: на каждое событие вызывался
self._iter_unique_routers(self.routers)— полный рекурсивный обход дерева роутеров с накоплением middleware и фильтров каждого уровня.Чем чревато отсутствие: При дереве из 20 роутеров каждое событие выполняет рекурсивный обход. Дерево стабильно после
__ready()— повторный обход является чистым расточительством. При активном polling'е (1000 событий/сек × 20 роутеров) это 20 000 итераций генератора в секунду вхолостую.Решение: в конце
_prepare_handlers:Все методы (
_iter_and_dispatch_routers,handle_raw_response) используют кеш:12. ✅
check_me()— двойной вызов_ensure_bot()и доступ к._meБыло:
Проблемы:
_ensure_bot()вызывается дважды — метод проверяетself.bot is not Noneи бросает исключение иначе. Два вызова — две потенциальные точки отказа там, где должна быть одна.._me— обращение к приватному атрибуту нарушает инкапсуляцию и хрупко при рефакторинге имён классаBot.Чем чревато отсутствие: Если
_ensure_bot()когда-либо приобретёт побочные эффекты (например, логирование предупреждения при повторном вызове), двойной вызов сломает ожидаемое поведение.Решение:
13. ✅
start_polling— монолитный метод (60+ строк, два try/except)Было:
start_pollingсодержал всё: запросget_updates, два блокаtry/exceptна разные классы ошибок, обход событий, логикуskip_updates, вызовhandle— более 60 строк в одном методе.Чем чревато отсутствие: Монолитный метод не поддаётся изолированному тестированию. Нельзя проверить: «что происходит при
InvalidToken», «корректно ли обрабатываетсяMaxConnection», «правильно ли работаетskip_updates» — без полноценного mock'а всего цикла.Решение: декомпозиция на два приватных метода:
_fetch_updates_once(bot) → dict | None— изолирует всю обработку ошибок сети:AsyncioTimeoutError,MaxConnection,InvalidToken,MaxApiError, общийException. ВозвращаетNoneпри recoverable-ошибках, поднимаетInvalidTokenкак неrecoverable. Полностью тестируем в изоляции._dispatch_fetched_events(events, current_timestamp, *, skip_updates)— изолирует логику обработки: маркер,skip_updates, веткуuse_create_task.start_pollingсократился до 10 строк:14. ✅
asyncio.create_task— утечка ссылок на задачиБыло:
Проблема: создание задачи без сохранения сильной ссылки — задокументированная ловушка asyncio. Цитата из официальной документации:
GC может удалить задачу до её завершения — событие молча потеряется без каких-либо исключений или логов.
Чем чревато отсутствие: В production под нагрузкой события могут исчезать. Особенно вероятно при коротких событиях и активном GC. Воспроизвести такой баг крайне сложно — он недетерминирован.
Решение:
_background_tasks: set[asyncio.Task]хранит сильные ссылки. Callbackdiscardосвобождает ссылку после завершения, не давая set'у расти бесконечно.15. ✅
stop_polling— не дожидался фоновых задачБыло:
Проблема: при
use_create_task=Trueвызовstop_pollingнемедленно возвращал управление. Все задачи, запущенные черезcreate_task, либо продолжали выполняться в фоне, либо отменялись вместе с event loop'ом до завершения.Чем чревато отсутствие: Незавершённые обработчики событий при graceful shutdown — потеря данных, незафиксированные транзакции, неотправленные ответы. Критично для обработчиков платёжной логики или записи в БД.
Решение:
16. ✅
_check_router_filters— усложнённая проверка результатаprocess_base_filtersБыло:
Проблема: лишний
isinstance(result, dict)порождён нестабильным return typeprocess_base_filters(п. 8). После унификации это мертвый код, который замедляет понимание.Решение: после унификации
process_base_filters → dict | Noneлогика сокращается до прямогоreturn:17. ✅
_get_middleware_title— inline-логика определения имени middlewareБыло: в блоке обработки исключений в
handle():Проблема: inline-логика в
except-блоке замедляет понимание основного потока. Та же логика потенциально нужна на других уровнях middleware.Чем чревато отсутствие: При необходимости диагностики имени middleware в другом месте — копирование кода. Либо придётся его трогать в блоке
except, где высок риск внести ошибку.Решение: выделен
@staticmethod _get_middleware_title(chain) -> strс единственной реализацией:18. ✅
Handler.func_args— нестабильный тип, повторное чтение__annotations__Было: в
call_handlerпри каждом вызове:Проблема:
dict_keys— живой view на__annotations__. При использовании декораторов-обёрток (@wraps,@cache)__annotations__может динамически измениться между регистрацией обработчика и его вызовом. Также тип нигде явно не аннотирован, что затрудняет статический анализ.Чем чревато отсутствие: Труднодиагностируемые ошибки при комбинировании декораторов Python с регистраторами хендлеров.
frozensetфиксирует аннотации на момент регистрации — корректная семантика.Решение: в
_prepare_handlers:frozensetобеспечивает O(1) membership test, иммутабельность и явный типfrozenset[str].20. ✅
enrich_event— монолитная функция 80 строк → три изолированных хелпераБыло: единая функция
enrich_event(~80 строк) смешивала три независимые задачи в одном теле:Проблемы:
hasattr(event_object, "chat")guard повторялся 5 раз — следствие того, что верхний блокhasattr(chat_id)уже мог загрузить чат, а нижние ветки не знали об этом. Защитный паттерн размножился по всем веткам.Хрупкий
is_bot_removed_from_channel— проверкаis_channelуBotRemovedдля того, чтобы не делать запрос. При этомBotRemovedникогда не имеет доступногоchat_id— условие было избыточным.UserAddedвыделен в отдельную ветку несмотря на идентичную логику сBotAdded,BotStartedи ещё семью типами (from_user = event.user).DialogRemovedотсутствовал —from_userне проставлялся, молчаливая неполнота.Тип аннотации
AnyвместоUpdateUnion— статический анализатор не видит атрибутов события.Невозможно тестировать изолированно — чтобы проверить только логику загрузки чата для
MessageCallback, нужно прогнать всю функцию целиком.Чем чревато отсутствие: При добавлении нового типа события (например
ChannelCreated) нужно было вставить новую веткуelifвнутрь уже 80-строчной функции, не забыть проif not hasattr(event_object, "chat"):guard и не забыть про инъекциюbot. Вероятность пропустить один из шагов — высокая.Решение: три изолированных хелпера + константа-кортеж:
Каждый хелпер независимо покрыт тестами в
test_enrich_event.py:19. ✅
_iter_routers— shadowing параметров локальными переменнымиБыло:
Проблема: параметры функции переопределялись одноимёнными локальными переменными. В теле функции
parent_middlewares— уже локальная переменная. Любая правка, добавляющая раннийreturnили условную ветку ДО строки переопределения, случайно получитNoneвместо[]. Это класс ошибок, который ruff/pylint обнаруживают какW0621/PLW0621.Чем чревато отсутствие: Достаточно добавить одну строку
if something: return routerперед переопределениями — и рекурсивный вызов получит сырыеNone-значения, что приведёт кAttributeErrorпри конкатенацииNone + list.Решение: введены чистые локальные переменные без shadowing:
📊 Сводная таблица
handle()— двухуровневые замыкания_dispatch_to_router()—nonlocalboolbuild_middleware_chain— без кеша_execute_handler+call_handler_check_*— дублирование фильтрацииenumerateв_iter_and_dispatchself.contexts— unbounded dictprocess_base_filters— return type_prepare_handlers— отсутствие кешей_find_matching_handlers— O(n) поиск_cached_router_entries— рекурсия на evtcheck_me()— двойной_ensure_bot()+._mestart_polling— монолитный методasyncio.create_task— утечка ссылокstop_polling— не ждал фоновые задачи_check_router_filters— лишняя проверка_get_middleware_title— inline в exceptHandler.func_args— нестабильный тип_iter_routers— shadowing параметровenrich_event— монолит → 3 хелпера🧪 Покрытие тестами
После рефакторинга всех изменённых модулей написаны изолированные unit-тесты, покрывающие каждую ветку кода:
dispatcher.py—_fetch_updates_onceAsyncioTimeoutError,MaxConnection,InvalidToken,MaxApiError,Exceptiondispatcher.py—_dispatch_fetched_eventsskip_updates,use_create_task,ClientConnectorErrordispatcher.py—__get_contextCONTEXTS_MAX_SIZEdispatcher.py—process_base_filtersdict-результат, falsy-результат (→ None), пустой dictdispatcher.py—_check_handler_matchdispatcher.py—stop_polling_background_tasksutils/updates.py—_resolve_chatDialogRemoved,BotRemoved, top-levelchat_id, fallback кrecipient.chat_id,message=Noneutils/updates.py—_resolve_from_userMessageCreated,MessageCallback,MessageRemoved(CHAT/DIALOG),UserRemoved(с/безadmin_id), 10 типов из_EVENTS_WITH_USER_ATTRutils/updates.py—_inject_botbody=None,message=Noneutils/updates.py—enrich_eventauto_requests=FalseИтог: 493 теста, все проходят ✅
⚡ Результат
partial/evtfunc_argsвычислениеcontextscreate_task_background_tasksset)asyncio.gatherвсех задач_iter_routersперезаписывал 4nonlocal× 2, nested × 2enrich_eventguardshasattr(chat)× 5 в одной функцииDialogRemovedбезfrom_user