diff --git a/vendor/magento/module-catalog-rule/Model/Indexer/IndexBuilder.php b/vendor/magento/module-catalog-rule/Model/Indexer/IndexBuilder.php index 13a2e420d6be6..cdeae4f5e22a2 100644 --- a/vendor/magento/module-catalog-rule/Model/Indexer/IndexBuilder.php +++ b/vendor/magento/module-catalog-rule/Model/Indexer/IndexBuilder.php @@ -21,6 +21,7 @@ use Magento\Eav\Model\Config; use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\DB\Sql\Expression; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Indexer\IndexerRegistry; use Magento\Framework\Pricing\PriceCurrencyInterface; @@ -231,7 +232,7 @@ public function __construct( DateTime $dateFormat, DateTime\DateTime $dateTime, ProductFactory $productFactory, - $batchCount = 1000, + $batchCount = 2000, ?ProductPriceCalculator $productPriceCalculator = null, ?ReindexRuleProduct $reindexRuleProduct = null, ?ReindexRuleGroupWebsite $reindexRuleGroupWebsite = null, @@ -245,7 +246,7 @@ public function __construct( ?ProductCollectionFactory $productCollectionFactory = null, ?IndexerRegistry $indexerRegistry = null, ?ReindexRuleProductsPrice $reindexRuleProductsPrice = null, - int $productBatchSize = 1000 + int $productBatchSize = 2000 ) { $this->resource = $resource; $this->connection = $resource->getConnection(); @@ -421,15 +422,166 @@ protected function doReindexFull() } /** - * Clean product index + * Cleanup product index. + * + * Two different strategies in use depending on how much data to be deleted. + * 1. If more than 25% of the table rows consider to be deleted, copy-and-swap strategy is in use. + * This strategy helps to minimize time while table is locked, also skipping InnoDB index from recalculation. + * 2. If less than 25% of the table rows consider to be deleted, batch deletion strategy is in use. + * For not a significant chunk of data to be deleted, to avoid heavy operation just splitting query to batches. * * @param array $productIds * @return void + * @throws Zend_Db_Statement_Exception + * @throws \Zend_Db_Exception */ private function cleanProductIndex(array $productIds): void { - $where = ['product_id IN (?)' => $productIds]; - $this->connection->delete($this->getTable('catalogrule_product'), $where); + if (empty($productIds)) { + return; + } + + $tableName = $this->getTable('catalogrule_product'); + + $this->deleteRatio($tableName, $productIds) > 0.25 + ? $this->copyAndSwapTable($tableName, $productIds) + : $this->batchRowsDelete($tableName, $productIds); + } + + /** + * Calculate deletion ratio for the table. + * + * Alternatively, information_schema can be used: + * ``` + * $sql = $this->connection->select(); + * $sql->from( + * ['information_schema.TABLES'], + * ['TABLE_ROWS'] + * ); + * $sql->where('TABLE_SCHEMA = ?', $this->resource->getSchemaName(ResourceConnection::DEFAULT_CONNECTION)); + * $sql->where('TABLE_NAME = ?', $table); + * $this->connection->fetchOne($sql); + * ``` + * + * @param string $tableName + * @param array $productIds + * @return float + */ + private function deleteRatio(string $tableName, array $productIds): float + { + if (empty($productIds)) { + return 0.0; + } + + $sql = $this->connection->select(); + $sql->from( + $tableName, + new Expression( + sprintf('COUNT(*) / (SELECT COUNT(*) FROM `%s`) as count', $tableName) + ) + ); + $sql->where('product_id IN (?)', $productIds); + + $ratio = (float) $this->connection->fetchOne($sql); + + return is_nan($ratio) ? 0.0 : $ratio; + } + + /** + * Copy-and-swap table strategy for large deletion chunks. + * + * Optimized for Galera clusters: avoids massive DELETE operations that would: + * - Exceed Galera transaction size limit (2GB writeset) + * - Cause row-by-row replication to secondary nodes + * - Degrade cluster performance severely + * + * Instead, creates new table with only data to keep, then atomic RENAME. + * + * WARNING: Uses DDL operations (RENAME TABLE) which cause implicit commit in MySQL/MariaDB. + * The INSERT operations are transactional, but the final RENAME always commits. + * This is expected DDL behavior and works correctly across MySQL 8.x, MariaDB 10/11, and Galera. + * + * @link https://dev.mysql.com/doc/refman/8.4/en/delete.html#id246642 + * @param string $tableName + * @param array $productIds + * @return void + * @throws \Exception + */ + private function copyAndSwapTable(string $tableName, array $productIds): void + { + //#0. Create backup and temporary table names with random suffixes + $backupTable = $this->connection->getTableName($tableName . '_bak' . $this->getRandomSuffix()); + $temporaryTable = $this->connection->getTableName($tableName . '_tmp' . $this->getRandomSuffix()); + + //#1. Create clone of the original table + $this->connection->createTable( + $this->connection->createTableByDdl($tableName, $temporaryTable) + ); + + //#2. Fill the temporary table with rows NOT to be deleted + $this->connection->beginTransaction(); + try { + $select = $this->connection->select(); + $select->from($tableName); + $select->where('product_id NOT IN (?)', $productIds); + $this->connection->query( + $this->connection->insertFromSelect($select, $temporaryTable) + ); + $this->connection->commit(); + } catch (\Exception $exception) { + $this->connection->rollBack(); + $this->connection->dropTable($temporaryTable); + throw $exception; + } + + //#3. Rename tables, original moves away as backup, temporary becomes the original. + $renameTables = [ + [ + 'oldName' => $tableName, + 'newName' => $backupTable + ], + [ + 'oldName' => $temporaryTable, + 'newName' => $tableName + ] + ]; + $this->connection->renameTablesBatch($renameTables); + + //#4. Drop the backup table + $this->connection->dropTable($backupTable); + } + + /** + * Create random suffix. + * + * @return string + * @throws \Random\RandomException + */ + private function getRandomSuffix(): string + { + return bin2hex(random_bytes(4)); + } + + /** + * Batch deletion strategy for row deletion in small chunks. + * + * Each batch is deleted separately to avoid: + * - "WSREP: transaction size limit exceeded" errors + * - Massive replication lag on Galera secondary nodes + * - Long-running transactions that lock tables + * + * @param string $tableName + * @param array $productIds + * @return void + */ + private function batchRowsDelete(string $tableName, array $productIds): void + { + while (!empty($productIds)) { + $batch = array_splice($productIds, 0, $this->batchCount); + + $where = ['product_id IN (?)' => $batch]; + $this->connection->delete($tableName, $where); + } } /**