scheduleSingleIfMissing( ABJ_404_Solution_CronScheduler::HOOK_REPAIR_ADMIN_TABLES, 1 ); if (!$scheduled) { self::logAdminTableRepairWarning( 'Admin table repair enqueue failed: ' . $scheduler->lastFailureDetail() ); return false; } } catch (Throwable $e) { self::logAdminTableRepairWarning( 'Admin table repair enqueue failed: ' . get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage() ); return false; } if (function_exists('set_transient')) { set_transient(self::ADMIN_TABLE_REPAIR_QUEUE_TRANSIENT, 1, self::ADMIN_TABLE_REPAIR_QUEUE_TTL); } return true; } /** * Repair indexes and derived redirect data outside the foreground request. * WP-Cron listener body: one tick, re-queued while it is still making * progress on remaining rows. * * @return void */ public function repairAdminTables(): void { $lock = $this->syncUtils->synchronizerAcquireLockTry(self::ADMIN_TABLE_REPAIR_LOCK); if ($lock === '') { $this->scheduleAdminTableRepairContinuation(); return; } try { $outcome = $this->repairAdminTablesLocked(null); if (!$outcome['complete'] && $outcome['progressed']) { $this->scheduleAdminTableRepairContinuation(); } } catch (Throwable $e) { $this->logger->warn( 'Admin table repair failed: ' . get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage() ); $this->scheduleAdminTableRepairContinuation(self::ADMIN_TABLE_REPAIR_QUEUE_TTL); } finally { $this->syncUtils->synchronizerReleaseLock($lock, self::ADMIN_TABLE_REPAIR_LOCK); } } /** * Ordinary admin views consume the queued repair when the site has proved * WP-Cron is stalled, through the same lock and body as the cron listener. * * @return bool Whether this request registered the post-response slice. */ public function armAdminTableRepairCronFallback(): bool { if ($this->adminTableRepairContinuation === null) { $this->adminTableRepairContinuation = new ABJ_404_Solution_QueuedCronEventAdminContinuation(array( 'hook' => ABJ_404_Solution_CronScheduler::HOOK_REPAIR_ADMIN_TABLES, 'lockKey' => self::ADMIN_TABLE_REPAIR_LOCK, 'label' => 'admin table repair', 'sync' => $this->syncUtils, 'clock' => abj_clock(), 'cron' => $this->cronScheduler !== null ? $this->cronScheduler : abj_cron_scheduler(), 'logger' => $this->logger, 'pending' => function (): bool { return $this->adminTableRepairPending(); }, 'slice' => function (float $deadline): array { return $this->repairAdminTablesLocked($deadline); }, 'describe' => function (): array { return array( 'logs_url_sort_ready' => (new ABJ_404_Solution_LogsUrlSortRepository($this->dbCore))->isReady(), 'denorm_backlog' => $this->upgrades()->redirectsDenormBackfillUpgrade()->hasDeferredBacklog(), ); }, )); } return $this->adminTableRepairContinuation->arm(); } /** * The repair body, run under ADMIN_TABLE_REPAIR_LOCK by its caller. * Completion is read from the rows afterwards, never from a cursor. * * @param ?float $deadline Absolute clock deadline for the row drains; null * keeps the cron tick's own budgets. * @return array{complete:bool,progressed:bool} */ private function repairAdminTablesLocked(?float $deadline): array { // Use the normal additive schema verifier before the index pass: // dropped legacy columns must recover in the same background tick. $logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}'); $logsDdl = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . '/../../sql/createLogTable.sql'); $this->upgrades()->schemaDiffUpgrade()->verifyColumns($logsTable, $logsDdl); $this->upgrades()->indexesUpgrade()->createIndexes(); $logs = (new ABJ_404_Solution_LogsUrlSortRepository($this->dbCore))->backfill( $deadline === null ? 5.0 : $this->secondsUntil($deadline)); $written = $this->upgrades()->redirectsDenormBackfillUpgrade()->runDeferredDenormBackfillPass( $deadline === null ? null : $this->secondsUntil($deadline)); return array( 'complete' => !$this->adminTableRepairPending(), 'progressed' => $logs['updated'] > 0 || $written > 0, ); } /** Whether the rows still need any drain this repair runs. */ private function adminTableRepairPending(): bool { return !(new ABJ_404_Solution_LogsUrlSortRepository($this->dbCore))->isReady() || $this->upgrades()->redirectsDenormBackfillUpgrade()->hasDeferredBacklog(); } private function secondsUntil(float $deadline): float { return max(0.0, $deadline - abj_clock()->nowFloat()); } /** @return void */ private function scheduleAdminTableRepairContinuation(int $delaySeconds = 1): void { try { $scheduler = $this->cronScheduler !== null ? $this->cronScheduler : abj_cron_scheduler(); if (!$scheduler->scheduleSingleIfMissing( ABJ_404_Solution_CronScheduler::HOOK_REPAIR_ADMIN_TABLES, $delaySeconds )) { $this->logger->warn( 'Admin table repair continuation failed: ' . $scheduler->lastFailureDetail() ); } } catch (Throwable $e) { $this->logger->warn( 'Admin table repair continuation failed: ' . get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage() ); } } /** @param string $message @return void */ private static function logAdminTableRepairWarning(string $message): void { try { if (function_exists('abj_service')) { $logger = abj_service('logging'); if (is_object($logger) && method_exists($logger, 'warn')) { $logger->warn($message); return; } } } catch (Throwable $e) { $message .= '; logger failure: ' . get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage(); } abj404_logPhpFallback('service-resolution-fallback', $message); } /** @return void */ public function runDailyInsuranceCheck() { // Always verify current site only // Per-site cron execution ensures network coverage without O(N²) duplication $this->verifyAndRepairCurrentSite(); } /** * Self-healing boot prologue. The single named token every "heavy boot" * entry point routes through before doing useful work. * * Documented order, delegated to verifyAndRepairCurrentSite() (the * existing per-site insurance check): * * 1. Discover required tables from create*Table.sql files (same source * of truth as runInitialCreateTables()). * 2. If ANY table is missing, call createDatabaseTables(false), which * funnels through reallyCreateDatabaseTables() into runInitialCreateTables(), * reaching: * a. repairStrippedViewCacheTable() (3.3.3 column-drop recovery), * b. verifyTableMaterialized() (d9024114 per-table CREATE verify), * c. verifyColumns() (schema-drift tolerance), * then updateTableEngineToInnoDB(), correctCollations(), createIndexes(), * and renameAbj404TablesToLowerCase() into adoptOrphanedTables(). * 3. If all tables exist, run the drift-correction sweep: * a. correctCollations() (utf8mb4 drift), * b. createIndexes() (lost-index recovery after hosting migrations), * c. updateTableEngineToInnoDB() (MyISAM reversion recovery). * 4. adoptOrphanedTables() covers prefix migrations even when tables * under the current prefix exist. * * Idempotent: safe to call multiple times in the same request. The * SHOW TABLES check makes the tables-exist branch cheap (~1ms per table). * * Light-path entry points (frontend 404 dispatch, admin AJAX, REST, * WP-CLI commands, on-demand caches like permalink-cache rebuild) opt * out via the SelfHealingPrologueReachabilityTest allowlist because * (a) the prologue runs nightly via the daily cron tick so drift is * caught within 24h, and (b) running it on every request would be a * perf regression and risks cron-lock contention under high traffic. * Those paths rely on * `queryAndGetResults::attemptMissingTableRepairAndRetry()` for * per-query recovery instead. * * @return void */ public function runSelfHealPrologue() { $this->verifyAndRepairCurrentSite(); } /** * The bounded counterpart to runSelfHealPrologue(), for a request a person * is waiting on: close whatever plugin tables are missing and do nothing * else. * * Exists because the plugin's own admin pages used to recover a dropped * table for free rather than on purpose. Every render ran the status-count * aggregate inline, that aggregate read the redirects table, so a missing * table surfaced as a failing query and the DAO's per-query auto-repair * closed it. Nothing declared the repair; it was a side effect of an * unrelated read. When that read became cache-only with a rate-limited * background refresh -- correctly, it was a full aggregate on every page * view -- the recovery went with it, and a page that renders no list stopped * healing at all: inside the refresh cooldown a settings screen queries the * redirects table not at all, so nothing fails and nothing repairs. * * Bounded means the missing-only pass, never createDatabaseTables(): one * metadata probe per DDL file plus CREATE TABLE for whatever is genuinely * absent. The schema-wide passes (collations, indexes, engine, orphan * adoption, backfills) scale with site size and belong to the maintenance * tick that repairMissingTables() queues for itself when it creates * something. * * A repair failure is contained here and never reaches the caller. This is * insurance, not a precondition: a host that refuses DDL (read-only * replica, revoked CREATE grant, full disk) still owes the admin the page, * which is where they read the log line explaining why. * * @return void */ public function repairMissingTablesForRequest() { try { $this->upgrades()->bootstrapUpgrade()->repairMissingTables(); } catch (\Throwable $e) { $this->logger->warn( 'Bounded missing-table repair failed during a user-facing request: ' . get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage() ); } } /** * Verify and repair tables for the current site only. * * Derives the list of required tables dynamically from create*Table.sql files * (same source of truth as runInitialCreateTables()), so new tables are * automatically included without any code changes here. * * If ANY table is missing, triggers full table creation/repair. * * @return void */ public function verifyAndRepairCurrentSite() { global $wpdb; // Derive required tables from SQL DDL files -- same source of truth as runInitialCreateTables(). $requiredTables = []; foreach ($this->upgrades()->bootstrapUpgrade()->discoverPermanentDDLFiles() as $ddlEntry) { $requiredTables[] = $ddlEntry['bareTableName']; } $missingTables = []; $normalizedPrefix = $this->dbCore->tableNameResolver()->getLowercasePrefix(); // Check each required table foreach ($requiredTables as $tableName) { $fullTableName = $this->dbCore->tableNameResolver()->getPrefixedTableName($tableName); // DAO-bypass-approved: Schema-bootstrap inside repairMissingTables() -- runs before CREATE TABLE; routing through DAO would trigger the same missing-table auto-repair we are about to invoke ourselves (recursion) $tableExists = ABJ_404_Solution_DatabaseDriverCall::getVar($wpdb, "SHOW TABLES LIKE '{$fullTableName}'"); if (!$tableExists) { $missingTables[] = $tableName; } } // If any tables are missing, run repair if (!empty($missingTables)) { $this->logger->infoMessage(sprintf( "Site %d (prefix: %s, normalized: %s) is missing %d table(s): %s. Running repair...", get_current_blog_id(), $wpdb->prefix, $normalizedPrefix, count($missingTables), implode(', ', $missingTables) )); // Repair: call the same idempotent routine activation uses // This is safe because createDatabaseTables() is idempotent $this->upgrades()->bootstrapUpgrade()->createDatabaseTables(false); // false = not updating to new version $this->logger->infoMessage("Table repair complete for site " . get_current_blog_id()); } else { // Tables exist - insurance: verify/correct collations, ensure indexes exist, // and enforce InnoDB engine. This catches collation drift (including column-level // drift), missed index additions, and MyISAM reversions from hosting migrations // or table restores -- without waiting for the next plugin upgrade. $this->upgrades()->collationDriftUpgrade()->correctCollations(); $this->upgrades()->indexesUpgrade()->createIndexes(); $this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB(); } // Check for orphaned tables under a stale/changed prefix and adopt their data. // This catches hosting migrations or wp-config prefix changes that leave plugin // tables under the old prefix. The method is idempotent -- no-op when nothing to adopt. // (On the missing-tables path above, createDatabaseTables() already triggers adoption // via renameAbj404TablesToLowerCase(), but running it again is harmless and covers // edge cases where tables exist under the current prefix but orphans remain.) $this->upgrades()->orphanAdoptionUpgrade()->adoptOrphanedTables(); } }