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

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

629 lines 29.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 trait ABJ_404_Solution_DataAccess_ViewBuildStageCallbacksTrait {
40
41 /** Drop both the build buffer and the leftover deleteme. Used on fresh-start only. */
42 private function dropTransientStagedTables(): void {
43 $buildTempTable = $this->viewBuildTableName();
44 $deletemeTempTable = $this->viewDeletemeTableName();
45 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $buildTempTable . '`',
46 array('log_errors' => false));
47 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
48 array('log_errors' => false));
49 }
50
51 /** Drop only the deleteme leftover from a prior crashed RENAME swap. */
52 private function dropDeletemeTable(): void {
53 $deletemeTempTable = $this->viewDeletemeTableName();
54 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
55 array('log_errors' => false));
56 }
57
58 /**
59 * Drop view_build / view_deleteme only if either exists on disk. Gated by
60 * SHOW TABLES so a steady-state invalidate (no buffer present, the common
61 * case for redirect-edit invalidations) does not pile DROP IF EXISTS DDL
62 * on the hot path. Called from the runner-owned force-rebuild primitive
63 * (see DataAccessTrait_ViewBuildForceRestart) so the buffer drop is
64 * atomic with the progress-option clear.
65 *
66 * @return void
67 */
68 private function dropTransientBuffersIfPresent(): void {
69 $buildTempTable = $this->viewBuildTableName();
70 $deletemeTempTable = $this->viewDeletemeTableName();
71 if ($this->stagedTableExists($buildTempTable)) {
72 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $buildTempTable . '`',
73 array('log_errors' => false));
74 }
75 if ($this->stagedTableExists($deletemeTempTable)) {
76 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
77 array('log_errors' => false));
78 }
79 }
80
81 /**
82 * S1: create the build buffer. Tries the system default storage
83 * engine, then falls back to MyISAM, then to InnoDB so it works on
84 * hosts that disable one or the other.
85 *
86 * @return void
87 */
88 private function stageCreateBuildTable(): void {
89 $template = ABJ_404_Solution_Functions::readFileContents(__DIR__ . '/sql/createViewBuildTable.sql');
90 $base = $this->doTableNameReplacements(is_string($template) ? $template : '');
91 if (trim($base) === '') {
92 throw new \Exception('createViewBuildTable.sql is empty or unreadable.');
93 }
94
95 $attempts = array(
96 'default' => $base,
97 'MyISAM' => $base . ' ENGINE=MyISAM',
98 'InnoDB' => $base . ' ENGINE=InnoDB',
99 );
100 $lastError = '';
101 $errorsSoFar = array();
102 $opts = $this->stagedQueryOptions();
103 $opts['log_errors'] = false;
104 foreach ($attempts as $engineLabel => $sql) {
105 $attemptStarted = microtime(true);
106 $this->logger->debugMessage(sprintf(
107 '[staged] S1 createViewBuildTable attempt starting: engine=%s',
108 $engineLabel
109 ));
110 $result = $this->queryAndGetResults($sql, $opts);
111 $err = isset($result['last_error']) && is_string($result['last_error'])
112 ? trim($result['last_error']) : '';
113 $timedOut = !empty($result['timed_out']);
114 $elapsedMs = (int)round((microtime(true) - $attemptStarted) * 1000);
115 $this->logger->debugMessage(sprintf(
116 '[staged] S1 createViewBuildTable attempt finished: engine=%s elapsed_ms=%d timed_out=%s last_error=%s',
117 $engineLabel,
118 $elapsedMs,
119 $timedOut ? 'true' : 'false',
120 $err !== '' ? substr($err, 0, 240) : 'none'
121 ));
122 if ($err === '' && !$timedOut) {
123 if ($engineLabel !== 'default') {
124 // Default engine failed but a fallback won. Worth knowing
125 // because hosts that need a fallback often have other
126 // engine-specific quirks downstream (lock waits, ALTER
127 // semantics, etc.).
128 $this->logger->warn(sprintf(
129 '[staged] S1 createViewBuildTable: default engine '
130 . 'failed (%s); succeeded on fallback %s.',
131 substr(implode('; ', $errorsSoFar), 0, 200),
132 $engineLabel
133 ));
134 }
135 return;
136 }
137 $lastError = $err !== '' ? $err : 'unknown';
138 $errorsSoFar[] = $engineLabel . ': ' . $lastError;
139 }
140 throw new \Exception('Could not create view build table on any storage engine: ' . $lastError);
141 }
142
143 /**
144 * S2: bulk-load redirects into the build buffer, in resumable batches.
145 *
146 * Source of truth for the high-water is `MAX(id)` of the build buffer
147 * itself; option `s2_high_water` is written for diagnostics / visibility
148 * but is never consulted. Using buffer MAX(id) directly makes resumption
149 * crash-safe: if PHP dies between an INSERT and the option write, the
150 * next request still picks up from exactly where the INSERT left off.
151 *
152 * Each batch INSERTs the next BATCH_SIZE rows from wp_abj404_redirects
153 * with `id > <buffer MAX(id)>`. Per-stage budget caps wall-clock time
154 * so the request can finish even when the dataset is too big to copy in
155 * one shot.
156 *
157 * @return bool true when the entire redirects table has been copied;
158 * false when the per-stage budget was exhausted mid-stage.
159 */
160 private function stageInsertRedirectsBatched(): bool {
161 $this->markBuildStage('staged_build_s2_insert');
162 $deadline = microtime(true) + $this->viewBuildPerStageBudgetSeconds();
163 // Pre-flight check uses the SQL hint (smaller than the wall-clock
164 // budget by design), not the budget itself. This is the worst-case
165 // time a single batch can take before SET STATEMENT max_statement_time
166 // fires. The budget is a loop-level wall clock; a single batch never
167 // takes a full budget to run.
168 $perQueryLimit = max(1.0, (float)$this->intelligentStagedQueryTimeoutSeconds());
169
170 $totalCount = $this->countLiveRedirects();
171 if ($totalCount <= 0) {
172 // Empty redirects table; nothing to copy.
173 $this->writeProgressOption('s2_high_water', 0);
174 return true;
175 }
176
177 $batchNumber = 0;
178 while (true) {
179 $copiedSoFar = $this->countViewBuildRows();
180 if ($copiedSoFar >= $totalCount) {
181 break; // covered the table
182 }
183 // Wall-clock yield (Path A): per-stage budget exhausted. NOT a
184 // batch-size problem; do not shrink.
185 if (microtime(true) >= $deadline) {
186 $this->markBuildStage('staged_build_s2_insert',
187 'batch ' . $this->humanBatchProgress($copiedSoFar, $totalCount) . ' (yielded)');
188 return false;
189 }
190 // Pre-flight: only start a batch when the request has enough PHP
191 // time left to finish it at our SQL hint. Without this, a batch
192 // we started with too little time would get killed mid-flight by
193 // PHP's max_execution_time and we could not safely tell whether
194 // the kill was a real batch-too-big problem or just request-time
195 // exhaustion. Yield without shrinking.
196 //
197 // Always allow the first batch of a tick to run, even when PHP
198 // time looks tight: phpTimeRemainingSeconds() reflects the time
199 // left at the START of the stage, which on a typical 30s shared
200 // host is already below the SQL hint after WP boot. Without this
201 // first-batch escape, the build would yield on every request
202 // without ever inserting a row -- exactly the "stuck at stage
203 // 1/11" symptom that stranded large-site installs.
204 if ($batchNumber > 0 && $this->phpTimeRemainingSeconds() < $perQueryLimit + 1.0) {
205 $this->markBuildStage('staged_build_s2_insert',
206 'batch ' . $this->humanBatchProgress($copiedSoFar, $totalCount) . ' (yielded; tight time)');
207 return false;
208 }
209
210 $batchSize = $this->viewBuildBatchSizeForStage('s2_batch_size');
211 $batchNumber++;
212 $loBound = $this->maxBuildBufferId();
213 $beforeMax = $loBound;
214 try {
215 // Public extension point. Sites hook this for per-batch
216 // telemetry; tests bind a callback that throws to simulate
217 // a host kill. Inside the try/catch so a hook-thrown
218 // resumable error is handled exactly the same way as a
219 // real kill from the SQL call below.
220 if (function_exists('do_action')) {
221 do_action('abj404_view_build_batch_starting', 's2_insert', $batchNumber, $batchSize);
222 }
223 $afterMax = $this->runInsertBatch($loBound, $batchSize);
224 } catch (\Throwable $e) {
225 if ($this->isResumableStagedKill($e->getMessage())) {
226 // Path B: batch genuinely too big at the host limit.
227 // Halve, persist, yield. Next tick uses smaller size.
228 $newSize = $this->recordStageBatchKilled('s2_batch_size');
229 $this->logger->warn(sprintf(
230 '[staged] S2 batch killed by host at size %d; '
231 . 'shrunk s2_batch_size to %d. Trigger: %s',
232 $batchSize, $newSize, substr($e->getMessage(), 0, 200)
233 ));
234 $this->markBuildStage('staged_build_s2_insert',
235 'batch killed at size ' . $batchSize
236 . '; shrunk to ' . $newSize . ', yielded');
237 return false;
238 }
239 throw $e;
240 }
241 if ($afterMax === $beforeMax) {
242 // Distinguish "no new rows to copy" from "buffer missing".
243 // Without this check, a missing view_build looks identical
244 // to a real shrink-during-build because maxBuildBufferId
245 // returns 0 in both cases. The missing-buffer scenario is a
246 // pipeline corruption that must halt, not silently mark
247 // S2 complete. See Pattern 13.
248 if (!$this->stagedTableExists($this->viewBuildTableName())) {
249 throw new \Exception(
250 'Staged view-build buffer missing during S2 INSERT; '
251 . 'pipeline state diverged from disk. Halting stage.'
252 );
253 }
254 // No rows above $loBound to copy. Either the redirects table
255 // shrank during the build, or all remaining ids are <= loBound
256 // (impossible given strict id-range semantics, but defensive).
257 // Treat as done; the read query will reflect whatever was
258 // captured.
259 $this->logger->warn(sprintf(
260 '[staged] S2 stopping early: INSERT batch did not advance '
261 . 'MAX(id) (loBound=%d, beforeMax=%d, afterMax=%d, '
262 . 'copiedSoFar=%d, totalCount=%d). Treating as done.',
263 $loBound, $beforeMax, $afterMax, $copiedSoFar, $totalCount
264 ));
265 break;
266 }
267 // Mirror MAX(id) into the option for diagnostics. This is
268 // best-effort; correctness does NOT depend on this write.
269 $this->writeProgressOption('s2_high_water', $afterMax);
270
271 $this->markBuildStage('staged_build_s2_insert',
272 'batch ' . $this->humanBatchProgress($this->countViewBuildRows(), $totalCount));
273 }
274
275 $this->writeProgressOption('s2_high_water', 0);
276 return true;
277 }
278
279 /** @return void */
280 private function stageAddPreJoinIndexes(): void {
281 // S3 indexes are added with IF NOT EXISTS semantics emulated by
282 // catching "Duplicate key name" on retry. See runStagedSqlFile
283 // tolerance below. ALTER TABLE itself is fast on the buffer.
284 $this->assertBuildBufferExistsOrHalt('S3 stageAddPreJoinIndexes');
285 $this->runStagedSqlFileTolerantOfDuplicateKey('03_index_fd.sql', array());
286 }
287
288 /**
289 * S4: resolve POST-typed redirects against wp_posts in resumable batches
290 * keyed by view_build.id range.
291 *
292 * @return bool true when stage completed; false when budget exhausted.
293 */
294 private function stageUpdatePostsBatched(): bool {
295 return $this->runIdRangeBatchedUpdate(
296 'staged_build_s4_update_posts',
297 's4_high_water',
298 '04_update_posts.sql'
299 );
300 }
301
302 /**
303 * S5: resolve CAT/TAG-typed redirects against wp_terms in resumable
304 * batches keyed by view_build.id range.
305 *
306 * @return bool true when stage completed; false when budget exhausted.
307 */
308 private function stageUpdateTermsBatched(): bool {
309 return $this->runIdRangeBatchedUpdate(
310 'staged_build_s5_update_terms',
311 's5_high_water',
312 '05_update_terms.sql'
313 );
314 }
315
316 /** @return void */
317 private function stageUpdateHome(): void {
318 $this->assertBuildBufferExistsOrHalt('S6 stageUpdateHome');
319 $this->runStagedSqlFile('06_update_home.sql', array());
320 }
321
322 /** @return void */
323 private function stageUpdateExternal(): void {
324 $this->assertBuildBufferExistsOrHalt('S7 stageUpdateExternal');
325 $this->runStagedSqlFile('07_update_external.sql', array());
326 }
327
328 /** @return void */
329 private function stageUpdateSpecial(): void {
330 $this->assertBuildBufferExistsOrHalt('S8 stageUpdateSpecial');
331 $this->runStagedSqlFile('08_update_special.sql', $this->viewBuildOnlyTranslations());
332 }
333
334 /** @return void */
335 private function stageUpdateHits(): void {
336 $this->assertBuildBufferExistsOrHalt('S9 stageUpdateHits');
337 $this->runStagedSqlFile('09a_drop_hits_temp.sql', array());
338 $this->runStagedSqlFile('09b_create_hits_temp.sql', array());
339 $this->runStagedSqlFile('09c_insert_hits_temp.sql', array());
340 $this->runStagedSqlFile('09_update_hits.sql', array());
341 $this->runStagedSqlFile('09a_drop_hits_temp.sql', array());
342 }
343
344 /** @return void */
345 private function stageAddSortIndexes(): void {
346 $this->assertBuildBufferExistsOrHalt('S10 stageAddSortIndexes');
347 $this->runStagedSqlFileTolerantOfDuplicateKey('10_index_sort.sql', array());
348 }
349
350 /**
351 * Run one INSERT batch for S2. Inserts the next BATCH_SIZE rows from
352 * wp_abj404_redirects with `id > $loBound` (ORDER BY id ASC LIMIT
353 * BATCH_SIZE) into the build buffer. Returns the new MAX(id) of the
354 * buffer so the caller can detect "no more rows" (buffer max didn't
355 * change after the insert).
356 *
357 * @param int $loBound MAX(id) of the buffer at batch start.
358 * @param int $batchSize
359 * @return int New MAX(id) of the buffer after this batch (== $loBound
360 * when no rows were inserted).
361 */
362 private function runInsertBatch(int $loBound, int $batchSize): int {
363 $loBound = max(0, intval($loBound));
364 $batchSize = max(1, intval($batchSize));
365
366 $extra = $this->viewBuildOnlyTranslations();
367 $extra['{LO_BOUND}'] = (string)$loBound;
368 $extra['{BATCH_SIZE}'] = (string)$batchSize;
369 $this->runStagedSqlFile('02_insert.sql', $extra);
370
371 return $this->maxBuildBufferId();
372 }
373
374 /**
375 * Run an UPDATE-JOIN stage in id-range batches against the build buffer.
376 *
377 * The SQL fragment must use `WHERE t.id > {LO_BOUND} AND t.id <= {HI_BOUND}`
378 * (the staged 04/05 SQL files do this once converted) so we can stride
379 * forward by id without a per-batch COUNT.
380 *
381 * @param string $stageKey Sub-stage label, e.g. 'staged_build_s4_update_posts'.
382 * @param string $highWaterKey Progress option key, e.g. 's4_high_water'.
383 * @param string $sqlFile Filename under sql/getRedirectsForViewStaged/.
384 * @return bool true when stage completed; false when budget exhausted.
385 */
386 private function runIdRangeBatchedUpdate(string $stageKey, string $highWaterKey, string $sqlFile): bool {
387 $this->markBuildStage($stageKey);
388 $deadline = microtime(true) + $this->viewBuildPerStageBudgetSeconds();
389 $perQueryLimit = max(1.0, (float)$this->intelligentStagedQueryTimeoutSeconds());
390 // s4_high_water -> s4_batch_size; s5_high_water -> s5_batch_size.
391 $batchSizeKey = str_replace('_high_water', '_batch_size', $highWaterKey);
392
393 $highWater = $this->readProgressOption($highWaterKey, 0);
394 $totalMaxId = $this->maxBuildBufferId();
395 if ($totalMaxId <= 0) {
396 // Distinguish "buffer is empty" (legitimate: no redirects on
397 // the site) from "buffer is missing" (pipeline corruption:
398 // concurrent invalidateViewDone dropped view_build between
399 // S1 and here, S1 silently approved without executing, or
400 // switch_to_blog moved us off the schema where S1 created it).
401 // The former is fine to mark complete; the latter must halt
402 // and let the orchestrator restart cleanly on the next tick.
403 // Without this check, maxBuildBufferId's 0 return shadows the
404 // real error after queryAndGetResults swallows the missing-
405 // table error string. See Pattern 13 in
406 // docs/PROACTIVE_BUG_DISCOVERY.md.
407 if (!$this->stagedTableExists($this->viewBuildTableName())) {
408 throw new \Exception(sprintf(
409 'Staged view-build buffer missing at %s entry; pipeline state '
410 . 'diverged from disk. Halting stage.',
411 $stageKey
412 ));
413 }
414 // Buffer is empty (no redirects). Nothing to update.
415 $this->writeProgressOption($highWaterKey, 0);
416 return true;
417 }
418
419 $batchNumber = 0;
420 while ($highWater < $totalMaxId) {
421 // Wall-clock yield (Path A); not a batch-size problem.
422 if (microtime(true) >= $deadline) {
423 $this->markBuildStage($stageKey,
424 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId) . ' (yielded)');
425 return false;
426 }
427 // Pre-flight: yield without shrinking when there is not enough
428 // PHP request time left to finish a batch at the SQL hint. Same
429 // rationale as in stageInsertRedirectsBatched, including the
430 // first-batch escape so a tight-PHP-time request still makes
431 // forward progress instead of yielding indefinitely.
432 if ($batchNumber > 0 && $this->phpTimeRemainingSeconds() < $perQueryLimit + 1.0) {
433 $this->markBuildStage($stageKey,
434 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId) . ' (yielded; tight time)');
435 return false;
436 }
437
438 $batchSize = $this->viewBuildBatchSizeForStage($batchSizeKey);
439 $batchNumber++;
440 $hiBound = min($totalMaxId, $highWater + $batchSize);
441 $extra = array(
442 '{LO_BOUND}' => (string)$highWater,
443 '{HI_BOUND}' => (string)$hiBound,
444 );
445 try {
446 if (function_exists('do_action')) {
447 do_action('abj404_view_build_batch_starting', $stageKey, $batchNumber, $batchSize);
448 }
449 $this->runStagedSqlFile($sqlFile, $extra);
450 } catch (\Throwable $e) {
451 if ($this->isResumableStagedKill($e->getMessage())) {
452 $newSize = $this->recordStageBatchKilled($batchSizeKey);
453 $this->logger->warn(sprintf(
454 '[staged] %s batch killed by host at size %d; '
455 . 'shrunk %s to %d. Trigger: %s',
456 $stageKey, $batchSize, $batchSizeKey, $newSize,
457 substr($e->getMessage(), 0, 200)
458 ));
459 $this->markBuildStage($stageKey,
460 'batch killed at size ' . $batchSize
461 . '; shrunk to ' . $newSize . ', yielded');
462 return false;
463 }
464 throw $e;
465 }
466 $highWater = $hiBound;
467 $this->writeProgressOption($highWaterKey, $highWater);
468
469 $this->markBuildStage($stageKey,
470 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId));
471 }
472
473 // Stage done; reset high-water for the next rebuild.
474 $this->writeProgressOption($highWaterKey, 0);
475 return true;
476 }
477
478 /** @return int Total active+inactive rows in wp_abj404_redirects. */
479 private function countLiveRedirects(): int {
480 $sql = 'SELECT COUNT(*) AS cnt FROM '
481 . $this->doTableNameReplacements('{wp_abj404_redirects}');
482 $result = $this->queryAndGetResults($sql, $this->stagedQueryOptions());
483 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
484 if (empty($rows) || !is_array($rows[0])) {
485 return 0;
486 }
487 $cnt = $rows[0]['cnt'] ?? 0;
488 return is_scalar($cnt) ? max(0, intval($cnt)) : 0;
489 }
490
491 /** @return int Rows currently in the build buffer. */
492 private function countViewBuildRows(): int {
493 $sql = 'SELECT COUNT(*) AS cnt FROM '
494 . $this->doTableNameReplacements('{wp_abj404_view_build}');
495 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
496 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
497 if (empty($rows) || !is_array($rows[0])) {
498 return 0;
499 }
500 $cnt = $rows[0]['cnt'] ?? 0;
501 return is_scalar($cnt) ? max(0, intval($cnt)) : 0;
502 }
503
504 /** @return int Max(id) in the build buffer, 0 when empty. */
505 private function maxBuildBufferId(): int {
506 $sql = 'SELECT MAX(id) AS max_id FROM '
507 . $this->doTableNameReplacements('{wp_abj404_view_build}');
508 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
509 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
510 if (empty($rows) || !is_array($rows[0])) {
511 return 0;
512 }
513 $rawMax = $rows[0]['max_id'] ?? null;
514 if ($rawMax === null || $rawMax === '') {
515 return 0;
516 }
517 return max(0, intval($rawMax));
518 }
519
520 /**
521 * @param int $done
522 * @param int $total
523 * @return string e.g. "12/45" or "complete" when done==total.
524 */
525 private function humanBatchProgress(int $done, int $total): string {
526 if ($total <= 0) {
527 return 'complete';
528 }
529 return min($done, $total) . '/' . $total;
530 }
531
532 /**
533 * S11: atomic RENAME TABLE swap. Build buffer becomes the new served
534 * table; the previous served table (if any) becomes deleteme and is
535 * dropped.
536 *
537 * @return void
538 */
539 private function stageRenameSwap(): void {
540 $this->assertBuildBufferExistsOrHalt('S11 stageRenameSwap');
541 $buildTempTable = $this->viewBuildTableName();
542 $done = $this->viewDoneTableName();
543 $deletemeTempTable = $this->viewDeletemeTableName();
544
545 // Defensive: ensure deleteme is gone before the swap (S0 already did
546 // this, but a poorly-timed parallel rebuild could have created it).
547 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
548 array('log_errors' => false));
549
550 if ($this->viewDoneTableExists()) {
551 $sql = 'RENAME TABLE `' . $done . '` TO `' . $deletemeTempTable . '`,'
552 . ' `' . $buildTempTable . '` TO `' . $done . '`';
553 } else {
554 $sql = 'RENAME TABLE `' . $buildTempTable . '` TO `' . $done . '`';
555 }
556
557 $result = $this->queryAndGetResults($sql, array('log_errors' => true));
558 $err = isset($result['last_error']) && is_string($result['last_error'])
559 ? trim($result['last_error']) : '';
560 if ($err !== '') {
561 throw new \Exception('RENAME TABLE swap failed: ' . $err);
562 }
563
564 $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`',
565 array('log_errors' => false));
566 }
567
568 /**
569 * Pre-stage probe that halts the running stage cleanly when the
570 * view_build buffer is missing on disk. A concurrent invalidateViewDone()
571 * (redirect edit, plugin upgrade, correctCollations, daily maintenance)
572 * can call dropTransientBuffersIfPresent() and remove view_build between
573 * the start of a build tick and the next stage callback. Without this
574 * probe, the stage's first DDL/DML against view_build would hit
575 * queryAndGetResults, which logs the "Table doesn't exist" error at
576 * ERROR severity. The dispatcher then uploads it as a real bug report
577 * even though it is an expected concurrent-invalidate race (Pattern 13).
578 *
579 * Three production reports on 2026-05-16 (ids 9/10/11; plugin 4.1.18;
580 * sites greyleafmedia.com, myticas.com, p2p-game.com) traced to this
581 * shape at S3 (stageAddPreJoinIndexes) and S11 (stageRenameSwap). S2,
582 * S4, and S5 had bespoke inline guards already; this helper centralizes
583 * the same shape so every stage that touches view_build can opt in by
584 * adding one line at the top of its callback.
585 *
586 * The exception text MUST begin with "Staged view-build buffer missing"
587 * so the orchestrator's catch (runTimedViewBuildStage -> classifyStage-
588 * Failure in DataAccessTrait_ErrorClassification.php) recognizes the
589 * marker and routes the failure to 'resumable' -> resumable_yield ->
590 * orchestrator returns false. The next tick reads progress options and
591 * either restarts from S0 (if invalidateViewDone cleared them, the
592 * normal case) or fails the same check again until floor_kill_streak
593 * trips a clean halt notice.
594 *
595 * Warn-level severity is the correct choice per CLAUDE.md ยง8
596 * ("Infrastructure errors are warnings, not bugs -- unless the plugin
597 * can't function"): the build can recover by restarting on the next
598 * tick, so it remains functional. The warn line is still visible in
599 * debug.log for diagnosis; it just does not trigger the ERROR-level
600 * dispatcher upload path.
601 *
602 * @param string $stageLabel Human-readable stage tag included in the
603 * warn line and exception message so the
604 * failure can be attributed to the exact
605 * stage callback that detected the race.
606 * @throws \Exception Always when the buffer is missing; never throws
607 * when the buffer is present (silent no-op happy
608 * path).
609 * @return void
610 */
611 private function assertBuildBufferExistsOrHalt(string $stageLabel): void {
612 if ($this->stagedTableExists($this->viewBuildTableName())) {
613 return;
614 }
615 $this->logger->warn(sprintf(
616 '[staged] %s halting: view_build buffer missing on disk. A '
617 . 'concurrent invalidateViewDone() drop is the expected cause '
618 . '(Pattern 13). Yielding stage; the next tick will rebuild '
619 . 'from S0 after progress options are cleared.',
620 $stageLabel
621 ));
622 throw new \Exception(sprintf(
623 'Staged view-build buffer missing at %s entry; pipeline state '
624 . 'diverged from disk. Halting stage for resume.',
625 $stageLabel
626 ));
627 }
628 }
629