Skip to content

Fix the configurable stock index child status join and drop the isSalable N+1 on listings - #37

Merged
jeanmarcos-dev merged 2 commits into
dist-2.4.8from
integrate/2.4.8-configurable-issalable-n1
Jul 30, 2026
Merged

Fix the configurable stock index child status join and drop the isSalable N+1 on listings#37
jeanmarcos-dev merged 2 commits into
dist-2.4.8from
integrate/2.4.8-configurable-issalable-n1

Conversation

@jeanmarcos-dev

Copy link
Copy Markdown
Owner

Two related changes in the configurable-product salability path: a correctness fix on the stock index, and the removal of an N+1 on product listings.

1. Stock index: harden the child status join

InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php joins catalog_product_entity_int so that disabled children stop counting towards the parent's salable aggregate (introduced by magento#3241). Two problems with that join:

  • It is not scoped to a store. status has one row per store scope, so a child enabled in the default scope and in a store view produced two rows in the group, doubling SUM(stock.quantity). Measured on a parent with 15 children of 100 qty each, on a non-default stock:

    join quantity rows in group
    before MC-38590: "Out of Stock" Configurable Product Shows Up on Storefront … magento/inventory#3241 1500 15
    MC-38590: "Out of Stock" Configurable Product Shows Up on Storefront … magento/inventory#3241 as shipped 3000 30
    with store_id = 0 1500 15

    is_salable was unaffected; the inflated value is what the "only X left" displays consume.

  • It drops the parent row entirely when no child is enabled, because it is an INNER JOIN. A row that is absent behaves like a row set to 0 for addStockDataToCollection (which filters is_salable = 1), but not for GetStockItemsData: a single-sku lookup that finds nothing falls back to cataloginventory_stock_item, which for a configurable reports in-stock and therefore salable.

Both are fixed by pinning the join to Store::DEFAULT_STORE_ID and turning it into a LEFT JOIN, moving the status test into the aggregate: MAX(IF(product_status.value = <enabled>, stock.is_salable, 0)). The parent row now stays in the index with is_salable = 0. Pinning to the default scope is deliberate — the index has no store dimension, so a store-level status override is not reflected, which is the same limitation the legacy cataloginventory_stock indexer has.

Verified end to end on a non-default stock, parent with 15 children:

scenario before after
every child disabled row absent (is_salable effectively unknown) is_salable = 0
one child re-enabled 1 1
quantity of the parent 3000 1500

2. Listings: drop the per-product salable children count

Magento\ConfigurableProduct\Model\Product\Type\Configurable::isSalable() memoises per sku, so a listing of N configurables pays N COUNT(DISTINCT e.entity_id) queries joining catalog_product_super_link, the status attribute and the stock index. The question that COUNT answers — "does this parent have at least one enabled, in-stock child?" — is the aggregate the stock index already stores on the parent's own row, which the collection has already joined in as is_salable.

The new IsSalablePlugin (frontend only, next to the existing IsSalableOptionPlugin) answers from that loaded value and delegates to the core otherwise:

  • no is_salable on the product (PDP, repository-loaded product, quote item) → $proceed(). The bulk stock API is deliberately not used here: it answers from cataloginventory_stock_status with a fallback to cataloginventory_stock_item, which is not equivalent to the core's COUNT when the legacy index is incomplete.
  • the salability being asked for is not the current store's → $proceed(). Note this compares store ids rather than checking getStoreFilter() for null: in frontend UsedProductsWebsiteFilter stamps the current store on the product on every getUsedProducts() call, and listing swatches run before the template asks isSaleable(), so a null check would disable the fast path on exactly the pages this targets.
  • any Throwable$proceed(). An optimisation must not take a page down.

Otherwise it returns what AbstractType::isSalable() already computes from status + is_salable, which is the cheap half the core runs before the COUNT.

Measured

Category listing of 12 configurables, warm caches, FPC and block cache off:

statements salability COUNTs
before 21 12
after 9 0

The page total drops by exactly N — the fast path adds no query of its own. SPX over the same scenario: Configurable::isSalable goes from 12 calls / 17.5 ms (8.2 ms of it in getSize()) to ~250 us; total function calls 101.2K → 70.5K.

Validation

  • Equivalence against the core implementation for every configurable in the catalog (167), across six scenarios — baseline, all children disabled, parent disabled, all children out of stock, multi-stock, multi-stock with children disabled — and two load paths (listing collection and repository): 0 mismatches on the listing path in all six, and 0 on both paths for the products whose legacy stock index is complete.
  • Rendered HTML byte-identical (modulo form keys) on a configurable listing, a simple-product listing and search.
  • Source-level reservations stress + e2e suite: PASS, 27 checks, 0 failures, ledger invariants green.
  • Unit tests, phpcs (Magento2) and phpmd (both the local and the CI ruleset): clean.

@jeanmarcos-dev
jeanmarcos-dev merged commit 4fff75f into dist-2.4.8 Jul 30, 2026
1 check passed
@jeanmarcos-dev
jeanmarcos-dev deleted the integrate/2.4.8-configurable-issalable-n1 branch July 30, 2026 15:49
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