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);
+        }
     }
 
     /**
