From a468df0fb4f1d779956a4c45cc4b2ac05aa027cb Mon Sep 17 00:00:00 2001 From: Michael Etokakpan Date: Sat, 20 Jun 2026 18:08:14 +0100 Subject: [PATCH] Cache API: Avoid redundant cache invalidation in wp_insert_* functions during bulk meta input. When inserting posts, comments, or users with multiple meta entries via meta_input or comment_meta, the wp_cache_set_*_last_changed() callback (hooked to added_*_meta/updated_*_meta) fired once per meta key, causing N redundant cache writes for N meta entries. This temporarily suspends the per-key _last_changed callback during the bulk meta loop and restores it afterward. The parent functions (clean_post_cache(), etc.) already invalidate the cache once at the end of the insertion, so no explicit re-trigger is needed. Affected functions: - wp_insert_post() meta_input loop - wp_insert_comment() and wp_update_comment() comment_meta loops - wp_insert_user() meta loops (both add and update branches) Fixes #65485. --- src/wp-includes/comment.php | 24 ++ src/wp-includes/post.php | 15 ++ src/wp-includes/user.php | 12 + .../tests/cache/redundantMetaInvalidation.php | 237 ++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 tests/phpunit/tests/cache/redundantMetaInvalidation.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b93908adc0519..49d1acbf5525c 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2160,9 +2160,19 @@ function wp_insert_comment( $commentdata ) { // If metadata is provided, store it. if ( isset( $commentdata['comment_meta'] ) && is_array( $commentdata['comment_meta'] ) ) { + /* + * Temporarily suspend the per-meta-key `last_changed` cache invalidation + * to avoid redundant cache writes during bulk comment meta input. + */ + $has_added_action = remove_action( 'added_comment_meta', 'wp_cache_set_comments_last_changed' ); + foreach ( $commentdata['comment_meta'] as $meta_key => $meta_value ) { add_comment_meta( $comment->comment_ID, $meta_key, $meta_value, true ); } + + if ( $has_added_action ) { + add_action( 'added_comment_meta', 'wp_cache_set_comments_last_changed' ); + } } /** @@ -2733,9 +2743,23 @@ function wp_update_comment( $commentarr, $wp_error = false ) { // If metadata is provided, store it. if ( isset( $commentarr['comment_meta'] ) && is_array( $commentarr['comment_meta'] ) ) { + /* + * Temporarily suspend the per-meta-key `last_changed` cache invalidation + * to avoid redundant cache writes during bulk comment meta updates. + */ + $has_updated_action = remove_action( 'updated_comment_meta', 'wp_cache_set_comments_last_changed' ); + $has_added_action = remove_action( 'added_comment_meta', 'wp_cache_set_comments_last_changed' ); + foreach ( $commentarr['comment_meta'] as $meta_key => $meta_value ) { update_comment_meta( $comment_id, $meta_key, $meta_value ); } + + if ( $has_updated_action ) { + add_action( 'updated_comment_meta', 'wp_cache_set_comments_last_changed' ); + } + if ( $has_added_action ) { + add_action( 'added_comment_meta', 'wp_cache_set_comments_last_changed' ); + } } clean_comment_cache( $comment_id ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..c74b5bd2a44bb 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -5019,9 +5019,24 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) } if ( ! empty( $postarr['meta_input'] ) ) { + /* + * Temporarily suspend the per-meta-key `last_changed` cache invalidation + * to avoid redundant cache writes during bulk meta input. A single + * invalidation is triggered below once the loop completes. + */ + $has_updated_action = remove_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ); + $has_added_action = remove_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ); + foreach ( $postarr['meta_input'] as $field => $value ) { update_post_meta( $post_id, $field, $value ); } + + if ( $has_updated_action ) { + add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ); + } + if ( $has_added_action ) { + add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ); + } } $current_guid = get_post_field( 'guid', $post_id ); diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 7c856f0963634..6aa2dbb62f655 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2637,14 +2637,26 @@ function wp_insert_user( $userdata ) { if ( $update ) { // Update user meta. + $has_updated_action = remove_action( 'updated_user_meta', 'wp_cache_set_users_last_changed' ); + foreach ( $meta as $key => $value ) { update_user_meta( $user_id, $key, $value ); } + + if ( $has_updated_action ) { + add_action( 'updated_user_meta', 'wp_cache_set_users_last_changed' ); + } } else { // Add user meta. + $has_added_action = remove_action( 'added_user_meta', 'wp_cache_set_users_last_changed' ); + foreach ( $meta as $key => $value ) { add_user_meta( $user_id, $key, $value ); } + + if ( $has_added_action ) { + add_action( 'added_user_meta', 'wp_cache_set_users_last_changed' ); + } } foreach ( wp_get_user_contact_methods( $user ) as $key => $value ) { diff --git a/tests/phpunit/tests/cache/redundantMetaInvalidation.php b/tests/phpunit/tests/cache/redundantMetaInvalidation.php new file mode 100644 index 0000000000000..0c5e0e75b1a68 --- /dev/null +++ b/tests/phpunit/tests/cache/redundantMetaInvalidation.php @@ -0,0 +1,237 @@ +posts_last_changed_calls = 0; + $this->comments_last_changed_calls = 0; + $this->users_last_changed_calls = 0; + + // Ensure the last_changed keys are pre-initialized so that reads via + // wp_cache_get_last_changed() do not trigger a write during the test. + wp_cache_get_last_changed( 'posts' ); + wp_cache_get_last_changed( 'comment' ); + wp_cache_get_last_changed( 'users' ); + + $self = $this; + + add_action( + 'wp_cache_set_last_changed', + function ( $group ) use ( $self ) { + switch ( $group ) { + case 'posts': + ++$self->posts_last_changed_calls; + break; + case 'comment': + ++$self->comments_last_changed_calls; + break; + case 'users': + ++$self->users_last_changed_calls; + break; + } + } + ); + } + + /** + * Inserting a post with 5 meta_input entries should not trigger a + * last_changed cache write for every meta key. Without the fix, this + * would fire 5 times during the meta loop. + */ + public function test_wp_insert_post_meta_input_avoids_redundant_cache_writes() { + $meta_count = 5; + + // Insert a baseline post without meta to account for any + // post-insert side effects that legitimately invalidate the cache. + $this->posts_last_changed_calls = 0; + wp_insert_post( + array( + 'post_title' => 'Baseline Post', + 'post_status' => 'publish', + 'post_type' => 'post', + ) + ); + $baseline_writes = $this->posts_last_changed_calls; + + $this->posts_last_changed_calls = 0; + + wp_insert_post( + array( + 'post_title' => 'Test Post', + 'post_status' => 'publish', + 'post_type' => 'post', + 'meta_input' => array_fill_keys( array_map( 'strval', range( 1, $meta_count ) ), 'value' ), + ) + ); + + // The number of cache writes should not exceed the baseline (no meta) + // writes plus a single invalidation for the meta loop. + $this->assertLessThanOrEqual( + $baseline_writes + 1, + $this->posts_last_changed_calls, + 'Bulk meta_input should not trigger a redundant last_changed write per meta key.' + ); + } + + /** + * Inserting a comment with comment_meta should not trigger a redundant + * last_changed cache write per meta key. + */ + public function test_wp_insert_comment_comment_meta_avoids_redundant_cache_writes() { + $post_id = self::factory()->post->create(); + $meta_count = 5; + + // Establish a baseline by inserting a comment without meta. + $this->comments_last_changed_calls = 0; + wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'Baseline comment', + ) + ); + $baseline_writes = $this->comments_last_changed_calls; + + $this->comments_last_changed_calls = 0; + + wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'Test comment', + 'comment_meta' => array_fill_keys( array_map( 'strval', range( 1, $meta_count ) ), 'value' ), + ) + ); + + $this->assertLessThanOrEqual( + $baseline_writes + 1, + $this->comments_last_changed_calls, + 'Bulk comment_meta should not trigger a redundant last_changed write per meta key.' + ); + } + + /** + * Inserting a user with meta_input should not trigger a redundant + * last_changed cache write per meta key. + */ + public function test_wp_insert_user_meta_input_avoids_redundant_cache_writes() { + $meta_count = 5; + + // Establish a baseline by inserting a user without custom meta_input. + $this->users_last_changed_calls = 0; + wp_insert_user( + array( + 'user_login' => 'baselineuser65485', + 'user_email' => 'baselineuser65485@example.org', + 'user_pass' => 'password', + 'role' => 'subscriber', + ) + ); + $baseline_writes = $this->users_last_changed_calls; + + $this->users_last_changed_calls = 0; + + wp_insert_user( + array( + 'user_login' => 'testuser65485', + 'user_email' => 'testuser65485@example.org', + 'user_pass' => 'password', + 'role' => 'subscriber', + 'meta_input' => array_fill_keys( array_map( 'strval', range( 1, $meta_count ) ), 'value' ), + ) + ); + + $this->assertLessThanOrEqual( + $baseline_writes + 1, + $this->users_last_changed_calls, + 'Bulk user meta_input should not trigger a redundant last_changed write per meta key.' + ); + } + + /** + * Updating a comment with comment_meta should not trigger a redundant + * last_changed cache write per meta key. + */ + public function test_wp_update_comment_comment_meta_avoids_redundant_cache_writes() { + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'Test comment', + ) + ); + + // Establish a baseline by updating the comment without meta. + $this->comments_last_changed_calls = 0; + wp_update_comment( + array( + 'comment_ID' => $comment_id, + 'comment_content' => 'Baseline update', + ) + ); + $baseline_writes = $this->comments_last_changed_calls; + + $this->comments_last_changed_calls = 0; + + wp_update_comment( + array( + 'comment_ID' => $comment_id, + 'comment_meta' => array_fill_keys( array_map( 'strval', range( 1, 5 ) ), 'updated_value' ), + ) + ); + + $this->assertLessThanOrEqual( + $baseline_writes + 1, + $this->comments_last_changed_calls, + 'Bulk comment_meta updates should not trigger a redundant last_changed write per meta key.' + ); + } + + /** + * Sanity check: without meta_input, a single update_post_meta call still + * invalidates the cache. Ensures the suspension is correctly scoped to the + * bulk loop only and does not leak. + */ + public function test_single_meta_update_still_invalidates_cache() { + $post_id = self::factory()->post->create(); + + $this->posts_last_changed_calls = 0; + update_post_meta( $post_id, 'solo_key', 'solo_value' ); + + $this->assertGreaterThan( 0, $this->posts_last_changed_calls, 'A single meta update should still invalidate the last_changed cache.' ); + } +}