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

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