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

287 lines 12.2 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 * @return void
163 */
164 public function handleFinalSqlErrorReporting(
165 string $query,
166 array &$result,
167 array $options,
168 array $ignoreErrorStrings,
169 ABJ_404_Solution_Timer $timer
170 ): void {
171 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
172 ? (string)$result['last_error'] : '';
173
174 if ($options['log_errors'] && $lastError !== '') {
175 $this->runRepairHooksForFinalError($query, $result, $lastError);
176 $lastError = isset($result['last_error']) && is_scalar($result['last_error'])
177 ? (string)$result['last_error'] : '';
178 if ($lastError === '') {
179 return;
180 }
181
182 if (!$this->shouldReportFinalError($lastError, $ignoreErrorStrings)) {
183 return;
184 }
185
186 if ($this->isInfrastructureSqlError($lastError)) {
187 $this->logger->warn("Server-side DB issue (handled): " . $lastError);
188 return;
189 }
190
191 $this->logDetailedFinalSqlError($query, $lastError, $timer);
192 return;
193 }
194
195 if ($options['log_too_slow'] && $timer->getElapsedTime() > 5) {
196 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
197 $this->logger->debugMessage("Slow query (" . round($timer->getElapsedTime(), 2) . " seconds): " .
198 $sqlInfo);
199 }
200
201 if ($lastError === '') {
202 $this->clearRecoveredServerSideNoticeIfNeeded();
203 }
204 }
205
206 /**
207 * @param string $query
208 * @param array<string, mixed> $result
209 * @param string $lastError
210 * @return void
211 */
212 private function runRepairHooksForFinalError(string $query, array &$result, string $lastError): void {
213 if (strpos($lastError, " is marked as crashed ") !== false) {
214 $this->core->tableRepairer()->repairTable($lastError);
215 }
216 if (strpos($lastError, "ALTER TABLE causes auto_increment resequencing") !== false &&
217 strpos($lastError, "resulting in duplicate entry") !== false) {
218 $this->core->tableRepairer()->repairDuplicateIDs($lastError, $query);
219 }
220 if ($this->core->errorClassifier()->taxonomy()->schema()->isIncorrectKeyFileError($lastError)) {
221 $this->core->tableRepairer()->repairCorruptedTableAndRetry($query, $result);
222 }
223 }
224
225 /**
226 * @param string $lastError
227 * @param array<int|string, string> $ignoreErrorStrings
228 * @return bool
229 */
230 private function shouldReportFinalError(string $lastError, array $ignoreErrorStrings): bool {
231 foreach ($ignoreErrorStrings as $ignoreThis) {
232 if (is_string($ignoreThis) && strpos($lastError, $ignoreThis) !== false) {
233 return false;
234 }
235 }
236 return true;
237 }
238
239 /**
240 * @param string $query
241 * @param string $lastError
242 * @param ABJ_404_Solution_Timer $timer
243 * @return void
244 */
245 private function logDetailedFinalSqlError(string $query, string $lastError, ABJ_404_Solution_Timer $timer): void {
246 global $wpdb;
247
248 $strippedQuery = 'n/a';
249 if ($this->core->errorClassifier()->taxonomy()->schema()->isInvalidDataError($lastError)) {
250 $strippedResult = $this->core->tableRepairer()->get_stripped_query_result($query);
251 $strippedQuery = is_string($strippedResult) ? $strippedResult : 'n/a';
252 }
253
254 $extraDataQuery = "select @@max_join_size as max_join_size, " .
255 "@@sql_big_selects as sql_big_selects, " .
256 "@@character_set_database as character_set_database";
257 // DAO-bypass-approved: SQL-error diagnostics must read server variables without recursive DAO error handling.
258 $someMySQLVariables = $wpdb->get_results($extraDataQuery, ARRAY_A);
259 $variables = print_r($someMySQLVariables, true);
260
261 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->core->queryDiagnostics()->extractSqlFilename($query);
262 $dbVer = $wpdb->db_version();
263 $this->logger->errorMessage("Ugh. SQL query error: " . $lastError .
264 ", SQL: " . $sqlInfo .
265 ", Execution time: " . round($timer->getElapsedTime(), 2) .
266 ", DB ver: " . (is_string($dbVer) ? $dbVer : 'unknown') .
267 ", Variables: " . $variables .
268 ", stripped_query: " . $strippedQuery);
269 }
270
271 /** @return void */
272 private function clearRecoveredServerSideNoticeIfNeeded(): void {
273 if (!$this->core->noticeState()->isServerSideIssueNoted() && !$this->core->noticeState()->isServerSideIssueChecked()) {
274 $this->core->noticeState()->markServerSideIssueChecked();
275 $existing = $this->core->noticeState()->getRuntimeFlag('abj404_plugin_db_notice');
276 $excludedTypes = array('stale_permalink_cache', 'missing_table');
277 if (is_array($existing) && !empty($existing['type'])
278 && !in_array($existing['type'], $excludedTypes, true)) {
279 $this->core->noticeState()->markServerSideIssueNoted();
280 }
281 }
282 if ($this->core->noticeState()->isServerSideIssueNoted() && !$this->core->noticeState()->isWriteBlockActive() && !$this->core->errorClassifier()->isQuotaCooldownActive()) {
283 $this->core->noticeState()->clearServerSideDbNotice();
284 }
285 }
286 }
287