dbCore = $dbCore; $this->logger = $logger; } /** * Bring one index into existence with the definition the schema asks for. * * @param string $tableName Fully-qualified table name. * @param array{name: string, columns: string, unique: bool} $spec The index the schema defines. * `columns` carries its own parentheses, e.g. "(`a`, `b`(190))". * @param array{replace_existing?: bool, try_online_first?: bool} $options * replace_existing: drop the same-named index in the SAME ALTER first, so a drifted * index is swapped without the table ever being without it (default false). * try_online_first: attempt ALGORITHM=INPLACE, LOCK=NONE and fall back to a plain * ALTER if the server or storage engine rejects the hints (default true). The * logsv2 composite passes false: its repair is a DROP and an ADD in one statement * on a table that can be multi-GB, and it has always issued that plainly. * @return void */ public function addIndex(string $tableName, array $spec, array $options = array()): void { $replaceExisting = !empty($options['replace_existing']); $tryOnlineFirst = !array_key_exists('try_online_first', $options) || !empty($options['try_online_first']); $statement = $this->buildAddIndexStatement(array( 'tableName' => $tableName, 'spec' => $spec, 'online' => $tryOnlineFirst, 'replaceExisting' => $replaceExisting, )); $lastError = $this->runStatement($statement); if ($lastError !== '' && $tryOnlineFirst) { if ($this->recordRedundantChange(array( 'tableName' => $tableName, 'spec' => $spec, 'lastError' => $lastError, ))) { return; } if (!$this->isOnlineDdlHintRejection($lastError)) { $this->logger->errorMessage("Failed to add index {$spec['name']} to {$tableName}: " . $lastError . " (query: {$statement})"); return; } $this->logger->warn("Online index add for {$spec['name']} on {$tableName} failed; " . "retrying without online DDL hints: " . $lastError . " (query: {$statement})"); $statement = $this->buildAddIndexStatement(array( 'tableName' => $tableName, 'spec' => $spec, 'online' => false, 'replaceExisting' => $replaceExisting, )); $lastError = $this->runStatement($statement); } if ($lastError !== '') { if ($this->recordRedundantChange(array( 'tableName' => $tableName, 'spec' => $spec, 'lastError' => $lastError, ))) { return; } $this->logger->errorMessage("Failed to add index {$spec['name']} to {$tableName}: " . $lastError . " (query: {$statement})"); return; } $this->logger->infoMessage("I added an index: " . $statement); } /** * @param string $statement * @return string The engine's error, or '' when it had none. */ private function runStatement(string $statement): string { $result = $this->dbCore->queryAndGetResults($statement); return isset($result['last_error']) && is_scalar($result['last_error']) ? (string)$result['last_error'] : ''; } /** * Whether the engine rejected the optional online-DDL clauses themselves. * Other failures (permissions, disk, connection, syntax) must not be * repeated as a bare ALTER that cannot correct their cause. * * @param string $lastError What the engine said. * @return bool */ private function isOnlineDdlHintRejection(string $lastError): bool { $lower = strtolower($lastError); $namesOnlineClause = strpos($lower, 'lock=none') !== false || strpos($lower, 'algorithm=inplace') !== false; $rejectsClause = strpos($lower, 'not supported') !== false || strpos($lower, 'unsupported') !== false; return $namesOnlineClause && $rejectsClause; } /** * Whether the statement failed only because the change had already been * made and -- when it had -- what the table actually ended up carrying. * * Retrying such a statement cannot change the answer: the name is taken * either way, which is the state the caller asked for. Reporting it as a * failed index add describes a table that has the index. So the caller * stops here, and what gets recorded is what the schema now says, read * back rather than assumed. * * @param array{tableName: string, spec: array{name: string, columns: string, unique: bool}, * lastError: string} $request * @return bool True when the change was already applied and the caller must stop. */ private function recordRedundantChange(array $request): bool { $tableName = $request['tableName']; $spec = $request['spec']; $lastError = $request['lastError']; if (!$this->dbCore->sqlErrorReporter()->isRedundantSchemaChangeError($lastError)) { return false; } $indexName = (string)$spec['name']; $goalSignature = ABJ_404_Solution_IndexDefinitionComparator::signatureOfDdlSpec($spec); $liveDefinitions = (new ABJ_404_Solution_TableIndexDefinitions($this->dbCore))->readLive($tableName); if ($liveDefinitions === null) { $this->logger->warn("Index {$indexName} on {$tableName} was already there when this " . "process tried to add it, and the table's index metadata could not be read back to " . "confirm what it contains. Leaving it for the next upgrade tick to check."); return true; } $liveDefinition = $liveDefinitions[strtolower($indexName)] ?? null; if ($goalSignature !== null && is_array($liveDefinition) && ABJ_404_Solution_IndexDefinitionComparator::signatureOfLiveDefinition($liveDefinition) === $goalSignature) { $this->logger->infoMessage("Index {$indexName} on {$tableName} was added by another " . "process while this one was building it, and it matches the shipped definition."); return true; } // The name is held by something other than what the schema asks for. // The plugin still works (worst case a sort is slower), and the drift // branch of verifyIndexes() rebuilds a mismatch on a later pass, so this // is recorded rather than reported. $this->logger->warn("Index {$indexName} on {$tableName} was already there when this process " . "tried to add it, but the server does not describe it as " . trim((string)$spec['columns']) . ". Leaving it for the drift check to repair."); return true; } /** * Build a valid ALTER TABLE ... ADD INDEX statement for THIS server. * * @param array{tableName: string, spec: array{name: string, columns: string, unique: bool}, * online: bool, replaceExisting: bool} $request The table/index definition and * statement policy. replaceExisting emits "drop index `n`, add ..." so a drifted * index is swapped in one statement. * @return string */ private function buildAddIndexStatement(array $request): string { $tableName = $request['tableName']; $spec = $request['spec']; $online = $request['online']; $replaceExisting = $request['replaceExisting']; global $wpdb; /** @var \wpdb $wpdb */ $serverVersion = is_object($wpdb) && method_exists($wpdb, 'db_version') ? ($wpdb->db_version() ?: '') : ''; $serverInfo = is_object($wpdb) && property_exists($wpdb, 'db_server_info') ? ($wpdb->db_server_info ?? '') : ''; $isMaria = stripos($serverInfo, 'mariadb') !== false || stripos($serverVersion, 'maria') !== false; $cleanedVersion = preg_replace('/[^\d\.]/', '', $serverVersion) ?? ''; $supportsIfNotExists = $isMaria && version_compare($cleanedVersion, '10.5', '>='); $indexName = (string)$spec['name']; $indexType = !empty($spec['unique']) ? 'unique index' : 'index'; $ifNotExists = ($supportsIfNotExists && !$replaceExisting) ? ' if not exists' : ''; $onlineClause = $online ? ', ALGORITHM=INPLACE, LOCK=NONE' : ''; $dropClause = $replaceExisting ? " drop index `" . $indexName . "`," : ''; return "alter table " . $tableName . $dropClause . " add " . $indexType . $ifNotExists . " `" . $indexName . "` " . trim((string)$spec['columns']) . $onlineClause; } }