PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / DataAccessTrait_ViewBuildLockAndCron.php

DataAccessTrait_ViewBuildLockAndCron.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ViewBuildLockAndCron.php

514 lines 23.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Cross-request coordination primitives for the staged view-build pipeline.
9 *
10 * Two responsibilities, both called from the staged-build orchestrator in
11 * ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait:
12 *
13 * 1. Build-writer serialization: acquireViewBuildLock / releaseViewBuildLock
14 * with a wp_options-row advisory-lock fallback for managed/sharded MySQL
15 * hosts (PlanetScale, Vitess, split-routing ProxySQL) where session-
16 * scoped GET_LOCK is unsupported. Includes the diagnostic
17 * verifyBuildLockSerializesWriter() probe.
18 *
19 * 2. Background rebuild scheduling: scheduleViewDoneRebuild() plus the
20 * cron-stuck and schedule-failed deduplicated admin notices. Detects a
21 * stuck WordPress cron from wp_get_ready_cron_jobs() age and surfaces a
22 * notice without sending email or flooding wp-admin.
23 *
24 * Sibling to ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait; both are
25 * mixed into ABJ_404_Solution_DataAccess. Calls localizeOrDefaultViewBuildNotice()
26 * from the helpers trait (resolved via $this-> on the composing class).
27 */
28 trait ABJ_404_Solution_DataAccess_ViewBuildLockAndCronTrait {
29
30 /**
31 * Per-request memo of whether session-scoped GET_LOCK is supported on
32 * this host. null = not yet probed; true = a prior probe returned
33 * got=1; false = a prior probe returned NULL or the function is
34 * unrecognized (managed/sharded MySQL: PlanetScale, Vitess, certain
35 * ProxySQL routings). Once unsupported, we skip the GET_LOCK round
36 * trip and go straight to the option-row fallback for the rest of the
37 * request.
38 *
39 * @var bool|null
40 */
41 private static $namedLockSupportedThisRequest = null;
42
43 /**
44 * Fires the "fallback in use" log line at info level once per request
45 * even when many `acquireViewBuildLock` calls take the fallback path.
46 * Diagnostics only; no correctness impact.
47 *
48 * @var bool
49 */
50 private static $fallbackLockLoggedThisRequest = false;
51
52 /**
53 * Tracks whether the most recent successful acquire used the option-row
54 * fallback (true) or the native GET_LOCK (false), so the matching
55 * `releaseViewBuildLock` releases the correct primitive.
56 *
57 * @var bool
58 */
59 private $usingTransientFallbackLock = false;
60
61 /** @var string Last detected reason for falling back; surfaced in the notice. */
62 private $lastNamedLockUnsupportedReason = '';
63 /** @var string Last detected error string from GET_LOCK; surfaced in the notice. */
64 private $lastNamedLockUnsupportedError = '';
65
66 /**
67 * @param int $timeoutSeconds GET_LOCK wait-time. 0 (default) is the
68 * non-blocking acquire used by every steady-state path: if cron or a
69 * sibling tab holds the lock, we yield immediately so the caller can
70 * return locked=true. Use a positive value only for the diagnostic
71 * force-rebuild path, where we want to block until the in-flight
72 * build releases so we can own the next one.
73 *
74 * On managed/sharded MySQL hosts where session-scoped named locks are
75 * unavailable (`GET_LOCK` returns NULL or "function does not exist"),
76 * falls back to a wp_options-row advisory lock acquired with
77 * `add_option` semantics. This serializes concurrent workers on a
78 * single-master WordPress site even when the database layer cannot.
79 * Documented in `ViewBuildLockUnavailabilityTest`.
80 *
81 * @return bool
82 */
83 private function acquireViewBuildLock(int $timeoutSeconds = 0): bool {
84 $name = $this->getLowercasePrefix() . ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_BUILD_LOCK_NAME;
85
86 // Once we've classified the host as "named locks unsupported" in
87 // this request, don't pay the round-trip on every subsequent
88 // acquire. Re-check happens on the next request because the static
89 // is request-scoped.
90 if (self::$namedLockSupportedThisRequest === false) {
91 $this->ensureFallbackLockNoticeAndLog();
92 return $this->acquireTransientFallbackLock($name);
93 }
94
95 $timeout = max(0, $timeoutSeconds);
96 $sql = "SELECT GET_LOCK('" . esc_sql($name) . "', " . $timeout . ") AS got";
97 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
98
99 $err = isset($result['last_error']) && is_string($result['last_error'])
100 ? trim($result['last_error']) : '';
101 if ($err !== '' && $this->isNamedLockUnsupportedError($err)) {
102 self::$namedLockSupportedThisRequest = false;
103 $this->lastNamedLockUnsupportedReason = 'function_unsupported';
104 $this->lastNamedLockUnsupportedError = $err;
105 $this->ensureFallbackLockNoticeAndLog();
106 return $this->acquireTransientFallbackLock($name);
107 }
108
109 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
110 if (!empty($rows) && is_array($rows[0]) && array_key_exists('got', $rows[0])) {
111 $got = $rows[0]['got'];
112 if ($got === null) {
113 // NULL: per the MySQL manual, GET_LOCK returns NULL on an
114 // error. On sharded/managed hosts (PlanetScale, Vitess) it
115 // is also returned as a "no-op" indicator. Either way the
116 // session-scoped lock did not engage; treat as unsupported.
117 self::$namedLockSupportedThisRequest = false;
118 $this->lastNamedLockUnsupportedReason = 'returned_null';
119 $this->lastNamedLockUnsupportedError = '';
120 $this->ensureFallbackLockNoticeAndLog();
121 return $this->acquireTransientFallbackLock($name);
122 }
123 $intGot = is_scalar($got) ? intval($got) : 0;
124 if ($intGot === 1) {
125 if (self::$namedLockSupportedThisRequest === null) {
126 self::$namedLockSupportedThisRequest = true;
127 }
128 $this->usingTransientFallbackLock = false;
129 return true;
130 }
131 // got=0 (or any other integer): another connection holds the
132 // lock. Normal contention; do NOT fall back, the other worker
133 // is already advancing the build.
134 return false;
135 }
136
137 // No rows and no recognized "unsupported" error string: ambiguous.
138 // Treat as lock unavailable rather than guessing fallback is needed.
139 return false;
140 }
141
142 /** @return void */
143 private function releaseViewBuildLock(): void {
144 // @utf8-audit: opt-out - $name is built from $wpdb->prefix + a class
145 // constant; never user input, cannot contain invalid UTF-8 bytes.
146 $name = $this->getLowercasePrefix() . ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_BUILD_LOCK_NAME;
147 if ($this->usingTransientFallbackLock) {
148 $this->usingTransientFallbackLock = false;
149 if (function_exists('delete_option')) {
150 delete_option($this->transientFallbackLockOptionName($name));
151 }
152 return;
153 }
154 $this->queryAndGetResults("SELECT RELEASE_LOCK('" . esc_sql($name) . "')",
155 array('log_errors' => false));
156 }
157
158 /**
159 * Acquire the option-row advisory lock that stands in for GET_LOCK on
160 * hosts where named locks are unavailable. Race-safe: `add_option`
161 * fails when the option already exists, so at most one worker wins
162 * the contended add. Stale locks from a prior crashed worker are
163 * cleared when their stored expiry timestamp has passed.
164 *
165 * @param string $name Already-prefixed lock identifier shared with GET_LOCK.
166 * @return bool
167 */
168 private function acquireTransientFallbackLock(string $name): bool {
169 if (!function_exists('add_option') || !function_exists('get_option')) {
170 return false;
171 }
172 $optionName = $this->transientFallbackLockOptionName($name);
173 $now = time();
174 $ttl = ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_TRANSIENT_LOCK_TTL_SECONDS;
175 $expiresAt = $now + $ttl;
176
177 // Stale-lock recovery: if the existing option's expiry has passed,
178 // the prior holder crashed without releasing. Delete and try again.
179 $existing = get_option($optionName, 0);
180 $existingExpires = is_scalar($existing) ? intval($existing) : 0;
181 if ($existingExpires > 0 && $existingExpires <= $now && function_exists('delete_option')) {
182 delete_option($optionName);
183 }
184
185 // add_option returns false if the option row already exists. This
186 // is the race-safe primitive: even with N parallel PHP workers
187 // racing on the same option, at most one wins. set_transient is
188 // NOT race-safe in this way (it overwrites), so we deliberately
189 // use add_option directly.
190 $added = add_option($optionName, (string)$expiresAt, '', false);
191 if ($added) {
192 $this->usingTransientFallbackLock = true;
193 return true;
194 }
195 return false;
196 }
197
198 /** @param string $name @return string */
199 private function transientFallbackLockOptionName(string $name): string {
200 return $name . '_transient_lock';
201 }
202
203 /**
204 * Match a `last_error` string against the patterns that indicate the
205 * MySQL host does not support session-scoped named locks. Conservative:
206 * we only return true when the error specifically names GET_LOCK as
207 * unrecognized; any other DB error stays in the "lock unavailable,
208 * try later" bucket.
209 *
210 * @param string $err
211 * @return bool
212 */
213 private function isNamedLockUnsupportedError(string $err): bool {
214 $errLow = strtolower($err);
215 if (strpos($errLow, 'get_lock') === false) {
216 return false;
217 }
218 return strpos($errLow, 'does not exist') !== false
219 || strpos($errLow, 'unknown function') !== false
220 || strpos($errLow, 'er_sp_does_not_exist') !== false
221 || strpos($errLow, 'is not allowed') !== false
222 || strpos($errLow, 'not allowed in this context') !== false;
223 }
224
225 /**
226 * Surface a deduplicated admin notice for the fallback path and emit
227 * the info-level "fallback in use" log line once per request.
228 *
229 * The notice transient is refreshed on every fallback acquire (cheap
230 * and idempotent) so admins on hosts where named locks come and go
231 * still see an up-to-date "still on fallback" indicator. The log line
232 * is gated on a per-request static so a steady-state host that
233 * acquires the lock dozens of times per request only produces a
234 * single info entry.
235 *
236 * @return void
237 */
238 private function ensureFallbackLockNoticeAndLog(): void {
239 if (function_exists('set_transient')) {
240 // allow-cache-empty: notice must exist even when the host returns no named-lock error text.
241 set_transient(
242 'abj404_view_build_get_lock_unsupported_notice',
243 array(
244 'reason' => $this->lastNamedLockUnsupportedReason !== ''
245 ? $this->lastNamedLockUnsupportedReason : 'unknown',
246 'error' => substr($this->lastNamedLockUnsupportedError, 0, 500),
247 'when' => time(),
248 'message' => 'This database does not support session-scoped GET_LOCK named locks. '
249 . 'The plugin is using a WordPress option-row fallback to coordinate the staged view-build. '
250 . 'Common on PlanetScale, Vitess, and split-routing ProxySQL deployments.',
251 ),
252 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
253 );
254 }
255 if (!self::$fallbackLockLoggedThisRequest) {
256 self::$fallbackLockLoggedThisRequest = true;
257 $message = '[staged] view-build lock: GET_LOCK unsupported on this host '
258 . '(reason=' . ($this->lastNamedLockUnsupportedReason !== ''
259 ? $this->lastNamedLockUnsupportedReason : 'unknown')
260 . '); using option-row fallback.';
261 if (is_object($this->logger) && method_exists($this->logger, 'infoMessage')) {
262 $this->logger->infoMessage($message);
263 } elseif (is_object($this->logger) && method_exists($this->logger, 'debugMessage')) {
264 $this->logger->debugMessage($message);
265 }
266 }
267 }
268
269 /**
270 * Resets the per-request lock-fallback memos so a fresh request starts
271 * by probing GET_LOCK on the host again. Tests use this to drive the
272 * per-request lifecycle inside a single PHP process.
273 *
274 * @return void
275 */
276 public static function resetViewBuildLockFallbackMemos(): void {
277 self::$namedLockSupportedThisRequest = null;
278 self::$fallbackLockLoggedThisRequest = false;
279 }
280
281 /**
282 * Probe whether the build lock primitive on this host actually
283 * serializes the writer connection. On split-routing deployments
284 * (ProxySQL/Vitess/MaxScale read-write split, PlanetScale branch
285 * replicas), `SELECT GET_LOCK` may be routed to a replica session
286 * that holds a session-scoped lock without preventing two writer
287 * connections from running concurrent DDL. The classic symptom is
288 * two staged-build workers both passing `acquireViewBuildLock` and
289 * both attempting the S11 RENAME swap.
290 *
291 * The probe acquires the build lock, writes a unique nonce to
292 * wp_options, reads it back through the same code path, and verifies
293 * the round trip. A passing probe is consistent with single-master
294 * routing; a failing probe is a strong signal the lock did not
295 * serialize the writer and the caller should switch to the option-row
296 * fallback.
297 *
298 * Public so {@see ABJ_404_Solution_DataAccess} exposes it for the
299 * lock-coverage test suite and any future health-check page.
300 *
301 * @return bool true when the probe round-tripped successfully through
302 * the held lock; false on any inability to acquire / write / read /
303 * verify.
304 */
305 public function verifyBuildLockSerializesWriter(): bool {
306 if (!$this->acquireViewBuildLock(0)) {
307 return false;
308 }
309 try {
310 if (!function_exists('update_option') || !function_exists('get_option')) {
311 return false;
312 }
313 $optionName = $this->getLowercasePrefix() . 'abj404_view_build_lock_writer_probe';
314 try {
315 $nonce = bin2hex(random_bytes(8));
316 } catch (\Throwable $t) {
317 $nonce = (string)mt_rand() . '_' . (string)microtime(true);
318 }
319 update_option($optionName, $nonce, false);
320 $readBack = get_option($optionName, '');
321 if (function_exists('delete_option')) {
322 delete_option($optionName);
323 }
324 return is_string($readBack) && $readBack === $nonce;
325 } finally {
326 $this->releaseViewBuildLock();
327 }
328 }
329
330 /**
331 * Schedule the staged-build cron rebuild hook. Idempotent: the
332 * wp_next_scheduled() check short-circuits when an event is already
333 * queued. Promoted from `private` to `public` in Phase 4 of the staged
334 * view-build watermark refactor: the deleted invalidateViewDone() god
335 * method previously exposed schedule-only semantics through its body;
336 * the post-refactor seam for "schedule a rebuild with no other side
337 * effects" is this method called directly. Production callers reach it
338 * via invalidateViewSnapshotCache() (cron / mutation path) and
339 * forceRestartViewBuild() (runner-owned force-restart); the public
340 * surface lets test code drive the same primitive without resorting to
341 * reflection.
342 *
343 * @return void
344 */
345 public function scheduleViewDoneRebuild(int $delaySeconds = 1): void {
346 if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_single_event')) {
347 return;
348 }
349 $hook = 'abj404_rebuildViewDone';
350 // Detect a stuck WordPress cron by reading WP's own scheduled-event
351 // metadata. When cron is firing normally, wp_reschedule_event()
352 // (wp-cron.php:129) advances each recurring event's next_run_time
353 // to the future before the handler executes, so wp_get_ready_cron_jobs()
354 // returns events whose timestamps are at most a few minutes overdue.
355 // When cron stops, those timestamps stay frozen in the past and the
356 // earliest one grows older with every passing hour. >= 24h overdue
357 // is unambiguously broken; this works whether DISABLE_WP_CRON is set
358 // or not, and produces no false positives for sites with working
359 // external cron (the great majority of DISABLE_WP_CRON installs).
360 $stuckHours = $this->getCronStuckHours();
361 if ($stuckHours >= 24) {
362 $this->setViewBuildCronStuckNotice($stuckHours);
363 } elseif (function_exists('delete_transient')) {
364 // Cron is healthy. Self-heal: clear any stale cron-stuck notice
365 // so a previous false-positive (or a recovered failure) does
366 // not linger up to 24h waiting for the dedup transient to
367 // expire on its own.
368 delete_transient('abj404_view_build_stuck_wp_cron_disabled');
369 }
370 $next = wp_next_scheduled($hook);
371 if ($next !== false) {
372 return;
373 }
374 // Pass wp_error=true so a failed schedule returns a WP_Error we can
375 // route into a notice instead of silently dropping. WP cron schedule
376 // can fail when the cron lock is held, the cron option is unwritable,
377 // or a custom cron implementation rejects the event.
378 $scheduled = wp_schedule_single_event(
379 time() + max(1, intval($delaySeconds)),
380 $hook,
381 array(),
382 true
383 );
384 $isError = (function_exists('is_wp_error') && is_wp_error($scheduled));
385 if ($scheduled === false) {
386 $this->setViewBuildScheduleFailedNotice('');
387 } elseif ($isError) {
388 $errMsg = '';
389 if (is_object($scheduled) && method_exists($scheduled, 'get_error_message')) {
390 $msg = $scheduled->get_error_message();
391 $errMsg = is_string($msg) ? $msg : '';
392 }
393 $this->setViewBuildScheduleFailedNotice($errMsg);
394 }
395 }
396
397 /**
398 * Deduplicated admin notice (24h transient) telling the admin that
399 * WordPress cron has stopped advancing. Triggered by isCronStuck()
400 * detecting that the earliest overdue cron event is at least 24 hours
401 * older than now, which means recurring events are no longer being
402 * rescheduled and cron-dependent plugin features are stalled.
403 *
404 * @param int $hoursStuck how many hours the earliest overdue event has been waiting
405 * @return void
406 */
407 private function setViewBuildCronStuckNotice(int $hoursStuck): void {
408 if (!function_exists('set_transient')) {
409 return;
410 }
411 $key = 'abj404_view_build_stuck_wp_cron_disabled';
412 if (function_exists('get_transient') && get_transient($key) !== false) {
413 return; // dedup window still active
414 }
415 $template = $this->localizeOrDefaultViewBuildNotice(
416 'WordPress cron does not appear to be running. The earliest overdue '
417 . 'cron event has been waiting at least %d hours, so cron-dependent '
418 . 'plugin features (staged view-build, daily cleanup, log updates, '
419 . 'digest emails) are not advancing. To resolve: if DISABLE_WP_CRON '
420 . 'is set in wp-config.php either remove it, or configure a system '
421 . 'cron job that requests wp-cron.php periodically. To force the '
422 . 'redirect view to rebuild right now in your browser (workaround '
423 . 'while cron is broken), open the 404 Solution Redirects page '
424 . 'with ?abj404_force_view_rebuild=1 appended to the URL.'
425 );
426 $payload = array(
427 'type' => 'view_build_stuck_cron_disabled',
428 'message' => sprintf($template, $hoursStuck),
429 'timestamp' => time(),
430 'error_string' => '',
431 );
432 // allow-cache-empty: intentional notice payload; error_string is empty by definition for cron-disabled state.
433 set_transient($key, $payload, 86400);
434 }
435
436 /**
437 * @return int hours since the earliest overdue WordPress cron event,
438 * or 0 when cron is healthy / cannot be inspected.
439 *
440 * Uses WP core bookkeeping rather than a heartbeat option:
441 * `wp_reschedule_event()` (wp-cron.php:129) updates each recurring
442 * event's next_run_time to a future timestamp before its handler
443 * executes. So if cron is running, every recurring event lives in the
444 * future and `wp_get_ready_cron_jobs()` returns at most a few-minute
445 * window of events that have just become due. If cron stops, those
446 * timestamps stay frozen in the past and the earliest one keeps
447 * aging.
448 */
449 private function getCronStuckHours(): int {
450 if (!function_exists('wp_get_ready_cron_jobs')) {
451 return 0;
452 }
453 $ready = wp_get_ready_cron_jobs();
454 if (!is_array($ready) || empty($ready)) {
455 return 0;
456 }
457 $earliest = 0;
458 foreach (array_keys($ready) as $ts) {
459 $tsInt = (int) $ts;
460 if ($tsInt > 0 && ($earliest === 0 || $tsInt < $earliest)) {
461 $earliest = $tsInt;
462 }
463 }
464 if ($earliest <= 0) {
465 return 0;
466 }
467 $delta = time() - $earliest;
468 if ($delta <= 0) {
469 return 0;
470 }
471 return (int) floor($delta / 3600);
472 }
473
474 /**
475 * Deduplicated admin notice (24h transient) when wp_schedule_single_event
476 * itself returns false / WP_Error -- the cron lock is held, the cron
477 * option is unwritable, or a custom cron implementation rejected the
478 * event. Distinct from the DISABLE_WP_CRON case: scheduling itself
479 * failed, so the build will not advance even with external cron.
480 *
481 * @param string $detail
482 * @return void
483 */
484 private function setViewBuildScheduleFailedNotice(string $detail): void {
485 if (!function_exists('set_transient')) {
486 return;
487 }
488 $key = 'abj404_view_build_cron_schedule_failed';
489 if (function_exists('get_transient') && get_transient($key) !== false) {
490 return; // dedup window still active
491 }
492 $message = 'Scheduling the 404 Solution staged view-build cron event failed. '
493 . 'The build will not advance in the background until this clears. '
494 . 'This usually indicates the WordPress cron lock is held, the cron '
495 . 'option is unwritable, or a custom cron implementation rejected '
496 . 'the event. Check your hosting provider and any cron-replacement '
497 . 'plugins. To force the redirect view to rebuild right now in your '
498 . 'browser (workaround while cron scheduling is failing), open the '
499 . '404 Solution Redirects page with ?abj404_force_view_rebuild=1 '
500 . 'appended to the URL.';
501 if ($detail !== '') {
502 $message .= ' (' . $detail . ')';
503 }
504 $payload = array(
505 'type' => 'view_build_schedule_failed',
506 'message' => $this->localizeOrDefaultViewBuildNotice($message),
507 'timestamp' => time(),
508 'error_string' => $detail,
509 );
510 // allow-cache-empty: schedule-failure notice remains useful even when WP returns no detail string.
511 set_transient($key, $payload, 86400);
512 }
513 }
514