PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 / DatabaseUpgradeIndexes.php

DatabaseUpgradeIndexes.php in 404 Solution 4.3.0, at includes/database/upgrades/DatabaseUpgradeIndexes.php

460 lines 20.6 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 * Index discovery, parsing, verification, and add-index DDL helpers for
9 * ABJ_404_Solution_DatabaseUpgradesEtc, plus the small ensureLogs* helpers
10 * that gate online DDL on the logsv2 table.
11 *
12 * Extracted from DatabaseUpgradesEtc.php in 4.1.12 to keep the host class
13 * under the FileSizeLimitsTest line budget. No behavior change.
14 */
15 class ABJ_404_Solution_DatabaseUpgradeIndexes extends ABJ_404_Solution_DatabaseUpgradeComponent {
16
17 /** @return void */
18 function createIndexes() {
19 foreach ($this->upgrades()->bootstrapUpgrade()->discoverPermanentDDLFiles() as $ddlEntry) {
20 $tableName = $this->dbCore->doTableNameReplacements($ddlEntry['placeholder']);
21 $query = $this->dbCore->doTableNameReplacements($ddlEntry['ddlContent']);
22 $this->verifyIndexes($tableName, $query);
23 }
24 }
25
26 /**
27 * @param string $tableName
28 * @param string $createTableStatementGoal
29 * @return void
30 */
31 function verifyIndexes($tableName, $createTableStatementGoal) {
32
33 // get the indexes.
34 // Pattern matches lines starting with "KEY" / "UNIQUE KEY" - handles composite indexes with commas inside parens
35 // Indexes: treat the CREATE TABLE SQL as source of truth, and treat the database as truth
36 // for what exists (SHOW INDEX). Avoid parsing SHOW CREATE TABLE output, which is vendor/format dependent.
37 $goalSpecsByName = $this->parseIndexSpecsFromCreateTableSql($createTableStatementGoal);
38
39 $missingIndexNames = [];
40 foreach (array_keys($goalSpecsByName) as $indexName) {
41 if (!$this->indexExists($tableName, $indexName)) {
42 $missingIndexNames[] = $indexName;
43 }
44 }
45 $missingIndexNames = $this->prioritizeMissingIndexNames($missingIndexNames);
46
47 if (count($missingIndexNames) > 0) {
48 $this->logger->infoMessage($this->getUpgradeRuntimeId() . ": On {$tableName} I'm adding missing indexes: " . implode(', ', $missingIndexNames));
49 }
50
51 // Get actual columns in the table so we can skip indexes that reference missing columns.
52 $existingColumns = [];
53 $showColResult = $this->dbCore->queryAndGetResults("SHOW COLUMNS FROM " . $tableName);
54 $showColRows = is_array($showColResult['rows'] ?? null) ? $showColResult['rows'] : [];
55 foreach ($showColRows as $colRow) {
56 if (!is_array($colRow)) { continue; }
57 foreach ($colRow as $key => $value) {
58 if (strtolower((string)$key) === 'field' && is_scalar($value)) {
59 $existingColumns[] = strtolower((string)$value);
60 break;
61 }
62 }
63 }
64
65 foreach ($missingIndexNames as $indexName) {
66 $spec = $goalSpecsByName[$indexName] ?? null;
67 if (empty($spec)) {
68 continue;
69 }
70
71 // Verify all columns referenced by this index actually exist in the table.
72 if (!empty($existingColumns)) {
73 $indexColNames = [];
74 preg_match_all('/`([^`]+)`/', $spec['columns'], $colMatches);
75 if (!empty($colMatches[1])) {
76 $indexColNames = array_map('strtolower', $colMatches[1]);
77 }
78 $missingCols = array_diff($indexColNames, $existingColumns);
79 if (!empty($missingCols)) {
80 $this->logger->warn("Skipping index {$indexName} on {$tableName}: " .
81 "column(s) " . implode(', ', $missingCols) . " do not exist in the table.");
82 continue;
83 }
84 }
85
86 $spellingCacheTableName = $this->dbCore->doTableNameReplacements('{wp_abj404_spelling_cache}');
87 $tableNameLower = strtolower($tableName);
88 if ($tableNameLower == $spellingCacheTableName && !empty($spec['unique'])) {
89 $this->contentRepo->deleteSpellingCache();
90 }
91
92 $this->addIndexWithOnlineFallback($tableName, $spec['name'], $spec['columns'], $spec['unique']);
93 }
94 }
95
96 /**
97 * Add one missing index. Try online/no-lock DDL first; if the server or
98 * storage engine rejects those hints, retry the legacy plain ADD INDEX.
99 *
100 * @param string $tableName
101 * @param string $indexName
102 * @param string $columnsSql
103 * @param bool $unique
104 * @return void
105 */
106 private function addIndexWithOnlineFallback($tableName, $indexName, $columnsSql, $unique): void {
107 $addStatement = $this->buildAddIndexStatementFromParts($tableName, $indexName, $columnsSql, $unique, true);
108 $result = $this->dbCore->queryAndGetResults($addStatement);
109 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
110 if ($lastError !== '') {
111 $this->logger->warn("Online index add for {$indexName} on {$tableName} failed; retrying without online DDL hints: " .
112 $lastError . " (query: {$addStatement})");
113 $addStatement = $this->buildAddIndexStatementFromParts($tableName, $indexName, $columnsSql, $unique, false);
114 $result = $this->dbCore->queryAndGetResults($addStatement);
115 $lastError = isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : '';
116 if ($lastError !== '') {
117 $this->logger->errorMessage("Failed to add index {$indexName} to {$tableName}: " .
118 $lastError . " (query: {$addStatement})");
119 return;
120 }
121 }
122 $this->logger->infoMessage("I added an index: " . $addStatement);
123 }
124
125 /**
126 * Put redirect admin-view performance indexes before lower-impact recovery
127 * indexes. Each index is still added by its own ALTER TABLE statement; this
128 * only controls which missing index is attempted first on weak hosts.
129 *
130 * @param array<int, string> $missingIndexNames
131 * @return array<int, string>
132 */
133 private function prioritizeMissingIndexNames(array $missingIndexNames): array {
134 if (count($missingIndexNames) < 2) {
135 return $missingIndexNames;
136 }
137 $originalPosition = array();
138 foreach ($missingIndexNames as $i => $name) {
139 $originalPosition[$name] = $i;
140 }
141 $priority = array_flip(array(
142 'idx_dest_for_view_id',
143 'idx_status_disabled_url_sort_id',
144 'idx_disabled_url_sort_id',
145 'idx_status_disabled_dest_sort_id',
146 'idx_disabled_dest_sort_id',
147 'idx_status_disabled_logshits_id',
148 'idx_disabled_logshits_id',
149 'idx_status_disabled_last_used_id',
150 'idx_disabled_last_used_id',
151 'idx_status_disabled_score_id',
152 'idx_disabled_score_id',
153 'idx_status_disabled',
154 'idx_url_disabled_status',
155 'idx_canonical_url',
156 ));
157 usort($missingIndexNames, function ($a, $b) use ($priority, $originalPosition) {
158 $pa = array_key_exists($a, $priority) ? $priority[$a] : 1000;
159 $pb = array_key_exists($b, $priority) ? $priority[$b] : 1000;
160 if ($pa === $pb) {
161 return ($originalPosition[$a] ?? 0) <=> ($originalPosition[$b] ?? 0);
162 }
163 return $pa <=> $pb;
164 });
165 return $missingIndexNames;
166 }
167
168 /**
169 * @param string $tableName
170 * @param string $indexName
171 * @return bool
172 */
173 private function indexExists($tableName, $indexName) {
174 global $wpdb;
175 $sql = $wpdb->prepare("SHOW INDEX FROM {$tableName} WHERE Key_name = %s", $indexName);
176 // DAO-bypass-approved: indexExists() schema-introspection helper (already prepared); DDL pre-check before ALTER TABLE
177 $results = $wpdb->get_results($sql, ARRAY_A);
178 return !empty($results);
179 }
180
181 /**
182 * Parse an index DDL line from our CREATE TABLE SQL into a structured spec.
183 *
184 * Accepts forms like:
185 * - KEY `name` (`col`(190), `other`)
186 * - UNIQUE KEY `name` (`col`)
187 * - KEY `name` (`col`) USING BTREE
188 *
189 * Returns null if the line doesn't look like a KEY/UNIQUE KEY definition.
190 *
191 * @param string $indexDDL
192 * @return array{name: string, columns: string, unique: bool}|null
193 */
194 private function parseIndexDDLToSpec($indexDDL) {
195 $indexDDL = trim($indexDDL);
196 // Tolerate a trailing comma — the line-extracting regex pulls each
197 // KEY definition out as-is from the surrounding CREATE TABLE list,
198 // and any KEY that isn't the LAST one will end with a comma. Same
199 // canonical form either way.
200 $indexDDL = rtrim($indexDDL, ',');
201 $matches = [];
202 if (!preg_match('/^(unique\\s+)?key\\s+`?([^`\\s]+)`?\\s*(\\(.+\\))\\s*(?:using\\s+\\w+)?\\s*$/i', $indexDDL, $matches)) {
203 return null;
204 }
205
206 return [
207 'name' => $matches[2],
208 'columns' => $matches[3],
209 'unique' => !empty($matches[1]),
210 ];
211 }
212
213 /**
214 * Extract index specs from a CREATE TABLE statement (plugin SQL templates).
215 *
216 * @param string $createTableSql
217 * @return array<string, array{name:string, columns:string, unique:bool}> keyed by index name
218 */
219 private function parseIndexSpecsFromCreateTableSql($createTableSql) {
220 if (!is_string($createTableSql) || $createTableSql === '') {
221 return [];
222 }
223
224 $matches = [];
225 preg_match_all('/^\\s*(?:unique\\s+)?key\\s+.+?\\s*$/im', $createTableSql, $matches);
226 $lines = $matches[0];
227
228 $specsByName = [];
229 foreach ($lines as $line) {
230 $spec = $this->parseIndexDDLToSpec($line);
231 if (empty($spec) || empty($spec['name'])) {
232 continue;
233 }
234 $specsByName[$spec['name']] = $spec;
235 }
236
237 return $specsByName;
238 }
239
240 /**
241 * Build a valid ALTER TABLE ... ADD INDEX statement from structured parts.
242 *
243 * @param string $tableName
244 * @param string $indexName
245 * @param string $columnsSql Must include surrounding parentheses, e.g. "(`a`, `b`(190))"
246 * @param bool $unique
247 * @param bool $online Whether to append ALGORITHM=INPLACE, LOCK=NONE.
248 * @return string
249 */
250 private function buildAddIndexStatementFromParts($tableName, $indexName, $columnsSql, $unique, $online = false) {
251 global $wpdb;
252 /** @var \wpdb $wpdb */
253 $serverVersion = is_object($wpdb) && method_exists($wpdb, 'db_version') ? ($wpdb->db_version() ?: '') : '';
254 $serverInfo = is_object($wpdb) && property_exists($wpdb, 'db_server_info') ? ($wpdb->db_server_info ?? '') : '';
255
256 $isMaria = stripos($serverInfo, 'mariadb') !== false || stripos($serverVersion, 'maria') !== false;
257 $cleanedVersion = preg_replace('/[^\d\.]/', '', $serverVersion) ?? '';
258 $supportsIfNotExists = $isMaria && version_compare($cleanedVersion, '10.5', '>=');
259
260 $indexType = $unique ? 'unique index' : 'index';
261 $ifNotExists = $supportsIfNotExists ? ' if not exists' : '';
262 $onlineClause = $online ? ', ALGORITHM=INPLACE, LOCK=NONE' : '';
263
264 return "alter table " . $tableName . " add " . $indexType . $ifNotExists . " `" . $indexName . "` " . trim($columnsSql) . $onlineClause;
265 }
266
267 /**
268 * @param string $logsTable
269 * @param string|null $createSqlOverride
270 * @return void
271 */
272 public function ensureLogsCompositeIndex($logsTable, $createSqlOverride = null) {
273 $indexName = 'idx_requested_url_timestamp';
274 $createSql = is_string($createSqlOverride) ? $createSqlOverride : ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../../sql/createLogTable.sql");
275 $specsByName = $this->parseIndexSpecsFromCreateTableSql($createSql);
276 $spec = $specsByName[$indexName] ?? null;
277 if (empty($spec)) {
278 $this->logger->errorMessage("Failed to add {$indexName} to {$logsTable}: index definition not found in createLogTable.sql");
279 return;
280 }
281
282 if ($this->indexExists($logsTable, $indexName)) {
283 return;
284 }
285 $query = $this->buildAddIndexStatementFromParts($logsTable, $spec['name'], $spec['columns'], $spec['unique']);
286 $results = $this->dbCore->queryAndGetResults($query);
287 $lastError = isset($results['last_error']) && is_scalar($results['last_error'])
288 ? (string)$results['last_error']
289 : '';
290 if ($lastError !== '') {
291 $this->logger->errorMessage("Failed to add {$indexName} to {$logsTable}: " . $lastError . " (query: {$query})");
292 } else {
293 $this->logger->infoMessage("Added {$indexName} to {$logsTable} using query: {$query}");
294 }
295 }
296
297 /**
298 * Add the canonical_url column to logsv2 with online DDL when supported.
299 *
300 * Mirrors ensureLogsCompositeIndex(): a small idempotent helper that runs
301 * ahead of the generic verifyColumns() flow so the column add can use
302 * ALGORITHM=INPLACE, LOCK=NONE on InnoDB ≥ 5.6 (no table lock during the
303 * rewrite). On engines that don't support online DDL for ADD COLUMN the
304 * explicit clause causes the statement to fail with
305 * ER_ALTER_OPERATION_NOT_SUPPORTED; we then fall back to a bare ALTER —
306 * which is what verifyColumns() also runs as the safety net.
307 *
308 * The matching idx_canonical_url is added by the standard verifyIndexes()
309 * flow — index adds use online DDL by default on InnoDB ≥ 5.6 so a
310 * separate ensure helper isn't required for the index.
311 *
312 * @param string $logsTable
313 * @return void
314 */
315 public function ensureLogsv2CanonicalUrlColumn(string $logsTable): void {
316 if ($this->upgrades()->canonicalUrlBackfillUpgrade()->columnExists($logsTable, 'canonical_url')) {
317 return;
318 }
319 $inplaceQuery = "ALTER TABLE " . $logsTable .
320 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL," .
321 " ALGORITHM=INPLACE, LOCK=NONE";
322 $result = $this->dbCore->queryAndGetResults($inplaceQuery,
323 array('log_too_slow' => false, 'log_errors' => false));
324 if (empty($result['last_error'])) {
325 $this->logger->infoMessage("Added canonical_url to {$logsTable} (ALGORITHM=INPLACE, LOCK=NONE).");
326 return;
327 }
328 // Engine didn't support online DDL for ADD COLUMN — bare ALTER falls
329 // back to whatever algorithm the engine picks (COPY on MyISAM / very
330 // old InnoDB). On modern InnoDB the bare ALTER is itself implicitly
331 // INPLACE for ADD COLUMN ... DEFAULT NULL, so this branch only runs
332 // on legacy engines where some lock is unavoidable.
333 $bareQuery = "ALTER TABLE " . $logsTable .
334 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL";
335 $bare = $this->dbCore->queryAndGetResults($bareQuery,
336 array('log_too_slow' => false));
337 if (empty($bare['last_error'])) {
338 $this->logger->infoMessage("Added canonical_url to {$logsTable} (bare ALTER fallback).");
339 }
340 }
341
342 /**
343 * Add the canonical_url column to the redirects table with online DDL
344 * when supported.
345 *
346 * Sibling of ensureLogsv2CanonicalUrlColumn() applied to the redirects
347 * side. The column shipped in 4.1.11 and is normally added by dbDelta
348 * on plugin update. On hosts where dbDelta silently fails to ALTER ADD
349 * it, every captured-404 INSERT errors out with "Unknown column
350 * 'canonical_url' in 'field list'" until verifyColumns eventually
351 * retries the column add. One site in the May 10 debug zip emitted
352 * 1671 such errors over 10 days on 4.1.12. Calling this helper eagerly
353 * from runInitialCreateTables() shortens that window: every cron tick
354 * that runs the bootstrap loop retries the ALTER on its own,
355 * independent of the verifyColumns DDL diff path.
356 *
357 * @param string $redirectsTable
358 * @return void
359 */
360 public function ensureRedirectsCanonicalUrlColumn(string $redirectsTable): void {
361 if ($this->upgrades()->canonicalUrlBackfillUpgrade()->columnExists($redirectsTable, 'canonical_url')) {
362 return;
363 }
364 $inplaceQuery = "ALTER TABLE " . $redirectsTable .
365 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL," .
366 " ALGORITHM=INPLACE, LOCK=NONE";
367 $result = $this->dbCore->queryAndGetResults($inplaceQuery,
368 array('log_too_slow' => false, 'log_errors' => false));
369 if (empty($result['last_error'])) {
370 $this->logger->infoMessage("Added canonical_url to {$redirectsTable} (ALGORITHM=INPLACE, LOCK=NONE).");
371 return;
372 }
373 $bareQuery = "ALTER TABLE " . $redirectsTable .
374 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL";
375 $bare = $this->dbCore->queryAndGetResults($bareQuery,
376 array('log_too_slow' => false));
377 if (empty($bare['last_error'])) {
378 $this->logger->infoMessage("Added canonical_url to {$redirectsTable} (bare ALTER fallback).");
379 }
380 }
381
382 /**
383 * The four denormalized derived columns added to the redirects table in
384 * Denorm Step 3a (i459), keyed by column name with the exact column DDL
385 * fragment used in ADD COLUMN. Single source of truth shared by the
386 * targeted online-DDL add here and the backfill component's
387 * column-exists guards. Must stay in sync with createRedirectsTable.sql.
388 *
389 * @var array<string, string>
390 */
391 private const REDIRECTS_DENORM_COLUMN_DDL = array(
392 'logshits' => '`logshits` BIGINT(20) NOT NULL DEFAULT 0',
393 'last_used' => '`last_used` BIGINT(20) DEFAULT NULL',
394 'dest_for_view' => '`dest_for_view` VARCHAR(2048) DEFAULT NULL',
395 'dest_sort_key' => '`dest_sort_key` VARCHAR(191) DEFAULT NULL',
396 'url_sort_key' => '`url_sort_key` VARCHAR(191) DEFAULT NULL',
397 'published_status' => '`published_status` TINYINT(4) DEFAULT NULL',
398 );
399
400 /**
401 * Add the four denormalized derived columns (logshits, last_used,
402 * dest_for_view, published_status) to the redirects table with online
403 * DDL when supported.
404 *
405 * Sibling of {@see ensureRedirectsCanonicalUrlColumn()}: a small
406 * idempotent helper that runs ahead of the generic verifyColumns() flow
407 * so the column adds can use ALGORITHM=INPLACE, LOCK=NONE on InnoDB 5.6
408 * or newer (no table lock during the rewrite; 21K-row redirects tables
409 * add in seconds). Only the columns actually missing are added, so
410 * re-running this on a fully-migrated table is a no-op (each column is
411 * SHOW COLUMNS-guarded per defensive philosophy #1/#7).
412 *
413 * On engines that don't support online DDL for ADD COLUMN the explicit
414 * ALGORITHM clause causes ER_ALTER_OPERATION_NOT_SUPPORTED; we then fall
415 * back to a bare ALTER, which is what verifyColumns() also runs as the
416 * safety net. The derived columns carry sensible defaults (logshits 0;
417 * the rest NULL) so existing rows are valid immediately;
418 * backfillRedirectsDenormColumns() populates the real values across
419 * later cron ticks without ever blocking activation.
420 *
421 * @param string $redirectsTable Fully-qualified redirects table name.
422 * @return void
423 */
424 public function ensureRedirectsDenormColumns(string $redirectsTable): void {
425 $backfill = $this->upgrades()->redirectsDenormBackfillUpgrade();
426 $missingClauses = array();
427 foreach (self::REDIRECTS_DENORM_COLUMN_DDL as $columnName => $columnDdl) {
428 if (!$backfill->columnExists($redirectsTable, $columnName)) {
429 $missingClauses[] = 'ADD COLUMN ' . $columnDdl;
430 }
431 }
432 if (empty($missingClauses)) {
433 return;
434 }
435
436 $addClause = implode(', ', $missingClauses);
437 $inplaceQuery = "ALTER TABLE " . $redirectsTable . " " . $addClause .
438 ", ALGORITHM=INPLACE, LOCK=NONE";
439 $result = $this->dbCore->queryAndGetResults($inplaceQuery,
440 array('log_too_slow' => false, 'log_errors' => false));
441 if (empty($result['last_error'])) {
442 $this->logger->infoMessage("Added denorm columns to {$redirectsTable} " .
443 "(ALGORITHM=INPLACE, LOCK=NONE): " . $addClause);
444 return;
445 }
446 // Engine didn't support online DDL for ADD COLUMN, fall back to a
447 // bare ALTER, same as verifyColumns() would run. On modern InnoDB the
448 // bare ALTER is itself implicitly INSTANT/INPLACE for ADD COLUMN with
449 // a default, so this branch only runs on legacy engines.
450 $bareQuery = "ALTER TABLE " . $redirectsTable . " " . $addClause;
451 $bare = $this->dbCore->queryAndGetResults($bareQuery,
452 array('log_too_slow' => false));
453 if (empty($bare['last_error'])) {
454 $this->logger->infoMessage("Added denorm columns to {$redirectsTable} " .
455 "(bare ALTER fallback): " . $addClause);
456 }
457 }
458
459 }
460