| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Per-site self-heal prologue: verify required plugin tables exist on every heavy |
| 9 |
* boot path, and either trigger full CREATE TABLE recovery or run the lightweight |
| 10 |
* drift-correction sweep when everything is present. |
| 11 |
* |
| 12 |
* The single named token every "heavy boot" entry point routes through before |
| 13 |
* doing useful work. Pattern 1 in docs/PROACTIVE_BUG_DISCOVERY.md: bugs recurred |
| 14 |
* because each recovery primitive (repairStrippedViewCacheTable, |
| 15 |
* updateTableEngineToInnoDB, createIndexes, correctCollations, adoptOrphanedTables) |
| 16 |
* was reachable from one boot path only. Centralising the fan-out here makes the |
| 17 |
* reachability invariant testable (SelfHealingPrologueReachabilityTest) instead of |
| 18 |
* relying on a developer to remember to wire every primitive into every new entry |
| 19 |
* point. |
| 20 |
* |
| 21 |
* Reached by Loader heavy boot, multisite activation/upgrade batches, the daily |
| 22 |
* cron via {@see ABJ_404_Solution_DatabaseUpgradeDailyMaintenance}, and direct |
| 23 |
* coordinator calls. |
| 24 |
*/ |
| 25 |
class ABJ_404_Solution_DatabaseUpgradeSelfHeal extends ABJ_404_Solution_DatabaseUpgradeComponent { |
| 26 |
|
| 27 |
/** @return void */ |
| 28 |
public function runDailyInsuranceCheck() { |
| 29 |
// Always verify current site only |
| 30 |
// Per-site cron execution ensures network coverage without O(N²) duplication |
| 31 |
$this->verifyAndRepairCurrentSite(); |
| 32 |
} |
| 33 |
|
| 34 |
/** |
| 35 |
* Self-healing boot prologue. The single named token every "heavy boot" |
| 36 |
* entry point routes through before doing useful work. |
| 37 |
* |
| 38 |
* Documented order, delegated to verifyAndRepairCurrentSite() (the |
| 39 |
* existing per-site insurance check): |
| 40 |
* |
| 41 |
* 1. Discover required tables from create*Table.sql files (same source |
| 42 |
* of truth as runInitialCreateTables()). |
| 43 |
* 2. If ANY table is missing, call createDatabaseTables(false), which |
| 44 |
* funnels through reallyCreateDatabaseTables() into runInitialCreateTables(), |
| 45 |
* reaching: |
| 46 |
* a. repairStrippedViewCacheTable() (3.3.3 column-drop recovery), |
| 47 |
* b. verifyTableMaterialized() (d9024114 per-table CREATE verify), |
| 48 |
* c. verifyColumns() (schema-drift tolerance), |
| 49 |
* then updateTableEngineToInnoDB(), correctCollations(), createIndexes(), |
| 50 |
* and renameAbj404TablesToLowerCase() into adoptOrphanedTables(). |
| 51 |
* 3. If all tables exist, run the drift-correction sweep: |
| 52 |
* a. correctCollations() (utf8mb4 drift), |
| 53 |
* b. createIndexes() (lost-index recovery after hosting migrations), |
| 54 |
* c. updateTableEngineToInnoDB() (MyISAM reversion recovery). |
| 55 |
* 4. adoptOrphanedTables() covers prefix migrations even when tables |
| 56 |
* under the current prefix exist. |
| 57 |
* |
| 58 |
* Idempotent: safe to call multiple times in the same request. The |
| 59 |
* SHOW TABLES check makes the tables-exist branch cheap (~1ms per table). |
| 60 |
* |
| 61 |
* Light-path entry points (frontend 404 dispatch, admin AJAX, REST, |
| 62 |
* WP-CLI commands, on-demand caches like permalink-cache rebuild) opt |
| 63 |
* out via the SelfHealingPrologueReachabilityTest allowlist because |
| 64 |
* (a) the prologue runs nightly via the daily cron tick so drift is |
| 65 |
* caught within 24h, and (b) running it on every request would be a |
| 66 |
* perf regression and risks cron-lock contention under high traffic. |
| 67 |
* Those paths rely on |
| 68 |
* `queryAndGetResults::attemptMissingTableRepairAndRetry()` for |
| 69 |
* per-query recovery instead. |
| 70 |
* |
| 71 |
* @return void |
| 72 |
*/ |
| 73 |
public function runSelfHealPrologue() { |
| 74 |
$this->verifyAndRepairCurrentSite(); |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* The bounded counterpart to runSelfHealPrologue(), for a request a person |
| 79 |
* is waiting on: close whatever plugin tables are missing and do nothing |
| 80 |
* else. |
| 81 |
* |
| 82 |
* Exists because the plugin's own admin pages used to recover a dropped |
| 83 |
* table for free rather than on purpose. Every render ran the status-count |
| 84 |
* aggregate inline, that aggregate read the redirects table, so a missing |
| 85 |
* table surfaced as a failing query and the DAO's per-query auto-repair |
| 86 |
* closed it. Nothing declared the repair; it was a side effect of an |
| 87 |
* unrelated read. When that read became cache-only with a rate-limited |
| 88 |
* background refresh -- correctly, it was a full aggregate on every page |
| 89 |
* view -- the recovery went with it, and a page that renders no list stopped |
| 90 |
* healing at all: inside the refresh cooldown a settings screen queries the |
| 91 |
* redirects table not at all, so nothing fails and nothing repairs. |
| 92 |
* |
| 93 |
* Bounded means the missing-only pass, never createDatabaseTables(): one |
| 94 |
* metadata probe per DDL file plus CREATE TABLE for whatever is genuinely |
| 95 |
* absent. The schema-wide passes (collations, indexes, engine, orphan |
| 96 |
* adoption, backfills) scale with site size and belong to the maintenance |
| 97 |
* tick that repairMissingTables() queues for itself when it creates |
| 98 |
* something. |
| 99 |
* |
| 100 |
* A repair failure is contained here and never reaches the caller. This is |
| 101 |
* insurance, not a precondition: a host that refuses DDL (read-only |
| 102 |
* replica, revoked CREATE grant, full disk) still owes the admin the page, |
| 103 |
* which is where they read the log line explaining why. |
| 104 |
* |
| 105 |
* @return void |
| 106 |
*/ |
| 107 |
public function repairMissingTablesForRequest() { |
| 108 |
try { |
| 109 |
$this->upgrades()->bootstrapUpgrade()->repairMissingTables(); |
| 110 |
} catch (\Throwable $e) { |
| 111 |
$this->logger->warn( |
| 112 |
'Bounded missing-table repair failed during a user-facing request: ' |
| 113 |
. get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage() |
| 114 |
); |
| 115 |
} |
| 116 |
} |
| 117 |
|
| 118 |
/** |
| 119 |
* Verify and repair tables for the current site only. |
| 120 |
* |
| 121 |
* Derives the list of required tables dynamically from create*Table.sql files |
| 122 |
* (same source of truth as runInitialCreateTables()), so new tables are |
| 123 |
* automatically included without any code changes here. |
| 124 |
* |
| 125 |
* If ANY table is missing, triggers full table creation/repair. |
| 126 |
* |
| 127 |
* @return void |
| 128 |
*/ |
| 129 |
public function verifyAndRepairCurrentSite() { |
| 130 |
global $wpdb; |
| 131 |
|
| 132 |
// Derive required tables from SQL DDL files -- same source of truth as runInitialCreateTables(). |
| 133 |
$requiredTables = []; |
| 134 |
foreach ($this->upgrades()->bootstrapUpgrade()->discoverPermanentDDLFiles() as $ddlEntry) { |
| 135 |
$requiredTables[] = $ddlEntry['bareTableName']; |
| 136 |
} |
| 137 |
|
| 138 |
$missingTables = []; |
| 139 |
$normalizedPrefix = $this->dbCore->tableNameResolver()->getLowercasePrefix(); |
| 140 |
|
| 141 |
// Check each required table |
| 142 |
foreach ($requiredTables as $tableName) { |
| 143 |
$fullTableName = $this->dbCore->tableNameResolver()->getPrefixedTableName($tableName); |
| 144 |
// 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) |
| 145 |
$tableExists = $wpdb->get_var("SHOW TABLES LIKE '{$fullTableName}'"); |
| 146 |
|
| 147 |
if (!$tableExists) { |
| 148 |
$missingTables[] = $tableName; |
| 149 |
} |
| 150 |
} |
| 151 |
|
| 152 |
// If any tables are missing, run repair |
| 153 |
if (!empty($missingTables)) { |
| 154 |
$this->logger->infoMessage(sprintf( |
| 155 |
"Site %d (prefix: %s, normalized: %s) is missing %d table(s): %s. Running repair...", |
| 156 |
get_current_blog_id(), |
| 157 |
$wpdb->prefix, |
| 158 |
$normalizedPrefix, |
| 159 |
count($missingTables), |
| 160 |
implode(', ', $missingTables) |
| 161 |
)); |
| 162 |
|
| 163 |
// Repair: call the same idempotent routine activation uses |
| 164 |
// This is safe because createDatabaseTables() is idempotent |
| 165 |
$this->upgrades()->bootstrapUpgrade()->createDatabaseTables(false); // false = not updating to new version |
| 166 |
|
| 167 |
$this->logger->infoMessage("Table repair complete for site " . get_current_blog_id()); |
| 168 |
} else { |
| 169 |
// Tables exist - insurance: verify/correct collations, ensure indexes exist, |
| 170 |
// and enforce InnoDB engine. This catches collation drift (including column-level |
| 171 |
// drift), missed index additions, and MyISAM reversions from hosting migrations |
| 172 |
// or table restores -- without waiting for the next plugin upgrade. |
| 173 |
$this->upgrades()->collationDriftUpgrade()->correctCollations(); |
| 174 |
$this->upgrades()->indexesUpgrade()->createIndexes(); |
| 175 |
$this->upgrades()->engineNormalizationUpgrade()->updateTableEngineToInnoDB(); |
| 176 |
} |
| 177 |
|
| 178 |
// Check for orphaned tables under a stale/changed prefix and adopt their data. |
| 179 |
// This catches hosting migrations or wp-config prefix changes that leave plugin |
| 180 |
// tables under the old prefix. The method is idempotent -- no-op when nothing to adopt. |
| 181 |
// (On the missing-tables path above, createDatabaseTables() already triggers adoption |
| 182 |
// via renameAbj404TablesToLowerCase(), but running it again is harmless and covers |
| 183 |
// edge cases where tables exist under the current prefix but orphans remain.) |
| 184 |
$this->upgrades()->orphanAdoptionUpgrade()->adoptOrphanedTables(); |
| 185 |
} |
| 186 |
} |
| 187 |
|