Skip to content

Оптимизация Dispatcher#88

Merged
love-apples merged 4 commits intolove-apples:mainfrom
Olegt0rr:chore/dispatcher-refactor
Apr 6, 2026
Merged

Оптимизация Dispatcher#88
love-apples merged 4 commits intolove-apples:mainfrom
Olegt0rr:chore/dispatcher-refactor

Conversation

@Olegt0rr
Copy link
Copy Markdown
Collaborator

@Olegt0rr Olegt0rr commented Apr 5, 2026

Ниже идёт очень жирный отчёт, но я настоятельно рекомендую его прочитать. И в образовательных целях, и в целях упрощения проведения review

.
.
.
.

🔍 Точки оптимизации

1. ✅ handle() — двухуровневое вложение замыканий + nonlocal

Было:

async def handle(self, event_object):
    is_handled = False

    async def _process_event(_, data):
        nonlocal router_id, is_handled          # ← мутабельный захват

        for index, (router, ...) in enumerate(self._iter_unique_routers(...)):
            ...
            async def _process_handlers(event, handler_data):
                nonlocal is_handled             # ← второй уровень nonlocal
                for handler in matching_handlers:
                    ...
                    is_handled = True
                    break

            if router_middlewares:
                router_chain = self.build_middleware_chain(
                    router_middlewares, _process_handlers   # ← замыкание
                )
                await router_chain(event_object, data)
            else:
                await _process_handlers(event_object, data)

    global_chain = self.build_middleware_chain(self.middlewares, _process_event)
    await global_chain(event_object, kwargs)

Проблемы:

  • Двухуровневое вложение: _process_handlers_process_eventhandle — три аллокации объектов замыканий на каждое событие.
  • 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 — пересборка цепочки на каждое событие

Было:

# В handle — на каждое событие:
global_chain = self.build_middleware_chain(self.middlewares, _process_event)

# В _dispatch_to_router — на каждый роутер × каждое событие:
chain = self.build_middleware_chain(router_middlewares, _process_handlers)

# В _execute_handler — на каждый вызов хендлера:
handler_chain = self.build_middleware_chain(
    handler.middlewares, functools.partial(self.call_handler, handler)
)

Проблема: три пересборки через 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-цепочки кешируются в handler.mw_chain при _prepare_handlers().
  • _execute_handler использует handler.mw_chain с fallback на пересборку.
  • build_middleware_chain стал @staticmethod — не читает состояние экземпляра.
  • Router middleware цепочки строятся per-event (inner handler зависит от параметров события).

4. ✅ _execute_handler + call_handler — двойное вычисление kwargs_filtered

Было:

# В _execute_handler:
func_args = handler.func_event.__annotations__.keys()
kwargs_filtered = {k: v for k, v in data.items() if k in func_args}
# ... chain вызывает call_handler с теми же данными ...

# В call_handler — ТО ЖЕ САМОЕ повторно:
func_args = handler.func_event.__annotations__.keys()
kwargs_filtered = {k: v for k, v in data.items() if k in func_args}
await handler.func_event(event_object, **kwargs_filtered)

Плюс принудительная инъекция context даже если обработчик его не объявляет:

if "context" not in kwargs_filtered and "context" in data:
    kwargs_filtered["context"] = data["context"]

Чем чревато отсутствие: Если обработчик не принимает context, он всё равно получал его в kwargs — молчаливое загрязнение интерфейса. При добавлении новых внутренних ключей в data (например, _memory_context) риск «протечки» служебных ключей в аргументы пользовательского хендлера возрастает.

Решение: call_handler упрощён — принимает уже отфильтрованные kwargs и передаёт их напрямую. Принудительная инъекция context удалена — он попадает в хендлер только если явно объявлен в аннотациях.


5. ✅ _check_handler_match — дублирование логики фильтрации

Было:

# _check_router_filters:
if filters and not filter_attrs(event, *filters):
    return False
if base_filters:
    result = await self.process_base_filters(...)
    if isinstance(result, dict): return result
    if not result: return False
return {}

# _check_handler_match — та же логика ещё раз:
if handler.filters and not filter_attrs(event, *handler.filters):
    return False
if handler.states and current_state not in handler.states:
    return False
if handler.base_filters:
    result = await self.process_base_filters(...)
    if isinstance(result, dict): return result
    if not result: return False
return {}

Чем чревато отсутствие: При необходимости добавить новый тип фильтра или изменить порядок проверок — изменение нужно вносить в два места. История показывает: такие «двойники» расходятся — в одном месте баг исправляется, в другом нет.

Решение: _check_handler_match сначала проверяет states (быстрый guard без await), затем делегирует ВСЮ фильтрацию в _check_router_filters — единственный источник правды для MagicFilter + BaseFilter.


6. ✅ _iter_and_dispatch_routersenumerate() для router_id

Было:

for index, (router, ...) in enumerate(self._iter_unique_routers(...)):
    router_id = router.router_id or index

Проблема: index — позиция в плоском порядке обхода при конкретном вызове. При вложенных роутерах он не отражает структуру дерева, меняется при добавлении/удалении роутеров и непригоден для сопоставления с именами в логах.

Чем чревато отсутствие: В логах вместо router_id: payment_router будет router_id: 3 — при отладке сложных деревьев роутеров бесполезно.

Решение: enumerate убран. Fallback: router.router_id or id(router)id() гарантированно уникален в рамках процесса и однозначно идентифицирует объект.


7. ✅ self.contexts — unbounded рост словаря (утечка памяти)

Было:

self.contexts: dict[tuple[int | None, int | None], BaseContext] = {}

def __get_context(self, chat_id, user_id):
    key = (chat_id, user_id)
    if key in self.contexts:
        return self.contexts[key]
    self.contexts[key] = self.storage(chat_id, user_id, **self.storage_kwargs)
    return self.contexts[key]

Чем чревато отсутствие: В боте с тысячами пользователей словарь растёт линейно и никогда не очищается. При 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

Было:

async def process_base_filters(...) -> dict[str, Any] | None | Literal[False]:
    ...
    elif not result:
        return result   # ← None, False, 0, "" — всё что falsy
    return data

Проблема: три варианта типа усложняли все вызывающие места:

if isinstance(result, dict):
    return result
if not result:
    return False
# Что если result == {} (пустой dict — тоже falsy)?

Пустой {} — это 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 — отсутствие индексов и кешей при старте

Было:

def _prepare_handlers(self, bot: Bot) -> None:
    handlers_count = 0
    for router, *_ in self._iter_unique_routers(self.routers, warn_duplicates=True):
        router.bot = bot
        for handler in router.event_handlers:
            handlers_count += 1
            extract_commands(handler, bot)
    logger_dp.info(...)

Метод только считал обработчики — никакие структуры для ускорения поиска не строились.

Чем чревато отсутствие: Три последствия, проявляющиеся на каждом событии:

  1. Поиск обработчиков по update_type — O(n) перебор event_handlers.
  2. handler.func_event.__annotations__ читается при каждом вызове хендлера.
  3. Дерево роутеров обходится рекурсивно заново.

Решение: _prepare_handlers строит четыре кеша за один проход:

router.handlers_by_type = {}   # dict[UpdateType, list[Handler]] — O(1) поиск
handler.func_args = frozenset(handler.func_event.__annotations__)  # кеш аргументов
handler.mw_chain = build_middleware_chain(...)                     # кеш цепочки
self._cached_router_entries = list(self._iter_unique_routers(...)) # кеш обхода

10. ✅ _find_matching_handlers — линейный поиск O(n) → словарный индекс O(1)

Было:

def _find_matching_handlers(self, router, event_type):
    return [
        handler
        for handler in router.event_handlers
        if handler.update_type == event_type
    ]

Чем чревато отсутствие: При роутере с 50 обработчиками и 10 роутерах — 500 сравнений строк на каждое входящее событие только для поиска подходящих хендлеров. С индексом — 10 dict-lookup'ов. Разница растёт линейно с ростом числа обработчиков.

Решение: _prepare_handlers строит router.handlers_by_type: dict[UpdateType, list[Handler]]. Метод использует индекс с fallback для случаев когда _prepare_handlers ещё не вызван:

@staticmethod
def _find_matching_handlers(router, event_type):
    index = router.handlers_by_type
    if index is not None:
        return index.get(event_type, [])
    return [h for h in router.event_handlers if h.update_type == event_type]

11. ✅ _cached_router_entries — рекурсивный обход дерева на каждое событие

Было: на каждое событие вызывался self._iter_unique_routers(self.routers) — полный рекурсивный обход дерева роутеров с накоплением middleware и фильтров каждого уровня.

Чем чревато отсутствие: При дереве из 20 роутеров каждое событие выполняет рекурсивный обход. Дерево стабильно после __ready() — повторный обход является чистым расточительством. При активном polling'е (1000 событий/сек × 20 роутеров) это 20 000 итераций генератора в секунду вхолостую.

Решение: в конце _prepare_handlers:

self._cached_router_entries = list(self._iter_unique_routers(self.routers))

Все методы (_iter_and_dispatch_routers, handle_raw_response) используют кеш:

entries = self._cached_router_entries or self._iter_unique_routers(self.routers)

12. ✅ check_me() — двойной вызов _ensure_bot() и доступ к ._me

Было:

async def check_me(self):
    me = await self._ensure_bot().get_me()
    self._ensure_bot()._me = me   # ← второй вызов + приватный атрибут
    logger_dp.info(...)

Проблемы:

  • _ensure_bot() вызывается дважды — метод проверяет self.bot is not None и бросает исключение иначе. Два вызова — две потенциальные точки отказа там, где должна быть одна.
  • ._me — обращение к приватному атрибуту нарушает инкапсуляцию и хрупко при рефакторинге имён класса Bot.

Чем чревато отсутствие: Если _ensure_bot() когда-либо приобретёт побочные эффекты (например, логирование предупреждения при повторном вызове), двойной вызов сломает ожидаемое поведение.

Решение:

async def check_me(self):
    bot = self._ensure_bot()   # одно обращение, одна точка отказа
    me = await bot.get_me()
    bot.me = me                # публичный атрибут
    logger_dp.info(...)

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 строк:

while self.polling:
    events = await self._fetch_updates_once(bot)
    if events is None:
        continue
    await self._dispatch_fetched_events(events, current_timestamp, skip_updates=skip_updates)

14. ✅ asyncio.create_task — утечка ссылок на задачи

Было:

if self.use_create_task:
    asyncio.create_task(self.handle(event))

Проблема: создание задачи без сохранения сильной ссылки — задокументированная ловушка asyncio. Цитата из официальной документации:

"Save a reference to the result of this function, to avoid a task disappearing mid-execution. The event loop only keeps weak references to tasks."

GC может удалить задачу до её завершения — событие молча потеряется без каких-либо исключений или логов.

Чем чревато отсутствие: В production под нагрузкой события могут исчезать. Особенно вероятно при коротких событиях и активном GC. Воспроизвести такой баг крайне сложно — он недетерминирован.

Решение:

task = asyncio.create_task(self.handle(event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)

_background_tasks: set[asyncio.Task] хранит сильные ссылки. Callback discard освобождает ссылку после завершения, не давая set'у расти бесконечно.


15. ✅ stop_polling — не дожидался фоновых задач

Было:

async def stop_polling(self):
    if self.polling:
        self.polling = False
        logger_dp.info("Polling остановлен")

Проблема: при use_create_task=True вызов stop_polling немедленно возвращал управление. Все задачи, запущенные через create_task, либо продолжали выполняться в фоне, либо отменялись вместе с event loop'ом до завершения.

Чем чревато отсутствие: Незавершённые обработчики событий при graceful shutdown — потеря данных, незафиксированные транзакции, неотправленные ответы. Критично для обработчиков платёжной логики или записи в БД.

Решение:

async def stop_polling(self):
    if self.polling:
        self.polling = False
        logger_dp.info("Polling остановлен")

    if self._background_tasks:
        logger_dp.info(f"Ожидаю завершения {len(self._background_tasks)} фоновых задач...")
        await asyncio.gather(*self._background_tasks, return_exceptions=True)
        logger_dp.info("Все фоновые задачи завершены")

16. ✅ _check_router_filters — усложнённая проверка результата process_base_filters

Было:

async def _check_router_filters(...) -> dict[str, Any] | Literal[False]:
    if filters and not filter_attrs(event, *filters):
        return False
    if base_filters:
        result = await self.process_base_filters(...)
        if isinstance(result, dict):     # ← необходим из-за нестабильного типа
            return result
        if not result:
            return False
    return {}

Проблема: лишний isinstance(result, dict) порождён нестабильным return type process_base_filters (п. 8). После унификации это мертвый код, который замедляет понимание.

Решение: после унификации process_base_filters → dict | None логика сокращается до прямого return:

async def _check_router_filters(...) -> dict[str, Any] | None:
    if filters and not filter_attrs(event, *filters):
        return None
    if base_filters:
        return await self.process_base_filters(event=event, filters=base_filters)
    return {}

17. ✅ _get_middleware_title — inline-логика определения имени middleware

Было: в блоке обработки исключений в handle():

if hasattr(global_chain, "func"):
    middleware_title = global_chain.func.__class__.__name__
else:
    middleware_title = getattr(global_chain, "__name__", global_chain.__class__.__name__)

Проблема: inline-логика в except-блоке замедляет понимание основного потока. Та же логика потенциально нужна на других уровнях middleware.

Чем чревато отсутствие: При необходимости диагностики имени middleware в другом месте — копирование кода. Либо придётся его трогать в блоке except, где высок риск внести ошибку.

Решение: выделен @staticmethod _get_middleware_title(chain) -> str с единственной реализацией:

@staticmethod
def _get_middleware_title(chain: Any) -> str:
    if hasattr(chain, "func"):
        return str(chain.func.__class__.__name__)
    return str(getattr(chain, "__name__", chain.__class__.__name__))

18. ✅ Handler.func_args — нестабильный тип, повторное чтение __annotations__

Было: в call_handler при каждом вызове:

func_args = handler.func_event.__annotations__.keys()  # ← чтение __annotations__
kwargs_filtered = {k: v for k, v in data.items() if k in func_args}
# ... chain вызывает call_handler с теми же данными ...

# В call_handler — ТО ЖЕ САМОЕ повторно:
func_args = handler.func_event.__annotations__.keys()
kwargs_filtered = {k: v for k, v in data.items() if k in func_args}
await handler.func_event(event_object, **kwargs_filtered)

Проблема: dict_keys — живой view на __annotations__. При использовании декораторов-обёрток (@wraps, @cache) __annotations__ может динамически измениться между регистрацией обработчика и его вызовом. Также тип нигде явно не аннотирован, что затрудняет статический анализ.

Чем чревато отсутствие: Труднодиагностируемые ошибки при комбинировании декораторов Python с регистраторами хендлеров. frozenset фиксирует аннотации на момент регистрации — корректная семантика.

Решение: в _prepare_handlers:

handler.func_args = frozenset(handler.func_event.__annotations__)

frozenset обеспечивает O(1) membership test, иммутабельность и явный тип frozenset[str].


20. ✅ enrich_event — монолитная функция 80 строк → три изолированных хелпера

Было: единая функция enrich_event (~80 строк) смешивала три независимые задачи в одном теле:

async def enrich_event(event_object: Any, bot: Bot) -> Any:
    if not bot.auto_requests:
        return event_object

    # 1. Загрузка chat — но с дублирующим guard'ом
    if hasattr(event_object, "chat_id"):
        is_bot_removed_from_channel = isinstance(event_object, BotRemoved) \
            and getattr(event_object, "is_channel", False)
        if not is_bot_removed_from_channel:
            event_object.chat = await bot.get_chat_by_id(event_object.chat_id)
        else:
            event_object.chat = None

    # 2. from_user — каждый тип события своя ветка, с повторными
    #    "if not hasattr(event_object, 'chat'):" для защиты от двойного вызова
    if isinstance(event_object, (MessageCreated, MessageEdited)):
        if event_object.message.recipient.chat_id is not None:
            if not hasattr(event_object, "chat"):          # ← guard №1
                event_object.chat = await bot.get_chat_by_id(...)
        event_object.from_user = ...

    elif isinstance(event_object, MessageCallback):
        ...
        if not hasattr(event_object, "chat"):              # ← guard №2
            event_object.chat = await bot.get_chat_by_id(...)
        ...

    elif isinstance(event_object, MessageRemoved):
        if not hasattr(event_object, "chat"):              # ← guard №3
            event_object.chat = await bot.get_chat_by_id(...)
        ...

    elif isinstance(event_object, UserRemoved):
        if not hasattr(event_object, "chat"):              # ← guard №4
            event_object.chat = await bot.get_chat_by_id(...)
        ...

    elif isinstance(event_object, UserAdded):              # ← отдельная ветка,
        if not hasattr(event_object, "chat"):              # ←  хотя логика та же
            event_object.chat = await bot.get_chat_by_id(...)
        event_object.from_user = event_object.user

    elif isinstance(event_object, (BotAdded, BotRemoved, BotStarted,
                                    ChatTitleChanged, BotStopped,
                                    DialogCleared, DialogMuted, DialogUnmuted)):
        if not hasattr(event_object, "chat"):              # ← guard №5
            event_object.chat = await bot.get_chat_by_id(...)
        event_object.from_user = event_object.user
    # DialogRemoved — отсутствует! from_user не проставлялся

    # 3. Инъекция bot — в конце того же тела
    if isinstance(event_object, (MessageCreated, MessageEdited, MessageCallback)):
        ...

Проблемы:

  1. hasattr(event_object, "chat") guard повторялся 5 раз — следствие того, что верхний блок hasattr(chat_id) уже мог загрузить чат, а нижние ветки не знали об этом. Защитный паттерн размножился по всем веткам.

  2. Хрупкий is_bot_removed_from_channel — проверка is_channel у BotRemoved для того, чтобы не делать запрос. При этом BotRemoved никогда не имеет доступного chat_id — условие было избыточным.

  3. UserAdded выделен в отдельную ветку несмотря на идентичную логику с BotAdded, BotStarted и ещё семью типами (from_user = event.user).

  4. DialogRemoved отсутствовалfrom_user не проставлялся, молчаливая неполнота.

  5. Тип аннотации Any вместо UpdateUnion — статический анализатор не видит атрибутов события.

  6. Невозможно тестировать изолированно — чтобы проверить только логику загрузки чата для MessageCallback, нужно прогнать всю функцию целиком.

Чем чревато отсутствие: При добавлении нового типа события (например ChannelCreated) нужно было вставить новую ветку elif внутрь уже 80-строчной функции, не забыть про if not hasattr(event_object, "chat"): guard и не забыть про инъекцию bot. Вероятность пропустить один из шагов — высокая.

Решение: три изолированных хелпера + константа-кортеж:

_EVENTS_WITH_USER_ATTR = (
    UserAdded, BotAdded, BotRemoved, BotStarted, BotStopped,
    ChatTitleChanged, DialogCleared, DialogMuted, DialogUnmuted, DialogRemoved,
)

async def _resolve_chat(event, bot):
    """Загружает объект чата. Одна ответственность — только chat."""
    if isinstance(event, (DialogRemoved, BotRemoved)):
        return                          # ← вся логика пропуска — здесь
    chat_id = getattr(event, "chat_id", None)
    if chat_id is None and isinstance(event, (MessageCreated, MessageEdited)):
        chat_id = event.message.recipient.chat_id
    elif chat_id is None and isinstance(event, MessageCallback):
        ...
    if chat_id is not None:
        event.chat = await bot.get_chat_by_id(chat_id)

async def _resolve_from_user(event, bot):
    """Определяет отправителя. Никогда не трогает chat."""
    if isinstance(event, (MessageCreated, MessageEdited)):
        event.from_user = getattr(event.message, "sender", None)
    elif isinstance(event, MessageCallback):
        event.from_user = getattr(event.callback, "user", None)
    elif isinstance(event, MessageRemoved):
        ...
    elif isinstance(event, UserRemoved):
        ...
    elif isinstance(event, _EVENTS_WITH_USER_ATTR):   # ← 10 типов одной строкой
        event.from_user = event.user

def _inject_bot(event, bot):
    """Внедряет ссылку на бота. Никогда не трогает chat или from_user."""
    ...

Каждый хелпер независимо покрыт тестами в test_enrich_event.py:

TestResolveChat      — 7 тестов, все ветки _resolve_chat
TestResolveFromUser  — 10 тестов (включая параметризованный на 10 типов)
TestInjectBot        — 5 тестов (message, event, attachments, body=None, message=None)
TestEnrichEvent      — 7 интеграционных тестов сквозного пайплайна

19. ✅ _iter_routers — shadowing параметров локальными переменными

Было:

def _iter_routers(self, routers, parent_middlewares=None, parent_filters=None, ...):
    parent_middlewares = parent_middlewares or []   # ← параметр переопределяется
    parent_filters = parent_filters or []
    parent_base_filters = parent_base_filters or []
    path = path if path is not None else set()

Проблема: параметры функции переопределялись одноимёнными локальными переменными. В теле функции parent_middlewares — уже локальная переменная. Любая правка, добавляющая ранний return или условную ветку ДО строки переопределения, случайно получит None вместо []. Это класс ошибок, который ruff/pylint обнаруживают как W0621/PLW0621.

Чем чревато отсутствие: Достаточно добавить одну строку if something: return router перед переопределениями — и рекурсивный вызов получит сырые None-значения, что приведёт к AttributeError при конкатенации None + list.

Решение: введены чистые локальные переменные без shadowing:

middlewares = parent_middlewares or []
filters = parent_filters or []
base_filters = parent_base_filters or []

if path is None:
    path = set()

📊 Сводная таблица

# Точка Уровень Тип Статус
1 handle() — двухуровневые замыкания 🔴 Выс. Читаемость + GC
2 _dispatch_to_router()nonlocal bool 🔴 Выс. Читаемость + корректность
3 build_middleware_chain — без кеша 🟡 Сред. Перформанс
4 _execute_handler + call_handler 🟡 Сред. Дублирование
5 _check_* — дублирование фильтрации 🟡 Сред. Дублирование
6 enumerate в _iter_and_dispatch 🟢 Низк. Читаемость
7 self.contexts — unbounded dict 🟡 Сред. Память
8 process_base_filters — return type 🟡 Сред. API контракт
9 _prepare_handlers — отсутствие кешей 🔴 Выс. Перформанс
10 _find_matching_handlers — O(n) поиск 🔴 Выс. Перформанс
11 _cached_router_entries — рекурсия на evt 🟡 Сред. Перформанс
12 check_me() — двойной _ensure_bot() + ._me 🟢 Низк. Читаемость + надёжность
13 start_polling — монолитный метод 🟡 Сред. Тестируемость
14 asyncio.create_task — утечка ссылок 🔴 Выс. Корректность
15 stop_polling — не ждал фоновые задачи 🔴 Выс. Корректность
16 _check_router_filters — лишняя проверка 🟢 Низк. Упрощение
17 _get_middleware_title — inline в except 🟢 Низк. Читаемость
18 Handler.func_args — нестабильный тип 🟢 Низк. Надёжность
19 _iter_routers — shadowing параметров 🟢 Низк. Читаемость
20 enrich_event — монолит → 3 хелпера 🟡 Сред. Тестируемость + надёжность

🧪 Покрытие тестами

После рефакторинга всех изменённых модулей написаны изолированные unit-тесты, покрывающие каждую ветку кода:

Модуль / область Что покрыто
dispatcher.py_fetch_updates_once Все 5 классов исключений: AsyncioTimeoutError, MaxConnection, InvalidToken, MaxApiError, Exception
dispatcher.py_dispatch_fetched_events skip_updates, use_create_task, ClientConnectorError
dispatcher.py__get_context LRU-вытеснение при достижении CONTEXTS_MAX_SIZE
dispatcher.pyprocess_base_filters dict-результат, falsy-результат (→ None), пустой dict
dispatcher.py_check_handler_match states guard, MagicFilter pass/fail, BaseFilter pass/fail
dispatcher.pystop_polling Ожидание фоновых задач, пустой _background_tasks
utils/updates.py_resolve_chat Все ветки: DialogRemoved, BotRemoved, top-level chat_id, fallback к recipient.chat_id, message=None
utils/updates.py_resolve_from_user Все типы: MessageCreated, MessageCallback, MessageRemoved (CHAT/DIALOG), UserRemoved (с/без admin_id), 10 типов из _EVENTS_WITH_USER_ATTR
utils/updates.py_inject_bot message, event, attachments, body=None, message=None
utils/updates.pyenrich_event Сквозной пайплайн для 12 типов событий + auto_requests=False

Итог: 493 теста, все проходят ✅


⚡ Результат

Метрика До После
Уровней вложения замыканий 2 (handle → _process → _handlers) 0 (плоские методы)
Аллокаций на событие 3+ замыкания 0 замыканий
Построений mw-цепочки 3 × partial/evt 1 (router) + кеш
Поиск хендлеров O(n) linear scan O(1) dict lookup
Обход дерева роутеров Рекурсия на каждое событие Кеш (1 раз при __ready)
func_args вычисление 2× при каждом вызове хендлера 1× при регистрации (frozenset)
Рост contexts Unbounded LRU (10K max)
Утечка create_task ДА (нет сильных ссылок) Нет (_background_tasks set)
Graceful shutdown Не ждал задачи asyncio.gather всех задач
Shadowing параметров _iter_routers перезаписывал 4 Чистые локальные переменные
Когнитивная сложность nonlocal × 2, nested × 2 Плоские методы, явные return
enrich_event guards hasattr(chat) × 5 в одной функции Устранены декомпозицией
Пропущенные события DialogRemoved без from_user Все типы покрыты
Тесты 493 passed ✅ (100% веток)

Copilot AI review requested due to automatic review settings April 5, 2026 22:34
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/test_integration.py Outdated
Comment thread maxapi/dispatcher.py
Comment thread maxapi/dispatcher.py Outdated
Comment thread maxapi/dispatcher.py
Comment thread maxapi/dispatcher.py Outdated
Comment thread maxapi/utils/updates.py
@love-apples
Copy link
Copy Markdown
Owner

🤯

@love-apples love-apples merged commit be9ca88 into love-apples:main Apr 6, 2026
13 checks passed
@Olegt0rr
Copy link
Copy Markdown
Collaborator Author

Olegt0rr commented Apr 6, 2026

🤯

Сам в шоке :)))

@Olegt0rr Olegt0rr deleted the chore/dispatcher-refactor branch April 6, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants