Skip to content
Draft
4 changes: 4 additions & 0 deletions projects/packages/stats/changelog/update-blog_stats_debug
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Improve Post Stats cache handling for invalid or error data
68 changes: 47 additions & 21 deletions projects/packages/stats/src/class-wpcom-stats.php
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,11 @@ protected function fetch_stats( $args = array() ) {
*
* Unlike the above function, this caches data in the post meta table. As such,
* it prevents wp_options from blowing up when retrieving views for large numbers
* of posts at the same time. However, the final response is the same as above.
* of posts at the same time.
*
* This function returns valid arrays and WP_Error objects from cache if within the expiration period.
* If the cached entry is malformed or invalid, a refresh is triggered regardless of cache time.
* This self-healing behavior reduces API calls when remote fetch fails, but ensures data validity.
*
* @param array $args Query parameters.
* @param int $post_id Post ID to acquire stats for.
Expand All @@ -503,34 +507,56 @@ protected function fetch_post_stats( $args, $post_id ) {
if ( $stats_cache ) {
$data = reset( $stats_cache );

if (
! is_array( $data )
|| empty( $data )
|| is_wp_error( $data )
) {
return $data;
}
// Check if we have a valid cache structure with a time key.
if ( is_array( $data ) && ! empty( $data ) ) {
$time = key( $data );

$time = key( $data );
$views = $data[ $time ] ?? null;
// If we have a numeric time, check if cache is still valid.
if ( is_numeric( $time ) ) {
/** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
$expiration = apply_filters(
'jetpack_fetch_stats_cache_expiration',
self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
);

// Bail if data is malformed.
if ( ! is_numeric( $time ) || ! is_array( $views ) ) {
return $data;
}
// If within cache period, return cached data after type validation.
if ( ( time() - $time ) < $expiration ) {
$cached_value = $data[ $time ];

/** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
$expiration = apply_filters(
'jetpack_fetch_stats_cache_expiration',
self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
);
// If it's an array or WP_Error, add cached time and return to user.
if ( is_array( $cached_value ) || is_wp_error( $cached_value ) ) {
return array_merge( array( 'cached_at' => $time ), (array) $cached_value );
}

if ( ( time() - $time ) < $expiration ) {
return array_merge( array( 'cached_at' => $time ), $views );
// For any other unexpected type, treat as malformed cache.
// Fall through to refresh.
}
}
}
}

// Cache doesn't exist, is expired, or is malformed - refresh it.
return $this->refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name );
}

/**
* Force fetch stats from WPCOM, and always update cache.
*
* This function will cache the result regardless of whether the fetch succeeds
* or fails. This ensures that failed requests are also cached, reducing the
* frequency of API calls when the remote service is experiencing issues.
*
* @param string $endpoint The stats endpoint.
* @param array $args The query arguments.
* @param int $post_id The post ID.
* @param string $meta_name The meta name.
*
* @return array|WP_Error
*/
protected function refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ) {
$wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );

// Always cache the result, even if it's an error or empty.
update_post_meta( $post_id, $meta_name, array( time() => $wpcom_stats ) );

return $wpcom_stats;
Expand Down
283 changes: 283 additions & 0 deletions projects/packages/stats/tests/php/WPCOM_Stats_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,289 @@ public function test_get_stats_with_arguments() {
$this->assertSame( wp_json_encode( $expected_stats, JSON_UNESCAPED_SLASHES ), self::get_stats_transient( '/sites/1234/stats/', $args ) );
}

/**
* Test that invalid/malformed cache entries trigger a refresh.
*/
public function test_fetch_post_stats_with_malformed_cache_triggers_refresh() {
$post_id = 1234;
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// Set up malformed cache - a string instead of expected array structure.
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
update_post_meta( $post_id, $meta_name, 'malformed_string_data' );

$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh data, not the malformed cache.
$this->assertSame( $expected_stats, $stats );
}

/**
* Test that cache with invalid structure (no time key) triggers a refresh.
*/
public function test_fetch_post_stats_with_invalid_structure_triggers_refresh() {
$post_id = 1234;
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// Set up invalid cache structure - array with non-numeric key.
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
update_post_meta( $post_id, $meta_name, array( 'invalid_key' => array( 'data' => 'value' ) ) );

$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh data, not the invalid cache.
$this->assertSame( $expected_stats, $stats );
}

/**
* Test that cache with unexpected value type triggers a refresh.
*/
public function test_fetch_post_stats_with_unexpected_value_type_triggers_refresh() {
$post_id = 1234;
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// Set up cache with unexpected value type (string instead of array or WP_Error).
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$time = time();
update_post_meta( $post_id, $meta_name, array( $time => 'unexpected_string' ) );

$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh data, not the unexpected cached value.
$this->assertSame( $expected_stats, $stats );
}

/**
* Test that WP_Error cached entries are returned correctly within expiration period.
*/
public function test_fetch_post_stats_returns_cached_wp_error_within_expiration() {
$post_id = 1234;
$expected_error = new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );

// Set up cache with WP_Error within expiration period.
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$time = time() - 60; // 1 minute ago.
update_post_meta( $post_id, $meta_name, array( $time => $expected_error ) );

// Should not call fetch_remote_stats since cache is valid.
$this->wpcom_stats
->expects( $this->never() )
->method( 'fetch_remote_stats' );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the cached WP_Error as an array with cached_at timestamp.
// When a WP_Error is cached, it's converted to array and merged with cached_at.
$this->assertTrue( is_array( $stats ) );
$this->assertArrayHasKey( 'cached_at', $stats );
$this->assertSame( $time, $stats['cached_at'] );
// WP_Error properties are converted to array keys.
$this->assertArrayHasKey( 'errors', $stats );
}

/**
* Test that WP_Error cached entries are refreshed beyond expiration period.
*/
public function test_fetch_post_stats_refreshes_cached_wp_error_beyond_expiration() {
$post_id = 1234;
$cached_error = new WP_Error( 'old_error', 'Old error' );
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// Set up cache with WP_Error beyond expiration period (6 minutes ago, expiration is 5 minutes).
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$time = time() - ( 6 * MINUTE_IN_SECONDS );
update_post_meta( $post_id, $meta_name, array( $time => $cached_error ) );

// Should call fetch_remote_stats since cache is expired.
$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh data, not the expired cached error.
$this->assertSame( $expected_stats, $stats );
}

/**
* Test that valid array cached entries are returned correctly within expiration period.
*/
public function test_fetch_post_stats_returns_cached_array_within_expiration() {
$post_id = 1234;
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// Set up cache with array within expiration period.
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$time = time() - 60; // 1 minute ago.
update_post_meta( $post_id, $meta_name, array( $time => $expected_stats ) );

// Should not call fetch_remote_stats since cache is valid.
$this->wpcom_stats
->expects( $this->never() )
->method( 'fetch_remote_stats' );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the cached array with cached_at timestamp.
$this->assertArrayHasKey( 'cached_at', $stats );
$this->assertSame( $time, $stats['cached_at'] );
$this->assertSame( $expected_stats['date'], $stats['date'] );
$this->assertSame( $expected_stats['views'], $stats['views'] );
}

/**
* Test that refresh_post_stats_cache properly caches success responses.
*/
public function test_refresh_post_stats_cache_caches_success_response() {
$post_id = 1234;
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the stats.
$this->assertSame( $expected_stats, $stats );

// Verify the cache was updated.
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$stats_cache = get_post_meta( $post_id, $meta_name, true );

$this->assertIsArray( $stats_cache );
$this->assertCount( 1, $stats_cache );

$time = key( $stats_cache );
$this->assertTrue( is_numeric( $time ) );
$this->assertSame( $expected_stats, $stats_cache[ $time ] );
}

/**
* Test that refresh_post_stats_cache properly caches error responses.
*/
public function test_refresh_post_stats_cache_caches_error_response() {
$post_id = 1234;
$expected_error = new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );

$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_error );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh WP_Error directly (not wrapped in array).
$this->assertTrue( is_wp_error( $stats ) );
$this->assertSame( 'stats_error', $stats->get_error_code() );

// Verify the error was cached.
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$stats_cache = get_post_meta( $post_id, $meta_name, true );

$this->assertIsArray( $stats_cache );
$this->assertCount( 1, $stats_cache );

$time = key( $stats_cache );
$this->assertTrue( is_numeric( $time ) );
$this->assertInstanceOf( WP_Error::class, $stats_cache[ $time ] );
}

/**
* Test that cache expiration logic works correctly with the new validation.
*/
public function test_fetch_post_stats_respects_cache_expiration() {
$post_id = 1234;
$cached_stats = array(
'date' => '2022-09-28',
'views' => 50,
);
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// Set up cache beyond expiration period (6 minutes ago, expiration is 5 minutes).
$meta_name = '_' . WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX;
$time = time() - ( 6 * MINUTE_IN_SECONDS );
update_post_meta( $post_id, $meta_name, array( $time => $cached_stats ) );

// Should call fetch_remote_stats since cache is expired.
$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh data, not the expired cache.
$this->assertSame( $expected_stats, $stats );

// Verify the cache was updated with new timestamp.
$stats_cache = get_post_meta( $post_id, $meta_name, true );
$new_time = key( $stats_cache );
$this->assertGreaterThan( $time, $new_time );
$this->assertSame( $expected_stats, $stats_cache[ $new_time ] );
}

/**
* Test that empty cache triggers a refresh.
*/
public function test_fetch_post_stats_with_no_cache_triggers_refresh() {
$post_id = 1234;
$expected_stats = array(
'date' => '2022-09-29',
'views' => 100,
);

// No cache set up.

$this->wpcom_stats
->expects( $this->once() )
->method( 'fetch_remote_stats' )
->willReturn( $expected_stats );

$stats = $this->wpcom_stats->get_post_views( $post_id, array(), true );

// Should return the fresh data.
$this->assertSame( $expected_stats, $stats );
}

/**
* Helper for fetching the stats transient.
*
Expand Down