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):
-
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.
-
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.
-
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
-
Run a full stock reindex: bin/magento indexer:reindex cataloginventory_stock.
-
Re-query the same three values.
-
Run bin/magento indexer:reindex (everything) and re-query.
-
Run bin/magento cron:run and re-query.
Realistic merchant-lifecycle reproduction (natural state):
- 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).
- In Admin (or via a Stock Sources CSV import), set every child's stock to qty=0, status=0.
- Run
bin/magento cron:run. Confirm the parent's cataloginventory_stock_status.stock_status is now 0 (correct — every child is OOS).
- 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.
- 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.
Terminology used throughout this report:
catalog_product_entitywithtype_id = 'configurable'.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 incataloginventory_stock_item(or "the parent's stock-item record").css— the parent's row incataloginventory_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
vendor/magento/module-inventory-catalog/Model/ResourceModel/StockStatusExpressionForDefaultStock.php(lines 46-50); the upstream copy onmagento/magento22.4-developshould be confirmed but the dependency is structural to the legacy stock indexer and is wired identically.magento/module-inventory-catalog1.3.2-p8 (contains the defective expression and DI wiring)magento/module-configurable-product(Magento core, 2.4.7-p9) — consumes the expression viaConfigurable::_getStockStatusSelectmagento/module-inventory-configurable-product-indexer1.2.5 — contains the MSI-sideStockIndexerthat skips the default stock and defers to the legacy indexerMagento_InventoryCatalogcreatesinventory_stock_1as a MySQLVIEWovercataloginventory_stock_statusviavendor/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.cataloginventory_stock_status.stock_status = 0whilecataloginventory_stock_item.is_in_stock = 1(withmanage_stock = 1and at least one in-stock child).Steps to reproduce
Synthetic, deterministic reproduction (planted state):
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 = 1for that child). Capture the configurable parent's primary-keyentity_idfromcatalog_product_entity(theentity_idof the configurable itself, not theentity_idof any of its children) — referred to as<configurable_product_entity_id>throughout the remaining steps. For example:Plant the feedback-loop precondition by writing the parent's indexed stock status (
css) to0while deliberately leaving the parent's stock-item record (cisi) atis_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:Confirm the starting state — parent indexed as OOS, stock-item record still in-stock, at least one child salable:
Run a full stock reindex:
bin/magento indexer:reindex cataloginventory_stock.Re-query the same three values.
Run
bin/magento indexer:reindex(everything) and re-query.Run
bin/magento cron:runand re-query.Realistic merchant-lifecycle reproduction (natural state):
manage_stock = 1,use_config_manage_stock = 1).bin/magento cron:run. Confirm the parent'scataloginventory_stock_status.stock_statusis now0(correct — every child is OOS).cataloginventory_stock_item.is_in_stock = 1on the parent without triggeringMagento\InventoryConfigurableProduct\Plugin\CatalogInventory\UpdateLegacyStockStatusForConfigurableProduct::afterSavewithstock_status_changed_auto = 1. Examples: custom data-migration scripts, bulk SQL writes that set the parent'scisiindependently, or any code path that saves the parent'sStock\Itemwithout setting the auto flag.bin/magento cron:runand inspect the parent'scataloginventory_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_statusas1and write it tocataloginventory_stock_status.In the realistic reproduction, after step 5 the parent's
cataloginventory_stock_status.stock_statusshould reflect the children's salability —1if any child is in stock.Actual result
In both reproductions, the parent's
cataloginventory_stock_status.stock_statusremains0indefinitely. No amount ofbin/magento indexer:reindexorbin/magento cron:runrecovers it. Becausecataloginventory_stock_statusis 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-50is:cisiis the parent's row incataloginventory_stock_item;cssis the parent's row incataloginventory_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 inLEAST(MAX(children), MIN(parent_expression)). Withcss.stock_status = 0andcisi.is_in_stock = 1, the second branch reads the parent's own stale0and returns it.LEAST(1, 0) = 0— the indexer writes0back to the same row. Subsequent reindexes read0, return0, write0. The state is a fixed point.The expression is registered as the
defaultstatus expression invendor/magento/module-inventory-catalog/etc/di.xml:189. Bundle and grouped product types use the vanillaMagento\CatalogInventory\Model\ResourceModel\Indexer\Stock\StatusExpression\DefaultExpressioninstead (di.xml:190-191), which does not contain thecss.stock_status IS NOT NULLfallback and is not vulnerable.MSI's parallel configurable indexer (
vendor/magento/module-inventory-configurable-product-indexer/Indexer/Stock/StockIndexer.php:184-187) explicitlycontinues pastdefaultStockProvider->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):use_config_manage_stock = 0 AND manage_stock = 0cisi.is_in_stock = 0css.stock_status IS NOT NULLLEAST(MAX(children)=1, MIN(0)) = 0. Indexer writes0.Existing partial mitigation.
Magento\InventoryConfigurableProduct\Plugin\CatalogInventory\UpdateLegacyStockStatusForConfigurableProduct::afterSave(invendor/magento/module-inventory-configurable-product/Plugin/CatalogInventory/UpdateLegacyStockStatusForConfigurableProduct.php) direct-writescataloginventory_stock_status.stock_status = Stock::STOCK_IN_STOCKfor configurables when the parent's stock-item save setsis_in_stock = 1together withstock_status_changed_auto = 1. This covers the common restock path throughMagento\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.
csssnapshot was0;cisiwas1).stockStatusChangedAuto = 1. This includes some custom save paths and third-party flows.Recovery. Once trapped, neither
bin/magento indexer:reindexnorbin/magento cron:runrecovers 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) changingmanage_stockto0on the parent, which routes evaluation through Branch 1 of the expression (returns1unconditionally).Suggested fix. Remove the
IF (css.stock_status IS NOT NULL) THEN css.stock_statusclause fromStockStatusExpressionForDefaultStock::getExpression. Reduce the expression to:— matching the vanilla
DefaultExpression. This eliminates the self-read, removes the fixed point at0, and makes parent stock status a pure function of child salability and the parent's ownstock_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. Thedefaultstock status expression no longer reads the parent's own row incataloginventory_stock_statusas 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.