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 / includes / class-metasync-headless-refresh-job.php

class-metasync-headless-refresh-job.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at includes/class-metasync-headless-refresh-job.php

1,311 lines 46.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Headless stale-while-revalidate OTTO refresh job.
4 *
5 * Headless mode serves GraphQL SEO data straight out of stored WordPress meta
6 * (see Metasync_Headless_Seo_Surface) so a GraphQL resolver never has to wait
7 * on OTTO. That is correct and fast, but it means a missed or delayed OTTO
8 * deployment webhook leaves the stored values stale with no page-load path
9 * that would ever refresh them — there is no render pass to piggyback on the
10 * way normal mode's transient cache does.
11 *
12 * This job is the safety net, not a replacement for the webhook. It runs in
13 * the background (WP-Cron, or the REST/WP-CLI fallback when WP-Cron is
14 * disabled), and for any headless-visible post or term whose stored OTTO data
15 * is missing or older than the configured interval, it:
16 *
17 * 1. Resolves the object's frontend URL explicitly, via the same
18 * Metasync_Headless_Url_Builder + WordPress object ID every other
19 * headless code path uses. It never calls url_to_postid() against a
20 * frontend host — the whole reason a URL-based lookup does not work here
21 * is that the crawled URL and the WordPress URL are different hosts.
22 * 2. Calls the existing OTTO URL-details fetch (metasync_fetch_otto_seo_data())
23 * directly for that URL.
24 * 3. Writes through the existing compare-before-write update functions
25 * (metasync_update_comprehensive_seo_fields() /
26 * metasync_update_comprehensive_taxonomy_seo_fields()) — the same ones the
27 * webhook path uses — so unchanged results are a no-op and the meta keys
28 * stay identical to what the webhook writes.
29 *
30 * Deliberately out of scope, per the approved design: no synchronous OTTO call
31 * from a GraphQL resolver, no re-enabling OTTO SSR/rendering, and no frontend
32 * cache management of any kind — this class owns WordPress storage only.
33 *
34 * Every WordPress-touching operation is a protected static "read seam" (the
35 * same pattern used throughout the headless surface) so tests can override
36 * just the seam being exercised without needing a live WordPress environment
37 * or colliding with unrelated ambient stubs.
38 *
39 * @package Metasync
40 * @subpackage Metasync/includes
41 */
42
43 if (!defined('ABSPATH')) {
44 exit; # Exit if accessed directly
45 }
46
47 require_once dirname(__DIR__) . '/headless-refresh-history/class-metasync-headless-refresh-history-database.php';
48
49 class Metasync_Headless_Refresh_Job
50 {
51 /**
52 * WP-Cron hook name.
53 */
54 const CRON_HOOK = 'metasync_headless_refresh_cron';
55
56 /**
57 * Custom WP-Cron recurrence name registered via the cron_schedules filter.
58 */
59 const CRON_RECURRENCE = 'metasync_headless_refresh';
60
61 /**
62 * Option remembering the interval (seconds) the currently-scheduled cron
63 * event was created with, so a later settings change can be detected and
64 * the event rescheduled — WP-Cron bakes the interval in at schedule time.
65 */
66 const SCHEDULED_INTERVAL_OPTION = 'metasync_headless_refresh_scheduled_interval';
67
68 /**
69 * Post/term meta key stamped every time this job checks an object,
70 * whether or not the check produced a change. Distinct from
71 * `_metasync_otto_last_update` (which the webhook path also writes),
72 * because that key only moves when OTTO data actually changes — using it
73 * for staleness would mean an object whose OTTO data never changes gets
74 * re-checked on every single batch forever, burning API budget on a URL
75 * that will keep answering "nothing changed".
76 */
77 const CHECKED_META_KEY = '_metasync_headless_refresh_checked';
78
79 /**
80 * Per-object lock TTL (seconds). Short: a lock only needs to outlive one
81 * OTTO API round trip, not a full refresh interval.
82 */
83 const LOCK_TTL = 60;
84
85 /**
86 * Batch size bounds and default.
87 */
88 const DEFAULT_BATCH_SIZE = 50;
89 const MIN_BATCH_SIZE = 1;
90 const MAX_BATCH_SIZE = 500;
91
92 /**
93 * API timeout bounds and default (seconds). Deliberately short: this job
94 * must not let one slow URL stall a whole batch.
95 */
96 const DEFAULT_API_TIMEOUT = 2;
97 const MIN_API_TIMEOUT = 1;
98 const MAX_API_TIMEOUT = 10;
99
100 /**
101 * Outcomes of one refresh attempt.
102 *
103 * run_batch() only ever needed three buckets, and refresh_object() still
104 * answers in those three. A request-driven caller needs more than that: it
105 * has to tell "OTTO answered, there was nothing to write" (a success — the
106 * cooldown has moved and the stored values are current) apart from "we never
107 * got to ask" (lock held, budget spent, breaker open), because only the
108 * second kind means the stored values may still be stale. Collapsing both
109 * into one value, as the batch contract does, would make that undecidable.
110 */
111 const OUTCOME_FRESH = 'fresh';
112 const OUTCOME_REFRESHED = 'refreshed';
113 const OUTCOME_UNCHANGED = 'unchanged';
114 const OUTCOME_EMPTY = 'empty';
115 const OUTCOME_NO_URL = 'no_url';
116 const OUTCOME_LOCKED = 'locked';
117 const OUTCOME_RATE_LIMITED = 'rate_limited';
118 const OUTCOME_BREAKER_OPEN = 'breaker_open';
119 const OUTCOME_FAILED = 'failed';
120 const OUTCOME_DISABLED = 'disabled';
121
122 /**
123 * Wire the job into WordPress. Safe to call unconditionally — every branch
124 * either checks Metasync_Headless_Config::is_active() itself or is cheap
125 * enough (a filter registration, a WP-CLI command registration) to cost
126 * nothing when headless mode is off.
127 *
128 * @return void
129 */
130 public static function init()
131 {
132 add_filter('cron_schedules', array(__CLASS__, 'register_cron_schedule'));
133 add_action('init', array(__CLASS__, 'maybe_reschedule'));
134 add_action(self::CRON_HOOK, array(__CLASS__, 'run_scheduled_batch'));
135 add_action('rest_api_init', array(__CLASS__, 'register_rest_route'));
136
137 # Same shape as cli_command() below: class_exists() both narrows for
138 # static analysis (the WP_CLI constant stub resolves to always-true,
139 # so && WP_CLI trips it) and guards the WP_CLI:: call.
140 if (defined('WP_CLI') && class_exists('WP_CLI')) {
141 WP_CLI::add_command('metasync headless-refresh', array(__CLASS__, 'cli_command'));
142 }
143 }
144
145 // ------------------------------------------------------------------
146 // Scheduling
147 // ------------------------------------------------------------------
148
149 /**
150 * Register the custom recurrence WP-Cron needs to schedule this job at
151 * the currently-configured interval.
152 *
153 * @param array $schedules Existing schedules.
154 * @return array
155 */
156 public static function register_cron_schedule($schedules)
157 {
158 $schedules[self::CRON_RECURRENCE] = array(
159 'interval' => static::get_interval_seconds(),
160 'display' => __('MetaSync Headless Refresh Interval', 'metasync'),
161 );
162
163 return $schedules;
164 }
165
166 /**
167 * Ensure the cron event matches the current on/off state and interval.
168 *
169 * Headless-gated so an inactive install carries zero cron overhead — not
170 * merely a job that runs and exits, but no scheduled event at all. Also
171 * reschedules when the configured interval has changed since the event
172 * was created, because WP-Cron bakes the interval into the event at
173 * schedule time; changing the option alone does not move an already
174 * -scheduled event.
175 *
176 * @return void
177 */
178 public static function maybe_reschedule()
179 {
180 if (!static::is_enabled()) {
181 if (static::next_scheduled()) {
182 static::clear_scheduled();
183 }
184 if (get_option(self::SCHEDULED_INTERVAL_OPTION, false) !== false) {
185 delete_option(self::SCHEDULED_INTERVAL_OPTION);
186 }
187 return;
188 }
189
190 $desired = static::get_interval_seconds();
191 $applied = (int) get_option(self::SCHEDULED_INTERVAL_OPTION, 0);
192
193 if (static::next_scheduled() && $applied === $desired) {
194 return;
195 }
196
197 if (static::next_scheduled()) {
198 static::clear_scheduled();
199 }
200
201 static::schedule_event(time() + $desired);
202 update_option(self::SCHEDULED_INTERVAL_OPTION, $desired, false);
203 }
204
205 /**
206 * @return int|false
207 */
208 protected static function next_scheduled()
209 {
210 return wp_next_scheduled(self::CRON_HOOK);
211 }
212
213 /**
214 * @return void
215 */
216 protected static function clear_scheduled()
217 {
218 wp_clear_scheduled_hook(self::CRON_HOOK);
219 }
220
221 /**
222 * @param int $timestamp
223 * @return void
224 */
225 protected static function schedule_event($timestamp)
226 {
227 wp_schedule_event($timestamp, self::CRON_RECURRENCE, self::CRON_HOOK);
228 }
229
230 /**
231 * Cron entry point. Never lets an exception escape — a background job
232 * failing loudly would, at best, spam the WordPress error log every
233 * interval forever, and at worst take down whatever WP-Cron run
234 * triggered it alongside unrelated scheduled hooks.
235 *
236 * @return void
237 */
238 public static function run_scheduled_batch()
239 {
240 try {
241 static::run_batch(0);
242 } catch (\Throwable $e) {
243 self::log_failure('cron', $e->getMessage());
244 }
245 }
246
247 // ------------------------------------------------------------------
248 // Configuration
249 // ------------------------------------------------------------------
250
251 /**
252 * Is the job allowed to do anything right now?
253 *
254 * Headless-gated first: every other check is pointless if headless mode
255 * itself is off or unusable (no frontend domain configured).
256 *
257 * @return bool
258 */
259 public static function is_enabled()
260 {
261 if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_active()) {
262 return false;
263 }
264
265 return (bool) apply_filters('metasync_headless_refresh_enabled', true);
266 }
267
268 /**
269 * Refresh interval, in seconds.
270 *
271 * Base value comes from the Headless settings UI (safe-bounded there);
272 * `metasync_headless_refresh_interval` lets a developer override it
273 * per-filter (e.g. for a staging site), but the result is re-clamped to
274 * the same safe bounds either way — a filter is not a way to disable the
275 * bounds, only to move within them faster than editing settings.
276 *
277 * @return int Seconds.
278 */
279 public static function get_interval_seconds()
280 {
281 # Update-window guard: cron_schedules fires during init whenever
282 # anything calls wp_get_schedules(), so this method runs on every
283 # request even with headless off. During an auto-update WordPress
284 # copies the plugin's files one at a time, and a request that lands
285 # while the config class has not been copied yet fatals on the read
286 # below — the classmap cannot autoload a file that is not on disk.
287 # The recurrence this produces is only consulted when headless is
288 # enabled, and is_enabled() above already answered false for the same
289 # missing class, so the placeholder is never scheduled; it just has to
290 # be a sane interval to hand WP-Cron. The literal mirrors
291 # REFRESH_INTERVAL_DEFAULT_MINUTES — exactly the value that cannot be
292 # read here.
293 if (!class_exists('Metasync_Headless_Config')) {
294 return 30 * MINUTE_IN_SECONDS;
295 }
296
297 $minutes = Metasync_Headless_Config::get_refresh_interval_minutes();
298
299 $seconds = $minutes * MINUTE_IN_SECONDS;
300 $seconds = (int) apply_filters('metasync_headless_refresh_interval', $seconds);
301
302 $min = Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES * MINUTE_IN_SECONDS;
303 $max = Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES * MINUTE_IN_SECONDS;
304
305 return max($min, min($max, $seconds));
306 }
307
308 /**
309 * @return int
310 */
311 public static function get_batch_size()
312 {
313 $size = (int) apply_filters('metasync_headless_refresh_batch_size', self::DEFAULT_BATCH_SIZE);
314
315 return max(self::MIN_BATCH_SIZE, min(self::MAX_BATCH_SIZE, $size));
316 }
317
318 /**
319 * @return int Seconds.
320 */
321 public static function get_api_timeout()
322 {
323 $timeout = (int) apply_filters('metasync_headless_refresh_api_timeout', self::DEFAULT_API_TIMEOUT);
324
325 return max(self::MIN_API_TIMEOUT, min(self::MAX_API_TIMEOUT, $timeout));
326 }
327
328 /**
329 * Shared per-minute OTTO API call budget for this job.
330 *
331 * Filterable, but defaults to the same "10 calls/minute" figure the
332 * page-render OTTO cache enforces. A separate counter namespace on
333 * purpose: a background refresh burning the same budget page loads use
334 * would make an unlucky visitor pay for a batch job's API calls.
335 *
336 * @return int
337 */
338 public static function get_rate_limit()
339 {
340 $default = class_exists('Metasync_Otto_Transient_Cache')
341 ? Metasync_Otto_Transient_Cache::MAX_API_CALLS_PER_MINUTE
342 : 10;
343
344 return max(1, (int) apply_filters('metasync_headless_refresh_rate_limit', $default));
345 }
346
347 // ------------------------------------------------------------------
348 // Batch execution
349 // ------------------------------------------------------------------
350
351 /**
352 * Run one batch of refresh checks.
353 *
354 * Safe to call directly (REST endpoint, WP-CLI, tests) as well as from
355 * cron. Always returns a result array; never throws for a per-object
356 * failure — an OTTO failure for one URL must not stop the rest of the
357 * batch, and stored GraphQL values keep serving from whatever was last
358 * successfully written.
359 *
360 * @param int $offset Pagination offset into the stale-object list.
361 * @param int|null $batch_size Objects to process. Defaults to get_batch_size().
362 * @return array{processed:int,refreshed:int,skipped:int,failed:int,next_offset:int|null}
363 */
364 public static function run_batch($offset = 0, $batch_size = null)
365 {
366 $result = array(
367 'processed' => 0,
368 'refreshed' => 0,
369 'skipped' => 0,
370 'failed' => 0,
371 'next_offset' => null,
372 );
373
374 if (!static::is_enabled()) {
375 return $result;
376 }
377
378 if (!static::cpu_load_safe()) {
379 return $result;
380 }
381
382 $otto_uuid = static::otto_uuid();
383 if (empty($otto_uuid)) {
384 return $result;
385 }
386
387 $offset = max(0, (int) $offset);
388 $batch_size = $batch_size !== null ? max(self::MIN_BATCH_SIZE, min(self::MAX_BATCH_SIZE, (int) $batch_size)) : static::get_batch_size();
389
390 $candidates = static::collect_candidates($batch_size, $offset);
391
392 foreach ($candidates as $candidate) {
393 $result['processed']++;
394
395 try {
396 $outcome = static::refresh_object($candidate['type'], $candidate['id'], $candidate['taxonomy'], $otto_uuid);
397 } catch (\Throwable $e) {
398 $outcome = 'failed';
399 static::log_failure($candidate['type'] . ':' . $candidate['id'], $e->getMessage());
400 }
401
402 if (!isset($result[$outcome])) {
403 $outcome = 'failed';
404 }
405
406 $result[$outcome]++;
407 }
408
409 $result['next_offset'] = count($candidates) < $batch_size ? null : ($offset + count($candidates));
410
411 return $result;
412 }
413
414 /**
415 * Refresh one known object for a request-driven caller.
416 *
417 * Unlike run_batch(), this method never discovers or processes other
418 * objects: it acts on the object identity the caller already resolved, and
419 * on nothing else. That is the whole reason it exists — a GraphQL resolver
420 * must never be able to trigger a site-wide sweep.
421 *
422 * Answers with one of the OUTCOME_* constants rather than the batch's
423 * three-way result, because the caller has to decide whether to re-read
424 * stored values, and only OUTCOME_REFRESHED means they changed.
425 *
426 * @param string $type 'post' or 'term'.
427 * @param int $id WordPress object ID.
428 * @param string|null $taxonomy Taxonomy name, for terms only.
429 * @param int|null $timeout Transport timeout override, seconds.
430 * @return string One of the OUTCOME_* constants.
431 */
432 public static function refresh_single_object($type, $id, $taxonomy = null, $timeout = null)
433 {
434 if (!static::is_enabled() || !static::cpu_load_safe()) {
435 return self::OUTCOME_DISABLED;
436 }
437
438 $otto_uuid = static::otto_uuid();
439 if ($otto_uuid === '') {
440 return self::OUTCOME_DISABLED;
441 }
442
443 if (!static::is_stale($type, (int) $id, $taxonomy)) {
444 return self::OUTCOME_FRESH;
445 }
446
447 try {
448 return static::attempt_refresh(
449 $type,
450 (int) $id,
451 $taxonomy,
452 $otto_uuid,
453 $timeout === null ? static::get_api_timeout() : max(self::MIN_API_TIMEOUT, (int) $timeout)
454 );
455 } catch (\Throwable $e) {
456 # Caught here rather than left to the caller: a resolver that let
457 # this escape would fail the whole GraphQL field, which is exactly
458 # the outcome a refresh is not allowed to cause.
459 static::log_failure($type . ':' . (int) $id, $e->getMessage());
460
461 return self::OUTCOME_FAILED;
462 }
463 }
464
465 /**
466 * Is this object's checked marker missing or outside the cooldown?
467 *
468 * Deliberately reads `_metasync_headless_refresh_checked` and not
469 * `_metasync_otto_last_update`: the second only moves when OTTO data
470 * actually changes, so an object whose suggestions never change would read
471 * as permanently stale and be re-fetched on every single request.
472 *
473 * @param string $type
474 * @param int $id
475 * @param string|null $taxonomy
476 * @return bool
477 */
478 protected static function is_stale($type, $id, $taxonomy = null)
479 {
480 $checked = $type === 'post'
481 ? static::read_checked_post_meta((int) $id)
482 : static::read_checked_term_meta((int) $id);
483
484 if ($checked === '' || $checked === null || $checked === false) {
485 return true;
486 }
487
488 return (int) $checked < (time() - static::get_interval_seconds());
489 }
490
491 /**
492 * @param int $id
493 * @return mixed Stored checked-at value, or '' when there is none.
494 */
495 protected static function read_checked_post_meta($id)
496 {
497 return get_post_meta((int) $id, self::CHECKED_META_KEY, true);
498 }
499
500 /**
501 * @param int $id
502 * @return mixed Stored checked-at value, or '' when there is none.
503 */
504 protected static function read_checked_term_meta($id)
505 {
506 return get_term_meta((int) $id, self::CHECKED_META_KEY, true);
507 }
508
509 /**
510 * Refresh one object: fetch, compare, write only what changed.
511 *
512 * The batch's three-value contract, unchanged. The detail lives in
513 * attempt_refresh(); this maps it back down, so a per-object failure still
514 * counts the way run_batch()'s result array has always counted it.
515 *
516 * @param string $type 'post' or 'term'.
517 * @param int $id WordPress object ID.
518 * @param string|null $taxonomy Taxonomy name, for terms only.
519 * @param string $otto_uuid OTTO UUID.
520 * @return string One of 'refreshed', 'skipped', 'failed'.
521 */
522 protected static function refresh_object($type, $id, $taxonomy, $otto_uuid)
523 {
524 $outcome = static::attempt_refresh($type, $id, $taxonomy, $otto_uuid, static::get_api_timeout());
525
526 if ($outcome === self::OUTCOME_REFRESHED) {
527 return 'refreshed';
528 }
529
530 if ($outcome === self::OUTCOME_FAILED) {
531 return 'failed';
532 }
533
534 # Everything else — no URL, lock held, budget spent, breaker open, an
535 # empty answer, an answer that changed nothing — is a skip for the
536 # batch: nothing about the object itself failed, and the next batch
537 # will pick it up if it is still stale.
538 return 'skipped';
539 }
540
541 /**
542 * One refresh attempt, reporting exactly why it ended the way it did.
543 *
544 * @param string $type
545 * @param int $id
546 * @param string|null $taxonomy
547 * @param string $otto_uuid
548 * @param int $timeout
549 * @return string One of the OUTCOME_* constants.
550 */
551 protected static function attempt_refresh($type, $id, $taxonomy, $otto_uuid, $timeout)
552 {
553 $url = static::resolve_frontend_url($type, $id, $taxonomy);
554 if ($url === '') {
555 # No frontend URL could be built — headless misconfigured, object
556 # deleted between query and processing, or an unmapped type.
557 # Nothing to fetch and nothing to compromise.
558 #
559 # Stamped checked all the same. Without it the object stays stale
560 # for ever, and since the batch query always starts from the oldest
561 # stale objects, a handful of permanently URL-less ones would sit at
562 # the head of every batch and crowd out the objects that can
563 # actually be refreshed.
564 static::mark_checked($type, $id, $taxonomy);
565
566 return self::OUTCOME_NO_URL;
567 }
568
569 if (static::breaker_open()) {
570 # The shared OTTO breaker is open or the endpoint is in backoff.
571 # Asked before the lock on purpose: it is a cheap read, and taking
572 # a lock only to discover we may not call is pure contention.
573 return self::OUTCOME_BREAKER_OPEN;
574 }
575
576 $lock_key = static::lock_key($type, $id, $taxonomy);
577 if (!static::acquire_lock($lock_key)) {
578 # Another worker — a concurrent request for the same URL, a batch,
579 # WP-Cron overlap — is already refreshing this exact object. This
580 # is what stops a burst of requests for one page becoming a burst
581 # of identical OTTO calls.
582 return self::OUTCOME_LOCKED;
583 }
584
585 try {
586 if (!static::rate_limit_ok()) {
587 # Per-minute budget exhausted. Leave the object stale rather
588 # than failing it; nothing about the object itself failed.
589 return self::OUTCOME_RATE_LIMITED;
590 }
591
592 $seo_data = static::fetch_seo_data($url, $otto_uuid, $timeout);
593
594 # Stamp checked-at regardless of outcome. A URL that fails every
595 # time must still age out of "needs refresh" on the same cooldown
596 # as everything else, or it would monopolise every future batch —
597 # the API budget being spent retrying a URL that will keep failing
598 # is a worse outcome than waiting one more interval to retry it.
599 static::mark_checked($type, $id, $taxonomy);
600
601 if ($seo_data === false) {
602 # Transport error, timeout, or an HTTP status the fetch helper
603 # treats as failure (401, 429, 5xx). Stored values are
604 # untouched and keep serving as-is — this job never has a
605 # reason to clear or degrade what is stored.
606 return self::OUTCOME_FAILED;
607 }
608
609 if (empty($seo_data)) {
610 # A definitive-but-empty answer (204, or 404 "no OTTO
611 # optimizations available for this URL"): the API answered,
612 # there is simply nothing to write. A successful check with no
613 # changes, not a failure — otherwise a site OTTO has not
614 # optimized yet would read as permanently broken.
615 return self::OUTCOME_EMPTY;
616 }
617
618 if ($type === 'post') {
619 $update = static::update_post_fields((int) $id, $seo_data);
620 } else {
621 $update = static::update_term_fields((int) $id, (string) $taxonomy, $seo_data);
622 }
623
624 return !empty($update['updated']) ? self::OUTCOME_REFRESHED : self::OUTCOME_UNCHANGED;
625 } finally {
626 static::release_lock($lock_key);
627 }
628 }
629
630 /**
631 * @param string $type
632 * @param int $id
633 * @param string|null $taxonomy
634 * @return string Frontend URL, or '' when one cannot be built.
635 */
636 protected static function resolve_frontend_url($type, $id, $taxonomy)
637 {
638 if (!class_exists('Metasync_Headless_Url_Builder')) {
639 return '';
640 }
641
642 if ($type === 'post') {
643 $post = static::read_post($id);
644 if (!$post) {
645 return '';
646 }
647
648 return (string) Metasync_Headless_Url_Builder::from_wp_url(static::read_permalink($post), (string) $post->post_type);
649 }
650
651 if ($type === 'term') {
652 $term = static::read_term($id, $taxonomy);
653 if (!$term) {
654 return '';
655 }
656
657 return (string) Metasync_Headless_Url_Builder::from_wp_url(static::read_term_link($term));
658 }
659
660 return '';
661 }
662
663 /**
664 * @param string $type
665 * @param int $id
666 * @param string|null $taxonomy
667 * @return string
668 */
669 protected static function lock_key($type, $id, $taxonomy)
670 {
671 $suffix = $type === 'term' ? '_' . sanitize_key((string) $taxonomy) : '';
672
673 return 'metasync_headless_refresh_lock_' . $type . '_' . (int) $id . $suffix;
674 }
675
676 // ------------------------------------------------------------------
677 // Candidate selection
678 // ------------------------------------------------------------------
679
680 /**
681 * Collect up to $limit stale/missing objects starting at $offset, posts
682 * first then terms — a single virtual list spanning both, so REST/WP-CLI
683 * pagination can walk the whole site with one offset counter.
684 *
685 * @param int $limit
686 * @param int $offset
687 * @return array<int,array{type:string,id:int,taxonomy:?string}>
688 */
689 public static function collect_candidates($limit, $offset)
690 {
691 $threshold = time() - static::get_interval_seconds();
692 $candidates = array();
693
694 $post_total = static::count_stale_posts($threshold);
695
696 if ($offset < $post_total) {
697 $post_limit = min($limit, $post_total - $offset);
698 foreach (static::query_stale_post_ids($post_limit, $offset, $threshold) as $post_id) {
699 $candidates[] = array('type' => 'post', 'id' => (int) $post_id, 'taxonomy' => null);
700 }
701 $remaining = $limit - count($candidates);
702 $term_offset = 0;
703 } else {
704 $remaining = $limit;
705 $term_offset = $offset - $post_total;
706 }
707
708 if ($remaining > 0) {
709 foreach (static::query_stale_term_ids($remaining, $term_offset, $threshold) as $row) {
710 $candidates[] = array('type' => 'term', 'id' => (int) $row['id'], 'taxonomy' => (string) $row['taxonomy']);
711 }
712 }
713
714 return $candidates;
715 }
716
717 /**
718 * Meta query matching "never checked" OR "checked before $threshold".
719 *
720 * @param int $threshold Unix timestamp.
721 * @return array
722 */
723 protected static function stale_meta_query($threshold)
724 {
725 return array(
726 'relation' => 'OR',
727 array(
728 'key' => self::CHECKED_META_KEY,
729 'compare' => 'NOT EXISTS',
730 ),
731 array(
732 'key' => self::CHECKED_META_KEY,
733 'value' => (string) $threshold,
734 'compare' => '<',
735 'type' => 'NUMERIC',
736 ),
737 );
738 }
739
740 /**
741 * @return string[] Public post type names.
742 */
743 protected static function eligible_post_types()
744 {
745 return array_values(get_post_types(array('public' => true), 'names'));
746 }
747
748 /**
749 * @return string[] Public taxonomy names.
750 */
751 protected static function eligible_taxonomies()
752 {
753 return array_values(get_taxonomies(array('public' => true), 'names'));
754 }
755
756 /**
757 * @param int $threshold
758 * @return int
759 */
760 protected static function count_stale_posts($threshold)
761 {
762 $post_types = static::eligible_post_types();
763 if (empty($post_types)) {
764 return 0;
765 }
766
767 $query = new WP_Query(array(
768 'post_type' => $post_types,
769 'post_status' => 'publish',
770 'posts_per_page' => 1,
771 'fields' => 'ids',
772 'ignore_sticky_posts' => true,
773 'meta_query' => static::stale_meta_query($threshold),
774 ));
775
776 return (int) $query->found_posts;
777 }
778
779 /**
780 * @param int $limit
781 * @param int $offset
782 * @param int $threshold
783 * @return int[]
784 */
785 protected static function query_stale_post_ids($limit, $offset, $threshold)
786 {
787 $post_types = static::eligible_post_types();
788 if (empty($post_types) || $limit <= 0) {
789 return array();
790 }
791
792 $query = new WP_Query(array(
793 'post_type' => $post_types,
794 'post_status' => 'publish',
795 'posts_per_page' => $limit,
796 'offset' => $offset,
797 'orderby' => 'ID',
798 'order' => 'ASC',
799 'fields' => 'ids',
800 'no_found_rows' => true,
801 'ignore_sticky_posts' => true,
802 'meta_query' => static::stale_meta_query($threshold),
803 ));
804
805 return array_map('intval', $query->posts);
806 }
807
808 /**
809 * @param int $limit
810 * @param int $offset
811 * @param int $threshold
812 * @return array<int,array{id:int,taxonomy:string}>
813 */
814 protected static function query_stale_term_ids($limit, $offset, $threshold)
815 {
816 $taxonomies = static::eligible_taxonomies();
817 if (empty($taxonomies) || $limit <= 0) {
818 return array();
819 }
820
821 $terms = get_terms(array(
822 'taxonomy' => $taxonomies,
823 'hide_empty' => false,
824 'number' => $limit,
825 'offset' => $offset,
826 'orderby' => 'id',
827 'order' => 'ASC',
828 'fields' => 'all',
829 'meta_query' => static::stale_meta_query($threshold),
830 ));
831
832 if (is_wp_error($terms)) {
833 return array();
834 }
835
836 $rows = array();
837 foreach ($terms as $term) {
838 $rows[] = array('id' => (int) $term->term_id, 'taxonomy' => (string) $term->taxonomy);
839 }
840
841 return $rows;
842 }
843
844 // ------------------------------------------------------------------
845 // Read/write seams — overridden by tests, real WordPress/OTTO calls
846 // otherwise. Kept intentionally thin: each wraps exactly one WordPress
847 // or existing-OTTO-function call, so a test double can stand in for one
848 // seam without dragging in unrelated behaviour.
849 // ------------------------------------------------------------------
850
851 /**
852 * @return bool
853 */
854 protected static function cpu_load_safe()
855 {
856 return !class_exists('Metasync_CPU_Monitor') || Metasync_CPU_Monitor::is_load_safe();
857 }
858
859 /**
860 * @return string
861 */
862 protected static function otto_uuid()
863 {
864 return class_exists('Metasync_Otto_Config') ? (string) Metasync_Otto_Config::get_otto_uuid() : '';
865 }
866
867 /**
868 * Is there room in the per-minute OTTO budget?
869 *
870 * Deliberately one bucket for both callers. The limit exists to cap what
871 * this site asks of OTTO per minute, and that ceiling does not rise because
872 * the calls arrived from GraphQL rather than from cron. Requests can
873 * therefore crowd the batch out of a given minute — which is the right way
874 * round: a minute spent refreshing the objects someone is actually asking
875 * for is better spent than a minute of speculative maintenance, and the
876 * batch simply picks those objects up as already-fresh next time.
877 *
878 * @return bool
879 */
880 protected static function rate_limit_ok()
881 {
882 if (!class_exists('Metasync_Rate_Limiter')) {
883 return true;
884 }
885
886 $result = Metasync_Rate_Limiter::get_instance()->check_rate_limit(
887 'batch',
888 self::get_rate_limit(),
889 MINUTE_IN_SECONDS,
890 'metasync_headless_refresh_'
891 );
892
893 return $result === true;
894 }
895
896 /**
897 * @param string $url
898 * @param string $otto_uuid
899 * @param int $timeout
900 * @return array|false
901 */
902 protected static function fetch_seo_data($url, $otto_uuid, $timeout)
903 {
904 if (!function_exists('metasync_fetch_otto_seo_data')) {
905 return false;
906 }
907
908 // The fetcher's third parameter is its by-reference failure
909 // classification, which this job does not consume — a local throwaway
910 // is passed so the timeout lands in the fourth slot.
911 $failure = null;
912
913 return metasync_fetch_otto_seo_data($url, $otto_uuid, $failure, $timeout);
914 }
915
916 /**
917 * Is the shared OTTO protection currently refusing outbound calls?
918 *
919 * Bridged explicitly rather than assumed: metasync_fetch_otto_seo_data()
920 * makes the request directly and consults neither the circuit breaker nor
921 * the backoff manager — both of those live around the render path's
922 * transient cache. Without this check a job calling the fetch helper would
923 * keep hammering a host the rest of the plugin has already given up on.
924 *
925 * Read-only on both: this never opens, closes or probes the breaker, so a
926 * refresh cannot disturb the render path's state machine.
927 *
928 * @return bool True when no call should be attempted right now.
929 */
930 protected static function breaker_open()
931 {
932 $endpoint = static::otto_endpoint_url();
933 if ($endpoint === '') {
934 return false;
935 }
936
937 if (class_exists('Metasync_Otto_Transient_Cache')
938 && Metasync_Otto_Transient_Cache::is_host_breaker_open($endpoint)) {
939 return true;
940 }
941
942 if (class_exists('Metasync_API_Backoff_Manager')
943 && Metasync_API_Backoff_Manager::get_instance()->is_endpoint_in_backoff($endpoint)) {
944 return true;
945 }
946
947 return false;
948 }
949
950 /**
951 * The OTTO URL-details endpoint, for breaker/backoff lookups only.
952 *
953 * @return string
954 */
955 protected static function otto_endpoint_url()
956 {
957 if (class_exists('Metasync_Endpoint_Manager')) {
958 return (string) Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
959 }
960
961 return '';
962 }
963
964 /**
965 * @param int $post_id
966 * @param array $seo_data
967 * @return array{updated:bool,fields_updated:array}
968 */
969 protected static function update_post_fields($post_id, $seo_data)
970 {
971 if (!function_exists('metasync_update_comprehensive_seo_fields')) {
972 return array('updated' => false, 'fields_updated' => array());
973 }
974
975 return metasync_update_comprehensive_seo_fields($post_id, $seo_data);
976 }
977
978 /**
979 * @param int $term_id
980 * @param string $taxonomy
981 * @param array $seo_data
982 * @return array{updated:bool,fields_updated:array}
983 */
984 protected static function update_term_fields($term_id, $taxonomy, $seo_data)
985 {
986 if (!function_exists('metasync_update_comprehensive_taxonomy_seo_fields')) {
987 return array('updated' => false, 'fields_updated' => array());
988 }
989
990 return metasync_update_comprehensive_taxonomy_seo_fields($term_id, $taxonomy, $seo_data);
991 }
992
993 /**
994 * @param int $id
995 * @return WP_Post|null
996 */
997 protected static function read_post($id)
998 {
999 $post = get_post($id);
1000 return $post instanceof WP_Post ? $post : null;
1001 }
1002
1003 /**
1004 * @param WP_Post $post
1005 * @return string
1006 */
1007 protected static function read_permalink($post)
1008 {
1009 $permalink = get_permalink($post);
1010 return $permalink ?: '';
1011 }
1012
1013 /**
1014 * @param int $id
1015 * @param string $taxonomy
1016 * @return WP_Term|null
1017 */
1018 protected static function read_term($id, $taxonomy)
1019 {
1020 $term = get_term($id, (string) $taxonomy);
1021 return ($term instanceof WP_Term) ? $term : null;
1022 }
1023
1024 /**
1025 * @param WP_Term $term
1026 * @return string
1027 */
1028 protected static function read_term_link($term)
1029 {
1030 $link = get_term_link($term);
1031 return is_string($link) ? $link : '';
1032 }
1033
1034 /**
1035 * Acquire the per-object lock, atomically.
1036 *
1037 * A read-then-write pair is not good enough here. Two concurrent GraphQL
1038 * requests for the same page would both find no lock, both set one, and
1039 * both call OTTO — precisely the duplicate call the lock exists to prevent,
1040 * and the case most likely to happen, since a burst of traffic to one URL
1041 * is what makes an object interesting in the first place.
1042 *
1043 * On an external object cache wp_cache_add() is SET NX and the backend
1044 * decides the winner. On the MySQL transient backend the unique index on
1045 * option_name makes INSERT IGNORE the equivalent; an expired-but-not-yet
1046 * -deleted row is claimed by a compare-and-swap so only one racer takes it.
1047 * This mirrors Metasync_Otto_Transient_Cache::acquire_lock_atomic(), which
1048 * is the render path's answer to the same race.
1049 *
1050 * @param string $lock_key
1051 * @return bool True if this caller acquired the lock.
1052 */
1053 protected static function acquire_lock($lock_key)
1054 {
1055 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
1056 return (bool) wp_cache_add($lock_key, '1', 'transient', self::LOCK_TTL);
1057 }
1058
1059 global $wpdb;
1060
1061 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'query')) {
1062 # No database handle to be atomic against (unit context, or an
1063 # install mid-teardown). Fall back to the check-then-set pair: it
1064 # is weaker, but a lock that cannot be taken at all would mean
1065 # never refreshing rather than refreshing twice.
1066 if (get_transient($lock_key) !== false) {
1067 return false;
1068 }
1069
1070 set_transient($lock_key, 1, self::LOCK_TTL);
1071
1072 return true;
1073 }
1074
1075 $now = time();
1076 $new_expires = $now + self::LOCK_TTL;
1077 $timeout_option = '_transient_timeout_' . $lock_key;
1078 $value_option = '_transient_' . $lock_key;
1079
1080 # claim_lock_row() is private (not a test seam), so bind it statically.
1081 if (self::claim_lock_row($timeout_option, $value_option, $new_expires)) {
1082 return true;
1083 }
1084
1085 $existing_expires = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE is the claim primitive
1086 $wpdb->prepare(
1087 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1088 $timeout_option
1089 )
1090 );
1091
1092 if ($existing_expires === null) {
1093 # The row was there when the insert raced and gone by the time we
1094 # read it: the previous holder released between the two statements.
1095 # There is nothing to compare against, so claim it the same way the
1096 # first caller would have. One retry only — a second failure means
1097 # someone else won, which is the lock doing its job.
1098 return self::claim_lock_row($timeout_option, $value_option, $new_expires);
1099 }
1100
1101 $existing_expires = (int) $existing_expires;
1102
1103 if ($existing_expires > $now) {
1104 return false;
1105 }
1106
1107 $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE is the claim primitive
1108 $wpdb->prepare(
1109 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) = %d",
1110 (string) $new_expires,
1111 $timeout_option,
1112 $existing_expires
1113 )
1114 );
1115
1116 if ($updated === 1) {
1117 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE is the claim primitive
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
1125 return true;
1126 }
1127
1128 return false;
1129 }
1130
1131 /**
1132 * Insert the lock row, winning only if no row was there.
1133 *
1134 * The unique index on option_name is what makes this atomic: exactly one
1135 * concurrent INSERT IGNORE reports a row inserted.
1136 *
1137 * @param string $timeout_option
1138 * @param string $value_option
1139 * @param int $expires
1140 * @return bool
1141 */
1142 private static function claim_lock_row($timeout_option, $value_option, $expires)
1143 {
1144 global $wpdb;
1145
1146 $inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE is the claim primitive
1147 $wpdb->prepare(
1148 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
1149 $timeout_option,
1150 (string) $expires
1151 )
1152 );
1153
1154 if ($inserted !== 1) {
1155 return false;
1156 }
1157
1158 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE is the claim primitive
1159 $wpdb->prepare(
1160 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
1161 $value_option,
1162 '1'
1163 )
1164 );
1165
1166 return true;
1167 }
1168
1169 /**
1170 * @param string $lock_key
1171 * @return void
1172 */
1173 protected static function release_lock($lock_key)
1174 {
1175 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
1176 wp_cache_delete($lock_key, 'transient');
1177
1178 return;
1179 }
1180
1181 delete_transient($lock_key);
1182 }
1183
1184 /**
1185 * @param string $type
1186 * @param int $id
1187 * @param string|null $taxonomy Unused; kept for signature symmetry with lock_key().
1188 * @return void
1189 */
1190 protected static function mark_checked($type, $id, $taxonomy = null)
1191 {
1192 if ($type === 'post') {
1193 update_post_meta($id, self::CHECKED_META_KEY, time());
1194 return;
1195 }
1196
1197 update_term_meta($id, self::CHECKED_META_KEY, time());
1198 }
1199
1200 /**
1201 * Record a failure for diagnostics without exposing internal details
1202 * publicly — this is a plain error_log() call plus the plugin's own
1203 * failed-action counter, the same one metasync_process_otto_seo_data()
1204 * feeds, so Site Health's existing "failed actions" surface picks these
1205 * up too rather than needing a second diagnostic to check.
1206 *
1207 * @param string $context
1208 * @param string $message
1209 * @return void
1210 */
1211 protected static function log_failure($context, $message)
1212 {
1213 if (function_exists('metasync_record_failed_action')) {
1214 metasync_record_failed_action('metasync_headless_refresh:' . $context);
1215 }
1216
1217 error_log('MetaSync Headless Refresh: ' . $context . ' - ' . $message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1218 }
1219
1220 // ------------------------------------------------------------------
1221 // REST + WP-CLI fallbacks for hosts where WP-Cron is disabled
1222 // ------------------------------------------------------------------
1223
1224 /**
1225 * Registered directly here rather than from the REST API class, so the
1226 * whole fallback path (route + handler + the job itself) lives in one
1227 * file.
1228 *
1229 * @return void
1230 */
1231 public static function register_rest_route()
1232 {
1233 register_rest_route('metasync/v1', 'headless-refresh-batch', array(
1234 array(
1235 'methods' => 'POST',
1236 'callback' => array(__CLASS__, 'rest_run_batch'),
1237 'permission_callback' => array(__CLASS__, 'rest_permission_check'),
1238 'args' => array(
1239 'offset' => array(
1240 'default' => 0,
1241 'sanitize_callback' => 'absint',
1242 ),
1243 'batch_size' => array(
1244 'required' => false,
1245 'sanitize_callback' => 'absint',
1246 ),
1247 ),
1248 ),
1249 ));
1250 }
1251
1252 /**
1253 * Reuses the plugin's own API-key/nonce authorization middleware so this
1254 * endpoint is protected the same way every other MetaSync REST route is,
1255 * rather than inventing a second auth path.
1256 *
1257 * @param WP_REST_Request $request
1258 * @return bool
1259 */
1260 public static function rest_permission_check($request)
1261 {
1262 if (!class_exists('Metasync_Rest_Api')) {
1263 return false;
1264 }
1265
1266 $rest_api = new Metasync_Rest_Api('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '0.0.0');
1267 return $rest_api->rest_authorization_middleware($request);
1268 }
1269
1270 /**
1271 * @param WP_REST_Request $request
1272 * @return WP_REST_Response
1273 */
1274 public static function rest_run_batch($request)
1275 {
1276 $offset = (int) $request->get_param('offset');
1277 $batch_size = $request->get_param('batch_size');
1278 $batch_size = ($batch_size === null || $batch_size === '') ? null : (int) $batch_size;
1279
1280 $result = static::run_batch($offset, $batch_size);
1281
1282 return new WP_REST_Response($result, 200);
1283 }
1284
1285 /**
1286 * WP-CLI command: `wp metasync headless-refresh [--batch-size=<n>] [--offset=<n>]`
1287 *
1288 * @param array $args
1289 * @param array $assoc_args
1290 * @return void
1291 */
1292 public static function cli_command($args, $assoc_args)
1293 {
1294 $offset = isset($assoc_args['offset']) ? (int) $assoc_args['offset'] : 0;
1295 $batch_size = isset($assoc_args['batch-size']) ? (int) $assoc_args['batch-size'] : null;
1296
1297 $result = static::run_batch($offset, $batch_size);
1298
1299 if (class_exists('WP_CLI')) {
1300 WP_CLI::log(wp_json_encode($result));
1301 WP_CLI::success(sprintf(
1302 'Processed %d, refreshed %d, skipped %d, failed %d.',
1303 $result['processed'],
1304 $result['refreshed'],
1305 $result['skipped'],
1306 $result['failed']
1307 ));
1308 }
1309 }
1310 }
1311