cursor ORDER BY id ASC LIMIT chunkSize`, * advancing the cursor per chunk, until the time budget is spent (cursor * persists, a bounded continuation of that nightly sweep resumes mid-table) or the backlog drains * (cursor resets to 0 so the next tick starts a fresh full pass). Recompute is * idempotent, so an interrupted run resumes cleanly and a re-run is a no-op on * values that were already correct. * * Degrades gracefully (defensive philosophy #2/#7/#8): missing redirects table, * missing derived columns (column-add ALTER never completed), or an active write * block (read-only replica / disk full) -> skip and return 0, never error or * email. Per-statement write failures are logged as warnings by * queryAndGetResults (the centralized DAO error handler) inside the shared * resolver and stop the run without surfacing to the admin. */ class ABJ_404_Solution_DatabaseUpgradeRedirectsDenormReconcile extends ABJ_404_Solution_DatabaseUpgradeComponent { /** * Recompute all four derived columns for every redirect row, one cursor-walked * chunk at a time, bounded by the per-invocation time budget. * * Skips silently (returns 0) when: * - $wpdb is unavailable, * - the redirects table is missing (degraded site state), * - the dest_for_view column is missing (column add has not happened yet), * - a DB write block is active (read-only replica / disk full). * * @param ?callable $onPassComplete Notifies the daily orchestrator when the * entire sweep has completed, including an empty table. Failure and budget * exhaustion never notify, so the same nightly pass remains resumable. * @return int Number of redirect rows recomputed in this invocation. */ public function reconcileRedirectsDenormColumns(?callable $onPassComplete = null): int { global $wpdb; if (!isset($wpdb)) { return 0; } $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}'); // SHOW TABLES existence probe, same shape as the Step 3a backfill. // Routing through queryAndGetResults would log a benign "table doesn't // exist" error on freshly-installed sites before create-tables has run. // DAO-bypass-approved: schema existence probe, see comment above. $found = ABJ_404_Solution_DatabaseDriverCall::getVar($wpdb, "SHOW TABLES LIKE '" . esc_sql($redirectsTable) . "'"); if ($found !== $redirectsTable) { return 0; } if ($this->columnExists($redirectsTable, 'dest_for_view') !== true) { return 0; } if ($this->dbCore->noticeState()->isWriteBlockActive()) { // Read-only replica / disk full: skip the whole write-heavy pass and // leave the cursor untouched so the next healthy tick resumes. $this->logger->debugMessage( "reconcileRedirectsDenormColumns skipped: DB write block active (read-only / disk full)." ); return 0; } $batchSizer = new ABJ_404_Solution_DatabaseMaintenanceBatchSizer(array( 'initial_size' => (int)$this->getRedirectsDenormReconcileChunkSize(), 'maximum_size' => 5000, 'maximum_sparse_size' => 1000, )); $timeBudget = (float)$this->getRedirectsDenormReconcileTimeBudgetSec(); $cursor = $this->readReconcileCursor(); $start = abj_clock()->nowFloat(); $totalReconciled = 0; if (is_callable(array(ABJ_404_Solution_RedirectsDenormChunkResolver::class, 'forPass'))) { $resolveChunk = ABJ_404_Solution_RedirectsDenormChunkResolver::forPass( $this->dbCore, $this->logger, $redirectsTable, true ); } else { // An in-flight plugin request may retain the older resolver API. $dbCore = $this->dbCore; $logger = $this->logger; $resolveChunk = static function (array $ids) use ($dbCore, $logger, $redirectsTable): bool { return ABJ_404_Solution_RedirectsDenormChunkResolver::resolveChunk( $dbCore, $logger, $redirectsTable, $ids, true ); }; } while ((abj_clock()->nowFloat() - $start) < $timeBudget) { $iterationStart = abj_clock()->nowFloat(); $requestedLimit = $batchSizer->limit(); $ids = $this->fetchNextReconcileChunkIds($redirectsTable, $cursor, $requestedLimit); if ($ids === null) { // Read error already warned about; leave the cursor where it is // so the next tick re-reads this range, and stop so we don't spin. return $totalReconciled; } if (empty($ids)) { // No rows past the cursor: a full pass completed. Reset so the // next nightly tick starts a fresh sweep from the top. The // daily orchestrator retires this pass's continuation chain. $this->resetReconcileCursor(); if ($onPassComplete !== null) { $onPassComplete(); } break; } $fetchedCount = count($ids); $ids = $batchSizer->boundedIds($ids); $lastChunk = $fetchedCount < $requestedLimit && count($ids) === $fetchedCount; if (!$resolveChunk($ids)) { // Write error: leave the cursor at the last fully-resolved chunk // so the next tick retries this one. Never reset on failure. return $totalReconciled; } $cursor = (int)max($ids); $this->writeReconcileCursor($cursor); $totalReconciled += count($ids); $batchSizer->observe($ids, abj_clock()->nowFloat() - $iterationStart); if ($lastChunk) { // Last (partial) chunk drained the table: pass complete, reset. $this->resetReconcileCursor(); if ($onPassComplete !== null) { $onPassComplete(); } break; } } if ($totalReconciled > 0) { $this->logger->infoMessage(sprintf( "reconcileRedirectsDenormColumns: recomputed %d redirect rows in %.2fs.", $totalReconciled, abj_clock()->nowFloat() - $start )); } return $totalReconciled; } /** * Read the next chunk of redirect ids strictly past the cursor, in id order. * * Unlike the Step 3a backfill (which selects on the dest_for_view IS NULL * sentinel, a self-clearing predicate), the reconcile must revisit rows whose * columns are already non-NULL, so progress can only be tracked by an * explicit ascending-id cursor rather than by the data itself. * * @param string $redirectsTable * @param int $cursor Highest id reconciled so far (exclusive lower bound). * @param int $chunkSize * @return array|null List of ids (possibly empty), or null on a query error. */ private function fetchNextReconcileChunkIds(string $redirectsTable, int $cursor, int $chunkSize): ?array { $result = $this->dbCore->queryAndGetResults( "SELECT id FROM " . $redirectsTable . " FORCE INDEX (PRIMARY) WHERE id > " . (int)$cursor . " ORDER BY id ASC LIMIT " . $chunkSize ); $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : ''; if (($result['query_succeeded'] ?? null) !== true || (array_key_exists('timed_out', $result) && $result['timed_out'] !== false) || $lastError !== '' || !array_key_exists('rows', $result) || !is_array($result['rows'])) { $this->logger->warn("reconcileRedirectsDenormColumns: stopping after read error: " . $lastError); return null; } $rows = $result['rows']; $ids = array(); foreach ($rows as $row) { $id = is_array($row) ? ABJ_404_Solution_ExactInteger::read($row['id'] ?? null, 1) : null; if ($id === null) { return null; } $ids[] = $id; } return $ids; } /** * Read the persisted reconcile cursor (highest id reconciled in the current * pass), 0 when no pass is in flight. * * @return int */ private function readReconcileCursor(): int { if (!function_exists('get_option')) { return 0; } $raw = ABJ_404_Solution_OptionShapeRead::scalar($this->getRedirectsDenormReconcileCursorOption(), 0); $cursor = is_scalar($raw) ? (int)$raw : 0; return max(0, $cursor); } /** * Persist the reconcile cursor so a budget-exhausted pass resumes mid-table * on the next nightly tick. Non-autoloaded (cron-only read). * * @param int $cursor * @return void */ private function writeReconcileCursor(int $cursor): void { if (!function_exists('update_option')) { return; } update_option($this->getRedirectsDenormReconcileCursorOption(), (string)$cursor, false); } /** * Reset the cursor to 0 once a full pass completes, so the next nightly tick * starts a fresh sweep from the top of the table. * * @return void */ private function resetReconcileCursor(): void { $this->writeReconcileCursor(0); } }