PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.13, at includes/DataAccessTrait_ErrorClassification.php

577 lines 26.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 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->isTransientConnectionError($errorText)
63 ) {
64 $this->logger->warn("Server-side DB issue (handled): " . $errorText);
65 $this->noteDatabaseIssueFromError($errorText);
66 return true;
67 }
68
69 return false;
70 }
71
72 /** @param string|null $errorText @return bool */
73 private function isTransientConnectionError(?string $errorText): bool {
74 $errorText = $errorText ?? '';
75 if ($errorText === '') {
76 return false;
77 }
78 $lower = strtolower($errorText);
79 $transientMarkers = array(
80 'server has gone away',
81 'lost connection to mysql server during query',
82 'error while sending query packet',
83 'packets out of order',
84 'connection was killed',
85 );
86 foreach ($transientMarkers as $marker) {
87 if ($this->f->strpos($lower, $marker) !== false) {
88 return true;
89 }
90 }
91 return false;
92 }
93
94 /** @param string $errorText @return bool */
95 private function isQuotaLimitError(string $errorText): bool {
96 if (!is_string($errorText) || $errorText === '') {
97 return false;
98 }
99 $lower = strtolower($errorText);
100 return ($this->f->strpos($lower, 'max_questions') !== false ||
101 $this->f->strpos($lower, 'resource') !== false && $this->f->strpos($lower, 'question') !== false);
102 }
103
104 /** @param string $errorText @return bool */
105 private function isDiskFullError(string $errorText): bool {
106 if (!is_string($errorText) || $errorText === '') {
107 return false;
108 }
109 $lower = strtolower($errorText);
110 // "Got error 28 from storage engine" (ER_GET_ERRNO with POSIX ENOSPC)
111 // "errno: 28" / "Errcode: 28" (ER_DISK_FULL, ER_ERROR_ON_WRITE)
112 // "No space left on device" (OS strerror for ENOSPC, English only)
113 // "The table '...' is full" (ER_RECORD_FILE_FULL / error 1114)
114 // "Disk full" (ER_DISK_FULL)
115 // Note: on servers with non-English lc_messages, the text around "28"
116 // may be translated (e.g. "erreur 28" in French), but the numeric 28
117 // always appears. The strpos checks cover all known English MySQL/MariaDB
118 // message formats; non-English servers are rare in WordPress hosting.
119 return ($this->f->strpos($lower, 'error 28') !== false ||
120 $this->f->strpos($lower, 'errno: 28') !== false ||
121 $this->f->strpos($lower, 'errcode: 28') !== false ||
122 $this->f->strpos($lower, 'no space left on device') !== false ||
123 $this->f->strpos($lower, "' is full") !== false ||
124 $this->f->strpos($lower, 'table is full') !== false ||
125 $this->f->strpos($lower, 'disk full') !== false);
126 }
127
128 /** @param string $errorText @return bool */
129 private function isReadOnlyError(string $errorText): bool {
130 if (!is_string($errorText) || $errorText === '') {
131 return false;
132 }
133 $lower = strtolower($errorText);
134 return ($this->f->strpos($lower, 'read only') !== false ||
135 $this->f->strpos($lower, 'read-only') !== false ||
136 $this->f->strpos($lower, 'super_read_only') !== false);
137 }
138
139 /** @param string $errorText @return bool */
140 private function isCollationError(string $errorText): bool {
141 if (!is_string($errorText) || $errorText === '') {
142 return false;
143 }
144 $lower = strtolower($errorText);
145 return ($this->f->strpos($lower, 'illegal mix of collations') !== false ||
146 $this->f->strpos($lower, 'unknown collation') !== false ||
147 $this->f->strpos($lower, 'collation') !== false && $this->f->strpos($lower, 'not valid') !== false);
148 }
149
150 /** @param string $errorText @return bool */
151 private function isCrashedTableError(string $errorText): bool {
152 if (!is_string($errorText) || $errorText === '') {
153 return false;
154 }
155 return stripos($errorText, 'is marked as crashed') !== false;
156 }
157
158 /** @param string $errorText @return bool */
159 private function isIncorrectKeyFileError(string $errorText): bool {
160 if (!is_string($errorText) || $errorText === '') {
161 return false;
162 }
163 return stripos($errorText, 'Incorrect key file') !== false;
164 }
165
166 /** Detect MySQL MAX_EXECUTION_TIME (errno 3024) and MariaDB max_statement_time (errno 1969) timeouts.
167 * @param string $errorText @return bool */
168 private function isQueryTimeoutError(string $errorText): bool {
169 if (!is_string($errorText) || $errorText === '') {
170 return false;
171 }
172 return (strpos($errorText, '3024') !== false ||
173 strpos($errorText, '1969') !== false ||
174 stripos($errorText, 'max_execution_time') !== false ||
175 stripos($errorText, 'max_statement_time') !== false);
176 }
177
178 /** @param string $errorText @return bool */
179 private function isDeadlockOrLockTimeoutError(string $errorText): bool {
180 if (!is_string($errorText) || $errorText === '') {
181 return false;
182 }
183 $lower = strtolower($errorText);
184 return ($this->f->strpos($lower, 'deadlock found') !== false ||
185 $this->f->strpos($lower, 'lock wait timeout exceeded') !== false ||
186 $this->f->strpos($lower, 'error 1213') !== false ||
187 $this->f->strpos($lower, 'error 1205') !== false);
188 }
189
190 /**
191 * Extract a table name from a MySQL "table is full" error message.
192 * MySQL formats this as: The table 'table_name' is full
193 * @param string $errorText
194 * @return string|null The table name, or null if not parseable.
195 */
196 private function extractTableNameFromFullError(string $errorText): ?string {
197 if (preg_match("/table '([^']+)' is full/i", $errorText, $m)) {
198 return $m[1];
199 }
200 return null;
201 }
202
203 /**
204 * Check if a given table uses the InnoDB storage engine.
205 * Returns false on any query failure (safe default).
206 * @param string $tableName
207 * @return bool
208 */
209 private function isInnoDBTable(string $tableName): bool {
210 global $wpdb;
211 /** @var wpdb $wpdb */
212 if (!method_exists($wpdb, 'get_var') || !method_exists($wpdb, 'prepare')) {
213 return false; // Safe default when $wpdb is a partial stub
214 }
215 if (defined('DB_NAME')) {
216 $dbName = (string)DB_NAME;
217 } else {
218 // Per-request warn once: silent empty-string fallback hides
219 // whether the schema-probe is actually working in tests that
220 // forget to define DB_NAME (Smell 1 from error-swallow audit).
221 static $warnedNoDbName = false;
222 if (!$warnedNoDbName) {
223 $warnedNoDbName = true;
224 $this->logger->warn(__METHOD__ . ': DB_NAME undefined; using empty schema in InnoDB probe');
225 }
226 $dbName = '';
227 }
228 $engine = $wpdb->get_var(
229 $wpdb->prepare(
230 "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
231 $dbName,
232 $tableName
233 )
234 );
235 return is_string($engine) && strtolower($engine) === 'innodb';
236 }
237
238 private function noteDatabaseIssueFromError(string $errorText): void {
239 if (!is_string($errorText) || trim($errorText) === '') {
240 return;
241 }
242 if ($this->isDiskFullError($errorText)) {
243 $this->serverSideIssueNoted = true;
244 $this->setRuntimeFlag('abj404_db_disk_full_until', $this->clock()->now() + self::DB_WRITE_BLOCK_COOLDOWN_SECONDS, self::DB_WRITE_BLOCK_COOLDOWN_SECONDS);
245
246 // Disambiguate InnoDB tablespace exhaustion from actual disk full or MyISAM limit.
247 // "table is full" for InnoDB means the shared tablespace (ibdata1) is at capacity —
248 // trimming plugin rows will NOT free space; the host must expand the tablespace.
249 $tableFull = stripos($errorText, 'table') !== false && stripos($errorText, 'is full') !== false;
250 if ($tableFull) {
251 $tableName = $this->extractTableNameFromFullError($errorText);
252 if ($tableName !== null && $this->isInnoDBTable($tableName)) {
253 $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);
254 return;
255 }
256 }
257
258 $this->setPluginDbNotice('disk_full', $this->localizeOrDefault('Database storage appears full (disk/engine space). Plugin write-heavy tasks are temporarily paused.'), $errorText);
259 return;
260 }
261 if ($this->isQuotaLimitError($errorText)) {
262 $this->serverSideIssueNoted = true;
263 $this->setRuntimeFlag('abj404_db_quota_cooldown_until', $this->clock()->now() + self::DB_QUOTA_COOLDOWN_SECONDS, self::DB_QUOTA_COOLDOWN_SECONDS);
264 $this->setPluginDbNotice('query_quota', $this->localizeOrDefault('Database query quota was exceeded (for example max_questions). Non-essential plugin background tasks are temporarily paused.'), $errorText);
265 return;
266 }
267 if ($this->isReadOnlyError($errorText)) {
268 $this->serverSideIssueNoted = true;
269 $this->setRuntimeFlag('abj404_db_read_only_until', $this->clock()->now() + self::DB_WRITE_BLOCK_COOLDOWN_SECONDS, self::DB_WRITE_BLOCK_COOLDOWN_SECONDS);
270 $this->setPluginDbNotice('read_only', $this->localizeOrDefault('Database appears to be in read-only mode. Plugin write operations are temporarily paused.'), $errorText);
271 return;
272 }
273 if ($this->isCollationError($errorText)) {
274 // Per owner directive: collation issues must NEVER surface as user notices.
275 // The plugin auto-recovers by running correctCollations() at query time
276 // (see DataAccess::recoverFromCollationMismatchAndRetry()). Here we only
277 // log the original error at debug level so developers can see it in
278 // debug.txt without the user ever being notified.
279 $this->logger->debugMessage("Collation mismatch detected (auto-recovery will run): " . $errorText);
280 }
281 }
282
283 /** @return bool */
284 private function isQuotaCooldownActive(): bool {
285 $rawQuotaFlag = $this->getRuntimeFlag('abj404_db_quota_cooldown_until');
286 $until = is_scalar($rawQuotaFlag) ? (int)$rawQuotaFlag : 0;
287 return ($until > $this->clock()->now());
288 }
289
290 /** @param string $errorText @return bool */
291 private function isMissingPluginTableError(string $errorText): bool {
292 if (!is_string($errorText) || $errorText === '') {
293 return false;
294 }
295 $lower = strtolower($errorText);
296 if ($this->f->strpos($lower, '_abj404_logs_hits') !== false) {
297 return false;
298 }
299 return ($this->f->strpos($lower, "doesn't exist") !== false &&
300 $this->f->strpos($lower, '_abj404_') !== false);
301 }
302
303 /**
304 * Attempt one auto-repair pass for missing plugin tables, then retry query once.
305 *
306 * @param string $query
307 * @param array<string, mixed> $result
308 * @return void
309 */
310 private function attemptMissingTableRepairAndRetry($query, &$result) {
311 if (self::$tableRepairInProgress) {
312 return;
313 }
314
315 // Rate-limit repeated failures: after a failed repair, downgrade subsequent
316 // occurrences to WARNING for 1 hour so cron-per-run error storms don't
317 // generate email reports. The first failure still logs ERROR and attempts repair.
318 // 1 hour (not 24h) — a transient race during the repair (e.g. concurrent wp-cron
319 // firing) can cause one failure that would clear by the next page load. A 24h
320 // lockout permanently disables self-healing for the rest of the admin session.
321 $repairCooldownKey = 'abj404_missing_table_repair_cooldown';
322 $cooldownTtlSeconds = 3600;
323 $cooldownUntil = $this->getRuntimeFlag($repairCooldownKey);
324 if (is_scalar($cooldownUntil) && (int)$cooldownUntil > $this->clock()->now()) {
325 $this->logger->warn("Missing plugin table (repair previously failed, cooldown active): "
326 . $result['last_error']);
327 // Clear last_error so the caller (queryAndGetResults) does not
328 // double-report this error as "Ugh. SQL query error" ERROR.
329 $result['last_error'] = '';
330 return;
331 }
332
333 // During upgrades and nightly maintenance, createDatabaseTables() runs
334 // proactively before any queries. If we reach this point, a plugin table
335 // went missing during normal usage. Log as INFO while we attempt repair;
336 // only escalate to ERROR if repair fails (avoids flooding admin with
337 // error emails for transient issues that auto-repair resolves).
338 $originalSqlError = is_string($result['last_error']) ? $result['last_error'] : '';
339 $missingTable = $this->extractMissingTableNameFromError($originalSqlError);
340 $this->logger->infoMessage("Missing plugin table detected during query. "
341 . "Attempting auto-repair. SQL error: " . $originalSqlError);
342
343 self::$tableRepairInProgress = true;
344 try {
345 $upgrades = abj_service('database_upgrades');
346 // Pass $force = true so the repair bypasses the concurrency lock — if another
347 // request holds the lock (e.g. a concurrent upgrade), calling createDatabaseTables
348 // without $force would silently return without creating anything, leaving the
349 // missing table unrepaired. Concurrent CREATE TABLE IF NOT EXISTS calls are safe
350 // (idempotent), so bypassing the lock here is correct.
351 $upgrades->createDatabaseTables(false, true);
352
353 global $wpdb;
354 $wpdb->flush();
355
356 // Suppress WP's own error output for the retry — if it also fails, we
357 // report it ourselves below. Without this, WP logs a second
358 // "WordPress database error" entry on top of the first, producing
359 // duplicate noise in debug.log for every failed cron run.
360 $prevSuppressState = $wpdb->suppress_errors(true);
361 $result['rows'] = $wpdb->get_results($query, $this->currentResultType);
362 $wpdb->suppress_errors($prevSuppressState);
363 $this->harvestWpdbResult($result);
364
365 if ($result['last_error'] === '') {
366 $this->logger->infoMessage("Missing-table auto-repair succeeded.");
367 // Clear any active cooldown — repair is now working.
368 if (function_exists('delete_transient')) {
369 delete_transient($repairCooldownKey);
370 } elseif (function_exists('delete_option')) {
371 delete_option($repairCooldownKey);
372 }
373 // If a stale missing_table notice exists from an earlier failed
374 // repair attempt, clear it immediately now that repair succeeded.
375 $this->clearPluginDbNoticeIfType('missing_table');
376 } else {
377 // Check for prefix mismatch: plugin tables may exist under a
378 // different $table_prefix than the current $wpdb->prefix (common
379 // after site migrations or hosting panel clones).
380 $prefixDiag = $this->diagnosePrefixMismatch();
381
382 // Multisite cross-prefix: a query referenced another subsite's table.
383 // The plugin correctly created tables for the current site, but cannot
384 // fix another subsite's missing tables from this request context.
385 // That subsite will get its tables when its own cron fires.
386 if ($this->isMultisiteCrossPrefixError($originalSqlError)) {
387 $this->logger->warn("Multisite cross-prefix table reference (not actionable from this site). "
388 . "Current prefix: " . ($wpdb->prefix ?? '')
389 . ", Original error: " . $originalSqlError . $prefixDiag);
390 // Clear last_error so queryAndGetResults() does not double-report.
391 $result['last_error'] = '';
392 return;
393 }
394
395 // Repair failed — now escalate to ERROR so it triggers email notification.
396 // Include the specific table that failed plus an explicit post-CREATE
397 // existence check so the debug log distinguishes "CREATE didn't materialize
398 // the table" (concurrency race, swallowed SQL error in queryAndGetResults,
399 // insufficient privileges) from other retry-failure modes.
400 $tableStillMissing = ($missingTable !== '' && !$this->tableExists($missingTable));
401 $tableContext = ($missingTable !== '')
402 ? " Table: " . $missingTable . "."
403 : '';
404 $existenceContext = $tableStillMissing
405 ? ' Table is still missing after CREATE TABLE ran — '
406 . 'createDatabaseTables() did not materialize this table '
407 . '(likely a concurrent DROP, swallowed SQL error in queryAndGetResults, '
408 . 'or insufficient CREATE TABLE privileges).'
409 : '';
410 $this->logger->errorMessage("Missing plugin table auto-repair failed."
411 . $tableContext
412 . $existenceContext
413 . " Original error: " . $originalSqlError
414 . ", Retry error: " . $result['last_error']
415 . $prefixDiag);
416 // Engage 1h cooldown and surface a single admin notice on
417 // the plugin screen so the admin knows to investigate.
418 // Never email; never show on all wp-admin pages.
419 $this->setRuntimeFlag($repairCooldownKey, $this->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds);
420 $tableLabel = ($missingTable !== '') ? "'" . $missingTable . "' " : '';
421 $adminMsg = 'A plugin database table ' . $tableLabel
422 . 'is missing and could not be repaired automatically. '
423 . 'Try deactivating and reactivating 404 Solution, or verify that your database user has CREATE TABLE privileges.';
424 if ($prefixDiag !== '') {
425 $adminMsg .= ' ' . $prefixDiag;
426 }
427 $noticePayload = array(
428 'type' => 'missing_table',
429 'message' => $this->localizeOrDefault($adminMsg),
430 'timestamp' => $this->clock()->now(),
431 'error_string' => $result['last_error'],
432 );
433 $this->setRuntimeFlag('abj404_plugin_db_notice', $noticePayload, 86400);
434 }
435 } catch (Throwable $e) {
436 $this->logger->warn("Missing-table auto-repair failed: " . $e->getMessage());
437 $this->setRuntimeFlag($repairCooldownKey, $this->clock()->now() + $cooldownTtlSeconds, $cooldownTtlSeconds);
438 } finally {
439 self::$tableRepairInProgress = false;
440 }
441 }
442
443 /**
444 * Extract the unprefixed-by-database table name from a MySQL "doesn't exist"
445 * error message. Returns the bare table name (e.g. "wp_abj404_redirects")
446 * or empty string if the error format does not match.
447 *
448 * MySQL emits errors as either:
449 * Table 'dbname.tablename' doesn't exist
450 * Table 'tablename' doesn't exist
451 * The database-name segment is stripped because callers want the live
452 * table name suitable for SHOW TABLES LIKE.
453 *
454 * @param string $errorText
455 * @return string
456 */
457 private function extractMissingTableNameFromError(string $errorText): string {
458 if ($errorText === '') {
459 return '';
460 }
461 if (!preg_match("/Table '([^']+)' doesn't exist/i", $errorText, $matches)) {
462 return '';
463 }
464 $fullName = $matches[1];
465 $dotPos = strrpos($fullName, '.');
466 return $dotPos !== false ? substr($fullName, $dotPos + 1) : $fullName;
467 }
468
469 /**
470 * Check whether plugin tables exist under a different prefix than $wpdb->prefix.
471 *
472 * After site migrations or hosting panel clones, $table_prefix in wp-config.php
473 * may differ from the prefix used when the plugin tables were originally created.
474 * Returns a diagnostic string if a mismatch is detected, empty string otherwise.
475 *
476 * @return string Diagnostic message or empty string.
477 */
478 private function diagnosePrefixMismatch(): string {
479 global $wpdb;
480 try {
481 $dbName = $wpdb->dbname ?? '';
482 if ($dbName === '') {
483 return '';
484 }
485 // @utf8-audit: opt-out — $wpdb->dbname is set by WordPress at
486 // bootstrap from wp-config.php; never user input.
487 $dbNameEscaped = esc_sql($dbName);
488 $dbNameStr = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
489 // Find any table containing 'abj404_redirects' in this database.
490 $rows = $wpdb->get_results(
491 "SELECT table_name FROM information_schema.tables "
492 . "WHERE table_schema = '{$dbNameStr}' "
493 . "AND LOWER(table_name) LIKE '%abj404\_redirects'",
494 ARRAY_A
495 );
496 if (!is_array($rows) || empty($rows)) {
497 return '';
498 }
499 $expectedTable = $this->getLowercasePrefix() . 'abj404_redirects';
500 $foundTables = [];
501 foreach ($rows as $row) {
502 // Case-insensitive key lookup (MySQL driver inconsistency).
503 $name = null;
504 foreach ($row as $key => $value) {
505 if (strtolower((string)$key) === 'table_name') {
506 $name = (string)$value;
507 break;
508 }
509 }
510 if ($name !== null) {
511 $foundTables[] = $name;
512 }
513 }
514 // Filter out the table we're already looking for.
515 $mismatched = array_filter($foundTables, function ($t) use ($expectedTable) {
516 return strtolower($t) !== strtolower($expectedTable);
517 });
518 if (empty($mismatched)) {
519 return '';
520 }
521 $msg = ', PREFIX MISMATCH DETECTED: $wpdb->prefix is "' . ($wpdb->prefix ?? '')
522 . '" (expected table: ' . $expectedTable . ') but plugin tables exist as: '
523 . implode(', ', $mismatched) . '.';
524 if (function_exists('is_multisite') && is_multisite()) {
525 $msg .= ' This is a multisite installation — the other prefixes likely belong to other subsites (normal).';
526 } else {
527 $msg .= ' Check $table_prefix in wp-config.php.';
528 }
529 return $msg;
530 } catch (Throwable $e) {
531 return '';
532 }
533 }
534
535 /**
536 * Detect whether a missing-table error references a different multisite subsite's prefix.
537 *
538 * On network-activated multisite, wp-cron can fire queries that reference tables
539 * from a different subsite's prefix (e.g. wp_4_abj404_* while current prefix is wp_).
540 * This is not an error — the other subsite's tables exist under its own prefix and
541 * will be serviced when that subsite's cron fires.
542 *
543 * @param string $errorText The MySQL error string.
544 * @return bool True if the error references a different multisite subsite's prefix.
545 */
546 private function isMultisiteCrossPrefixError(string $errorText): bool {
547 if ($errorText === '' || !function_exists('is_multisite') || !is_multisite()) {
548 return false;
549 }
550
551 global $wpdb;
552 // Extract table name from error. MySQL formats:
553 // Table 'dbname.tablename' doesn't exist
554 // Table `dbname`.`tablename` doesn't exist
555 if (!preg_match("/['\x60](?:[^'\x60]+\.)?([^'\x60]*abj404_[^'\x60]+)['\x60]/i", $errorText, $matches)) {
556 return false;
557 }
558 $referencedTable = strtolower($matches[1]);
559
560 $currentPrefix = strtolower($wpdb->prefix ?? 'wp_');
561 $basePrefix = strtolower($wpdb->base_prefix ?? 'wp_');
562
563 // If the table starts with the current prefix, it's genuinely missing for THIS site.
564 if (strpos($referencedTable, $currentPrefix . 'abj404_') === 0) {
565 return false;
566 }
567
568 // Check if it matches {base_prefix}{N}_abj404_ (a different subsite's table).
569 $pattern = '/^' . preg_quote($basePrefix, '/') . '(\d+)_abj404_/';
570 if (preg_match($pattern, $referencedTable)) {
571 return true;
572 }
573
574 return false;
575 }
576 }
577