PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / database / upgrades / DatabaseUpgradeSelfHeal.php

DatabaseUpgradeSelfHeal.php in 404 Solution trunk, at includes/database/upgrades/DatabaseUpgradeSelfHeal.php

187 lines 9.1 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-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