diff --git a/changelog.txt b/changelog.txt index ed8045ea72..8547d810d7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -16,6 +16,7 @@ * Dev - Upgrades the Babel-related packages * Dev - Consolidate component used for unavailable payment methods * Dev - Update webhook unit tests to be compatible with WooCommerce 10.2 +* Add - Implement cache prefetch for payment method configuration = 9.9.1 - 2025-09-16 = * Add - Allow Klarna to be used for recurring payments and subscriptions diff --git a/includes/class-wc-stripe-database-cache-prefetch.php b/includes/class-wc-stripe-database-cache-prefetch.php new file mode 100644 index 0000000000..46c62a81d2 --- /dev/null +++ b/includes/class-wc-stripe-database-cache-prefetch.php @@ -0,0 +1,220 @@ + 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; + } + + /** + * 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; + } +} diff --git a/includes/class-wc-stripe-database-cache.php b/includes/class-wc-stripe-database-cache.php index e6e2503c48..d0a854aed3 100644 --- a/includes/class-wc-stripe-database-cache.php +++ b/includes/class-wc-stripe-database-cache.php @@ -101,6 +101,8 @@ public static function get( $key ) { return null; } + self::maybe_trigger_prefetch( $key, $cache_contents ); + return $cache_contents['data']; } @@ -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. @@ -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]. diff --git a/includes/class-wc-stripe-payment-method-configurations.php b/includes/class-wc-stripe-payment-method-configurations.php index b92f52cace..7adf26568f 100644 --- a/includes/class-wc-stripe-payment-method-configurations.php +++ b/includes/class-wc-stripe-payment-method-configurations.php @@ -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). diff --git a/includes/class-wc-stripe.php b/includes/class-wc-stripe.php index 133c7f8616..b6e07e4ab6 100644 --- a/includes/class-wc-stripe.php +++ b/includes/class-wc-stripe.php @@ -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'; @@ -293,6 +294,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 ); } /** diff --git a/readme.txt b/readme.txt index 8e16a1eeec..31241dd953 100644 --- a/readme.txt +++ b/readme.txt @@ -126,5 +126,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o * Dev - Upgrades the Babel-related packages * Dev - Consolidate component used for unavailable payment methods * Dev - Update webhook unit tests to be compatible with WooCommerce 10.2 +* 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). diff --git a/tests/phpunit/WC_Stripe_Database_Cache_Prefetch_Test.php b/tests/phpunit/WC_Stripe_Database_Cache_Prefetch_Test.php new file mode 100644 index 0000000000..9f0770bb6b --- /dev/null +++ b/tests/phpunit/WC_Stripe_Database_Cache_Prefetch_Test.php @@ -0,0 +1,130 @@ + [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, true ], + 'invalid_key_should_not_prefetch' => [ 'invalid_test_key', false ], + ]; + } + + /** + * Test {@see \WC_Stripe_Database_Cache_Prefetch::handle_prefetch_action()}. + * + * @param string $key The key to prefetch. + * @param bool $should_prefetch Whether we expect the key to be prefetched. + * + * @dataProvider provide_handle_prefetch_action_test_cases + */ + public function test_handle_prefetch_action( string $key, bool $should_prefetch ): void { + $mock_instance = $this->getMockBuilder( 'WC_Stripe_Database_Cache_Prefetch' ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'prefetch_cache_key' ] ) + ->getMock(); + + $expected_prefetch_count = $should_prefetch ? $this->once() : $this->never(); + + $mock_instance->expects( $expected_prefetch_count ) + ->method( 'prefetch_cache_key' ) + ->with( $key ) + ->willReturn( true ); + + $mock_instance->handle_prefetch_action( $key ); + } + + /** + * Provide test cases for {@see test_maybe_queue_prefetch()}. + * + * @return array + */ + public function provide_maybe_queue_prefetch_test_cases(): array { + return [ + 'invalid_key_should_not_prefetch' => [ 'invalid_test_key', 5, false ], + 'pmc_key_expires_in_60_seconds_should_not_prefetch' => [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, 60, false ], + 'pmc_key_expires_in_5_seconds_should_prefetch' => [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, 5, true ], + 'pmc_key_expires_in_5_seconds_with_option_set_2s_should_not_prefetch' => [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, 5, false, 2 ], + 'pmc_key_expires_in_5_seconds_with_option_set_-2s_should_not_prefetch' => [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, 5, false, -2 ], + 'pmc_key_expires_in_5_seconds_with_option_set_-11s_should_prefetch' => [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, 5, true, -11 ], + 'pmc_key_expires_in_5_seconds_with_invalid_option_should_prefetch' => [ \WC_Stripe_Payment_Method_Configurations::CONFIGURATION_CACHE_KEY, 5, true, 'invalid' ], + ]; + } + + /** + * Test {@see \WC_Stripe_Database_Cache_Prefetch::maybe_queue_prefetch()}. + * + * @param string $key The key to prefetch. + * @param int $expiry_time_adjustment The adjustment in seconds to the expiry time of the cache entry. Computed relative to the current time when the test is run. + * @param bool $should_enqueue_action Whether we expect the action to be enqueued. + * @param mixed $option_adjusted_time The time to set the option to. Null is no value set; an integer will be treated as an adjustment from the runtime timestamp; any other value will be written to the option. + * + * @dataProvider provide_maybe_queue_prefetch_test_cases + */ + public function test_maybe_queue_prefetch( string $key, int $expiry_time_adjustment, bool $should_enqueue_action, $option_adjusted_time = null ): void { + $instance = \WC_Stripe_Database_Cache_Prefetch::get_instance(); + + $mock_class = $this->getMockBuilder( \stdClass::class ) + ->addMethods( [ 'test_stub_callback' ] ) + ->getMock(); + + $mock_class->expects( $should_enqueue_action ? $this->once() : $this->never() ) + ->method( 'test_stub_callback' ) + ->with( null, \WC_Stripe_Database_Cache_Prefetch::ASYNC_PREFETCH_ACTION, [ $key ], 'woocommerce-gateway-stripe' ) + ->willReturn( 1 ); + + add_filter( 'pre_as_enqueue_async_action', [ $mock_class, 'test_stub_callback' ], 10, 4 ); + + $test_args = [ $key, $expiry_time_adjustment, $should_enqueue_action, $option_adjusted_time ]; + + $option_name = 'wcstripe_prefetch_' . $key; + $initial_option_value = null; + + $start_time = time(); + $expiry_time = $start_time + $expiry_time_adjustment; + + if ( null == $option_adjusted_time ) { + delete_option( $option_name ); + } elseif ( is_int( $option_adjusted_time ) ) { + $initial_option_value = $start_time + $option_adjusted_time; + } else { + $initial_option_value = $option_adjusted_time; + } + + if ( null !== $initial_option_value ) { + update_option( $option_name, $initial_option_value ); + } + + $instance->maybe_queue_prefetch( $key, $expiry_time ); + $end_time = time(); + + remove_filter( 'pre_as_enqueue_async_action', [ $mock_class, 'test_stub_callback' ], 10 ); + + $option_value = get_option( $option_name, false ); + + delete_option( $option_name ); + + if ( $should_enqueue_action ) { + $this->assertIsInt( $option_value ); + $this->assertGreaterThanOrEqual( $start_time, $option_value ); + $this->assertLessThanOrEqual( $end_time, $option_value ); + } elseif ( null === $initial_option_value ) { + $this->assertFalse( $option_value ); + } else { + $this->assertEquals( $initial_option_value, $option_value ); + } + } +}