| 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 |
|