| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The DDL-issuing half of the table-index domain: given a table and the index |
| 9 |
* definition the shipped schema asks for, bring the engine to that state and |
| 10 |
* say honestly what happened. |
| 11 |
* |
| 12 |
* {@see ABJ_404_Solution_TableIndexDefinitions} is the read half and says so in |
| 13 |
* its own header ("Everything here is read-only ... It issues no DDL and makes |
| 14 |
* no repair decisions"). This is the counterpart it names. Between them sits |
| 15 |
* {@see ABJ_404_Solution_IndexDefinitionComparator}, which both use to ask |
| 16 |
* whether two definitions agree. |
| 17 |
* |
| 18 |
* What lives here is everything that depends on how an ENGINE behaves rather |
| 19 |
* than on what the plugin's schema wants: which syntax this server accepts |
| 20 |
* (MariaDB 10.5+ takes ADD INDEX IF NOT EXISTS, MySQL never has), whether the |
| 21 |
* online-DDL hints are worth trying before a plain ALTER, and what each answer |
| 22 |
* it gives back actually means. The decision of WHICH indexes need repairing, |
| 23 |
* in what order, and how often a rebuild may be re-attempted stays with |
| 24 |
* {@see ABJ_404_Solution_DatabaseUpgradeIndexes}, which is schema policy and |
| 25 |
* changes for entirely different reasons. |
| 26 |
* |
| 27 |
* The interpretation half is the reason this is a module and not a function. |
| 28 |
* An ADD INDEX has three outcomes, not two: it worked, it failed, or it was |
| 29 |
* redundant because another process made the same change first -- and the third |
| 30 |
* one reached the state the caller wanted. Report 270 (dianthus.zuidplas.net, |
| 31 |
* 2026-08-16 07:10:52) is what the third outcome looks like when it is handled |
| 32 |
* as the second: two concurrent requests both read SHOW INDEX before either |
| 33 |
* wrote, both decided idx_status_disabled_timestamp_id was missing, and the |
| 34 |
* loser answered the winner's success with a retry that could not succeed and |
| 35 |
* five ERROR lines about an index that was there. Every caller that emits index |
| 36 |
* DDL needs that distinction, and before this module there were two copies of |
| 37 |
* the emit path and only one of them made it. |
| 38 |
* |
| 39 |
* Being told the name is taken is NOT taken as proof the goal was met: this |
| 40 |
* domain exists because an index can carry exactly the right name and the wrong |
| 41 |
* columns (MySQL silently narrows an index when a column it names is dropped). |
| 42 |
* So the schema is read back and compared, and only a match is recorded as an |
| 43 |
* index that is present. |
| 44 |
*/ |
| 45 |
class ABJ_404_Solution_TableIndexWriter { |
| 46 |
|
| 47 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 48 |
private $dbCore; |
| 49 |
|
| 50 |
/** @var ABJ_404_Solution_Logging */ |
| 51 |
private $logger; |
| 52 |
|
| 53 |
/** |
| 54 |
* @param ABJ_404_Solution_DatabaseCore $dbCore Runs the statements and owns the error taxonomy. |
| 55 |
* @param ABJ_404_Solution_Logging $logger |
| 56 |
*/ |
| 57 |
public function __construct($dbCore, $logger) { |
| 58 |
$this->dbCore = $dbCore; |
| 59 |
$this->logger = $logger; |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Bring one index into existence with the definition the schema asks for. |
| 64 |
* |
| 65 |
* @param string $tableName Fully-qualified table name. |
| 66 |
* @param array{name: string, columns: string, unique: bool} $spec The index the schema defines. |
| 67 |
* `columns` carries its own parentheses, e.g. "(`a`, `b`(190))". |
| 68 |
* @param array{replace_existing?: bool, try_online_first?: bool} $options |
| 69 |
* replace_existing: drop the same-named index in the SAME ALTER first, so a drifted |
| 70 |
* index is swapped without the table ever being without it (default false). |
| 71 |
* try_online_first: attempt ALGORITHM=INPLACE, LOCK=NONE and fall back to a plain |
| 72 |
* ALTER if the server or storage engine rejects the hints (default true). The |
| 73 |
* logsv2 composite passes false: its repair is a DROP and an ADD in one statement |
| 74 |
* on a table that can be multi-GB, and it has always issued that plainly. |
| 75 |
* @return void |
| 76 |
*/ |
| 77 |
public function addIndex(string $tableName, array $spec, array $options = array()): void { |
| 78 |
$replaceExisting = !empty($options['replace_existing']); |
| 79 |
$tryOnlineFirst = !array_key_exists('try_online_first', $options) |
| 80 |
|| !empty($options['try_online_first']); |
| 81 |
|
| 82 |
$statement = $this->buildAddIndexStatement(array( |
| 83 |
'tableName' => $tableName, |
| 84 |
'spec' => $spec, |
| 85 |
'online' => $tryOnlineFirst, |
| 86 |
'replaceExisting' => $replaceExisting, |
| 87 |
)); |
| 88 |
$lastError = $this->runStatement($statement); |
| 89 |
|
| 90 |
if ($lastError !== '' && $tryOnlineFirst) { |
| 91 |
if ($this->recordRedundantChange(array( |
| 92 |
'tableName' => $tableName, |
| 93 |
'spec' => $spec, |
| 94 |
'lastError' => $lastError, |
| 95 |
))) { |
| 96 |
return; |
| 97 |
} |
| 98 |
if (!$this->isOnlineDdlHintRejection($lastError)) { |
| 99 |
$this->logger->errorMessage("Failed to add index {$spec['name']} to {$tableName}: " . |
| 100 |
$lastError . " (query: {$statement})"); |
| 101 |
return; |
| 102 |
} |
| 103 |
$this->logger->warn("Online index add for {$spec['name']} on {$tableName} failed; " . |
| 104 |
"retrying without online DDL hints: " . $lastError . " (query: {$statement})"); |
| 105 |
$statement = $this->buildAddIndexStatement(array( |
| 106 |
'tableName' => $tableName, |
| 107 |
'spec' => $spec, |
| 108 |
'online' => false, |
| 109 |
'replaceExisting' => $replaceExisting, |
| 110 |
)); |
| 111 |
$lastError = $this->runStatement($statement); |
| 112 |
} |
| 113 |
|
| 114 |
if ($lastError !== '') { |
| 115 |
if ($this->recordRedundantChange(array( |
| 116 |
'tableName' => $tableName, |
| 117 |
'spec' => $spec, |
| 118 |
'lastError' => $lastError, |
| 119 |
))) { |
| 120 |
return; |
| 121 |
} |
| 122 |
$this->logger->errorMessage("Failed to add index {$spec['name']} to {$tableName}: " . |
| 123 |
$lastError . " (query: {$statement})"); |
| 124 |
return; |
| 125 |
} |
| 126 |
|
| 127 |
$this->logger->infoMessage("I added an index: " . $statement); |
| 128 |
} |
| 129 |
|
| 130 |
/** |
| 131 |
* @param string $statement |
| 132 |
* @return string The engine's error, or '' when it had none. |
| 133 |
*/ |
| 134 |
private function runStatement(string $statement): string { |
| 135 |
$result = $this->dbCore->queryAndGetResults($statement); |
| 136 |
return isset($result['last_error']) && is_scalar($result['last_error']) |
| 137 |
? (string)$result['last_error'] : ''; |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Whether the engine rejected the optional online-DDL clauses themselves. |
| 142 |
* Other failures (permissions, disk, connection, syntax) must not be |
| 143 |
* repeated as a bare ALTER that cannot correct their cause. |
| 144 |
* |
| 145 |
* @param string $lastError What the engine said. |
| 146 |
* @return bool |
| 147 |
*/ |
| 148 |
private function isOnlineDdlHintRejection(string $lastError): bool { |
| 149 |
$lower = strtolower($lastError); |
| 150 |
$namesOnlineClause = strpos($lower, 'lock=none') !== false |
| 151 |
|| strpos($lower, 'algorithm=inplace') !== false; |
| 152 |
$rejectsClause = strpos($lower, 'not supported') !== false |
| 153 |
|| strpos($lower, 'unsupported') !== false; |
| 154 |
return $namesOnlineClause && $rejectsClause; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Whether the statement failed only because the change had already been |
| 159 |
* made and -- when it had -- what the table actually ended up carrying. |
| 160 |
* |
| 161 |
* Retrying such a statement cannot change the answer: the name is taken |
| 162 |
* either way, which is the state the caller asked for. Reporting it as a |
| 163 |
* failed index add describes a table that has the index. So the caller |
| 164 |
* stops here, and what gets recorded is what the schema now says, read |
| 165 |
* back rather than assumed. |
| 166 |
* |
| 167 |
* @param array{tableName: string, spec: array{name: string, columns: string, unique: bool}, |
| 168 |
* lastError: string} $request |
| 169 |
* @return bool True when the change was already applied and the caller must stop. |
| 170 |
*/ |
| 171 |
private function recordRedundantChange(array $request): bool { |
| 172 |
$tableName = $request['tableName']; |
| 173 |
$spec = $request['spec']; |
| 174 |
$lastError = $request['lastError']; |
| 175 |
if (!$this->dbCore->sqlErrorReporter()->isRedundantSchemaChangeError($lastError)) { |
| 176 |
return false; |
| 177 |
} |
| 178 |
|
| 179 |
$indexName = (string)$spec['name']; |
| 180 |
$goalSignature = ABJ_404_Solution_IndexDefinitionComparator::signatureOfDdlSpec($spec); |
| 181 |
$liveDefinitions = (new ABJ_404_Solution_TableIndexDefinitions($this->dbCore))->readLive($tableName); |
| 182 |
if ($liveDefinitions === null) { |
| 183 |
$this->logger->warn("Index {$indexName} on {$tableName} was already there when this " . |
| 184 |
"process tried to add it, and the table's index metadata could not be read back to " . |
| 185 |
"confirm what it contains. Leaving it for the next upgrade tick to check."); |
| 186 |
return true; |
| 187 |
} |
| 188 |
|
| 189 |
$liveDefinition = $liveDefinitions[strtolower($indexName)] ?? null; |
| 190 |
if ($goalSignature !== null && is_array($liveDefinition) |
| 191 |
&& ABJ_404_Solution_IndexDefinitionComparator::signatureOfLiveDefinition($liveDefinition) |
| 192 |
=== $goalSignature) { |
| 193 |
$this->logger->infoMessage("Index {$indexName} on {$tableName} was added by another " . |
| 194 |
"process while this one was building it, and it matches the shipped definition."); |
| 195 |
return true; |
| 196 |
} |
| 197 |
|
| 198 |
// The name is held by something other than what the schema asks for. |
| 199 |
// The plugin still works (worst case a sort is slower), and the drift |
| 200 |
// branch of verifyIndexes() rebuilds a mismatch on a later pass, so this |
| 201 |
// is recorded rather than reported. |
| 202 |
$this->logger->warn("Index {$indexName} on {$tableName} was already there when this process " . |
| 203 |
"tried to add it, but the server does not describe it as " . trim((string)$spec['columns']) . |
| 204 |
". Leaving it for the drift check to repair."); |
| 205 |
return true; |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* Build a valid ALTER TABLE ... ADD INDEX statement for THIS server. |
| 210 |
* |
| 211 |
* @param array{tableName: string, spec: array{name: string, columns: string, unique: bool}, |
| 212 |
* online: bool, replaceExisting: bool} $request The table/index definition and |
| 213 |
* statement policy. replaceExisting emits "drop index `n`, add ..." so a drifted |
| 214 |
* index is swapped in one statement. |
| 215 |
* @return string |
| 216 |
*/ |
| 217 |
private function buildAddIndexStatement(array $request): string { |
| 218 |
$tableName = $request['tableName']; |
| 219 |
$spec = $request['spec']; |
| 220 |
$online = $request['online']; |
| 221 |
$replaceExisting = $request['replaceExisting']; |
| 222 |
global $wpdb; |
| 223 |
/** @var \wpdb $wpdb */ |
| 224 |
$serverVersion = is_object($wpdb) && method_exists($wpdb, 'db_version') ? ($wpdb->db_version() ?: '') : ''; |
| 225 |
$serverInfo = is_object($wpdb) && property_exists($wpdb, 'db_server_info') ? ($wpdb->db_server_info ?? '') : ''; |
| 226 |
|
| 227 |
$isMaria = stripos($serverInfo, 'mariadb') !== false || stripos($serverVersion, 'maria') !== false; |
| 228 |
$cleanedVersion = preg_replace('/[^\d\.]/', '', $serverVersion) ?? ''; |
| 229 |
$supportsIfNotExists = $isMaria && version_compare($cleanedVersion, '10.5', '>='); |
| 230 |
|
| 231 |
$indexName = (string)$spec['name']; |
| 232 |
$indexType = !empty($spec['unique']) ? 'unique index' : 'index'; |
| 233 |
$ifNotExists = ($supportsIfNotExists && !$replaceExisting) ? ' if not exists' : ''; |
| 234 |
$onlineClause = $online ? ', ALGORITHM=INPLACE, LOCK=NONE' : ''; |
| 235 |
$dropClause = $replaceExisting ? " drop index `" . $indexName . "`," : ''; |
| 236 |
|
| 237 |
return "alter table " . $tableName . $dropClause . " add " . $indexType . $ifNotExists . |
| 238 |
" `" . $indexName . "` " . trim((string)$spec['columns']) . $onlineClause; |
| 239 |
} |
| 240 |
} |
| 241 |
|