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

627 lines 21.0 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 if another process is fetching (lock)
126 if (get_transient($keys['lock']) !== false) {
127 # Another process is fetching - wait briefly and retry
128 usleep(500000); // 0.5 seconds
129 $cached = get_transient($keys['transient']);
130 if ($cached !== false) {
131 self::$cache_status[$cache_status_key] = 'HIT'; // Got it from other process
132 return $cached;
133 }
134 # Still no cache - proceed with our own fetch
135 }
136
137 # Step 3: Check rate limit
138 if (!$this->can_make_api_call()) {
139 # Rate limited - try to use stale cache
140 $stale = get_transient($keys['stale']);
141 if ($stale !== false) {
142 # NEW: Structured error logging with category and code
143 if (class_exists('Metasync_Error_Logger')) {
144 Metasync_Error_Logger::log(
145 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
146 Metasync_Error_Logger::SEVERITY_INFO,
147 'OTTO API rate limited - using stale cache',
148 [
149 'url' => $url,
150 'fallback' => 'stale_cache',
151 'api_endpoint' => 'OTTO Suggestions API',
152 'operation' => 'get_suggestions'
153 ]
154 );
155 }
156
157 error_log('MetaSync OTTO: Rate limited, using stale cache for ' . $url);
158 self::$cache_status[$cache_status_key] = 'STALE';
159 return $stale;
160 }
161 # No stale cache available - return false
162 self::$cache_status[$cache_status_key] = 'RATE_LIMITED';
163 return false;
164 }
165
166 # Step 4: Acquire lock
167 set_transient($keys['lock'], true, self::LOCK_TIMEOUT);
168
169 # Step 5: Fetch from API (with timeout and error handling)
170 $suggestions = $this->fetch_from_api($url);
171
172 # NitroPack purge removed from page-load path to prevent feedback loop:
173 # get_suggestions() runs on every visitor request when the transient expires,
174 # and purging NitroPack here causes it to flush its cache (and potentially the
175 # object cache holding our transients), triggering another API fetch → purge cycle.
176 # The webhook handler (otto_pixel.php → purge_single_url) already covers the
177 # legitimate case where suggestions change after an OTTO crawl.
178
179 # Step 6: Store result in transient
180 if ($suggestions && $this->has_payload($suggestions)) {
181 # Has suggestions - cache for 30 minutes
182 set_transient($keys['transient'], $suggestions, self::SUGGESTIONS_TTL);
183 # Also store as stale cache for fallback
184 set_transient($keys['stale'], $suggestions, self::SUGGESTIONS_TTL * 2);
185 self::$cache_status[$cache_status_key] = 'MISS'; // Cache miss, fetched from API
186 } elseif ($suggestions !== false) {
187 # API responded 200 OK but genuinely no OTTO suggestions for this URL.
188 # Cache the negative result for a short time to prevent hammering the API.
189 set_transient($keys['transient'], false, self::NO_SUGGESTIONS_TTL);
190 self::$cache_status[$cache_status_key] = 'NO_SUGGESTIONS';
191 } else {
192 # fetch_from_api() returned false — network error, timeout, or non-200 response.
193 # Do NOT cache the failure: a stale false would poison subsequent MISS requests
194 # (Kinsta sees MISS → PHP runs → transient HIT = false → OTTO skips → old title cached).
195 # Leave the transient empty so the very next page load retries the API call.
196 self::$cache_status[$cache_status_key] = 'API_ERROR';
197 }
198
199 # Step 7: Release lock
200 delete_transient($keys['lock']);
201
202 return $suggestions;
203 }
204
205 /**
206 * Get cache status for a request
207 *
208 * @param string $key The tracking key
209 * @return string Cache status (HIT, MISS, STALE, RATE_LIMITED, NO_SUGGESTIONS)
210 */
211 public static function get_cache_status($key) {
212 return self::$cache_status[$key] ?? 'UNKNOWN';
213 }
214
215 /**
216 * Check if URL has OTTO suggestions (quick check)
217 *
218 * @param string $url The page URL
219 * @return bool True if URL has suggestions
220 */
221 public function has_suggestions($url) {
222 $suggestions = $this->get_suggestions($url);
223 return $suggestions !== false && $this->has_payload($suggestions);
224 }
225
226 /**
227 * Invalidate cache for a URL
228 *
229 * @param string $url The page URL
230 * @return bool Success
231 */
232 public function invalidate($url) {
233 # PERFORMANCE OPTIMIZATION: Generate all cache keys once
234 $keys = $this->get_cache_keys($url);
235
236 delete_transient($keys['transient']);
237 delete_transient($keys['stale']);
238 delete_transient($keys['lock']);
239
240 return true;
241 }
242
243 /**
244 * Warm cache for a URL (pre-fetch and cache)
245 * Called from the OTTO webhook handler — NOT from a page load.
246 * Uses a longer API timeout so transients are reliably populated before
247 * the Kinsta/WP Engine cache purge fires, preventing the "false transient
248 * poisoning" race condition (MISS → false transient HIT → OTTO skips →
249 * old Yoast title cached).
250 *
251 * @param string $url The page URL
252 * @return array|false Suggestions or false
253 */
254 public function warm_cache($url) {
255 # Invalidate existing cache first
256 $this->invalidate($url);
257
258 # Use a longer timeout for this pre-warm request (webhook context, not page load).
259 $saved_timeout = $this->fetch_timeout;
260 $this->fetch_timeout = 8; // 8 seconds — enough for cross-region API calls
261
262 # Fetch and cache (with the longer timeout)
263 $result = $this->get_suggestions($url);
264
265 # Restore original timeout
266 $this->fetch_timeout = $saved_timeout;
267
268 return $result;
269 }
270
271 /**
272 * Fetch suggestions from OTTO API
273 *
274 * @param string $url The page URL
275 * @return array|false API response or false on failure
276 */
277 private function fetch_from_api($url) {
278 # Check if endpoint is in backoff mode (explicit check for better error handling)
279 if (class_exists('Metasync_API_Backoff_Manager')) {
280 $backoff_manager = Metasync_API_Backoff_Manager::get_instance();
281 if ($backoff_manager->is_endpoint_in_backoff($this->api_endpoint)) {
282 error_log('MetaSync OTTO: API call skipped - endpoint in backoff mode');
283 # Try to use stale cache if available
284 $stale = get_transient($this->get_stale_key($url));
285 if ($stale !== false) {
286 error_log('MetaSync OTTO: Using stale cache due to backoff');
287 return $stale;
288 }
289 return false;
290 }
291 }
292
293 # Construct API URL
294 $api_url = add_query_arg(
295 [
296 'url' => $url,
297 'uuid' => $this->otto_uuid,
298 ],
299 $this->api_endpoint
300 );
301
302 # Make API request — timeout comes from $this->fetch_timeout so warm_cache()
303 # can override it without affecting page-load requests.
304 $response = wp_remote_get($api_url, [
305 'timeout' => $this->fetch_timeout,
306 'redirection' => 5,
307 'user-agent' => 'MetaSync-OTTO-Transient-Cache/1.0',
308 'sslverify' => true,
309 ]);
310
311 # Check for errors
312 if (is_wp_error($response)) {
313 $error_message = $response->get_error_message();
314
315 # Check if error is due to backoff
316 if ($response->get_error_code() === 'api_backoff_active') {
317 error_log('MetaSync OTTO: API call blocked by backoff - ' . $error_message);
318 # Try to use stale cache
319 $stale = get_transient($this->get_stale_key($url));
320 if ($stale !== false) {
321 error_log('MetaSync OTTO: Using stale cache due to backoff');
322 return $stale;
323 }
324 } else {
325 error_log('MetaSync OTTO: API call failed for ' . $url . ' - ' . $error_message);
326 }
327 return false;
328 }
329
330 # Check response code
331 $response_code = wp_remote_retrieve_response_code($response);
332 if ($response_code !== 200) {
333 # For 429/503, the backoff manager will handle it automatically
334 # Try to use stale cache for these errors
335 if (in_array($response_code, [429, 503], true)) {
336 $stale = get_transient($this->get_stale_key($url));
337 if ($stale !== false) {
338 error_log('MetaSync OTTO: Using stale cache due to rate limit/service unavailable');
339 return $stale;
340 }
341 }
342 return false;
343 }
344
345 # Parse response body
346 $body = wp_remote_retrieve_body($response);
347 if (empty($body)) {
348 return false;
349 }
350
351 $data = json_decode($body, true);
352 if (json_last_error() !== JSON_ERROR_NONE) {
353 return false;
354 }
355
356 return $data;
357 }
358
359 /**
360 * Purge NitroPack cache for a specific URL
361 * Called when OTTO API returns new suggestions to ensure NitroPack regenerates cache with fresh content
362 * @param string $url The page URL to purge from NitroPack cache
363 * @return bool True if purge was attempted, false if NitroPack not available
364 */
365 private function purge_nitropack_cache($url) {
366 # Check if NitroPack functions are available
367 if (!function_exists('nitropack_sdk_purge')) {
368 return false;
369 }
370
371 try {
372 # Purge NitroPack's cache (both local and remote) for this URL
373 # Using nitropack_sdk_purge() ensures remote API cache is also cleared
374 return nitropack_sdk_purge($url, NULL, 'MetaSync OTTO API call - fresh suggestions fetched');
375 } catch (\Exception $e) {
376 return false;
377 }
378 }
379
380 /**
381 * Check if API response has payload (suggestions)
382 * Public method for external use
383 *
384 * @param array $data API response data
385 * @return bool True if has suggestions
386 */
387 public function has_payload($data) {
388 if (empty($data) || !is_array($data)) {
389 return false;
390 }
391
392 return !empty($data['header_replacements']) ||
393 !empty($data['header_html_insertion']) ||
394 !empty($data['body_substitutions']) ||
395 !empty($data['body_top_html_insertion']) ||
396 !empty($data['body_bottom_html_insertion']) ||
397 !empty($data['footer_html_insertion']);
398 }
399
400 /**
401 * Get OTTO API rate limit from execution settings
402 * Falls back to default constant if setting not configured
403 *
404 * @return int Rate limit (calls per minute)
405 */
406 private function get_rate_limit() {
407 $execution_settings = get_option('metasync_execution_settings', array());
408 if (isset($execution_settings['otto_rate_limit'])) {
409 return (int) $execution_settings['otto_rate_limit'];
410 }
411 // Fallback to default constant
412 return self::MAX_API_CALLS_PER_MINUTE;
413 }
414
415 /**
416 * Check if we can make an API call (rate limiting)
417 *
418 * @return bool True if under rate limit
419 */
420 private function can_make_api_call() {
421 # Create rate limit key (per minute)
422 $rate_key = self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i');
423
424 # Get current count
425 $count = (int) get_transient($rate_key);
426
427 # Get rate limit from execution settings
428 $rate_limit = $this->get_rate_limit();
429
430 # Check if under limit
431 if ($count >= $rate_limit) {
432 return false;
433 }
434
435 # Increment counter
436 set_transient($rate_key, $count + 1, MINUTE_IN_SECONDS);
437
438 return true;
439 }
440
441 /**
442 * PERFORMANCE OPTIMIZATION: Generate all cache keys at once
443 * Reduces redundant URL normalization and MD5 hashing
444 *
445 * @param string $url The page URL
446 * @return array All cache keys ['transient', 'lock', 'stale', 'track']
447 */
448 private function get_cache_keys($url) {
449 # Normalize URL once (remove trailing slash, lowercase)
450 $normalized = rtrim(strtolower($url), '/');
451 # Compute MD5 hash once
452 $hash = md5($normalized);
453 # Get site ID once
454 $site_id = is_multisite() ? get_current_blog_id() : 0;
455
456 return [
457 'transient' => self::TRANSIENT_PREFIX . $site_id . '_' . $hash,
458 'lock' => self::LOCK_PREFIX . $hash,
459 'stale' => self::STALE_PREFIX . $hash,
460 'track' => 'otto_' . $hash,
461 ];
462 }
463
464 /**
465 * Get transient key for URL
466 *
467 * @param string $url The page URL
468 * @return string Transient key
469 */
470 private function get_transient_key($url) {
471 $keys = $this->get_cache_keys($url);
472 return $keys['transient'];
473 }
474
475 /**
476 * Get lock key for URL
477 *
478 * @param string $url The page URL
479 * @return string Lock key
480 */
481 private function get_lock_key($url) {
482 $keys = $this->get_cache_keys($url);
483 return $keys['lock'];
484 }
485
486 /**
487 * Get stale cache key for URL
488 *
489 * @param string $url The page URL
490 * @return string Stale cache key
491 */
492 private function get_stale_key($url) {
493 $keys = $this->get_cache_keys($url);
494 return $keys['stale'];
495 }
496
497 /**
498 * Get cache statistics for debugging
499 *
500 * @param string $url The page URL
501 * @return array Statistics
502 */
503 public function get_stats($url) {
504 $transient_key = $this->get_transient_key($url);
505 $cached = get_transient($transient_key);
506
507 return [
508 'url' => $url,
509 'has_cache' => $cached !== false,
510 'has_suggestions' => $cached !== false && $this->has_payload($cached),
511 'cache_key' => $transient_key,
512 'rate_limit_key' => self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i'),
513 'current_rate_count' => (int) get_transient(self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i')),
514 ];
515 }
516
517 /**
518 * Clear all OTTO transient caches
519 *
520 * @return array Results with count of cleared items
521 */
522 public static function clear_all_transients() {
523 global $wpdb;
524
525 # PERFORMANCE OPTIMIZATION: Batch delete in single query instead of N+1 loop
526 # Build the LIKE conditions for all prefixes
527 $prefixes = [
528 self::TRANSIENT_PREFIX,
529 self::LOCK_PREFIX,
530 self::STALE_PREFIX,
531 self::RATE_LIMIT_PREFIX,
532 ];
533
534 # Build WHERE clause for all transient and timeout variants
535 $where_parts = [];
536 foreach ($prefixes as $prefix) {
537 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_' . $prefix . '%');
538 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_timeout_' . $prefix . '%');
539 }
540 $where_clause = implode(' OR ', $where_parts);
541
542 # Count entries before deletion
543 $count_query = "SELECT COUNT(*) FROM {$wpdb->options} WHERE " . $where_clause;
544 $cleared_count = (int) $wpdb->get_var($count_query);
545
546 # Batch delete all matching transients in single query
547 $delete_query = "DELETE FROM {$wpdb->options} WHERE " . $where_clause;
548 $wpdb->query($delete_query);
549
550 # Also clear object cache if available
551 if (function_exists('wp_cache_flush_group')) {
552 wp_cache_flush_group('transient');
553 }
554
555 delete_transient('metasync_otto_js_detected');
556
557 return [
558 'success' => true,
559 'cleared_count' => $cleared_count,
560 'message' => sprintf('Cleared %d OTTO transient cache entries', $cleared_count)
561 ];
562 }
563
564 /**
565 * Clear transient cache for a specific URL
566 *
567 * @param string $url The page URL
568 * @return array Results
569 */
570 public static function clear_url_transient($url) {
571 if (empty($url)) {
572 return [
573 'success' => false,
574 'message' => 'URL is required'
575 ];
576 }
577
578 # Normalize URL for transient key (matches get_transient_key)
579 $normalized = rtrim(strtolower($url), '/');
580 $site_id = is_multisite() ? get_current_blog_id() : 0;
581
582 # Use the same normalization as get_cache_keys() for all three keys.
583 # Previously lock/stale used raw $url (no normalization), which generated
584 # different hashes than the actual keys — they were never actually deleted.
585 $hash = md5($normalized);
586 $transient_key = self::TRANSIENT_PREFIX . $site_id . '_' . $hash;
587 $lock_key = self::LOCK_PREFIX . $hash;
588 $stale_key = self::STALE_PREFIX . $hash;
589
590 $keys_to_clear = [$transient_key, $lock_key, $stale_key];
591
592 $cleared_count = 0;
593 foreach ($keys_to_clear as $key) {
594 if (delete_transient($key)) {
595 $cleared_count++;
596 }
597 }
598
599 return [
600 'success' => true,
601 'cleared_count' => $cleared_count,
602 'url' => $url,
603 'message' => sprintf('Cleared cache for URL: %s (%d entries)', $url, $cleared_count)
604 ];
605 }
606
607 /**
608 * Get count of cached transients
609 *
610 * @return int Number of cached transients
611 */
612 public static function get_cache_count() {
613 global $wpdb;
614
615 $count = $wpdb->get_var(
616 $wpdb->prepare(
617 "SELECT COUNT(*) FROM {$wpdb->options}
618 WHERE option_name LIKE %s",
619 '_transient_' . self::TRANSIENT_PREFIX . '%'
620 )
621 );
622
623 return (int) $count;
624 }
625 }
626
627