OrdersVersionStringInvalidator.php
6 months ago
ProductCache.php
7 months ago
ProductCacheController.php
1 month ago
ProductTransientsDeferrer.php
1 month ago
ProductVersionStringInvalidator.php
1 month ago
TaxRateVersionStringInvalidator.php
6 months ago
VersionStringGenerator.php
2 weeks ago
VersionStringGenerator.php
264 lines
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Internal\Caches; |
| 6 | |
| 7 | use Automattic\WooCommerce\Proxies\LegacyProxy; |
| 8 | |
| 9 | /** |
| 10 | * Version string generator/cache class. |
| 11 | * |
| 12 | * Provides a generic mechanism for generating and caching unique version strings |
| 13 | * for any identifiable item. Each item is identified by a string ID, and has |
| 14 | * an associated version string (UUID) that can be regenerated to invalidate caches. |
| 15 | * This is useful for cache invalidation strategies where items change over time. |
| 16 | * The standard WordPress cache is used to store the version strings. |
| 17 | */ |
| 18 | class VersionStringGenerator { |
| 19 | |
| 20 | /** |
| 21 | * Cache group name. |
| 22 | */ |
| 23 | private const CACHE_GROUP = 'woocommerce_version_strings'; |
| 24 | |
| 25 | /** |
| 26 | * Can the version string cache be used? |
| 27 | * |
| 28 | * @var bool|null |
| 29 | */ |
| 30 | private ?bool $can_use = null; |
| 31 | |
| 32 | /** |
| 33 | * Legacy proxy instance. |
| 34 | * |
| 35 | * @var LegacyProxy |
| 36 | */ |
| 37 | private LegacyProxy $legacy_proxy; |
| 38 | |
| 39 | /** |
| 40 | * Initialize the class dependencies. |
| 41 | * |
| 42 | * @internal |
| 43 | * |
| 44 | * @param LegacyProxy $legacy_proxy Legacy proxy instance. |
| 45 | */ |
| 46 | final public function init( LegacyProxy $legacy_proxy ) { |
| 47 | $this->legacy_proxy = $legacy_proxy; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Tells whether the version string cache can be used or not. |
| 52 | * |
| 53 | * This will return true only if an external object cache is configured in WordPress, |
| 54 | * since otherwise the cached entries will only persist for the current request. |
| 55 | * |
| 56 | * @return bool |
| 57 | */ |
| 58 | public function can_use(): bool { |
| 59 | if ( ! is_null( $this->can_use ) ) { |
| 60 | return $this->can_use; |
| 61 | } |
| 62 | |
| 63 | $this->can_use = $this->legacy_proxy->call_function( 'wp_using_ext_object_cache' ) ?? false; |
| 64 | |
| 65 | return $this->can_use; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Get the current version string for an ID. |
| 70 | * |
| 71 | * If no valid version exists and $generate is true, a new version will be created. |
| 72 | * If no valid version exists and $generate is false, null will be returned. |
| 73 | * |
| 74 | * Cached values that aren't non-empty strings are treated as invalid and are never |
| 75 | * returned. When $generate is true they are overwritten by the newly generated |
| 76 | * version; when $generate is false they are deleted, which means a call with |
| 77 | * $generate set to false can write to the cache. |
| 78 | * |
| 79 | * @param string $id The ID to get the version string for. |
| 80 | * @param bool $generate Whether to generate a new version if one doesn't exist. Default true. |
| 81 | * @return string|null Version string, or null if not found and $generate is false. |
| 82 | * @throws \InvalidArgumentException If id is invalid. |
| 83 | * |
| 84 | * @since 10.4.0 |
| 85 | */ |
| 86 | public function get_version( string $id, bool $generate = true ): ?string { |
| 87 | $this->validate_input( $id ); |
| 88 | |
| 89 | $cache_key = $this->get_cache_key( $id ); |
| 90 | $found = false; |
| 91 | $version = wp_cache_get( $cache_key, self::CACHE_GROUP, false, $found ); |
| 92 | |
| 93 | if ( ! is_string( $version ) || '' === $version ) { |
| 94 | $entry_exists = $this->cache_entry_exists( $version, $found ); |
| 95 | |
| 96 | if ( $entry_exists ) { |
| 97 | $this->log_invalid_cached_value( $id, $version, $generate ); |
| 98 | } |
| 99 | |
| 100 | if ( $generate ) { |
| 101 | // The new version is written to the same cache key, replacing the invalid |
| 102 | // value, so deleting it first would only add a redundant round-trip. |
| 103 | return $this->generate_version( $id ); |
| 104 | } |
| 105 | |
| 106 | if ( $entry_exists ) { |
| 107 | wp_cache_delete( $cache_key, self::CACHE_GROUP ); |
| 108 | } |
| 109 | |
| 110 | return null; |
| 111 | } |
| 112 | |
| 113 | // Refresh the cache lifetime. |
| 114 | $this->store_version( $id, $version ); |
| 115 | return $version; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Generate and store a new version string for an ID. |
| 120 | * The already existing version string, if any, will be replaced. |
| 121 | * |
| 122 | * @param string $id The ID to generate a version string for. |
| 123 | * @return string The new version string. |
| 124 | * @throws \InvalidArgumentException If id is invalid. |
| 125 | * |
| 126 | * @since 10.4.0 |
| 127 | */ |
| 128 | public function generate_version( string $id ): string { |
| 129 | $this->validate_input( $id ); |
| 130 | |
| 131 | $version = wp_generate_uuid4(); |
| 132 | $this->store_version( $id, $version ); |
| 133 | return $version; |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Store the version string in cache with a filterable TTL. |
| 138 | * |
| 139 | * @param string $id The ID to store the version string for. |
| 140 | * @param string $version The version string to store. |
| 141 | * @return bool True on success, false on failure. |
| 142 | */ |
| 143 | protected function store_version( string $id, string $version ): bool { |
| 144 | $cache_key = $this->get_cache_key( $id ); |
| 145 | |
| 146 | /** |
| 147 | * Filter the TTL for version string cache. |
| 148 | * |
| 149 | * @param int $ttl Time to live in seconds. Default 1 day. |
| 150 | * @param string $id The ID. |
| 151 | * |
| 152 | * @since 10.4.0 |
| 153 | */ |
| 154 | $ttl = apply_filters( 'woocommerce_version_string_generator_ttl', DAY_IN_SECONDS, $id ); |
| 155 | $ttl = max( 0, (int) $ttl ); |
| 156 | |
| 157 | $result = wp_cache_set( $cache_key, $version, self::CACHE_GROUP, $ttl ); |
| 158 | |
| 159 | if ( is_bool( $result ) ) { |
| 160 | return $result; |
| 161 | } |
| 162 | |
| 163 | // Some object cache implementations may return non-boolean values. |
| 164 | // Verify the store by reading the value back. |
| 165 | $found = false; |
| 166 | $stored_value = wp_cache_get( $cache_key, self::CACHE_GROUP, false, $found ); |
| 167 | if ( $stored_value === $version ) { |
| 168 | return true; |
| 169 | } |
| 170 | |
| 171 | // The stored value doesn't match; clean up and report failure. |
| 172 | if ( $this->cache_entry_exists( $stored_value, $found ) ) { |
| 173 | wp_cache_delete( $cache_key, self::CACHE_GROUP ); |
| 174 | } |
| 175 | return false; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Tell whether a value read from the cache is evidence that an entry is stored. |
| 180 | * |
| 181 | * Object cache drop-ins replace wp_cache_get() wholesale, so neither the returned |
| 182 | * value nor the found flag is trustworthy on its own: some drop-ins never populate |
| 183 | * $found, and some signal a miss with null rather than false. The value is therefore |
| 184 | * the primary evidence, with $found as a tie-breaker that tells a stored false apart |
| 185 | * from a genuine miss. $found is checked loosely because a drop-in may report it as a |
| 186 | * truthy non-boolean. |
| 187 | * |
| 188 | * @param mixed $value The value returned by wp_cache_get(). |
| 189 | * @param mixed $found The found flag as populated by wp_cache_get(), if it populates it at all. |
| 190 | * @return bool True if an entry appears to be stored, false if this looks like a miss. |
| 191 | */ |
| 192 | private function cache_entry_exists( $value, $found ): bool { |
| 193 | return (bool) $found || ( false !== $value && null !== $value ); |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * Log a value found in the version string cache that isn't a usable version string. |
| 198 | * |
| 199 | * This should never happen with a well-behaved object cache, so surface it for |
| 200 | * diagnosis rather than silently self-healing. |
| 201 | * |
| 202 | * @param string $id The ID the invalid value was cached for. |
| 203 | * @param mixed $value The invalid cached value. |
| 204 | * @param bool $regenerating Whether a replacement version is being generated. |
| 205 | * @return void |
| 206 | */ |
| 207 | private function log_invalid_cached_value( string $id, $value, bool $regenerating ): void { |
| 208 | $this->legacy_proxy->call_function( 'wc_get_logger' )->warning( |
| 209 | sprintf( |
| 210 | 'Discarded an invalid version string cache entry for ID "%1$s" (got %2$s); %3$s.', |
| 211 | $id, |
| 212 | gettype( $value ), |
| 213 | $regenerating ? 'the version will be regenerated' : 'the entry will be deleted' |
| 214 | ), |
| 215 | array( 'source' => 'version-string-generator' ) |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Delete the version string for an ID by deleting its cached entry. |
| 221 | * |
| 222 | * @param string $id The ID to delete the version string for. |
| 223 | * @return bool True on success, false on failure. |
| 224 | * @throws \InvalidArgumentException If id is invalid. |
| 225 | * |
| 226 | * @since 10.4.0 |
| 227 | */ |
| 228 | public function delete_version( string $id ): bool { |
| 229 | $this->validate_input( $id ); |
| 230 | |
| 231 | $cache_key = $this->get_cache_key( $id ); |
| 232 | $result = wp_cache_delete( $cache_key, self::CACHE_GROUP ); |
| 233 | |
| 234 | // Some object cache implementations may return non-boolean values. |
| 235 | return ! is_bool( $result ) || $result; |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * Get the cache key for an ID. |
| 240 | * |
| 241 | * The ID is hashed to ensure a consistent key length and avoid issues |
| 242 | * with special characters or very long IDs. |
| 243 | * |
| 244 | * @param string $id The ID to get the cache key for. |
| 245 | * @return string The cache key. |
| 246 | */ |
| 247 | private function get_cache_key( string $id ): string { |
| 248 | return 'wc_version_string_' . md5( $id ); |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Validate ID input. |
| 253 | * |
| 254 | * @param string $id The ID to validate. |
| 255 | * @return void |
| 256 | * @throws \InvalidArgumentException If id is invalid. |
| 257 | */ |
| 258 | private function validate_input( string $id ): void { |
| 259 | if ( '' === $id ) { |
| 260 | throw new \InvalidArgumentException( 'ID cannot be empty.' ); |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 |