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 / ViewQueriesStaged.php

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

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