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_ViewQueriesStaged.php

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

1,483 lines 73.4 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 * Staged getRedirectsForView pipeline.
9 *
10 * Replaces the legacy single-shot SQL that JOINed wp_posts/wp_terms/wp_options
11 * onto every active redirect and forced an ORDER BY published_status filesort
12 * across the full result before LIMIT applied. That shape times out at 45s+
13 * on cold-cache shared hosts (Bruno/Showmetech, multiple 4.1.13 reports).
14 *
15 * The pipeline writes a precomputed view of every redirect into a shared
16 * persistent table (`{wp_abj404_view_done}`). Reads serve directly from
17 * that table with WHERE/ORDER/LIMIT applied. Per-tab status filtering and
18 * filterText LIKE both apply at read time, so one shared `_done` serves
19 * every admin's tab.
20 *
21 * Concurrency model: one builder at a time per site, gated by a session
22 * lock (`GET_LOCK`). Atomic `RENAME TABLE` swap publishes a freshly built
23 * buffer to readers. Stale-while-revalidate on every request: if the
24 * served snapshot is older than VIEW_DONE_FRESHNESS_TTL_SECONDS, kick off
25 * a rebuild for the next request and serve the stale data now.
26 */
27 trait ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait {
28
29 // Tunable constants live on ABJ_404_Solution_ViewBuildConfig (see
30 // includes/ViewBuildConfig.php) instead of as `const` declarations on
31 // this trait, because PHP traits cannot have constants until 8.2 and
32 // the plugin declares Requires PHP: 7.4. References below are written
33 // as the FQN class constant rather than `self::` so they resolve the
34 // same way regardless of which class consumes the trait.
35
36 /** @var bool Process-local guard so a single request never rebuilds twice. */
37 private static $viewBuildAlreadyRanThisRequest = false;
38
39 // Stage-runner / shutdown-diagnostics state ($viewBuildShutdownLoggerRegistered,
40 // $viewBuildStageOpenForShutdown, $viewBuildShutdownStageNumber,
41 // $viewBuildShutdownStageKey, $lastBatchProgressDetail) is declared on the
42 // sibling ABJ_404_Solution_DataAccess_ViewBuildStageRunnerTrait so the
43 // per-stage timing, shutdown logger and inflight marker share one
44 // composing-class field set with the runner methods that read/write them.
45
46 // $stagedQueryTimeoutSeconds is declared on the sibling
47 // ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait so the same field
48 // backs both stagedQueryOptions() (helpers trait) and the per-stage
49 // writes performed by the orchestrator below.
50
51 /**
52 * Request-lifetime cache of viewDoneIsServeable(). The AJAX gate, the
53 * progress reader, and the pending-build response share the same answer
54 * within a single request; without this cache each call reissues a SHOW
55 * TABLES probe through the centralized DAO, which on a slow host pays the
56 * full diagnostic latency on every probe and pushes the gate response
57 * over criterion 6's <2s budget.
58 *
59 * Reset to null on every fetch entry / write that mutates view_done so a
60 * fresh request never sees a stale answer.
61 *
62 * @var bool|null
63 */
64 private $viewDoneIsServeableCache = null;
65
66 // $viewBuildProgressOptionNames (the option-name registry) is declared
67 // on the sibling ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait so
68 // the progress get/set/clear helpers and this orchestrator share one
69 // registry. self::$viewBuildProgressOptionNames resolves to the same
70 // composing-class property regardless of which trait references it.
71
72 /** @return void */
73 public static function resetViewBuildOncePerRequestGuard(): void {
74 self::$viewBuildAlreadyRanThisRequest = false;
75 self::$viewBuildShutdownLoggerRegistered = false;
76 }
77
78 /** @return string */
79 private function viewBuildTableName(): string {
80 return $this->doTableNameReplacements('{wp_abj404_view_build}');
81 }
82
83 /** @return string */
84 private function viewDoneTableName(): string {
85 return $this->doTableNameReplacements('{wp_abj404_view_done}');
86 }
87
88 /** @return string */
89 private function viewDeletemeTableName(): string {
90 return $this->doTableNameReplacements('{wp_abj404_view_deleteme}');
91 }
92
93 /** @return string */
94 private function viewDoneFreshnessOptionName(): string {
95 return $this->getLowercasePrefix() . 'abj404_view_done_built_at';
96 }
97
98 // Foreground admin/AJAX flows hold this lease briefly so staged-build
99 // diagnostics reach the browser instead of being hidden inside cron. Cron
100 // checks foregroundViewBuildLeaseActive() and reschedules itself instead
101 // of taking the build lock while the lease is held.
102 /** @return void */
103 public function claimForegroundViewBuildLease(): void {
104 if (!function_exists('update_option')) { return; }
105 update_option($this->getLowercasePrefix() . 'abj404_view_build_foreground_until',
106 time() + ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_FOREGROUND_LEASE_SECONDS, false);
107 }
108 /** @return bool */
109 private function foregroundViewBuildLeaseActive(): bool {
110 if (!function_exists('get_option')) { return false; }
111 $until = get_option($this->getLowercasePrefix() . 'abj404_view_build_foreground_until', 0);
112 return is_scalar($until) && intval($until) > time();
113 }
114
115 /**
116 * Public entry: returns the page of rows the admin Redirects/Captured
117 * tab should render. Read-only with respect to view_done; never runs
118 * the staged build inline. If view_done is missing or invalidated, a
119 * background rebuild is scheduled and ABJ_404_Solution_ViewBuildPendingException
120 * is thrown so the caller can translate it into a pending response.
121 *
122 * The fetch AJAX handler (ViewUpdater::getPaginationLinks) gates on
123 * viewDoneIsServeable() before calling this method, so under normal
124 * traffic this never throws. Non-AJAX callers (REST API, snapshot
125 * warmup pipeline, tests) can hit the pending path; they handle it
126 * by retrying once cron / the JS poller advances the build.
127 *
128 * @param string $sub
129 * @param array<string, mixed> $tableOptions
130 * @return array<int, array<string, mixed>>
131 * @throws ABJ_404_Solution_ViewBuildPendingException
132 */
133 public function runRedirectsForViewStaged(string $sub, array $tableOptions): array {
134 // Honor _abj404_query_timeout from the warmup pipeline so staged
135 // queries inherit the same per-stage budget legacy code did. Reset
136 // on entry so a previous request's value cannot leak across calls.
137 $this->stagedQueryTimeoutSeconds = isset($tableOptions['_abj404_query_timeout'])
138 && is_numeric($tableOptions['_abj404_query_timeout'])
139 ? max(0, intval($tableOptions['_abj404_query_timeout'])) : 0;
140 if (!empty($tableOptions['_abj404_force_view_rebuild'])) {
141 // Diagnostic ?_abj404_force_view_rebuild=1 path: discard the
142 // runner's in-flight state and start fresh. Non-blocking
143 // acquire: if a sibling cron / AJAX advance holds the lock,
144 // we skip the cleanup and fall through to serve-stale; the
145 // already-running build will publish on its own. This is the
146 // Phase 3a / Phase 4 successor to the direct invalidateViewDone()
147 // pre-call -- the runner-owned primitive preserves the published
148 // view_done snapshot for parallel readers until the new S11
149 // RENAME swap, which is the intended force-rebuild contract.
150 $this->forceRestartViewBuild(0);
151 }
152 $builtAt = $this->viewDoneBuiltAt();
153 $isFresh = $builtAt > 0
154 && (time() - $builtAt) < ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_FRESHNESS_TTL_SECONDS
155 && $this->viewDoneIsServeable();
156
157 if ($isFresh) {
158 return $this->readFromViewDone($sub, $tableOptions);
159 }
160
161 // Stale or invalidated: if view_done is serveable (table exists with
162 // rows on disk) we return the snapshot now and kick off a background
163 // rebuild for the next request. Invalidated counts as "stale-but-
164 // present"; the freshness signal was cleared (so the rebuild gets
165 // scheduled) but the data on disk is the most recent successful
166 // snapshot and is correct to serve. Hard-stale notice fires when
167 // the data on disk exceeds VIEW_DONE_HARD_STALE_NOTICE_AGE_SECONDS.
168 if ($this->viewDoneIsServeable()) {
169 $this->scheduleViewDoneRebuild();
170 $this->maybeRaiseViewDoneHardStaleNotice();
171 return $this->readFromViewDone($sub, $tableOptions);
172 }
173
174 // view_done is missing on disk or empty (no usable data). Schedule a
175 // background rebuild (cron + ajaxAdvanceViewBuild advance the build)
176 // and signal pending back up. Inline build inside a fetch request is
177 // intentionally removed: on slow hosts it fatals at max_execution_time
178 // and the HTTP 500 / "critical error" payload defeats client-side
179 // recovery.
180 $this->scheduleViewDoneRebuild();
181 $progress = $this->describeBuildProgressForNotice();
182 throw new ABJ_404_Solution_ViewBuildPendingException(
183 'Staged view build pending; background rebuild scheduled. Progress: ' . $progress,
184 $progress
185 );
186 }
187
188 /** @return int Unix timestamp of last successful build, or 0 if missing. */
189 private function viewDoneBuiltAt(): int {
190 if (!function_exists('get_option')) {
191 return 0;
192 }
193 $built = get_option($this->viewDoneFreshnessOptionName(), 0);
194 return is_scalar($built) ? max(0, intval($built)) : 0;
195 }
196
197 /**
198 * Public read-only check used by the AJAX fetch endpoints to gate "serve
199 * from cache vs. return pending". True when view_done exists on disk and
200 * contains rows. Stale-but-present is serveable: invalidate clears the
201 * freshness signal but leaves the table contents intact, so the steady-
202 * state warm path serves stale and schedules a background rebuild
203 * without blocking.
204 *
205 * Note: serveability does NOT depend on the freshness/built_at signal.
206 * That signal gates whether to schedule a rebuild (stale = schedule), not
207 * whether the existing data can be returned. Serving stale-but-present
208 * data is correct: the data is at most one freshness window out of date
209 * relative to when it was last produced, and the maybeRaiseViewDoneHard
210 * StaleNotice() check fires an admin notice when staleness exceeds the
211 * upper bound (VIEW_DONE_HARD_STALE_NOTICE_AGE_SECONDS).
212 *
213 * The ViewUpdater AJAX path uses this to avoid triggering the inline
214 * build inside a request: if not serveable, the fetch returns
215 * `viewBuildPending: true` and the JS poller hits ajaxAdvanceViewBuild.
216 *
217 * @return bool
218 */
219 public function viewDoneIsServeable(): bool {
220 if ($this->viewDoneIsServeableCache !== null) {
221 return $this->viewDoneIsServeableCache;
222 }
223 if (!$this->viewDoneTableExists()) {
224 $this->viewDoneIsServeableCache = false;
225 return false;
226 }
227 // Admin-mutation gate (Phase 4 watermark mechanism, owned by
228 // ABJ_404_Solution_DataAccess_AdminMutationGateTrait). Blocks
229 // reads while built_watermark < the watermark the admin observed
230 // at click-Save time, OR until the sanity window elapses.
231 if ($this->adminMutationGateBlocks()) {
232 $this->viewDoneIsServeableCache = false;
233 return false;
234 }
235 // Empty view_done is NOT serveable when there has never been a
236 // successful build: rendering an empty admin screen during a cold
237 // start is a worse UX than a brief pending/loading state that drives
238 // the build forward. The has-rows probe also catches the rare "S11
239 // promoted an empty buffer" failure mode where the swap completed
240 // but S2 produced no rows (botched build state); without this guard
241 // the admin would render blank indefinitely with no rebuild ever
242 // scheduled.
243 //
244 // BUT: when a build has actually completed (data_built_at > 0) and
245 // the table is genuinely empty (e.g. a fresh install with no
246 // redirects yet, or the admin dropped wp_abj404_redirects via WP-CLI
247 // and the recreated table is empty), an empty view_done IS the
248 // correct serveable result. Returning false here would loop the JS
249 // poller forever on a cold install: every build cycle produces an
250 // empty view_done, viewDoneIsServeable() returns false, ViewUpdater
251 // returns viewBuildPending, the poller fires another advance, and
252 // the cycle repeats with no exit. data_built_at distinguishes
253 // "build has never completed" from "build completed and the dataset
254 // is genuinely empty".
255 if ($this->viewDoneHasRows()) {
256 $this->viewDoneIsServeableCache = true;
257 return true;
258 }
259 if ($this->viewDoneDataBuiltAt() > 0) {
260 $this->viewDoneIsServeableCache = true;
261 return true;
262 }
263 $this->viewDoneIsServeableCache = false;
264 return false;
265 }
266
267 /**
268 * Public accessor for the unix-time the view_done snapshot was last
269 * successfully built. Returns 0 when never built or when the freshness
270 * option has been cleared by an invalidation. Used by the admin footer
271 * (and any diagnostic surface) to render a "Cache view freshness: 5m"
272 * indicator without exposing the internal option name.
273 *
274 * @return int Unix timestamp, or 0.
275 */
276 public function getViewDoneBuiltAtTimestamp(): int {
277 return $this->viewDoneBuiltAt();
278 }
279
280 /**
281 * Invalidate the request-lifetime serveability cache. Called from any
282 * code path that mutates view_done (rename/drop/build completion) so a
283 * subsequent read in the same request sees fresh state.
284 *
285 * @return void
286 */
287 private function invalidateViewDoneServeableCache(): void {
288 $this->viewDoneIsServeableCache = null;
289 }
290
291 /**
292 * Public hook called from the S11 swap completion path and from the
293 * reconcile-promote path when a fresh view_done has just been published.
294 * Updates both freshness and data-built-at signals to now, clears the
295 * hard-stale admin notice (self-heal), and resets the request-lifetime
296 * serveability cache so subsequent reads in the same request see the
297 * just-published table.
298 *
299 * The data-built-at signal is the floor used by maybeRaiseViewDoneHard
300 * StaleNotice() to decide when to surface the "data may be out of date"
301 * admin notice. Updating it here means the notice can self-clear
302 * automatically once the build catches up, so an admin who fixed the
303 * underlying cron or host issue does not see a 24h-stale warning for
304 * the entire dedup TTL after recovery.
305 *
306 * @return void
307 */
308 public function markViewDoneBuildCompleted(): void {
309 if (function_exists('update_option')) {
310 $now = $this->clock()->now();
311 update_option($this->viewDoneFreshnessOptionName(), $now, false);
312 update_option($this->viewDoneDataBuiltAtOptionName(), $now, false);
313 }
314 $this->clearViewDoneHardStaleNotice();
315 // Clear the admin-mutation gate: the fresh build covers any
316 // mutation that triggered it, so viewDoneIsServeable() no longer
317 // needs to block reads. Leaving it set would force "Loading
318 // redirects" for the full 5-minute sanity window after every
319 // admin save even though fresh data is on disk.
320 $this->clearAdminMutationGateOptions();
321 $this->invalidateViewDoneServeableCache();
322 }
323
324 /**
325 * Public progress snapshot used by the AJAX fetch endpoints when they
326 * return a pending response, and by the build-advance endpoint after each
327 * tick. Always safe to call; never queries beyond cheap option reads
328 * plus a SHOW TABLES probe.
329 *
330 * Shape:
331 * - status: 'ready' (view_done is serveable) or 'pending'
332 * - stage: current sub-stage (0..11) reached so far
333 * - of: 11 (total number of build sub-stages)
334 * - build_started: unix ts when this resumable build began (0 if none)
335 * - progress_text: short human-readable summary (e.g. "stage 2/11")
336 * - fingerprint: per-tick mutation counters used by the JS poller
337 * to detect within-stage progress (S2/S4/S5 advance
338 * high-water ids across multiple ticks while
339 * current_stage stays the same). The poller gives
340 * up only when this fingerprint stops changing.
341 *
342 * @return array<string, mixed>
343 */
344 public function getViewBuildProgress(): array {
345 $stage = $this->readProgressOption('current_stage', 0);
346 $startedAt = $this->readProgressOption('started_at', 0);
347 $status = $this->viewDoneIsServeable() ? 'ready' : 'pending';
348 return array(
349 'status' => $status,
350 'stage' => max(0, $stage),
351 'of' => 11,
352 'build_started' => max(0, $startedAt),
353 // Cheap text derived from option reads only. The row-count rich
354 // version (describeBuildProgressForNotice) issues two extra DAO
355 // queries (countViewBuildRows + countLiveRedirects) which on a
356 // slow host can multiply the gate's response time several-fold.
357 // Callers that want the rich text can call describeBuildProgressForNotice
358 // directly; AJAX gate / poll responses use the cheap form.
359 'progress_text' => $stage > 0 ? ('stage ' . $stage . '/11') : 'not yet started',
360 'fingerprint' => $this->getViewBuildProgressFingerprint(),
361 );
362 }
363
364 /**
365 * Public bounded build-advance entry point used by ajaxAdvanceViewBuild.
366 * Runs at most one resumable tick of the staged build (10s/stage budget;
367 * yields mid-stage on S2/S4/S5). Idempotent: safe to call concurrently.
368 * Competing callers fail to acquire the build lock and just return the
369 * current progress. Returns the same shape as getViewBuildProgress()
370 * with an additional `locked` bool that is true when this call did not
371 * acquire the lock (another worker is already advancing the build).
372 *
373 * Errors during a tick propagate as exceptions; the caller (AJAX handler)
374 * surfaces them. This is intentionally NOT silent: a failing build that
375 * never advances would otherwise leave the JS poller spinning forever.
376 *
377 * @param bool $forceRebuild Diagnostic mode (?abj404_force_view_rebuild=1):
378 * - skip the viewDoneIsServeable() short-circuit so we always run the
379 * staged build under the caller's request context, making every
380 * staged_build_s* sub-stage event visible in the AJAX debug log;
381 * - wait up to 30s for the build lock so a sibling cron / tab build
382 * finishes and we can take ownership of the next build cleanly;
383 * - re-invalidate inside the locked region so we run a fresh build
384 * rather than the data the prior lock holder just produced;
385 * - reset the per-request once-guard so a force-rebuild always proceeds
386 * even if a sibling code path already ran a build in this request.
387 *
388 * @return array<string, mixed>
389 */
390 public function advanceViewBuildOnce(bool $forceRebuild = false): array {
391 if ($forceRebuild) {
392 // Allow the build to run even if a sibling read path on the same
393 // request already entered the once-guard; the diagnostic flow
394 // explicitly wants to rerun.
395 self::$viewBuildAlreadyRanThisRequest = false;
396 }
397 if (!$forceRebuild && $this->viewDoneIsServeable()) {
398 return $this->getViewBuildProgress();
399 }
400 // 10s wait when forced so a cron build mid-flight can release the
401 // lock before we take it. Without this, force-rebuild would return
402 // locked=true, the JS poller would back off, the cron build would
403 // finish in the background under no AJAX context, and the next
404 // poll would see view_done as fresh -- no stage diagnostics ever
405 // reach the debug log. 10s leaves comfortable headroom inside a
406 // 30s PHP request: the typical cron build completes in seconds,
407 // and if it doesn't we still return locked=true and the JS poller
408 // can retry on the next page load.
409 $lockTimeoutSeconds = $forceRebuild ? 10 : 0;
410 if (!$this->acquireViewBuildLock($lockTimeoutSeconds)) {
411 // Force-rebuild lock losses are interesting: a 10s wait that
412 // still failed means another build held the lock longer than
413 // expected (cron stuck, sibling tab mid-S2/S4/S5, dead session
414 // holding GET_LOCK). Always-locked is one of the symptoms
415 // Bruno/Troy report when their build never finishes, so log
416 // every miss with the path so we can tell which caller blocked.
417 $this->logger->debugMessage(sprintf(
418 '[staged] advanceViewBuildOnce: lock not acquired '
419 . '(forceRebuild=%s, waited up to %ds)',
420 $forceRebuild ? 'true' : 'false', $lockTimeoutSeconds
421 ));
422 $progress = $this->getViewBuildProgress();
423 $progress['locked'] = true;
424 return $progress;
425 }
426 try {
427 if ($forceRebuild) {
428 // Whatever the prior lock holder produced (cron, sibling tab,
429 // a finished S11 swap) we discard inside the locked region
430 // so the rebuild happens fresh under the caller's request
431 // context. The inside-lock cleanup helper drops the buffer,
432 // clears progress + S1 prefix capture, and clears the
433 // active-build started-watermark stamp; it also flips the
434 // per-request serveability cache so getViewBuildProgress()
435 // at the end reflects the rebuilt state. Direct call to
436 // the runner-owned inside-lock helper avoids reacquiring
437 // the lock (we already hold it).
438 $this->runForceRestartCleanupInsideLock();
439 // Force-rebuild also clears any per-stage permanent skip
440 // markers and the build-halted gate from a prior host
441 // failure. The admin pressed "rebuild" explicitly, so
442 // re-attempting denied DDL is the intended action.
443 $this->clearStagedBuildDegradedState();
444 } else {
445 // Same runner-startup reconciliation as the cron entry: an
446 // AJAX advance picking up after a previous crash must
447 // preserve the buffer S2-S10 already built rather than
448 // throwing it away to start over. Force-rebuild skips
449 // this because the admin explicitly asked to rebuild
450 // from scratch.
451 $reconcileResult = $this->reconcileStagedTablesAtRunnerStartup();
452 if ($reconcileResult === 'promoted') {
453 return $this->getViewBuildProgress();
454 }
455 }
456 $isComplete = $this->runStagedBuildOnce();
457 } finally {
458 $this->releaseViewBuildLock();
459 }
460 if ($isComplete) {
461 return $this->getViewBuildProgress();
462 }
463 // Yielded mid-stage; schedule a background tick so cron pushes forward
464 // even if the JS poller stops. Cron respects the foreground lease.
465 $this->scheduleViewDoneRebuild();
466 return $this->getViewBuildProgress();
467 }
468
469 /**
470 * Synchronous fallback that advances the staged view-build by one
471 * tick on plugin admin page-load when WP-Cron is broken. Pairs with
472 * the cron-stuck admin notice (c374): the notice tells the admin
473 * cron is broken; this fallback unblocks the page in the meantime
474 * so they do not stare at "Carregando redirecionamentos..." (the
475 * Portuguese localization Bruno reported) forever while they fix
476 * cron.
477 *
478 * Without this, hosts where DISABLE_WP_CRON is set in wp-config.php
479 * AND no external system cron replaces it leave the staged build
480 * stuck: the AJAX JS poller would advance it, but the poller only
481 * fires after the page renders, and the fetch path hard-gates on
482 * view_done being serveable. The admin sees the loading message
483 * indefinitely on every page-load.
484 *
485 * Gates (cheap, in order):
486 * 1. getCronStuckHours() < 24: cron is healthy enough; no fallback
487 * needed. The 24h floor matches the cron-stuck notice (c374)
488 * so the two signals fire together, not piecewise.
489 * 2. viewDoneIsServeable() === true: the build is already done,
490 * so there is nothing to advance. Free option-read check.
491 * 3. abj404_page_load_fallback_advance transient set: a sibling
492 * sub-request already ran the fallback inside the 60s window;
493 * avoid burning a second stage of inline work in the same
494 * admin burst.
495 *
496 * Bounding:
497 * - A short-lived filter is registered on the per-stage budget
498 * hook (abj404_view_build_per_stage_budget_seconds) so the
499 * advance call inside the lock cannot spend the full 10s
500 * default per-stage budget. The filter is removed in finally
501 * so the next AJAX advance / cron tick sees the normal budget.
502 *
503 * Lock semantics:
504 * - Delegates to advanceViewBuildOnce(false), which acquires the
505 * build lock with a 0s timeout. A sibling cron / AJAX advance
506 * already in flight returns immediately with locked=true and
507 * this method reports reason='locked' without doing further
508 * work. The build progress under the existing lock holder is
509 * still being made; the admin's next page-load (after the gate
510 * window) will try again.
511 *
512 * Caller contract (admin_init wrapper in 404-solution.php):
513 * - Only call when is_admin() is true.
514 * - Only call when the current user has the plugin-admin
515 * capability so unauthenticated requests cannot trigger build
516 * work.
517 * - Wrap in try/catch; a failure here must not break admin page
518 * rendering. Log at warning level so it does not generate dev
519 * email reports per the self-healing philosophy.
520 *
521 * @return array{ran:bool, reason:string, progress:array<string,mixed>}
522 */
523 public function runPageLoadFallbackAdvance(): array {
524 // Cron is healthy: nothing for the fallback to do. Free check
525 // (one wp_get_ready_cron_jobs call) so we can run it first.
526 if ($this->getCronStuckHours() < 24) {
527 return array(
528 'ran' => false,
529 'reason' => 'cron_healthy',
530 'progress' => $this->getViewBuildProgress(),
531 );
532 }
533
534 // Build already serveable: returning before any further work
535 // keeps the fallback's steady-state cost at zero on hosts that
536 // recover, which is the desirable shape (admin returns to a
537 // working page without page-load latency).
538 if ($this->viewDoneIsServeable()) {
539 return array(
540 'ran' => false,
541 'reason' => 'not_needed',
542 'progress' => $this->getViewBuildProgress(),
543 );
544 }
545
546 // Transient gate: a single admin click can produce many
547 // sub-requests (prefetch, refresh, multiple browser tabs).
548 // Cap inline advances to one per 60s so the page-load impact
549 // cannot compound. 60s is short enough that an attentive admin
550 // sees real per-load progress, and long enough that bursts of
551 // navigation do not stack inline build work.
552 $haveTransientApi = function_exists('get_transient') && function_exists('set_transient');
553 $gateKey = ABJ_404_Solution_ViewBuildConfig::PAGE_LOAD_FALLBACK_GATE_KEY;
554 if ($haveTransientApi && get_transient($gateKey) !== false) {
555 return array(
556 'ran' => false,
557 'reason' => 'gate_active',
558 'progress' => $this->getViewBuildProgress(),
559 );
560 }
561 if ($haveTransientApi) {
562 // Set the gate BEFORE running the advance so any failure or
563 // long-running stage still suppresses the next sub-request.
564 // Without this, a slow advance that times out partway would
565 // be retried by the very next sub-request, compounding the
566 // page-load impact instead of bounding it.
567 set_transient(
568 $gateKey,
569 1,
570 (int)ABJ_404_Solution_ViewBuildConfig::PAGE_LOAD_FALLBACK_GATE_SECONDS
571 );
572 }
573
574 // Compress the per-stage budget for just this advance. The
575 // production filter machinery is the existing hook
576 // abj404_view_build_per_stage_budget_seconds; clamping via
577 // min() rather than overwriting preserves any operator-set
578 // smaller-budget filter for hosts that have already tuned
579 // down. Priority 100 runs after most operator filters so the
580 // fallback's ceiling dominates.
581 $budgetSeconds = (float)ABJ_404_Solution_ViewBuildConfig::PAGE_LOAD_FALLBACK_BUDGET_SECONDS;
582 $budgetFilter = static function ($incoming) use ($budgetSeconds) {
583 $value = is_scalar($incoming) ? (float)$incoming : $budgetSeconds;
584 return min($value, $budgetSeconds);
585 };
586 $filterRegistered = false;
587 if (function_exists('add_filter')) {
588 add_filter('abj404_view_build_per_stage_budget_seconds', $budgetFilter, 100);
589 $filterRegistered = true;
590 }
591
592 try {
593 // forceRebuild=false: this is the self-healing path. The
594 // explicit ?abj404_force_view_rebuild=1 admin recovery is a
595 // separate gesture that intentionally clears degraded gates
596 // and waits 30s for the lock. Page-load fallback should
597 // never escalate to those semantics.
598 $progress = $this->advanceViewBuildOnce(false);
599 } finally {
600 if ($filterRegistered && function_exists('remove_filter')) {
601 remove_filter('abj404_view_build_per_stage_budget_seconds', $budgetFilter, 100);
602 }
603 }
604
605 $reason = !empty($progress['locked']) ? 'locked' : 'advanced';
606 return array(
607 'ran' => true,
608 'reason' => $reason,
609 'progress' => $progress,
610 );
611 }
612
613 /**
614 * COUNT(*) sibling to runRedirectsForViewStaged. Used by
615 * getRedirectsForViewCount when filterText is non-empty (the
616 * filterText-empty path already uses the optimized COUNT against
617 * the live redirects table, which stays fast). Same build/serve flow
618 * as the row path; the build is shared via the per-request guard.
619 *
620 * @param string $sub
621 * @param array<string, mixed> $tableOptions
622 * @return int
623 */
624 public function runRedirectsForViewCountStaged(string $sub, array $tableOptions): int {
625 $this->stagedQueryTimeoutSeconds = isset($tableOptions['_abj404_query_timeout'])
626 && is_numeric($tableOptions['_abj404_query_timeout'])
627 ? max(0, intval($tableOptions['_abj404_query_timeout'])) : 0;
628 $builtAt = $this->viewDoneBuiltAt();
629 $isFresh = $builtAt > 0
630 && (time() - $builtAt) < ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_FRESHNESS_TTL_SECONDS
631 && $this->viewDoneIsServeable();
632
633 if (!$this->viewDoneIsServeable()) {
634 // view_done is missing on disk or empty (no usable data).
635 // Schedule a background rebuild and signal pending; never run
636 // the staged build inline inside a request. The fetch AJAX gate
637 // prevents this from being reached under normal traffic; non-
638 // AJAX callers retry on next request.
639 $this->scheduleViewDoneRebuild();
640 $progress = $this->describeBuildProgressForNotice();
641 throw new ABJ_404_Solution_ViewBuildPendingException(
642 'Staged view-count build pending; background rebuild scheduled. Progress: ' . $progress,
643 $progress
644 );
645 }
646
647 if (!$isFresh) {
648 // Stale or invalidated but data on disk is serveable: return the
649 // stale count and kick off a background rebuild. Hard-stale
650 // notice fires when the data on disk exceeds the upper bound.
651 $this->scheduleViewDoneRebuild();
652 $this->maybeRaiseViewDoneHardStaleNotice();
653 }
654
655 $sql = $this->buildViewDoneCountQuery($sub, $tableOptions);
656 $result = $this->queryAndGetResults($sql, $this->stagedQueryOptions());
657 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
658 if (empty($rows)) {
659 return 0;
660 }
661 $row = is_array($rows[0]) ? $rows[0] : array();
662 $raw = $row['cnt'] ?? reset($row);
663 return is_scalar($raw) ? intval($raw) : 0;
664 }
665
666 /**
667 * Hook target for `wp_schedule_single_event('abj404_rebuildViewDone')`.
668 * Rebuilds inline (under the build lock) so the next admin request
669 * sees fresh data. Called from PluginLogic during cron registration.
670 *
671 * @return void
672 */
673 public function rebuildViewDoneInBackground(): void {
674 if ($this->foregroundViewBuildLeaseActive()) {
675 $this->logger->debugMessage(
676 '[staged] rebuildViewDoneInBackground: deferring; '
677 . 'foreground build lease active. Rescheduled.'
678 );
679 $this->scheduleViewDoneRebuild(ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_FOREGROUND_LEASE_SECONDS);
680 return;
681 }
682 if (!$this->acquireViewBuildLock()) {
683 $this->logger->debugMessage(
684 '[staged] rebuildViewDoneInBackground: lock not acquired '
685 . '(another worker is building); skipping this cron tick.'
686 );
687 return;
688 }
689 try {
690 // Runner-startup reconciliation: clean up inconsistent staged-
691 // table state from a previous run that crashed mid-S11 or was
692 // OOM-killed before the swap option write. Runs BEFORE
693 // runStagedBuildOnce() so its halt-gate / once-guard short-
694 // circuits cannot suppress the cleanup, and it can short-
695 // circuit the rebuild itself when it manages to recover the
696 // previous run's buffer in place.
697 $reconcileResult = $this->reconcileStagedTablesAtRunnerStartup();
698 if ($reconcileResult === 'promoted') {
699 // The previous run's view_build was renamed to view_done
700 // in place; freshness is recorded; view_done is now
701 // serveable. No need to re-run the staged build this tick.
702 return;
703 }
704 $isComplete = $this->runStagedBuildOnce();
705 if (!$isComplete) {
706 // Build yielded mid-stage; schedule another tick to continue.
707 $this->scheduleViewDoneRebuild();
708 }
709 } catch (Throwable $e) {
710 // Log at warning level, not error: a failed background rebuild
711 // leaves the plugin functional (the prior view_done snapshot,
712 // if any, is still served). Per CLAUDE.md self-healing rules,
713 // infrastructure failures should not generate dev email reports.
714 $this->logger->warn('[staged] background rebuild yielded an error: ' . $e->getMessage());
715 } finally {
716 $this->releaseViewBuildLock();
717 }
718 }
719
720 /**
721 * Reconcile staged-build table state from a previous run that ended
722 * in an inconsistent place, before this run's stages execute. Called
723 * from rebuildViewDoneInBackground() AFTER the build lock is
724 * acquired (so we cannot race a sibling worker on the same site)
725 * and BEFORE runStagedBuildOnce() (so the staged orchestrator sees
726 * a clean starting state regardless of which entry path took the
727 * lock).
728 *
729 * Cases handled:
730 *
731 * 1. Orphan `{wp_abj404_view_deleteme}` from a prior crashed S11
732 * swap (or a critical-stage halt that left the previous run's
733 * deleteme on disk). Drop it. Always safe; deleteme is a
734 * transient by design.
735 *
736 * 2. `{wp_abj404_view_build}` exists, `{wp_abj404_view_done}` does
737 * NOT, AND no resumable build progress is recorded: the
738 * previous run completed S2-S10 but crashed before the S11
739 * RENAME swap published the buffer. Promote the buffer in
740 * place via `RENAME TABLE view_build TO view_done`, mark
741 * fresh, clear progress. Preserves the work of S2-S10 instead
742 * of throwing it away.
743 *
744 * 3. Both `{wp_abj404_view_build}` and `{wp_abj404_view_done}`
745 * exist, AND no resumable build progress is recorded: the
746 * previous run halted between stages with both tables on
747 * disk. Treat view_done as the live one and drop view_build
748 * so the next fresh build starts from a known empty buffer.
749 *
750 * Resumable progress = `started_at` within
751 * VIEW_BUILD_RESUME_TTL_SECONDS and either a completed stage
752 * (`current_stage` > 0) or a durable started-stage marker. When a
753 * resumable build is in flight we leave view_build alone so the
754 * next tick can continue from the persisted high-water id (cases
755 * 2 and 3 are skipped; case 1 still runs).
756 *
757 * Reconciliation actions are best-effort: when DROP / RENAME is
758 * denied by the host (privilege loss between runs), surface a
759 * deduplicated admin notice naming the specific tables and
760 * recommending manual cleanup. The build then falls through to
761 * runStagedBuildOnce() which will hit its own host-failure
762 * classifier.
763 *
764 * @return string One of:
765 * 'none' - no reconciliation needed.
766 * 'cleaned' - orphan tables dropped; build can proceed.
767 * 'promoted' - view_build was renamed to view_done;
768 * view_done is fresh; rebuild can be
769 * skipped this tick.
770 * 'failed' - reconciliation could not complete
771 * (privilege denied); admin notice set.
772 */
773 public function reconcileStagedTablesAtRunnerStartup(): string {
774 $tempDeletemeTable = $this->viewDeletemeTableName();
775 $tempBuildTable = $this->viewBuildTableName();
776 $doneTable = $this->viewDoneTableName();
777
778 $action = 'none';
779 $haveDeleteme = $this->stagedTableExists($tempDeletemeTable);
780
781 if ($haveDeleteme) {
782 $r = $this->queryAndGetResults(
783 'DROP TABLE IF EXISTS `' . $tempDeletemeTable . '`',
784 array('log_errors' => false)
785 );
786 $err = isset($r['last_error']) && is_string($r['last_error']) ? trim($r['last_error']) : '';
787 if ($err === '' || !$this->stagedTableExists($tempDeletemeTable)) {
788 $this->logger->infoMessage(sprintf(
789 '[staged] reconcile: dropped orphan view_deleteme `%s` from a previous failed run',
790 $tempDeletemeTable
791 ));
792 $action = 'cleaned';
793 } else {
794 $this->logger->warn(sprintf(
795 '[staged] reconcile: orphan view_deleteme `%s` could not be dropped: %s',
796 $tempDeletemeTable, substr($err, 0, 200)
797 ));
798 $this->setStagedBuildHaltNotice('orphan_deleteme', sprintf(
799 'An orphan staged-build buffer `%s` from a previous failed run could not be removed (privilege denied?): %s. Manual cleanup: drop the buffer table `%s` from your database (e.g. via phpMyAdmin or your hosting MySQL console).',
800 $tempDeletemeTable, substr($err, 0, 200), $tempDeletemeTable
801 ));
802 $action = 'failed';
803 }
804 }
805
806 // Resumable build in flight? Leave $tempBuildTable / view_done alone
807 // so the next tick can continue from the persisted high-water
808 // id; orphan deleteme cleanup above already ran and is enough.
809 $startedAt = $this->readProgressOption('started_at', 0);
810 $currentStage = $this->readProgressOption('current_stage', 0);
811 $lastStartedStage = $this->readProgressOption('last_started_stage', 0);
812 $resumeWindowOk = $startedAt > 0
813 && (time() - $startedAt) < ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_RESUME_TTL_SECONDS;
814 if ($resumeWindowOk && ($currentStage > 0 || $lastStartedStage > 0)) {
815 return $action;
816 }
817
818 $haveBuild = $this->stagedTableExists($tempBuildTable);
819 $haveDone = $this->stagedTableExists($doneTable);
820
821 // Case 2: view_build exists, view_done missing. Promote the
822 // buffer in place rather than re-running S1-S11 from scratch
823 // -- but only when an integrity probe says the buffer is
824 // plausibly complete. Without the integrity check we could
825 // publish a partially-built buffer (S2 stopped halfway, or a
826 // force-rebuild cleared progress while a redirect edit had
827 // also added rows we never picked up).
828 if ($haveBuild && !$haveDone) {
829 if (!$this->bufferIntegrityPassesForPromote($tempBuildTable)) {
830 $this->logger->infoMessage(sprintf(
831 '[staged] reconcile: not promoting view_build `%s` (integrity probe failed); dropping for fresh rebuild',
832 $tempBuildTable
833 ));
834 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $tempBuildTable . '`',
835 array('log_errors' => false));
836 return 'cleaned';
837 }
838 $sql = 'RENAME TABLE `' . $tempBuildTable . '` TO `' . $doneTable . '`';
839 $r = $this->queryAndGetResults($sql, array('log_errors' => true));
840 $err = isset($r['last_error']) && is_string($r['last_error']) ? trim($r['last_error']) : '';
841 if ($err === '' && $this->stagedTableExists($doneTable)) {
842 $this->logger->infoMessage(sprintf(
843 '[staged] reconcile: promoted view_build to view_done '
844 . '(`%s` -> `%s`); previous run crashed before S11 swap',
845 $tempBuildTable, $doneTable
846 ));
847 // Same as the S11 swap completion: update both freshness
848 // signals, clear hard-stale notice, reset serveability cache.
849 $this->markViewDoneBuildCompleted();
850 $this->clearAllProgressOptions();
851 return 'promoted';
852 }
853 $this->logger->warn(sprintf(
854 '[staged] reconcile: could not promote view_build to view_done: %s',
855 substr($err, 0, 200)
856 ));
857 $this->setStagedBuildHaltNotice('promote_build_failed', sprintf(
858 'A staged-build buffer `%s` exists from a previous run but could not be promoted to `%s` (privilege denied?): %s. Manual cleanup: rename the buffer `%s` to `%s`, or remove the buffer `%s` from your database (e.g. via phpMyAdmin or your hosting MySQL console).',
859 $tempBuildTable, $doneTable, substr($err, 0, 200),
860 $tempBuildTable, $doneTable, $tempBuildTable
861 ));
862 return 'failed';
863 }
864
865 // Case 3: both tables exist. view_done is the live one; the
866 // orphan $tempBuildTable is from a halted previous run. Drop it so
867 // the next fresh build starts from a known empty buffer.
868 if ($haveBuild && $haveDone) {
869 $r = $this->queryAndGetResults(
870 'DROP TABLE IF EXISTS `' . $tempBuildTable . '`',
871 array('log_errors' => false)
872 );
873 $err = isset($r['last_error']) && is_string($r['last_error']) ? trim($r['last_error']) : '';
874 if ($err === '' || !$this->stagedTableExists($tempBuildTable)) {
875 // WARN (not INFO) so this signal survives a site with DEBUG
876 // disabled. Carries the four progress fields support needs
877 // to distinguish "build keeps restarting at S1" from
878 // "build invalidated on every redirect edit / cron tick"
879 // from "multi-tab/cron lock contention orphaning each
880 // partial build" without asking for another debug zip.
881 $lastCompletedStage = $this->readProgressOption('last_completed_stage', 0);
882 $age = $startedAt > 0 ? max(0, time() - $startedAt) : 0;
883 $this->logger->warn(sprintf(
884 '[staged] reconcile: dropped orphan view_build `%s` (view_done is live; previous run halted before swap); '
885 . 'current_stage=%d last_started_stage=%d last_completed_stage=%d started_at=%d age=%ds',
886 $tempBuildTable,
887 $currentStage,
888 $lastStartedStage,
889 $lastCompletedStage,
890 $startedAt,
891 $age
892 ));
893 return 'cleaned';
894 }
895 $this->logger->warn(sprintf(
896 '[staged] reconcile: orphan view_build `%s` could not be dropped: %s',
897 $tempBuildTable, substr($err, 0, 200)
898 ));
899 $this->setStagedBuildHaltNotice('orphan_build', sprintf(
900 'A staged-build buffer `%s` from a previous run still exists alongside the live view_done, but could not be removed: %s. Manual cleanup: drop the buffer `%s` from your database (e.g. via phpMyAdmin or your hosting MySQL console).',
901 $tempBuildTable, substr($err, 0, 200), $tempBuildTable
902 ));
903 return 'failed';
904 }
905
906 return $action;
907 }
908
909 /**
910 * Integrity probe used by the case-2 promote branch of
911 * reconcileStagedTablesAtRunnerStartup(). Returns true only when the
912 * buffer is plausibly complete: row count matches the live redirects
913 * table within a small tolerance (one redirect could have been
914 * added during the build window). Returns false on any probe error
915 * so a transient DB hiccup never publishes a buffer of unknown
916 * shape as the live snapshot.
917 *
918 * Row-count parity is a coarse check (it cannot detect stale POST
919 * resolutions when wp_posts has changed mid-flight). Promote is
920 * already an opportunistic recovery; if we're wrong, the next
921 * invalidate-driven rebuild will replace view_done.
922 *
923 * @param string $bufferTable
924 * @return bool
925 */
926 private function bufferIntegrityPassesForPromote(string $bufferTable): bool {
927 $bufferRows = $this->countViewBuildRows();
928 if ($bufferRows <= 0) {
929 return false;
930 }
931 $liveRows = $this->countLiveRedirects();
932 if ($liveRows <= 0) {
933 // No redirects in the live table -- treat any buffer as
934 // unsafe to publish (a bug pruned all redirects, or the
935 // count probe itself errored).
936 return false;
937 }
938 // Allow the buffer to differ from live by up to one row in
939 // either direction so an admin who created or deleted a single
940 // redirect during the build window does not block promotion.
941 $diff = abs($bufferRows - $liveRows);
942 return $diff <= 1;
943 }
944
945 // invalidateViewDone() was deleted in Phase 4 of the staged view-build
946 // watermark refactor (see docs/refactor-staged-view-build-watermark.md).
947 // The god-method conflated three concepts (logical invalidation, runner
948 // lifecycle, reader policy) and was the seam through which external code
949 // destroyed runner-owned state. Replacements, by intent:
950 //
951 // - Source data changed (admin/REST/CLI/AJAX/cron mutation): call
952 // bumpMutationWatermark() (DataAccessTrait_MutationWatermarkSeam).
953 // For admin form actions that also need the strict admin-visibility
954 // gate, call markViewDoneInvalidatedByAdminMutation()
955 // (DataAccessTrait_AdminMutationGate) which composes the watermark
956 // bump with the observed-watermark gate option.
957 // - Discard + restart the in-flight build (admin "rebuild now",
958 // diagnostic ?abj404_force_view_rebuild=1 paths): call
959 // forceRestartViewBuild() (DataAccessTrait_ViewBuildForceRestart).
960 // - Schedule a cron rebuild without other side effects: call
961 // scheduleViewDoneRebuild() (DataAccessTrait_ViewBuildLockAndCron).
962 //
963 // The semantic-forbidden-operation lint in StagedBuildOwnershipLintTest
964 // (lint d) enforces that no future code introduces a new caller of
965 // ->invalidateViewDone( anywhere in includes/.
966
967 // progressOptionName / readProgressOption / writeProgressOption /
968 // clearAllProgressOptions live on the sibling
969 // ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait. They read and
970 // write the progress option registry declared in that trait.
971
972 /**
973 * Read the configured per-batch row count. Honors:
974 * - define('ABJ404_VIEW_BUILD_BATCH_SIZE', N) for tests/operators
975 * - apply_filters('abj404_view_build_batch_size', N) for site overrides
976 *
977 * @return int Always >= 1.
978 */
979 private function viewBuildBatchSize(): int {
980 $size = ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEFAULT_BATCH_SIZE;
981 if (defined('ABJ404_VIEW_BUILD_BATCH_SIZE')) {
982 $size = intval(ABJ404_VIEW_BUILD_BATCH_SIZE);
983 }
984 if (function_exists('apply_filters')) {
985 $filtered = apply_filters('abj404_view_build_batch_size', $size);
986 if (is_scalar($filtered)) {
987 $size = intval($filtered);
988 }
989 }
990 return max(1, $size);
991 }
992
993
994 /**
995 * Wall-clock budget after which a batched stage (S2 / S4 / S5) yields
996 * to the next request rather than starting another batch. This is NOT a
997 * query cancellation: any in-flight INSERT or UPDATE-JOIN runs to its
998 * own MySQL timeout. It only stops the loop from issuing more batches
999 * once the request is close to the real ceiling.
1000 *
1001 * Default: derive from PHP's max_execution_time minus a 2s response
1002 * cushion. WP-CLI / cron with no PHP time limit (max_execution_time = 0)
1003 * fall back to ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_PER_STAGE_BUDGET_SECONDS
1004 * since unbounded loops would still be undesirable in those contexts.
1005 *
1006 * Explicit overrides (define / filter) win over auto-detection so
1007 * operators can tune it for their host. The minimum floor of 0.1s is
1008 * preserved so tests that set a tiny override still complete a batch.
1009 *
1010 * @return float Seconds; always > 0.
1011 */
1012 private function viewBuildPerStageBudgetSeconds(): float {
1013 $explicitOverride = false;
1014 $budget = (float)ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_PER_STAGE_BUDGET_SECONDS;
1015
1016 if (defined('ABJ404_VIEW_BUILD_PER_STAGE_BUDGET_SECONDS')) {
1017 $budget = (float)ABJ404_VIEW_BUILD_PER_STAGE_BUDGET_SECONDS;
1018 $explicitOverride = true;
1019 }
1020
1021 // When set_time_limit() is in disable_functions, the build cannot
1022 // extend its time mid-request. Widen the cushion (4s vs. 2s) and
1023 // cap below the default budget so each tick yields earlier and the
1024 // next cron tick resumes inside its own fresh request budget.
1025 $setTimeLimitAvailable = $this->probeSetTimeLimitAvailability();
1026
1027 if (!$explicitOverride) {
1028 $maxExec = (int)ini_get('max_execution_time');
1029 if ($maxExec >= 5) {
1030 $cushion = $setTimeLimitAvailable ? 2 : 4;
1031 $budget = (float)max(1, $maxExec - $cushion);
1032 }
1033 }
1034 if (!$explicitOverride && !$setTimeLimitAvailable) {
1035 $tightCap = max(
1036 1.0,
1037 (float)ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_PER_STAGE_BUDGET_SECONDS - 4.0
1038 );
1039 $budget = min($budget, $tightCap);
1040 }
1041
1042 if (function_exists('apply_filters')) {
1043 $filtered = apply_filters('abj404_view_build_per_stage_budget_seconds', $budget);
1044 if (is_scalar($filtered)) {
1045 $budget = (float)$filtered;
1046 }
1047 }
1048 return $budget > 0.1 ? $budget : 0.1;
1049 }
1050
1051 /**
1052 * Release the build lock and immediately reacquire it (non-blocking).
1053 * Called between every stage of the staged view build so the lock hold
1054 * is bounded by the duration of a single stage rather than the cumulative
1055 * 11-stage wall time.
1056 *
1057 * Why this exists: production error reports #14 (ajasha.de, S4), #15
1058 * (greyleafmedia.com, S11), #16 (p2p-game.com, S5), and #17 (remiancelin.fr,
1059 * S11), all plugin 4.1.18 via wp-cron, traced to a connection-drop race.
1060 * GET_LOCK was acquired once and held across the full S1-S11 run. On
1061 * shared hosting the MySQL connection dropped mid-hold (wait_timeout or
1062 * pool eviction); wpdb auto-reconnected with no lock; a second cron tick
1063 * acquired the (now freed) lock, ran reconcile Case 3, and dropped
1064 * view_build out from under the first worker. The first worker then
1065 * queried a missing table: "Table doesn't exist" (MariaDB) or "Can't find
1066 * .frm file" (MySQL 5.7).
1067 *
1068 * Per-stage release bounds the connection-drop exposure window to a
1069 * single stage, and a sibling worker that takes the lock between our
1070 * stages sees the persisted current_stage / started_at and resumes from
1071 * where we left off rather than running reconcile against a partial build.
1072 *
1073 * @return bool true when the lock was reacquired and the caller should
1074 * continue with the next stage; false when a sibling worker
1075 * took the lock in the gap -- the caller must yield this
1076 * tick. RELEASE_LOCK / delete_option of a lock we no longer
1077 * hold is a no-op, so the caller's outer try/finally release
1078 * is harmless even on the yield path.
1079 */
1080 private function releaseAndReacquireBetweenStages(): bool {
1081 $this->releaseViewBuildLock();
1082 if (!$this->acquireViewBuildLock(0)) {
1083 $this->logger->infoMessage(
1084 '[staged] runStagedBuildOnce: released build lock between stages; '
1085 . 'another worker took it during the gap. Yielding this tick; '
1086 . 'the next cron / AJAX advance will resume from the persisted current_stage.'
1087 );
1088 return false;
1089 }
1090 return true;
1091 }
1092
1093 /**
1094 * Verify `$wpdb->prefix` has not changed since S1 captured it. When the
1095 * snapshot and the live prefix disagree, surface a deduplicated admin
1096 * notice, log the mismatch with both prefixes for post-mortem, and
1097 * return true so the orchestrator halts before S2-S11 run any DML
1098 * against a different blog's tables (Codex finding #8: a mu-plugin
1099 * calling `switch_to_blog()` between cron ticks would otherwise let
1100 * the build write across prefixes silently).
1101 *
1102 * Idempotent: calling on the matching path is cheap (one option read or
1103 * one in-memory string compare) and never mutates state.
1104 *
1105 * @param int $aboutToRunStage 1..11; included in the notice for context.
1106 * @return bool True on mismatch -- caller should `return false` from
1107 * runStagedBuildOnce immediately. False when prefix matches
1108 * (or no capture exists) -- caller proceeds with the stage.
1109 */
1110 private function haltIfPrefixChangedSinceStageOne(int $aboutToRunStage): bool {
1111 if ($this->verifyPrefixUnchangedSinceStageOne()) {
1112 return false;
1113 }
1114 global $wpdb;
1115 $current = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : '';
1116 $captured = $this->capturedPrefixForLog();
1117 $msg = sprintf(
1118 'Multisite blog context changed during view rebuild; rebuild '
1119 . 'aborted to prevent cross-blog data corruption. '
1120 . 'captured_prefix=%s current_prefix=%s aborted_at_stage=%d',
1121 $captured,
1122 $current,
1123 $aboutToRunStage
1124 );
1125 $this->setStagedBuildHaltNotice('multisite_prefix_changed', $msg);
1126 $this->logger->warn('[staged] ' . $msg);
1127 // Do NOT clear progress / captured prefix here: the original blog's
1128 // resume on a future request will see its (untouched) progress and
1129 // prefix capture, verify cleanly, and continue. Clearing here would
1130 // be writing through the WRONG blog's options table anyway.
1131 return true;
1132 }
1133
1134 /**
1135 * Run the staged build from wherever we left off, atomically swap into
1136 * view_done when all stages have completed.
1137 *
1138 * Resumable: each stage records progress in WP options so the next
1139 * request (driven by WP-Cron or by the JS poll re-issuing the page
1140 * request) can continue where this request left off. S2/S4/S5 are
1141 * additionally batched within a single request and yield mid-stage
1142 * when the per-stage budget is exhausted; the next request resumes
1143 * from the persisted high-water id.
1144 *
1145 * Process-local guard prevents re-entrance within a single request.
1146 *
1147 * @return bool true when the build fully completed and view_done is
1148 * now fresh; false when the request yielded mid-stage and
1149 * another request is needed to finish.
1150 */
1151 private function runStagedBuildOnce(): bool {
1152 if (self::$viewBuildAlreadyRanThisRequest) {
1153 // Already either ran to completion or yielded earlier in this
1154 // request, do not re-enter. Caller should not block on this.
1155 return $this->viewDoneIsFresh();
1156 }
1157 self::$viewBuildAlreadyRanThisRequest = true;
1158 $this->registerViewBuildShutdownDiagnostics();
1159
1160 // Probe the PHP runtime once per request: surfaces a low-memory
1161 // admin notice and gates the per-stage budget into a tighter
1162 // cron-tick mode when set_time_limit() is in disable_functions.
1163 $this->probePhpEnvironmentForBuild();
1164 // Probe filesystem-side host constraints (open_basedir, upload_tmp_dir,
1165 // tmpdir disk-free) once per request. Read-and-warn-only: surfaces a
1166 // deduplicated admin notice if anything is out of range; never blocks.
1167 $this->probeFilesystemEnvironmentForBuild();
1168
1169 // Build is in the dedup window after a critical-stage permanent
1170 // host failure. Re-running would just produce the same denied
1171 // DDL again. Cron ticks during the window are no-ops; an explicit
1172 // force rebuild clears the gate via clearStagedBuildDegradedState().
1173 if ($this->isBuildHaltedForHostFailure()) {
1174 return $this->viewDoneIsFresh();
1175 }
1176
1177 // Decide: resume or restart from scratch?
1178 $startedAt = $this->readProgressOption('started_at', 0);
1179 $bufferExists = $this->stagedTableExists($this->viewBuildTableName());
1180 $isResuming = $startedAt > 0
1181 && (time() - $startedAt) < ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_RESUME_TTL_SECONDS
1182 && $bufferExists;
1183
1184 // Single line per advance request that pins down WHICH path was
1185 // taken and why. On a build that takes hours across many requests,
1186 // this is the entry point for any "stuck at stage N" investigation:
1187 // a single grep for [staged] in the debug log shows whether each
1188 // request was resuming, restarting, or skipping due to the per-request
1189 // guard.
1190 $currentStage = $this->readProgressOption('current_stage', 0);
1191 if (!$isResuming) {
1192 $reason = ($startedAt <= 0)
1193 ? 'no prior started_at'
1194 : (!$bufferExists
1195 ? 'buffer table missing (prior crash or fresh install)'
1196 : ('prior build older than resume TTL ('
1197 . (time() - $startedAt) . 's elapsed)'));
1198 // INFO (not DEBUG) so this signal survives a site that has
1199 // disabled DEBUG. Without it, a stuck-at-S1 redirects page on
1200 // such a site leaves no log evidence of whether each request
1201 // is restarting fresh or resuming.
1202 $this->logger->infoMessage(sprintf(
1203 '[staged] runStagedBuildOnce: fresh start (%s); current_stage=%d',
1204 $reason, $currentStage
1205 ));
1206 // Scrap any partial state. An abandoned partial build older
1207 // than the resume TTL is not safe to continue; wp_posts /
1208 // wp_terms / wp_options state may have drifted. Also wipes
1209 // the Phase-2 active stamp (kept outside the progress
1210 // registry so it survives the S11 happy-path clear).
1211 $this->performFreshStartCleanup();
1212 } else {
1213 // INFO (not DEBUG): see fresh-start branch above. The pair
1214 // (fresh start vs. resuming) is the entry point for any
1215 // stuck-build investigation and must survive DEBUG-off sites.
1216 $this->logger->infoMessage(sprintf(
1217 '[staged] runStagedBuildOnce: resuming (started_at=%d, %ds ago); current_stage=%d',
1218 $startedAt, time() - $startedAt, $currentStage
1219 ));
1220 // Resuming: drop only the leftover deleteme from a prior crashed
1221 // RENAME swap. Keep the buffer + progress options intact.
1222 $this->dropDeletemeTable();
1223 }
1224
1225 // Set our own per-query timeout for every staged-build query
1226 // run during this advance call. Sized below the host's session
1227 // max_statement_time so our hint fires first, producing a
1228 // classifiable "max_statement_time exceeded" error we can react
1229 // to (Path B: shrink the batch). Without this, MariaDB's silent
1230 // server-level kill produces a less-classifiable connection or
1231 // generic-query error.
1232 $this->stagedQueryTimeoutSeconds = (int)round($this->intelligentStagedQueryTimeoutSeconds());
1233
1234 $stage = $this->readProgressOption('current_stage', 0);
1235
1236 if ($stage < 1) {
1237 // Capture $wpdb->prefix BEFORE the S1 callback so subsequent
1238 // stage entries can detect a mid-build switch_to_blog().
1239 $this->capturePrefixAtBuildStart();
1240 $this->stampStartedWatermarksAtS1Entry();
1241 // Probe sql_mode + max_allowed_packet for THIS connection. The
1242 // probe persists in `view_build_state` and (best-effort) clears
1243 // STRICT_TRANS_TABLES / ONLY_FULL_GROUP_BY for the build session
1244 // so any future query the build adds inherits non-strict
1245 // semantics. The S2 INSERT is already strict-safe via REGEXP-
1246 // guarded CAST so this is belt-and-suspenders for new code.
1247 $this->probeSqlModeForBuild();
1248 // Probe operational + DDL-safety MySQL session variables once at
1249 // S1 entry. Read-and-warn-only: surfaces a single consolidated
1250 // admin notice when any variable is out of range; never blocks.
1251 $this->probeSessionVariablesAtS1Entry();
1252 $this->logger->debugMessage(sprintf(
1253 '[staged] runStagedBuildOnce: capturing prefix at S1 entry: prefix=%s',
1254 $this->capturedPrefixForLog()
1255 ));
1256 $this->markBuildStage('staged_build_s1_create');
1257 $r = $this->runTimedViewBuildStage(1, 'staged_build_s1_create', function () {
1258 $this->stageCreateBuildTable();
1259 });
1260 if ($r === false || $r === 'halted') {
1261 return false; // host killed S1 or halt set; next tick gated on halt window
1262 }
1263 // Stamp started_at on the very first stage so the resume-TTL
1264 // clock starts from buffer creation.
1265 if ($this->readProgressOption('started_at', 0) === 0) {
1266 $this->writeProgressOption('started_at', time());
1267 }
1268 $this->writeProgressOption('current_stage', 1);
1269 $stage = 1;
1270 }
1271
1272 if ($stage < 2) {
1273 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1274 if ($this->haltIfPrefixChangedSinceStageOne(2)) { return false; }
1275 if ($this->gateAbortIfMutationWatermarkAdvanced(2)) { return false; }
1276 $r = $this->runTimedViewBuildStage(2, 'staged_build_s2_insert', function () {
1277 return $this->stageInsertRedirectsBatched();
1278 });
1279 if ($r === false || $r === 'halted') {
1280 return false; // budget exhausted, kill, or halt; resume / no-op next request
1281 }
1282 $this->writeProgressOption('current_stage', 2);
1283 $stage = 2;
1284 }
1285
1286 if ($stage < 3) {
1287 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1288 if ($this->haltIfPrefixChangedSinceStageOne(3)) { return false; }
1289 if ($this->gateAbortIfMutationWatermarkAdvanced(3)) { return false; }
1290 if ($this->isStageMarkedSkipped(3)) {
1291 // Permanent host-side denial recorded on a prior tick.
1292 // Advance current_stage past S3 without touching the SQL.
1293 $this->writeProgressOption('current_stage', 3);
1294 $stage = 3;
1295 } else {
1296 $this->markBuildStage('staged_build_s3_index_fd');
1297 // Non-batched: kill-streak escape valve extends the per-query
1298 // timeout above the host's session limit on retry. Without
1299 // this, a CREATE INDEX that exceeds max_statement_time on
1300 // big buffers loops with the same timeout forever.
1301 $r = $this->runNonBatchedStageWithKillStreakEscape(
1302 3, 'staged_build_s3_index_fd', 's3_kill_streak',
1303 function () { $this->stageAddPreJoinIndexes(); }
1304 );
1305 if ($r === false || $r === 'halted') {
1306 return false;
1307 }
1308 $this->writeProgressOption('current_stage', 3);
1309 $stage = 3;
1310 }
1311 }
1312
1313 if ($stage < 4) {
1314 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1315 if ($this->haltIfPrefixChangedSinceStageOne(4)) { return false; }
1316 if ($this->gateAbortIfMutationWatermarkAdvanced(4)) { return false; }
1317 $r = $this->runTimedViewBuildStage(4, 'staged_build_s4_update_posts', function () {
1318 return $this->stageUpdatePostsBatched();
1319 });
1320 if ($r === false || $r === 'halted') {
1321 return false;
1322 }
1323 $this->writeProgressOption('current_stage', 4);
1324 $stage = 4;
1325 }
1326
1327 if ($stage < 5) {
1328 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1329 if ($this->haltIfPrefixChangedSinceStageOne(5)) { return false; }
1330 if ($this->gateAbortIfMutationWatermarkAdvanced(5)) { return false; }
1331 $r = $this->runTimedViewBuildStage(5, 'staged_build_s5_update_terms', function () {
1332 return $this->stageUpdateTermsBatched();
1333 });
1334 if ($r === false || $r === 'halted') {
1335 return false;
1336 }
1337 $this->writeProgressOption('current_stage', 5);
1338 $stage = 5;
1339 }
1340
1341 if ($stage < 6) {
1342 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1343 if ($this->haltIfPrefixChangedSinceStageOne(6)) { return false; }
1344 if ($this->gateAbortIfMutationWatermarkAdvanced(6)) { return false; }
1345 $this->markBuildStage('staged_build_s6_update_home');
1346 $r = $this->runTimedViewBuildStage(6, 'staged_build_s6_update_home', function () {
1347 $this->stageUpdateHome();
1348 });
1349 if ($r === false || $r === 'halted') {
1350 return false;
1351 }
1352 $this->writeProgressOption('current_stage', 6);
1353 $stage = 6;
1354 }
1355
1356 if ($stage < 7) {
1357 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1358 if ($this->haltIfPrefixChangedSinceStageOne(7)) { return false; }
1359 if ($this->gateAbortIfMutationWatermarkAdvanced(7)) { return false; }
1360 $this->markBuildStage('staged_build_s7_update_external');
1361 $r = $this->runTimedViewBuildStage(7, 'staged_build_s7_update_external', function () {
1362 $this->stageUpdateExternal();
1363 });
1364 if ($r === false || $r === 'halted') {
1365 return false;
1366 }
1367 $this->writeProgressOption('current_stage', 7);
1368 $stage = 7;
1369 }
1370
1371 if ($stage < 8) {
1372 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1373 if ($this->haltIfPrefixChangedSinceStageOne(8)) { return false; }
1374 if ($this->gateAbortIfMutationWatermarkAdvanced(8)) { return false; }
1375 $this->markBuildStage('staged_build_s8_update_special');
1376 $r = $this->runTimedViewBuildStage(8, 'staged_build_s8_update_special', function () {
1377 $this->stageUpdateSpecial();
1378 });
1379 if ($r === false || $r === 'halted') {
1380 return false;
1381 }
1382 $this->writeProgressOption('current_stage', 8);
1383 $stage = 8;
1384 }
1385
1386 if ($stage < 9) {
1387 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1388 if ($this->haltIfPrefixChangedSinceStageOne(9)) { return false; }
1389 if ($this->gateAbortIfMutationWatermarkAdvanced(9)) { return false; }
1390 if ($this->isStageMarkedSkipped(9)) {
1391 $this->writeProgressOption('current_stage', 9);
1392 $stage = 9;
1393 } else {
1394 // Non-batched: temp-table aggregate over wp_abj404_logs_hits +
1395 // UPDATE JOIN against the buffer. Kill-streak escape valve
1396 // extends the per-query timeout on retry so a logs_hits scan
1397 // that doesn't fit in the host's max_statement_time can
1398 // eventually finish.
1399 $s9Result = $this->runNonBatchedStageWithKillStreakEscape(
1400 9, 'staged_build_s9_update_hits', 's9_kill_streak',
1401 function () {
1402 if ($this->logsHitsTableExists()) {
1403 $this->markBuildStage('staged_build_s9_update_hits');
1404 $this->stageUpdateHits();
1405 return null;
1406 }
1407 $this->markBuildStage('staged_build_s9_update_hits', 'skipped; logs hits table unavailable');
1408 return 'skipped';
1409 }
1410 );
1411 if ($s9Result === false || $s9Result === 'halted') {
1412 return false;
1413 }
1414 // Skipped or not, advance past S9.
1415 $this->writeProgressOption('current_stage', 9);
1416 $stage = 9;
1417 }
1418 }
1419
1420 if ($stage < 10) {
1421 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1422 if ($this->haltIfPrefixChangedSinceStageOne(10)) { return false; }
1423 if ($this->gateAbortIfMutationWatermarkAdvanced(10)) { return false; }
1424 if ($this->isStageMarkedSkipped(10)) {
1425 $this->writeProgressOption('current_stage', 10);
1426 $stage = 10;
1427 } else {
1428 $this->markBuildStage('staged_build_s10_index_sort');
1429 // Non-batched: same kill-streak escape valve as S3. A
1430 // CREATE INDEX that exceeds the host's max_statement_time on
1431 // big buffers needs an extended retry timeout to complete.
1432 $r = $this->runNonBatchedStageWithKillStreakEscape(
1433 10, 'staged_build_s10_index_sort', 's10_kill_streak',
1434 function () { $this->stageAddSortIndexes(); }
1435 );
1436 if ($r === false || $r === 'halted') {
1437 return false;
1438 }
1439 $this->writeProgressOption('current_stage', 10);
1440 $stage = 10;
1441 }
1442 }
1443
1444 if ($stage < 11) {
1445 if (!$this->releaseAndReacquireBetweenStages()) { return false; }
1446 if ($this->haltIfPrefixChangedSinceStageOne(11)) { return false; }
1447 if ($this->gateAbortIfMutationWatermarkAdvanced(11)) { return false; }
1448 $this->markBuildStage('staged_build_s11_swap');
1449 if (!$this->runS11SwapWithPreRenameWatermarkRecheck()) { return false; }
1450 $this->publishBuiltWatermarkFromActiveBuildStartedWatermark();
1451 $this->markViewDoneBuildCompleted();
1452 $this->clearAllProgressOptions();
1453 }
1454
1455 return true;
1456 }
1457
1458
1459 // The following helpers all live on sibling traits so this file stays
1460 // focused on the orchestrator. They're listed here as a navigation aid:
1461 //
1462 // ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait:
1463 // - runStagedSqlFile / runStagedSqlFileTolerantOfDuplicateKey
1464 // - describeStagedSqlFailure / describeBuildProgressForNotice
1465 // - stagedQueryOptions
1466 // - viewDoneTableExists / stagedTableExists
1467 // - viewDoneIsFresh
1468 // - acquireViewBuildLock / releaseViewBuildLock
1469 // - scheduleViewDoneRebuild
1470 //
1471 // ABJ_404_Solution_DataAccess_ViewBuildStageCallbacksTrait:
1472 // - dropTransientStagedTables / dropDeletemeTable
1473 // - stageCreateBuildTable (S1)
1474 // - stageInsertRedirectsBatched (S2) / runInsertBatch
1475 // - stageAddPreJoinIndexes (S3)
1476 // - stageUpdatePostsBatched (S4) / stageUpdateTermsBatched (S5) / runIdRangeBatchedUpdate
1477 // - stageUpdateHome (S6) / stageUpdateExternal (S7) / stageUpdateSpecial (S8)
1478 // - stageUpdateHits (S9)
1479 // - stageAddSortIndexes (S10)
1480 // - stageRenameSwap (S11)
1481 // - countLiveRedirects / countViewBuildRows / maxBuildBufferId / humanBatchProgress
1482 }
1483