PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.11
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.11
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.11, at otto/class-metasync-otto-transient-cache.php

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