PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / class-metasync-otto-transient-cache.php

class-metasync-otto-transient-cache.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.10, at otto/class-metasync-otto-transient-cache.php

775 lines 28.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OTTO Transient Cache Manager
4 * Implements Option 1: On-Demand Transient Caching with mitigations
5 *
6 * Features:
7 * - Transient-based caching (30 min TTL)
8 * - Rate limiting (max 10 API calls per minute)
9 * - Request locking (prevents thundering herd)
10 * - API timeout with fallback
11 * - Stale cache fallback on API failure
12 */
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 class Metasync_Otto_Transient_Cache {
19
20 /**
21 * Transient prefix
22 */
23 private const TRANSIENT_PREFIX = 'otto_suggestions_';
24
25 /**
26 * Lock transient prefix
27 */
28 private const LOCK_PREFIX = 'otto_lock_';
29
30 /**
31 * Rate limit transient prefix
32 */
33 private const RATE_LIMIT_PREFIX = 'otto_api_rate_';
34
35 /**
36 * Stale cache prefix (for fallback)
37 */
38 private const STALE_PREFIX = 'otto_stale_';
39
40 /**
41 * Default TTL for suggestions (30 minutes)
42 */
43 public const SUGGESTIONS_TTL = 30 * MINUTE_IN_SECONDS;
44
45 /**
46 * TTL for "no suggestions" cache (5 minutes)
47 */
48 private const NO_SUGGESTIONS_TTL = 5 * MINUTE_IN_SECONDS;
49
50 /**
51 * Lock timeout (5 seconds)
52 */
53 private const LOCK_TIMEOUT = 5;
54
55 /**
56 * Max API calls per minute
57 */
58 public const MAX_API_CALLS_PER_MINUTE = 10;
59
60 /**
61 * API timeout (2 seconds)
62 */
63 private const API_TIMEOUT = 2;
64
65 /**
66 * OTTO UUID
67 */
68 private $otto_uuid;
69
70 /**
71 * OTTO API endpoint
72 */
73 private $api_endpoint;
74
75 /**
76 * API fetch timeout for the current operation.
77 * Page-load requests use the short API_TIMEOUT (2s).
78 * warm_cache() uses a longer timeout since it runs in a webhook handler.
79 */
80 private $fetch_timeout = self::API_TIMEOUT;
81
82 /**
83 * Constructor
84 */
85 public function __construct($otto_uuid) {
86 $this->otto_uuid = $otto_uuid;
87
88 # Use endpoint manager if available, otherwise fallback to production
89 if (class_exists('Metasync_Endpoint_Manager')) {
90 $this->api_endpoint = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
91 } else {
92 $this->api_endpoint = 'https://sa.searchatlas.com/api/v2/otto-url-details';
93 }
94 }
95
96 /**
97 * Cache status tracker (for header)
98 */
99 private static $cache_status = [];
100
101 /**
102 * Get OTTO suggestions for a URL (with transient caching)
103 *
104 * @param string $url The page URL
105 * @param string $track_key Optional key to track cache status for this request
106 * @return array|false OTTO suggestions data or false if none
107 */
108 public function get_suggestions($url, $track_key = null) {
109 if (empty($url) || empty($this->otto_uuid)) {
110 return false;
111 }
112
113 # PERFORMANCE OPTIMIZATION: Generate all cache keys once
114 $keys = $this->get_cache_keys($url);
115 $cache_status_key = $track_key ?: $keys['track'];
116
117 # Step 1: Check transient cache first
118 $cached = get_transient($keys['transient']);
119 if ($cached !== false) {
120 # Cache hit - return cached data
121 self::$cache_status[$cache_status_key] = 'HIT';
122 return $cached;
123 }
124
125 # Step 2: Check rate limit
126 if (!$this->can_make_api_call()) {
127 # Rate limited - try to use stale cache
128 $stale = get_transient($keys['stale']);
129 if ($stale !== false) {
130 # NEW: Structured error logging with category and code
131 if (class_exists('Metasync_Error_Logger')) {
132 Metasync_Error_Logger::log(
133 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
134 Metasync_Error_Logger::SEVERITY_INFO,
135 'OTTO API rate limited - using stale cache',
136 [
137 'url' => $url,
138 'fallback' => 'stale_cache',
139 'api_endpoint' => 'OTTO Suggestions API',
140 'operation' => 'get_suggestions'
141 ]
142 );
143 }
144
145 error_log('MetaSync OTTO: Rate limited, using stale cache for ' . $url);
146 self::$cache_status[$cache_status_key] = 'STALE';
147 return $stale;
148 }
149 # No stale cache available - return false
150 self::$cache_status[$cache_status_key] = 'RATE_LIMITED';
151 return false;
152 }
153
154 # Step 3: Acquire lock atomically (test-and-set).
155 # If another worker already holds the lock, wait briefly for them to populate
156 # the cache, then either return their result or bail out — DO NOT fall through
157 # to fetch_from_api() (that was the original bug: duplicate concurrent API calls).
158 if (!$this->acquire_lock_atomic($keys['lock'], self::LOCK_TIMEOUT)) {
159 usleep(500000); // 0.5 seconds
160 $cached = get_transient($keys['transient']);
161 if ($cached !== false) {
162 self::$cache_status[$cache_status_key] = 'HIT';
163 return $cached;
164 }
165 self::$cache_status[$cache_status_key] = 'LOCKED';
166 return false;
167 }
168
169 $suggestions = false;
170 try {
171 # Step 4: Fetch from API (with timeout and error handling)
172 $suggestions = $this->fetch_from_api($url);
173
174 # NitroPack purge removed from page-load path to prevent feedback loop:
175 # get_suggestions() runs on every visitor request when the transient expires,
176 # and purging NitroPack here causes it to flush its cache (and potentially the
177 # object cache holding our transients), triggering another API fetch → purge cycle.
178 # The webhook handler (otto_pixel.php → purge_single_url) already covers the
179 # legitimate case where suggestions change after an OTTO crawl.
180
181 # Step 5: Store result in transient
182 if ($suggestions && $this->has_payload($suggestions)) {
183 # Has suggestions - cache for 30 minutes
184 set_transient($keys['transient'], $suggestions, self::SUGGESTIONS_TTL);
185 # Also store as stale cache for fallback
186 set_transient($keys['stale'], $suggestions, self::SUGGESTIONS_TTL * 2);
187 self::$cache_status[$cache_status_key] = 'MISS'; // Cache miss, fetched from API
188 } elseif ($suggestions !== false) {
189 # API responded 200 OK but genuinely no OTTO suggestions for this URL.
190 # Cache the negative result for a short time to prevent hammering the API.
191 set_transient($keys['transient'], false, self::NO_SUGGESTIONS_TTL);
192 # WP-361: Wipe the stale fallback too. Without this, a rate-limit
193 # event within the next 60 min serves the previously-deployed (now
194 # undeployed) suggestion, keeping the wrong title alive on the page.
195 delete_transient($keys['stale']);
196 self::$cache_status[$cache_status_key] = 'NO_SUGGESTIONS';
197 } else {
198 # fetch_from_api() returned false — network error, timeout, or non-200 response.
199 # Do NOT cache the failure: a stale false would poison subsequent MISS requests
200 # (Kinsta sees MISS → PHP runs → transient HIT = false → OTTO skips → old title cached).
201 # Leave the transient empty so the very next page load retries the API call.
202 self::$cache_status[$cache_status_key] = 'API_ERROR';
203 }
204 } finally {
205 # Step 6: Release lock — guaranteed to run even if fetch_from_api() throws
206 # or the storage block errors, so we never leak the lock for the full TTL.
207 # Note: acquire_lock_atomic() uses raw SQL (INSERT IGNORE / CAS UPDATE) on
208 # the MySQL path, but delete_transient() via the WP API is safe here because
209 # the rows use autoload='no' and are not in the alloptions cache.
210 delete_transient($keys['lock']);
211 }
212
213 return $suggestions;
214 }
215
216 /**
217 * Get cache status for a request
218 *
219 * @param string $key The tracking key
220 * @return string Cache status (HIT, MISS, STALE, RATE_LIMITED, NO_SUGGESTIONS, LOCKED, UNKNOWN)
221 */
222 public static function get_cache_status($key) {
223 return self::$cache_status[$key] ?? 'UNKNOWN';
224 }
225
226 /**
227 * Check if URL has OTTO suggestions (quick check)
228 *
229 * @param string $url The page URL
230 * @return bool True if URL has suggestions
231 */
232 public function has_suggestions($url) {
233 $suggestions = $this->get_suggestions($url);
234 return $suggestions !== false && $this->has_payload($suggestions);
235 }
236
237 /**
238 * Invalidate cache for a URL
239 *
240 * @param string $url The page URL
241 * @return bool Success
242 */
243 public function invalidate($url) {
244 # PERFORMANCE OPTIMIZATION: Generate all cache keys once
245 $keys = $this->get_cache_keys($url);
246
247 delete_transient($keys['transient']);
248 delete_transient($keys['stale']);
249 delete_transient($keys['lock']);
250
251 return true;
252 }
253
254 /**
255 * Warm cache for a URL (pre-fetch and cache)
256 * Called from the OTTO webhook handler — NOT from a page load.
257 * Uses a longer API timeout so transients are reliably populated before
258 * the Kinsta/WP Engine cache purge fires, preventing the "false transient
259 * poisoning" race condition (MISS → false transient HIT → OTTO skips →
260 * old Yoast title cached).
261 *
262 * @param string $url The page URL
263 * @return array|false Suggestions or false
264 */
265 public function warm_cache($url) {
266 # Invalidate existing cache first
267 $this->invalidate($url);
268
269 # Use a longer timeout for this pre-warm request (webhook context, not page load).
270 $saved_timeout = $this->fetch_timeout;
271 $this->fetch_timeout = 8; // 8 seconds — enough for cross-region API calls
272
273 # Fetch and cache (with the longer timeout)
274 $result = $this->get_suggestions($url);
275
276 # Restore original timeout
277 $this->fetch_timeout = $saved_timeout;
278
279 return $result;
280 }
281
282 /**
283 * Fetch suggestions from OTTO API
284 *
285 * @param string $url The page URL
286 * @return array|false API response or false on failure
287 */
288 private function fetch_from_api($url) {
289 # Check if endpoint is in backoff mode (explicit check for better error handling)
290 if (class_exists('Metasync_API_Backoff_Manager')) {
291 $backoff_manager = Metasync_API_Backoff_Manager::get_instance();
292 if ($backoff_manager->is_endpoint_in_backoff($this->api_endpoint)) {
293 error_log('MetaSync OTTO: API call skipped - endpoint in backoff mode');
294 # Try to use stale cache if available
295 $stale = get_transient($this->get_stale_key($url));
296 if ($stale !== false) {
297 error_log('MetaSync OTTO: Using stale cache due to backoff');
298 return $stale;
299 }
300 return false;
301 }
302 }
303
304 # Construct API URL
305 $api_url = add_query_arg(
306 [
307 'url' => $url,
308 'uuid' => $this->otto_uuid,
309 ],
310 $this->api_endpoint
311 );
312
313 # Make API request — timeout comes from $this->fetch_timeout so warm_cache()
314 # can override it without affecting page-load requests.
315 $response = wp_remote_get($api_url, [
316 'timeout' => $this->fetch_timeout,
317 'redirection' => 5,
318 'user-agent' => 'MetaSync-OTTO-Transient-Cache/1.0',
319 'sslverify' => true,
320 ]);
321
322 # Check for errors
323 if (is_wp_error($response)) {
324 $error_message = $response->get_error_message();
325
326 # Check if error is due to backoff
327 if ($response->get_error_code() === 'api_backoff_active') {
328 error_log('MetaSync OTTO: API call blocked by backoff - ' . $error_message);
329 # Try to use stale cache
330 $stale = get_transient($this->get_stale_key($url));
331 if ($stale !== false) {
332 error_log('MetaSync OTTO: Using stale cache due to backoff');
333 return $stale;
334 }
335 } else {
336 error_log('MetaSync OTTO: API call failed for ' . $url . ' - ' . $error_message);
337 }
338 return false;
339 }
340
341 # Check response code
342 $response_code = wp_remote_retrieve_response_code($response);
343 if ($response_code !== 200) {
344 # For 429/503, the backoff manager will handle it automatically
345 # Try to use stale cache for these errors
346 if (in_array($response_code, [429, 503], true)) {
347 $stale = get_transient($this->get_stale_key($url));
348 if ($stale !== false) {
349 error_log('MetaSync OTTO: Using stale cache due to rate limit/service unavailable');
350 return $stale;
351 }
352 }
353 return false;
354 }
355
356 # Parse response body
357 $body = wp_remote_retrieve_body($response);
358 if (empty($body)) {
359 return false;
360 }
361
362 $data = json_decode($body, true);
363 if (json_last_error() !== JSON_ERROR_NONE) {
364 return false;
365 }
366
367 return $data;
368 }
369
370 /**
371 * Purge NitroPack cache for a specific URL
372 * Called when OTTO API returns new suggestions to ensure NitroPack regenerates cache with fresh content
373 * @param string $url The page URL to purge from NitroPack cache
374 * @return bool True if purge was attempted, false if NitroPack not available
375 */
376 private function purge_nitropack_cache($url) {
377 # Check if NitroPack functions are available
378 if (!function_exists('nitropack_sdk_purge')) {
379 return false;
380 }
381
382 try {
383 # Purge NitroPack's cache (both local and remote) for this URL
384 # Using nitropack_sdk_purge() ensures remote API cache is also cleared
385 return nitropack_sdk_purge($url, NULL, 'MetaSync OTTO API call - fresh suggestions fetched');
386 } catch (\Exception $e) {
387 return false;
388 }
389 }
390
391 /**
392 * Check if API response has payload (suggestions)
393 * Public method for external use
394 *
395 * @param array $data API response data
396 * @return bool True if has suggestions
397 */
398 public function has_payload($data) {
399 if (empty($data) || !is_array($data)) {
400 return false;
401 }
402
403 # WP-361: Undeployed OTTO responses ship harmless whitespace ("\n") and
404 # empty wrappers ({"images": {}}) that PHP's empty() reads as non-empty.
405 # Without these guards, has_payload() flagged undeployed pages as "live"
406 # and downstream code (otto_has_live_suggestions(), title filter) kept
407 # applying the stale _metasync_otto_title meta from the previous deploy.
408
409 $has_string = static function ($value) {
410 return is_string($value) && trim($value) !== '';
411 };
412
413 $has_container = static function ($value) {
414 if (!is_array($value) || empty($value)) {
415 return false;
416 }
417 foreach ($value as $child) {
418 if (!empty($child)) {
419 return true;
420 }
421 }
422 return false;
423 };
424
425 if (!empty($data['header_replacements']) && is_array($data['header_replacements'])) {
426 foreach ($data['header_replacements'] as $replacement) {
427 if (is_array($replacement) && !empty($replacement['recommended_value'])) {
428 return true;
429 }
430 }
431 }
432
433 return $has_string($data['header_html_insertion'] ?? '')
434 || $has_container($data['body_substitutions'] ?? [])
435 || $has_string($data['body_top_html_insertion'] ?? '')
436 || $has_string($data['body_bottom_html_insertion'] ?? '')
437 || $has_string($data['footer_html_insertion'] ?? '')
438 || $has_container($data['image_missing_alt'] ?? []);
439 }
440
441 /**
442 * Get OTTO API rate limit from execution settings
443 * Falls back to default constant if setting not configured
444 *
445 * @return int Rate limit (calls per minute)
446 */
447 private function get_rate_limit() {
448 $execution_settings = get_option('metasync_execution_settings', array());
449 if (isset($execution_settings['otto_rate_limit'])) {
450 return (int) $execution_settings['otto_rate_limit'];
451 }
452 // Fallback to default constant
453 return self::MAX_API_CALLS_PER_MINUTE;
454 }
455
456 /**
457 * Check if we can make an API call (rate limiting)
458 *
459 * @return bool True if under rate limit
460 * @note With a persistent object cache (Redis/Memcached) the increment is atomic and race-free.
461 * Without one, falls back to transients (minor TOCTOU race under concurrency, but the counter persists across requests).
462 */
463 private function can_make_api_call(): bool {
464 # Scope rate-limit key per site so each multisite subsite has its own
465 # 10 req/min budget. Without this, one subsite's traffic spike
466 # exhausts the network-wide bucket and silently rate-limits every
467 # other subsite for the rest of the minute. WP-293.
468 $site_id = is_multisite() ? get_current_blog_id() : 0;
469 # Create rate limit key (per site, per minute)
470 $rate_key = self::RATE_LIMIT_PREFIX . $site_id . '_' . date('Y-m-d-H-i');
471
472 # Get rate limit from execution settings
473 $rate_limit = $this->get_rate_limit();
474
475 if (wp_using_ext_object_cache()) {
476 # Atomic path — race-free on Redis/Memcached
477 wp_cache_add($rate_key, 0, 'otto_rate', MINUTE_IN_SECONDS);
478 $new_count = wp_cache_incr($rate_key, 1, 'otto_rate');
479 return $new_count <= $rate_limit;
480 }
481
482 # Transient fallback — persists across requests, minor race under concurrency
483 $count = (int) get_transient($rate_key);
484 if ($count >= $rate_limit) {
485 return false;
486 }
487 set_transient($rate_key, $count + 1, MINUTE_IN_SECONDS);
488 return true;
489 }
490
491 /**
492 * Atomically acquire a lock (test-and-set).
493 *
494 * On Redis/Memcached object cache backends, wp_cache_add() maps to SET NX —
495 * the kernel guarantees only one caller succeeds. On the MySQL transient
496 * backend we INSERT IGNORE the timeout and value rows; the unique key on
497 * option_name makes that atomic. For an expired-but-not-yet-deleted lock
498 * we use a compare-and-swap UPDATE so only one racing worker can claim it.
499 *
500 * @param string $lock_key Transient key for the lock (without _transient_ prefix)
501 * @param int $ttl Lock timeout in seconds
502 * @return bool True if this caller acquired the lock, false if it was already held
503 */
504 private function acquire_lock_atomic($lock_key, $ttl) {
505 # Fast path: external object cache (Redis/Memcached) — wp_cache_add is atomic.
506 if (wp_using_ext_object_cache()) {
507 return (bool) wp_cache_add($lock_key, '1', 'transient', $ttl);
508 }
509
510 # MySQL transient backend — emulate compare-and-set with INSERT IGNORE on the
511 # _transient_timeout_ row (which carries the unique option_name constraint).
512 global $wpdb;
513 $now = time();
514 $new_expires = $now + (int) $ttl;
515 $timeout_option = '_transient_timeout_' . $lock_key;
516 $value_option = '_transient_' . $lock_key;
517
518 $inserted = $wpdb->query(
519 $wpdb->prepare(
520 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
521 $timeout_option,
522 (string) $new_expires
523 )
524 );
525
526 if ($inserted === 1) {
527 # We won the insert race — now write the value row. INSERT IGNORE keeps it
528 # safe if a stale value row from a prior expired lock is still around.
529 $wpdb->query(
530 $wpdb->prepare(
531 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
532 $value_option,
533 '1'
534 )
535 );
536 return true;
537 }
538
539 # Insert was ignored — a timeout row already exists. Check whether the
540 # existing lock has expired so we can attempt to take it over.
541 $existing_expires = (int) $wpdb->get_var(
542 $wpdb->prepare(
543 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
544 $timeout_option
545 )
546 );
547
548 if ($existing_expires > $now) {
549 # Lock is still active — we did not acquire.
550 return false;
551 }
552
553 # Stale lock — try to claim it via compare-and-swap on the timeout column.
554 # Only the worker whose UPDATE actually changes a row wins the race.
555 $updated = $wpdb->query(
556 $wpdb->prepare(
557 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) = %d",
558 (string) $new_expires,
559 $timeout_option,
560 $existing_expires
561 )
562 );
563
564 if ($updated === 1) {
565 # We won the takeover. Make sure the value row exists.
566 $wpdb->query(
567 $wpdb->prepare(
568 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
569 $value_option,
570 '1'
571 )
572 );
573 return true;
574 }
575
576 return false;
577 }
578
579 /**
580 * PERFORMANCE OPTIMIZATION: Generate all cache keys at once
581 * Reduces redundant URL normalization and MD5 hashing
582 *
583 * @param string $url The page URL
584 * @return array All cache keys ['transient', 'lock', 'stale', 'track']
585 */
586 private function get_cache_keys($url) {
587 # Normalize URL once (remove trailing slash, lowercase)
588 $normalized = rtrim(strtolower($url), '/');
589 # Compute MD5 hash once
590 $hash = md5($normalized);
591 # Get site ID once
592 $site_id = is_multisite() ? get_current_blog_id() : 0;
593
594 # All keys scoped by $site_id so multisite subsites don't collide on a
595 # shared Redis/Memcached object cache. WP-293.
596 return [
597 'transient' => self::TRANSIENT_PREFIX . $site_id . '_' . $hash,
598 'lock' => self::LOCK_PREFIX . $site_id . '_' . $hash,
599 'stale' => self::STALE_PREFIX . $site_id . '_' . $hash,
600 'track' => 'otto_' . $site_id . '_' . $hash,
601 ];
602 }
603
604 /**
605 * Get transient key for URL
606 *
607 * @param string $url The page URL
608 * @return string Transient key
609 */
610 private function get_transient_key($url) {
611 $keys = $this->get_cache_keys($url);
612 return $keys['transient'];
613 }
614
615 /**
616 * Get lock key for URL
617 *
618 * @param string $url The page URL
619 * @return string Lock key
620 */
621 private function get_lock_key($url) {
622 $keys = $this->get_cache_keys($url);
623 return $keys['lock'];
624 }
625
626 /**
627 * Get stale cache key for URL
628 *
629 * @param string $url The page URL
630 * @return string Stale cache key
631 */
632 private function get_stale_key($url) {
633 $keys = $this->get_cache_keys($url);
634 return $keys['stale'];
635 }
636
637 /**
638 * Get cache statistics for debugging
639 *
640 * @param string $url The page URL
641 * @return array Statistics
642 */
643 public function get_stats($url) {
644 $transient_key = $this->get_transient_key($url);
645 $cached = get_transient($transient_key);
646
647 # Match the scoped rate-limit key produced by can_make_api_call(). WP-293.
648 $site_id = is_multisite() ? get_current_blog_id() : 0;
649 $rate_limit_key = self::RATE_LIMIT_PREFIX . $site_id . '_' . date('Y-m-d-H-i');
650
651 return [
652 'url' => $url,
653 'has_cache' => $cached !== false,
654 'has_suggestions' => $cached !== false && $this->has_payload($cached),
655 'cache_key' => $transient_key,
656 'rate_limit_key' => $rate_limit_key,
657 'current_rate_count' => wp_using_ext_object_cache()
658 ? (int) wp_cache_get($rate_limit_key, 'otto_rate')
659 : (int) get_transient($rate_limit_key),
660 ];
661 }
662
663 /**
664 * Clear all OTTO transient caches
665 *
666 * @return array Results with count of cleared items
667 */
668 public static function clear_all_transients() {
669 global $wpdb;
670
671 # PERFORMANCE OPTIMIZATION: Batch delete in single query instead of N+1 loop
672 # Build the LIKE conditions for all prefixes
673 $prefixes = [
674 self::TRANSIENT_PREFIX,
675 self::LOCK_PREFIX,
676 self::STALE_PREFIX,
677 self::RATE_LIMIT_PREFIX,
678 ];
679
680 # Build WHERE clause for all transient and timeout variants
681 $where_parts = [];
682 foreach ($prefixes as $prefix) {
683 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_' . $prefix . '%');
684 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_timeout_' . $prefix . '%');
685 }
686 $where_clause = implode(' OR ', $where_parts);
687
688 # Count entries before deletion
689 $count_query = "SELECT COUNT(*) FROM {$wpdb->options} WHERE " . $where_clause;
690 $cleared_count = (int) $wpdb->get_var($count_query);
691
692 # Batch delete all matching transients in single query
693 $delete_query = "DELETE FROM {$wpdb->options} WHERE " . $where_clause;
694 $wpdb->query($delete_query);
695
696 # Also clear object cache if available
697 if (function_exists('wp_cache_flush_group')) {
698 wp_cache_flush_group('transient');
699 wp_cache_flush_group('otto_rate');
700 }
701
702 delete_transient('metasync_otto_js_detected');
703
704 return [
705 'success' => true,
706 'cleared_count' => $cleared_count,
707 'message' => sprintf('Cleared %d OTTO transient cache entries', $cleared_count)
708 ];
709 }
710
711 /**
712 * Clear transient cache for a specific URL
713 *
714 * @param string $url The page URL
715 * @return array Results
716 */
717 public static function clear_url_transient($url) {
718 if (empty($url)) {
719 return [
720 'success' => false,
721 'message' => 'URL is required'
722 ];
723 }
724
725 # Normalize URL for transient key (matches get_transient_key)
726 $normalized = rtrim(strtolower($url), '/');
727 $site_id = is_multisite() ? get_current_blog_id() : 0;
728
729 # Use the same normalization as get_cache_keys() for all three keys.
730 # Previously lock/stale used raw $url (no normalization), which generated
731 # different hashes than the actual keys — they were never actually deleted.
732 $hash = md5($normalized);
733 # All three keys must include $site_id to match get_cache_keys(). WP-293.
734 $transient_key = self::TRANSIENT_PREFIX . $site_id . '_' . $hash;
735 $lock_key = self::LOCK_PREFIX . $site_id . '_' . $hash;
736 $stale_key = self::STALE_PREFIX . $site_id . '_' . $hash;
737
738 $keys_to_clear = [$transient_key, $lock_key, $stale_key];
739
740 $cleared_count = 0;
741 foreach ($keys_to_clear as $key) {
742 if (delete_transient($key)) {
743 $cleared_count++;
744 }
745 }
746
747 return [
748 'success' => true,
749 'cleared_count' => $cleared_count,
750 'url' => $url,
751 'message' => sprintf('Cleared cache for URL: %s (%d entries)', $url, $cleared_count)
752 ];
753 }
754
755 /**
756 * Get count of cached transients
757 *
758 * @return int Number of cached transients
759 */
760 public static function get_cache_count() {
761 global $wpdb;
762
763 $count = $wpdb->get_var(
764 $wpdb->prepare(
765 "SELECT COUNT(*) FROM {$wpdb->options}
766 WHERE option_name LIKE %s",
767 '_transient_' . self::TRANSIENT_PREFIX . '%'
768 )
769 );
770
771 return (int) $count;
772 }
773 }
774
775