Skip to content

Commit

Permalink
Options/Meta APIs: Optimize cache hits for non-existent options.
Browse files Browse the repository at this point in the history
Optimize the order of checking the various options caches in `get_option()` to prevent hitting external caches each time it is called for a known non-existent option.

The caches are checked in the following order when getting an option:

1. Check the `alloptions` cache first to prioritize existing loaded options.
2. Check the `notoptions` cache before a cache lookup or DB hit.
3. Check the `options` cache prior to a DB hit.

Follow up to [56595].

Props adamsilverstein, flixos90, ivankristianto, joemcgill, rmccue, siliconforks, spacedmonkey.
Fixes #62692.
See #58277.


git-svn-id: https://develop.svn.wordpress.org/trunk@59631 602fd350-edb4-49c9-b593-d223f7449a82
  • Loading branch information
peterwilsoncc committed Jan 15, 2025
1 parent d2630e0 commit 5b01d24
Show file tree
Hide file tree
Showing 2 changed files with 122 additions and 48 deletions.
59 changes: 34 additions & 25 deletions src/wp-includes/option.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,37 +162,46 @@ function get_option( $option, $default_value = false ) {

if ( ! wp_installing() ) {
$alloptions = wp_load_alloptions();

/*
* When getting an option value, we check in the following order for performance:
*
* 1. Check the 'alloptions' cache first to prioritize existing loaded options.
* 2. Check the 'notoptions' cache before a cache lookup or DB hit.
* 3. Check the 'options' cache prior to a DB hit.
* 4. Check the DB for the option and cache it in either the 'options' or 'notoptions' cache.
*/
if ( isset( $alloptions[ $option ] ) ) {
$value = $alloptions[ $option ];
} else {
// Check for non-existent options first to avoid unnecessary object cache lookups and DB hits.
$notoptions = wp_cache_get( 'notoptions', 'options' );

if ( ! is_array( $notoptions ) ) {
$notoptions = array();
wp_cache_set( 'notoptions', $notoptions, 'options' );
}

if ( isset( $notoptions[ $option ] ) ) {
/**
* Filters the default value for an option.
*
* The dynamic portion of the hook name, `$option`, refers to the option name.
*
* @since 3.4.0
* @since 4.4.0 The `$option` parameter was added.
* @since 4.7.0 The `$passed_default` parameter was added to distinguish between a `false` value and the default parameter value.
*
* @param mixed $default_value The default value to return if the option does not exist
* in the database.
* @param string $option Option name.
* @param bool $passed_default Was `get_option()` passed a default value?
*/
return apply_filters( "default_option_{$option}", $default_value, $option, $passed_default );
}

$value = wp_cache_get( $option, 'options' );

if ( false === $value ) {
// Prevent non-existent options from triggering multiple queries.
$notoptions = wp_cache_get( 'notoptions', 'options' );

// Prevent non-existent `notoptions` key from triggering multiple key lookups.
if ( ! is_array( $notoptions ) ) {
$notoptions = array();
wp_cache_set( 'notoptions', $notoptions, 'options' );
} elseif ( isset( $notoptions[ $option ] ) ) {
/**
* Filters the default value for an option.
*
* The dynamic portion of the hook name, `$option`, refers to the option name.
*
* @since 3.4.0
* @since 4.4.0 The `$option` parameter was added.
* @since 4.7.0 The `$passed_default` parameter was added to distinguish between a `false` value and the default parameter value.
*
* @param mixed $default_value The default value to return if the option does not exist
* in the database.
* @param string $option Option name.
* @param bool $passed_default Was `get_option()` passed a default value?
*/
return apply_filters( "default_option_{$option}", $default_value, $option, $passed_default );
}

$row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", $option ) );

Expand Down
111 changes: 88 additions & 23 deletions tests/phpunit/tests/option/option.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ public function test_get_option_notoptions_cache() {
wp_cache_set( 'notoptions', $notoptions, 'options' );

$before = get_num_queries();
$value = get_option( 'invalid' );
$after = get_num_queries();
get_option( 'invalid' );
$after = get_num_queries();

$this->assertSame( 0, $after - $before );
}
Expand All @@ -127,8 +127,8 @@ public function test_get_option_notoptions_set_cache() {
get_option( 'invalid' );

$before = get_num_queries();
$value = get_option( 'invalid' );
$after = get_num_queries();
get_option( 'invalid' );
$after = get_num_queries();

$notoptions = wp_cache_get( 'notoptions', 'options' );

Expand All @@ -137,25 +137,6 @@ public function test_get_option_notoptions_set_cache() {
$this->assertArrayHasKey( 'invalid', $notoptions, 'The "invalid" option should be in the notoptions cache.' );
}

/**
* @ticket 58277
*
* @covers ::get_option
*/
public function test_get_option_notoptions_do_not_load_cache() {
add_option( 'foo', 'bar', '', false );
wp_cache_delete( 'notoptions', 'options' );

$before = get_num_queries();
$value = get_option( 'foo' );
$after = get_num_queries();

$notoptions = wp_cache_get( 'notoptions', 'options' );

$this->assertSame( 0, $after - $before, 'The options cache was not hit on the second call to `get_option()`.' );
$this->assertFalse( $notoptions, 'The notoptions cache should not be set.' );
}

/**
* @covers ::get_option
* @covers ::add_option
Expand Down Expand Up @@ -548,4 +529,88 @@ public function test_add_option_clears_the_notoptions_cache() {
$updated_notoptions = wp_cache_get( 'notoptions', 'options' );
$this->assertArrayNotHasKey( $option_name, $updated_notoptions, 'The "foobar" option should not be in the notoptions cache after adding it.' );
}

/**
* Test that get_option() does not hit the external cache multiple times for the same option.
*
* @ticket 62692
*
* @covers ::get_option
*
* @dataProvider data_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option
*
* @param int $expected_connections Expected number of connections to the memcached server.
* @param bool $option_exists Whether the option should be set. Default true.
* @param string $autoload Whether the option should be auto loaded. Default true.
*/
public function test_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option( $expected_connections, $option_exists = true, $autoload = true ) {
if ( ! wp_using_ext_object_cache() ) {
$this->markTestSkipped( 'This test requires an external object cache.' );
}

if ( false === $this->helper_object_cache_stats_cmd_get() ) {
$this->markTestSkipped( 'This test requires access to the number of get requests to the external object cache.' );
}

if ( $option_exists ) {
add_option( 'ticket-62692', 'value', '', $autoload );
}

wp_cache_delete_multiple( array( 'ticket-62692', 'notoptions', 'alloptions' ), 'options' );

$connections_start = $this->helper_object_cache_stats_cmd_get();

$call_getter = 10;
while ( $call_getter-- ) {
get_option( 'ticket-62692' );
}

$connections_end = $this->helper_object_cache_stats_cmd_get();

$this->assertSame( $expected_connections, $connections_end - $connections_start );
}

/**
* Data provider.
*
* @return array[]
*/
public function data_get_option_does_not_hit_the_external_cache_multiple_times_for_the_same_option() {
return array(
'exists, autoload' => array( 1, true, true ),
'exists, not autoloaded' => array( 3, true, false ),
'does not exist' => array( 3, false ),
);
}

/**
* Helper function to get the number of get commands from the external object cache.
*
* @return int|false Number of get command calls, false if unavailable.
*/
public function helper_object_cache_stats_cmd_get() {
if ( ! wp_using_ext_object_cache() || ! function_exists( 'wp_cache_get_stats' ) ) {
return false;
}

$stats = wp_cache_get_stats();

// Check the shape of the stats.
if ( ! is_array( $stats ) ) {
return false;
}

// Get the first server's stats.
$stats = array_shift( $stats );

if ( ! is_array( $stats ) ) {
return false;
}

if ( ! array_key_exists( 'cmd_get', $stats ) ) {
return false;
}

return $stats['cmd_get'];
}
}

0 comments on commit 5b01d24

Please sign in to comment.