PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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 4.3.3, at includes/database/DatabaseSqlErrorReporter.php

325 lines 13.4 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 $this->logger->errorMessage($message);
115 }
116
117 /**
118 * Pure classifier: true if the SQL error string matches a known
119 * infrastructure / host / hosting-environment failure pattern. These
120 * are conditions the plugin can detect and degrade past, not plugin
121 * bugs. Centralizing the union here keeps logObservedSqlError() and
122 * the downstream reporting branch in queryAndGetResults() consistent.
123 *
124 * @param string $errorText
125 * @return bool
126 */
127 public function isInfrastructureSqlError(string $errorText): bool {
128 if ($errorText === '') {
129 return false;
130 }
131 return $this->core->errorClassifier()->taxonomy()->isInfrastructureSqlError($errorText);
132 }
133
134 /**
135 * @param string $query
136 * @param Throwable $e
137 * @param array<string, mixed> $options
138 * @param bool $producesRows
139 * @return void
140 */
141 public function logSqlThrowable(string $query, Throwable $e, array $options, bool $producesRows): void {
142 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
143 $logErrors = !array_key_exists('log_errors', $options) || (bool)$options['log_errors'];
144 $message = 'SQL query threw exception: ' . $e->getMessage()
145 . ', SQL: ' . $sqlInfo
146 . ', source: ' . $this->core->queryDiagnostics()->extractSqlFilename($query)
147 . ', route: ' . ($producesRows ? 'get_results' : 'query')
148 . ', log_errors_option: ' . ($logErrors ? 'true' : 'false');
149
150 $exception = $e instanceof Exception ? $e : new Exception($e->getMessage(), (int)$e->getCode(), $e);
151 $this->logger->errorMessage($message, $exception);
152 }
153
154 /**
155 * Handle final SQL reporting and stale-notice cleanup after recovery.
156 *
157 * @param string $query
158 * @param array<string, mixed> $result
159 * @param array<string, mixed> $options
160 * @param array<int|string, string> $ignoreErrorStrings
161 * @param ABJ_404_Solution_Timer $timer
162 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
163 * @return void
164 */
165 public function handleFinalSqlErrorReporting(
166 string $query,
167 array &$result,
168 array $options,
169 array $ignoreErrorStrings,
170 ABJ_404_Solution_Timer $timer,
171 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
172 ): void {
173 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
174 ? (string)$result['last_error'] : '';
175
176 if ($options['log_errors'] && $lastError !== '') {
177 $this->runRepairHooksForFinalError(
178 $query,
179 $result,
180 $lastError,
181 $tracer
182 );
183 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
184 ? (string)$result['last_error'] : '';
185 if ($lastError === '') {
186 return;
187 }
188
189 if (!$this->shouldReportFinalError($lastError, $ignoreErrorStrings)) {
190 return;
191 }
192
193 if ($this->isInfrastructureSqlError($lastError)) {
194 $this->logger->warn("Server-side DB issue (handled): " . $lastError);
195 return;
196 }
197
198 $this->logDetailedFinalSqlError($query, $lastError, $timer);
199 return;
200 }
201
202 if ($options['log_too_slow'] && $timer->getElapsedTime() > 5) {
203 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
204 $this->logger->debugMessage("Slow query (" . round($timer->getElapsedTime(), 2) . " seconds): " .
205 $sqlInfo);
206 }
207
208 if ($lastError === '') {
209 $this->clearRecoveredServerSideNoticeIfNeeded();
210 }
211 }
212
213 /**
214 * @param string $query
215 * @param array<string, mixed> $result
216 * @param string $lastError
217 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
218 * @return void
219 */
220 private function runRepairHooksForFinalError(
221 string $query,
222 array &$result,
223 string $lastError,
224 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
225 ): void {
226 if (strpos($lastError, " is marked as crashed ") !== false) {
227 $repair = function () use ($lastError): void {
228 $this->core->tableRepairer()->repairTable($lastError);
229 };
230 if ($tracer === null) {
231 $repair();
232 } else {
233 $tracer->traceBranch('corrupted_table', $repair);
234 }
235 }
236 if (strpos($lastError, "ALTER TABLE causes auto_increment resequencing") !== false &&
237 strpos($lastError, "resulting in duplicate entry") !== false) {
238 $repair = function () use ($lastError, $query): void {
239 $this->core->tableRepairer()->repairDuplicateIDs($lastError, $query);
240 };
241 if ($tracer === null) {
242 $repair();
243 } else {
244 $tracer->traceBranch('duplicate_id', $repair);
245 }
246 }
247 if ($this->core->errorClassifier()->taxonomy()->schema()->isIncorrectKeyFileError($lastError)) {
248 $repair = function () use ($query, &$result, $tracer): void {
249 $this->core->tableRepairer()->repairCorruptedTableAndRetry(
250 $query,
251 $result,
252 $tracer
253 );
254 };
255 if ($tracer === null) {
256 $repair();
257 } else {
258 $tracer->traceBranch('corrupted_table', $repair);
259 }
260 }
261 }
262
263 /**
264 * @param string $lastError
265 * @param array<int|string, string> $ignoreErrorStrings
266 * @return bool
267 */
268 private function shouldReportFinalError(string $lastError, array $ignoreErrorStrings): bool {
269 foreach ($ignoreErrorStrings as $ignoreThis) {
270 if (is_string($ignoreThis) && strpos($lastError, $ignoreThis) !== false) {
271 return false;
272 }
273 }
274 return true;
275 }
276
277 /**
278 * @param string $query
279 * @param string $lastError
280 * @param ABJ_404_Solution_Timer $timer
281 * @return void
282 */
283 private function logDetailedFinalSqlError(string $query, string $lastError, ABJ_404_Solution_Timer $timer): void {
284 global $wpdb;
285
286 $strippedQuery = 'n/a';
287 if ($this->core->errorClassifier()->taxonomy()->schema()->isInvalidDataError($lastError)) {
288 $strippedResult = $this->core->tableRepairer()->get_stripped_query_result($query);
289 $strippedQuery = is_string($strippedResult) ? $strippedResult : 'n/a';
290 }
291
292 $extraDataQuery = "select @@max_join_size as max_join_size, " .
293 "@@sql_big_selects as sql_big_selects, " .
294 "@@character_set_database as character_set_database";
295 // DAO-bypass-approved: SQL-error diagnostics must read server variables without recursive DAO error handling.
296 $someMySQLVariables = $wpdb->get_results($extraDataQuery, ARRAY_A);
297 $variables = print_r($someMySQLVariables, true);
298
299 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
300 $dbVer = $wpdb->db_version();
301 $this->logger->errorMessage("Ugh. SQL query error: " . $lastError .
302 ", SQL: " . $sqlInfo .
303 ", Execution time: " . round($timer->getElapsedTime(), 2) .
304 ", DB ver: " . (is_string($dbVer) ? $dbVer : 'unknown') .
305 ", Variables: " . $variables .
306 ", stripped_query: " . $strippedQuery);
307 }
308
309 /** @return void */
310 private function clearRecoveredServerSideNoticeIfNeeded(): void {
311 if (!$this->core->noticeState()->isServerSideIssueNoted() && !$this->core->noticeState()->isServerSideIssueChecked()) {
312 $this->core->noticeState()->markServerSideIssueChecked();
313 $existing = $this->core->noticeState()->getRuntimeFlag('abj404_plugin_db_notice');
314 $excludedTypes = array('stale_permalink_cache', 'missing_table');
315 if (is_array($existing) && !empty($existing['type'])
316 && !in_array($existing['type'], $excludedTypes, true)) {
317 $this->core->noticeState()->markServerSideIssueNoted();
318 }
319 }
320 if ($this->core->noticeState()->isServerSideIssueNoted() && !$this->core->noticeState()->isWriteBlockActive() && !$this->core->errorClassifier()->isQuotaCooldownActive()) {
321 $this->core->noticeState()->clearServerSideDbNotice();
322 }
323 }
324 }
325