Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 99 additions & 5 deletions src/wp-admin/includes/class-wp-site-health.php
Original file line number Diff line number Diff line change
Expand Up @@ -2802,21 +2802,37 @@ public function get_test_search_engine_visibility() {
}

/**
* Tests if opcode cache is enabled and available.
* Tests if opcode cache is enabled and available, and checks for saturation.
*
* Detects three distinct saturation conditions when OPcache is enabled:
* memory full, interned strings buffer exhausted, and key/script table full.
*
* @since 7.0.0
*
* @return array<string, string|array<string, string>> The test result.
*/
public function get_test_opcode_cache(): array {
$opcode_cache_enabled = false;
$status = false;

if ( function_exists( 'opcache_get_status' ) ) {
$status = @opcache_get_status( false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warning emitted in failure case.
if ( $status && true === $status['opcache_enabled'] ) {
$opcode_cache_enabled = true;
}
}

/**
* Filters the OPcache status data used by the Site Health opcode cache test.
*
* Allows overriding the array returned by opcache_get_status() for testing
* or for environments where the built-in function does not reflect reality.
* Return false to indicate OPcache is unavailable.
*
* @since x.x.x
*
* @param array|false $status The OPcache status array, or false if unavailable.
*/
$status = apply_filters( 'wp_site_health_opcache_status', $status );

$opcode_cache_enabled = $status && isset( $status['opcache_enabled'] ) && true === $status['opcache_enabled'];

$result = array(
'label' => __( 'Opcode cache is enabled' ),
'status' => 'good',
Expand All @@ -2842,6 +2858,84 @@ public function get_test_opcode_cache(): array {
$result['status'] = 'recommended';
$result['label'] = __( 'Opcode cache is not enabled' );
$result['description'] .= '<p>' . __( 'Enabling this cache can significantly improve the performance of your site.' ) . '</p>';
return $result;
}

if ( $status ) {
$saturation_notices = array();

// Check if OPcache memory is full or has been reset due to memory exhaustion.
$memory_oom = isset( $status['opcache_statistics']['oom_restarts'] ) && $status['opcache_statistics']['oom_restarts'] > 0;
if ( ! empty( $status['cache_full'] ) || $memory_oom ) {
$saturation_notices[] = sprintf(
/* translators: %s: PHP ini setting name. */
__( 'The opcode cache memory is full and PHP is evicting scripts to make room for new ones, which reduces caching effectiveness. Consider increasing the %s PHP setting.' ),
'<code>opcache.memory_consumption</code>'
);
} elseif ( isset(
$status['memory_usage']['free_memory'],
$status['memory_usage']['used_memory'],
$status['memory_usage']['wasted_memory']
) ) {
$total_memory = $status['memory_usage']['free_memory'] + $status['memory_usage']['used_memory'] + $status['memory_usage']['wasted_memory'];
if ( $total_memory > 0 ) {
$used_percent = ( $status['memory_usage']['used_memory'] + $status['memory_usage']['wasted_memory'] ) / $total_memory;
if ( $used_percent > 0.9 ) {
$saturation_notices[] = sprintf(
/* translators: 1: Percentage of OPcache memory in use. 2: PHP ini setting name. */
__( 'The opcode cache memory is nearly full (%1$d%% used). Consider increasing the %2$s PHP setting.' ),
(int) ( $used_percent * 100 ),
'<code>opcache.memory_consumption</code>'
);
}
}
}

// Check if the interned strings buffer is nearly exhausted.
if ( isset( $status['interned_strings_usage']['free_memory'], $status['interned_strings_usage']['buffer_size'] )
&& $status['interned_strings_usage']['buffer_size'] > 0 ) {
$interned_free_ratio = $status['interned_strings_usage']['free_memory'] / $status['interned_strings_usage']['buffer_size'];
if ( $interned_free_ratio < 0.1 ) {
$saturation_notices[] = sprintf(
/* translators: 1: Percentage of OPcache interned strings buffer in use. 2: PHP ini setting name. */
__( 'The interned strings buffer is nearly exhausted (%1$d%% used). Consider increasing the %2$s PHP setting.' ),
(int) ( ( 1 - $interned_free_ratio ) * 100 ),
'<code>opcache.interned_strings_buffer</code>'
);
}
}

// Check if the script/key hash table has overflowed or is nearly full.
if ( ! empty( $status['opcache_statistics']['hash_restarts'] ) ) {
$saturation_notices[] = sprintf(
/* translators: %s: PHP ini setting name. */
__( 'The opcode cache key table has overflowed and been reset. Consider increasing the %s PHP setting.' ),
'<code>opcache.max_accelerated_files</code>'
);
} elseif ( isset( $status['opcache_statistics']['num_cached_keys'], $status['opcache_statistics']['max_cached_keys'] )
&& $status['opcache_statistics']['max_cached_keys'] > 0 ) {
$key_usage = $status['opcache_statistics']['num_cached_keys'] / $status['opcache_statistics']['max_cached_keys'];
if ( $key_usage > 0.9 ) {
$saturation_notices[] = sprintf(
/* translators: 1: Percentage of OPcache key slots in use. 2: PHP ini setting name. */
__( 'The opcode cache key table is nearly full (%1$d%% used). Consider increasing the %2$s PHP setting.' ),
(int) ( $key_usage * 100 ),
'<code>opcache.max_accelerated_files</code>'
);
}
}

if ( ! empty( $saturation_notices ) ) {
$result['status'] = 'recommended';
$result['label'] = __( 'Opcode cache is enabled, but appears saturated' );
foreach ( $saturation_notices as $notice ) {
$result['description'] .= '<p>' . $notice . '</p>';
}
$result['description'] .= sprintf(
'<p>%s</p>',
__( 'Ask your hosting provider or server administrator about increasing these opcode cache settings.' )
);
}
}

return $result;
Expand Down
195 changes: 193 additions & 2 deletions tests/phpunit/tests/admin/wpSiteHealth.php
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ public function test_get_test_opcode_cache_return_structure() {
* Tests get_test_opcode_cache() result when opcode cache is enabled or not.
*
* Covers: opcache enabled, disabled, not available, and opcache_get_status() returns false.
* When enabled, status may be 'good' or 'recommended' if the cache appears saturated.
*
* @ticket 63697
*
Expand All @@ -699,12 +700,202 @@ public function test_get_test_opcode_cache_result_by_environment() {
}

if ( $opcache_enabled ) {
$this->assertSame( 'good', $result['status'], 'When opcache is enabled, status should be "good".' );
$this->assertSame( __( 'Opcode cache is enabled' ), $result['label'] );
$this->assertContains(
$result['status'],
array( 'good', 'recommended' ),
'When opcache is enabled, status should be "good" or "recommended" if saturated.'
);
$this->assertContains(
$result['label'],
array( __( 'Opcode cache is enabled' ), __( 'Opcode cache is enabled, but appears saturated' ) )
);
} else {
$this->assertSame( 'recommended', $result['status'] );
$this->assertSame( __( 'Opcode cache is not enabled' ), $result['label'] );
$this->assertStringContainsString( __( 'Enabling this cache can significantly improve the performance of your site.' ), $result['description'] );
}
}

/**
* Returns a minimal healthy OPcache status array for use in tests.
*
* @return array A status array with generous free space in all pools.
*/
private function get_healthy_opcache_status(): array {
return array(
'opcache_enabled' => true,
'cache_full' => false,
'memory_usage' => array(
'used_memory' => 10 * MB_IN_BYTES,
'free_memory' => 118 * MB_IN_BYTES,
'wasted_memory' => 0,
),
'interned_strings_usage' => array(
'buffer_size' => 8 * MB_IN_BYTES,
'free_memory' => 7 * MB_IN_BYTES,
),
'opcache_statistics' => array(
'num_cached_keys' => 110,
'max_cached_keys' => 16229,
'oom_restarts' => 0,
'hash_restarts' => 0,
),
);
}

/**
* Tests get_test_opcode_cache() with a mocked OPcache status via the filter.
*
* @ticket 64854
*
* @dataProvider data_get_test_opcode_cache_saturation
*
* @covers ::get_test_opcode_cache()
*
* @param array $status_overrides Overrides to merge into the healthy base status.
* @param string $expected_status Expected 'status' value in the result.
* @param string $expected_label Expected 'label' value in the result.
* @param string $expected_in_description Substring expected in the description, or empty string to skip.
*/
public function test_get_test_opcode_cache_saturation( array $status_overrides, string $expected_status, string $expected_label, string $expected_in_description ) {
$base_status = $this->get_healthy_opcache_status();
$mock_status = array_replace_recursive( $base_status, $status_overrides );

add_filter(
'wp_site_health_opcache_status',
static function () use ( $mock_status ) {
return $mock_status;
}
);

$result = $this->instance->get_test_opcode_cache();

remove_all_filters( 'wp_site_health_opcache_status' );

$this->assertSame( $expected_status, $result['status'], "status mismatch for: $expected_label" );
$this->assertSame( $expected_label, $result['label'] );

if ( '' !== $expected_in_description ) {
$this->assertStringContainsString( $expected_in_description, $result['description'] );
}
}

/**
* Data provider for test_get_test_opcode_cache_saturation().
*
* Each entry provides:
* 1. array Overrides to merge into the healthy base status.
* 2. string Expected 'status' value.
* 3. string Expected 'label' value.
* 4. string Substring expected in description (empty to skip check).
*
* @return array[]
*/
public function data_get_test_opcode_cache_saturation(): array {
return array(
'healthy opcache is good' => array(
array(),
'good',
__( 'Opcode cache is enabled' ),
'',
),
'opcache disabled via filter' => array(
array( 'opcache_enabled' => false ),
'recommended',
__( 'Opcode cache is not enabled' ),
__( 'Enabling this cache can significantly improve the performance of your site.' ),
),
'cache_full flag set' => array(
array( 'cache_full' => true ),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.memory_consumption',
),
'oom_restarts greater than zero' => array(
array( 'opcache_statistics' => array( 'oom_restarts' => 3 ) ),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.memory_consumption',
),
'memory usage above 90 percent' => array(
array(
'memory_usage' => array(
'used_memory' => 115 * MB_IN_BYTES,
'free_memory' => 10 * MB_IN_BYTES,
'wasted_memory' => 3 * MB_IN_BYTES,
),
),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.memory_consumption',
),
'memory usage at 90 percent is still good' => array(
array(
'memory_usage' => array(
'used_memory' => 90 * MB_IN_BYTES,
'free_memory' => 10 * MB_IN_BYTES,
'wasted_memory' => 0,
),
),
'good',
__( 'Opcode cache is enabled' ),
'',
),
'interned strings below 10 percent free' => array(
array(
'interned_strings_usage' => array(
'buffer_size' => 8 * MB_IN_BYTES,
'free_memory' => 700 * KB_IN_BYTES,
'used_memory' => ( 8 * MB_IN_BYTES ) - ( 700 * KB_IN_BYTES ),
),
),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.interned_strings_buffer',
),
'interned strings at 10 percent free is good' => array(
array(
'interned_strings_usage' => array(
'buffer_size' => 10 * MB_IN_BYTES,
'free_memory' => 1 * MB_IN_BYTES,
'used_memory' => 9 * MB_IN_BYTES,
),
),
'good',
__( 'Opcode cache is enabled' ),
'',
),
'hash_restarts greater than zero' => array(
array( 'opcache_statistics' => array( 'hash_restarts' => 1 ) ),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.max_accelerated_files',
),
'key table above 90 percent full' => array(
array(
'opcache_statistics' => array(
'num_cached_keys' => 14900,
'max_cached_keys' => 16229,
),
),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.max_accelerated_files',
),
'multiple saturation issues at once' => array(
array(
'cache_full' => true,
'interned_strings_usage' => array(
'buffer_size' => 8 * MB_IN_BYTES,
'free_memory' => 100 * KB_IN_BYTES,
'used_memory' => ( 8 * MB_IN_BYTES ) - ( 100 * KB_IN_BYTES ),
),
'opcache_statistics' => array( 'hash_restarts' => 2 ),
),
'recommended',
__( 'Opcode cache is enabled, but appears saturated' ),
'opcache.interned_strings_buffer',
),
);
}
}
Loading