Skip to content

perf(mgr-api): вернуть LIMIT/OFFSET в листинг товаров категории (#473) - #910

Merged
biz87 merged 1 commit into
betafrom
fix/473-category-products-pagination
Oct 15, 2026
Merged

biz87 merged 1 commit into
betafrom
fix/473-category-products-pagination

Conversation

@biz87

@biz87 biz87 commented Oct 15, 2026

Copy link
Copy Markdown
Member

Листинг товаров внутри категории в админке долго думает при пагинации. Нашлось при разборе наблюдения пользователя.

Что происходит

Листинг был обычной постраничной выборкой, пока 9f091d1e (#473, «document-level ACL для category products») не добавил проверку checkPolicy('view') на каждом товаре. Проверять политику можно только по загруженному объекту, поэтому прямой вызов getPage() заменили сканированием:

метод что делает
collectVisibleListPage() сканирует от начала батчами max(limit*2, 20) и пропускает start записей в PHP
countVisibleListResults() проходит всю категорию батчами по 200, грузит полные строки с grid-полями — ради одного числа total

Было до #473:

$page = $listService->getPage($categoryId, $params, $nested, $gridFields, $start, $limit, $sortBy, $sortDir);
return Response::success(['results' => $page['results'], 'total' => $page['total']]);

Замер на категории с 2114 товарами:

страница      запросов  время
start=0         18       953 мс
start=200       18       897 мс
start=400       23      1010 мс
start=800       33      1350 мс

Базовые ~950 мс не зависят от страницы — это подсчёт total полным проходом. Сверху линейная доплата за дальность. На 10 000 товаров выйдет 4–5 секунд.

Важно: сам limit в SQL не сломан, Vue получает ровно 20 строк. Сломана схема запросов вокруг него. И сервис всё умеет правильно — COUNT(DISTINCT msProduct.id) одним запросом плюс limit($limit, $start) (CategoryProductsListService.php:58-67); контроллер просто перестал этим пользоваться.

Решение

Быстрый путь, когда построчная проверка заведомо никого не отбрасывает.

Видимость ресурса ограничивает только группа ресурсов с записью доступа: modResource::findPolicy() собирает политику джойном modAccessResourceGroup + modResourceGroupResource и отдаёт пустой набор, если настройка контекста выключена. Отсюда условие — нет ни одной записи modAccessResourceGroup, либо выключена access_resource_group_enabled, либо пользователь sudo. Тогда работает ровно тот код, что был до #473.

Если группы ресурсов настроены — остаётся прежнее сканирование: права важнее скорости.

После правки, та же категория

nested=false   1 запрос,  24–31 мс  на любой странице, включая start=2000
nested=true   22 запроса, 32 мс     (22 — обход дерева категорий, он был и раньше)

Время больше не растёт с номером страницы.

Эквивалентность проверена: total быстрого пути совпадает с прежним сканирующим подсчётом в обоих режимах — 2114 против 2114. То есть на этих данных фильтр и правда ничего не отбрасывал.

Обратное тоже проверено: создал на стенде группу ресурсов с записью доступа — условие переключилось на false, после уборки вернулось в true.

Про тесты

Фильтрация листинга по ACL не покрыта тестами — ни collectVisibleListPage, ни countVisibleListResults, ни filterListResultsByDocumentView в тестах не упоминаются. Так что зелёные гейты сами по себе сохранность прав здесь не доказывали, отсюда живая проверка выше и новый тест на само условие переключения: ошибка в нём либо вернёт медленный листинг, либо, что хуже, обойдёт проверку прав.

tests/Unit/Controllers/CategoryProductsListFastPathTest.php — четыре случая: нет записей ACL, есть записи (1 и 25), выключенная настройка контекста, и memoisation в пределах запроса.

Стабу modX добавлены getCount() и optionOverrides. Подпись getCount() намеренно без типов — существующие тесты переопределяют метод в анонимных подклассах со своей, более слабой (CustomerGroupsControllerAclCacheTest), строгая подпись их ломала.

Проверка остальных листингов

По просьбе пользователя посмотрел, нет ли того же в других местах. Паттерн сканирования уникален для этого контроллера:

листинг как считает
заказы в админке (ManagerOrderListService) COUNT(DISTINCT msOrder.id) + limit($limit, $start)
заказы в кабинете (OrdersPageService) getCount() + limit($limit, $offset)
публичный каталог товаров и категорий COUNT + limit

count($modx->getCollection(…)) вместо getCount() в src/ не встречается.

Гейты локально: PHPUnit 834/834, smoke 124/124, PHPStan по изменённому файлу чисто.

Листинг был обычной постраничной выборкой, пока #473 не добавил ACL уровня
документа. Проверять checkPolicy('view') можно только по загруженному
объекту, поэтому прямой вызов getPage() заменили сканированием:

  collectVisibleListPage()  — сканирует от начала батчами и пропускает
                              start записей в PHP;
  countVisibleListResults() — проходит всю категорию батчами по 200 и
                              считает видимые, ради одного числа total.

На категории с 2114 товарами это 18 запросов и 953 мс на первой странице,
33 запроса и 1350 мс на start=800 — цена растёт с номером страницы.

Сервис при этом всё умеет сам: COUNT(DISTINCT msProduct.id) одним запросом
и limit($limit, $start). Контроллер просто перестал этим пользоваться.

Ограничить видимость ресурса может только группа ресурсов с записью
доступа: modResource::findPolicy() собирает политику джойном
modAccessResourceGroup + modResourceGroupResource и отдаёт пустой набор,
когда настройка контекста выключена. Значит нет ни одной записи доступа —
нет ни одного закрытого товара, и построчный фильтр заведомо пропускает
всё; для sudo он пропускает всё всегда. В этих случаях включается быстрый
путь, то есть ровно тот код, что был до #473.

Если группы ресурсов настроены, остаётся прежнее сканирование: права
важнее скорости.

Замер после правки на той же категории:

  nested=false  1 запрос,  24-31 мс на любой странице (было 18-33 / 953-1350)
  nested=true  22 запроса, 32 мс (22 — обход дерева категорий, он был и раньше)

total совпадает с прежним сканирующим подсчётом в обоих режимах: 2114.
Проверено и обратное — при появлении записи modAccessResourceGroup условие
переключается на false, после уборки обратно на true.

Фильтрация листинга по ACL тестами не покрыта (ни collectVisibleListPage,
ни countVisibleListResults, ни filterListResultsByDocumentView в тестах не
упоминаются), поэтому добавлен тест на само условие переключения: ошибка в
нём либо вернёт медленный листинг, либо обойдёт проверку прав.

Стабу modX добавлены getCount() и optionOverrides. Подпись getCount()
намеренно без типов: тесты переопределяют метод в анонимных подклассах со
своей, более слабой.
@biz87
biz87 merged commit 41bf877 into beta Oct 15, 2026
6 checks passed
@biz87
biz87 deleted the fix/473-category-products-pagination branch October 15, 2026 20:27
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.

1 participant