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

1,319 lines 52.8 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 * Stale fallback retention (12 hours), independent of the live cache TTL.
42 */
43 private const STALE_TTL = 12 * HOUR_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 * Recovery probe lock timeout (10 seconds).
57 * Must exceed warm_cache()'s longest API timeout (8 seconds), so a slow
58 * recovery probe cannot overlap with a second probe.
59 */
60 private const BREAKER_PROBE_LOCK_TIMEOUT = 10;
61
62 /**
63 * Circuit breaker prefix (per API host, not per URL — the failure being
64 * tracked is "the OTTO API is unreachable", which is host-wide)
65 */
66 private const BREAKER_PREFIX = 'otto_breaker_';
67
68 /**
69 * Circuit breaker consecutive-failure tally prefix
70 */
71 private const BREAKER_FAIL_PREFIX = 'otto_breaker_fail_';
72
73 /**
74 * Circuit breaker probe-lock prefix (half-open state)
75 */
76 private const BREAKER_PROBE_PREFIX = 'otto_breaker_probe_';
77
78 /**
79 * Object-cache group for atomic counters (rate limiter, breaker tally).
80 * These are NOT stored in the transient namespace when an external object
81 * cache is present, so they must always be read and deleted via
82 * counter_get() / counter_delete().
83 */
84 private const COUNTER_GROUP = 'otto_counters';
85
86 /**
87 * Consecutive hard failures before the breaker opens.
88 */
89 private const BREAKER_FAILURE_THRESHOLD = 3;
90
91 /**
92 * How long the breaker stays open before allowing a single probe (seconds).
93 * Deliberately short: long enough to shed load off the PHP workers, short
94 * enough that a brief upstream blip does not visibly de-OTTO the site.
95 */
96 private const BREAKER_COOLOFF = 60;
97
98 /**
99 * Max API calls per minute
100 */
101 public const MAX_API_CALLS_PER_MINUTE = 10;
102
103 /**
104 * API timeout (2 seconds)
105 */
106 private const API_TIMEOUT = 2;
107
108 /**
109 * OTTO UUID
110 */
111 private $otto_uuid;
112
113 /**
114 * Whether the last fetch_from_api() call was answered with HTTP 404.
115 *
116 * Tracked explicitly rather than inferred from the returned value: a 404 is
117 * represented as an empty array, but json_decode() also yields an empty array
118 * for a 200 body of `{}` or `[]`. Distinguishing the two by emptiness would
119 * silently misclassify such a response as "no data held" and stop clearing the
120 * stale copy — resurfacing a withdrawn title, which is the bug the stale wipe
121 * exists to prevent.
122 *
123 * @var bool
124 */
125 private $last_fetch_was_404 = false;
126
127 /**
128 * Whether the last fetch_from_api() failure was permanent (a 4xx refusal
129 * such as 400/401/403, i.e. an answer retrying cannot change).
130 *
131 * Like the 404 flag above, this is tracked explicitly because the return
132 * value cannot carry it: warm failures return plain false whether the API
133 * timed out (retryable) or rejected the request outright (permanent).
134 * Callers that own a retry policy — the crawl-notify job — read this to
135 * fail fast instead of burning the full attempt budget on rejections.
136 *
137 * @var bool
138 */
139 private $last_failure_permanent = false;
140
141 /**
142 * OTTO API endpoint
143 */
144 private $api_endpoint;
145
146 /**
147 * API fetch timeout for the current operation.
148 * Page-load requests use the short API_TIMEOUT (2s).
149 * warm_cache() uses a longer timeout since it runs in a webhook handler.
150 */
151 private $fetch_timeout = self::API_TIMEOUT;
152
153 /**
154 * Constructor
155 */
156 public function __construct($otto_uuid) {
157 $this->otto_uuid = $otto_uuid;
158
159 # Use endpoint manager if available, otherwise fallback to production
160 if (class_exists('Metasync_Endpoint_Manager')) {
161 $this->api_endpoint = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
162 } else {
163 $this->api_endpoint = 'https://sa.searchatlas.com/api/v2/otto-url-details';
164 }
165 }
166
167 /**
168 * Cache status tracker (for header)
169 */
170 private static $cache_status = [];
171
172 /**
173 * Get OTTO suggestions for a URL (with transient caching)
174 *
175 * @param string $url The page URL
176 * @param string $track_key Optional key to track cache status for this request
177 * @return array|false OTTO suggestions data or false if none
178 */
179 public function get_suggestions($url, $track_key = null) {
180 if (empty($url) || empty($this->otto_uuid)) {
181 return false;
182 }
183
184 # PERFORMANCE OPTIMIZATION: Generate all cache keys once
185 $keys = $this->get_cache_keys($url);
186 $cache_status_key = $track_key ?: $keys['track'];
187
188 # Step 1: Check transient cache first
189 $cached = get_transient($keys['transient']);
190 if ($cached !== false) {
191 # Cache hit. Distinguish a real payload from the "checked, nothing
192 # here" marker so the X-MetaSync-OTTO-Cache header stays truthful —
193 # reporting HIT for a URL OTTO holds no data for would be misleading
194 # when debugging, and the two cases are handled differently upstream.
195 self::$cache_status[$cache_status_key] = $this->has_payload($cached)
196 ? 'HIT'
197 : 'NO_SUGGESTIONS';
198 return $cached;
199 }
200
201 # Step 1.5: circuit breaker.
202 #
203 # Checked BEFORE the rate limiter on purpose. can_make_api_call() increments
204 # a per-minute counter, so running it first would spend the site's API budget
205 # on requests the breaker is about to block anyway — leaving OTTO throttled
206 # for the rest of the minute even after the upstream recovers.
207 if ($this->is_breaker_open()) {
208 $stale = get_transient($keys['stale']);
209 if ($stale !== false) {
210 self::$cache_status[$cache_status_key] = 'STALE';
211 return $stale;
212 }
213 self::$cache_status[$cache_status_key] = 'BREAKER_OPEN';
214 return false;
215 }
216
217 # Step 2: Acquire lock atomically (test-and-set).
218 # If another worker already holds the lock, wait briefly for them to populate
219 # the cache, then either return their result or bail out — DO NOT fall through
220 # to fetch_from_api() (that was the original bug: duplicate concurrent API calls).
221 if (!$this->acquire_lock_atomic($keys['lock'], self::LOCK_TIMEOUT)) {
222 usleep(500000); // 0.5 seconds
223 $cached = get_transient($keys['transient']);
224 if ($cached !== false) {
225 self::$cache_status[$cache_status_key] = 'HIT';
226 return $cached;
227 }
228 # The lock holder did not finish in time. Before giving up and letting
229 # the caller serve un-optimized HTML, reuse the stale copy — it is the
230 # same data one TTL older, which is far better for a crawler than the
231 # original title. Previously only the rate-limit path consulted it.
232 $stale = get_transient($keys['stale']);
233 if ($stale !== false) {
234 self::$cache_status[$cache_status_key] = 'STALE';
235 return $stale;
236 }
237 self::$cache_status[$cache_status_key] = 'LOCKED';
238 return false;
239 }
240
241 try {
242 # Another request may have populated the cache between our initial
243 # miss and this lock acquisition. Recheck before spending API budget.
244 $cached = get_transient($keys['transient']);
245 if ($cached !== false) {
246 self::$cache_status[$cache_status_key] = $this->has_payload($cached)
247 ? 'HIT'
248 : 'NO_SUGGESTIONS';
249 return $cached;
250 }
251
252 # Step 3: Count only the lock winner that can make a real API call.
253 if (!$this->can_make_api_call()) {
254 # Rate limited - try to use stale cache
255 $stale = get_transient($keys['stale']);
256 if ($stale !== false) {
257 # NEW: Structured error logging with category and code
258 if (class_exists('Metasync_Error_Logger')) {
259 Metasync_Error_Logger::log(
260 Metasync_Error_Logger::CATEGORY_API_RATE_LIMIT,
261 Metasync_Error_Logger::SEVERITY_INFO,
262 'OTTO suggestions throttled - served from cache',
263 [
264 'url' => $url,
265 'fallback' => 'stale_cache',
266 'api_endpoint' => 'OTTO Suggestions API',
267 'operation' => 'get_suggestions'
268 ]
269 );
270 }
271
272 error_log('MetaSync OTTO: Throttled, serving suggestions from cache for ' . $url);
273 self::$cache_status[$cache_status_key] = 'STALE';
274 return $stale;
275 }
276 # No stale cache available - return false
277 self::$cache_status[$cache_status_key] = 'RATE_LIMITED';
278 return false;
279 }
280
281 # Step 4: Fetch from API (with timeout and error handling)
282 $suggestions = $this->fetch_from_api($url);
283
284 # NitroPack purge removed from page-load path to prevent feedback loop:
285 # get_suggestions() runs on every visitor request when the transient expires,
286 # and purging NitroPack here causes it to flush its cache (and potentially the
287 # object cache holding our transients), triggering another API fetch → purge cycle.
288 # The webhook handler (otto_pixel.php → purge_single_url) already covers the
289 # legitimate case where suggestions change after an OTTO crawl.
290
291 # Step 5: Store result in transient
292 if ($suggestions && $this->has_payload($suggestions)) {
293 # Has suggestions - cache for the admin-configured TTL (default 30 min)
294 $ttl = Metasync_Otto_Config::get_otto_cache_ttl_seconds();
295 set_transient($keys['transient'], $suggestions, $ttl);
296 # Keep a longer fallback without reducing live suggestion freshness.
297 set_transient($keys['stale'], $suggestions, self::STALE_TTL);
298 self::$cache_status[$cache_status_key] = 'MISS'; // Cache miss, fetched from API
299 } elseif ($suggestions !== false) {
300 # API responded 200 OK but genuinely no OTTO suggestions for this URL,
301 # or answered 404 (no data held for this URL at all).
302 #
303 # Cache the negative result so we stop asking. This MUST be stored as
304 # an empty array rather than `false`: WordPress cannot distinguish a
305 # stored `false` from an absent key, so a `false` marker never reads
306 # back as a hit and every request re-calls the API — which then
307 # exhausts the per-minute budget and pushes these URLs into
308 # RATE_LIMITED, a status treated as a transient failure. An empty
309 # array reads back cleanly, and has_payload([]) is false, so every
310 # consumer still takes the "no suggestions" path.
311 set_transient($keys['transient'], [], self::NO_SUGGESTIONS_TTL);
312
313 # Wipe the stale fallback for any empty answer that was NOT a 404 —
314 # a 200 whose body is empty or hollow, and a 204. Both mean the URL is
315 # known to OTTO but currently holds no suggestions, and without the
316 # wipe a rate-limit event within the next hour would serve the
317 # previously-deployed (now undeployed) suggestion, keeping the wrong
318 # title alive on the page.
319 #
320 # A 404 is different, and the difference is who answered. A 204 or an
321 # empty 200 came from the application itself: it was asked about this
322 # URL and deliberately said "nothing here". A 404 may never have
323 # reached the application at all — a changed endpoint path, a gateway,
324 # or a CDN in front of the API can produce it for every URL on every
325 # site at once, for reasons that say nothing about whether suggestions
326 # exist. Wiping stale copies on that would destroy the fallback
327 # everywhere simultaneously, precisely when it is the only thing
328 # keeping correct SEO on the page. So a 404 leaves it alone.
329 #
330 # Keyed off the recorded status code rather than the emptiness of the
331 # decoded body: json_decode() also returns an empty array for a 200
332 # body of `{}` or `[]`, which would otherwise be mistaken for a 404.
333 if (!$this->last_fetch_was_404) {
334 delete_transient($keys['stale']);
335 }
336
337 self::$cache_status[$cache_status_key] = 'NO_SUGGESTIONS';
338 } else {
339 # fetch_from_api() returned false — network error, timeout, or a
340 # non-200 response other than 404 and 204 (both of those return []
341 # and are handled by the "no suggestions" branch above).
342 # Do NOT cache the failure: a stale false would poison subsequent MISS requests
343 # (Kinsta sees MISS → PHP runs → transient HIT = false → OTTO skips → old title cached).
344 # Leave the transient empty so the very next page load retries the API call.
345 #
346 # Before reporting the failure upward, reuse the stale copy if we
347 # have one — same data, one TTL older, which still renders correct
348 # SEO instead of falling through to the original markup.
349 $stale = get_transient($keys['stale']);
350 if ($stale !== false) {
351 self::$cache_status[$cache_status_key] = 'STALE';
352 $suggestions = $stale;
353 } else {
354 self::$cache_status[$cache_status_key] = 'API_ERROR';
355 }
356 }
357 } finally {
358 # Step 6: Release lock — guaranteed to run even if fetch_from_api() throws
359 # or the storage block errors, so we never leak the lock for the full TTL.
360 # Note: acquire_lock_atomic() uses raw SQL (INSERT IGNORE / CAS UPDATE) on
361 # the MySQL path, but delete_transient() via the WP API is safe here because
362 # the rows use autoload='no' and are not in the alloptions cache.
363 delete_transient($keys['lock']);
364 }
365
366 return $suggestions;
367 }
368
369 /**
370 * Get cache status for a request
371 *
372 * @param string $key The tracking key
373 * @return string Cache status (HIT, MISS, STALE, RATE_LIMITED, NO_SUGGESTIONS, LOCKED, UNKNOWN)
374 */
375 public static function get_cache_status($key) {
376 return self::$cache_status[$key] ?? 'UNKNOWN';
377 }
378
379 /**
380 * Check if URL has OTTO suggestions (quick check)
381 *
382 * @param string $url The page URL
383 * @return bool True if URL has suggestions
384 */
385 public function has_suggestions($url) {
386 $suggestions = $this->get_suggestions($url);
387 return $suggestions !== false && $this->has_payload($suggestions);
388 }
389
390 /**
391 * Invalidate cache for a URL
392 *
393 * @param string $url The page URL
394 * @return bool Success
395 */
396 public function invalidate($url) {
397 # PERFORMANCE OPTIMIZATION: Generate all cache keys once
398 $keys = $this->get_cache_keys($url);
399
400 delete_transient($keys['transient']);
401 delete_transient($keys['stale']);
402 delete_transient($keys['lock']);
403
404 return true;
405 }
406
407 /**
408 * Warm cache for a URL (pre-fetch and cache)
409 * Called from the OTTO webhook handler — NOT from a page load.
410 * Uses a longer API timeout so transients are reliably populated before
411 * the Kinsta/WP Engine cache purge fires, preventing the "false transient
412 * poisoning" race condition (MISS → false transient HIT → OTTO skips →
413 * old Yoast title cached).
414 *
415 * @param string $url The page URL
416 * @return array|false Suggestions or false
417 */
418 public function warm_cache($url) {
419 # Invalidate existing cache first
420 $this->invalidate($url);
421
422 # Use a longer timeout for this pre-warm request (webhook context, not page load).
423 $saved_timeout = $this->fetch_timeout;
424 $this->fetch_timeout = 8; // 8 seconds — enough for cross-region API calls
425
426 # Fetch and cache (with the longer timeout)
427 $result = $this->get_suggestions($url);
428
429 # Restore original timeout
430 $this->fetch_timeout = $saved_timeout;
431
432 return $result;
433 }
434
435 /**
436 * Whether the last warm/fetch failure was a permanent 4xx refusal.
437 *
438 * Meaningful only after warm_cache() returned false: a true here means the
439 * API answered and refused the request, so retrying is futile. Transport
440 * failures, 5xx, rate limits, lock contention, and an open circuit breaker
441 * all leave this false.
442 *
443 * @return bool
444 */
445 public function last_failure_is_permanent() {
446 return $this->last_failure_permanent;
447 }
448
449 /**
450 * Fetch suggestions from OTTO API
451 *
452 * @param string $url The page URL
453 * @return array|false API response or false on failure
454 */
455 private function fetch_from_api($url) {
456 # Reset per call so a previous 404 cannot influence this one's handling.
457 $this->last_fetch_was_404 = false;
458 $this->last_failure_permanent = false;
459
460 # Check if endpoint is in backoff mode (explicit check for better error handling)
461 if (class_exists('Metasync_API_Backoff_Manager')) {
462 $backoff_manager = Metasync_API_Backoff_Manager::get_instance();
463 if ($backoff_manager->is_endpoint_in_backoff($this->api_endpoint)) {
464 error_log('MetaSync OTTO: Request deferred - retry scheduled for this endpoint');
465 # Try to use stale cache if available
466 $stale = get_transient($this->get_stale_key($url));
467 if ($stale !== false) {
468 error_log('MetaSync OTTO: Serving suggestions from cache while retry is pending');
469 return $stale;
470 }
471 return false;
472 }
473 }
474
475 # NOTE: the circuit breaker gate lives in get_suggestions(), which is
476 # this method's only caller — placed there so an open breaker also skips the
477 # rate-limit increment. Failure recording stays here, at the point where the
478 # transport result is known.
479
480 # Construct API URL
481 $api_url = add_query_arg(
482 [
483 'url' => $url,
484 'uuid' => $this->otto_uuid,
485 ],
486 $this->api_endpoint
487 );
488
489 # Make API request — timeout comes from $this->fetch_timeout so warm_cache()
490 # can override it without affecting page-load requests.
491 $response = wp_remote_get($api_url, [
492 'timeout' => $this->fetch_timeout,
493 'redirection' => 5,
494 'user-agent' => 'MetaSync-OTTO-Transient-Cache/1.0',
495 'sslverify' => true,
496 ]);
497
498 # Check for errors
499 if (is_wp_error($response)) {
500 $error_message = $response->get_error_message();
501
502 # Check if error is due to backoff
503 if ($response->get_error_code() === 'api_backoff_active') {
504 error_log('MetaSync OTTO: Request deferred by retry schedule - ' . $error_message);
505 # Try to use stale cache
506 $stale = get_transient($this->get_stale_key($url));
507 if ($stale !== false) {
508 error_log('MetaSync OTTO: Serving suggestions from cache while retry is pending');
509 return $stale;
510 }
511 } else {
512 # A WP_Error that is not a backoff block is a hard transport
513 # failure (cURL 28 timeout, connection refused, DNS failure). These are
514 # the failures that cost a full API_TIMEOUT of PHP-FPM worker time.
515 $this->record_breaker_failure();
516 error_log('MetaSync OTTO: API call failed for ' . $url . ' - ' . $error_message);
517 }
518 return false;
519 }
520
521 # Check response code
522 $response_code = wp_remote_retrieve_response_code($response);
523 if ($response_code !== 200) {
524 # 404 and 204 are ANSWERS, not failures: the API is telling us it
525 # holds no OTTO data for this URL (never crawled, outside the project,
526 # or successfully answered with no content).
527 # Returning false here would classify it as API_ERROR, which is never
528 # cached and therefore re-requested on every uncached page load —
529 # burning the per-minute API budget on URLs that will keep answering
530 # 404 or 204, and (once fail-open emits no-cache headers) making every
531 # not-yet-crawled page uncacheable. Newly published posts are in this
532 # state until OTTO crawls them, so this is the common case, not an edge
533 # case. Return an empty array instead: get_suggestions() then takes the
534 # "no suggestions" branch and negative-caches it like any other
535 # legitimately empty result.
536 #
537 # These also close the circuit breaker, for the same reason a 200 does:
538 # the host answered, so it is reachable. Without that, a project whose
539 # URLs all answer 404 (not yet crawled) would leave a previously-opened
540 # breaker stuck half-open, re-probing every LOCK_TIMEOUT seconds against
541 # an API that is in fact perfectly healthy.
542 if ($response_code === 404) {
543 $this->last_fetch_was_404 = true;
544 $this->reset_breaker();
545 return [];
546 }
547 if ($response_code === 204) {
548 $this->reset_breaker();
549 return [];
550 }
551
552 # 5xx means the upstream is unhealthy — count it toward the breaker.
553 # Checked after the 404/204 answers above so they are never miscounted
554 # as failures. 429/503 are left to the backoff manager as well (503 is
555 # counted by both, which is correct: it is simultaneously "slow down"
556 # and "upstream unhealthy").
557 if ($response_code >= 500) {
558 $this->record_breaker_failure();
559 }
560
561 # For 429/503, the backoff manager will handle it automatically
562 # Try to use stale cache for these errors
563 if (in_array($response_code, [429, 503], true)) {
564 $stale = get_transient($this->get_stale_key($url));
565 if ($stale !== false) {
566 error_log('MetaSync OTTO: Serving suggestions from cache - source temporarily unavailable');
567 return $stale;
568 }
569 }
570
571 # Any other non-200 is a 4xx refusal (400/401/403/410 …): the API
572 # rejected the request itself — bad key, revoked project, malformed
573 # target. Retrying cannot change that answer, so flag it for
574 # callers that own a retry policy; they fail fast instead of
575 # spending the full attempt budget on guaranteed rejections.
576 if ($response_code < 500 && $response_code !== 429) {
577 $this->last_failure_permanent = true;
578 }
579 return false;
580 }
581
582 # Parse response body
583 $body = wp_remote_retrieve_body($response);
584 if (empty($body)) {
585 return false;
586 }
587
588 $data = json_decode($body, true);
589 if (json_last_error() !== JSON_ERROR_NONE) {
590 return false;
591 }
592
593 # A clean 200 means the upstream is healthy again — close the breaker.
594 $this->reset_breaker();
595
596 return $data;
597 }
598
599 // ------------------------------------------------------------------
600 // Circuit breaker
601 //
602 // Scoped to the API *host*, not the URL: when sa.searchatlas.com is
603 // unreachable it is unreachable for every URL, so a per-URL breaker would
604 // need BREAKER_FAILURE_THRESHOLD failures per URL before it helped — which
605 // on a site with thousands of URLs is never.
606 // ------------------------------------------------------------------
607
608 /**
609 * Transient key for the breaker state (per API host, per site).
610 *
611 * @return string
612 */
613 private function get_breaker_key() {
614 return self::breaker_key_for_host(wp_parse_url($this->api_endpoint, PHP_URL_HOST) ?: 'unknown');
615 }
616
617 /**
618 * Single derivation of the breaker key, shared by the instance and static
619 * paths so the two cannot drift apart.
620 *
621 * @param string $host API host.
622 * @return string
623 */
624 private static function breaker_key_for_host($host) {
625 $site_id = is_multisite() ? get_current_blog_id() : 0;
626 return self::BREAKER_PREFIX . $site_id . '_' . md5($host);
627 }
628
629 /**
630 * Is the circuit breaker currently open (i.e. should we skip the API call)?
631 *
632 * Three states:
633 * - closed : fewer than BREAKER_FAILURE_THRESHOLD consecutive failures. Allow.
634 * - open : threshold reached, still inside the cool-off. Block.
635 * - half-open : cool-off elapsed. Allow exactly ONE request through to probe
636 * the upstream; block the rest. Without the probe lock every
637 * concurrent request would retry at once and we would recreate
638 * the stampede the breaker exists to prevent.
639 *
640 * @return bool True if the API call should be skipped.
641 */
642 private function is_breaker_open() {
643 # Escape hatch: lets support disable the breaker on a single site without
644 # shipping a release, mirroring how other OTTO behaviour is gated.
645 if (!apply_filters('metasync_otto_circuit_breaker_enabled', true)) {
646 return false;
647 }
648
649 $open_until = (int) get_transient($this->get_breaker_key());
650
651 if ($open_until <= 0) {
652 # Closed (or counting failures but not yet tripped).
653 return false;
654 }
655
656 if (time() < $open_until) {
657 return true;
658 }
659
660 # Half-open: the first caller to win the probe lock gets to test the
661 # upstream. Without this every concurrent request would retry at once and
662 # recreate the stampede the breaker exists to prevent.
663 # RETRY-LOOP-SAFETY: bounded recovery; do not add a naive retry handler here.
664 # Concurrency: one host-level lock lasting beyond the 8-second transport timeout.
665 # Attempts/backoff: one transport attempt; failure re-arms the 60-second cool-off.
666 # Blocked callers return immediately; inline retries would hold PHP workers.
667 $probe_key = self::BREAKER_PROBE_PREFIX . md5($this->get_breaker_key());
668 if ($this->acquire_lock_atomic($probe_key, self::BREAKER_PROBE_LOCK_TIMEOUT)) {
669 return false;
670 }
671
672 return true;
673 }
674
675 /**
676 * Record a hard upstream failure and open the breaker once the threshold is hit.
677 *
678 * The failure tally is incremented atomically: a read-modify-write here loses
679 * increments under exactly the concurrency the breaker is meant to protect
680 * against, which delays the trip and lets the damage continue. Verified in
681 * load testing — the naive version opened several seconds late.
682 *
683 * The open-until timestamp is a plain write because every racing worker
684 * computes the same value, so a lost update is harmless.
685 */
686 private function record_breaker_failure() {
687 $open_key = $this->get_breaker_key();
688
689 $failures = $this->atomic_increment(
690 $this->get_breaker_fail_key(),
691 self::BREAKER_COOLOFF * 10
692 );
693
694 # Re-arm when the tally crosses the threshold, OR when the breaker is
695 # already tripped.
696 #
697 # The second condition is not redundant. The failure tally's expiry is
698 # written once, on first insert, and is never refreshed — so during an
699 # outage longer than its lifetime the tally silently resets to 1. Without
700 # this check a failed half-open probe would then fail to extend the
701 # cool-off, and the breaker would decay into one probe every LOCK_TIMEOUT
702 # seconds instead of one every BREAKER_COOLOFF.
703 #
704 # Short-circuits, so the extra read only happens on the sub-threshold
705 # failure path, which is rare.
706 if ($failures >= self::BREAKER_FAILURE_THRESHOLD || get_transient($open_key) !== false) {
707 # Re-writing this also refreshes its TTL, so the open marker cannot
708 # expire underneath a sustained outage.
709 set_transient($open_key, time() + self::BREAKER_COOLOFF, self::BREAKER_COOLOFF * 10);
710 }
711 }
712
713 /**
714 * Close the breaker after a successful call.
715 */
716 private function reset_breaker() {
717 $open_key = $this->get_breaker_key();
718 $fail_key = $this->get_breaker_fail_key();
719
720 # Guarded so a healthy site is not issuing deletes on every cache MISS.
721 # counter_get()/counter_delete() for the tally because with an object
722 # cache it does not live in the transient namespace.
723 if (get_transient($open_key) !== false || $this->counter_get($fail_key) > 0) {
724 delete_transient($open_key);
725 $this->counter_delete($fail_key);
726 }
727 }
728
729 /**
730 * Transient key for the breaker's consecutive-failure tally.
731 *
732 * @return string
733 */
734 private function get_breaker_fail_key() {
735 return self::BREAKER_FAIL_PREFIX . substr($this->get_breaker_key(), strlen(self::BREAKER_PREFIX));
736 }
737
738 /**
739 * Is the breaker open for the host serving the given endpoint?
740 *
741 * Static so other SearchAtlas callers on the same host (the bot crawl-log
742 * push in particular) can cheaply avoid an outbound call we already know
743 * will hang. Read-only: never opens, closes or probes the breaker, so a
744 * caller using this cannot disturb the suggestions path's state machine.
745 *
746 * @param string $endpoint_url Any URL on the host in question.
747 * @return bool
748 */
749 public static function is_host_breaker_open($endpoint_url) {
750 if (!apply_filters('metasync_otto_circuit_breaker_enabled', true)) {
751 return false;
752 }
753
754 $host = wp_parse_url($endpoint_url, PHP_URL_HOST);
755 if (empty($host)) {
756 return false;
757 }
758
759 $open_until = (int) get_transient(self::breaker_key_for_host($host));
760
761 return $open_until > 0 && time() < $open_until;
762 }
763
764 /**
765 * Circuit breaker state, for Site Health / debug surfaces.
766 *
767 * @return array{open:bool, failures:int, seconds_remaining:int}
768 */
769 public function get_breaker_status() {
770 $remaining = max(0, (int) get_transient($this->get_breaker_key()) - time());
771
772 return [
773 'open' => $remaining > 0,
774 'failures' => $this->counter_get($this->get_breaker_fail_key()),
775 'seconds_remaining' => $remaining,
776 ];
777 }
778
779 /**
780 * Purge NitroPack cache for a specific URL
781 * Called when OTTO API returns new suggestions to ensure NitroPack regenerates cache with fresh content
782 * @param string $url The page URL to purge from NitroPack cache
783 * @return bool True if purge was attempted, false if NitroPack not available
784 */
785 private function purge_nitropack_cache($url) {
786 # Check if NitroPack functions are available
787 if (!function_exists('nitropack_sdk_purge')) {
788 return false;
789 }
790
791 try {
792 # Purge NitroPack's cache (both local and remote) for this URL
793 # Using nitropack_sdk_purge() ensures remote API cache is also cleared
794 return nitropack_sdk_purge($url, NULL, 'MetaSync OTTO API call - fresh suggestions fetched');
795 } catch (\Exception $e) {
796 return false;
797 }
798 }
799
800 /**
801 * Check if API response has payload (suggestions)
802 * Public method for external use
803 *
804 * @param array $data API response data
805 * @return bool True if has suggestions
806 */
807 public function has_payload($data) {
808 if (empty($data) || !is_array($data)) {
809 return false;
810 }
811
812 # Undeployed OTTO responses ship harmless whitespace ("\n") and
813 # empty wrappers ({"images": {}}) that PHP's empty() reads as non-empty.
814 # Without these guards, has_payload() flagged undeployed pages as "live"
815 # and downstream code (otto_has_live_suggestions(), title filter) kept
816 # applying the stale _metasync_otto_title meta from the previous deploy.
817
818 $has_string = static function ($value) {
819 return is_string($value) && trim($value) !== '';
820 };
821
822 $has_container = static function ($value) {
823 if (!is_array($value) || empty($value)) {
824 return false;
825 }
826 foreach ($value as $child) {
827 if (!empty($child)) {
828 return true;
829 }
830 }
831 return false;
832 };
833
834 if (!empty($data['header_replacements']) && is_array($data['header_replacements'])) {
835 foreach ($data['header_replacements'] as $replacement) {
836 if (is_array($replacement) && !empty($replacement['recommended_value'])) {
837 return true;
838 }
839 }
840 }
841
842 return $has_string($data['header_html_insertion'] ?? '')
843 || $has_container($data['body_substitutions'] ?? [])
844 || $has_string($data['body_top_html_insertion'] ?? '')
845 || $has_string($data['body_bottom_html_insertion'] ?? '')
846 || $has_string($data['footer_html_insertion'] ?? '')
847 || $has_container($data['image_missing_alt'] ?? []);
848 }
849
850 /**
851 * Get OTTO API rate limit from execution settings
852 * Falls back to default constant if setting not configured
853 *
854 * @return int Rate limit (calls per minute)
855 */
856 private function get_rate_limit() {
857 $execution_settings = get_option('metasync_execution_settings', array());
858 if (isset($execution_settings['otto_rate_limit'])) {
859 return (int) $execution_settings['otto_rate_limit'];
860 }
861 // Fallback to default constant
862 return self::MAX_API_CALLS_PER_MINUTE;
863 }
864
865 /**
866 * Check if we can make an API call (rate limiting)
867 *
868 * @return bool True if under rate limit
869 * @note With a persistent object cache (Redis/Memcached) the increment is atomic and race-free.
870 * Without one, falls back to transients (minor TOCTOU race under concurrency, but the counter persists across requests).
871 */
872 private function can_make_api_call(): bool {
873 # Scope rate-limit key per site so each multisite subsite has its own
874 # 10 req/min budget. Without this, one subsite's traffic spike
875 # exhausts the network-wide bucket and silently rate-limits every
876 # other subsite for the rest of the minute.
877 $site_id = is_multisite() ? get_current_blog_id() : 0;
878 # Create rate limit key (per site, per minute)
879 $rate_key = self::RATE_LIMIT_PREFIX . $site_id . '_' . date('Y-m-d-H-i');
880
881 # Get rate limit from execution settings
882 $rate_limit = $this->get_rate_limit();
883
884 if (wp_using_ext_object_cache()) {
885 # Atomic path — race-free on Redis/Memcached
886 wp_cache_add($rate_key, 0, 'otto_rate', MINUTE_IN_SECONDS);
887 $new_count = wp_cache_incr($rate_key, 1, 'otto_rate');
888 return $new_count <= $rate_limit;
889 }
890
891 # MySQL transient fallback, atomic. The previous
892 # read-then-write lost increments whenever two PHP workers interleaved,
893 # which is precisely the traffic pattern the limiter exists for.
894 # Measured effect: ~22 calls/min against a configured cap of 10.
895 return $this->atomic_increment($rate_key, MINUTE_IN_SECONDS) <= $rate_limit;
896 }
897
898 /**
899 * Atomically increment a transient-backed counter and return its new value.
900 *
901 * Shared by the rate limiter and the circuit breaker. Both are counters that
902 * are only ever consulted while multiple PHP workers are racing, so a
903 * read-modify-write (get_transient + set_transient) silently undercounts
904 * exactly when accuracy matters.
905 *
906 * On MySQL, INSERT ... ON DUPLICATE KEY UPDATE is atomic under the unique
907 * key on option_name, and the LAST_INSERT_ID(expr) idiom hands back the
908 * post-increment value on the same connection without a second, racy SELECT.
909 *
910 * Caveat: the raw INSERT bypasses WordPress's in-process option cache, so a
911 * get_transient() call for the same key later in the SAME request may return
912 * a stale value. Nothing reads these counters that way — both callers use the
913 * return value here — but diagnostic surfaces should re-read from the DB.
914 *
915 * @param string $key Transient key (without the _transient_ prefix).
916 * @param int $ttl Lifetime in seconds.
917 * @return int New counter value.
918 */
919 private function atomic_increment($key, $ttl) {
920 if (wp_using_ext_object_cache()) {
921 # Redis/Memcached INCR is already atomic.
922 wp_cache_add($key, 0, self::COUNTER_GROUP, $ttl);
923 return (int) wp_cache_incr($key, 1, self::COUNTER_GROUP);
924 }
925
926 global $wpdb;
927 $value_option = '_transient_' . $key;
928 $timeout_option = '_transient_timeout_' . $key;
929
930 # Let WordPress reap an expired window first. get_transient() deletes both
931 # rows when the timeout has passed; without this the raw INSERT below would
932 # happily increment a counter left over from a previous, long-finished
933 # window — so a single fresh failure could inherit an old tally and trip
934 # the breaker immediately.
935 get_transient($key);
936
937 # Timeout row first so WP's transient GC can reap the counter.
938 $wpdb->query(
939 $wpdb->prepare(
940 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
941 $timeout_option,
942 (string) (time() + (int) $ttl)
943 )
944 );
945
946 # MySQL reports 1 affected row for an insert and 2 for an update, which is
947 # how we distinguish "first increment" from "incremented again".
948 $affected = $wpdb->query(
949 $wpdb->prepare(
950 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, '1', 'no')
951 ON DUPLICATE KEY UPDATE option_value = LAST_INSERT_ID(CAST(option_value AS UNSIGNED) + 1)",
952 $value_option
953 )
954 );
955
956 # Fresh insert — the counter started at 1.
957 if ((int) $affected === 1) {
958 return 1;
959 }
960
961 # Increment — LAST_INSERT_ID() carries the new value on this connection.
962 $new_value = (int) $wpdb->insert_id;
963 if ((int) $affected > 1 && $new_value > 0) {
964 return $new_value;
965 }
966
967 # Anything else means the storage layer did not behave as expected;
968 # wpdb::query() returns false on any SQL error. Do NOT trust insert_id
969 # here — it may hold a stale value from an unrelated statement, and a
970 # large one reads as "budget exhausted", silently switching OTTO off for
971 # the whole site until the minute rolls over. Fall back to a plain
972 # read-modify-write: less precise under concurrency, but it fails open
973 # rather than closed.
974 $count = (int) get_transient($key) + 1;
975 set_transient($key, $count, $ttl);
976
977 return $count;
978 }
979
980 /**
981 * Read a counter written by atomic_increment().
982 *
983 * Must branch on the same backend as the writer. With an external object
984 * cache the counter lives in COUNTER_GROUP, NOT in the transient namespace,
985 * so reading it with get_transient() would always report zero.
986 *
987 * Reads zero if atomic_increment() already ran for this key in the SAME
988 * request, because that writes via raw SQL and WordPress's in-process option
989 * cache still holds the pre-write miss. Harmless for both callers: a request
990 * that recorded a failure does not also reset the breaker, and the diagnostic
991 * surface reads on a separate request.
992 *
993 * @param string $key Counter key.
994 * @return int
995 */
996 private function counter_get($key) {
997 if (wp_using_ext_object_cache()) {
998 return (int) wp_cache_get($key, self::COUNTER_GROUP);
999 }
1000
1001 return (int) get_transient($key);
1002 }
1003
1004 /**
1005 * Delete a counter written by atomic_increment().
1006 *
1007 * Backend-matched for the same reason as counter_get(). Getting this wrong
1008 * leaves the tally growing forever on object-cached sites, so every later
1009 * incident would trip the breaker on its first failure.
1010 *
1011 * @param string $key Counter key.
1012 */
1013 private function counter_delete($key) {
1014 if (wp_using_ext_object_cache()) {
1015 wp_cache_delete($key, self::COUNTER_GROUP);
1016 return;
1017 }
1018
1019 delete_transient($key);
1020 }
1021
1022 /**
1023 * Atomically acquire a lock (test-and-set).
1024 *
1025 * On Redis/Memcached object cache backends, wp_cache_add() maps to SET NX —
1026 * the kernel guarantees only one caller succeeds. On the MySQL transient
1027 * backend we INSERT IGNORE the timeout and value rows; the unique key on
1028 * option_name makes that atomic. For an expired-but-not-yet-deleted lock
1029 * we use a compare-and-swap UPDATE so only one racing worker can claim it.
1030 *
1031 * @param string $lock_key Transient key for the lock (without _transient_ prefix)
1032 * @param int $ttl Lock timeout in seconds
1033 * @return bool True if this caller acquired the lock, false if it was already held
1034 */
1035 private function acquire_lock_atomic($lock_key, $ttl) {
1036 # Fast path: external object cache (Redis/Memcached) — wp_cache_add is atomic.
1037 if (wp_using_ext_object_cache()) {
1038 return (bool) wp_cache_add($lock_key, '1', 'transient', $ttl);
1039 }
1040
1041 # MySQL transient backend — emulate compare-and-set with INSERT IGNORE on the
1042 # _transient_timeout_ row (which carries the unique option_name constraint).
1043 global $wpdb;
1044 $now = time();
1045 $new_expires = $now + (int) $ttl;
1046 $timeout_option = '_transient_timeout_' . $lock_key;
1047 $value_option = '_transient_' . $lock_key;
1048
1049 $inserted = $wpdb->query(
1050 $wpdb->prepare(
1051 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
1052 $timeout_option,
1053 (string) $new_expires
1054 )
1055 );
1056
1057 if ($inserted === 1) {
1058 # We won the insert race — now write the value row. INSERT IGNORE keeps it
1059 # safe if a stale value row from a prior expired lock is still around.
1060 $wpdb->query(
1061 $wpdb->prepare(
1062 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
1063 $value_option,
1064 '1'
1065 )
1066 );
1067 return true;
1068 }
1069
1070 # Insert was ignored — a timeout row already exists. Check whether the
1071 # existing lock has expired so we can attempt to take it over.
1072 $existing_expires = (int) $wpdb->get_var(
1073 $wpdb->prepare(
1074 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1075 $timeout_option
1076 )
1077 );
1078
1079 if ($existing_expires > $now) {
1080 # Lock is still active — we did not acquire.
1081 return false;
1082 }
1083
1084 # Stale lock — try to claim it via compare-and-swap on the timeout column.
1085 # Only the worker whose UPDATE actually changes a row wins the race.
1086 $updated = $wpdb->query(
1087 $wpdb->prepare(
1088 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) = %d",
1089 (string) $new_expires,
1090 $timeout_option,
1091 $existing_expires
1092 )
1093 );
1094
1095 if ($updated === 1) {
1096 # We won the takeover. Make sure the value row exists.
1097 $wpdb->query(
1098 $wpdb->prepare(
1099 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
1100 $value_option,
1101 '1'
1102 )
1103 );
1104 return true;
1105 }
1106
1107 return false;
1108 }
1109
1110 /**
1111 * PERFORMANCE OPTIMIZATION: Generate all cache keys at once
1112 * Reduces redundant URL normalization and MD5 hashing
1113 *
1114 * @param string $url The page URL
1115 * @return array All cache keys ['transient', 'lock', 'stale', 'track']
1116 */
1117 private function get_cache_keys($url) {
1118 # Normalize URL once (remove trailing slash, lowercase)
1119 $normalized = rtrim(strtolower($url), '/');
1120 # Compute MD5 hash once
1121 $hash = md5($normalized);
1122 # Get site ID once
1123 $site_id = is_multisite() ? get_current_blog_id() : 0;
1124
1125 # All keys scoped by $site_id so multisite subsites don't collide on a
1126 # shared Redis/Memcached object cache.
1127 return [
1128 'transient' => self::TRANSIENT_PREFIX . $site_id . '_' . $hash,
1129 'lock' => self::LOCK_PREFIX . $site_id . '_' . $hash,
1130 'stale' => self::STALE_PREFIX . $site_id . '_' . $hash,
1131 'track' => 'otto_' . $site_id . '_' . $hash,
1132 ];
1133 }
1134
1135 /**
1136 * Get transient key for URL
1137 *
1138 * @param string $url The page URL
1139 * @return string Transient key
1140 */
1141 private function get_transient_key($url) {
1142 $keys = $this->get_cache_keys($url);
1143 return $keys['transient'];
1144 }
1145
1146 /**
1147 * Get lock key for URL
1148 *
1149 * @param string $url The page URL
1150 * @return string Lock key
1151 */
1152 private function get_lock_key($url) {
1153 $keys = $this->get_cache_keys($url);
1154 return $keys['lock'];
1155 }
1156
1157 /**
1158 * Get stale cache key for URL
1159 *
1160 * @param string $url The page URL
1161 * @return string Stale cache key
1162 */
1163 private function get_stale_key($url) {
1164 $keys = $this->get_cache_keys($url);
1165 return $keys['stale'];
1166 }
1167
1168 /**
1169 * Get cache statistics for debugging
1170 *
1171 * @param string $url The page URL
1172 * @return array Statistics
1173 */
1174 public function get_stats($url) {
1175 $transient_key = $this->get_transient_key($url);
1176 $cached = get_transient($transient_key);
1177
1178 # Match the scoped rate-limit key produced by can_make_api_call().
1179 $site_id = is_multisite() ? get_current_blog_id() : 0;
1180 $rate_limit_key = self::RATE_LIMIT_PREFIX . $site_id . '_' . date('Y-m-d-H-i');
1181
1182 return [
1183 'url' => $url,
1184 'has_cache' => $cached !== false,
1185 'has_suggestions' => $cached !== false && $this->has_payload($cached),
1186 'cache_key' => $transient_key,
1187 'rate_limit_key' => $rate_limit_key,
1188 'current_rate_count' => wp_using_ext_object_cache()
1189 ? (int) wp_cache_get($rate_limit_key, 'otto_rate')
1190 : (int) get_transient($rate_limit_key),
1191 ];
1192 }
1193
1194 /**
1195 * Clear all OTTO transient caches
1196 *
1197 * @return array Results with count of cleared items
1198 */
1199 public static function clear_all_transients() {
1200 global $wpdb;
1201
1202 # PERFORMANCE OPTIMIZATION: Batch delete in single query instead of N+1 loop
1203 # Build the LIKE conditions for all prefixes
1204 $prefixes = [
1205 self::TRANSIENT_PREFIX,
1206 self::LOCK_PREFIX,
1207 self::STALE_PREFIX,
1208 self::RATE_LIMIT_PREFIX,
1209 # Render-failure report throttles. Without this they survive a full
1210 # OTTO cache clear, so a support-led "clear the cache" would leave
1211 # reporting suppressed for up to 5 more minutes per URL.
1212 'metasync_otto_fail_',
1213 # Circuit breaker state, for the same reason: clearing the cache is
1214 # what support does to make a site retry immediately, so it must also
1215 # un-stick the breaker rather than leaving OTTO off for another
1216 # cool-off. This one prefix covers the open marker, the failure tally
1217 # and the half-open probe lock.
1218 self::BREAKER_PREFIX,
1219 ];
1220
1221 # Build WHERE clause for all transient and timeout variants
1222 $where_parts = [];
1223 foreach ($prefixes as $prefix) {
1224 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_' . $prefix . '%');
1225 $where_parts[] = $wpdb->prepare("option_name LIKE %s", '_transient_timeout_' . $prefix . '%');
1226 }
1227 $where_clause = implode(' OR ', $where_parts);
1228
1229 # Count entries before deletion
1230 $count_query = "SELECT COUNT(*) FROM {$wpdb->options} WHERE " . $where_clause;
1231 $cleared_count = (int) $wpdb->get_var($count_query);
1232
1233 # Batch delete all matching transients in single query
1234 $delete_query = "DELETE FROM {$wpdb->options} WHERE " . $where_clause;
1235 $wpdb->query($delete_query);
1236
1237 # Also clear object cache if available
1238 if (function_exists('wp_cache_flush_group')) {
1239 wp_cache_flush_group('transient');
1240 # Counters live in their own group when an external object cache is
1241 # present, so the raw SQL above cannot reach them. Covers both the
1242 # rate limiter and the breaker's failure tally.
1243 wp_cache_flush_group(self::COUNTER_GROUP);
1244 }
1245
1246 delete_transient('metasync_otto_js_detected');
1247
1248 return [
1249 'success' => true,
1250 'cleared_count' => $cleared_count,
1251 'message' => sprintf('Cleared %d OTTO transient cache entries', $cleared_count)
1252 ];
1253 }
1254
1255 /**
1256 * Clear transient cache for a specific URL
1257 *
1258 * @param string $url The page URL
1259 * @return array Results
1260 */
1261 public static function clear_url_transient($url) {
1262 if (empty($url)) {
1263 return [
1264 'success' => false,
1265 'message' => 'URL is required'
1266 ];
1267 }
1268
1269 # Normalize URL for transient key (matches get_transient_key)
1270 $normalized = rtrim(strtolower($url), '/');
1271 $site_id = is_multisite() ? get_current_blog_id() : 0;
1272
1273 # Use the same normalization as get_cache_keys() for all three keys.
1274 # Previously lock/stale used raw $url (no normalization), which generated
1275 # different hashes than the actual keys — they were never actually deleted.
1276 $hash = md5($normalized);
1277 # All three keys must include $site_id to match get_cache_keys().
1278 $transient_key = self::TRANSIENT_PREFIX . $site_id . '_' . $hash;
1279 $lock_key = self::LOCK_PREFIX . $site_id . '_' . $hash;
1280 $stale_key = self::STALE_PREFIX . $site_id . '_' . $hash;
1281
1282 $keys_to_clear = [$transient_key, $lock_key, $stale_key];
1283
1284 $cleared_count = 0;
1285 foreach ($keys_to_clear as $key) {
1286 if (delete_transient($key)) {
1287 $cleared_count++;
1288 }
1289 }
1290
1291 return [
1292 'success' => true,
1293 'cleared_count' => $cleared_count,
1294 'url' => $url,
1295 'message' => sprintf('Cleared cache for URL: %s (%d entries)', $url, $cleared_count)
1296 ];
1297 }
1298
1299 /**
1300 * Get count of cached transients
1301 *
1302 * @return int Number of cached transients
1303 */
1304 public static function get_cache_count() {
1305 global $wpdb;
1306
1307 $count = $wpdb->get_var(
1308 $wpdb->prepare(
1309 "SELECT COUNT(*) FROM {$wpdb->options}
1310 WHERE option_name LIKE %s",
1311 '_transient_' . self::TRANSIENT_PREFIX . '%'
1312 )
1313 );
1314
1315 return (int) $count;
1316 }
1317 }
1318
1319