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 / TableIndexWriter.php

TableIndexWriter.php in 404 Solution trunk, at includes/database/TableIndexWriter.php

241 lines 11.4 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 * 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