advanceViewBuildOnce(...$arguments) * @method void assertBuildBufferExistsOrHalt(...$arguments) * @method ?bool attemptRelaxSqlModeForBuildConnection(...$arguments) * @method bool bufferIntegrityPassesForPromote(...$arguments) * @method string buildHaltTransientKey(...$arguments) * @method string buildViewDoneCountQuery(...$arguments) * @method string builtWatermarkOptionName(...$arguments) * @method int bumpMutationWatermark(...$arguments) * @method int bumpStageNoProgressStreak(...$arguments) * @method string capturedPrefixForLog(...$arguments) * @method void capturePrefixAtBuildStart(...$arguments) * @method void claimForegroundViewBuildLease(...$arguments) * @method string classifyAndHandleStageFailure(...$arguments) * @method array classifySessionVariableWarnings(...$arguments) * @method string classifyStageFailure(...$arguments) * @method void clearActiveBuildStartedWatermark(...$arguments) * @method void clearAdminMutationGateOptions(...$arguments) * @method void clearAllProgressOptions(...$arguments) * @method void clearPhpEnvironmentProbeCache(...$arguments) * @method void clearPrefixAtStageOne(...$arguments) * @method void clearSessionVariablesProbeCache(...$arguments) * @method void clearSqlModeProbeCache(...$arguments) * @method void clearStagedBuildDegradedState(...$arguments) * @method void clearViewBuildOpenStageForShutdown(...$arguments) * @method void clearViewDoneHardStaleNotice(...$arguments) * @method ABJ_404_Solution_Clock clock(...$arguments) * @method int countLiveRedirects(...$arguments) * @method int countViewBuildRows(...$arguments) * @method string describeBuildProgressForNotice(...$arguments) * @method string describeDegradedNotice(...$arguments) * @method string describeStagedSqlFailure(...$arguments) * @method array detectAndAdjustSqlMode(...$arguments) * @method float detectHostStagedQueryLimitSeconds(...$arguments) * @method string doTableNameReplacements(...$arguments) * @method void dropDeletemeTable(...$arguments) * @method void dropTransientBuffersIfPresent(...$arguments) * @method void dropTransientStagedTables(...$arguments) * @method void ensureConnection(...$arguments) * @method void ensureFallbackLockNoticeAndLog(...$arguments) * @method int extendedTimeoutForKilledNonBatchedStage(...$arguments) * @method array fetchSessionVariablesRowOrEmpty(...$arguments) * @method string filesystemEnvironmentProbeOptionName(...$arguments) * @method bool forceRestartViewBuild(...$arguments) * @method bool foregroundViewBuildLeaseActive(...$arguments) * @method string formatPhpMemoryBytesHuman(...$arguments) * @method bool gateAbortIfMutationWatermarkAdvanced(...$arguments) * @method string getColumnCollationString(...$arguments) * @method int getCronStuckHours(...$arguments) * @method string getLowercasePrefix(...$arguments) * @method array getViewBuildProgress(...$arguments) * @method array getViewBuildProgressFingerprint(...$arguments) * @method int getViewDoneBuiltAtTimestamp(...$arguments) * @method bool haltIfPrefixChangedSinceStageOne(...$arguments) * @method string humanBatchProgress(...$arguments) * @method float intelligentStagedQueryTimeoutSeconds(...$arguments) * @method void invalidateViewDoneServeableCache(...$arguments) * @method bool isBuildHaltedForHostFailure(...$arguments) * @method bool isCurrentStageOptionName(...$arguments) * @method bool isNamedLockUnsupportedError(...$arguments) * @method bool isResumableStagedKill(...$arguments) * @method bool isStageMarkedSkipped(...$arguments) * @method bool isTransientConnectionError(...$arguments) * @method string lastBuildStartedWatermarkOptionName(...$arguments) * @method string legacyStartedWatermarkOptionName(...$arguments) * @method string localizeOrDefaultViewBuildNotice(...$arguments) * @method bool logsHitsTableExists(...$arguments) * @method void logTimedViewBuildStage(...$arguments) * @method void logViewBuildProgressOptionWrite(...$arguments) * @method void logViewBuildShutdownDiagnostics(...$arguments) * @method void markBuildHaltedForHostFailure(...$arguments) * @method void markBuildStage(...$arguments) * @method void markStageSkippedForHostFailure(...$arguments) * @method void markViewBuildStageCompleted(...$arguments) * @method void markViewBuildStageStarted(...$arguments) * @method void markViewDoneBuildCompleted(...$arguments) * @method void markViewDoneInvalidatedByAdminMutation(...$arguments) * @method int maxBuildBufferId(...$arguments) * @method void maybeRaiseViewDoneHardStaleNotice(...$arguments) * @method bool mutationWatermarkAdvancedSinceBuildStart(...$arguments) * @method int mutationWatermarkObservedByAdminAction(...$arguments) * @method int mutationWatermarkObservedByAdminActionAt(...$arguments) * @method string mutationWatermarkObservedByAdminActionAtOptionName(...$arguments) * @method string mutationWatermarkObservedByAdminActionOptionName(...$arguments) * @method string normalizePathPrefix(...$arguments) * @method bool optionReadBackMatches(...$arguments) * @method int parsePhpMemoryLimitToBytes(...$arguments) * @method bool pathFallsWithinAny(...$arguments) * @method void performFreshStartCleanup(...$arguments) * @method array phpDisabledFunctionsList(...$arguments) * @method string phpEnvironmentProbeOptionName(...$arguments) * @method float phpTimeRemainingSeconds(...$arguments) * @method string prefixAtStageOneOptionName(...$arguments) * @method array probeFilesystemEnvironmentForBuild(...$arguments) * @method float probeFloatFromValues(...$arguments) * @method int probeIntFromValues(...$arguments) * @method int probeMemoryLimitForS9(...$arguments) * @method array probePhpEnvironmentForBuild(...$arguments) * @method array probeSessionVariablesAtS1Entry(...$arguments) * @method bool probeSetTimeLimitAvailability(...$arguments) * @method array probeSqlModeForBuild(...$arguments) * @method string probeStringFromValues(...$arguments) * @method string progressOptionName(...$arguments) * @method void publishBuiltWatermarkFromActiveBuildStartedWatermark(...$arguments) * @method array queryAndGetResults(...$arguments) * @method int readActiveBuildStartedWatermark(...$arguments) * @method array> readFromViewDone(...$arguments) * @method int readProgressOption(...$arguments) * @method int readWatermarkOption(...$arguments) * @method void rebuildViewDoneInBackground(...$arguments) * @method bool reconcilePostStageElevenState(...$arguments) * @method string reconcileStagedTablesAtRunnerStartup(...$arguments) * @method int recordStageBatchKilled(...$arguments) * @method void registerViewBuildShutdownDiagnostics(...$arguments) * @method bool releaseAndReacquireBetweenStages(...$arguments) * @method void releaseViewBuildLock(...$arguments) * @method void resetStageNoProgressStreak(...$arguments) * @method string resolveColumnCollationForStagedBuild(...$arguments) * @method void runForceRestartCleanupInsideLock(...$arguments) * @method bool runIdRangeBatchedUpdate(...$arguments) * @method int runInsertBatch(...$arguments) * @method mixed runNonBatchedStageWithKillStreakEscape(...$arguments) * @method array{ran: bool, reason: string, progress: array} runPageLoadFallbackAdvance(...$arguments) * @method int runRedirectsForViewCountStaged(...$arguments) * @method array> runRedirectsForViewStaged(...$arguments) * @method bool runS11SwapWithPreRenameWatermarkRecheck(...$arguments) * @method bool runStagedBuildOnce(...$arguments) * @method bool runStagedBuildStages6Through11(...$arguments) * @method void runStagedSqlFile(...$arguments) * @method void runStagedSqlFileTolerantOfDuplicateKey(...$arguments) * @method mixed runTimedViewBuildStage(...$arguments) * @method int safeCurrentMutationWatermark(...$arguments) * @method string sanitizeUrlBeforeInsert(...$arguments) * @method void scheduleViewDoneRebuild(...$arguments) * @method string sessionVariablesProbeOptionName(...$arguments) * @method void setFilesystemEnvAdminNotice(...$arguments) * @method void setLowMemoryLimitAdminNotice(...$arguments) * @method void setSessionEnvAdminNotice(...$arguments) * @method void setStagedBuildDegradedNotice(...$arguments) * @method void setStagedBuildHaltNotice(...$arguments) * @method void setViewBuildCronStuckNotice(...$arguments) * @method void setViewBuildScheduleFailedNotice(...$arguments) * @method void setViewDoneHardStaleNotice(...$arguments) * @method array splitOpenBasedirPaths(...$arguments) * @method string sqlModeProbeOptionName(...$arguments) * @method void stageAddPreJoinIndexes(...$arguments) * @method void stageAddSortIndexes(...$arguments) * @method void stageCreateBuildTable(...$arguments) * @method array stagedQueryOptions(...$arguments) * @method bool stagedTableExists(...$arguments) * @method bool stageInsertRedirectsBatched(...$arguments) * @method string stageNoProgressStreakOptionName(...$arguments) * @method void stageRenameSwap(...$arguments) * @method string stageSkipOptionName(...$arguments) * @method void stageUpdateExternal(...$arguments) * @method void stageUpdateHits(...$arguments) * @method void stageUpdateHome(...$arguments) * @method bool stageUpdatePostsBatched(...$arguments) * @method void stageUpdateSpecial(...$arguments) * @method bool stageUpdateTermsBatched(...$arguments) * @method void stampStartedWatermarksAtS1Entry(...$arguments) * @method void sweepStaleRebuildTransients(...$arguments) * @method string transientFallbackLockOptionName(...$arguments) * @method bool verifyBuildLockSerializesWriter(...$arguments) * @method bool verifyOptionWriteCoherent(...$arguments) * @method bool verifyPrefixUnchangedSinceStageOne(...$arguments) * @method int viewBuildBatchSize(...$arguments) * @method int viewBuildBatchSizeForStage(...$arguments) * @method array viewBuildOnlyTranslations(...$arguments) * @method float viewBuildPerStageBudgetSeconds(...$arguments) * @method string viewBuildTableName(...$arguments) * @method string viewDeletemeTableName(...$arguments) * @method int viewDoneBuiltAt(...$arguments) * @method int viewDoneBuiltWatermark(...$arguments) * @method int viewDoneDataBuiltAt(...$arguments) * @method string viewDoneDataBuiltAtOptionName(...$arguments) * @method string viewDoneFreshnessOptionName(...$arguments) * @method bool viewDoneHasRows(...$arguments) * @method bool viewDoneIsFresh(...$arguments) * @method bool viewDoneIsServeable(...$arguments) * @method int viewDoneMutationInvalidatedAt(...$arguments) * @method string viewDoneMutationInvalidatedAtOptionName(...$arguments) * @method bool viewDoneTableExists(...$arguments) * @method string viewDoneTableName(...$arguments) * @method void writeProgressOption(...$arguments) * @method void writeWatermarkOption(...$arguments) */ class ABJ_404_Solution_ViewBuildStageCallbacks extends ABJ_404_Solution_ViewBuildCollaborator { /** Drop both the build buffer and the leftover deleteme. Used on fresh-start only. */ public function dropTransientStagedTables(): void { $buildTempTable = $this->viewBuildTableName(); $deletemeTempTable = $this->viewDeletemeTableName(); $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $buildTempTable . '`', array('log_errors' => false)); $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`', array('log_errors' => false)); } /** Drop only the deleteme leftover from a prior crashed RENAME swap. */ public function dropDeletemeTable(): void { $deletemeTempTable = $this->viewDeletemeTableName(); $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`', array('log_errors' => false)); } /** * Drop view_build / view_deleteme only if either exists on disk. Gated by * SHOW TABLES so a steady-state invalidate (no buffer present, the common * case for redirect-edit invalidations) does not pile DROP IF EXISTS DDL * on the hot path. Called from the runner-owned force-rebuild primitive * (see DataAccessTrait_ViewBuildForceRestart) so the buffer drop is * atomic with the progress-option clear. * * @return void */ public function dropTransientBuffersIfPresent(): void { $buildTempTable = $this->viewBuildTableName(); $deletemeTempTable = $this->viewDeletemeTableName(); if ($this->stagedTableExists($buildTempTable)) { $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $buildTempTable . '`', array('log_errors' => false)); } if ($this->stagedTableExists($deletemeTempTable)) { $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`', array('log_errors' => false)); } } /** * S1: create the build buffer. Tries the system default storage * engine, then falls back to MyISAM, then to InnoDB so it works on * hosts that disable one or the other. * * @return void */ public function stageCreateBuildTable(): void { $template = ABJ_404_Solution_Functions::readFileContents(__DIR__ . '/sql/createViewBuildTable.sql'); $base = $this->doTableNameReplacements(is_string($template) ? $template : ''); if (trim($base) === '') { throw new \Exception('createViewBuildTable.sql is empty or unreadable.'); } $attempts = array( 'default' => $base, 'MyISAM' => $base . ' ENGINE=MyISAM', 'InnoDB' => $base . ' ENGINE=InnoDB', ); $lastError = ''; $errorsSoFar = array(); $opts = $this->stagedQueryOptions(); $opts['log_errors'] = false; foreach ($attempts as $engineLabel => $sql) { $attemptStarted = microtime(true); $this->logger->debugMessage(sprintf( '[staged] S1 createViewBuildTable attempt starting: engine=%s', $engineLabel )); $result = $this->queryAndGetResults($sql, $opts); $err = isset($result['last_error']) && is_string($result['last_error']) ? trim($result['last_error']) : ''; $timedOut = !empty($result['timed_out']); $elapsedMs = (int)round((microtime(true) - $attemptStarted) * 1000); $this->logger->debugMessage(sprintf( '[staged] S1 createViewBuildTable attempt finished: engine=%s elapsed_ms=%d timed_out=%s last_error=%s', $engineLabel, $elapsedMs, $timedOut ? 'true' : 'false', $err !== '' ? substr($err, 0, 240) : 'none' )); if ($err === '' && !$timedOut) { if ($engineLabel !== 'default') { // Default engine failed but a fallback won. Worth knowing // because hosts that need a fallback often have other // engine-specific quirks downstream (lock waits, ALTER // semantics, etc.). $this->logger->warn(sprintf( '[staged] S1 createViewBuildTable: default engine ' . 'failed (%s); succeeded on fallback %s.', substr(implode('; ', $errorsSoFar), 0, 200), $engineLabel )); } return; } $lastError = $err !== '' ? $err : 'unknown'; $errorsSoFar[] = $engineLabel . ': ' . $lastError; } throw new \Exception('Could not create view build table on any storage engine: ' . $lastError); } /** * S2: bulk-load redirects into the build buffer, in resumable batches. * * Source of truth for the high-water is `MAX(id)` of the build buffer * itself; option `s2_high_water` is written for diagnostics / visibility * but is never consulted. Using buffer MAX(id) directly makes resumption * crash-safe: if PHP dies between an INSERT and the option write, the * next request still picks up from exactly where the INSERT left off. * * Each batch INSERTs the next BATCH_SIZE rows from wp_abj404_redirects * with `id > `. Per-stage budget caps wall-clock time * so the request can finish even when the dataset is too big to copy in * one shot. * * @return bool true when the entire redirects table has been copied; * false when the per-stage budget was exhausted mid-stage. */ public function stageInsertRedirectsBatched(): bool { $this->markBuildStage('staged_build_s2_insert'); $deadline = microtime(true) + $this->viewBuildPerStageBudgetSeconds(); // Pre-flight check uses the SQL hint (smaller than the wall-clock // budget by design), not the budget itself. This is the worst-case // time a single batch can take before SET STATEMENT max_statement_time // fires. The budget is a loop-level wall clock; a single batch never // takes a full budget to run. $perQueryLimit = max(1.0, (float)$this->intelligentStagedQueryTimeoutSeconds()); $totalCount = $this->countLiveRedirects(); if ($totalCount <= 0) { // Empty redirects table; nothing to copy. $this->writeProgressOption('s2_high_water', 0); return true; } $batchNumber = 0; while (true) { $copiedSoFar = $this->countViewBuildRows(); if ($copiedSoFar >= $totalCount) { break; // covered the table } // Wall-clock yield (Path A): per-stage budget exhausted. NOT a // batch-size problem; do not shrink. if (microtime(true) >= $deadline) { $this->markBuildStage('staged_build_s2_insert', 'batch ' . $this->humanBatchProgress($copiedSoFar, $totalCount) . ' (yielded)'); return false; } // Pre-flight: only start a batch when the request has enough PHP // time left to finish it at our SQL hint. Without this, a batch // we started with too little time would get killed mid-flight by // PHP's max_execution_time and we could not safely tell whether // the kill was a real batch-too-big problem or just request-time // exhaustion. Yield without shrinking. // // Always allow the first batch of a tick to run, even when PHP // time looks tight: phpTimeRemainingSeconds() reflects the time // left at the START of the stage, which on a typical 30s shared // host is already below the SQL hint after WP boot. Without this // first-batch escape, the build would yield on every request // without ever inserting a row -- exactly the "stuck at stage // 1/11" symptom that stranded large-site installs. if ($batchNumber > 0 && $this->phpTimeRemainingSeconds() < $perQueryLimit + 1.0) { $this->markBuildStage('staged_build_s2_insert', 'batch ' . $this->humanBatchProgress($copiedSoFar, $totalCount) . ' (yielded; tight time)'); return false; } $batchSize = $this->viewBuildBatchSizeForStage('s2_batch_size'); $batchNumber++; $loBound = $this->maxBuildBufferId(); $beforeMax = $loBound; try { // Public extension point. Sites hook this for per-batch // telemetry; tests bind a callback that throws to simulate // a host kill. Inside the try/catch so a hook-thrown // resumable error is handled exactly the same way as a // real kill from the SQL call below. if (function_exists('do_action')) { do_action('abj404_view_build_batch_starting', 's2_insert', $batchNumber, $batchSize); } $afterMax = $this->runInsertBatch($loBound, $batchSize); } catch (\Throwable $e) { if ($this->isResumableStagedKill($e->getMessage())) { // Path B: batch genuinely too big at the host limit. // Halve, persist, yield. Next tick uses smaller size. $newSize = $this->recordStageBatchKilled('s2_batch_size'); $this->logger->warn(sprintf( '[staged] S2 batch killed by host at size %d; ' . 'shrunk s2_batch_size to %d. Trigger: %s', $batchSize, $newSize, substr($e->getMessage(), 0, 200) )); $this->markBuildStage('staged_build_s2_insert', 'batch killed at size ' . $batchSize . '; shrunk to ' . $newSize . ', yielded'); return false; } throw $e; } if ($afterMax === $beforeMax) { // Distinguish "no new rows to copy" from "buffer missing". // Without this check, a missing view_build looks identical // to a real shrink-during-build because maxBuildBufferId // returns 0 in both cases. The missing-buffer scenario is a // pipeline corruption that must halt, not silently mark // S2 complete. See Pattern 13. if (!$this->stagedTableExists($this->viewBuildTableName())) { throw new \Exception( 'Staged view-build buffer missing during S2 INSERT; ' . 'pipeline state diverged from disk. Halting stage.' ); } // No rows above $loBound to copy. Either the redirects table // shrank during the build, or all remaining ids are <= loBound // (impossible given strict id-range semantics, but defensive). // Treat as done; the read query will reflect whatever was // captured. $this->logger->warn(sprintf( '[staged] S2 stopping early: INSERT batch did not advance ' . 'MAX(id) (loBound=%d, beforeMax=%d, afterMax=%d, ' . 'copiedSoFar=%d, totalCount=%d). Treating as done.', $loBound, $beforeMax, $afterMax, $copiedSoFar, $totalCount )); break; } // Mirror MAX(id) into the option for diagnostics. This is // best-effort; correctness does NOT depend on this write. $this->writeProgressOption('s2_high_water', $afterMax); $this->markBuildStage('staged_build_s2_insert', 'batch ' . $this->humanBatchProgress($this->countViewBuildRows(), $totalCount)); } $this->writeProgressOption('s2_high_water', 0); return true; } /** @return void */ public function stageAddPreJoinIndexes(): void { // S3 indexes are added with IF NOT EXISTS semantics emulated by // catching "Duplicate key name" on retry. See runStagedSqlFile // tolerance below. ALTER TABLE itself is fast on the buffer. $this->assertBuildBufferExistsOrHalt('S3 stageAddPreJoinIndexes'); $this->runStagedSqlFileTolerantOfDuplicateKey('03_index_fd.sql', array()); } /** * S4: resolve POST-typed redirects against wp_posts in resumable batches * keyed by view_build.id range. * * @return bool true when stage completed; false when budget exhausted. */ public function stageUpdatePostsBatched(): bool { return $this->runIdRangeBatchedUpdate( 'staged_build_s4_update_posts', 's4_high_water', '04_update_posts.sql' ); } /** * S5: resolve CAT/TAG-typed redirects against wp_terms in resumable * batches keyed by view_build.id range. * * @return bool true when stage completed; false when budget exhausted. */ public function stageUpdateTermsBatched(): bool { return $this->runIdRangeBatchedUpdate( 'staged_build_s5_update_terms', 's5_high_water', '05_update_terms.sql' ); } /** @return void */ public function stageUpdateHome(): void { $this->assertBuildBufferExistsOrHalt('S6 stageUpdateHome'); $this->runStagedSqlFile('06_update_home.sql', array()); } /** @return void */ public function stageUpdateExternal(): void { $this->assertBuildBufferExistsOrHalt('S7 stageUpdateExternal'); $this->runStagedSqlFile('07_update_external.sql', array()); } /** @return void */ public function stageUpdateSpecial(): void { $this->assertBuildBufferExistsOrHalt('S8 stageUpdateSpecial'); $this->runStagedSqlFile('08_update_special.sql', $this->viewBuildOnlyTranslations()); } /** @return void */ public function stageUpdateHits(): void { $this->assertBuildBufferExistsOrHalt('S9 stageUpdateHits'); $s9Collation = $this->resolveColumnCollationForStagedBuild(); $collationExtra = array('{S9_COLLATION}' => $s9Collation); $this->runStagedSqlFile('09a_drop_hits_temp.sql', array()); $this->runStagedSqlFile('09b_create_hits_temp.sql', $collationExtra); $this->runStagedSqlFile('09c_insert_hits_temp.sql', array()); $this->runStagedSqlFile('09_update_hits.sql', $collationExtra); $this->runStagedSqlFile('09a_drop_hits_temp.sql', array()); } /** @return void */ public function stageAddSortIndexes(): void { $this->assertBuildBufferExistsOrHalt('S10 stageAddSortIndexes'); $this->runStagedSqlFileTolerantOfDuplicateKey('10_index_sort.sql', array()); } /** * Run one INSERT batch for S2. Inserts the next BATCH_SIZE rows from * wp_abj404_redirects with `id > $loBound` (ORDER BY id ASC LIMIT * BATCH_SIZE) into the build buffer. Returns the new MAX(id) of the * buffer so the caller can detect "no more rows" (buffer max didn't * change after the insert). * * @param int $loBound MAX(id) of the buffer at batch start. * @param int $batchSize * @return int New MAX(id) of the buffer after this batch (== $loBound * when no rows were inserted). */ public function runInsertBatch(int $loBound, int $batchSize): int { $loBound = max(0, intval($loBound)); $batchSize = max(1, intval($batchSize)); $extra = $this->viewBuildOnlyTranslations(); $extra['{LO_BOUND}'] = (string)$loBound; $extra['{BATCH_SIZE}'] = (string)$batchSize; $this->runStagedSqlFile('02_insert.sql', $extra); return $this->maxBuildBufferId(); } /** * Run an UPDATE-JOIN stage in id-range batches against the build buffer. * * The SQL fragment must use `WHERE t.id > {LO_BOUND} AND t.id <= {HI_BOUND}` * (the staged 04/05 SQL files do this once converted) so we can stride * forward by id without a per-batch COUNT. * * @param string $stageKey Sub-stage label, e.g. 'staged_build_s4_update_posts'. * @param string $highWaterKey Progress option key, e.g. 's4_high_water'. * @param string $sqlFile Filename under sql/getRedirectsForViewStaged/. * @return bool true when stage completed; false when budget exhausted. */ public function runIdRangeBatchedUpdate(string $stageKey, string $highWaterKey, string $sqlFile): bool { $this->markBuildStage($stageKey); $deadline = microtime(true) + $this->viewBuildPerStageBudgetSeconds(); $perQueryLimit = max(1.0, (float)$this->intelligentStagedQueryTimeoutSeconds()); // s4_high_water -> s4_batch_size; s5_high_water -> s5_batch_size. $batchSizeKey = str_replace('_high_water', '_batch_size', $highWaterKey); $highWater = $this->readProgressOption($highWaterKey, 0); $totalMaxId = $this->maxBuildBufferId(); if ($totalMaxId <= 0) { // Distinguish "buffer is empty" (legitimate: no redirects on // the site) from "buffer is missing" (pipeline corruption: // concurrent invalidateViewDone dropped view_build between // S1 and here, S1 silently approved without executing, or // switch_to_blog moved us off the schema where S1 created it). // The former is fine to mark complete; the latter must halt // and let the orchestrator restart cleanly on the next tick. // Without this check, maxBuildBufferId's 0 return shadows the // real error after queryAndGetResults swallows the missing- // table error string. See Pattern 13 in // docs/PROACTIVE_BUG_DISCOVERY.md. if (!$this->stagedTableExists($this->viewBuildTableName())) { throw new \Exception(sprintf( 'Staged view-build buffer missing at %s entry; pipeline state ' . 'diverged from disk. Halting stage.', $stageKey )); } // Buffer is empty (no redirects). Nothing to update. $this->writeProgressOption($highWaterKey, 0); return true; } $batchNumber = 0; while ($highWater < $totalMaxId) { // Wall-clock yield (Path A); not a batch-size problem. if (microtime(true) >= $deadline) { $this->markBuildStage($stageKey, 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId) . ' (yielded)'); return false; } // Pre-flight: yield without shrinking when there is not enough // PHP request time left to finish a batch at the SQL hint. Same // rationale as in stageInsertRedirectsBatched, including the // first-batch escape so a tight-PHP-time request still makes // forward progress instead of yielding indefinitely. if ($batchNumber > 0 && $this->phpTimeRemainingSeconds() < $perQueryLimit + 1.0) { $this->markBuildStage($stageKey, 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId) . ' (yielded; tight time)'); return false; } $batchSize = $this->viewBuildBatchSizeForStage($batchSizeKey); $batchNumber++; $hiBound = min($totalMaxId, $highWater + $batchSize); $extra = array( '{LO_BOUND}' => (string)$highWater, '{HI_BOUND}' => (string)$hiBound, ); try { if (function_exists('do_action')) { do_action('abj404_view_build_batch_starting', $stageKey, $batchNumber, $batchSize); } $this->runStagedSqlFile($sqlFile, $extra); } catch (\Throwable $e) { if ($this->isResumableStagedKill($e->getMessage())) { $newSize = $this->recordStageBatchKilled($batchSizeKey); $this->logger->warn(sprintf( '[staged] %s batch killed by host at size %d; ' . 'shrunk %s to %d. Trigger: %s', $stageKey, $batchSize, $batchSizeKey, $newSize, substr($e->getMessage(), 0, 200) )); $this->markBuildStage($stageKey, 'batch killed at size ' . $batchSize . '; shrunk to ' . $newSize . ', yielded'); return false; } throw $e; } $highWater = $hiBound; $this->writeProgressOption($highWaterKey, $highWater); $this->markBuildStage($stageKey, 'batch ' . $this->humanBatchProgress($highWater, $totalMaxId)); } // Stage done; reset high-water for the next rebuild. $this->writeProgressOption($highWaterKey, 0); return true; } /** @return int Total active+inactive rows in wp_abj404_redirects. */ public function countLiveRedirects(): int { $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->doTableNameReplacements('{wp_abj404_redirects}'); $result = $this->queryAndGetResults($sql, $this->stagedQueryOptions()); $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); if (empty($rows) || !is_array($rows[0])) { return 0; } $cnt = $rows[0]['cnt'] ?? 0; return is_scalar($cnt) ? max(0, intval($cnt)) : 0; } /** @return int Rows currently in the build buffer. */ public function countViewBuildRows(): int { $sql = 'SELECT COUNT(*) AS cnt FROM ' . $this->doTableNameReplacements('{wp_abj404_view_build}'); $result = $this->queryAndGetResults($sql, array('log_errors' => false)); $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); if (empty($rows) || !is_array($rows[0])) { return 0; } $cnt = $rows[0]['cnt'] ?? 0; return is_scalar($cnt) ? max(0, intval($cnt)) : 0; } /** @return int Max(id) in the build buffer, 0 when empty. */ public function maxBuildBufferId(): int { $sql = 'SELECT MAX(id) AS max_id FROM ' . $this->doTableNameReplacements('{wp_abj404_view_build}'); $result = $this->queryAndGetResults($sql, array('log_errors' => false)); $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); if (empty($rows) || !is_array($rows[0])) { return 0; } $rawMax = $rows[0]['max_id'] ?? null; if ($rawMax === null || $rawMax === '') { return 0; } return is_scalar($rawMax) ? max(0, intval($rawMax)) : 0; } /** * @param int $done * @param int $total * @return string e.g. "12/45" or "complete" when done==total. */ public function humanBatchProgress(int $done, int $total): string { if ($total <= 0) { return 'complete'; } return min($done, $total) . '/' . $total; } /** * S11: atomic RENAME TABLE swap. Build buffer becomes the new served * table; the previous served table (if any) becomes deleteme and is * dropped. * * @return void */ public function stageRenameSwap(): void { $this->assertBuildBufferExistsOrHalt('S11 stageRenameSwap'); $buildTempTable = $this->viewBuildTableName(); $done = $this->viewDoneTableName(); $deletemeTempTable = $this->viewDeletemeTableName(); // Defensive: ensure deleteme is gone before the swap (S0 already did // this, but a poorly-timed parallel rebuild could have created it). $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`', array('log_errors' => false)); if ($this->viewDoneTableExists()) { $sql = 'RENAME TABLE `' . $done . '` TO `' . $deletemeTempTable . '`,' . ' `' . $buildTempTable . '` TO `' . $done . '`'; } else { $sql = 'RENAME TABLE `' . $buildTempTable . '` TO `' . $done . '`'; } $result = $this->queryAndGetResults($sql, array('log_errors' => true)); $err = isset($result['last_error']) && is_string($result['last_error']) ? trim($result['last_error']) : ''; if ($err !== '') { throw new \Exception('RENAME TABLE swap failed: ' . $err); } $this->queryAndGetResults('DROP TABLE IF EXISTS `' . $deletemeTempTable . '`', array('log_errors' => false)); } /** * Pre-stage probe that halts the running stage cleanly when the * view_build buffer is missing on disk. A concurrent invalidateViewDone() * (redirect edit, plugin upgrade, correctCollations, daily maintenance) * can call dropTransientBuffersIfPresent() and remove view_build between * the start of a build tick and the next stage callback. Without this * probe, the stage's first DDL/DML against view_build would hit * queryAndGetResults, which logs the "Table doesn't exist" error at * ERROR severity. The dispatcher then uploads it as a real bug report * even though it is an expected concurrent-invalidate race (Pattern 13). * * Three production reports on 2026-05-16 (ids 9/10/11; plugin 4.1.18; * sites greyleafmedia.com, myticas.com, p2p-game.com) traced to this * shape at S3 (stageAddPreJoinIndexes) and S11 (stageRenameSwap). S2, * S4, and S5 had bespoke inline guards already; this helper centralizes * the same shape so every stage that touches view_build can opt in by * adding one line at the top of its callback. * * The exception text MUST begin with "Staged view-build buffer missing" * so the orchestrator's catch (runTimedViewBuildStage -> classifyStage- * Failure in DataAccessTrait_ErrorClassification.php) recognizes the * marker and routes the failure to 'resumable' -> resumable_yield -> * orchestrator returns false. The next tick reads progress options and * either restarts from S0 (if invalidateViewDone cleared them, the * normal case) or fails the same check again until floor_kill_streak * trips a clean halt notice. * * Warn-level severity is the correct choice per CLAUDE.md ยง8 * ("Infrastructure errors are warnings, not bugs -- unless the plugin * can't function"): the build can recover by restarting on the next * tick, so it remains functional. The warn line is still visible in * debug.log for diagnosis; it just does not trigger the ERROR-level * dispatcher upload path. * * @param string $stageLabel Human-readable stage tag included in the * warn line and exception message so the * failure can be attributed to the exact * stage callback that detected the race. * @throws \Exception Always when the buffer is missing; never throws * when the buffer is present (silent no-op happy * path). * @return void */ public function assertBuildBufferExistsOrHalt(string $stageLabel): void { if ($this->stagedTableExists($this->viewBuildTableName())) { return; } $this->logger->warn(sprintf( '[staged] %s halting: view_build buffer missing on disk. A ' . 'concurrent invalidateViewDone() drop is the expected cause ' . '(Pattern 13). Yielding stage; the next tick will rebuild ' . 'from S0 after progress options are cleared.', $stageLabel )); throw new \Exception(sprintf( 'Staged view-build buffer missing at %s entry; pipeline state ' . 'diverged from disk. Halting stage for resume.', $stageLabel )); } }