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

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

362 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SQL error reporting helpers for DataAccess.
4 *
5 * Extracted from DataAccess.php to keep the main class under the file-size
6 * limit. All methods are called via $this-> from DataAccess (trait context)
7 * and rely on sibling traits: ErrorClassificationTrait for the isXxxError()
8 * predicates, plus the host class's $this->logger and query diagnostics.
9 *
10 * @since 4.1.8
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 class ABJ_404_Solution_DatabaseSqlErrorReporter {
18
19 /** @var ABJ_404_Solution_DatabaseCore */
20 private $core;
21
22 /** @var ABJ_404_Solution_Logging */
23 private $logger;
24
25 /**
26 * @param ABJ_404_Solution_DatabaseCore $core
27 * @param ABJ_404_Solution_Logging $logger
28 */
29 public function __construct(ABJ_404_Solution_DatabaseCore $core, $logger) {
30 $this->core = $core;
31 $this->logger = $logger;
32 }
33
34 /**
35 * Forward DatabaseCore infrastructure calls that remain owned by the core.
36 *
37 * @param string $name
38 * @param array<int, mixed> $arguments
39 * @return mixed
40 */
41 public function __call(string $name, array $arguments) {
42 return $this->core->$name(...$arguments);
43 }
44
45 /**
46 * Log the first observed database error for every query, before retry and
47 * recovery paths can mutate or clear wpdb::last_error.
48 *
49 * @param string $query
50 * @param array<string, mixed> $result
51 * @param array<string, mixed> $options
52 * @param bool $producesRows
53 * @return void
54 */
55 public function logObservedSqlError(string $query, array $result, array $options, bool $producesRows): void {
56 $lastError = isset($result['last_error']) && is_string($result['last_error'])
57 ? trim($result['last_error']) : '';
58 if ($lastError === '') {
59 return;
60 }
61
62 // Honor an explicit log_errors=false: the caller has accepted that
63 // this query may fail and does not want the failure routed through
64 // Logging::errorMessage() (which can email the developer and surface
65 // admin notices). The error is still returned in $result['last_error']
66 // so callers can react to it. May 2026: a regression in this function
67 // was emailing 35 of 38 4.1.15 sites about benign SHOW CREATE TABLE
68 // probes of the transient view_build table. log_errors=false must
69 // mean "do not log".
70 $logErrors = !array_key_exists('log_errors', $options) || (bool)$options['log_errors'];
71 if (!$logErrors) {
72 return;
73 }
74
75 // Honor ignore_errors: callers pass substring patterns for errors
76 // that are expected and benign for that query (e.g. RENAME TABLE
77 // with ignore_errors=["already exists"] in renameAbj404TablesToLowerCase
78 // when the lowercase target name pre-exists). Without this check,
79 // the observation logger fires ERROR before the downstream
80 // ignore_errors branch can suppress, which emails the developer.
81 // May 2026: 16+ sites in the email-flood cohort hit this path.
82 $ignoreErrorStrings = isset($options['ignore_errors']) && is_array($options['ignore_errors'])
83 ? $options['ignore_errors'] : array();
84 foreach ($ignoreErrorStrings as $needle) {
85 if (is_string($needle) && $needle !== '' && strpos($lastError, $needle) !== false) {
86 return;
87 }
88 }
89
90 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
91 $elapsed = isset($result['elapsed_time']) && is_numeric($result['elapsed_time'])
92 ? round((float)$result['elapsed_time'], 4) : 0;
93 $message = 'SQL query error observed: ' . $lastError
94 . ', SQL: ' . $sqlInfo
95 . ', source: ' . $this->core->queryDiagnostics()->extractSqlFilename($query)
96 . ', route: ' . ($producesRows ? 'get_results' : 'query')
97 . ', log_errors_option: ' . ($logErrors ? 'true' : 'false') /** @phpstan-ignore ternary.alwaysTrue */
98 . ', execution_time: ' . $elapsed;
99
100 // Infrastructure errors (collation mismatch, disk full, read-only,
101 // host quota, deadlock, transient connection drop, etc.) are server
102 // and host issues, not plugin bugs. Log as WARN so they appear in
103 // the debug log without triggering Logging::errorMessage() email
104 // reports. The downstream code at queryAndGetResults() lines 1067+
105 // already classifies these for its own reporting branch; doing the
106 // same classification here keeps the two layers consistent.
107 // May 2026: 4 of 38 4.1.13 sites in the email-flood cohort were
108 // "Illegal mix of collations" reports that should have been WARN.
109 if ($this->isInfrastructureSqlError($lastError)) {
110 $this->logger->warn($message);
111 return;
112 }
113
114 // The statement asked for a schema change that was already made. That
115 // is the state the caller wanted, so the plugin can still do its job --
116 // the project's own test for warning versus error -- and the developer
117 // has nothing to act on. Recorded, not reported. August 2026: report
118 // 270 was two concurrent upgrade requests racing to add the same index,
119 // and this line is where four of its five ERROR entries came from.
120 if ($this->isRedundantSchemaChangeError($lastError)) {
121 $this->logger->warn($message);
122 return;
123 }
124
125 $this->logger->errorMessage($message);
126 }
127
128 /**
129 * Pure classifier: true if the SQL error string says the schema already
130 * reflects the change the statement asked for. Mirrors
131 * {@see isInfrastructureSqlError()} in shape so the observed-error layer
132 * and the final-error layer classify from one definition rather than two
133 * copies of a string list.
134 *
135 * @param string $errorText
136 * @return bool
137 */
138 public function isRedundantSchemaChangeError(string $errorText): bool {
139 if ($errorText === '') {
140 return false;
141 }
142 return $this->core->errorClassifier()->isRedundantSchemaChangeError($errorText);
143 }
144
145 /**
146 * Pure classifier: true if the SQL error string matches a known
147 * infrastructure / host / hosting-environment failure pattern. These
148 * are conditions the plugin can detect and degrade past, not plugin
149 * bugs. Centralizing the union here keeps logObservedSqlError() and
150 * the downstream reporting branch in queryAndGetResults() consistent.
151 *
152 * @param string $errorText
153 * @return bool
154 */
155 public function isInfrastructureSqlError(string $errorText): bool {
156 if ($errorText === '') {
157 return false;
158 }
159 return $this->core->errorClassifier()->isInfrastructureSqlError($errorText);
160 }
161
162 /**
163 * @param string $query
164 * @param Throwable $e
165 * @param array<string, mixed> $options
166 * @param bool $producesRows
167 * @return void
168 */
169 public function logSqlThrowable(string $query, Throwable $e, array $options, bool $producesRows): void {
170 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
171 $logErrors = !array_key_exists('log_errors', $options) || (bool)$options['log_errors'];
172 $message = 'SQL query threw exception: ' . $e->getMessage()
173 . ', SQL: ' . $sqlInfo
174 . ', source: ' . $this->core->queryDiagnostics()->extractSqlFilename($query)
175 . ', route: ' . ($producesRows ? 'get_results' : 'query')
176 . ', log_errors_option: ' . ($logErrors ? 'true' : 'false');
177
178 $exception = $e instanceof Exception ? $e : new Exception($e->getMessage(), (int)$e->getCode(), $e);
179 $this->logger->errorMessage($message, $exception);
180 }
181
182 /**
183 * Handle final SQL reporting and stale-notice cleanup after recovery.
184 *
185 * @param string $query
186 * @param array<string, mixed> $result
187 * @param array<string, mixed> $options
188 * @param array<int|string, string> $ignoreErrorStrings
189 * @param ABJ_404_Solution_Timer $timer
190 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
191 * @return void
192 */
193 public function handleFinalSqlErrorReporting(
194 string $query,
195 array &$result,
196 array $options,
197 array $ignoreErrorStrings,
198 ABJ_404_Solution_Timer $timer,
199 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
200 ): void {
201 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
202 ? (string)$result['last_error'] : '';
203
204 if ($options['log_errors'] && $lastError !== '') {
205 $this->runRepairHooksForFinalError(
206 $query,
207 $result,
208 $lastError,
209 $tracer
210 );
211 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
212 ? (string)$result['last_error'] : '';
213 if ($lastError === '') {
214 return;
215 }
216
217 if (!$this->shouldReportFinalError($lastError, $ignoreErrorStrings)) {
218 return;
219 }
220
221 if ($this->isInfrastructureSqlError($lastError)) {
222 $this->logger->warn("Server-side DB issue (handled): " . $lastError);
223 return;
224 }
225
226 // Same reasoning as the observed-error layer above: a schema change
227 // that was already applied reached its goal, so it is recorded
228 // rather than reported. Both layers fire on one failed statement,
229 // so demoting only one of them would still email the developer.
230 if ($this->isRedundantSchemaChangeError($lastError)) {
231 $this->logger->warn("Schema change was already applied (handled): " . $lastError);
232 return;
233 }
234
235 $this->logDetailedFinalSqlError($query, $lastError, $timer);
236 return;
237 }
238
239 if ($options['log_too_slow'] && $timer->getElapsedTime() > 5) {
240 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
241 $this->logger->debugMessage("Slow query (" . round($timer->getElapsedTime(), 2) . " seconds): " .
242 $sqlInfo);
243 }
244
245 if ($lastError === '') {
246 $this->clearRecoveredServerSideNoticeIfNeeded();
247 }
248 }
249
250 /**
251 * @param string $query
252 * @param array<string, mixed> $result
253 * @param string $lastError
254 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
255 * @return void
256 */
257 private function runRepairHooksForFinalError(
258 string $query,
259 array &$result,
260 string $lastError,
261 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
262 ): void {
263 if (strpos($lastError, " is marked as crashed ") !== false) {
264 $repair = function () use ($lastError): void {
265 $this->core->tableRepairer()->repairTable($lastError);
266 };
267 if ($tracer === null) {
268 $repair();
269 } else {
270 $tracer->traceBranch('corrupted_table', $repair);
271 }
272 }
273 if (strpos($lastError, "ALTER TABLE causes auto_increment resequencing") !== false &&
274 strpos($lastError, "resulting in duplicate entry") !== false) {
275 $repair = function () use ($lastError, $query): void {
276 $this->core->tableRepairer()->repairDuplicateIDs($lastError, $query);
277 };
278 if ($tracer === null) {
279 $repair();
280 } else {
281 $tracer->traceBranch('duplicate_id', $repair);
282 }
283 }
284 if ($this->core->errorClassifier()->isIncorrectKeyFileError($lastError)) {
285 $repair = function () use ($query, &$result, $tracer): void {
286 $this->core->tableRepairer()->repairCorruptedTableAndRetry(
287 $query,
288 $result,
289 $tracer
290 );
291 };
292 if ($tracer === null) {
293 $repair();
294 } else {
295 $tracer->traceBranch('corrupted_table', $repair);
296 }
297 }
298 }
299
300 /**
301 * @param string $lastError
302 * @param array<int|string, string> $ignoreErrorStrings
303 * @return bool
304 */
305 private function shouldReportFinalError(string $lastError, array $ignoreErrorStrings): bool {
306 foreach ($ignoreErrorStrings as $ignoreThis) {
307 if (is_string($ignoreThis) && strpos($lastError, $ignoreThis) !== false) {
308 return false;
309 }
310 }
311 return true;
312 }
313
314 /**
315 * @param string $query
316 * @param string $lastError
317 * @param ABJ_404_Solution_Timer $timer
318 * @return void
319 */
320 private function logDetailedFinalSqlError(string $query, string $lastError, ABJ_404_Solution_Timer $timer): void {
321 global $wpdb;
322
323 $strippedQuery = 'n/a';
324 if ($this->core->errorClassifier()->isInvalidDataError($lastError)) {
325 $strippedResult = $this->core->tableRepairer()->get_stripped_query_result($query);
326 $strippedQuery = is_string($strippedResult) ? $strippedResult : 'n/a';
327 }
328
329 $extraDataQuery = "select @@max_join_size as max_join_size, " .
330 "@@sql_big_selects as sql_big_selects, " .
331 "@@character_set_database as character_set_database";
332 // DAO-bypass-approved: SQL-error diagnostics must read server variables without recursive DAO error handling.
333 $someMySQLVariables = $wpdb->get_results($extraDataQuery, ARRAY_A);
334 $variables = print_r($someMySQLVariables, true);
335
336 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
337 $dbVer = $wpdb->db_version();
338 $this->logger->errorMessage("Ugh. SQL query error: " . $lastError .
339 ", SQL: " . $sqlInfo .
340 ", Execution time: " . round($timer->getElapsedTime(), 2) .
341 ", DB ver: " . (is_string($dbVer) ? $dbVer : 'unknown') .
342 ", Variables: " . $variables .
343 ", stripped_query: " . $strippedQuery);
344 }
345
346 /** @return void */
347 private function clearRecoveredServerSideNoticeIfNeeded(): void {
348 if (!$this->core->noticeState()->isServerSideIssueNoted() && !$this->core->noticeState()->isServerSideIssueChecked()) {
349 $this->core->noticeState()->markServerSideIssueChecked();
350 $existing = $this->core->noticeState()->getRuntimeFlag('abj404_plugin_db_notice');
351 $excludedTypes = array('stale_permalink_cache', 'missing_table');
352 if (is_array($existing) && !empty($existing['type'])
353 && !in_array($existing['type'], $excludedTypes, true)) {
354 $this->core->noticeState()->markServerSideIssueNoted();
355 }
356 }
357 if ($this->core->noticeState()->isServerSideIssueNoted() && !$this->core->noticeState()->isWriteBlockActive() && !$this->core->errorClassifier()->isQuotaCooldownActive()) {
358 $this->core->noticeState()->clearServerSideDbNotice();
359 }
360 }
361 }
362