PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / DatabaseErrorClassifier.php

DatabaseErrorClassifier.php in 404 Solution 4.2.0, at includes/DatabaseErrorClassifier.php

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