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-backstop-sync.php

class-metasync-otto-backstop-sync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at otto/class-metasync-otto-backstop-sync.php

308 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Postmeta backstop for OTTO transient cache misses.
4 *
5 * The crawl-notify webhook (otto_crawl_notify -> metasync_process_otto_crawl_url_job
6 * -> metasync_process_otto_seo_data) is the primary path that writes fresh OTTO
7 * suggestions into post meta. It is a webhook plus WP-Cron, so it can fail —
8 * and when it fails repeatedly the stored meta goes stale indefinitely.
9 *
10 * A transient cache MISS is the one moment the render path ALREADY holds fresh
11 * OTTO data: get_suggestions() fetched it live to build the page. This class
12 * reuses that fetch — no second API call — and runs the existing compare-then-
13 * write sync (metasync_process_otto_seo_data) for it, after the response HTML
14 * has been built.
15 *
16 * Deliberate non-goals: no reconciliation sweep, no hashing scheme, no queue,
17 * and no WP-Cron. Every one of those would reintroduce the reliability gap
18 * this backstop exists to close.
19 *
20 * @package Search Atlas SEO
21 */
22
23 if (!defined('ABSPATH')) {
24 exit;
25 }
26
27 final class Metasync_Otto_Backstop_Sync {
28
29 /**
30 * Prefix of the per-URL guard that dedupes concurrent MISS syncs.
31 */
32 const GUARD_PREFIX = 'metasync_otto_backstop_';
33
34 /**
35 * Guard lifetime in seconds. Mirrors the 120s TTL of the per-URL SEO lock
36 * inside metasync_process_otto_seo_data() so the two claims on a URL
37 * cannot outlive each other.
38 */
39 const GUARD_TTL = 120;
40
41 /**
42 * Decide whether this request owes a postmeta sync, and defer it to shutdown.
43 *
44 * Called from Metasync_otto_pixel::render_route_html() with the cache status
45 * get_suggestions() just produced. Everything a steady-state request does —
46 * a transient HIT — bails on the first guard below and costs nothing.
47 *
48 * @param string $route Fully-qualified URL of the page being rendered.
49 * @param array $suggestions Suggestions fetched by this request's MISS.
50 * @param string $cache_status Cache status from Metasync_Otto_Transient_Cache.
51 * @return bool True when a shutdown sync was scheduled.
52 */
53 public static function maybe_defer_sync($route, array $suggestions, $cache_status) {
54 # Only a genuine MISS may sync. HIT (the steady state), STALE,
55 # NO_SUGGESTIONS, RATE_LIMITED, LOCKED, BREAKER_OPEN and API_ERROR all
56 # served data this request did not fetch, so none of them owes a write.
57 if ($cache_status !== Metasync_Otto_Transient_Cache::STATUS_MISS) {
58 return false;
59 }
60
61 # The PHPDoc promises a string, but the render path hands over whatever
62 # it holds; the runtime check stays for callers PHPStan cannot see
63 # (same idiom as class-metasync-otto-render-strategy.php).
64 # @phpstan-ignore-next-line function.alreadyNarrowedType
65 if (!is_string($route) || $route === '') {
66 return false;
67 }
68
69 # Kill switch. This behaviour hangs off the highest-risk files in the
70 # plugin, so support must be able to switch it off per site without a
71 # release.
72 if (!self::is_enabled()) {
73 return false;
74 }
75
76 $cache = new Metasync_Otto_Transient_Cache(Metasync_Otto_Config::get_otto_uuid());
77
78 # An empty payload is an answer ("OTTO holds nothing for this URL"),
79 # not a reason to touch postmeta. has_payload() also filters the
80 # whitespace-only wrappers an undeployed response ships.
81 if (!$cache->has_payload($suggestions)) {
82 return false;
83 }
84
85 # A loaded server never gets more work from a backstop. Skipping is
86 # cheap and self-healing: the next MISS, one cache TTL later, retries.
87 # Nothing is scheduled on this path — see the class docblock.
88 if (class_exists('Metasync_CPU_Monitor') && !Metasync_CPU_Monitor::is_load_safe()) {
89 return false;
90 }
91
92 # Once per URL per miss window. get_suggestions() has already released
93 # its fetch lock by the time the render path calls this, so a second
94 # worker can legitimately be in the same MISS for the same URL; the
95 # guard uses the SAME atomic primitive and only one of them wins.
96 # metasync_process_otto_seo_data() re-checks its own per-URL lock too,
97 # so a sync write is at most one per URL even across windows.
98 if (!$cache->acquire_lock(self::guard_key($route), self::GUARD_TTL)) {
99 return false;
100 }
101
102 # Shutdown callbacks run after the response HTML has been built (and,
103 # under FPM, after finish_request() has flushed it to the client), so
104 # the served page is untouched and the sync adds no measurable latency.
105 register_shutdown_function(array(__CLASS__, 'run'), $route, $suggestions);
106
107 self::debug_log('defer_scheduled', [
108 'route' => $route,
109 'status' => 'MISS',
110 'extra' => ['guard_key' => self::guard_key($route)],
111 ]);
112
113 return true;
114 }
115
116 /**
117 * Run the deferred postmeta sync. Registered by maybe_defer_sync().
118 *
119 * Public because register_shutdown_function() needs a callable; it is not
120 * meant to be called from request code.
121 *
122 * @param string $route Fully-qualified URL.
123 * @param array $suggestions Suggestions fetched by this request's MISS.
124 */
125 public static function run($route, array $suggestions) {
126 $guard_key = self::guard_key($route);
127 $started_at = microtime(true);
128 self::debug_log('sync_started', ['route' => $route]);
129
130 try {
131 # Hand the finished response to the client before touching the
132 # database. See finish_request() for what happens where this is a
133 # no-op.
134 self::finish_request();
135
136 if (!function_exists('metasync_process_otto_seo_data')) {
137 # The state constant is resolved at this call site, not inside
138 # record(), so it needs its own guard: in an upgrade window the
139 # pipeline file and the status class can both be missing, and
140 # an unguarded constant access would relabel this honest
141 # 'sync_missing' outcome as 'backstop:exception'.
142 if (class_exists('Metasync_Otto_Job_Status')) {
143 self::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'backstop:sync_missing');
144 }
145 return;
146 }
147
148 # Reuse the crawl-notify pipeline verbatim. It owns the per-URL SEO
149 # lock, redirect resolution, the 404/exclusion guards, and the
150 # compare-then-write updates for posts, categories, product
151 # categories, the home page and the posts page. The freshly fetched
152 # suggestions are handed in as the prefetch, so the sync makes no
153 # second API call. allow_defer=false keeps this path from ever
154 # scheduling cron work of its own — WP-Cron is the unreliable thing
155 # this backstop compensates for.
156 $outcome = metasync_process_otto_seo_data($route, false, 0, $suggestions);
157
158 # Success and no-change are the only outcomes that count as a repaired
159 # (or already-correct) URL; everything else is reported as a failure.
160 $terminal = ($outcome === Metasync_Otto_Job_Status::OUTCOME_SUCCESS
161 || $outcome === Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE);
162
163 $elapsed = (int) round((microtime(true) - $started_at) * 1000);
164 self::debug_log('sync_finished', [
165 'route' => $route,
166 'status' => $outcome,
167 'elapsed_ms' => $elapsed,
168 'extra' => ['terminal' => $terminal],
169 ]);
170
171 # PERMANENT means OTTO tracks a URL WordPress cannot map — author
172 # and date archives, excluded and 404'd URLs: an expected answer
173 # that owes no repair, on every miss cycle forever. It is neither
174 # recorded nor logged: a failed-status entry per cycle would bury
175 # real failures in the shared, bounded job-status store, and a log
176 # line per cycle would be unbounded noise.
177 if ($outcome !== Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
178 self::record(
179 $route,
180 $terminal ? Metasync_Otto_Job_Status::STATE_COMPLETED : Metasync_Otto_Job_Status::STATE_FAILED,
181 'backstop:' . $outcome
182 );
183
184 if (!$terminal) {
185 error_log('MetaSync OTTO: postmeta backstop did not sync ' . $route . ' (outcome: ' . $outcome . ')'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
186 }
187 }
188 } catch (Throwable $e) {
189 if (class_exists('Metasync_Otto_Job_Status')) {
190 self::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'backstop:exception');
191 }
192 error_log('MetaSync OTTO: postmeta backstop failed for ' . $route . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
193 } finally {
194 # Always hand the URL back, even on a fatal-path Throwable, so a
195 # crashed sync cannot hold the guard for the full TTL.
196 delete_transient($guard_key);
197 }
198 }
199
200 /**
201 * Flush the response to the client, if this SAPI can.
202 *
203 * fastcgi_finish_request() exists only under PHP-FPM (php_sapi_name()
204 * 'fpm-fcgi'), and probing for the function is more reliable than matching
205 * the SAPI name: what we depend on is the behaviour, not the label.
206 *
207 * KNOWN CAVEAT (documented in the tests as well): on hosts where the
208 * function is absent — mod_php, some LSAPI setups, CLI — the sync still
209 * runs after the response is BUILT, but the client waits for the process
210 * to exit before it sees the last byte, so the sync is not fully free
211 * there. The backstop is still correct and still bounded on those hosts;
212 * it is just not latency-invisible. Switch the feature off per site if
213 * that trade-off is unacceptable.
214 *
215 * @return bool True when the response was flushed before the sync ran.
216 */
217 public static function finish_request() {
218 if (function_exists('fastcgi_finish_request')) {
219 fastcgi_finish_request();
220 return true;
221 }
222
223 return false;
224 }
225
226 /**
227 * Whether the backstop is switched on for this site.
228 *
229 * On by default. It is an internal reliability mechanism, so there is
230 * deliberately no user-facing setting; support can switch it off per site
231 * through the filter below.
232 *
233 * @return bool
234 */
235 public static function is_enabled() {
236 /**
237 * Kill switch for the render-path postmeta backstop. Return false to
238 * stop MISS-triggered syncs.
239 *
240 * @param bool $enabled Whether the backstop is enabled.
241 */
242 return (bool) apply_filters('metasync_otto_backstop_sync_enabled', true);
243 }
244
245 /**
246 * Write optional JSONL diagnostics for QA/support. Enabled only when the
247 * site defines METASYNC_OTTO_BACKSTOP_DEBUG=true.
248 *
249 * The file lives under uploads (metasync/otto-backstop-debug.log), never
250 * inside the plugin folder — the plugin directory is wiped on every
251 * upgrade, and Plugin Check hard-fails plugin-dir writes.
252 *
253 * @param string $message Event name.
254 * @param array $context Diagnostic context.
255 */
256 private static function debug_log($message, $context = []) {
257 if (!defined('METASYNC_OTTO_BACKSTOP_DEBUG') || !METASYNC_OTTO_BACKSTOP_DEBUG) {
258 return;
259 }
260
261 $uploads = wp_upload_dir();
262 if (!empty($uploads['error'])) {
263 return;
264 }
265
266 $entry = array_merge(
267 [
268 'ts' => gmdate('c'),
269 'pid' => getmypid(),
270 'event' => $message,
271 ],
272 $context
273 );
274 @file_put_contents(
275 trailingslashit($uploads['basedir']) . 'metasync/otto-backstop-debug.log',
276 wp_json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n",
277 FILE_APPEND | LOCK_EX
278 );
279 }
280
281 /**
282 * Guard key for a URL. Same md5-of-route shape the SEO job lock uses.
283 *
284 * @param string $route Fully-qualified URL.
285 * @return string
286 */
287 private static function guard_key($route) {
288 return self::GUARD_PREFIX . md5((string) $route);
289 }
290
291 /**
292 * Record a backstop outcome in the bounded per-URL status store, so "did
293 * the meta ever get repaired" stays answerable from the same surface the
294 * crawl-notify pipeline reports through.
295 *
296 * @param string $route Fully-qualified URL.
297 * @param string $state Metasync_Otto_Job_Status state constant.
298 * @param string $reason Machine-readable reason.
299 */
300 private static function record($route, $state, $reason) {
301 if (!class_exists('Metasync_Otto_Job_Status')) {
302 return;
303 }
304
305 Metasync_Otto_Job_Status::record($route, $state, 'backstop', 0, $reason, array('via' => 'backstop'));
306 }
307 }
308