Skip to content

Batch product_count resolution against the category product index (#41214) - #41220

Open
lbajsarowicz wants to merge 1 commit into
magento:2.4-developfrom
lbajsarowicz:fix/41214-graphql-product-count-batch
Open

Batch product_count resolution against the category product index (#41214)#41220
lbajsarowicz wants to merge 1 commit into
magento:2.4-developfrom
lbajsarowicz:fix/41214-graphql-product-count-batch

Conversation

@lbajsarowicz

Copy link
Copy Markdown
Contributor

Description (*)

Magento\CatalogGraphQl\Model\Resolver\Category\ProductsCount resolves product_count one category at a time: it builds a full product collection for the category, runs the collection processors and calls getSize(). Every product_count field in a response therefore costs one COUNT(DISTINCT e.entity_id) with joins on the category index, the stock status index, two catalog_product_entity_int attribute tables and catalog_product_website. On a store with ~60k products this query averaged 0.6 s and ran ~98,000 times in a week from a single PWA product-detail operation.

The category product index catalog_category_product_index_store{N} already materializes enabled status, website membership and per-store visibility, so the joins on the attribute and website tables add nothing to the count. This PR adds:

  • Model\Category\ProductsCountProvider, which counts all requested categories of one store with one GROUP BY category_id query on the index table (resolved through TableMaintainer, never a hardcoded suffix). The is_parent = 1 predicate for non-anchor categories and the cataloginventory_stock_status join when "Display Out of Stock Products" is disabled are kept, so the numbers match the old count.
  • Model\Resolver\Category\BatchProductsCount, a BatchResolverInterface that groups the requests of a response by store and anchor flag and calls the provider once per group. Requests for the admin store (store_id = 0) are delegated to the existing ProductsCount resolver, because that path uses a different table and filter set.
  • schema.graphqls: CategoryInterface.product_count now points at the batch resolver. ProductsCount is unchanged and still wired for the admin scope.

Performance impact

Measured on a vanilla 2.4-develop install (performance toolkit small profile: 1200 products, 33 categories). Wall time is the median of 7 fresh requests without the profiler; call counts and memory are from one PHP SPX run of the same request. Response bodies are byte-identical before and after.

Query 1, three levels of categoryList with product_count (31 fields):

{ categoryList(filters: {ids: {eq: "2"}}) { children { id product_count children { id product_count children { id product_count } } } } }
Metric Before After Delta
SQL queries 52 22 -58%
product_count queries 31 COUNT(DISTINCT e.entity_id) 2 GROUP BY category_id
Wall time (median of 7) 94.6 ms 86.0 ms -9%
PHP function calls (SPX) 284,766 168,111 -41%

Query 2, product listing with categories (24 product_count fields over 12 items):

{ products(filter: {category_id: {eq: "3"}}, pageSize: 12) { items { sku categories { id product_count } } } }
Metric Before After Delta
SQL queries 68 45 -34%
product_count queries 24 COUNT(DISTINCT e.entity_id) 1 GROUP BY category_id
Wall time (median of 7) 122.0 ms 89.5 ms -27%
PHP function calls (SPX) 264,858 179,971 -32%

The remaining per-item queries in query 2 come from the categories resolver itself and are outside this change. On a small catalog the count query is cheap, so the wall-time gain is modest here; the production numbers in the issue show what the per-category query costs on a large catalog.

Fixed Issues (if relevant)

  1. Fixes [Performance] GraphQL category product_count runs a five-join COUNT(DISTINCT) query per category (N+1) #41214

Manual testing scenarios (*)

  1. Enable the DB query log and send the two queries above: the log contains GROUP BY cat_index.category_id queries on catalog_category_product_index_store1 and no COUNT(DISTINCT e.entity_id).
  2. Compare product_count values with the ones returned before the change for an anchor category, a non-anchor category, a category with out-of-stock products and a category with no products: identical (0 for the empty category).
  3. Set Stores > Configuration > Catalog > Inventory > Stock Options > Display Out of Stock Products to Yes: the stock join disappears from the logged query and the counts include out-of-stock products, as before.

Questions or comments

Gates run locally: unit (CatalogGraphQl suite, 70 tests), integration (CatalogGraphQl suite), PHPCS, PHPStan and the Static Tests LiveCodeTest on the changed files are clean. The API-functional tests that pin the count semantics (CategoryProductsCountTest, CategoryAnchorTest, CategoryListTest, CategoryTreeTest) need the WebAPI build.

Batching happens per GraphQL execution level: a three-level categoryList produces one query per level, a flat product listing one query per response.

The Semantic Version Checker will report two new classes and the changed @resolver class on CategoryInterface.product_count. Plugins on ProductsCount::resolve() no longer run for storefront requests, only for the admin-scope fallback.

Contribution checklist (*)

  • Pull request has a meaningful description of its purpose
  • All commits are accompanied by meaningful commit messages
  • All new or changed code is covered with unit/integration tests (if applicable)
  • README.md files for modified modules are updated and included in the pull request if any README.md predefined sections require an update
  • All automated tests passed successfully (all builds are green)

…gento#41214)

product_count built a full product collection per field instance and called
getSize(), so one COUNT(DISTINCT e.entity_id) with five joins ran for every
category in the response. Resolve it with a BatchResolverInterface that counts
all requested categories with one GROUP BY category_id query per store against
catalog_category_product_index_store{N}, which already materializes enabled
status, website membership and per-store visibility. The is_parent predicate for
non-anchor categories and the stock join for "Display Out of Stock Products" are
preserved; admin scope still goes through the collection path.
@lbajsarowicz

Copy link
Copy Markdown
Contributor Author

@magento run all tests

@m2-assistant

m2-assistant Bot commented Sep 4, 2026

Copy link
Copy Markdown

Hi @lbajsarowicz. Thank you for your contribution!
Here are some useful tips on how you can test your changes using Magento test environment.
❗ Automated tests can be triggered manually with an appropriate comment:

  • @magento run all tests - run or re-run all required tests against the PR changes
  • @magento run <test-build(s)> - run or re-run specific test build(s)
    For example: @magento run Unit Tests

<test-build(s)> is a comma-separated list of build names.

Allowed build names are:
  1. Database Compare
  2. Functional Tests CE
  3. Functional Tests EE
  4. Functional Tests B2B
  5. Integration Tests
  6. Magento Health Index
  7. Sample Data Tests CE
  8. Sample Data Tests EE
  9. Sample Data Tests B2B
  10. Static Tests
  11. Unit Tests
  12. WebAPI Tests
  13. Semantic Version Checker

You can find more information about the builds here
ℹ️ Run only required test builds during development. Run all test builds before sending your pull request for review.


For more details, review the Code Contributions documentation.
Join Magento Community Engineering Slack and ask your questions in #github channel.

@engcom-Bravo engcom-Bravo added the Priority: P2 A defect with this priority could have functionality issues which are not to expectations. label Sep 8, 2026
@github-project-automation github-project-automation Bot moved this to Pending Review in Pull Requests Dashboard Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority: P2 A defect with this priority could have functionality issues which are not to expectations. Progress: pending review

Projects

Status: Pending Review

Development

Successfully merging this pull request may close these issues.

[Performance] GraphQL category product_count runs a five-join COUNT(DISTINCT) query per category (N+1)

2 participants