From 0617fe61258d9e0a158ba2d1ba0fc82056771fcf Mon Sep 17 00:00:00 2001 From: Marcin Dudek Date: Thu, 23 Apr 2026 13:24:21 +0200 Subject: [PATCH] Cache API: Cache non-existent users in WP_User::get_data_by() to prevent duplicate queries. When get_userdata() or WP_User::get_data_by('id', $id) is called for a non-existent user ID, the result is now cached as a per-ID key ('notuser_$id') in the 'users' cache group. Subsequent calls return false immediately without hitting the database. The cache entry expires after one day as a safety net for persistent object cache backends. The cache is invalidated by clean_user_cache(), which is the canonical invalidation path called by both wp_insert_user() and wp_update_user(). When called with a plain integer (as wp_insert_user() does), the notuser key is cleared *before* constructing a new WP_User internally, preventing a self-referential cache miss. Using per-ID keys (rather than a shared array) avoids unbounded memory growth under persistent object cache backends and eliminates read-modify-write race conditions. Fixes #46388. Co-Authored-By: Claude Sonnet 4.6 --- src/wp-includes/class-wp-user.php | 9 ++ src/wp-includes/user.php | 7 ++ tests/phpunit/tests/user/getDataBy.php | 115 +++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/phpunit/tests/user/getDataBy.php diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index 63c3f8d04bf6a..1873de5814ca1 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -190,6 +190,7 @@ public function init( $data, $site_id = 0 ) { * * @since 3.3.0 * @since 4.4.0 Added 'ID' as an alias of 'id' for the `$field` parameter. + * @since 7.1.0 Non-existent users looked up by ID are now cached to prevent duplicate queries. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -251,6 +252,11 @@ public static function get_data_by( $field, $value ) { } } + // For ID lookups, check if this user has been cached as non-existent. + if ( 'id' === $field && wp_cache_get( 'notuser_' . $user_id, 'users' ) ) { + return false; + } + $user = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE $db_field = %s LIMIT 1", @@ -258,6 +264,9 @@ public static function get_data_by( $field, $value ) { ) ); if ( ! $user ) { + if ( 'id' === $field ) { + wp_cache_set( 'notuser_' . $user_id, true, 'users', DAY_IN_SECONDS ); + } return false; } diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 9c635f63d288a..78a05956aa6cd 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2013,11 +2013,17 @@ function update_user_caches( $user ) { * @since 3.0.0 * @since 4.4.0 'clean_user_cache' action was added. * @since 6.2.0 User metadata caches are now cleared. + * @since 7.1.0 The non-existent user cache is cleared before looking up user data. * * @param WP_User|int $user User object or ID to be cleaned from the cache */ function clean_user_cache( $user ) { if ( is_numeric( $user ) ) { + /* + * Clear the non-existent user cache before constructing WP_User, so that + * get_data_by() can reach the database and return the real user data. + */ + wp_cache_delete( 'notuser_' . (int) $user, 'users' ); $user = new WP_User( $user ); } @@ -2025,6 +2031,7 @@ function clean_user_cache( $user ) { return; } + wp_cache_delete( 'notuser_' . $user->ID, 'users' ); wp_cache_delete( $user->ID, 'users' ); wp_cache_delete( $user->user_login, 'userlogins' ); wp_cache_delete( $user->user_nicename, 'userslugs' ); diff --git a/tests/phpunit/tests/user/getDataBy.php b/tests/phpunit/tests/user/getDataBy.php new file mode 100644 index 0000000000000..d91c28abd0f0d --- /dev/null +++ b/tests/phpunit/tests/user/getDataBy.php @@ -0,0 +1,115 @@ +num_queries; + get_userdata( $nonexistent_id ); + $after_first_call = $wpdb->num_queries; + + get_userdata( $nonexistent_id ); + $after_second_call = $wpdb->num_queries; + + $this->assertSame( 1, $after_first_call - $before, 'First call for non-existent user should trigger one DB query.' ); + $this->assertSame( 0, $after_second_call - $after_first_call, 'Second call for non-existent user should not trigger any DB queries.' ); + } + + /** + * @ticket 46388 + * @covers WP_User::get_data_by + */ + public function test_nonexistent_user_by_id_is_added_to_notuser_cache() { + $nonexistent_id = PHP_INT_MAX - 1; + + wp_cache_delete( 'notuser_' . $nonexistent_id, 'users' ); + wp_cache_delete( $nonexistent_id, 'users' ); + + get_userdata( $nonexistent_id ); + + $this->assertNotFalse( + wp_cache_get( 'notuser_' . $nonexistent_id, 'users' ), + 'Non-existent user ID should be stored in the notuser cache after a DB miss.' + ); + } + + /** + * @ticket 46388 + * @covers WP_User::get_data_by + */ + public function test_existing_user_by_id_is_not_added_to_notuser_cache() { + $user_id = self::factory()->user->create(); + + get_userdata( $user_id ); + + $this->assertFalse( + wp_cache_get( 'notuser_' . $user_id, 'users' ), + 'Existing user ID should not be stored in the notuser cache.' + ); + } + + /** + * Verifies that clean_user_cache() called with a plain integer (as in wp_insert_user()) + * clears the notuser cache before constructing WP_User internally, + * preventing a self-referential cache miss. + * + * @ticket 46388 + * @covers ::clean_user_cache + */ + public function test_clean_user_cache_with_integer_clears_notuser_cache() { + $user_id = self::factory()->user->create(); + + // Manually poison the notuser cache for this user. + wp_cache_delete( $user_id, 'users' ); + wp_cache_set( 'notuser_' . $user_id, true, 'users' ); + + // wp_insert_user() calls clean_user_cache() with a plain integer. + clean_user_cache( $user_id ); + + $this->assertFalse( + wp_cache_get( 'notuser_' . $user_id, 'users' ), + 'clean_user_cache() with an integer must clear notuser_ before constructing WP_User.' + ); + } + + /** + * Verifies that clean_user_cache() called with a WP_User object (as in wp_update_user()) + * also clears the notuser cache. + * + * @ticket 46388 + * @covers ::clean_user_cache + */ + public function test_clean_user_cache_with_wp_user_object_clears_notuser_cache() { + $user_id = self::factory()->user->create(); + $user_obj = get_userdata( $user_id ); + + // Manually poison the notuser cache. + wp_cache_set( 'notuser_' . $user_id, true, 'users' ); + + // wp_update_user() calls clean_user_cache() with a WP_User object. + clean_user_cache( $user_obj ); + + $this->assertFalse( + wp_cache_get( 'notuser_' . $user_id, 'users' ), + 'clean_user_cache() with a WP_User object must clear the notuser_ cache entry.' + ); + } +}