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

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

837 lines 41.3 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 * Per-stage callback implementations for the staged view-build pipeline.
9 *
10 * Each `stage*` method here is invoked from the orchestrator's
11 * runStagedBuildOnce() loop in
12 * {@see ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait}. The orchestrator
13 * owns the `current_stage` progression, per-stage timing/logging, kill-streak
14 * escape, and request-scope guards; this trait owns just the per-stage
15 * SQL/work performed at each step:
16 *
17 * - dropTransientStagedTables / dropDeletemeTable: S0 cleanup.
18 * - stageCreateBuildTable: S1 CREATE with engine fallback.
19 * - stageInsertRedirectsBatched: S2 resumable INSERT loop.
20 * - stageAddPreJoinIndexes: S3 ALTER TABLE for join indexes.
21 * - stageUpdatePostsBatched / stageUpdateTermsBatched: S4/S5 resumable
22 * id-range UPDATE-JOINs (delegated to runIdRangeBatchedUpdate below).
23 * - stageUpdateHome / stageUpdateExternal / stageUpdateSpecial: S6/S7/S8
24 * non-batched UPDATEs.
25 * - stageUpdateHits: S9 hits aggregation.
26 * - stageAddSortIndexes: S10 ALTER TABLE for sort indexes.
27 * - stageRenameSwap: S11 atomic RENAME TABLE swap to publish the buffer.
28 *
29 * Plus shared helpers (runInsertBatch, runIdRangeBatchedUpdate,
30 * countLiveRedirects, countViewBuildRows, maxBuildBufferId,
31 * humanBatchProgress) that compose these stages.
32 *
33 * Sibling to ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait and
34 * ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait; all three are mixed
35 * into ABJ_404_Solution_DataAccess. Properties / helper methods declared on
36 * those traits (markBuildStage, runStagedSqlFile, viewBuildTableName,
37 * stagedQueryOptions, etc.) are visible inside the composing class.
38 *
39 * @property ABJ_404_Solution_DatabaseCore $dbCore
40 * @property ABJ_404_Solution_Functions $f
41 * @property ABJ_404_Solution_Logging $logger
42 * @property ABJ_404_Solution_ViewReadService|null $viewReadService
43 * @property ABJ_404_Solution_LogsRepository|null $logsRepo
44 * @property int $stagedQueryTimeoutSeconds
45 * @property string $lastBatchProgressDetail
46 * @property bool $viewBuildStageOpenForShutdown
47 * @property int $viewBuildShutdownStageNumber
48 * @property string $viewBuildShutdownStageKey
49 * @property bool|null $namedLockSupportedThisRequest
50 * @property bool $fallbackLockLoggedThisRequest
51 * @property bool $usingTransientFallbackLock
52 * @property string $lastNamedLockUnsupportedReason
53 * @property string $lastNamedLockUnsupportedError
54 * @method void abortStagedBuildForMutationWatermarkAdvance(...$arguments)
55 * @method bool acquireTransientFallbackLock(...$arguments)
56 * @method bool acquireViewBuildLock(...$arguments)
57 * @method string activeBuildStartedWatermarkOptionName(...$arguments)
58 * @method bool adminMutationGateBlocks(...$arguments)
59 * @method array<mixed> advanceViewBuildOnce(...$arguments)
60 * @method void assertBuildBufferExistsOrHalt(...$arguments)
61 * @method ?bool attemptRelaxSqlModeForBuildConnection(...$arguments)
62 * @method bool bufferIntegrityPassesForPromote(...$arguments)
63 * @method string buildHaltTransientKey(...$arguments)
64 * @method string buildViewDoneCountQuery(...$arguments)
65 * @method string builtWatermarkOptionName(...$arguments)
66 * @method int bumpMutationWatermark(...$arguments)
67 * @method int bumpStageNoProgressStreak(...$arguments)
68 * @method string capturedPrefixForLog(...$arguments)
69 * @method void capturePrefixAtBuildStart(...$arguments)
70 * @method void claimForegroundViewBuildLease(...$arguments)
71 * @method string classifyAndHandleStageFailure(...$arguments)
72 * @method array<mixed> classifySessionVariableWarnings(...$arguments)
73 * @method string classifyStageFailure(...$arguments)
74 * @method void clearActiveBuildStartedWatermark(...$arguments)
75 * @method void clearAdminMutationGateOptions(...$arguments)
76 * @method void clearAllProgressOptions(...$arguments)
77 * @method void clearPhpEnvironmentProbeCache(...$arguments)
78 * @method void clearPrefixAtStageOne(...$arguments)
79 * @method void clearSessionVariablesProbeCache(...$arguments)
80 * @method void clearSqlModeProbeCache(...$arguments)
81 * @method void clearStagedBuildDegradedState(...$arguments)
82 * @method void clearViewBuildOpenStageForShutdown(...$arguments)
83 * @method void clearViewDoneHardStaleNotice(...$arguments)
84 * @method ABJ_404_Solution_Clock clock(...$arguments)
85 * @method int countLiveRedirects(...$arguments)
86 * @method int countViewBuildRows(...$arguments)
87 * @method string describeBuildProgressForNotice(...$arguments)
88 * @method string describeDegradedNotice(...$arguments)
89 * @method string describeStagedSqlFailure(...$arguments)
90 * @method array<mixed> detectAndAdjustSqlMode(...$arguments)
91 * @method float detectHostStagedQueryLimitSeconds(...$arguments)
92 * @method string doTableNameReplacements(...$arguments)
93 * @method void dropDeletemeTable(...$arguments)
94 * @method void dropTransientBuffersIfPresent(...$arguments)
95 * @method void dropTransientStagedTables(...$arguments)
96 * @method void ensureConnection(...$arguments)
97 * @method void ensureFallbackLockNoticeAndLog(...$arguments)
98 * @method int extendedTimeoutForKilledNonBatchedStage(...$arguments)
99 * @method array<mixed> fetchSessionVariablesRowOrEmpty(...$arguments)
100 * @method string filesystemEnvironmentProbeOptionName(...$arguments)
101 * @method bool forceRestartViewBuild(...$arguments)
102 * @method bool foregroundViewBuildLeaseActive(...$arguments)
103 * @method string formatPhpMemoryBytesHuman(...$arguments)
104 * @method bool gateAbortIfMutationWatermarkAdvanced(...$arguments)
105 * @method string getColumnCollationString(...$arguments)
106 * @method int getCronStuckHours(...$arguments)
107 * @method string getLowercasePrefix(...$arguments)
108 * @method array<string, mixed> getViewBuildProgress(...$arguments)
109 * @method array<mixed> getViewBuildProgressFingerprint(...$arguments)
110 * @method int getViewDoneBuiltAtTimestamp(...$arguments)
111 * @method bool haltIfPrefixChangedSinceStageOne(...$arguments)
112 * @method string humanBatchProgress(...$arguments)
113 * @method float intelligentStagedQueryTimeoutSeconds(...$arguments)
114 * @method void invalidateViewDoneServeableCache(...$arguments)
115 * @method bool isBuildHaltedForHostFailure(...$arguments)
116 * @method bool isCurrentStageOptionName(...$arguments)
117 * @method bool isNamedLockUnsupportedError(...$arguments)
118 * @method bool isResumableStagedKill(...$arguments)
119 * @method bool isStageMarkedSkipped(...$arguments)
120 * @method bool isTransientConnectionError(...$arguments)
121 * @method string lastBuildStartedWatermarkOptionName(...$arguments)
122 * @method string legacyStartedWatermarkOptionName(...$arguments)
123 * @method string localizeOrDefaultViewBuildNotice(...$arguments)
124 * @method bool logsHitsTableExists(...$arguments)
125 * @method void logTimedViewBuildStage(...$arguments)
126 * @method void logViewBuildProgressOptionWrite(...$arguments)
127 * @method void logViewBuildShutdownDiagnostics(...$arguments)
128 * @method void markBuildHaltedForHostFailure(...$arguments)
129 * @method void markBuildStage(...$arguments)
130 * @method void markStageSkippedForHostFailure(...$arguments)
131 * @method void markViewBuildStageCompleted(...$arguments)
132 * @method void markViewBuildStageStarted(...$arguments)
133 * @method void markViewDoneBuildCompleted(...$arguments)
134 * @method void markViewDoneInvalidatedByAdminMutation(...$arguments)
135 * @method int maxBuildBufferId(...$arguments)
136 * @method void maybeRaiseViewDoneHardStaleNotice(...$arguments)
137 * @method bool mutationWatermarkAdvancedSinceBuildStart(...$arguments)
138 * @method int mutationWatermarkObservedByAdminAction(...$arguments)
139 * @method int mutationWatermarkObservedByAdminActionAt(...$arguments)
140 * @method string mutationWatermarkObservedByAdminActionAtOptionName(...$arguments)
141 * @method string mutationWatermarkObservedByAdminActionOptionName(...$arguments)
142 * @method string normalizePathPrefix(...$arguments)
143 * @method bool optionReadBackMatches(...$arguments)
144 * @method int parsePhpMemoryLimitToBytes(...$arguments)
145 * @method bool pathFallsWithinAny(...$arguments)
146 * @method void performFreshStartCleanup(...$arguments)
147 * @method array<mixed> phpDisabledFunctionsList(...$arguments)
148 * @method string phpEnvironmentProbeOptionName(...$arguments)
149 * @method float phpTimeRemainingSeconds(...$arguments)
150 * @method string prefixAtStageOneOptionName(...$arguments)
151 * @method array<mixed> probeFilesystemEnvironmentForBuild(...$arguments)
152 * @method float probeFloatFromValues(...$arguments)
153 * @method int probeIntFromValues(...$arguments)
154 * @method int probeMemoryLimitForS9(...$arguments)
155 * @method array<mixed> probePhpEnvironmentForBuild(...$arguments)
156 * @method array<mixed> probeSessionVariablesAtS1Entry(...$arguments)
157 * @method bool probeSetTimeLimitAvailability(...$arguments)
158 * @method array<mixed> probeSqlModeForBuild(...$arguments)
159 * @method string probeStringFromValues(...$arguments)
160 * @method string progressOptionName(...$arguments)
161 * @method void publishBuiltWatermarkFromActiveBuildStartedWatermark(...$arguments)
162 * @method array<mixed> queryAndGetResults(...$arguments)
163 * @method int readActiveBuildStartedWatermark(...$arguments)
164 * @method array<int, array<string, mixed>> readFromViewDone(...$arguments)
165 * @method int readProgressOption(...$arguments)
166 * @method int readWatermarkOption(...$arguments)
167 * @method void rebuildViewDoneInBackground(...$arguments)
168 * @method bool reconcilePostStageElevenState(...$arguments)
169 * @method string reconcileStagedTablesAtRunnerStartup(...$arguments)
170 * @method int recordStageBatchKilled(...$arguments)
171 * @method void registerViewBuildShutdownDiagnostics(...$arguments)
172 * @method bool releaseAndReacquireBetweenStages(...$arguments)
173 * @method void releaseViewBuildLock(...$arguments)
174 * @method void resetStageNoProgressStreak(...$arguments)
175 * @method string resolveColumnCollationForStagedBuild(...$arguments)
176 * @method void runForceRestartCleanupInsideLock(...$arguments)
177 * @method bool runIdRangeBatchedUpdate(...$arguments)
178 * @method int runInsertBatch(...$arguments)
179 * @method mixed runNonBatchedStageWithKillStreakEscape(...$arguments)
180 * @method array{ran: bool, reason: string, progress: array<string, mixed>} runPageLoadFallbackAdvance(...$arguments)
181 * @method int runRedirectsForViewCountStaged(...$arguments)
182 * @method array<int, array<string, mixed>> runRedirectsForViewStaged(...$arguments)
183 * @method bool runS11SwapWithPreRenameWatermarkRecheck(...$arguments)
184 * @method bool runStagedBuildOnce(...$arguments)
185 * @method bool runStagedBuildStages6Through11(...$arguments)
186 * @method void runStagedSqlFile(...$arguments)
187 * @method void runStagedSqlFileTolerantOfDuplicateKey(...$arguments)
188 * @method mixed runTimedViewBuildStage(...$arguments)
189 * @method int safeCurrentMutationWatermark(...$arguments)
190 * @method string sanitizeUrlBeforeInsert(...$arguments)
191 * @method void scheduleViewDoneRebuild(...$arguments)
192 * @method string sessionVariablesProbeOptionName(...$arguments)
193 * @method void setFilesystemEnvAdminNotice(...$arguments)
194 * @method void setLowMemoryLimitAdminNotice(...$arguments)
195 * @method void setSessionEnvAdminNotice(...$arguments)
196 * @method void setStagedBuildDegradedNotice(...$arguments)
197 * @method void setStagedBuildHaltNotice(...$arguments)
198 * @method void setViewBuildCronStuckNotice(...$arguments)
199 * @method void setViewBuildScheduleFailedNotice(...$arguments)
200 * @method void setViewDoneHardStaleNotice(...$arguments)
201 * @method array<mixed> splitOpenBasedirPaths(...$arguments)
202 * @method string sqlModeProbeOptionName(...$arguments)
203 * @method void stageAddPreJoinIndexes(...$arguments)
204 * @method void stageAddSortIndexes(...$arguments)
205 * @method void stageCreateBuildTable(...$arguments)
206 * @method array<string, mixed> stagedQueryOptions(...$arguments)
207 * @method bool stagedTableExists(...$arguments)
208 * @method bool stageInsertRedirectsBatched(...$arguments)
209 * @method string stageNoProgressStreakOptionName(...$arguments)
210 * @method void stageRenameSwap(...$arguments)
211 * @method string stageSkipOptionName(...$arguments)
212 * @method void stageUpdateExternal(...$arguments)
213 * @method void stageUpdateHits(...$arguments)
214 * @method void stageUpdateHome(...$arguments)
215 * @method bool stageUpdatePostsBatched(...$arguments)
216 * @method void stageUpdateSpecial(...$arguments)
217 * @method bool stageUpdateTermsBatched(...$arguments)
218 * @method void stampStartedWatermarksAtS1Entry(...$arguments)
219 * @method void sweepStaleRebuildTransients(...$arguments)
220 * @method string transientFallbackLockOptionName(...$arguments)
221 * @method bool verifyBuildLockSerializesWriter(...$arguments)
222 * @method bool verifyOptionWriteCoherent(...$arguments)
223 * @method bool verifyPrefixUnchangedSinceStageOne(...$arguments)
224 * @method int viewBuildBatchSize(...$arguments)
225 * @method int viewBuildBatchSizeForStage(...$arguments)
226 * @method array<mixed> viewBuildOnlyTranslations(...$arguments)
227 * @method float viewBuildPerStageBudgetSeconds(...$arguments)
228 * @method string viewBuildTableName(...$arguments)
229 * @method string viewDeletemeTableName(...$arguments)
230 * @method int viewDoneBuiltAt(...$arguments)
231 * @method int viewDoneBuiltWatermark(...$arguments)
232 * @method int viewDoneDataBuiltAt(...$arguments)
233 * @method string viewDoneDataBuiltAtOptionName(...$arguments)
234 * @method string viewDoneFreshnessOptionName(...$arguments)
235 * @method bool viewDoneHasRows(...$arguments)
236 * @method bool viewDoneIsFresh(...$arguments)
237 * @method bool viewDoneIsServeable(...$arguments)
238 * @method int viewDoneMutationInvalidatedAt(...$arguments)
239 * @method string viewDoneMutationInvalidatedAtOptionName(...$arguments)
240 * @method bool viewDoneTableExists(...$arguments)
241 * @method string viewDoneTableName(...$arguments)
242 * @method void writeProgressOption(...$arguments)
243 * @method void writeWatermarkOption(...$arguments)
244 */
245 class ABJ_404_Solution_ViewBuildStageCallbacks extends ABJ_404_Solution_ViewBuildCollaborator {
246
247 /** Drop both the build buffer and the leftover deleteme. Used on fresh-start only. */
248 public function dropTransientStagedTables(): void {
249 $buildTempTable = $this->viewBuildTableName();
250 $deletemeTempTable = $this->viewDeletemeTableName();
251 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $buildTempTable . '`',
252 array('log_errors' => false));
253 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
254 array('log_errors' => false));
255 }
256
257 /** Drop only the deleteme leftover from a prior crashed RENAME swap. */
258 public function dropDeletemeTable(): void {
259 $deletemeTempTable = $this->viewDeletemeTableName();
260 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
261 array('log_errors' => false));
262 }
263
264 /**
265 * Drop view_build / view_deleteme only if either exists on disk. Gated by
266 * SHOW TABLES so a steady-state invalidate (no buffer present, the common
267 * case for redirect-edit invalidations) does not pile DROP IF EXISTS DDL
268 * on the hot path. Called from the runner-owned force-rebuild primitive
269 * (see DataAccessTrait_ViewBuildForceRestart) so the buffer drop is
270 * atomic with the progress-option clear.
271 *
272 * @return void
273 */
274 public function dropTransientBuffersIfPresent(): void {
275 $buildTempTable = $this->viewBuildTableName();
276 $deletemeTempTable = $this->viewDeletemeTableName();
277 if ($this->stagedTableExists($buildTempTable)) {
278 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $buildTempTable . '`',
279 array('log_errors' => false));
280 }
281 if ($this->stagedTableExists($deletemeTempTable)) {
282 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
283 array('log_errors' => false));
284 }
285 }
286
287 /**
288 * S1: create the build buffer. Tries the system default storage
289 * engine, then falls back to MyISAM, then to InnoDB so it works on
290 * hosts that disable one or the other.
291 *
292 * @return void
293 */
294 public function stageCreateBuildTable(): void {
295 $template = ABJ_404_Solution_Functions::readFileContents(__DIR__ . '/sql/createViewBuildTable.sql');
296 $base = $this->doTableNameReplacements(is_string($template) ? $template : '');
297 if (trim($base) === '') {
298 throw new \Exception('createViewBuildTable.sql is empty or unreadable.');
299 }
300
301 $attempts = array(
302 'default' => $base,
303 'MyISAM' => $base . ' ENGINE=MyISAM',
304 'InnoDB' => $base . ' ENGINE=InnoDB',
305 );
306 $lastError = '';
307 $errorsSoFar = array();
308 $opts = $this->stagedQueryOptions();
309 $opts['log_errors'] = false;
310 foreach ($attempts as $engineLabel => $sql) {
311 $attemptStarted = microtime(true);
312 $this->logger->debugMessage(sprintf(
313 '[staged] S1 createViewBuildTable attempt starting: engine=%s',
314 $engineLabel
315 ));
316 $result = $this->queryAndGetResults($sql, $opts);
317 $err = isset($result['last_error']) && is_string($result['last_error'])
318 ? trim($result['last_error']) : '';
319 $timedOut = !empty($result['timed_out']);
320 $elapsedMs = (int)round((microtime(true) - $attemptStarted) * 1000);
321 $this->logger->debugMessage(sprintf(
322 '[staged] S1 createViewBuildTable attempt finished: engine=%s elapsed_ms=%d timed_out=%s last_error=%s',
323 $engineLabel,
324 $elapsedMs,
325 $timedOut ? 'true' : 'false',
326 $err !== '' ? substr($err, 0, 240) : 'none'
327 ));
328 if ($err === '' && !$timedOut) {
329 if ($engineLabel !== 'default') {
330 // Default engine failed but a fallback won. Worth knowing
331 // because hosts that need a fallback often have other
332 // engine-specific quirks downstream (lock waits, ALTER
333 // semantics, etc.).
334 $this->logger->warn(sprintf(
335 '[staged] S1 createViewBuildTable: default engine '
336 . 'failed (%s); succeeded on fallback %s.',
337 substr(implode('; ', $errorsSoFar), 0, 200),
338 $engineLabel
339 ));
340 }
341 return;
342 }
343 $lastError = $err !== '' ? $err : 'unknown';
344 $errorsSoFar[] = $engineLabel . ': ' . $lastError;
345 }
346 throw new \Exception('Could not create view build table on any storage engine: ' . $lastError);
347 }
348
349 /**
350 * S2: bulk-load redirects into the build buffer, in resumable batches.
351 *
352 * Source of truth for the high-water is `MAX(id)` of the build buffer
353 * itself; option `s2_high_water` is written for diagnostics / visibility
354 * but is never consulted. Using buffer MAX(id) directly makes resumption
355 * crash-safe: if PHP dies between an INSERT and the option write, the
356 * next request still picks up from exactly where the INSERT left off.
357 *
358 * Each batch INSERTs the next BATCH_SIZE rows from wp_abj404_redirects
359 * with `id > <buffer MAX(id)>`. Per-stage budget caps wall-clock time
360 * so the request can finish even when the dataset is too big to copy in
361 * one shot.
362 *
363 * @return bool true when the entire redirects table has been copied;
364 * false when the per-stage budget was exhausted mid-stage.
365 */
366 public function stageInsertRedirectsBatched(): bool {
367 $this->markBuildStage('staged_build_s2_insert');
368 $deadline = microtime(true) + $this->viewBuildPerStageBudgetSeconds();
369 // Pre-flight check uses the SQL hint (smaller than the wall-clock
370 // budget by design), not the budget itself. This is the worst-case
371 // time a single batch can take before SET STATEMENT max_statement_time
372 // fires. The budget is a loop-level wall clock; a single batch never
373 // takes a full budget to run.
374 $perQueryLimit = max(1.0, (float)$this->intelligentStagedQueryTimeoutSeconds());
375
376 $totalCount = $this->countLiveRedirects();
377 if ($totalCount <= 0) {
378 // Empty redirects table; nothing to copy.
379 $this->writeProgressOption('s2_high_water', 0);
380 return true;
381 }
382
383 $batchNumber = 0;
384 while (true) {
385 $copiedSoFar = $this->countViewBuildRows();
386 if ($copiedSoFar >= $totalCount) {
387 break; // covered the table
388 }
389 // Wall-clock yield (Path A): per-stage budget exhausted. NOT a
390 // batch-size problem; do not shrink.
391 if (microtime(true) >= $deadline) {
392 $this->markBuildStage('staged_build_s2_insert',
393 'batch ' . $this->humanBatchProgress($copiedSoFar, $totalCount) . ' (yielded)');
394 return false;
395 }
396 // Pre-flight: only start a batch when the request has enough PHP
397 // time left to finish it at our SQL hint. Without this, a batch
398 // we started with too little time would get killed mid-flight by
399 // PHP's max_execution_time and we could not safely tell whether
400 // the kill was a real batch-too-big problem or just request-time
401 // exhaustion. Yield without shrinking.
402 //
403 // Always allow the first batch of a tick to run, even when PHP
404 // time looks tight: phpTimeRemainingSeconds() reflects the time
405 // left at the START of the stage, which on a typical 30s shared
406 // host is already below the SQL hint after WP boot. Without this
407 // first-batch escape, the build would yield on every request
408 // without ever inserting a row -- exactly the "stuck at stage
409 // 1/11" symptom that stranded large-site installs.
410 if ($batchNumber > 0 && $this->phpTimeRemainingSeconds() < $perQueryLimit + 1.0) {
411 $this->markBuildStage('staged_build_s2_insert',
412 'batch ' . $this->humanBatchProgress($copiedSoFar, $totalCount) . ' (yielded; tight time)');
413 return false;
414 }
415
416 $batchSize = $this->viewBuildBatchSizeForStage('s2_batch_size');
417 $batchNumber++;
418 $loBound = $this->maxBuildBufferId();
419 $beforeMax = $loBound;
420 try {
421 // Public extension point. Sites hook this for per-batch
422 // telemetry; tests bind a callback that throws to simulate
423 // a host kill. Inside the try/catch so a hook-thrown
424 // resumable error is handled exactly the same way as a
425 // real kill from the SQL call below.
426 if (function_exists('do_action')) {
427 do_action('abj404_view_build_batch_starting', 's2_insert', $batchNumber, $batchSize);
428 }
429 $afterMax = $this->runInsertBatch($loBound, $batchSize);
430 } catch (\Throwable $e) {
431 if ($this->isResumableStagedKill($e->getMessage())) {
432 // Path B: batch genuinely too big at the host limit.
433 // Halve, persist, yield. Next tick uses smaller size.
434 $newSize = $this->recordStageBatchKilled('s2_batch_size');
435 $this->logger->warn(sprintf(
436 '[staged] S2 batch killed by host at size %d; '
437 . 'shrunk s2_batch_size to %d. Trigger: %s',
438 $batchSize, $newSize, substr($e->getMessage(), 0, 200)
439 ));
440 $this->markBuildStage('staged_build_s2_insert',
441 'batch killed at size ' . $batchSize
442 . '; shrunk to ' . $newSize . ', yielded');
443 return false;
444 }
445 throw $e;
446 }
447 if ($afterMax === $beforeMax) {
448 // Distinguish "no new rows to copy" from "buffer missing".
449 // Without this check, a missing view_build looks identical
450 // to a real shrink-during-build because maxBuildBufferId
451 // returns 0 in both cases. The missing-buffer scenario is a
452 // pipeline corruption that must halt, not silently mark
453 // S2 complete. See Pattern 13.
454 if (!$this->stagedTableExists($this->viewBuildTableName())) {
455 throw new \Exception(
456 'Staged view-build buffer missing during S2 INSERT; '
457 . 'pipeline state diverged from disk. Halting stage.'
458 );
459 }
460 // No rows above $loBound to copy. Either the redirects table
461 // shrank during the build, or all remaining ids are <= loBound
462 // (impossible given strict id-range semantics, but defensive).
463 // Treat as done; the read query will reflect whatever was
464 // captured.
465 $this->logger->warn(sprintf(
466 '[staged] S2 stopping early: INSERT batch did not advance '
467 . 'MAX(id) (loBound=%d, beforeMax=%d, afterMax=%d, '
468 . 'copiedSoFar=%d, totalCount=%d). Treating as done.',
469 $loBound, $beforeMax, $afterMax, $copiedSoFar, $totalCount
470 ));
471 break;
472 }
473 // Mirror MAX(id) into the option for diagnostics. This is
474 // best-effort; correctness does NOT depend on this write.
475 $this->writeProgressOption('s2_high_water', $afterMax);
476
477 $this->markBuildStage('staged_build_s2_insert',
478 'batch ' . $this->humanBatchProgress($this->countViewBuildRows(), $totalCount));
479 }
480
481 $this->writeProgressOption('s2_high_water', 0);
482 return true;
483 }
484
485 /** @return void */
486 public function stageAddPreJoinIndexes(): void {
487 // S3 indexes are added with IF NOT EXISTS semantics emulated by
488 // catching "Duplicate key name" on retry. See runStagedSqlFile
489 // tolerance below. ALTER TABLE itself is fast on the buffer.
490 $this->assertBuildBufferExistsOrHalt('S3 stageAddPreJoinIndexes');
491 $this->runStagedSqlFileTolerantOfDuplicateKey('03_index_fd.sql', array());
492 }
493
494 /**
495 * S4: resolve POST-typed redirects against wp_posts in resumable batches
496 * keyed by view_build.id range.
497 *
498 * @return bool true when stage completed; false when budget exhausted.
499 */
500 public function stageUpdatePostsBatched(): bool {
501 return $this->runIdRangeBatchedUpdate(
502 'staged_build_s4_update_posts',
503 's4_high_water',
504 '04_update_posts.sql'
505 );
506 }
507
508 /**
509 * S5: resolve CAT/TAG-typed redirects against wp_terms in resumable
510 * batches keyed by view_build.id range.
511 *
512 * @return bool true when stage completed; false when budget exhausted.
513 */
514 public function stageUpdateTermsBatched(): bool {
515 return $this->runIdRangeBatchedUpdate(
516 'staged_build_s5_update_terms',
517 's5_high_water',
518 '05_update_terms.sql'
519 );
520 }
521
522 /** @return void */
523 public function stageUpdateHome(): void {
524 $this->assertBuildBufferExistsOrHalt('S6 stageUpdateHome');
525 $this->runStagedSqlFile('06_update_home.sql', array());
526 }
527
528 /** @return void */
529 public function stageUpdateExternal(): void {
530 $this->assertBuildBufferExistsOrHalt('S7 stageUpdateExternal');
531 $this->runStagedSqlFile('07_update_external.sql', array());
532 }
533
534 /** @return void */
535 public function stageUpdateSpecial(): void {
536 $this->assertBuildBufferExistsOrHalt('S8 stageUpdateSpecial');
537 $this->runStagedSqlFile('08_update_special.sql', $this->viewBuildOnlyTranslations());
538 }
539
540 /** @return void */
541 public function stageUpdateHits(): void {
542 $this->assertBuildBufferExistsOrHalt('S9 stageUpdateHits');
543 $s9Collation = $this->resolveColumnCollationForStagedBuild();
544 $collationExtra = array('{S9_COLLATION}' => $s9Collation);
545 $this->runStagedSqlFile('09a_drop_hits_temp.sql', array());
546 $this->runStagedSqlFile('09b_create_hits_temp.sql', $collationExtra);
547 $this->runStagedSqlFile('09c_insert_hits_temp.sql', array());
548 $this->runStagedSqlFile('09_update_hits.sql', $collationExtra);
549 $this->runStagedSqlFile('09a_drop_hits_temp.sql', array());
550 }
551
552 /** @return void */
553 public function stageAddSortIndexes(): void {
554 $this->assertBuildBufferExistsOrHalt('S10 stageAddSortIndexes');
555 $this->runStagedSqlFileTolerantOfDuplicateKey('10_index_sort.sql', array());
556 }
557
558 /**
559 * Run one INSERT batch for S2. Inserts the next BATCH_SIZE rows from
560 * wp_abj404_redirects with `id > $loBound` (ORDER BY id ASC LIMIT
561 * BATCH_SIZE) into the build buffer. Returns the new MAX(id) of the
562 * buffer so the caller can detect "no more rows" (buffer max didn't
563 * change after the insert).
564 *
565 * @param int $loBound MAX(id) of the buffer at batch start.
566 * @param int $batchSize
567 * @return int New MAX(id) of the buffer after this batch (== $loBound
568 * when no rows were inserted).
569 */
570 public function runInsertBatch(int $loBound, int $batchSize): int {
571 $loBound = max(0, intval($loBound));
572 $batchSize = max(1, intval($batchSize));
573
574 $extra = $this->viewBuildOnlyTranslations();
575 $extra['{LO_BOUND}'] = (string)$loBound;
576 $extra['{BATCH_SIZE}'] = (string)$batchSize;
577 $this->runStagedSqlFile('02_insert.sql', $extra);
578
579 return $this->maxBuildBufferId();
580 }
581
582 /**
583 * Run an UPDATE-JOIN stage in id-range batches against the build buffer.
584 *
585 * The SQL fragment must use `WHERE t.id > {LO_BOUND} AND t.id <= {HI_BOUND}`
586 * (the staged 04/05 SQL files do this once converted) so we can stride
587 * forward by id without a per-batch COUNT.
588 *
589 * @param string $stageKey Sub-stage label, e.g. 'staged_build_s4_update_posts'.
590 * @param string $highWaterKey Progress option key, e.g. 's4_high_water'.
591 * @param string $sqlFile Filename under sql/getRedirectsForViewStaged/.
592 * @return bool true when stage completed; false when budget exhausted.
593 */
594 public function runIdRangeBatchedUpdate(string $stageKey, string $highWaterKey, string $sqlFile): bool {
595 $this->markBuildStage($stageKey);
596 $deadline = microtime(true) + $this->viewBuildPerStageBudgetSeconds();
597 $perQueryLimit = max(1.0, (float)$this->intelligentStagedQueryTimeoutSeconds());
598 // s4_high_water -> s4_batch_size; s5_high_water -> s5_batch_size.
599 $batchSizeKey = str_replace('_high_water', '_batch_size', $highWaterKey);
600
601 $highWater = $this->readProgressOption($highWaterKey, 0);
602 $totalMaxId = $this->maxBuildBufferId();
603 if ($totalMaxId <= 0) {
604 // Distinguish "buffer is empty" (legitimate: no redirects on
605 // the site) from "buffer is missing" (pipeline corruption:
606 // concurrent invalidateViewDone dropped view_build between
607 // S1 and here, S1 silently approved without executing, or
608 // switch_to_blog moved us off the schema where S1 created it).
609 // The former is fine to mark complete; the latter must halt
610 // and let the orchestrator restart cleanly on the next tick.
611 // Without this check, maxBuildBufferId's 0 return shadows the
612 // real error after queryAndGetResults swallows the missing-
613 // table error string. See Pattern 13 in
614 // docs/PROACTIVE_BUG_DISCOVERY.md.
615 if (!$this->stagedTableExists($this->viewBuildTableName())) {
616 throw new \Exception(sprintf(
617 'Staged view-build buffer missing at %s entry; pipeline state '
618 . 'diverged from disk. Halting stage.',
619 $stageKey
620 ));
621 }
622 // Buffer is empty (no redirects). Nothing to update.
623 $this->writeProgressOption($highWaterKey, 0);
624 return true;
625 }
626
627 $batchNumber = 0;
628 while ($highWater < $totalMaxId) {
629 // Wall-clock yield (Path A); not a batch-size problem.
630 if (microtime(true) >= $deadline) {
631 $this->markBuildStage($stageKey,
632 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId) . ' (yielded)');
633 return false;
634 }
635 // Pre-flight: yield without shrinking when there is not enough
636 // PHP request time left to finish a batch at the SQL hint. Same
637 // rationale as in stageInsertRedirectsBatched, including the
638 // first-batch escape so a tight-PHP-time request still makes
639 // forward progress instead of yielding indefinitely.
640 if ($batchNumber > 0 && $this->phpTimeRemainingSeconds() < $perQueryLimit + 1.0) {
641 $this->markBuildStage($stageKey,
642 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId) . ' (yielded; tight time)');
643 return false;
644 }
645
646 $batchSize = $this->viewBuildBatchSizeForStage($batchSizeKey);
647 $batchNumber++;
648 $hiBound = min($totalMaxId, $highWater + $batchSize);
649 $extra = array(
650 '{LO_BOUND}' => (string)$highWater,
651 '{HI_BOUND}' => (string)$hiBound,
652 );
653 try {
654 if (function_exists('do_action')) {
655 do_action('abj404_view_build_batch_starting', $stageKey, $batchNumber, $batchSize);
656 }
657 $this->runStagedSqlFile($sqlFile, $extra);
658 } catch (\Throwable $e) {
659 if ($this->isResumableStagedKill($e->getMessage())) {
660 $newSize = $this->recordStageBatchKilled($batchSizeKey);
661 $this->logger->warn(sprintf(
662 '[staged] %s batch killed by host at size %d; '
663 . 'shrunk %s to %d. Trigger: %s',
664 $stageKey, $batchSize, $batchSizeKey, $newSize,
665 substr($e->getMessage(), 0, 200)
666 ));
667 $this->markBuildStage($stageKey,
668 'batch killed at size ' . $batchSize
669 . '; shrunk to ' . $newSize . ', yielded');
670 return false;
671 }
672 throw $e;
673 }
674 $highWater = $hiBound;
675 $this->writeProgressOption($highWaterKey, $highWater);
676
677 $this->markBuildStage($stageKey,
678 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId));
679 }
680
681 // Stage done; reset high-water for the next rebuild.
682 $this->writeProgressOption($highWaterKey, 0);
683 return true;
684 }
685
686 /** @return int Total active+inactive rows in wp_abj404_redirects. */
687 public function countLiveRedirects(): int {
688 $sql = 'SELECT COUNT(*) AS cnt FROM '
689 . $this->doTableNameReplacements('{wp_abj404_redirects}');
690 $result = $this->queryAndGetResults($sql, $this->stagedQueryOptions());
691 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
692 if (empty($rows) || !is_array($rows[0])) {
693 return 0;
694 }
695 $cnt = $rows[0]['cnt'] ?? 0;
696 return is_scalar($cnt) ? max(0, intval($cnt)) : 0;
697 }
698
699 /** @return int Rows currently in the build buffer. */
700 public function countViewBuildRows(): int {
701 $sql = 'SELECT COUNT(*) AS cnt FROM '
702 . $this->doTableNameReplacements('{wp_abj404_view_build}');
703 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
704 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
705 if (empty($rows) || !is_array($rows[0])) {
706 return 0;
707 }
708 $cnt = $rows[0]['cnt'] ?? 0;
709 return is_scalar($cnt) ? max(0, intval($cnt)) : 0;
710 }
711
712 /** @return int Max(id) in the build buffer, 0 when empty. */
713 public function maxBuildBufferId(): int {
714 $sql = 'SELECT MAX(id) AS max_id FROM '
715 . $this->doTableNameReplacements('{wp_abj404_view_build}');
716 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
717 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
718 if (empty($rows) || !is_array($rows[0])) {
719 return 0;
720 }
721 $rawMax = $rows[0]['max_id'] ?? null;
722 if ($rawMax === null || $rawMax === '') {
723 return 0;
724 }
725 return is_scalar($rawMax) ? max(0, intval($rawMax)) : 0;
726 }
727
728 /**
729 * @param int $done
730 * @param int $total
731 * @return string e.g. "12/45" or "complete" when done==total.
732 */
733 public function humanBatchProgress(int $done, int $total): string {
734 if ($total <= 0) {
735 return 'complete';
736 }
737 return min($done, $total) . '/' . $total;
738 }
739
740 /**
741 * S11: atomic RENAME TABLE swap. Build buffer becomes the new served
742 * table; the previous served table (if any) becomes deleteme and is
743 * dropped.
744 *
745 * @return void
746 */
747 public function stageRenameSwap(): void {
748 $this->assertBuildBufferExistsOrHalt('S11 stageRenameSwap');
749 $buildTempTable = $this->viewBuildTableName();
750 $done = $this->viewDoneTableName();
751 $deletemeTempTable = $this->viewDeletemeTableName();
752
753 // Defensive: ensure deleteme is gone before the swap (S0 already did
754 // this, but a poorly-timed parallel rebuild could have created it).
755 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
756 array('log_errors' => false));
757
758 if ($this->viewDoneTableExists()) {
759 $sql = 'RENAME TABLE `' . $done . '` TO `' . $deletemeTempTable . '`,'
760 . ' `' . $buildTempTable . '` TO `' . $done . '`';
761 } else {
762 $sql = 'RENAME TABLE `' . $buildTempTable . '` TO `' . $done . '`';
763 }
764
765 $result = $this->queryAndGetResults($sql, array('log_errors' => true));
766 $err = isset($result['last_error']) && is_string($result['last_error'])
767 ? trim($result['last_error']) : '';
768 if ($err !== '') {
769 throw new \Exception('RENAME TABLE swap failed: ' . $err);
770 }
771
772 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
773 array('log_errors' => false));
774 }
775
776 /**
777 * Pre-stage probe that halts the running stage cleanly when the
778 * view_build buffer is missing on disk. A concurrent invalidateViewDone()
779 * (redirect edit, plugin upgrade, correctCollations, daily maintenance)
780 * can call dropTransientBuffersIfPresent() and remove view_build between
781 * the start of a build tick and the next stage callback. Without this
782 * probe, the stage's first DDL/DML against view_build would hit
783 * queryAndGetResults, which logs the "Table doesn't exist" error at
784 * ERROR severity. The dispatcher then uploads it as a real bug report
785 * even though it is an expected concurrent-invalidate race (Pattern 13).
786 *
787 * Three production reports on 2026-05-16 (ids 9/10/11; plugin 4.1.18;
788 * sites greyleafmedia.com, myticas.com, p2p-game.com) traced to this
789 * shape at S3 (stageAddPreJoinIndexes) and S11 (stageRenameSwap). S2,
790 * S4, and S5 had bespoke inline guards already; this helper centralizes
791 * the same shape so every stage that touches view_build can opt in by
792 * adding one line at the top of its callback.
793 *
794 * The exception text MUST begin with "Staged view-build buffer missing"
795 * so the orchestrator's catch (runTimedViewBuildStage -> classifyStage-
796 * Failure in DataAccessTrait_ErrorClassification.php) recognizes the
797 * marker and routes the failure to 'resumable' -> resumable_yield ->
798 * orchestrator returns false. The next tick reads progress options and
799 * either restarts from S0 (if invalidateViewDone cleared them, the
800 * normal case) or fails the same check again until floor_kill_streak
801 * trips a clean halt notice.
802 *
803 * Warn-level severity is the correct choice per CLAUDE.md ยง8
804 * ("Infrastructure errors are warnings, not bugs -- unless the plugin
805 * can't function"): the build can recover by restarting on the next
806 * tick, so it remains functional. The warn line is still visible in
807 * debug.log for diagnosis; it just does not trigger the ERROR-level
808 * dispatcher upload path.
809 *
810 * @param string $stageLabel Human-readable stage tag included in the
811 * warn line and exception message so the
812 * failure can be attributed to the exact
813 * stage callback that detected the race.
814 * @throws \Exception Always when the buffer is missing; never throws
815 * when the buffer is present (silent no-op happy
816 * path).
817 * @return void
818 */
819 public function assertBuildBufferExistsOrHalt(string $stageLabel): void {
820 if ($this->stagedTableExists($this->viewBuildTableName())) {
821 return;
822 }
823 $this->logger->warn(sprintf(
824 '[staged] %s halting: view_build buffer missing on disk. A '
825 . 'concurrent invalidateViewDone() drop is the expected cause '
826 . '(Pattern 13). Yielding stage; the next tick will rebuild '
827 . 'from S0 after progress options are cleared.',
828 $stageLabel
829 ));
830 throw new \Exception(sprintf(
831 'Staged view-build buffer missing at %s entry; pipeline state '
832 . 'diverged from disk. Halting stage for resume.',
833 $stageLabel
834 ));
835 }
836 }
837