PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / ViewBuildLockAndCron.php

ViewBuildLockAndCron.php in 404 Solution 4.2.0, at includes/ViewBuildLockAndCron.php

725 lines 35.7 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 * @property ABJ_404_Solution_DatabaseCore $dbCore
29 * @property ABJ_404_Solution_Functions $f
30 * @property ABJ_404_Solution_Logging $logger
31 * @property ABJ_404_Solution_ViewReadService|null $viewReadService
32 * @property ABJ_404_Solution_LogsRepository|null $logsRepo
33 * @property int $stagedQueryTimeoutSeconds
34 * @property string $lastBatchProgressDetail
35 * @property bool $viewBuildStageOpenForShutdown
36 * @property int $viewBuildShutdownStageNumber
37 * @property string $viewBuildShutdownStageKey
38 * @property bool|null $namedLockSupportedThisRequest
39 * @property bool $fallbackLockLoggedThisRequest
40 * @property bool $usingTransientFallbackLock
41 * @property string $lastNamedLockUnsupportedReason
42 * @property string $lastNamedLockUnsupportedError
43 * @method void abortStagedBuildForMutationWatermarkAdvance(...$arguments)
44 * @method bool acquireTransientFallbackLock(...$arguments)
45 * @method bool acquireViewBuildLock(...$arguments)
46 * @method string activeBuildStartedWatermarkOptionName(...$arguments)
47 * @method bool adminMutationGateBlocks(...$arguments)
48 * @method array<mixed> advanceViewBuildOnce(...$arguments)
49 * @method void assertBuildBufferExistsOrHalt(...$arguments)
50 * @method ?bool attemptRelaxSqlModeForBuildConnection(...$arguments)
51 * @method bool bufferIntegrityPassesForPromote(...$arguments)
52 * @method string buildHaltTransientKey(...$arguments)
53 * @method string buildViewDoneCountQuery(...$arguments)
54 * @method string builtWatermarkOptionName(...$arguments)
55 * @method int bumpMutationWatermark(...$arguments)
56 * @method int bumpStageNoProgressStreak(...$arguments)
57 * @method string capturedPrefixForLog(...$arguments)
58 * @method void capturePrefixAtBuildStart(...$arguments)
59 * @method void claimForegroundViewBuildLease(...$arguments)
60 * @method string classifyAndHandleStageFailure(...$arguments)
61 * @method array<mixed> classifySessionVariableWarnings(...$arguments)
62 * @method string classifyStageFailure(...$arguments)
63 * @method void clearActiveBuildStartedWatermark(...$arguments)
64 * @method void clearAdminMutationGateOptions(...$arguments)
65 * @method void clearAllProgressOptions(...$arguments)
66 * @method void clearPhpEnvironmentProbeCache(...$arguments)
67 * @method void clearPrefixAtStageOne(...$arguments)
68 * @method void clearSessionVariablesProbeCache(...$arguments)
69 * @method void clearSqlModeProbeCache(...$arguments)
70 * @method void clearStagedBuildDegradedState(...$arguments)
71 * @method void clearViewBuildOpenStageForShutdown(...$arguments)
72 * @method void clearViewDoneHardStaleNotice(...$arguments)
73 * @method ABJ_404_Solution_Clock clock(...$arguments)
74 * @method int countLiveRedirects(...$arguments)
75 * @method int countViewBuildRows(...$arguments)
76 * @method string describeBuildProgressForNotice(...$arguments)
77 * @method string describeDegradedNotice(...$arguments)
78 * @method string describeStagedSqlFailure(...$arguments)
79 * @method array<mixed> detectAndAdjustSqlMode(...$arguments)
80 * @method float detectHostStagedQueryLimitSeconds(...$arguments)
81 * @method string doTableNameReplacements(...$arguments)
82 * @method void dropDeletemeTable(...$arguments)
83 * @method void dropTransientBuffersIfPresent(...$arguments)
84 * @method void dropTransientStagedTables(...$arguments)
85 * @method void ensureConnection(...$arguments)
86 * @method void ensureFallbackLockNoticeAndLog(...$arguments)
87 * @method int extendedTimeoutForKilledNonBatchedStage(...$arguments)
88 * @method array<mixed> fetchSessionVariablesRowOrEmpty(...$arguments)
89 * @method string filesystemEnvironmentProbeOptionName(...$arguments)
90 * @method bool forceRestartViewBuild(...$arguments)
91 * @method bool foregroundViewBuildLeaseActive(...$arguments)
92 * @method string formatPhpMemoryBytesHuman(...$arguments)
93 * @method bool gateAbortIfMutationWatermarkAdvanced(...$arguments)
94 * @method string getColumnCollationString(...$arguments)
95 * @method int getCronStuckHours(...$arguments)
96 * @method string getLowercasePrefix(...$arguments)
97 * @method array<string, mixed> getViewBuildProgress(...$arguments)
98 * @method array<mixed> getViewBuildProgressFingerprint(...$arguments)
99 * @method int getViewDoneBuiltAtTimestamp(...$arguments)
100 * @method bool haltIfPrefixChangedSinceStageOne(...$arguments)
101 * @method string humanBatchProgress(...$arguments)
102 * @method float intelligentStagedQueryTimeoutSeconds(...$arguments)
103 * @method void invalidateViewDoneServeableCache(...$arguments)
104 * @method bool isBuildHaltedForHostFailure(...$arguments)
105 * @method bool isCurrentStageOptionName(...$arguments)
106 * @method bool isNamedLockUnsupportedError(...$arguments)
107 * @method bool isResumableStagedKill(...$arguments)
108 * @method bool isStageMarkedSkipped(...$arguments)
109 * @method bool isTransientConnectionError(...$arguments)
110 * @method string lastBuildStartedWatermarkOptionName(...$arguments)
111 * @method string legacyStartedWatermarkOptionName(...$arguments)
112 * @method string localizeOrDefaultViewBuildNotice(...$arguments)
113 * @method bool logsHitsTableExists(...$arguments)
114 * @method void logTimedViewBuildStage(...$arguments)
115 * @method void logViewBuildProgressOptionWrite(...$arguments)
116 * @method void logViewBuildShutdownDiagnostics(...$arguments)
117 * @method void markBuildHaltedForHostFailure(...$arguments)
118 * @method void markBuildStage(...$arguments)
119 * @method void markStageSkippedForHostFailure(...$arguments)
120 * @method void markViewBuildStageCompleted(...$arguments)
121 * @method void markViewBuildStageStarted(...$arguments)
122 * @method void markViewDoneBuildCompleted(...$arguments)
123 * @method void markViewDoneInvalidatedByAdminMutation(...$arguments)
124 * @method int maxBuildBufferId(...$arguments)
125 * @method void maybeRaiseViewDoneHardStaleNotice(...$arguments)
126 * @method bool mutationWatermarkAdvancedSinceBuildStart(...$arguments)
127 * @method int mutationWatermarkObservedByAdminAction(...$arguments)
128 * @method int mutationWatermarkObservedByAdminActionAt(...$arguments)
129 * @method string mutationWatermarkObservedByAdminActionAtOptionName(...$arguments)
130 * @method string mutationWatermarkObservedByAdminActionOptionName(...$arguments)
131 * @method string normalizePathPrefix(...$arguments)
132 * @method bool optionReadBackMatches(...$arguments)
133 * @method int parsePhpMemoryLimitToBytes(...$arguments)
134 * @method bool pathFallsWithinAny(...$arguments)
135 * @method void performFreshStartCleanup(...$arguments)
136 * @method array<mixed> phpDisabledFunctionsList(...$arguments)
137 * @method string phpEnvironmentProbeOptionName(...$arguments)
138 * @method float phpTimeRemainingSeconds(...$arguments)
139 * @method string prefixAtStageOneOptionName(...$arguments)
140 * @method array<mixed> probeFilesystemEnvironmentForBuild(...$arguments)
141 * @method float probeFloatFromValues(...$arguments)
142 * @method int probeIntFromValues(...$arguments)
143 * @method int probeMemoryLimitForS9(...$arguments)
144 * @method array<mixed> probePhpEnvironmentForBuild(...$arguments)
145 * @method array<mixed> probeSessionVariablesAtS1Entry(...$arguments)
146 * @method bool probeSetTimeLimitAvailability(...$arguments)
147 * @method array<mixed> probeSqlModeForBuild(...$arguments)
148 * @method string probeStringFromValues(...$arguments)
149 * @method string progressOptionName(...$arguments)
150 * @method void publishBuiltWatermarkFromActiveBuildStartedWatermark(...$arguments)
151 * @method array<mixed> queryAndGetResults(...$arguments)
152 * @method int readActiveBuildStartedWatermark(...$arguments)
153 * @method array<int, array<string, mixed>> readFromViewDone(...$arguments)
154 * @method int readProgressOption(...$arguments)
155 * @method int readWatermarkOption(...$arguments)
156 * @method void rebuildViewDoneInBackground(...$arguments)
157 * @method bool reconcilePostStageElevenState(...$arguments)
158 * @method string reconcileStagedTablesAtRunnerStartup(...$arguments)
159 * @method int recordStageBatchKilled(...$arguments)
160 * @method void registerViewBuildShutdownDiagnostics(...$arguments)
161 * @method bool releaseAndReacquireBetweenStages(...$arguments)
162 * @method void releaseViewBuildLock(...$arguments)
163 * @method void resetStageNoProgressStreak(...$arguments)
164 * @method string resolveColumnCollationForStagedBuild(...$arguments)
165 * @method void runForceRestartCleanupInsideLock(...$arguments)
166 * @method bool runIdRangeBatchedUpdate(...$arguments)
167 * @method int runInsertBatch(...$arguments)
168 * @method mixed runNonBatchedStageWithKillStreakEscape(...$arguments)
169 * @method array{ran: bool, reason: string, progress: array<string, mixed>} runPageLoadFallbackAdvance(...$arguments)
170 * @method int runRedirectsForViewCountStaged(...$arguments)
171 * @method array<int, array<string, mixed>> runRedirectsForViewStaged(...$arguments)
172 * @method bool runS11SwapWithPreRenameWatermarkRecheck(...$arguments)
173 * @method bool runStagedBuildOnce(...$arguments)
174 * @method bool runStagedBuildStages6Through11(...$arguments)
175 * @method void runStagedSqlFile(...$arguments)
176 * @method void runStagedSqlFileTolerantOfDuplicateKey(...$arguments)
177 * @method mixed runTimedViewBuildStage(...$arguments)
178 * @method int safeCurrentMutationWatermark(...$arguments)
179 * @method string sanitizeUrlBeforeInsert(...$arguments)
180 * @method void scheduleViewDoneRebuild(...$arguments)
181 * @method string sessionVariablesProbeOptionName(...$arguments)
182 * @method void setFilesystemEnvAdminNotice(...$arguments)
183 * @method void setLowMemoryLimitAdminNotice(...$arguments)
184 * @method void setSessionEnvAdminNotice(...$arguments)
185 * @method void setStagedBuildDegradedNotice(...$arguments)
186 * @method void setStagedBuildHaltNotice(...$arguments)
187 * @method void setViewBuildCronStuckNotice(...$arguments)
188 * @method void setViewBuildScheduleFailedNotice(...$arguments)
189 * @method void setViewDoneHardStaleNotice(...$arguments)
190 * @method array<mixed> splitOpenBasedirPaths(...$arguments)
191 * @method string sqlModeProbeOptionName(...$arguments)
192 * @method void stageAddPreJoinIndexes(...$arguments)
193 * @method void stageAddSortIndexes(...$arguments)
194 * @method void stageCreateBuildTable(...$arguments)
195 * @method array<string, mixed> stagedQueryOptions(...$arguments)
196 * @method bool stagedTableExists(...$arguments)
197 * @method bool stageInsertRedirectsBatched(...$arguments)
198 * @method string stageNoProgressStreakOptionName(...$arguments)
199 * @method void stageRenameSwap(...$arguments)
200 * @method string stageSkipOptionName(...$arguments)
201 * @method void stageUpdateExternal(...$arguments)
202 * @method void stageUpdateHits(...$arguments)
203 * @method void stageUpdateHome(...$arguments)
204 * @method bool stageUpdatePostsBatched(...$arguments)
205 * @method void stageUpdateSpecial(...$arguments)
206 * @method bool stageUpdateTermsBatched(...$arguments)
207 * @method void stampStartedWatermarksAtS1Entry(...$arguments)
208 * @method void sweepStaleRebuildTransients(...$arguments)
209 * @method string transientFallbackLockOptionName(...$arguments)
210 * @method bool verifyBuildLockSerializesWriter(...$arguments)
211 * @method bool verifyOptionWriteCoherent(...$arguments)
212 * @method bool verifyPrefixUnchangedSinceStageOne(...$arguments)
213 * @method int viewBuildBatchSize(...$arguments)
214 * @method int viewBuildBatchSizeForStage(...$arguments)
215 * @method array<mixed> viewBuildOnlyTranslations(...$arguments)
216 * @method float viewBuildPerStageBudgetSeconds(...$arguments)
217 * @method string viewBuildTableName(...$arguments)
218 * @method string viewDeletemeTableName(...$arguments)
219 * @method int viewDoneBuiltAt(...$arguments)
220 * @method int viewDoneBuiltWatermark(...$arguments)
221 * @method int viewDoneDataBuiltAt(...$arguments)
222 * @method string viewDoneDataBuiltAtOptionName(...$arguments)
223 * @method string viewDoneFreshnessOptionName(...$arguments)
224 * @method bool viewDoneHasRows(...$arguments)
225 * @method bool viewDoneIsFresh(...$arguments)
226 * @method bool viewDoneIsServeable(...$arguments)
227 * @method int viewDoneMutationInvalidatedAt(...$arguments)
228 * @method string viewDoneMutationInvalidatedAtOptionName(...$arguments)
229 * @method bool viewDoneTableExists(...$arguments)
230 * @method string viewDoneTableName(...$arguments)
231 * @method void writeProgressOption(...$arguments)
232 * @method void writeWatermarkOption(...$arguments)
233 */
234 class ABJ_404_Solution_ViewBuildLockAndCron extends ABJ_404_Solution_ViewBuildCollaborator {
235
236 /**
237 * Per-request memo of whether session-scoped GET_LOCK is supported on
238 * this host. null = not yet probed; true = a prior probe returned
239 * got=1; false = a prior probe returned NULL or the function is
240 * unrecognized (managed/sharded MySQL: PlanetScale, Vitess, certain
241 * ProxySQL routings). Once unsupported, we skip the GET_LOCK round
242 * trip and go straight to the option-row fallback for the rest of the
243 * request.
244 *
245 * @var bool|null
246 */
247 private static $namedLockSupportedThisRequest = null;
248
249 /**
250 * Fires the "fallback in use" log line at info level once per request
251 * even when many `acquireViewBuildLock` calls take the fallback path.
252 * Diagnostics only; no correctness impact.
253 *
254 * @var bool
255 */
256 private static $fallbackLockLoggedThisRequest = false;
257
258 /**
259 * Tracks whether the most recent successful acquire used the option-row
260 * fallback (true) or the native GET_LOCK (false), so the matching
261 * `releaseViewBuildLock` releases the correct primitive.
262 *
263 * @var bool
264 */
265 private $usingTransientFallbackLock = false;
266
267 /** @var string Last detected reason for falling back; surfaced in the notice. */
268 private $lastNamedLockUnsupportedReason = '';
269 /** @var string Last detected error string from GET_LOCK; surfaced in the notice. */
270 private $lastNamedLockUnsupportedError = '';
271
272 /**
273 * @param int $timeoutSeconds GET_LOCK wait-time. 0 (default) is the
274 * non-blocking acquire used by every steady-state path: if cron or a
275 * sibling tab holds the lock, we yield immediately so the caller can
276 * return locked=true. Use a positive value only for the diagnostic
277 * force-rebuild path, where we want to block until the in-flight
278 * build releases so we can own the next one.
279 *
280 * On managed/sharded MySQL hosts where session-scoped named locks are
281 * unavailable (`GET_LOCK` returns NULL or "function does not exist"),
282 * falls back to a wp_options-row advisory lock acquired with
283 * `add_option` semantics. This serializes concurrent workers on a
284 * single-master WordPress site even when the database layer cannot.
285 * Documented in `ViewBuildLockUnavailabilityTest`.
286 *
287 * @return bool
288 */
289 public function acquireViewBuildLock(int $timeoutSeconds = 0): bool {
290 $name = $this->getLowercasePrefix() . ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_BUILD_LOCK_NAME;
291
292 // Once we've classified the host as "named locks unsupported" in
293 // this request, don't pay the round-trip on every subsequent
294 // acquire. Re-check happens on the next request because the static
295 // is request-scoped.
296 if (self::$namedLockSupportedThisRequest === false) {
297 $this->ensureFallbackLockNoticeAndLog();
298 return $this->acquireTransientFallbackLock($name);
299 }
300
301 $timeout = max(0, $timeoutSeconds);
302 $sql = "SELECT GET_LOCK('" . esc_sql($name) . "', " . $timeout . ") AS got";
303 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
304
305 $err = isset($result['last_error']) && is_string($result['last_error'])
306 ? trim($result['last_error']) : '';
307 if ($err !== '' && $this->isNamedLockUnsupportedError($err)) {
308 self::$namedLockSupportedThisRequest = false;
309 $this->lastNamedLockUnsupportedReason = 'function_unsupported';
310 $this->lastNamedLockUnsupportedError = $err;
311 $this->ensureFallbackLockNoticeAndLog();
312 return $this->acquireTransientFallbackLock($name);
313 }
314
315 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
316 if (!empty($rows) && is_array($rows[0]) && array_key_exists('got', $rows[0])) {
317 $got = $rows[0]['got'];
318 if ($got === null) {
319 // NULL: per the MySQL manual, GET_LOCK returns NULL on an
320 // error. On sharded/managed hosts (PlanetScale, Vitess) it
321 // is also returned as a "no-op" indicator. Either way the
322 // session-scoped lock did not engage; treat as unsupported.
323 self::$namedLockSupportedThisRequest = false;
324 $this->lastNamedLockUnsupportedReason = 'returned_null';
325 $this->lastNamedLockUnsupportedError = '';
326 $this->ensureFallbackLockNoticeAndLog();
327 return $this->acquireTransientFallbackLock($name);
328 }
329 $intGot = is_scalar($got) ? intval($got) : 0;
330 if ($intGot === 1) {
331 if (self::$namedLockSupportedThisRequest === null) {
332 self::$namedLockSupportedThisRequest = true;
333 }
334 $this->usingTransientFallbackLock = false;
335 return true;
336 }
337 // got=0 (or any other integer): another connection holds the
338 // lock. Normal contention; do NOT fall back, the other worker
339 // is already advancing the build.
340 return false;
341 }
342
343 // No rows and no recognized "unsupported" error string: ambiguous.
344 // Treat as lock unavailable rather than guessing fallback is needed.
345 return false;
346 }
347
348 /** @return void */
349 public function releaseViewBuildLock(): void {
350 // @utf8-audit: opt-out - $name is built from $wpdb->prefix + a class
351 // constant; never user input, cannot contain invalid UTF-8 bytes.
352 $name = $this->getLowercasePrefix() . ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_BUILD_LOCK_NAME;
353 if ($this->usingTransientFallbackLock) {
354 $this->usingTransientFallbackLock = false;
355 if (function_exists('delete_option')) {
356 delete_option($this->transientFallbackLockOptionName($name));
357 }
358 return;
359 }
360 $this->queryAndGetResults("SELECT RELEASE_LOCK('" . esc_sql($name) . "')",
361 array('log_errors' => false));
362 }
363
364 /**
365 * Acquire the option-row advisory lock that stands in for GET_LOCK on
366 * hosts where named locks are unavailable. Race-safe: `add_option`
367 * fails when the option already exists, so at most one worker wins
368 * the contended add. Stale locks from a prior crashed worker are
369 * cleared when their stored expiry timestamp has passed.
370 *
371 * @param string $name Already-prefixed lock identifier shared with GET_LOCK.
372 * @return bool
373 */
374 public function acquireTransientFallbackLock(string $name): bool {
375 if (!function_exists('add_option') || !function_exists('get_option')) {
376 return false;
377 }
378 $optionName = $this->transientFallbackLockOptionName($name);
379 $now = time();
380 $ttl = ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_TRANSIENT_LOCK_TTL_SECONDS;
381 $expiresAt = $now + $ttl;
382
383 // Stale-lock recovery: if the existing option's expiry has passed,
384 // the prior holder crashed without releasing. Delete and try again.
385 $existing = get_option($optionName, 0);
386 $existingExpires = is_scalar($existing) ? intval($existing) : 0;
387 if ($existingExpires > 0 && $existingExpires <= $now && function_exists('delete_option')) {
388 delete_option($optionName);
389 }
390
391 // add_option returns false if the option row already exists. This
392 // is the race-safe primitive: even with N parallel PHP workers
393 // racing on the same option, at most one wins. set_transient is
394 // NOT race-safe in this way (it overwrites), so we deliberately
395 // use add_option directly.
396 $added = add_option($optionName, (string)$expiresAt, '', false);
397 if ($added) {
398 $this->usingTransientFallbackLock = true;
399 return true;
400 }
401 return false;
402 }
403
404 /** @param string $name @return string */
405 public function transientFallbackLockOptionName(string $name): string {
406 return $name . '_transient_lock';
407 }
408
409 /**
410 * Match a `last_error` string against the patterns that indicate the
411 * MySQL host does not support session-scoped named locks. Conservative:
412 * we only return true when the error specifically names GET_LOCK as
413 * unrecognized; any other DB error stays in the "lock unavailable,
414 * try later" bucket.
415 *
416 * @param string $err
417 * @return bool
418 */
419 public function isNamedLockUnsupportedError(string $err): bool {
420 $errLow = strtolower($err);
421 if (strpos($errLow, 'get_lock') === false) {
422 return false;
423 }
424 return strpos($errLow, 'does not exist') !== false
425 || strpos($errLow, 'unknown function') !== false
426 || strpos($errLow, 'er_sp_does_not_exist') !== false
427 || strpos($errLow, 'is not allowed') !== false
428 || strpos($errLow, 'not allowed in this context') !== false;
429 }
430
431 /**
432 * Surface a deduplicated admin notice for the fallback path and emit
433 * the info-level "fallback in use" log line once per request.
434 *
435 * The notice transient is refreshed on every fallback acquire (cheap
436 * and idempotent) so admins on hosts where named locks come and go
437 * still see an up-to-date "still on fallback" indicator. The log line
438 * is gated on a per-request static so a steady-state host that
439 * acquires the lock dozens of times per request only produces a
440 * single info entry.
441 *
442 * @return void
443 */
444 public function ensureFallbackLockNoticeAndLog(): void {
445 if (function_exists('set_transient')) {
446 // allow-cache-empty: notice must exist even when the host returns no named-lock error text.
447 set_transient(
448 'abj404_view_build_get_lock_unsupported_notice',
449 array(
450 'reason' => $this->lastNamedLockUnsupportedReason !== ''
451 ? $this->lastNamedLockUnsupportedReason : 'unknown',
452 'error' => substr($this->lastNamedLockUnsupportedError, 0, 500),
453 'when' => time(),
454 'message' => 'This database does not support session-scoped GET_LOCK named locks. '
455 . 'The plugin is using a WordPress option-row fallback to coordinate the staged view-build. '
456 . 'Common on PlanetScale, Vitess, and split-routing ProxySQL deployments.',
457 ),
458 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
459 );
460 }
461 if (!self::$fallbackLockLoggedThisRequest) {
462 self::$fallbackLockLoggedThisRequest = true;
463 $message = '[staged] view-build lock: GET_LOCK unsupported on this host '
464 . '(reason=' . ($this->lastNamedLockUnsupportedReason !== ''
465 ? $this->lastNamedLockUnsupportedReason : 'unknown')
466 . '); using option-row fallback.';
467 if (is_object($this->logger) && method_exists($this->logger, 'infoMessage')) {
468 $this->logger->infoMessage($message);
469 } elseif (is_object($this->logger) && method_exists($this->logger, 'debugMessage')) {
470 $this->logger->debugMessage($message);
471 }
472 }
473 }
474
475 /**
476 * Resets the per-request lock-fallback memos so a fresh request starts
477 * by probing GET_LOCK on the host again. Tests use this to drive the
478 * per-request lifecycle inside a single PHP process.
479 *
480 * @return void
481 */
482 public static function resetViewBuildLockFallbackMemos(): void {
483 self::$namedLockSupportedThisRequest = null;
484 self::$fallbackLockLoggedThisRequest = false;
485 }
486
487 /**
488 * Probe whether the build lock primitive on this host actually
489 * serializes the writer connection. On split-routing deployments
490 * (ProxySQL/Vitess/MaxScale read-write split, PlanetScale branch
491 * replicas), `SELECT GET_LOCK` may be routed to a replica session
492 * that holds a session-scoped lock without preventing two writer
493 * connections from running concurrent DDL. The classic symptom is
494 * two staged-build workers both passing `acquireViewBuildLock` and
495 * both attempting the S11 RENAME swap.
496 *
497 * The probe acquires the build lock, writes a unique nonce to
498 * wp_options, reads it back through the same code path, and verifies
499 * the round trip. A passing probe is consistent with single-master
500 * routing; a failing probe is a strong signal the lock did not
501 * serialize the writer and the caller should switch to the option-row
502 * fallback.
503 *
504 * Public so {@see ABJ_404_Solution_DataAccess} exposes it for the
505 * lock-coverage test suite and any future health-check page.
506 *
507 * @return bool true when the probe round-tripped successfully through
508 * the held lock; false on any inability to acquire / write / read /
509 * verify.
510 */
511 public function verifyBuildLockSerializesWriter(): bool {
512 if (!$this->acquireViewBuildLock(0)) {
513 return false;
514 }
515 try {
516 if (!function_exists('update_option') || !function_exists('get_option')) {
517 return false;
518 }
519 $optionName = $this->getLowercasePrefix() . 'abj404_view_build_lock_writer_probe';
520 try {
521 $nonce = bin2hex(random_bytes(8));
522 } catch (\Throwable $t) { // allow-silent-catch: random_bytes unavailable on some hosts; mt_rand fallback is sufficient for a disposable write-probe nonce
523 $nonce = (string)mt_rand() . '_' . (string)microtime(true);
524 }
525 update_option($optionName, $nonce, false);
526 $readBack = get_option($optionName, '');
527 if (function_exists('delete_option')) {
528 delete_option($optionName);
529 }
530 return is_string($readBack) && $readBack === $nonce;
531 } finally {
532 $this->releaseViewBuildLock();
533 }
534 }
535
536 /**
537 * Schedule the staged-build cron rebuild hook. Idempotent: the
538 * wp_next_scheduled() check short-circuits when an event is already
539 * queued. Promoted from `private` to `public` in Phase 4 of the staged
540 * view-build watermark refactor: the deleted invalidateViewDone() god
541 * method previously exposed schedule-only semantics through its body;
542 * the post-refactor seam for "schedule a rebuild with no other side
543 * effects" is this method called directly. Production callers reach it
544 * via invalidateViewSnapshotCache() (cron / mutation path) and
545 * forceRestartViewBuild() (runner-owned force-restart); the public
546 * surface lets test code drive the same primitive without resorting to
547 * reflection.
548 *
549 * @return void
550 */
551 public function scheduleViewDoneRebuild(int $delaySeconds = 1): void {
552 if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_single_event')) {
553 return;
554 }
555 if ($this->rebuildHealth instanceof ABJ_404_Solution_RebuildHealthState
556 && !$this->rebuildHealth->mayStartExpensiveRebuild()) {
557 $this->logger->debugMessage(__FUNCTION__ . ' skipped because rebuild health gate is closed.');
558 return;
559 }
560 $hook = 'abj404_rebuildViewDone';
561 // Detect a stuck WordPress cron by reading WP's own scheduled-event
562 // metadata. When cron is firing normally, wp_reschedule_event()
563 // (wp-cron.php:129) advances each recurring event's next_run_time
564 // to the future before the handler executes, so wp_get_ready_cron_jobs()
565 // returns events whose timestamps are at most a few minutes overdue.
566 // When cron stops, those timestamps stay frozen in the past and the
567 // earliest one grows older with every passing hour. >= 24h overdue
568 // is unambiguously broken; this works whether DISABLE_WP_CRON is set
569 // or not, and produces no false positives for sites with working
570 // external cron (the great majority of DISABLE_WP_CRON installs).
571 $stuckHours = $this->getCronStuckHours();
572 if ($stuckHours >= 24) {
573 $this->setViewBuildCronStuckNotice($stuckHours);
574 } elseif (function_exists('delete_transient')) {
575 // Cron is healthy. Self-heal: clear any stale cron-stuck notice
576 // so a previous false-positive (or a recovered failure) does
577 // not linger up to 24h waiting for the dedup transient to
578 // expire on its own.
579 delete_transient('abj404_view_build_stuck_wp_cron_disabled');
580 }
581 $next = wp_next_scheduled($hook);
582 if ($next !== false) {
583 return;
584 }
585 // Pass wp_error=true so a failed schedule returns a WP_Error we can
586 // route into a notice instead of silently dropping. WP cron schedule
587 // can fail when the cron lock is held, the cron option is unwritable,
588 // or a custom cron implementation rejects the event.
589 $scheduled = wp_schedule_single_event(
590 time() + max(1, intval($delaySeconds)),
591 $hook,
592 array(),
593 true
594 );
595 $isError = (function_exists('is_wp_error') && is_wp_error($scheduled));
596 if ($scheduled === false) {
597 $this->setViewBuildScheduleFailedNotice('');
598 } elseif ($isError) {
599 $errMsg = '';
600 if (is_object($scheduled) && method_exists($scheduled, 'get_error_message')) {
601 $msg = $scheduled->get_error_message();
602 $errMsg = is_string($msg) ? $msg : '';
603 }
604 $this->setViewBuildScheduleFailedNotice($errMsg);
605 }
606 }
607
608 /**
609 * Deduplicated admin notice (24h transient) telling the admin that
610 * WordPress cron has stopped advancing. Triggered by isCronStuck()
611 * detecting that the earliest overdue cron event is at least 24 hours
612 * older than now, which means recurring events are no longer being
613 * rescheduled and cron-dependent plugin features are stalled.
614 *
615 * @param int $hoursStuck how many hours the earliest overdue event has been waiting
616 * @return void
617 */
618 public function setViewBuildCronStuckNotice(int $hoursStuck): void {
619 if (!function_exists('set_transient')) {
620 return;
621 }
622 $key = 'abj404_view_build_stuck_wp_cron_disabled';
623 if (function_exists('get_transient') && get_transient($key) !== false) {
624 return; // dedup window still active
625 }
626 $template = $this->localizeOrDefaultViewBuildNotice(
627 'WordPress cron does not appear to be running. The earliest overdue '
628 . 'cron event has been waiting at least %d hours, so cron-dependent '
629 . 'plugin features (staged view-build, daily cleanup, log updates, '
630 . 'digest emails) are not advancing. To resolve: if DISABLE_WP_CRON '
631 . 'is set in wp-config.php either remove it, or configure a system '
632 . 'cron job that requests wp-cron.php periodically. To force the '
633 . 'redirect view to rebuild right now in your browser (workaround '
634 . 'while cron is broken), open the 404 Solution Redirects page '
635 . 'with ?abj404_force_view_rebuild=1 appended to the URL.'
636 );
637 $payload = array(
638 'type' => 'view_build_stuck_cron_disabled',
639 'message' => sprintf($template, $hoursStuck),
640 'timestamp' => time(),
641 'error_string' => '',
642 );
643 // allow-cache-empty: intentional notice payload; error_string is empty by definition for cron-disabled state.
644 set_transient($key, $payload, 86400);
645 }
646
647 /**
648 * @return int hours since the earliest overdue WordPress cron event,
649 * or 0 when cron is healthy / cannot be inspected.
650 *
651 * Uses WP core bookkeeping rather than a heartbeat option:
652 * `wp_reschedule_event()` (wp-cron.php:129) updates each recurring
653 * event's next_run_time to a future timestamp before its handler
654 * executes. So if cron is running, every recurring event lives in the
655 * future and `wp_get_ready_cron_jobs()` returns at most a few-minute
656 * window of events that have just become due. If cron stops, those
657 * timestamps stay frozen in the past and the earliest one keeps
658 * aging.
659 */
660 public function getCronStuckHours(): int {
661 if (!function_exists('wp_get_ready_cron_jobs')) {
662 return 0;
663 }
664 $ready = wp_get_ready_cron_jobs();
665 if (!is_array($ready) || empty($ready)) {
666 return 0;
667 }
668 $earliest = 0;
669 foreach (array_keys($ready) as $ts) {
670 $tsInt = (int) $ts;
671 if ($tsInt > 0 && ($earliest === 0 || $tsInt < $earliest)) {
672 $earliest = $tsInt;
673 }
674 }
675 if ($earliest <= 0) {
676 return 0;
677 }
678 $delta = time() - $earliest;
679 if ($delta <= 0) {
680 return 0;
681 }
682 return (int) floor($delta / 3600);
683 }
684
685 /**
686 * Deduplicated admin notice (24h transient) when wp_schedule_single_event
687 * itself returns false / WP_Error -- the cron lock is held, the cron
688 * option is unwritable, or a custom cron implementation rejected the
689 * event. Distinct from the DISABLE_WP_CRON case: scheduling itself
690 * failed, so the build will not advance even with external cron.
691 *
692 * @param string $detail
693 * @return void
694 */
695 public function setViewBuildScheduleFailedNotice(string $detail): void {
696 if (!function_exists('set_transient')) {
697 return;
698 }
699 $key = 'abj404_view_build_cron_schedule_failed';
700 if (function_exists('get_transient') && get_transient($key) !== false) {
701 return; // dedup window still active
702 }
703 $message = 'Scheduling the 404 Solution staged view-build cron event failed. '
704 . 'The build will not advance in the background until this clears. '
705 . 'This usually indicates the WordPress cron lock is held, the cron '
706 . 'option is unwritable, or a custom cron implementation rejected '
707 . 'the event. Check your hosting provider and any cron-replacement '
708 . 'plugins. To force the redirect view to rebuild right now in your '
709 . 'browser (workaround while cron scheduling is failing), open the '
710 . '404 Solution Redirects page with ?abj404_force_view_rebuild=1 '
711 . 'appended to the URL.';
712 if ($detail !== '') {
713 $message .= ' (' . $detail . ')';
714 }
715 $payload = array(
716 'type' => 'view_build_schedule_failed',
717 'message' => $this->localizeOrDefaultViewBuildNotice($message),
718 'timestamp' => time(),
719 'error_string' => $detail,
720 );
721 // allow-cache-empty: schedule-failure notice remains useful even when WP returns no detail string.
722 set_transient($key, $payload, 86400);
723 }
724 }
725