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 / view-build / ViewDiagnostics.php

ViewDiagnostics.php in 404 Solution trunk, at includes/view-build/ViewDiagnostics.php

446 lines 18.3 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 * Failure message formatting and diagnostic probing for view queries.
9 *
10 * Captures structured diagnostic snapshots when admin view queries fail
11 * or time out, giving support enough evidence in a single debug zip to
12 * identify the root cause without a follow-up round trip to the user.
13 */
14 class ABJ_404_Solution_ViewDiagnostics {
15
16 /** @var ABJ_404_Solution_DatabaseCore */
17 private $dbCore;
18
19 /** @var ABJ_404_Solution_DatabaseQueryDiagnostics */
20 private $queryDiagnostics;
21
22 /** @var ABJ_404_Solution_ViewQueryExplainDiagnostics */
23 private $explainDiagnostics;
24
25 /**
26 * @param ABJ_404_Solution_DatabaseCore $dbCore
27 * @param ABJ_404_Solution_DatabaseQueryDiagnostics|null $queryDiagnostics
28 * @param ABJ_404_Solution_ViewQueryExplainDiagnostics|null $explainDiagnostics
29 */
30 public function __construct(ABJ_404_Solution_DatabaseCore $dbCore, $queryDiagnostics = null, $explainDiagnostics = null) {
31 $this->dbCore = $dbCore;
32 $this->queryDiagnostics = $queryDiagnostics !== null ? $queryDiagnostics : $dbCore->queryDiagnostics();
33 $this->explainDiagnostics = $explainDiagnostics !== null
34 ? $explainDiagnostics
35 : new ABJ_404_Solution_ViewQueryExplainDiagnostics($dbCore);
36 }
37
38 /**
39 * @param string $queryLabel
40 * @param string $query
41 * @param array<string, mixed> $result
42 * @return string
43 */
44 public function formatViewQueryFailureMessage(string $queryLabel, string $query, array $result): string {
45 $lastErrorRaw = $result['last_error'] ?? '';
46 $lastError = is_string($lastErrorRaw) ? trim($lastErrorRaw) : '';
47 $timedOut = !empty($result['timed_out']);
48 $sqlSource = $this->queryDiagnostics->extractSqlFilename($query);
49
50 if ($lastError === '' && $timedOut) {
51 $lastError = $queryLabel . ' timed out';
52 } else if ($lastError === '') {
53 $lastError = $queryLabel . ' failed without a database error message';
54 }
55
56 return $queryLabel . ' failed'
57 . '; last_error=' . $lastError
58 . '; timed_out=' . ($timedOut ? 'true' : 'false')
59 . '; sql_source=' . $sqlSource;
60 }
61
62 /**
63 * Capture a structured diagnostics snapshot when getRedirectsForView() or
64 * getRedirectsForViewCount() fails or times out.
65 *
66 * @param string $sub
67 * @param string $failedQuery
68 * @param array<string, mixed> $tableOptions
69 * @param array<string, mixed> $queryResult
70 * @return array<string, mixed>
71 */
72 public function captureViewQueryFailureDiagnostics(string $sub, string $failedQuery, array $tableOptions, array $queryResult): array {
73 $diag = array(
74 'failed_query_label' => '',
75 'failed_query_redacted' => '',
76 'last_error' => '',
77 'timed_out' => false,
78 'elapsed_time_seconds' => null,
79 'sub' => $sub,
80 'redirects_count' => array('active' => null, 'trashed' => null),
81 'logsv2_count' => null,
82 'wp_posts_count' => null,
83 'tables' => array(),
84 'expected_indexes' => array(),
85 'canonical_url_state' => array(),
86 'db_version' => '',
87 'explain' => null,
88 );
89
90 $diag['failed_query_label'] = $this->resolveViewQueryDiagnosticLabel($failedQuery, $sub);
91 $diag['failed_query_redacted'] = $this->redactQueryShapeForDiagnostics($failedQuery);
92
93 $lastError = is_string($queryResult['last_error'] ?? null) ? $queryResult['last_error'] : '';
94 $diag['last_error'] = $lastError;
95 $diag['timed_out'] = !empty($queryResult['timed_out']);
96 if (isset($queryResult['elapsed_time']) && is_numeric($queryResult['elapsed_time'])) {
97 $diag['elapsed_time_seconds'] = (float)$queryResult['elapsed_time'];
98 }
99
100 global $wpdb;
101 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
102 $logsv2Table = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
103 $postsTable = $this->resolvePostsTableName();
104
105 $diag['explain'] = $this->explainDiagnostics->collect($sub, $failedQuery, $queryResult);
106 $diag['db_version'] = $this->safeProbeDbVersion();
107
108 $diag['redirects_count']['active'] = $this->safeProbeCount(
109 "SELECT COUNT(*) AS count FROM `" . $redirectsTable . "` WHERE disabled = 0"
110 );
111 $diag['redirects_count']['trashed'] = $this->safeProbeCount(
112 "SELECT COUNT(*) AS count FROM `" . $redirectsTable . "` WHERE disabled = 1"
113 );
114 $diag['logsv2_count'] = $this->safeProbeCount(
115 "SELECT COUNT(*) AS count FROM `" . $logsv2Table . "`"
116 );
117 if ($postsTable !== '') {
118 $diag['wp_posts_count'] = $this->safeProbeCount(
119 "SELECT COUNT(*) AS count FROM `" . $postsTable . "`"
120 );
121 }
122
123 $diag['tables'] = $this->safeProbeTableEnginesAndCollations(array($redirectsTable, $logsv2Table));
124
125 $diag['expected_indexes'] = array(
126 $redirectsTable => $this->safeProbeIndexCoverage($redirectsTable, array(
127 'PRIMARY', 'status', 'type', 'code', 'timestamp', 'disabled', 'url', 'final_dest',
128 'idx_url_disabled_status', 'idx_status_disabled', 'idx_canonical_url',
129 ), 'createRedirectsTable.sql'),
130 $logsv2Table => $this->safeProbeIndexCoverage($logsv2Table, array(
131 'PRIMARY', 'timestamp', 'requested_url', 'username', 'min_log_id',
132 'idx_requested_url_timestamp', 'idx_canonical_url',
133 ), 'createLogTable.sql'),
134 );
135
136 $diag['canonical_url_state'] = array(
137 $redirectsTable => $this->safeProbeCanonicalUrlState($redirectsTable),
138 $logsv2Table => $this->safeProbeCanonicalUrlState($logsv2Table),
139 );
140
141 return $diag;
142 }
143
144 /**
145 * @param string $failedQuery
146 * @param string $sub
147 * @return string
148 */
149 private function resolveViewQueryDiagnosticLabel(string $failedQuery, string $sub): string {
150 if (preg_match('/\/\*\s*-+\s*(.+?\.sql)\s+BEGIN\s*-+\s*\*\//i', $failedQuery, $m)) {
151 return basename($m[1]);
152 }
153 if (stripos($failedQuery, 'COUNT(*)') !== false) {
154 return 'getRedirectsForViewCount';
155 }
156 return 'getRedirectsForView';
157 }
158
159 /**
160 * @param string $sql
161 * @return string
162 */
163 private function redactQueryShapeForDiagnostics(string $sql): string {
164 if ($sql === '') {
165 return '';
166 }
167 $out = $sql;
168 $out = preg_replace("~'(?:\\\\'|''|[^'])*'~", "?", $out) ?? $out;
169 $out = preg_replace('~"(?:\\\\"|""|[^"])*"~', "?", $out) ?? $out;
170 $out = preg_replace('~\\b0x[0-9A-Fa-f]+\\b~', '?', $out) ?? $out;
171 $out = preg_replace('~\\b\\d+(?:\\.\\d+)?\\b~', '?', $out) ?? $out;
172 $out = preg_replace('~\\(\\s*\\?\\s*(?:,\\s*\\?\\s*)+\\)~', '(?)', $out) ?? $out;
173 $out = preg_replace('~\\s+~', ' ', trim($out)) ?? $out;
174 if (strlen($out) > 4000) {
175 $out = substr($out, 0, 4000);
176 }
177 return $out;
178 }
179
180 /** @return string */
181 private function resolvePostsTableName(): string {
182 global $wpdb;
183 if (isset($wpdb->posts) && is_string($wpdb->posts) && $wpdb->posts !== '') {
184 return $wpdb->posts;
185 }
186 if (isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') {
187 return $wpdb->prefix . 'posts';
188 }
189 return '';
190 }
191
192 /**
193 * @param string $countQuery
194 * @return int|string
195 */
196 private function safeProbeCount(string $countQuery) {
197 try {
198 $result = $this->dbCore->queryAndGetResults($countQuery, array(
199 'timeout' => 5,
200 'log_errors' => false,
201 'skip_repair' => true,
202 ));
203 $lastErrorRaw = $result['last_error'] ?? '';
204 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
205 if ($err !== '' || !empty($result['timed_out'])) {
206 return 'error: ' . ($err !== '' ? $err : 'timed out');
207 }
208 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
209 if (empty($rows)) {
210 return 0;
211 }
212 $first = is_array($rows[0]) ? $rows[0] : array();
213 $value = $first['count'] ?? $first['COUNT(*)'] ?? reset($first);
214 return is_scalar($value) ? (int)$value : 0;
215 } catch (Throwable $e) {
216 return 'error: ' . $e->getMessage();
217 }
218 }
219
220 /** @return string */
221 private function safeProbeDbVersion(): string {
222 try {
223 $result = $this->dbCore->queryAndGetResults('SELECT VERSION() AS version', array(
224 'timeout' => 5,
225 'log_errors' => false,
226 'skip_repair' => true,
227 ));
228 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
229 if (empty($rows)) {
230 return '';
231 }
232 $first = is_array($rows[0]) ? $rows[0] : array();
233 $value = $first['version'] ?? $first['VERSION()'] ?? reset($first);
234 return is_scalar($value) ? (string)$value : '';
235 } catch (Throwable $e) {
236 return 'error: ' . $e->getMessage();
237 }
238 }
239
240 /**
241 * @param array<int, string> $tableNames
242 * @return array<string, array{engine:string, collation:string}>
243 */
244 private function safeProbeTableEnginesAndCollations(array $tableNames): array {
245 $out = array();
246 foreach ($tableNames as $name) {
247 $out[$name] = array('engine' => '', 'collation' => '');
248 }
249 if (empty($tableNames)) {
250 return $out;
251 }
252 try {
253 $list = array();
254 foreach ($tableNames as $name) {
255 $list[] = "'" . str_replace("'", "''", $name) . "'";
256 }
257 $query = "SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM information_schema.TABLES "
258 . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (" . implode(',', $list) . ")";
259 $result = $this->dbCore->queryAndGetResults($query, array(
260 'timeout' => 5,
261 'log_errors' => false,
262 'skip_repair' => true,
263 ));
264 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
265 foreach ($rows as $row) {
266 if (!is_array($row)) {
267 continue;
268 }
269 $name = '';
270 $engine = '';
271 $collation = '';
272 foreach ($row as $key => $value) {
273 $k = strtolower((string)$key);
274 if ($k === 'table_name' && is_scalar($value)) {
275 $name = (string)$value;
276 } else if ($k === 'engine' && is_scalar($value)) {
277 $engine = (string)$value;
278 } else if ($k === 'table_collation' && is_scalar($value)) {
279 $collation = (string)$value;
280 }
281 }
282 if ($name !== '' && array_key_exists($name, $out)) {
283 $out[$name] = array('engine' => $engine, 'collation' => $collation);
284 }
285 }
286 } catch (Throwable $e) {
287 foreach (array_keys($out) as $name) {
288 if ($out[$name]['engine'] === '') {
289 $out[$name] = array('engine' => 'error: ' . $e->getMessage(), 'collation' => '');
290 }
291 }
292 }
293 return $out;
294 }
295
296 /**
297 * Which indexes a table has, which of the ones this failure bundle cares
298 * about are gone, and which are present under the right name but no longer
299 * match the shipped DDL.
300 *
301 * The `drifted` bucket exists because a name-only answer is not diagnostic:
302 * MySQL and MariaDB silently strip a dropped column out of every index that
303 * named it and keep the rest, so a table can report every expected index as
304 * `present` while the sort those indexes were built for filesorts the whole
305 * partition. A bundle that says "all indexes present" for a page timing out
306 * on an unindexed sort sends the reader in the wrong direction.
307 *
308 * `drifted` covers every index in the DDL, not just $expectedKeys: the
309 * composites the derived-sort reads depend on are exactly the ones a column
310 * drop narrows, and they were never in the hand-listed set.
311 *
312 * @param string $tableName
313 * @param array<int, string> $expectedKeys
314 * @param string $ddlFileName create*Table.sql to compare definitions against.
315 * @return array{expected: array<int,string>, present: array<int,string>, missing: array<int,string>, drifted: array<string,string>, error?: string}
316 */
317 private function safeProbeIndexCoverage(string $tableName, array $expectedKeys, string $ddlFileName = ''): array {
318 $out = array(
319 'expected' => array_values($expectedKeys),
320 'present' => array(),
321 'missing' => array(),
322 'drifted' => array(),
323 );
324 try {
325 $result = $this->dbCore->queryAndGetResults('SHOW INDEX FROM `' . $tableName . '`', array(
326 'timeout' => 5,
327 'log_errors' => false,
328 'skip_repair' => true,
329 ));
330 $lastErrorRaw = $result['last_error'] ?? '';
331 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
332 if ($err !== '') {
333 $out['error'] = $err;
334 $out['missing'] = $out['expected'];
335 return $out;
336 }
337 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
338 $definitions = ABJ_404_Solution_TableIndexDefinitions::fromShowIndexRows($rows);
339 if ($definitions === null) {
340 // The engine answered with rows this version cannot read, so
341 // this is not a description of the table. Reporting the indexes
342 // we managed to parse as "present" and the rest as "missing"
343 // would put a fabricated index inventory into a support payload,
344 // which is worse than reporting that the probe failed.
345 $out['error'] = 'SHOW INDEX returned rows that could not be read';
346 $out['missing'] = $out['expected'];
347 return $out;
348 }
349 $present = array();
350 foreach ($definitions as $definition) {
351 $present[(string)$definition['name']] = true;
352 }
353 $out['present'] = array_keys($present);
354 $missing = array();
355 foreach ($expectedKeys as $expected) {
356 if (!array_key_exists($expected, $present)) {
357 $missing[] = $expected;
358 }
359 }
360 $out['missing'] = $missing;
361 $out['drifted'] = $this->describeDriftedIndexes($definitions, $ddlFileName);
362 } catch (Throwable $e) {
363 $out['error'] = $e->getMessage();
364 $out['missing'] = $out['expected'];
365 }
366 return $out;
367 }
368
369 /**
370 * Present-but-wrong indexes, as "index name" => "has (...), schema says (...)".
371 *
372 * @param array<string, array{name: string, columns: array<int, array{column: string, prefix: int|null}>, unique: bool, describable?: bool}> $definitions
373 * @param string $ddlFileName
374 * @return array<string, string>
375 */
376 private function describeDriftedIndexes(array $definitions, string $ddlFileName): array {
377 if ($ddlFileName === '') {
378 return array();
379 }
380 $ddl = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . '/../sql/' . $ddlFileName);
381 $drifted = array();
382 foreach (ABJ_404_Solution_CreateTableIndexParser::fromCreateTableSql((string)$ddl) as $name => $spec) {
383 $live = $definitions[strtolower((string)$name)] ?? null;
384 if (!is_array($live)) {
385 continue;
386 }
387 if (!ABJ_404_Solution_IndexDefinitionComparator::isDriftedFromDdlSpec($live, $spec)) {
388 // Either the index matches its DDL, or one of the two sides was
389 // never fully read (the engine describes the index in a form
390 // this version cannot compare, or our own SQL template did not
391 // parse). Reporting the latter as drifted would send a support
392 // payload claiming a difference nobody established.
393 continue;
394 }
395 $drifted[(string)$name] = 'has (' .
396 ABJ_404_Solution_TableIndexDefinitions::describeColumns($live) .
397 '), schema says ' . trim((string)$spec['columns']);
398 }
399 return $drifted;
400 }
401
402 /**
403 * @param string $tableName
404 * @return array{column_exists: bool, null_count: int|string|null, total_count: int|string|null, error?: string}
405 */
406 private function safeProbeCanonicalUrlState(string $tableName): array {
407 $out = array(
408 'column_exists' => false,
409 'null_count' => null,
410 'total_count' => null,
411 );
412 try {
413 $colResult = $this->dbCore->queryAndGetResults('SHOW COLUMNS FROM `' . $tableName . '`', array(
414 'timeout' => 5,
415 'log_errors' => false,
416 'skip_repair' => true,
417 ));
418 $colRows = is_array($colResult['rows'] ?? null) ? $colResult['rows'] : array();
419 foreach ($colRows as $row) {
420 if (!is_array($row)) {
421 continue;
422 }
423 foreach ($row as $key => $value) {
424 if (strtolower((string)$key) === 'field' && is_scalar($value)
425 && strtolower((string)$value) === 'canonical_url') {
426 $out['column_exists'] = true;
427 break 2;
428 }
429 }
430 }
431 if (!$out['column_exists']) {
432 return $out;
433 }
434 $out['null_count'] = $this->safeProbeCount(
435 "SELECT COUNT(*) AS count FROM `" . $tableName . "` WHERE canonical_url IS NULL"
436 );
437 $out['total_count'] = $this->safeProbeCount(
438 "SELECT COUNT(*) AS count FROM `" . $tableName . "`"
439 );
440 } catch (Throwable $e) {
441 $out['error'] = $e->getMessage();
442 }
443 return $out;
444 }
445 }
446