Skip to content

Configurable products permanently stuck out-of-stock due to self-referential cataloginventory_stock_status read in the legacy stock indexer expression #3454

Description

@damienwebdev

Terminology used throughout this report:

  • Parent — the configurable product itself: the storefront-visible item the customer browses, with selectable options like size or color. Stored in catalog_product_entity with type_id = 'configurable'.
  • Child — a simple product (one per option combination) linked to the parent via catalog_product_super_link. Children are what physically hold inventory and what get added to the cart once a customer selects options. They are typically not individually visible in catalog/search (visibility = 1).
  • cisi — the parent's row in cataloginventory_stock_item (or "the parent's stock-item record").
  • css — the parent's row in cataloginventory_stock_status (or "the parent's indexed stock status"; this is the value the storefront reads to decide whether to show the product).

Preconditions and environment

  • Magento Open Source 2.4.7-p9. Defect lives in vendor/magento/module-inventory-catalog/Model/ResourceModel/StockStatusExpressionForDefaultStock.php (lines 46-50); the upstream copy on magento/magento2 2.4-develop should be confirmed but the dependency is structural to the legacy stock indexer and is wired identically.
  • Magento Inventory module versions:
    • magento/module-inventory-catalog 1.3.2-p8 (contains the defective expression and DI wiring)
    • magento/module-configurable-product (Magento core, 2.4.7-p9) — consumes the expression via Configurable::_getStockStatusSelect
    • magento/module-inventory-configurable-product-indexer 1.2.5 — contains the MSI-side StockIndexer that skips the default stock and defers to the legacy indexer
  • PHP 8.3, MariaDB.
  • Single-source MSI configuration. Magento_InventoryCatalog creates inventory_stock_1 as a MySQL VIEW over cataloginventory_stock_status via vendor/magento/module-inventory-catalog/Setup/Patch/Schema/CreateLegacyStockStatusView.php. Storefront-visibility code paths therefore read from the legacy table either directly or through the view.
  • Indexers in Update by Schedule (the Magento default for production), so reindex work is processed asynchronously via mview.
  • At least one configurable product whose enabled children have completed an out-of-stock-then-restock cycle, or any database state in which the parent's cataloginventory_stock_status.stock_status = 0 while cataloginventory_stock_item.is_in_stock = 1 (with manage_stock = 1 and at least one in-stock child).

Steps to reproduce

Synthetic, deterministic reproduction (planted state):

  1. Pick any configurable product whose cataloginventory_stock_item.is_in_stock = 1, manage_stock = 1, use_config_manage_stock = 1, and which has at least one child in stock (cataloginventory_stock_status.stock_status = 1 for that child). Capture the configurable parent's primary-key entity_id from catalog_product_entity (the entity_id of the configurable itself, not the entity_id of any of its children) — referred to as <configurable_product_entity_id> throughout the remaining steps. For example:

    SELECT entity_id, sku, type_id
    FROM   catalog_product_entity
    WHERE  sku = '<your configurable parent SKU>';
    -- Use the entity_id from this row as <configurable_product_entity_id> below.
  2. Plant the feedback-loop precondition by writing the parent's indexed stock status (css) to 0 while deliberately leaving the parent's stock-item record (cisi) at is_in_stock = 1. This asymmetry is what creates the trap and is the state that would otherwise arise from a partial DB write, a database dump captured mid-cycle, or any code path that updates one of the two tables without the other:

    UPDATE cataloginventory_stock_status
    SET    stock_status = 0
    WHERE  product_id = <configurable_product_entity_id>
      AND  website_id = 0
      AND  stock_id   = 1;
    -- Do NOT touch cataloginventory_stock_item for the same product;
    -- the trap requires cisi.is_in_stock = 1 while css.stock_status = 0.
  3. Confirm the starting state — parent indexed as OOS, stock-item record still in-stock, at least one child salable:

    SELECT
      (SELECT stock_status FROM cataloginventory_stock_status WHERE product_id=<configurable_product_entity_id>) AS css,
      (SELECT is_in_stock  FROM cataloginventory_stock_item   WHERE product_id=<configurable_product_entity_id>) AS cisi,
      (SELECT manage_stock FROM cataloginventory_stock_item   WHERE product_id=<configurable_product_entity_id>) AS manage_stock;
    -- expect css=0, cisi=1, manage_stock=1
  4. Run a full stock reindex: bin/magento indexer:reindex cataloginventory_stock.

  5. Re-query the same three values.

  6. Run bin/magento indexer:reindex (everything) and re-query.

  7. Run bin/magento cron:run and re-query.

Realistic merchant-lifecycle reproduction (natural state):

  1. Pick a healthy configurable with at least one enabled child in stock and the default settings as above (manage_stock = 1, use_config_manage_stock = 1).
  2. In Admin (or via a Stock Sources CSV import), set every child's stock to qty=0, status=0.
  3. Run bin/magento cron:run. Confirm the parent's cataloginventory_stock_status.stock_status is now 0 (correct — every child is OOS).
  4. Restock at least one child to qty>0, status=1 via any save path that flips cataloginventory_stock_item.is_in_stock = 1 on the parent without triggering Magento\InventoryConfigurableProduct\Plugin\CatalogInventory\UpdateLegacyStockStatusForConfigurableProduct::afterSave with stock_status_changed_auto = 1. Examples: custom data-migration scripts, bulk SQL writes that set the parent's cisi independently, or any code path that saves the parent's Stock\Item without setting the auto flag.
  5. Run bin/magento cron:run and inspect the parent's cataloginventory_stock_status.stock_status.

Expected result

After steps 4–7 of the synthetic reproduction, with at least one child in stock, the legacy stock indexer should compute the parent's stock_status as 1 and write it to cataloginventory_stock_status.

In the realistic reproduction, after step 5 the parent's cataloginventory_stock_status.stock_status should reflect the children's salability — 1 if any child is in stock.

Actual result

In both reproductions, the parent's cataloginventory_stock_status.stock_status remains 0 indefinitely. No amount of bin/magento indexer:reindex or bin/magento cron:run recovers it. Because cataloginventory_stock_status is the authoritative source for native Magento stock-visibility filters (Magento\CatalogInventory\Helper\Stock, Magento\CatalogInventory\Model\ResourceModel\Stock\Status, and the catalog product collection plugins applied to category listings and layered navigation), the configurable is filtered out of all storefront views.

In Admin → Catalog → Products → edit, the parent's "Stock" still reads "In Stock" (the Admin reads cataloginventory_stock_item, not the index), so there is no admin-side indicator of the inconsistency. The configurable appears healthy in admin while being invisible on the storefront.

Additional information

Root cause. The per-row stock-status expression at vendor/magento/module-inventory-catalog/Model/ResourceModel/StockStatusExpressionForDefaultStock.php:46-50 is:

IF (cisi.is_in_stock = 0) THEN 0
ELSE IF (css.stock_status IS NOT NULL) THEN css.stock_status   -- defect
ELSE                                        cisi.is_in_stock

cisi is the parent's row in cataloginventory_stock_item; css is the parent's row in cataloginventory_stock_status — the table the indexer is writing to. The configurable indexer (vendor/magento/module-configurable-product/Model/ResourceModel/Indexer/Stock/Configurable.php:96-101) wraps this in LEAST(MAX(children), MIN(parent_expression)). With css.stock_status = 0 and cisi.is_in_stock = 1, the second branch reads the parent's own stale 0 and returns it. LEAST(1, 0) = 0 — the indexer writes 0 back to the same row. Subsequent reindexes read 0, return 0, write 0. The state is a fixed point.

The expression is registered as the default status expression in vendor/magento/module-inventory-catalog/etc/di.xml:189. Bundle and grouped product types use the vanilla Magento\CatalogInventory\Model\ResourceModel\Indexer\Stock\StatusExpression\DefaultExpression instead (di.xml:190-191), which does not contain the css.stock_status IS NOT NULL fallback and is not vulnerable.

MSI's parallel configurable indexer (vendor/magento/module-inventory-configurable-product-indexer/Indexer/Stock/StockIndexer.php:184-187) explicitly continues past defaultStockProvider->getId(), deferring to the legacy indexer in single-source mode — so MSI provides no parallel recovery path.

Branch-by-branch behavior for a trapped configurable (css.stock_status = 0, cisi.is_in_stock = 1, manage_stock = 1, at least one in-stock child):

Branch Predicate Result
1 use_config_manage_stock = 0 AND manage_stock = 0 false → skip
2 cisi.is_in_stock = 0 false → skip
3 css.stock_status IS NOT NULL true (= 0) → return 0

LEAST(MAX(children)=1, MIN(0)) = 0. Indexer writes 0.

Existing partial mitigation. Magento\InventoryConfigurableProduct\Plugin\CatalogInventory\UpdateLegacyStockStatusForConfigurableProduct::afterSave (in vendor/magento/module-inventory-configurable-product/Plugin/CatalogInventory/UpdateLegacyStockStatusForConfigurableProduct.php) direct-writes cataloginventory_stock_status.stock_status = Stock::STOCK_IN_STOCK for configurables when the parent's stock-item save sets is_in_stock = 1 together with stock_status_changed_auto = 1. This covers the common restock path through Magento\ConfigurableProduct\Model\Inventory\ChangeParentStockStatus — including Stock Sources CSV imports — and is why the defect does not surface in the most common merchant workflow. It is not a fix: any save path that doesn't satisfy the plugin's gate leaves the trap reachable.

Entry paths into the unrecoverable state.

  • Restoring a database dump that captured a configurable mid-cycle (css snapshot was 0; cisi was 1).
  • Bulk SQL or data-migration scripts that update one of the two tables without the other.
  • Stock-item saves on the parent that do not set stockStatusChangedAuto = 1. This includes some custom save paths and third-party flows.

Recovery. Once trapped, neither bin/magento indexer:reindex nor bin/magento cron:run recovers the row. The only recovery paths are (a) UPDATE cataloginventory_stock_status SET stock_status = 1 WHERE product_id = <configurable_product_entity_id>, (b) saving the parent's stock-item through a path that triggers the bypass plugin (e.g. toggling "In Stock" off then on in Admin), or (c) changing manage_stock to 0 on the parent, which routes evaluation through Branch 1 of the expression (returns 1 unconditionally).

Suggested fix. Remove the IF (css.stock_status IS NOT NULL) THEN css.stock_status clause from StockStatusExpressionForDefaultStock::getExpression. Reduce the expression to:

IF (cisi.is_in_stock = 0) THEN 0
ELSE                          cisi.is_in_stock

— matching the vanilla DefaultExpression. This eliminates the self-read, removes the fixed point at 0, and makes parent stock status a pure function of child salability and the parent's own stock_item.is_in_stock, as the surrounding indexer logic already intends.

Release note

Fix configurable product stock indexer permanently writing out-of-stock when the parent's previous indexed stock status was 0. The default stock status expression no longer reads the parent's own row in cataloginventory_stock_status as a fallback, eliminating a feedback loop that could leave configurables hidden from the storefront after an out-of-stock-then-restock cycle.

Triage and priority

S2 — Major. Affected configurable products are silently and permanently hidden from the storefront despite having salable children. The Admin UI shows the product as "In Stock," so there is no operator-visible indication of the inconsistency. Cron-driven reindex does not recover the state; recovery requires either targeted SQL or specific admin-side interactions that flip a non-default flag.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions