Skip to content
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
* Update - Add nightly task and WooCommerce tool to remove stale entries from our database cache
* Dev - Make 'Add to cart' more robust in e2e tests
* Dev - Ensure e2e tests enable or disable Optimized Checkout during setup
* Add - Implement cache prefetch for payment method configuration

= 9.8.1 - 2025-08-15 =
* Fix - Remove connection type requirement from PMC sync migration attempt
Expand Down
220 changes: 220 additions & 0 deletions includes/class-wc-stripe-database-cache-prefetch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<?php

defined( 'ABSPATH' ) || exit; // block direct access.

/**
* Class WC_Stripe_Database_Cache_Prefetch
*
* This class is responsible for prefetching cache keys.
*/
class WC_Stripe_Database_Cache_Prefetch {
/**
* The action used for the asynchronous cache prefetch code.
*
* @var string
*/
public const ASYNC_PREFETCH_ACTION = 'wc_stripe_database_cache_prefetch_async';

/**
* Configuration for cache prefetching.
*
* @var int[]
*/
protected const PREFETCH_CONFIG = [
WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY => 10,
];

/**
* The prefix used for prefetch tracking options.
*
* @var string
*/
private const PREFETCH_OPTION_PREFIX = 'wcstripe_prefetch_';

/**
* The singleton instance.
*/
private static ?WC_Stripe_Database_Cache_Prefetch $instance = null;

/**
* Protected constructor to support singleton pattern.
*/
protected function __construct() {}

/**
* Get the singleton instance.
*
* @return WC_Stripe_Database_Cache_Prefetch The singleton instance.
*/
public static function get_instance(): WC_Stripe_Database_Cache_Prefetch {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}

/**
* Check if the unprefixed cache key has prefetch enabled.
*
* @param string $key The unprefixed cache key to check.
* @return bool True if the cache key can be prefetched, false otherwise.
*/
public function should_prefetch_cache_key( string $key ): bool {
return isset( self::PREFETCH_CONFIG[ $key ] ) && self::PREFETCH_CONFIG[ $key ] > 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional:

Suggested change
return isset( self::PREFETCH_CONFIG[ $key ] ) && self::PREFETCH_CONFIG[ $key ] > 0;
return ( self::PREFETCH_CONFIG[ $key ] ?? 0 ) > 0;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a filter here to provide a way for merchants to disable the prefetch for a specific key if they want.

}

/**
* Maybe queue a prefetch for a cache key.
*
* @param string $key The unprefixed cache key to prefetch.
* @param int $expiry_time The expiry time of the cache entry.
*/
public function maybe_queue_prefetch( string $key, int $expiry_time ): void {
if ( ! $this->should_prefetch_cache_key( $key ) ) {
return;
}

$prefetch_window = self::PREFETCH_CONFIG[ $key ];

// If now plus the prefetch window is before the expiry time, do not trigger a prefetch.
if ( ( time() + $prefetch_window ) < $expiry_time ) {
return;
}

$logging_context = [
'cache_key' => $key,
'expiry_time' => $expiry_time,
];

if ( $this->is_prefetch_queued( $key ) ) {
WC_Stripe_Logger::debug( 'Cache prefetch already pending', $logging_context );
return;
}

if ( ! did_action( 'action_scheduler_init' ) || ! function_exists( 'as_enqueue_async_action' ) ) {
WC_Stripe_Logger::debug( 'Unable to enqueue cache prefetch: Action Scheduler is not initialized or available', $logging_context );
return;
}

$prefetch_option_key = $this->get_prefetch_option_name( $key );

$result = as_enqueue_async_action( self::ASYNC_PREFETCH_ACTION, [ $key ], 'woocommerce-gateway-stripe' );
if ( 0 === $result ) {
WC_Stripe_Logger::warning( 'Failed to enqueue cache prefetch', $logging_context );
} else {
update_option( $prefetch_option_key, time() );
WC_Stripe_Logger::debug( 'Enqueued cache prefetch', $logging_context );
}
}

/**
* Check if a prefetch is already queued up.
*
* @param string $key The unprefixed cache key to check.
* @return bool True if a prefetch is queued up, false otherwise.
*/
private function is_prefetch_queued( string $key ): bool {
if ( ! isset( self::PREFETCH_CONFIG[ $key ] ) ) {
return false;
}

$prefetch_option_key = $this->get_prefetch_option_name( $key );

$prefetch_option = get_option( $prefetch_option_key, false );
// We use ctype_digit() and the (string) cast to ensure we handle the option value being returned as a string.
if ( ! ctype_digit( (string) $prefetch_option ) ) {
return false;
}

$now = time();
$prefetch_window = self::PREFETCH_CONFIG[ $key ];

if ( $prefetch_option >= ( $now - $prefetch_window ) ) {
// If the prefetch entry expires in the future, or falls within the prefetch window for the key, we should consider the item live and queued.
// We use a prefetch window buffer to account for latency on the prefetch processing and to make sure we don't prefetch more than once during the prefetch window.
return true;
}

return false;
}

/**
* Get the name of the prefetch tracking option for a given cache key.
*
* @param string $key The unprefixed cache key to get the option name for.
* @return string The name of the prefetch option.
*/
private function get_prefetch_option_name( string $key ): string {
return self::PREFETCH_OPTION_PREFIX . $key;
}

/**
* Handle the prefetch action. We are generally expecting this to be queued up by Action Scheduler using
* the action from {@see ASYNC_PREFETCH_ACTION}.
*
* @param string $key The unprefixed cache key to prefetch.
* @return void
*/
public function handle_prefetch_action( $key ): void {
if ( ! is_string( $key ) || empty( $key ) ) {
WC_Stripe_Logger::warning(
'Invalid cache prefetch key',
[
'cache_key' => $key,
'reason' => 'invalid_key',
]
);
return;
}

if ( ! $this->should_prefetch_cache_key( $key ) ) {
WC_Stripe_Logger::warning(
'Invalid cache prefetch key',
[
'cache_key' => $key,
'reason' => 'unsupported_cache_key',
]
);
return;
}

$this->prefetch_cache_key( $key );

// Regardless of whether the prefetch was successful or not, we should remove the prefetch tracking option.
delete_option( $this->get_prefetch_option_name( $key ) );
}

/**
* Helper method to implement prefetch/repopulation for supported cache entries.
*
* @param string $key The unprefixed cache key to prefetch.
* @return bool|null True if the prefetch was successful, false if the prefetch failed, or null if the prefetch was not attempted.
*/
protected function prefetch_cache_key( string $key ): ?bool {
$prefetched = null;

switch ( $key ) {
case WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY:
if ( WC_Stripe_Payment_Method_Configurations::is_enabled() ) {
WC_Stripe_Payment_Method_Configurations::get_upe_enabled_payment_method_ids( true );
$prefetched = true;
} else {
$prefetched = false;
WC_Stripe_Logger::debug( 'Unable to prefetch PMC cache as settings sync is disabled', [ 'cache_key' => $key ] );
}
break;
default:
break;
}

if ( true === $prefetched ) {
WC_Stripe_Logger::debug( 'Successfully prefetched cache key', [ 'cache_key' => $key ] );
} elseif ( null === $prefetched ) {
WC_Stripe_Logger::warning( 'Prefetch cache key not handled', [ 'cache_key' => $key ] );
} else {
WC_Stripe_Logger::debug( 'Failed to prefetch cache key', [ 'cache_key' => $key ] );
}

return $prefetched;
}
}
54 changes: 49 additions & 5 deletions includes/class-wc-stripe-database-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ public static function get( $key ) {
return null;
}

self::maybe_trigger_prefetch( $key, $cache_contents );

return $cache_contents['data'];
}

Expand Down Expand Up @@ -209,18 +211,17 @@ private static function get_from_cache( $prefixed_key ) {
* @return boolean True if the contents are expired. False otherwise.
*/
private static function is_expired( $prefixed_key, $cache_contents ) {
if ( ! is_array( $cache_contents ) || ! isset( $cache_contents['updated'] ) || ! isset( $cache_contents['ttl'] ) ) {
if ( ! is_array( $cache_contents ) ) {
// Treat bad/invalid cache contents as expired
return true;
}

// Double-check that we have integers for `updated` and `ttl`.
if ( ! is_int( $cache_contents['updated'] ) || ! is_int( $cache_contents['ttl'] ) ) {
$expires = self::get_expiry_time( $cache_contents );
if ( null === $expires ) {
return true;
}

$expires = $cache_contents['updated'] + $cache_contents['ttl'];
$now = time();
$now = time();

/**
* Filters the result of the database cache entry expiration check.
Expand All @@ -236,6 +237,49 @@ private static function is_expired( $prefixed_key, $cache_contents ) {
return apply_filters( 'wc_stripe_database_cache_is_expired', $expires < $now, $prefixed_key, $cache_contents );
}

/**
* Get the expiry time for a cache entry. Includes validation for time-related fields in the array.
*
* @param array $cache_contents The cache contents.
*
* @return int|null The expiry time as a timestamp. Null if the expiry time can't be determined.
*/
private static function get_expiry_time( array $cache_contents ): ?int {
// If we don't have updated and ttl keys, expiry time is unknown.
if ( ! isset( $cache_contents['updated'], $cache_contents['ttl'] ) ) {
return null;
}

// If we don't have integers for updated and ttl, expiry time is unknown.
if ( ! is_int( $cache_contents['updated'] ) || ! is_int( $cache_contents['ttl'] ) ) {
return null;
}

return $cache_contents['updated'] + $cache_contents['ttl'];
}

/**
* Maybe trigger a cache prefetch.
*
* @param string $key The unprefixed cache key.
* @param array $cache_contents The cache contents.
*
* @return void
*/
private static function maybe_trigger_prefetch( string $key, array $cache_contents ): void {
$prefetch = WC_Stripe_Database_Cache_Prefetch::get_instance();
if ( ! $prefetch->should_prefetch_cache_key( $key ) ) {
return;
}

$expires = self::get_expiry_time( $cache_contents );
if ( null === $expires ) {
return;
}

$prefetch->maybe_queue_prefetch( $key, $expires );
}

/**
* Adds the CACHE_KEY_PREFIX + plugin mode prefix to the key.
* Ex: "wcstripe_cache_[mode]_[key].
Expand Down
2 changes: 1 addition & 1 deletion includes/class-wc-stripe-payment-method-configurations.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class WC_Stripe_Payment_Method_Configurations {
*
* @var string
*/
const CONFIGURATION_CACHE_KEY = 'payment_method_configuration';
public const CONFIGURATION_CACHE_KEY = 'payment_method_configuration';

/**
* The payment method configuration cache expiration (TTL).
Expand Down
4 changes: 4 additions & 0 deletions includes/class-wc-stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public function init() {
require_once WC_STRIPE_PLUGIN_PATH . '/includes/class-wc-stripe-helper.php';
require_once WC_STRIPE_PLUGIN_PATH . '/includes/class-wc-stripe-database-cache.php';
require_once WC_STRIPE_PLUGIN_PATH . '/includes/class-wc-stripe-payment-method-configurations.php';
require_once WC_STRIPE_PLUGIN_PATH . '/includes/class-wc-stripe-database-cache-prefetch.php';
include_once WC_STRIPE_PLUGIN_PATH . '/includes/class-wc-stripe-api.php';
include_once WC_STRIPE_PLUGIN_PATH . '/includes/class-wc-stripe-mode.php';
require_once WC_STRIPE_PLUGIN_PATH . '/includes/compat/class-wc-stripe-subscriptions-helper.php';
Expand Down Expand Up @@ -290,6 +291,9 @@ public function init() {

add_action( WC_Stripe_Database_Cache::ASYNC_CLEANUP_ACTION, [ WC_Stripe_Database_Cache::class, 'delete_all_stale_entries_async' ], 10, 2 );
add_action( 'action_scheduler_run_recurring_actions_schedule_hook', [ WC_Stripe_Database_Cache::class, 'maybe_schedule_daily_async_cleanup' ], 10, 0 );

// Handle the async cache prefetch action.
add_action( WC_Stripe_Database_Cache_Prefetch::ASYNC_PREFETCH_ACTION, [ WC_Stripe_Database_Cache_Prefetch::get_instance(), 'handle_prefetch_action' ], 10, 1 );
}

/**
Expand Down
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
* Update - Add nightly task and WooCommerce tool to remove stale entries from our database cache
* Dev - Make 'Add to cart' more robust in e2e tests
* Dev - Ensure e2e tests enable or disable Optimized Checkout during setup
* Add - Implement cache prefetch for payment method configuration

[See changelog for full details across versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).
Loading
Loading