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

729 lines 25.9 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 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 return !empty($data['header_replacements']) ||
400 !empty($data['header_html_insertion']) ||
401 !empty($data['body_substitutions']) ||
402 !empty($data['body_top_html_insertion']) ||
403 !empty($data['body_bottom_html_insertion']) ||
404 !empty($data['footer_html_insertion']);
405 }
406
407 /**
408 * Get OTTO API rate limit from execution settings
409 * Falls back to default constant if setting not configured
410 *
411 * @return int Rate limit (calls per minute)
412 */
413 private function get_rate_limit() {
414 $execution_settings = get_option('metasync_execution_settings', array());
415 if (isset($execution_settings['otto_rate_limit'])) {
416 return (int) $execution_settings['otto_rate_limit'];
417 }
418 // Fallback to default constant
419 return self::MAX_API_CALLS_PER_MINUTE;
420 }
421
422 /**
423 * Check if we can make an API call (rate limiting)
424 *
425 * @return bool True if under rate limit
426 * @note With a persistent object cache (Redis/Memcached) the increment is atomic and race-free.
427 * Without one, falls back to transients (minor TOCTOU race under concurrency, but the counter persists across requests).
428 */
429 private function can_make_api_call(): bool {
430 # Create rate limit key (per minute)
431 $rate_key = self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i');
432
433 # Get rate limit from execution settings
434 $rate_limit = $this->get_rate_limit();
435
436 if (wp_using_ext_object_cache()) {
437 # Atomic path — race-free on Redis/Memcached
438 wp_cache_add($rate_key, 0, 'otto_rate', MINUTE_IN_SECONDS);
439 $new_count = wp_cache_incr($rate_key, 1, 'otto_rate');
440 return $new_count <= $rate_limit;
441 }
442
443 # Transient fallback — persists across requests, minor race under concurrency
444 $count = (int) get_transient($rate_key);
445 if ($count >= $rate_limit) {
446 return false;
447 }
448 set_transient($rate_key, $count + 1, MINUTE_IN_SECONDS);
449 return true;
450 }
451
452 /**
453 * Atomically acquire a lock (test-and-set).
454 *
455 * On Redis/Memcached object cache backends, wp_cache_add() maps to SET NX —
456 * the kernel guarantees only one caller succeeds. On the MySQL transient
457 * backend we INSERT IGNORE the timeout and value rows; the unique key on
458 * option_name makes that atomic. For an expired-but-not-yet-deleted lock
459 * we use a compare-and-swap UPDATE so only one racing worker can claim it.
460 *
461 * @param string $lock_key Transient key for the lock (without _transient_ prefix)
462 * @param int $ttl Lock timeout in seconds
463 * @return bool True if this caller acquired the lock, false if it was already held
464 */
465 private function acquire_lock_atomic($lock_key, $ttl) {
466 # Fast path: external object cache (Redis/Memcached) — wp_cache_add is atomic.
467 if (wp_using_ext_object_cache()) {
468 return (bool) wp_cache_add($lock_key, '1', 'transient', $ttl);
469 }
470
471 # MySQL transient backend — emulate compare-and-set with INSERT IGNORE on the
472 # _transient_timeout_ row (which carries the unique option_name constraint).
473 global $wpdb;
474 $now = time();
475 $new_expires = $now + (int) $ttl;
476 $timeout_option = '_transient_timeout_' . $lock_key;
477 $value_option = '_transient_' . $lock_key;
478
479 $inserted = $wpdb->query(
480 $wpdb->prepare(
481 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
482 $timeout_option,
483 (string) $new_expires
484 )
485 );
486
487 if ($inserted === 1) {
488 # We won the insert race — now write the value row. INSERT IGNORE keeps it
489 # safe if a stale value row from a prior expired lock is still around.
490 $wpdb->query(
491 $wpdb->prepare(
492 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
493 $value_option,
494 '1'
495 )
496 );
497 return true;
498 }
499
500 # Insert was ignored — a timeout row already exists. Check whether the
501 # existing lock has expired so we can attempt to take it over.
502 $existing_expires = (int) $wpdb->get_var(
503 $wpdb->prepare(
504 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
505 $timeout_option
506 )
507 );
508
509 if ($existing_expires > $now) {
510 # Lock is still active — we did not acquire.
511 return false;
512 }
513
514 # Stale lock — try to claim it via compare-and-swap on the timeout column.
515 # Only the worker whose UPDATE actually changes a row wins the race.
516 $updated = $wpdb->query(
517 $wpdb->prepare(
518 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) = %d",
519 (string) $new_expires,
520 $timeout_option,
521 $existing_expires
522 )
523 );
524
525 if ($updated === 1) {
526 # We won the takeover. Make sure the value row exists.
527 $wpdb->query(
528 $wpdb->prepare(
529 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
530 $value_option,
531 '1'
532 )
533 );
534 return true;
535 }
536
537 return false;
538 }
539
540 /**
541 * PERFORMANCE OPTIMIZATION: Generate all cache keys at once
542 * Reduces redundant URL normalization and MD5 hashing
543 *
544 * @param string $url The page URL
545 * @return array All cache keys ['transient', 'lock', 'stale', 'track']
546 */
547 private function get_cache_keys($url) {
548 # Normalize URL once (remove trailing slash, lowercase)
549 $normalized = rtrim(strtolower($url), '/');
550 # Compute MD5 hash once
551 $hash = md5($normalized);
552 # Get site ID once
553 $site_id = is_multisite() ? get_current_blog_id() : 0;
554
555 return [
556 'transient' => self::TRANSIENT_PREFIX . $site_id . '_' . $hash,
557 'lock' => self::LOCK_PREFIX . $hash,
558 'stale' => self::STALE_PREFIX . $hash,
559 'track' => 'otto_' . $hash,
560 ];
561 }
562
563 /**
564 * Get transient key for URL
565 *
566 * @param string $url The page URL
567 * @return string Transient key
568 */
569 private function get_transient_key($url) {
570 $keys = $this->get_cache_keys($url);
571 return $keys['transient'];
572 }
573
574 /**
575 * Get lock key for URL
576 *
577 * @param string $url The page URL
578 * @return string Lock key
579 */
580 private function get_lock_key($url) {
581 $keys = $this->get_cache_keys($url);
582 return $keys['lock'];
583 }
584
585 /**
586 * Get stale cache key for URL
587 *
588 * @param string $url The page URL
589 * @return string Stale cache key
590 */
591 private function get_stale_key($url) {
592 $keys = $this->get_cache_keys($url);
593 return $keys['stale'];
594 }
595
596 /**
597 * Get cache statistics for debugging
598 *
599 * @param string $url The page URL
600 * @return array Statistics
601 */
602 public function get_stats($url) {
603 $transient_key = $this->get_transient_key($url);
604 $cached = get_transient($transient_key);
605
606 return [
607 'url' => $url,
608 'has_cache' => $cached !== false,
609 'has_suggestions' => $cached !== false && $this->has_payload($cached),
610 'cache_key' => $transient_key,
611 'rate_limit_key' => self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i'),
612 'current_rate_count' => wp_using_ext_object_cache()
613 ? (int) wp_cache_get(self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i'), 'otto_rate')
614 : (int) get_transient(self::RATE_LIMIT_PREFIX . date('Y-m-d-H-i')),
615 ];
616 }
617
618 /**
619 * Clear all OTTO transient caches
620 *
621 * @return array Results with count of cleared items
622 */
623 public static function clear_all_transients() {
624 global $wpdb;
625
626 # PERFORMANCE OPTIMIZATION: Batch delete in single query instead of N+1 loop
627 # Build the LIKE conditions for all prefixes
628 $prefixes = [
629 self::TRANSIENT_PREFIX,
630 self::LOCK_PREFIX,
631 self::STALE_PREFIX,
632 self::RATE_LIMIT_PREFIX,
633 ];
634
635 # Build WHERE clause for all transient and timeout variants
636 $where_parts = [];
637 foreach ($prefixes as $prefix) {
638 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_' . $prefix . '%');
639 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_timeout_' . $prefix . '%');
640 }
641 $where_clause = implode(' OR ', $where_parts);
642
643 # Count entries before deletion
644 $count_query = "SELECT COUNT(*) FROM {$wpdb->options} WHERE " . $where_clause;
645 $cleared_count = (int) $wpdb->get_var($count_query);
646
647 # Batch delete all matching transients in single query
648 $delete_query = "DELETE FROM {$wpdb->options} WHERE " . $where_clause;
649 $wpdb->query($delete_query);
650
651 # Also clear object cache if available
652 if (function_exists('wp_cache_flush_group')) {
653 wp_cache_flush_group('transient');
654 wp_cache_flush_group('otto_rate');
655 }
656
657 delete_transient('metasync_otto_js_detected');
658
659 return [
660 'success' => true,
661 'cleared_count' => $cleared_count,
662 'message' => sprintf('Cleared %d OTTO transient cache entries', $cleared_count)
663 ];
664 }
665
666 /**
667 * Clear transient cache for a specific URL
668 *
669 * @param string $url The page URL
670 * @return array Results
671 */
672 public static function clear_url_transient($url) {
673 if (empty($url)) {
674 return [
675 'success' => false,
676 'message' => 'URL is required'
677 ];
678 }
679
680 # Normalize URL for transient key (matches get_transient_key)
681 $normalized = rtrim(strtolower($url), '/');
682 $site_id = is_multisite() ? get_current_blog_id() : 0;
683
684 # Use the same normalization as get_cache_keys() for all three keys.
685 # Previously lock/stale used raw $url (no normalization), which generated
686 # different hashes than the actual keys — they were never actually deleted.
687 $hash = md5($normalized);
688 $transient_key = self::TRANSIENT_PREFIX . $site_id . '_' . $hash;
689 $lock_key = self::LOCK_PREFIX . $hash;
690 $stale_key = self::STALE_PREFIX . $hash;
691
692 $keys_to_clear = [$transient_key, $lock_key, $stale_key];
693
694 $cleared_count = 0;
695 foreach ($keys_to_clear as $key) {
696 if (delete_transient($key)) {
697 $cleared_count++;
698 }
699 }
700
701 return [
702 'success' => true,
703 'cleared_count' => $cleared_count,
704 'url' => $url,
705 'message' => sprintf('Cleared cache for URL: %s (%d entries)', $url, $cleared_count)
706 ];
707 }
708
709 /**
710 * Get count of cached transients
711 *
712 * @return int Number of cached transients
713 */
714 public static function get_cache_count() {
715 global $wpdb;
716
717 $count = $wpdb->get_var(
718 $wpdb->prepare(
719 "SELECT COUNT(*) FROM {$wpdb->options}
720 WHERE option_name LIKE %s",
721 '_transient_' . self::TRANSIENT_PREFIX . '%'
722 )
723 );
724
725 return (int) $count;
726 }
727 }
728
729