From 26095ffd5a360e53fd2adfd54610a2e1308d6578 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Mon, 5 Oct 2026 08:23:49 +0200 Subject: [PATCH] Fix deadlocks when concurrent requests write tracked options Every request writes the options it read to wp_option_optimizer_tracked on shutdown with INSERT ... ON DUPLICATE KEY UPDATE. InnoDB locks rows in VALUES order, which followed per-request access order, so concurrent requests locked the same rows in different orders and deadlocked ("Deadlock found when trying to get lock"), losing that request's counts. Sort the options before writing so all writers lock in one order. On a failed insert, ROLLBACK instead of sending COMMIT (which also hid the error by clearing $wpdb->last_error), and retry once on a deadlock; the deadlock rolls back the whole transaction, so the retry can't double count. Reproduced locally with 20 parallel writers x 50 batch_insert() calls of 200 shuffled options: ~920/1000 deadlocks before, 0/1000 after with all 200000 increments landed. Retry path verified with a forced deadlock. Co-Authored-By: Claude Opus 5.5 --- src/class-database.php | 51 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/class-database.php b/src/class-database.php index 3a2ff06..46778e4 100644 --- a/src/class-database.php +++ b/src/class-database.php @@ -181,18 +181,41 @@ public static function migrate_chunk() { * Splits large datasets into chunks and wraps them in a transaction * for optimal performance on slow hosts with large datasets. * + * Rows are written in a fixed (sorted) order. Every request writes the + * options it read at shutdown, and InnoDB locks rows in the order of the + * VALUES list; with per-request access order, concurrent requests lock + * the same rows in different orders and deadlock each other. + * * @param array $options Array of option_name => count. * @param int $chunk_size Number of options per query. Default 500. * * @return void */ public static function batch_insert( $options, $chunk_size = 500 ) { - global $wpdb; - if ( empty( $options ) ) { return; } + // SORT_STRING: numeric option names become int keys, keep one total order. + \ksort( $options, SORT_STRING ); + + // A deadlock rolls back the whole transaction, so one retry can't double count. + if ( ! self::write_chunks( $options, $chunk_size ) && self::last_error_is_deadlock() ) { + self::write_chunks( $options, $chunk_size ); + } + } + + /** + * Write option counts in chunks inside a single transaction. + * + * @param array $options Array of option_name => count, already sorted. + * @param int $chunk_size Number of options per query. + * + * @return bool Whether the transaction was committed. + */ + private static function write_chunks( $options, $chunk_size ) { + global $wpdb; + $table_name = self::get_table_name(); // Use `START TRANSACTION` (not `BEGIN`) so W3 Total Cache's DbCache layer @@ -207,7 +230,7 @@ public static function batch_insert( $options, $chunk_size = 500 ) { foreach ( $chunk as $option_name => $count ) { $placeholders[] = '(%s, %d, NOW())'; - $values[] = $option_name; + $values[] = (string) $option_name; $values[] = (int) $count; } @@ -216,11 +239,29 @@ public static function batch_insert( $options, $chunk_size = 500 ) { ON DUPLICATE KEY UPDATE access_count = access_count + VALUES(access_count)'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( $wpdb->prepare( $sql, ...$values ) ); + if ( false === $wpdb->query( $wpdb->prepare( $sql, ...$values ) ) ) { + // Keep the error: ROLLBACK succeeding would clear `$wpdb->last_error`. + $error = $wpdb->last_error; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( 'ROLLBACK' ); + $wpdb->last_error = $error; + return false; + } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( 'COMMIT' ); + return false !== $wpdb->query( 'COMMIT' ); + } + + /** + * Whether the last database error was an InnoDB deadlock (MySQL error 1213). + * + * @return bool + */ + private static function last_error_is_deadlock() { + global $wpdb; + + return false !== \stripos( (string) $wpdb->last_error, 'Deadlock found' ); } /**