What happened?
Description
Carts::purgeIncompleteCarts() attempts to delete searchindex rows belonging to purged
carts, but the delete always matches zero rows. The searchindex rows for every purged
cart are therefore orphaned permanently.
The cause is statement ordering. The method builds a Query object (not a materialised
array of IDs), then reuses it for two deletes:
https://github.com/craftcms/commerce/blob/4.11.2/src/services/Carts.php#L381-L411
$cartIdsQuery = (new Query())
->select(['orders.id'])
->where(['not', ['isCompleted' => true]])
->andWhere('[[orders.dateUpdated]] <= :edge', ['edge' => Db::prepareDateForDb($edge)])
->from(['orders' => Table::ORDERS]);
// Delete the elements table rows, which will cascade across all other InnoDB tables
Craft::$app->getDb()->createCommand()
->delete('{{%elements}}', ['id' => $cartIdsQuery])
->execute();
// The searchindex table is probably MyISAM, though
Craft::$app->getDb()->createCommand()
->delete('{{%searchindex}}', ['elementId' => $cartIdsQuery])
->execute();
return $cartIdsQuery->count();
$cartIdsQuery selects from commerce_orders. The first delete removes the elements
rows, and commerce_orders.id has a foreign key to elements.id with
ON DELETE CASCADE, so the matching commerce_orders rows are removed as part of that
statement.
By the time the second delete runs, $cartIdsQuery is re-executed as a subquery against a
table whose rows no longer exist. It returns an empty set, so the searchindex delete
affects zero rows.
Foreign key confirmed on a Craft 4 / Commerce 4 schema:
CONSTRAINT_NAME DELETE_RULE TABLE_NAME COLUMN_NAME REFERENCED_TABLE_NAME
fk_sdqcfbkusgqmgvsrrpcymjspbgyrkgfaylre CASCADE commerce_orders id elements
Steps to reproduce
- On a Commerce site with
purgeInactiveCarts enabled, allow some incomplete carts to
age past purgeInactiveCartsDuration.
- Note the
searchindex rows belonging to those carts:
SELECT COUNT(*) FROM searchindex s
JOIN commerce_orders o ON o.id = s.elementId
WHERE o.isCompleted = 0;
- Run
php craft gc (or otherwise trigger purgeIncompleteCarts()).
- Confirm the carts are gone from
commerce_orders.
- Confirm their
searchindex rows remain, now orphaned:
SELECT COUNT(*) FROM searchindex s
LEFT JOIN elements_sites es
ON es.elementId = s.elementId AND es.siteId = s.siteId
WHERE es.elementId IS NULL;
Expected behaviour
searchindex rows belonging to purged carts are deleted along with the carts.
Actual behaviour
Zero searchindex rows are deleted. They accumulate indefinitely, one set per purged
cart.
Why this compounds
Craft's own orphan cleanup cannot recover from this at scale.
craft\services\Gc::run() calls hardDeleteElements() before
_deleteOrphanedSearchIndexes(), and Search::deleteOrphanedIndexes() performs a single
unbatched DELETE ... JOIN with no LIMIT. On a large table that statement does not
complete within typical timeouts, so the orphans are never cleared and each subsequent
purge adds more.
The practical result is unbounded growth of searchindex on any Commerce site with cart
churn.
Real-world impact
On the production site where we found this:
|
rows |
Orphaned (no matching elements_sites row) |
30,737,883 |
| Attached to live elements but with empty keywords |
2,421,550 |
| Legitimate, searchable rows |
638,319 |
| Total |
33,797,752 |
searchindex reached ~3,153 MB, roughly 89% of the entire database; the next largest
table was 87 MB. The site experienced downtime, and the hosting provider flagged the table
as the cause. Cleanup required deleting ~31 million rows in bounded chunks, because the
built-in cleanup paths could not complete.
Suggested fix
The minimal change is to reorder the two deletes so the subquery still resolves when the
searchindex delete runs:
// Delete the search index rows first, while the orders still exist for the subquery
Craft::$app->getDb()->createCommand()
->delete('{{%searchindex}}', ['elementId' => $cartIdsQuery])
->execute();
Craft::$app->getDb()->createCommand()
->delete('{{%elements}}', ['id' => $cartIdsQuery])
->execute();
Alternatively, materialise the IDs once and use them for both statements, which is more
explicit about intent and avoids re-running the query three times:
$cartIds = $cartIdsQuery->column();
if (empty($cartIds)) {
return 0;
}
Craft::$app->getDb()->createCommand()
->delete('{{%searchindex}}', ['elementId' => $cartIds])
->execute();
Craft::$app->getDb()->createCommand()
->delete('{{%elements}}', ['id' => $cartIds])
->execute();
return count($cartIds);
Chunking either form would also be worth considering, since a first-ever purge on a
long-running site can span a very large number of carts in one statement.
Two related observations
The return value is also affected. return $cartIdsQuery->count(); on line 410 runs
after the elements delete, so it counts a table whose rows are gone and returns 0.
purgeIncompleteCarts() therefore reports zero carts purged even when it has just purged
thousands, which makes the problem invisible in logs.
The comment on line 405 is out of date. "The searchindex table is probably MyISAM,
though" — on Craft 4 with MySQL 5.6+/8, searchindex is created with the default engine,
which is InnoDB. This doesn't affect the bug, but the comment appears to be the reason the
searchindex delete is a separate statement rather than relying on the cascade, so it may
be worth revisiting.
Versions
- Craft CMS: 4.18.2
- Craft Commerce: 4.11.2
- Craft Commerce Stripe: 4.1.8.2
- PHP: 8.2 in local dev
- Database (production): Percona XtraDB Cluster 8.0.36-28.1
- Database (local reproduction): MySQL 8.0.40
- Relevant settings:
purgeInactiveCarts enabled, purgeInactiveCartsDuration = P7D,
autoSetCartShippingMethodOption = true
Code references are to craftcms/commerce 4.11.2, src/services/Carts.php lines 381-411.
The same ordering is worth checking on Commerce 5.
Craft CMS version
4.18.2
Craft Commerce version
4.11.2
PHP version
8.2
Operating system and version
No response
Database type and version
No response
Image driver and version
No response
Installed plugins and versions
What happened?
Description
Carts::purgeIncompleteCarts()attempts to deletesearchindexrows belonging to purgedcarts, but the delete always matches zero rows. The
searchindexrows for every purgedcart are therefore orphaned permanently.
The cause is statement ordering. The method builds a
Queryobject (not a materialisedarray of IDs), then reuses it for two deletes:
https://github.com/craftcms/commerce/blob/4.11.2/src/services/Carts.php#L381-L411
$cartIdsQueryselects fromcommerce_orders. The first delete removes theelementsrows, and
commerce_orders.idhas a foreign key toelements.idwithON DELETE CASCADE, so the matchingcommerce_ordersrows are removed as part of thatstatement.
By the time the second delete runs,
$cartIdsQueryis re-executed as a subquery against atable whose rows no longer exist. It returns an empty set, so the
searchindexdeleteaffects zero rows.
Foreign key confirmed on a Craft 4 / Commerce 4 schema:
Steps to reproduce
purgeInactiveCartsenabled, allow some incomplete carts toage past
purgeInactiveCartsDuration.searchindexrows belonging to those carts:php craft gc(or otherwise triggerpurgeIncompleteCarts()).commerce_orders.searchindexrows remain, now orphaned:Expected behaviour
searchindexrows belonging to purged carts are deleted along with the carts.Actual behaviour
Zero
searchindexrows are deleted. They accumulate indefinitely, one set per purgedcart.
Why this compounds
Craft's own orphan cleanup cannot recover from this at scale.
craft\services\Gc::run()callshardDeleteElements()before_deleteOrphanedSearchIndexes(), andSearch::deleteOrphanedIndexes()performs a singleunbatched
DELETE ... JOINwith noLIMIT. On a large table that statement does notcomplete within typical timeouts, so the orphans are never cleared and each subsequent
purge adds more.
The practical result is unbounded growth of
searchindexon any Commerce site with cartchurn.
Real-world impact
On the production site where we found this:
elements_sitesrow)searchindexreached ~3,153 MB, roughly 89% of the entire database; the next largesttable was 87 MB. The site experienced downtime, and the hosting provider flagged the table
as the cause. Cleanup required deleting ~31 million rows in bounded chunks, because the
built-in cleanup paths could not complete.
Suggested fix
The minimal change is to reorder the two deletes so the subquery still resolves when the
searchindexdelete runs:Alternatively, materialise the IDs once and use them for both statements, which is more
explicit about intent and avoids re-running the query three times:
Chunking either form would also be worth considering, since a first-ever purge on a
long-running site can span a very large number of carts in one statement.
Two related observations
The return value is also affected.
return $cartIdsQuery->count();on line 410 runsafter the
elementsdelete, so it counts a table whose rows are gone and returns0.purgeIncompleteCarts()therefore reports zero carts purged even when it has just purgedthousands, which makes the problem invisible in logs.
The comment on line 405 is out of date. "The searchindex table is probably MyISAM,
though" — on Craft 4 with MySQL 5.6+/8,
searchindexis created with the default engine,which is InnoDB. This doesn't affect the bug, but the comment appears to be the reason the
searchindexdelete is a separate statement rather than relying on the cascade, so it maybe worth revisiting.
Versions
purgeInactiveCartsenabled,purgeInactiveCartsDuration=P7D,autoSetCartShippingMethodOption=trueCode references are to
craftcms/commerce4.11.2,src/services/Carts.phplines 381-411.The same ordering is worth checking on Commerce 5.
Craft CMS version
4.18.2
Craft Commerce version
4.11.2
PHP version
8.2
Operating system and version
No response
Database type and version
No response
Image driver and version
No response
Installed plugins and versions