PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_ErrorClassification.php

DataAccessTrait_ErrorClassification.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ErrorClassification.php

1,076 lines 49.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Error classification, infrastructure error handling, missing-table repair,
4 * and prefix mismatch diagnostics for DataAccess.
5 *
6 * Extracted from DataAccess.php to keep the main class under the file-size limit.
7 * All methods are called via $this-> from DataAccess (trait context).
8 *
9 * @since 4.1.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 trait ABJ_404_Solution_DataAccess_ErrorClassificationTrait {
17
18 /**
19 * Determine whether an error indicates invalid text/charset payload.
20 *
21 * @param string $errorText
22 * @return bool
23 */
24 private function isInvalidDataError($errorText) {
25 if (!is_string($errorText) || $errorText === '') {
26 return false;
27 }
28 $lower = strtolower($errorText);
29 return (
30 $this->f->strpos($lower, 'contains invalid data') !== false ||
31 $this->f->strpos($lower, 'incorrect string value') !== false ||
32 $this->f->strpos($lower, 'invalid utf8') !== false
33 );
34 }
35
36 /**
37 * Classify a $wpdb->last_error as an infrastructure issue (disk full, read-only, etc.).
38 * If it IS an infrastructure error: logs WARN and calls noteDatabaseIssueFromError().
39 * If it is NOT: returns false (caller is responsible for logging at ERROR level).
40 *
41 * Use this at call sites that bypass queryAndGetResults() and call $wpdb directly.
42 * Public so that NGramFilter, DatabaseUpgradesEtc, and other classes can call it
43 * via $this->dao->classifyAndHandleInfrastructureError().
44 *
45 * @param string $errorText The value of $wpdb->last_error.
46 * @return bool True if the error was classified as infrastructure (already handled).
47 */
48 public function classifyAndHandleInfrastructureError(string $errorText): bool {
49 if ($errorText === '') {
50 return false;
51 }
52
53 if ($this->isDiskFullError($errorText) ||
54 $this->isReadOnlyError($errorText) ||
55 $this->isQuotaLimitError($errorText) ||
56 $this->isInvalidDataError($errorText) ||
57 $this->isCollationError($errorText) ||
58 $this->isMissingPluginTableError($errorText) ||
59 $this->isIncorrectKeyFileError($errorText) ||
60 $this->isCrashedTableError($errorText) ||
61 $this->isDeadlockOrLockTimeoutError($errorText) ||
62 $this->isGaleraConflictError($errorText) ||
63 $this->isTransientConnectionError($errorText) ||
64 $this->isQueryTimeoutError($errorText) ||
65 $this->isAccessDeniedError($errorText)
66 ) {
67 $this->logger->warn("Server-side DB issue (handled): " . $errorText);
68 $this->noteDatabaseIssueFromError($errorText);
69 return true;
70 }
71
72 return false;
73 }
74
75 /** @param string|null $errorText @return bool */
76 private function isTransientConnectionError(?string $errorText): bool {
77 $errorText = $errorText ?? '';
78 if ($errorText === '') {
79 return false;
80 }
81 $lower = strtolower($errorText);
82 $transientMarkers = array(
83 'server has gone away',
84 'lost connection to mysql server during query',
85 'error while sending query packet',
86 'packets out of order',
87 'connection was killed',
88 );
89 foreach ($transientMarkers as $marker) {
90 if ($this->f->strpos($lower, $marker) !== false) {
91 return true;
92 }
93 }
94 // Numeric client-error codes for the connection-drop class. Some PDO
95 // / driver surfaces (and translated MySQL builds where the English
96 // text is missing) emit "(2006)", "[2006]", "errno 2006", or a
97 // SQLSTATE-formatted "SQLSTATE[HY000]: General error: 2006 ..." with
98 // no canonical "server has gone away" wording. Match the unambiguous
99 // bracketed / parenthesized / errno-prefixed forms so a bare "2006"
100 // appearing in some unrelated text (year, ID, row count) does not
101 // misclassify. 2006 = CR_SERVER_GONE_ERROR, 2013 = CR_SERVER_LOST.
102 foreach (array('2006', '2013') as $code) {
103 if ($this->f->strpos($lower, '[' . $code . ']') !== false
104 || $this->f->strpos($lower, '(' . $code . ')') !== false
105 || $this->f->strpos($lower, 'errno ' . $code) !== false
106 || $this->f->strpos($lower, 'errno: ' . $code) !== false
107 || $this->f->strpos($lower, 'error: ' . $code . ' ') !== false
108 || $this->f->strpos($lower, 'error ' . $code . ':') !== false) {
109 return true;
110 }
111 }
112 return false;
113 }
114
115 /** @param string $errorText @return bool */
116 private function isQuotaLimitError(string $errorText): bool {
117 if (!is_string($errorText) || $errorText === '') {
118 return false;
119 }
120 $lower = strtolower($errorText);
121 return ($this->f->strpos($lower, 'max_questions') !== false ||
122 $this->f->strpos($lower, 'resource') !== false && $this->f->strpos($lower, 'question') !== false);
123 }
124
125 /** @param string $errorText @return bool */
126 private function isDiskFullError(string $errorText): bool {
127 if (!is_string($errorText) || $errorText === '') {
128 return false;
129 }
130 $lower = strtolower($errorText);
131 // "Got error 28 from storage engine" (ER_GET_ERRNO with POSIX ENOSPC)
132 // "errno: 28" / "Errcode: 28" (ER_DISK_FULL, ER_ERROR_ON_WRITE)
133 // "No space left on device" (OS strerror for ENOSPC, English only)
134 // "The table '...' is full" (ER_RECORD_FILE_FULL / error 1114)
135 // "Disk full" (ER_DISK_FULL)
136 // Note: on servers with non-English lc_messages, the text around "28"
137 // may be translated (e.g. "erreur 28" in French), but the numeric 28
138 // always appears. The strpos checks cover all known English MySQL/MariaDB
139 // message formats; non-English servers are rare in WordPress hosting.
140 return ($this->f->strpos($lower, 'error 28') !== false ||
141 $this->f->strpos($lower, 'errno: 28') !== false ||
142 $this->f->strpos($lower, 'errcode: 28') !== false ||
143 $this->f->strpos($lower, 'no space left on device') !== false ||
144 $this->f->strpos($lower, "' is full") !== false ||
145 $this->f->strpos($lower, 'table is full') !== false ||
146 $this->f->strpos($lower, 'disk full') !== false);
147 }
148
149 /** @param string $errorText @return bool */
150 private function isReadOnlyError(string $errorText): bool {
151 if (!is_string($errorText) || $errorText === '') {
152 return false;
153 }
154 $lower = strtolower($errorText);
155 return ($this->f->strpos($lower, 'read only') !== false ||
156 $this->f->strpos($lower, 'read-only') !== false ||
157 $this->f->strpos($lower, 'super_read_only') !== false);
158 }
159
160 /**
161 * Detect MySQL/MariaDB access-denied errors. ER_DBACCESS_DENIED_ERROR
162 * (1044) and ER_TABLEACCESS_DENIED_ERROR (1142) fire when the configured
163 * DB user lacks rights for the requested operation: typical on hosting
164 * providers where the plugin's CREATE TABLE / DROP TABLE privileges are
165 * revoked, or where wp_options has been moved between databases.
166 * Server config issue, not a plugin bug. Should be a WARN, not an ERROR.
167 *
168 * @param string $errorText
169 * @return bool
170 */
171 private function isAccessDeniedError(string $errorText): bool {
172 if (!is_string($errorText) || $errorText === '') {
173 return false;
174 }
175 $lower = strtolower($errorText);
176 return ($this->f->strpos($lower, 'access denied') !== false ||
177 $this->f->strpos($lower, 'command denied') !== false);
178 }
179
180 /**
181 * True when an error indicates that the `SET STATEMENT max_statement_time=N FOR ...`
182 * timeout wrapper itself was rejected by the server (privilege denied or
183 * syntax not understood). Distinct from an error in the wrapped query.
184 *
185 * Hosts that reject the wrapper:
186 * 1. MariaDB requiring SUPER for SET STATEMENT (errno 1227 / SQLSTATE 42000)
187 * 2. ProxySQL / older audit firewalls that do not parse the prefix and
188 * return a syntax error (errno 1064 / SQLSTATE 42000) on "SET STATEMENT"
189 * 3. Galera clusters that reject SET STATEMENT in some replication modes
190 *
191 * The caller MUST also confirm the failed query actually started with
192 * a `SET STATEMENT max_statement_time=` prefix before treating the error
193 * as a wrapper rejection. Generic access-denied or syntax errors on
194 * other query shapes are not recoverable by stripping a wrapper that
195 * was never there.
196 *
197 * @param string $errorText
198 * @return bool
199 */
200 private function classifySetStatementFailure(string $errorText): bool {
201 if ($errorText === '') {
202 return false;
203 }
204 $lower = strtolower($errorText);
205 // SUPER privilege required (MariaDB SET STATEMENT requires SUPER on
206 // some configurations). The error is access-denied class, but the
207 // SUPER-privilege phrasing is the unambiguous tell. Generic
208 // table-access-denied uses "for user" or names a table.
209 if ($this->f->strpos($lower, 'super privilege') !== false ||
210 $this->f->strpos($lower, 'super_privilege') !== false ||
211 $this->f->strpos($lower, '(at least one of) the super') !== false) {
212 return true;
213 }
214 // ProxySQL / firewall syntax-error path: "syntax error" or
215 // "you have an error in your sql syntax" combined with "SET STATEMENT"
216 // mentioned in the error context. The wpdb->last_error often echoes
217 // a leading slice of the offending query.
218 if (($this->f->strpos($lower, 'syntax error') !== false ||
219 $this->f->strpos($lower, 'error in your sql syntax') !== false ||
220 $this->f->strpos($lower, '1064') !== false) &&
221 $this->f->strpos($lower, 'set statement') !== false) {
222 return true;
223 }
224 return false;
225 }
226
227 /** @param string $errorText @return bool */
228 private function isCollationError(string $errorText): bool {
229 if (!is_string($errorText) || $errorText === '') {
230 return false;
231 }
232 $lower = strtolower($errorText);
233 return ($this->f->strpos($lower, 'illegal mix of collations') !== false ||
234 $this->f->strpos($lower, 'unknown collation') !== false ||
235 $this->f->strpos($lower, 'collation') !== false && $this->f->strpos($lower, 'not valid') !== false);
236 }
237
238 /** @param string $errorText @return bool */
239 private function isCrashedTableError(string $errorText): bool {
240 if (!is_string($errorText) || $errorText === '') {
241 return false;
242 }
243 return stripos($errorText, 'is marked as crashed') !== false;
244 }
245
246 /** @param string $errorText @return bool */
247 private function isIncorrectKeyFileError(string $errorText): bool {
248 if (!is_string($errorText) || $errorText === '') {
249 return false;
250 }
251 return stripos($errorText, 'Incorrect key file') !== false;
252 }
253
254 /** Detect MySQL MAX_EXECUTION_TIME (errno 3024) and MariaDB max_statement_time (errno 1969) timeouts.
255 * @param string $errorText @return bool */
256 private function isQueryTimeoutError(string $errorText): bool {
257 if (!is_string($errorText) || $errorText === '') {
258 return false;
259 }
260 return (strpos($errorText, '3024') !== false ||
261 strpos($errorText, '1969') !== false ||
262 stripos($errorText, 'max_execution_time') !== false ||
263 stripos($errorText, 'max_statement_time') !== false);
264 }
265
266 /**
267 * Detect MySQL/MariaDB max_allowed_packet errors. Default error message:
268 * "Got a packet bigger than 'max_allowed_packet' bytes" (errno 1153).
269 * Routed by the staged-build orchestrator into batch-shrink recovery so
270 * a host with a small packet limit doesn't loop forever on the same
271 * oversized INSERT.
272 *
273 * @param string $errorText
274 * @return bool
275 */
276 private function isPacketTooLarge(string $errorText): bool {
277 if (!is_string($errorText) || $errorText === '') {
278 return false;
279 }
280 $lower = strtolower($errorText);
281 return ($this->f->strpos($lower, 'max_allowed_packet') !== false ||
282 $this->f->strpos($lower, 'got a packet bigger') !== false ||
283 $this->f->strpos($lower, '1153') !== false);
284 }
285
286 /** @param string $errorText @return bool */
287 private function isDeadlockOrLockTimeoutError(string $errorText): bool {
288 if (!is_string($errorText) || $errorText === '') {
289 return false;
290 }
291 $lower = strtolower($errorText);
292 return ($this->f->strpos($lower, 'deadlock found') !== false ||
293 $this->f->strpos($lower, 'lock wait timeout exceeded') !== false ||
294 $this->f->strpos($lower, 'error 1213') !== false ||
295 $this->f->strpos($lower, 'error 1205') !== false);
296 }
297
298 /**
299 * Detect MariaDB Galera optimistic-concurrency rejections.
300 *
301 * Galera (wsrep) clusters use optimistic concurrency control: a node
302 * accepts a write locally, then certifies it against the cluster on
303 * commit. If another node already wrote to the same row, certification
304 * fails and the local transaction is rolled back with errno 1020 /
305 * ER_CHECKREAD ("Record has changed since last read in table 'X'").
306 * Other related markers carry "wsrep_" or "cluster conflict" wording.
307 *
308 * Structurally this is the same retry-able conflict shape as InnoDB
309 * deadlock (errno 1213) and lock-wait timeout (errno 1205), but the
310 * error wording is different so isDeadlockOrLockTimeoutError() does
311 * not match. Like deadlock, the next cron tick can simply retry; it
312 * is a server-side coordination failure, not a plugin bug, and must
313 * be logged at WARN (not ERROR, which emails the admin).
314 *
315 * Source: 4.1.15 site (ohafiatv) running MariaDB 11.8.3 emitted 3 of
316 * these errors from updatePermalinkCache.sql; another cluster node was
317 * writing the same {prefix}_abj404_permalink_cache row.
318 *
319 * @param string $errorText
320 * @return bool
321 */
322 private function isGaleraConflictError(string $errorText): bool {
323 if (!is_string($errorText) || $errorText === '') {
324 return false;
325 }
326 $lower = strtolower($errorText);
327 return ($this->f->strpos($lower, 'record has changed since last read') !== false ||
328 $this->f->strpos($lower, 'wsrep_local_state') !== false ||
329 $this->f->strpos($lower, 'cluster conflict') !== false);
330 }
331
332 /**
333 * Detect PHP "Allowed memory size of N bytes exhausted" / "Out of memory"
334 * messages. Real OOM is a fatal that bypasses try/catch, but a Throwable
335 * wrapper (e.g. PHP 8 Error subclass surfaced from a memory-aware hook,
336 * or an explicit guard that pre-rejects an over-budget allocation) can
337 * carry the same wording. Routed through the staged-build classifier so
338 * S9 (optional) skips on OOM instead of bubbling out as a stage error.
339 *
340 * @param string $errorText
341 * @return bool
342 */
343 public function isOutOfMemoryError(string $errorText): bool {
344 if ($errorText === '') {
345 return false;
346 }
347 $lower = strtolower($errorText);
348 return ($this->f->strpos($lower, 'allowed memory size') !== false ||
349 $this->f->strpos($lower, 'out of memory') !== false ||
350 $this->f->strpos($lower, 'memory exhausted') !== false ||
351 $this->f->strpos($lower, 'memory_limit') !== false);
352 }
353
354 /**
355 * True when an error from a staged-build query represents a permanent
356 * host-side environmental constraint we cannot recover from by retrying:
357 * GRANT-revoked privilege (CREATE TEMPORARY TABLES, ALTER, RENAME),
358 * read-only replica, exhausted disk/quota, table marked crashed (a
359 * crashed plugin table on a stage that does DDL we can't repair our
360 * way out of), or a PHP-side OOM. Re-running the same query on the
361 * next cron tick will just produce the same error.
362 *
363 * Used by classifyStageFailure() to decide between "skip optional stage"
364 * and "halt critical stage". Resumable kills (max_statement_time, lock
365 * waits, gone-away) are NOT permanent and are already handled by
366 * isResumableStagedKill().
367 *
368 * Programmer-class errors (syntax, undefined column, unknown function)
369 * deliberately return false: we want those to surface as bugs, not be
370 * silently degraded around. (Codex pushback in test docblock for
371 * testStage9SyntaxErrorIsNotSilentlySkipped.)
372 *
373 * @param string $errorText
374 * @return bool
375 */
376 private function isPermanentHostSideStagedFailure(string $errorText): bool {
377 if (!is_string($errorText) || $errorText === '') {
378 return false;
379 }
380 if ($this->isResumableStagedKill($errorText)) {
381 return false;
382 }
383 return ($this->isAccessDeniedError($errorText)
384 || $this->isReadOnlyError($errorText)
385 || $this->isDiskFullError($errorText)
386 || $this->isQuotaLimitError($errorText)
387 || $this->isOutOfMemoryError($errorText));
388 }
389
390 /**
391 * Per-stage classification for an error raised inside the staged view
392 * build. Routes the orchestrator's catch block instead of the legacy
393 * binary "resumable-kill or rethrow" decision: the staged build has
394 * stages that can be skipped without breaking publication (S3/S9/S10:
395 * adds/aggregates) and stages that genuinely cannot proceed without
396 * (S1 create, S2 insert, S11 swap).
397 *
398 * Returns one of:
399 * - 'resumable' : kill class the next tick can retry (existing behavior)
400 * - 'skip' : permanent host failure on an optional stage; mark
401 * the stage permanently skipped, advance past it
402 * - 'halt' : permanent host failure on a critical stage; stop
403 * re-trying, surface a deduplicated admin notice
404 * - 'rethrow' : programmer-class or unknown error; let it propagate
405 * so the dev mailbox carries actionable context
406 *
407 * The per-stage policy lives on
408 * ABJ_404_Solution_ViewBuildConfig::stageFailurePolicy() so it can be
409 * tuned without touching this classifier.
410 *
411 * @param int $stageNumber 1..11
412 * @param string $errorText
413 * @return string
414 */
415 public function classifyStageFailure(int $stageNumber, string $errorText): string {
416 if (!is_string($errorText) || $errorText === '') {
417 return 'rethrow';
418 }
419 // Buffer-missing marker thrown by our own pre-stage probes
420 // (assertBuildBufferExistsOrHalt in DataAccessTrait_ViewBuildStage-
421 // Callbacks.php, the bespoke S2/S4/S5 inline guards in the same
422 // file). A concurrent invalidateViewDone() dropped view_build out
423 // from under the running build; the next tick rebuilds cleanly
424 // from S0. Classify as resumable so the orchestrator yields
425 // without escalating to the dev mailbox. Match before the
426 // resumable-kill / permanent-host-failure checks so a future
427 // change to those classifiers cannot accidentally shadow this
428 // marker. Substring match because the message includes the
429 // stage label ("at S3 entry", "during S2 INSERT", etc.) but the
430 // "Staged view-build buffer missing" prefix is invariant.
431 if (stripos($errorText, 'Staged view-build buffer missing') !== false) {
432 return 'resumable';
433 }
434 if ($this->isResumableStagedKill($errorText)) {
435 return 'resumable';
436 }
437 if (!$this->isPermanentHostSideStagedFailure($errorText)) {
438 return 'rethrow';
439 }
440 $policy = ABJ_404_Solution_ViewBuildConfig::stageFailurePolicy($stageNumber);
441 return $policy === 'optional' ? 'skip' : 'halt';
442 }
443
444 /**
445 * True when an error from a staged-build query represents a kill the
446 * host inflicted on us (out of our control) that the build can resume
447 * from on the next request. The staged pipeline persists progress
448 * (current_stage, batch high-water ids) on every batch boundary, so
449 * any of these classes can be safely converted to "yield this tick"
450 * without losing work.
451 *
452 * Covered: query-timeout kills (max_statement_time / max_execution_time
453 * exceeded, "Query execution was interrupted"), transient connection
454 * loss ("server has gone away", "Lost connection"), and lock-wait /
455 * deadlock kills. Any of these on a slow shared host will end the
456 * stage's query without ending the request, and we want the build to
457 * keep making forward progress on the next tick instead of returning
458 * a 500 that breaks the JS poll loop.
459 *
460 * @param string $errorText
461 * @return bool
462 */
463 private function isResumableStagedKill(string $errorText): bool {
464 if ($errorText === '') {
465 return false;
466 }
467 if ($this->isQueryTimeoutError($errorText)) {
468 return true;
469 }
470 if ($this->isTransientConnectionError($errorText)) {
471 return true;
472 }
473 if ($this->isDeadlockOrLockTimeoutError($errorText)) {
474 return true;
475 }
476 // "Query execution was interrupted" is the bare MariaDB / MySQL
477 // message variant that does not always carry the "max_statement_time"
478 // substring (server-side KILL QUERY, client cancellation, replica
479 // failover). Same resume semantics.
480 if (stripos($errorText, 'query execution was interrupted') !== false) {
481 return true;
482 }
483 // max_allowed_packet exceeded: the next tick's batch-shrink path
484 // will halve the batch size and retry, exactly like a host-killed
485 // batch. Without this, an oversized INSERT loops with the same
486 // packet error and never converges (same infinite-retry shape as
487 // the access-denied bug pre-classifier).
488 if ($this->isPacketTooLarge($errorText)) {
489 return true;
490 }
491 return false;
492 }
493
494 /**
495 * Extract a table name from a MySQL "table is full" error message.
496 * MySQL formats this as: The table 'table_name' is full
497 * @param string $errorText
498 * @return string|null The table name, or null if not parseable.
499 */
500 private function extractTableNameFromFullError(string $errorText): ?string {
501 if (preg_match("/table '([^']+)' is full/i", $errorText, $m)) {
502 return $m[1];
503 }
504 return null;
505 }
506
507 /**
508 * Check if a given table uses the InnoDB storage engine.
509 * Returns false on any query failure (safe default).
510 * @param string $tableName
511 * @return bool
512 */
513 private function isInnoDBTable(string $tableName): bool {
514 global $wpdb;
515 /** @var wpdb $wpdb */
516 if (!method_exists($wpdb, 'get_var') || !method_exists($wpdb, 'prepare')) {
517 return false; // Safe default when $wpdb is a partial stub
518 }
519 if (defined('DB_NAME')) {
520 $dbName = (string)DB_NAME;
521 } else {
522 // Per-request warn once: silent empty-string fallback hides
523 // whether the schema-probe is actually working in tests that
524 // forget to define DB_NAME (Smell 1 from error-swallow audit).
525 static $warnedNoDbName = false;
526 if (!$warnedNoDbName) {
527 $warnedNoDbName = true;
528 $this->logger->warn(__METHOD__ . ': DB_NAME undefined; using empty schema in InnoDB probe');
529 }
530 $dbName = '';
531 }
532 $engine = $wpdb->get_var(
533 $wpdb->prepare(
534 "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
535 $dbName,
536 $tableName
537 )
538 );
539 return is_string($engine) && strtolower($engine) === 'innodb';
540 }
541
542 private function noteDatabaseIssueFromError(string $errorText): void {
543 if (trim($errorText) === '') {
544 return;
545 }
546 if ($this->isDiskFullError($errorText)) {
547 $this->serverSideIssueNoted = true;
548 $this->setRuntimeFlag('abj404_db_disk_full_until', $this->clock()->now() + self::DB_WRITE_BLOCK_COOLDOWN_SECONDS, self::DB_WRITE_BLOCK_COOLDOWN_SECONDS);
549
550 // Disambiguate InnoDB tablespace exhaustion from actual disk full or MyISAM limit.
551 // "table is full" for InnoDB means the shared tablespace (ibdata1) is at capacity —
552 // trimming plugin rows will NOT free space; the host must expand the tablespace.
553 $tableFull = stripos($errorText, 'table') !== false && stripos($errorText, 'is full') !== false;
554 if ($tableFull) {
555 $tableName = $this->extractTableNameFromFullError($errorText);
556 if ($tableName !== null && $this->isInnoDBTable($tableName)) {
557 $this->setPluginDbNotice('disk_full', $this->localizeOrDefault('The InnoDB tablespace appears to be exhausted. Deleting plugin data will NOT free this space. Contact your hosting provider to expand the InnoDB tablespace (ibdata1).'), $errorText);
558 return;
559 }
560 }
561
562 $this->setPluginDbNotice('disk_full', $this->localizeOrDefault('Database storage appears full (disk/engine space). Plugin write-heavy tasks are temporarily paused.'), $errorText);
563 return;
564 }
565 if ($this->isQuotaLimitError($errorText)) {
566 $this->serverSideIssueNoted = true;
567 $this->setRuntimeFlag('abj404_db_quota_cooldown_until', $this->clock()->now() + self::DB_QUOTA_COOLDOWN_SECONDS, self::DB_QUOTA_COOLDOWN_SECONDS);
568 $this->setPluginDbNotice('query_quota', $this->localizeOrDefault('Database query quota was exceeded (for example max_questions). Non-essential plugin background tasks are temporarily paused.'), $errorText);
569 return;
570 }
571 if ($this->isReadOnlyError($errorText)) {
572 $this->serverSideIssueNoted = true;
573 $this->setRuntimeFlag('abj404_db_read_only_until', $this->clock()->now() + self::DB_WRITE_BLOCK_COOLDOWN_SECONDS, self::DB_WRITE_BLOCK_COOLDOWN_SECONDS);
574 $this->setPluginDbNotice('read_only', $this->localizeOrDefault('Database appears to be in read-only mode. Plugin write operations are temporarily paused.'), $errorText);
575 return;
576 }
577 if ($this->isCollationError($errorText)) {
578 // Per owner directive: collation issues must NEVER surface as user notices.
579 // The plugin auto-recovers by running correctCollations() at query time
580 // (see DataAccess::recoverFromCollationMismatchAndRetry()). Here we only
581 // log the original error at debug level so developers can see it in
582 // debug.txt without the user ever being notified.
583 $this->logger->debugMessage("Collation mismatch detected (auto-recovery will run): " . $errorText);
584 }
585 }
586
587 /** @return bool */
588 private function isQuotaCooldownActive(): bool {
589 $rawQuotaFlag = $this->getRuntimeFlag('abj404_db_quota_cooldown_until');
590 $until = is_scalar($rawQuotaFlag) ? (int)$rawQuotaFlag : 0;
591 return ($until > $this->clock()->now());
592 }
593
594 /** @param string $errorText @return bool */
595 private function isMissingPluginTableError(string $errorText): bool {
596 if ($errorText === '') {
597 return false;
598 }
599 $lower = strtolower($errorText);
600 if ($this->f->strpos($lower, '_abj404_logs_hits') !== false) {
601 return false;
602 }
603 return ($this->f->strpos($lower, "doesn't exist") !== false &&
604 $this->f->strpos($lower, '_abj404_') !== false);
605 }
606
607 /**
608 * The staged-view-build pipeline owns three transient tables
609 * (view_build, view_done, view_deleteme). They are created and dropped
610 * between build cycles by design; discoverPermanentDDLFiles() already
611 * excludes them from createDatabaseTables(). A SELECT that hits a
612 * swap-window race against any of them is not a corruption signal and
613 * must not surface the missing_table admin notice or engage the 1h
614 * repair cooldown.
615 *
616 * @param string $errorText
617 * @return bool
618 */
619 private function isTransientViewBuildTableError(string $errorText): bool {
620 if ($errorText === '') {
621 return false;
622 }
623 $lower = strtolower($errorText);
624 return ($this->f->strpos($lower, '_abj404_view_build') !== false ||
625 $this->f->strpos($lower, '_abj404_view_done') !== false ||
626 $this->f->strpos($lower, '_abj404_view_deleteme') !== false);
627 }
628
629 /**
630 * Attempt one auto-repair pass for missing plugin tables, then retry query once.
631 *
632 * @param string $query
633 * @param array<string, mixed> $result
634 * @return void
635 */
636 private function attemptMissingTableRepairAndRetry($query, &$result) {
637 if (self::$tableRepairInProgress) {
638 return;
639 }
640 if ($this->handleTransientViewBuildTableMissing($query, $result)) {
641 return;
642 }
643 // Rate-limit repeated failures: after a failed repair, downgrade subsequent
644 // occurrences to WARNING for 1 hour so cron-per-run error storms don't
645 // generate email reports. The first failure still logs ERROR and attempts repair.
646 // 1 hour (not 24h), because a transient race during the repair (e.g. concurrent
647 // wp-cron firing) can cause one failure that would clear by the next page load.
648 // A 24h lockout permanently disables self-healing for the rest of the admin session.
649 $repairCooldownKey = 'abj404_missing_table_repair_cooldown';
650 $cooldownTtlSeconds = 3600;
651 if ($this->isMissingTableRepairOnCooldown($result, $repairCooldownKey)) {
652 return;
653 }
654
655 // During upgrades and nightly maintenance, createDatabaseTables() runs
656 // proactively before any queries. If we reach this point, a plugin table
657 // went missing during normal usage. Log as INFO while we attempt repair;
658 // only escalate to ERROR if repair fails (avoids flooding admin with
659 // error emails for transient issues that auto-repair resolves).
660 $originalSqlError = is_string($result['last_error']) ? $result['last_error'] : '';
661 $missingTable = $this->extractMissingTableNameFromError($originalSqlError);
662 $this->logger->infoMessage("Missing plugin table detected during query. "
663 . "Attempting auto-repair. SQL error: " . $originalSqlError);
664
665 self::$tableRepairInProgress = true;
666 try {
667 $this->runRepairCreateRetryAndReport(
668 $query, $result, $repairCooldownKey, $cooldownTtlSeconds,
669 $originalSqlError, $missingTable
670 );
671 } catch (Throwable $e) {
672 $this->logger->warn("Missing-table auto-repair failed: " . $e->getMessage());
673 $this->setRuntimeFlag($repairCooldownKey, $this->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds);
674 } finally {
675 self::$tableRepairInProgress = false;
676 }
677 }
678
679 /**
680 * If the observed error is against a transient staged-view-build table
681 * (view_build, view_done, view_deleteme), handle it inline and return true.
682 * Returns false if the error is unrelated to those tables, so the caller
683 * proceeds with the normal repair flow.
684 *
685 * Transient staged-view-build tables are owned by the staged-build pipeline
686 * and created/dropped between cycles. discoverPermanentDDLFiles() excludes
687 * them from createDatabaseTables(), so the repair path cannot recreate them
688 * and would fall straight into the failed-repair branch, setting the
689 * missing_table admin notice on every plugin page and engaging a 1h cooldown
690 * that blocks legit missing-table repair for the redirects / logsv2 / etc.
691 * core tables.
692 *
693 * The silent-degrade is bounded to its actual use case: a SELECT against
694 * `view_done` during the S11 RENAME swap window. Reader (admin redirect-list
695 * AJAX) races writer (stageRenameSwap); the error is benign because the very
696 * next request will see the new view_done. Any OTHER query / table
697 * combination on these three tables represents the pipeline operating on its
698 * own internal state. If view_build goes missing during INSERT/UPDATE/ALTER/
699 * RENAME, the build is genuinely broken (concurrent invalidateViewDone
700 * dropping the buffer mid-pipeline, S1's CREATE TABLE silently
701 * approved-but-not-executed by an audit firewall, switch_to_blog race) and
702 * the error must propagate so the orchestrator can halt and surface a real
703 * admin notice instead of marching through every stage marking it complete.
704 *
705 * Cataloged as Pattern 13 in docs/PROACTIVE_BUG_DISCOVERY.md ("over-broad
706 * error-swallow silences real pipeline failure"), the inverse of Pattern 7
707 * ("don't escalate infra errors to email"). Reference: WP.org topic
708 * 18908598, wp_siddur_ prefix site whose entire S2-to-S11 pipeline silently
709 * failed on every cron tick because the prior unbounded swallow wiped
710 * last_error for every write.
711 *
712 * @param string $query
713 * @param array<string, mixed> $result
714 * @return bool true if the case was handled (caller should return).
715 */
716 private function handleTransientViewBuildTableMissing($query, array &$result): bool {
717 $observedError = is_string($result['last_error']) ? $result['last_error'] : '';
718 if (!$this->isTransientViewBuildTableError($observedError)) {
719 return false;
720 }
721 $lowerErr = strtolower($observedError);
722 $errorMentionsViewDone = ($this->f->strpos($lowerErr, '_abj404_view_done') !== false)
723 && ($this->f->strpos($lowerErr, '_abj404_view_deleteme') === false);
724 $isReadQuery = $this->queryProducesResultRows($query);
725
726 if ($errorMentionsViewDone && $isReadQuery) {
727 $this->logger->debugMessage(
728 "view_done missing on read (S11 swap-window race, expected): "
729 . $observedError
730 );
731 $result['last_error'] = '';
732 return true;
733 }
734
735 // Pipeline-write or pipeline-internal read against a transient
736 // build table that's missing. createDatabaseTables() cannot
737 // recreate these tables (they're excluded from
738 // discoverPermanentDDLFiles); the build orchestrator owns S1.
739 // Skip the repair attempt and let last_error propagate so the
740 // stage's runStagedSqlFile() throws and the classifier halts.
741 $this->logger->warn(
742 "Transient staged-build table missing during pipeline operation "
743 . "(build state diverged from disk; halting stage): "
744 . $observedError
745 );
746 return true;
747 }
748
749 /**
750 * Returns true if the missing-table auto-repair cooldown is currently active.
751 * When the cooldown is active, the caller's last_error is cleared so
752 * queryAndGetResults() does not double-report this error as
753 * "Ugh. SQL query error" ERROR.
754 *
755 * @param array<string, mixed> $result
756 * @param string $repairCooldownKey
757 * @return bool
758 */
759 private function isMissingTableRepairOnCooldown(array &$result, string $repairCooldownKey): bool {
760 $cooldownUntil = $this->getRuntimeFlag($repairCooldownKey);
761 if (!is_scalar($cooldownUntil) || (int)$cooldownUntil <= $this->clock()->now()) {
762 return false;
763 }
764 $this->logger->warn("Missing plugin table (repair previously failed, cooldown active): "
765 . $result['last_error']);
766 $result['last_error'] = '';
767 return true;
768 }
769
770 /**
771 * Run the actual repair: createDatabaseTables(), flush wpdb, retry the
772 * original query, and either clear the cooldown (success) or engage the
773 * cooldown + admin notice (failure).
774 *
775 * @param string $query
776 * @param array<string, mixed> $result
777 * @param string $repairCooldownKey
778 * @param int $cooldownTtlSeconds
779 * @param string $originalSqlError
780 * @param string $missingTable
781 * @return void
782 */
783 private function runRepairCreateRetryAndReport(
784 $query,
785 array &$result,
786 string $repairCooldownKey,
787 int $cooldownTtlSeconds,
788 string $originalSqlError,
789 string $missingTable
790 ): void {
791 $upgrades = abj_service('database_upgrades');
792 // Pass $force = true so the repair bypasses the concurrency lock. If another
793 // request holds the lock (e.g. a concurrent upgrade), calling createDatabaseTables
794 // without $force would silently return without creating anything, leaving the
795 // missing table unrepaired. Concurrent CREATE TABLE IF NOT EXISTS calls are safe
796 // (idempotent), so bypassing the lock here is correct.
797 $upgrades->createDatabaseTables(false, true);
798
799 global $wpdb;
800 $wpdb->flush();
801
802 // Suppress WP's own error output for the retry. If it also fails, we
803 // report it ourselves below. Without this, WP logs a second
804 // "WordPress database error" entry on top of the first, producing
805 // duplicate noise in debug.log for every failed cron run.
806 $prevSuppressState = $wpdb->suppress_errors(true);
807 $result['rows'] = $wpdb->get_results($query, $this->currentResultType);
808 $wpdb->suppress_errors($prevSuppressState);
809 $this->harvestWpdbResult($result);
810
811 if ($result['last_error'] === '') {
812 $this->logger->infoMessage("Missing-table auto-repair succeeded.");
813 // Clear any active cooldown now that repair is working.
814 if (function_exists('delete_transient')) {
815 delete_transient($repairCooldownKey);
816 } elseif (function_exists('delete_option')) {
817 delete_option($repairCooldownKey);
818 }
819 // If a stale missing_table notice exists from an earlier failed
820 // repair attempt, clear it immediately now that repair succeeded.
821 $this->clearPluginDbNoticeIfType('missing_table');
822 return;
823 }
824
825 $this->reportRepairRetryFailure(
826 $result, $repairCooldownKey, $cooldownTtlSeconds, $originalSqlError, $missingTable
827 );
828 }
829
830 /**
831 * The retry inside runRepairCreateRetryAndReport() came back with an
832 * error. Distinguish multisite-cross-prefix (not actionable, silent
833 * degrade) from a real failure (WARN log + 1h cooldown + admin notice).
834 *
835 * @param array<string, mixed> $result
836 * @param string $repairCooldownKey
837 * @param int $cooldownTtlSeconds
838 * @param string $originalSqlError
839 * @param string $missingTable
840 * @return void
841 */
842 private function reportRepairRetryFailure(
843 array &$result,
844 string $repairCooldownKey,
845 int $cooldownTtlSeconds,
846 string $originalSqlError,
847 string $missingTable
848 ): void {
849 global $wpdb;
850 // Check for prefix mismatch: plugin tables may exist under a
851 // different $table_prefix than the current $wpdb->prefix (common
852 // after site migrations or hosting panel clones).
853 $prefixDiag = $this->diagnosePrefixMismatch();
854
855 // Multisite cross-prefix: a query referenced another subsite's table.
856 // The plugin correctly created tables for the current site, but cannot
857 // fix another subsite's missing tables from this request context.
858 // That subsite will get its tables when its own cron fires.
859 if ($this->isMultisiteCrossPrefixError($originalSqlError)) {
860 $this->logger->warn("Multisite cross-prefix table reference (not actionable from this site). "
861 . "Current prefix: " . ($wpdb->prefix ?? '')
862 . ", Original error: " . $originalSqlError . $prefixDiag);
863 // Clear last_error so queryAndGetResults() does not double-report.
864 $result['last_error'] = '';
865 return;
866 }
867
868 // Repair failed. Log at WARN, not ERROR. Per the self-healing
869 // philosophy in CLAUDE.md (item 4): "Notify if recovery fails ...
870 // Never send email." The admin notice set below is the user-facing
871 // surface, gated to the plugin's own admin page. errorMessage()
872 // triggers the daily email digest; warn() does not. Previously
873 // this site emailed the developer once per cooldown expiry (every
874 // 1h) for any permanently-broken table, which is the email-storm
875 // pattern Bruno's and the kstal-site logs both exhibit.
876 // Include the specific table that failed plus an explicit post-CREATE
877 // existence check so the debug log distinguishes "CREATE didn't materialize
878 // the table" (concurrency race, swallowed SQL error in queryAndGetResults,
879 // insufficient privileges) from other retry-failure modes.
880 $tableStillMissing = ($missingTable !== '' && !$this->tableExists($missingTable));
881 $tableContext = ($missingTable !== '')
882 ? " Table: " . $missingTable . "."
883 : '';
884 $existenceContext = $tableStillMissing
885 ? ' Table is still missing after CREATE TABLE ran. '
886 . 'createDatabaseTables() did not materialize this table '
887 . '(likely a concurrent DROP, swallowed SQL error in queryAndGetResults, '
888 . 'or insufficient CREATE TABLE privileges).'
889 : '';
890 $this->logger->warn("Missing plugin table auto-repair failed."
891 . $tableContext
892 . $existenceContext
893 . " Original error: " . $originalSqlError
894 . ", Retry error: " . $result['last_error']
895 . $prefixDiag);
896 // Engage 1h cooldown and surface a single admin notice on
897 // the plugin screen so the admin knows to investigate.
898 // Never email; never show on all wp-admin pages.
899 $this->setRuntimeFlag($repairCooldownKey, $this->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds);
900 $this->setMissingTablePluginDbNotice($result, $missingTable, $prefixDiag);
901 }
902
903 /**
904 * Construct and store the missing-table admin notice that surfaces on the
905 * plugin's own admin screens (gated; never wp-admin-wide, never email).
906 *
907 * @param array<string, mixed> $result
908 * @param string $missingTable
909 * @param string $prefixDiag
910 * @return void
911 */
912 private function setMissingTablePluginDbNotice(array $result, string $missingTable, string $prefixDiag): void {
913 $tableLabel = ($missingTable !== '') ? "'" . $missingTable . "'" : 'a plugin database table';
914 $rawError = is_string($result['last_error']) ? $result['last_error'] : '';
915 $adminMsg =
916 '404 Solution cannot function correctly: the database table '
917 . $tableLabel . ' is missing, and the plugin tried to recreate it '
918 . 'but the CREATE TABLE statement could not be executed. '
919 . 'This almost always means the WordPress database user does not '
920 . 'have permission to run CREATE TABLE (and likely ALTER TABLE / '
921 . 'CREATE INDEX) on this database. Until this is fixed, the plugin '
922 . 'cannot record 404s, serve redirects, or generate suggestions. '
923 . 'To fix it: ask your hosting provider or database administrator '
924 . 'to grant CREATE, ALTER, and INDEX privileges to the WordPress '
925 . 'database user for this site, then reload this page. '
926 . 'Alternatively, restore the missing table from a recent database backup.';
927 if ($rawError !== '') {
928 $adminMsg .= ' Original database error: ' . $rawError;
929 }
930 if ($prefixDiag !== '') {
931 $adminMsg .= ' ' . $prefixDiag;
932 }
933 $noticePayload = array(
934 'type' => 'missing_table',
935 'message' => $this->localizeOrDefault($adminMsg),
936 'timestamp' => $this->clock()->now(),
937 'error_string' => $rawError,
938 );
939 $this->setRuntimeFlag('abj404_plugin_db_notice', $noticePayload, 86400);
940 }
941
942 /**
943 * Extract the unprefixed-by-database table name from a MySQL "doesn't exist"
944 * error message. Returns the bare table name (e.g. "wp_abj404_redirects")
945 * or empty string if the error format does not match.
946 *
947 * MySQL emits errors as either:
948 * Table 'dbname.tablename' doesn't exist
949 * Table 'tablename' doesn't exist
950 * The database-name segment is stripped because callers want the live
951 * table name suitable for SHOW TABLES LIKE.
952 *
953 * @param string $errorText
954 * @return string
955 */
956 private function extractMissingTableNameFromError(string $errorText): string {
957 if ($errorText === '') {
958 return '';
959 }
960 if (!preg_match("/Table '([^']+)' doesn't exist/i", $errorText, $matches)) {
961 return '';
962 }
963 $fullName = $matches[1];
964 $dotPos = strrpos($fullName, '.');
965 return $dotPos !== false ? substr($fullName, $dotPos + 1) : $fullName;
966 }
967
968 /**
969 * Check whether plugin tables exist under a different prefix than $wpdb->prefix.
970 *
971 * After site migrations or hosting panel clones, $table_prefix in wp-config.php
972 * may differ from the prefix used when the plugin tables were originally created.
973 * Returns a diagnostic string if a mismatch is detected, empty string otherwise.
974 *
975 * @return string Diagnostic message or empty string.
976 */
977 private function diagnosePrefixMismatch(): string {
978 global $wpdb;
979 try {
980 $dbName = $wpdb->dbname ?? '';
981 if ($dbName === '') {
982 return '';
983 }
984 // @utf8-audit: opt-out — $wpdb->dbname is set by WordPress at
985 // bootstrap from wp-config.php; never user input.
986 $dbNameEscaped = esc_sql($dbName);
987 $dbNameStr = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
988 // Find any table containing 'abj404_redirects' in this database.
989 $rows = $wpdb->get_results(
990 "SELECT table_name FROM information_schema.tables "
991 . "WHERE table_schema = '{$dbNameStr}' "
992 . "AND LOWER(table_name) LIKE '%abj404\_redirects'",
993 ARRAY_A
994 );
995 if (!is_array($rows) || empty($rows)) {
996 return '';
997 }
998 $expectedTable = $this->getLowercasePrefix() . 'abj404_redirects';
999 $foundTables = [];
1000 foreach ($rows as $row) {
1001 // Case-insensitive key lookup (MySQL driver inconsistency).
1002 $name = null;
1003 foreach ($row as $key => $value) {
1004 if (strtolower((string)$key) === 'table_name') {
1005 $name = (string)$value;
1006 break;
1007 }
1008 }
1009 if ($name !== null) {
1010 $foundTables[] = $name;
1011 }
1012 }
1013 // Filter out the table we're already looking for.
1014 $mismatched = array_filter($foundTables, function ($t) use ($expectedTable) {
1015 return strtolower($t) !== strtolower($expectedTable);
1016 });
1017 if (empty($mismatched)) {
1018 return '';
1019 }
1020 $msg = ', PREFIX MISMATCH DETECTED: $wpdb->prefix is "' . ($wpdb->prefix ?? '')
1021 . '" (expected table: ' . $expectedTable . ') but plugin tables exist as: '
1022 . implode(', ', $mismatched) . '.';
1023 if (function_exists('is_multisite') && is_multisite()) {
1024 $msg .= ' This is a multisite installation — the other prefixes likely belong to other subsites (normal).';
1025 } else {
1026 $msg .= ' Check $table_prefix in wp-config.php.';
1027 }
1028 return $msg;
1029 } catch (Throwable $e) {
1030 return '';
1031 }
1032 }
1033
1034 /**
1035 * Detect whether a missing-table error references a different multisite subsite's prefix.
1036 *
1037 * On network-activated multisite, wp-cron can fire queries that reference tables
1038 * from a different subsite's prefix (e.g. wp_4_abj404_* while current prefix is wp_).
1039 * This is not an error — the other subsite's tables exist under its own prefix and
1040 * will be serviced when that subsite's cron fires.
1041 *
1042 * @param string $errorText The MySQL error string.
1043 * @return bool True if the error references a different multisite subsite's prefix.
1044 */
1045 private function isMultisiteCrossPrefixError(string $errorText): bool {
1046 if ($errorText === '' || !function_exists('is_multisite') || !is_multisite()) {
1047 return false;
1048 }
1049
1050 global $wpdb;
1051 // Extract table name from error. MySQL formats:
1052 // Table 'dbname.tablename' doesn't exist
1053 // Table `dbname`.`tablename` doesn't exist
1054 if (!preg_match("/['\x60](?:[^'\x60]+\.)?([^'\x60]*abj404_[^'\x60]+)['\x60]/i", $errorText, $matches)) {
1055 return false;
1056 }
1057 $referencedTable = strtolower($matches[1]);
1058
1059 $currentPrefix = strtolower($wpdb->prefix ?? 'wp_');
1060 $basePrefix = strtolower($wpdb->base_prefix ?? 'wp_');
1061
1062 // If the table starts with the current prefix, it's genuinely missing for THIS site.
1063 if (strpos($referencedTable, $currentPrefix . 'abj404_') === 0) {
1064 return false;
1065 }
1066
1067 // Check if it matches {base_prefix}{N}_abj404_ (a different subsite's table).
1068 $pattern = '/^' . preg_quote($basePrefix, '/') . '(\d+)_abj404_/';
1069 if (preg_match($pattern, $referencedTable)) {
1070 return true;
1071 }
1072
1073 return false;
1074 }
1075 }
1076