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

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