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 / DataAccess.php

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

1,403 lines 63.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 require_once __DIR__ . '/DataAccessTrait_Maintenance.php';
9 require_once __DIR__ . '/DataAccessTrait_Connection.php';
10 require_once __DIR__ . '/DataAccessTrait_ViewMetadata.php';
11 require_once __DIR__ . '/DataAccessTrait_ViewQueries.php';
12 require_once __DIR__ . '/DataAccessTrait_ViewQueriesHitsLifecycle.php';
13 require_once __DIR__ . '/DataAccessTrait_ViewQueriesStaged.php';
14 require_once __DIR__ . '/DataAccessTrait_ViewBuildStageCallbacks.php';
15 require_once __DIR__ . '/DataAccessTrait_ViewQueriesStagedRead.php';
16 require_once __DIR__ . '/DataAccessTrait_ViewBuildAdaptive.php';
17 require_once __DIR__ . '/DataAccessTrait_ViewBuildHelpers.php';
18 require_once __DIR__ . '/DataAccessTrait_ViewBuildLockAndCron.php';
19 require_once __DIR__ . '/DataAccessTrait_ViewBuildPhpEnvProbe.php';
20 require_once __DIR__ . '/DataAccessTrait_ViewBuildSessionEnvProbe.php';
21 require_once __DIR__ . '/DataAccessTrait_ViewBuildHostFailurePolicy.php';
22 require_once __DIR__ . '/DataAccessTrait_ViewBuildForceRestart.php';
23 require_once __DIR__ . '/DataAccessTrait_MutationWatermarkSeam.php';
24 require_once __DIR__ . '/DataAccessTrait_AdminMutationGate.php';
25 require_once __DIR__ . '/DataAccessTrait_ViewSnapshotCache.php';
26 require_once __DIR__ . '/DataAccessTrait_QueryTimeouts.php';
27 require_once __DIR__ . '/DataAccessTrait_Logs.php';
28 require_once __DIR__ . '/DataAccessTrait_LogsHitsRebuild.php';
29 require_once __DIR__ . '/DataAccessTrait_Redirects.php';
30 require_once __DIR__ . '/DataAccessTrait_PublishedContent.php';
31 require_once __DIR__ . '/DataAccessTrait_Stats.php';
32 require_once __DIR__ . '/DataAccessTrait_ErrorClassification.php';
33 require_once __DIR__ . '/DataAccessTrait_SqlErrorReporting.php';
34 require_once __DIR__ . '/ViewQueryFailureException.php';
35 require_once __DIR__ . '/ViewBuildPendingException.php';
36
37 /* Functions in this class should all reference one of the following variables or support functions that do.
38 * $wpdb, $_GET, $_POST, $_SERVER, $_.*
39 * everything $wpdb related.
40 * everything $_GET, $_POST, (etc) related.
41 * Read the database, Store to the database,
42 */
43
44 class ABJ_404_Solution_DataAccess {
45
46 const UPDATE_LOGS_HITS_TABLE_HOOK = 'abj404_updateLogsHitsTableAction';
47
48 const KEY_REDIRECTS_FOR_VIEW_COUNT = 'abj404_redirects-for-view-count';
49
50 /** @var int Maximum age in seconds before hits table is considered stale */
51 const HITS_TABLE_MAX_AGE_SECONDS = 300; // 5 minutes
52 /** @var int Minimum interval between hits-table rebuild schedules (server-side dedupe). */
53 const HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS = 30;
54 /** @var int Short-lived cache for admin list snapshots (fast first paint). */
55 const VIEW_SNAPSHOT_CACHE_TTL_SECONDS = 120;
56 /** @var int Minimum interval between expensive refreshes for the same view key. */
57 const VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS = 30;
58 /** @var int DB timeout budget for each resumable table-cache warmup stage. */
59 const VIEW_SNAPSHOT_WARMUP_STAGE_TIMEOUT_SECONDS = 28;
60 /** @var int Age after which a running warmup stage is treated as killed/stalled. */
61 const VIEW_SNAPSHOT_WARMUP_STALE_SECONDS = 35;
62 /** @var int Max killed/timeout attempts for one warmup stage before blocking retries. */
63 const VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS = 3;
64 /** @var int Safety cap: avoid storing extremely large payloads in cache. */
65 const VIEW_SNAPSHOT_MAX_PAYLOAD_BYTES = 2097152; // 2 MiB
66 /** @var int Cross-request lock timeout for logs-hits rebuild jobs. */
67 const HITS_TABLE_REBUILD_LOCK_TTL_SECONDS = 180;
68 /** @var int Number of logsv2 IDs to process per chunk during pre-aggregation. */
69 const HITS_TABLE_PREAGG_CHUNK_SIZE = 100000;
70 /**
71 * @var int If MAX(id) - MIN(id) is at or below this threshold, the rebuild
72 * uses the single-statement direct path; above it, the chunked
73 * two-phase path. Threshold is intentionally far smaller than
74 * HITS_TABLE_PREAGG_CHUNK_SIZE: log retention by timestamp lets
75 * MIN(id) climb monotonically, so MAX-MIN converges to the live
76 * row count, and the direct path's CONCAT/COALESCE-derived JOIN
77 * times out at 60s on shared hosts at row counts well below
78 * HITS_TABLE_PREAGG_CHUNK_SIZE. Only truly tiny tables benefit
79 * from skipping the pre-agg overhead.
80 */
81 const HITS_TABLE_DIRECT_PATH_THRESHOLD = 5000;
82 /** @var int Max age for cached stats-periodic aggregates. */
83 const PERIODIC_STATS_CACHE_TTL_SECONDS = 300;
84 /** @var int Minimum interval before recalculating expensive stats aggregates. */
85 const PERIODIC_STATS_REFRESH_COOLDOWN_SECONDS = 30;
86 /** @var int Max age for cached daily-activity trend data (Stats tab Chart.js). */
87 const TREND_DATA_CACHE_TTL_SECONDS = 900;
88 /**
89 * @var int Short TTL for the cached `getLogsCount(0)` total row count.
90 * Audit F4: InnoDB has no maintained row counter, so the
91 * Logs admin tab's `SELECT COUNT(id) FROM logsv2` is a full
92 * index scan. New inserts move the cache key (`max_log_id`)
93 * so fresh data is picked up immediately; bulk deletes do
94 * not move the key, so the TTL bounds staleness at 60 s.
95 */
96 const LOGS_COUNT_CACHE_TTL_SECONDS = 60;
97 /** @var int Retention for dashboard stats snapshot payload (stale snapshot is acceptable for fast first paint). */
98 const STATS_DASHBOARD_CACHE_TTL_SECONDS = 86400;
99 /** @var int Minimum time between full stats snapshot recomputes. */
100 const STATS_DASHBOARD_REFRESH_COOLDOWN_SECONDS = 30;
101 /** @var int Cooldown when DB query quota is exceeded. */
102 const DB_QUOTA_COOLDOWN_SECONDS = 900;
103 /** @var int Cooldown when DB is read-only or storage is full. */
104 const DB_WRITE_BLOCK_COOLDOWN_SECONDS = 900;
105
106 /** @var string Runtime flag: last time we checked whether logs-hits needs rebuild (Unix timestamp). */
107 const HITS_TABLE_LAST_CHECKED_FLAG = 'abj404_logs_hits_last_checked_at';
108 /** @var string Runtime flag: last time we scheduled a rebuild (Unix timestamp). */
109 const HITS_TABLE_LAST_SCHEDULED_FLAG = 'abj404_logs_hits_last_scheduled_at';
110 /** @var string Runtime flag: last schedule decision ('scheduled','running','cooldown','paused','not_needed'). */
111 const HITS_TABLE_LAST_DECISION_FLAG = 'abj404_logs_hits_last_decision';
112 /** @var string Runtime flag: last successful hits-table rebuild completion (Unix timestamp). */
113 const HITS_TABLE_LAST_REFRESHED_FLAG = 'abj404_logs_hits_last_refreshed_at';
114 /**
115 * @var string Runtime flag: Unix timestamp of the first request that
116 * observed MAX(logsv2.id) > stored rollup watermark and the
117 * gap has remained open since. Drives the broken-cron
118 * admin notice; cleared on rebuild or when the gap closes.
119 */
120 const HITS_TABLE_FIRST_STALE_DETECTED_FLAG = 'abj404_logs_hits_first_stale_detected_at';
121 /** @var string Deduplicated admin-notice transient for stale logs_hits rollup. */
122 const HITS_TABLE_STALE_NOTICE_TRANSIENT = 'abj404_logs_hits_rollup_stale';
123 /**
124 * @var int Minimum age (seconds) of a persisted MAX(logsv2.id) >
125 * rollup-watermark gap before surfacing a broken-cron admin
126 * notice. 1 hour is well past the normal cron cycle for the
127 * 5-minute HITS_TABLE_MAX_AGE_SECONDS rollup, so a gap that
128 * stays open this long is unambiguously a broken or
129 * stopped cron event (abj404_updateLogsHitsTableAction).
130 */
131 const HITS_TABLE_STALE_NOTICE_THRESHOLD_SECONDS = 3600;
132
133 /** @var self|null */
134 private static $instance = null;
135
136 /** @var bool Whether the hits table rebuild has been scheduled for this request */
137 private static $hitsTableRebuildScheduled = false;
138 /** @var bool Prevent recursive auto-repair attempts on SQL errors. */
139 private static $tableRepairInProgress = false;
140 /** @var bool Prevent recursive invalid-data retry attempts. */
141 private static $invalidDataRetryInProgress = false;
142 /** @var bool Prevent recursive collation auto-recovery. correctCollations()
143 * emits ALTER TABLE statements that re-enter queryAndGetResults(); without
144 * this guard a collation error inside correctCollations() would deadlock on
145 * the cooldown transient and recurse indefinitely. */
146 private static $collationRecoveryInProgress = false;
147 /** @var bool Per-request cache: this server rejected the
148 * `SET STATEMENT max_statement_time=N FOR ...` timeout wrapper, so
149 * applyQueryTimeout() must skip wrapping for the rest of the request.
150 * Reset between requests because server config can change (privilege
151 * grants, proxy upgrades). See classifySetStatementFailure() and
152 * retryWithoutSetStatementWrapper(). */
153 private static $setStatementWrapperUnsupported = false;
154 /** @var string Current wpdb result type for queryAndGetResults (ARRAY_A or OBJECT). */
155 private $currentResultType = ARRAY_A;
156 /** @var bool Ensure view cache table DDL runs at most once per request. */
157 private static $viewSnapshotTableEnsured = false;
158 /** @param bool $value @return void */
159 public static function setViewSnapshotTableEnsured(bool $value): void {
160 self::$viewSnapshotTableEnsured = $value;
161 }
162
163 /**
164 * Reset the per-request "SET STATEMENT wrapper unsupported" cache.
165 * Public because the flag is request-scoped: callers that span requests
166 * (long-lived CLI workers, ParaTest workers reusing the process) need a
167 * way to clear the cache between request-equivalents. Tests use this to
168 * isolate the negative cache from other test methods.
169 *
170 * @param bool $value
171 * @return void
172 */
173 public static function setSetStatementWrapperUnsupported(bool $value): void {
174 self::$setStatementWrapperUnsupported = $value;
175 }
176
177 /**
178 * Read the per-request "SET STATEMENT wrapper unsupported" cache.
179 * Used by callers (and tests) that need to confirm whether a previous
180 * query in this request hit the wrapper-rejection path.
181 *
182 * @return bool
183 */
184 public static function isSetStatementWrapperUnsupported(): bool {
185 return self::$setStatementWrapperUnsupported;
186 }
187
188 /** @var ABJ_404_Solution_Functions */
189 private $f;
190
191 /** @var ABJ_404_Solution_Logging */
192 private $logger;
193
194 /** @var ABJ_404_Solution_Clock|null Lazy-resolved by clock(); kept null to preserve constructor signature. */
195 private $clock = null;
196 /** @var bool Whether a server-side DB issue was noted this request (for auto-clear). */
197 private $serverSideIssueNoted = false;
198 /** @var bool Whether we already checked for a stale notice transient this request. */
199 private $serverSideIssueChecked = false;
200 /** @var array<string,int> Request-local cached counts for redirects list views. */
201 private $redirectsForViewCountRequestCache = array();
202
203 use ABJ_404_Solution_DataAccess_MaintenanceTrait;
204 use ABJ_404_Solution_DataAccess_ConnectionTrait;
205 use ABJ_404_Solution_DataAccess_ViewMetadataTrait;
206 use ABJ_404_Solution_DataAccess_ViewQueriesTrait;
207 use ABJ_404_Solution_DataAccess_ViewQueriesHitsLifecycleTrait;
208 use ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait;
209 use ABJ_404_Solution_DataAccess_ViewBuildStageRunnerTrait;
210 use ABJ_404_Solution_DataAccess_ViewBuildStageCallbacksTrait;
211 use ABJ_404_Solution_DataAccess_ViewQueriesStagedReadTrait;
212 use ABJ_404_Solution_DataAccess_ViewBuildAdaptiveTrait;
213 use ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait;
214 use ABJ_404_Solution_DataAccess_ViewBuildStartedWatermarkTrait;
215 use ABJ_404_Solution_DataAccess_ViewBuildLockAndCronTrait;
216 use ABJ_404_Solution_DataAccess_ViewBuildPhpEnvProbeTrait;
217 use ABJ_404_Solution_DataAccess_ViewBuildSessionEnvProbeTrait;
218 use ABJ_404_Solution_DataAccess_ViewBuildHostFailurePolicyTrait;
219 use ABJ_404_Solution_DataAccess_ViewBuildForceRestartTrait;
220 use ABJ_404_Solution_DataAccess_MutationWatermarkSeamTrait;
221 use ABJ_404_Solution_DataAccess_AdminMutationGateTrait;
222 use ABJ_404_Solution_DataAccess_ViewSnapshotCacheTrait;
223 use ABJ_404_Solution_DataAccess_LogsTrait;
224 use ABJ_404_Solution_DataAccess_LogsHitsRebuildTrait;
225 use ABJ_404_Solution_DataAccess_RedirectsTrait;
226 use ABJ_404_Solution_DataAccess_PublishedContentTrait;
227 use ABJ_404_Solution_DataAccess_StatsTrait;
228 use ABJ_404_Solution_DataAccess_ErrorClassificationTrait;
229 use ABJ_404_Solution_DataAccess_SqlErrorReportingTrait;
230 use ABJ_404_Solution_DataAccess_QueryTimeoutsTrait;
231
232 /** Cache key for redirect status counts */
233 const CACHE_KEY_REDIRECT_STATUS = 'abj404_redirect_status_counts';
234
235 /** Cache key for captured status counts */
236 const CACHE_KEY_CAPTURED_STATUS = 'abj404_captured_status_counts';
237
238 /** Cache key for high-impact captured URL count (3+ hits) */
239 const CACHE_KEY_HIGH_IMPACT_CAPTURED = 'abj404_high_impact_captured';
240
241 /** Cache TTL in seconds (24 hours - safety net, primary refresh is event-driven invalidation) */
242 const STATUS_CACHE_TTL = 86400;
243
244 /**
245 * Short-TTL window used after a query timeout to break the
246 * "page reloads, page re-times-out" loop on slow hosts. 5 minutes is
247 * long enough that an admin browsing session does not re-pay the
248 * timeout cost, and short enough that once the scheduled hits-table
249 * rebuild completes, the next request after the window picks up the
250 * rebuilt rollup. See getHighImpactCapturedCount() self-heal branch.
251 */
252 const STATUS_CACHE_TIMEOUT_SELFHEAL_TTL = 300;
253
254 /** Maximum number of regex redirects to cache per-request (memory guard) */
255 const REGEX_CACHE_MAX_COUNT = 50;
256
257 /** @var array<int, array<string, mixed>>|null Per-request cache for regex redirects (static to persist across getInstance calls) */
258 private static $regexRedirectsCache = null;
259
260 /** @var bool Flag indicating if regex cache should be skipped (too many redirects) */
261 private static $regexCacheDisabled = false;
262
263 /** @var array<int, array<string, mixed>> Queue of log entries to be flushed at shutdown */
264 private static $logQueue = [];
265
266 /** @var bool Whether shutdown hook has been registered */
267 private static $shutdownHookRegistered = false;
268
269 /** @var bool Prevent re-entrancy during flush */
270 private static $isFlushingLogQueue = false;
271
272
273
274 /**
275 * Constructor with dependency injection.
276 * Dependencies are now explicit and visible.
277 *
278 * @param ABJ_404_Solution_Functions|null $functions String manipulation utilities
279 * @param ABJ_404_Solution_Logging|null $logging Logging service
280 */
281 public function __construct($functions = null, $logging = null) {
282 // Use injected dependencies or fall back to getInstance() for backward compatibility
283 $this->f = $functions !== null ? $functions : abj_service('functions');
284 $this->logger = $logging !== null ? $logging : abj_service('logging');
285 }
286
287 /**
288 * Inject a specific clock instance. Tests bind a `FrozenClock` so
289 * cooldown / rate-limit windows can be advanced deterministically.
290 * @param ABJ_404_Solution_Clock $clock @return void
291 */
292 public function setClock(ABJ_404_Solution_Clock $clock): void {
293 $this->clock = $clock;
294 }
295
296 /**
297 * Resolve the clock used for time-based operations: injected setter
298 * wins, then container `'clock'` service, then a fresh `SystemClock`
299 * (CLI / fixtures that bypass `bootstrap.php`).
300 * @return ABJ_404_Solution_Clock
301 */
302 protected function clock(): ABJ_404_Solution_Clock {
303 if ($this->clock !== null) { return $this->clock; }
304 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
305 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('clock');
306 if ($resolved instanceof ABJ_404_Solution_Clock) {
307 $this->clock = $resolved;
308 return $this->clock;
309 }
310 }
311 $this->clock = new ABJ_404_Solution_SystemClock();
312 return $this->clock;
313 }
314
315 /** @return self */
316 public static function getInstance() {
317 if (self::$instance !== null) {
318 return self::$instance;
319 }
320
321 // If the DI container is initialized, prefer it.
322 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
323 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('data_access');
324 if ($resolved instanceof self) {
325 self::$instance = $resolved;
326 return self::$instance;
327 }
328 }
329
330 // For backward compatibility, create with no arguments
331 // The constructor will use getInstance() for dependencies
332 self::$instance = new ABJ_404_Solution_DataAccess();
333
334 return self::$instance;
335 }
336
337 /**
338 * Check if a database table exists.
339 *
340 * Fix for missing table error (reported by 2 users - 4% of errors)
341 * This prevents crashes when querying tables that don't exist or have
342 * incorrect table prefixes, returning false instead of causing fatal errors.
343 *
344 * @param string $tableName Full table name to check (including prefix)
345 * @return bool True if table exists, false otherwise
346 */
347 private function tableExists($tableName) {
348 global $wpdb;
349
350 if (!isset($wpdb)) {
351 return false;
352 }
353
354 // @utf8-audit: opt-out — $tableName is always a system value (built
355 // from $wpdb->prefix or doTableNameReplacements); never user input.
356 // Use SHOW TABLES to check existence (esc_sql avoids prepare() variadic
357 // arg issues with some test mocks while remaining injection-safe for a table name)
358 $table = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'");
359
360 return ($table == $tableName);
361 }
362
363 /**
364 * Get the column names of an actual database table via SHOW COLUMNS.
365 * Returns empty array on failure (table missing, permissions, etc.)
366 * so callers can fall back to their default behavior.
367 *
368 * @param string $tableName Full table name (including prefix)
369 * @return array<int, string>
370 */
371 private function getTableColumnNames(string $tableName): array {
372 global $wpdb;
373 if (!isset($wpdb)) { return []; }
374 // @utf8-audit: opt-out — $tableName is always a system value (built
375 // from $wpdb->prefix or doTableNameReplacements); never user input.
376 $rows = $wpdb->get_results("SHOW COLUMNS FROM `" . esc_sql($tableName) . "`", ARRAY_A);
377 if (!is_array($rows) || !empty($wpdb->last_error)) { return []; }
378 $columns = [];
379 foreach ($rows as $row) {
380 if (isset($row['Field'])) { $columns[] = $row['Field']; }
381 }
382 return $columns;
383 }
384
385 /** @return array{version: string, last_updated: string|null} */
386 function getLatestPluginVersion() {
387 // Cache version info to avoid repeated slow wordpress.org API calls.
388 $cacheKey = 'abj404_latest_plugin_version_info';
389 if (function_exists('get_transient')) {
390 $cached = get_transient($cacheKey);
391 if (is_array($cached) && isset($cached['version'])) {
392 /** @var array{version: string, last_updated: string|null} $cached */
393 return $cached;
394 }
395 }
396
397 if (!function_exists('plugins_api')) {
398 require_once(ABSPATH . 'wp-admin/includes/plugin-install.php');
399 }
400 if (!function_exists('plugins_api')) {
401 $this->logger->infoMessage("I couldn't find the plugins_api function to check for the latest version.");
402 $fallback = array('version' => ABJ404_VERSION, 'last_updated' => null);
403 return $fallback;
404 }
405
406 $pluginSlug = dirname(ABJ404_NAME);
407
408 // set the arguments to get latest info from repository via API ##
409 $args = array(
410 'slug' => $pluginSlug,
411 'fields' => array(
412 'version' => true,
413 'last_updated' => true,
414 )
415 );
416
417 /** Prepare our query */
418 $call_api = plugins_api('plugin_information', $args);
419
420 /** Check for Errors & Display the results */
421 if (is_wp_error($call_api)) {
422 $api_error = $call_api->get_error_message();
423 $this->logger->infoMessage("There was an API issue checking the latest plugin version ("
424 . $api_error . ")");
425
426 $fallback = array('version' => ABJ404_VERSION, 'last_updated' => null);
427 return $fallback;
428 }
429
430 /** @var object $call_api */
431 $apiVersion = property_exists($call_api, 'version') ? (string)$call_api->version : ABJ404_VERSION;
432 $apiLastUpdated = property_exists($call_api, 'last_updated') ? (string)$call_api->last_updated : null;
433 $result = array('version' => $apiVersion, 'last_updated' => $apiLastUpdated);
434 if (function_exists('set_transient')) {
435 $ttl = defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400;
436 // allow-cache-empty: $result always carries a version string (fallback to ABJ404_VERSION when plugins_api omits it); is_wp_error early-returns above
437 set_transient($cacheKey, $result, $ttl);
438 }
439 return $result;
440 }
441
442 /** Check wordpress.org for the latest version of this plugin. Return true if the latest version is installed,
443 * false otherwise.
444 * @return boolean
445 */
446 function shouldEmailErrorFile() {
447 $abj404logging = abj_service('logging');
448
449 $pluginInfo = $this->getLatestPluginVersion();
450
451 $latestVersion = $pluginInfo['version'];
452 $currentVersion = ABJ404_VERSION;
453 if ($latestVersion == $currentVersion) {
454 return true;
455 }
456
457 if (version_compare(ABJ404_VERSION, $latestVersion) == 1) {
458 $this->logger->infoMessage("Development version: A more recent version is installed than " .
459 "what is available on the WordPress site (" . ABJ404_VERSION . " / " .
460 $latestVersion . ").");
461 return true;
462 }
463
464 $currentArray = explode(".", $currentVersion);
465 $latestArray = explode(".", $latestVersion);
466
467 // verify that the version numbers were parsed correctly.
468 if (count($currentArray) != 3 || count($latestArray) != 3) {
469 $this->logger->errorMessage("Issue parsing version numbers. " .
470 $currentVersion . ' / ' . $latestVersion);
471
472 } else if ($currentArray[0] == $latestArray[0] && $currentArray[1] == $latestArray[1]) {
473 // get the difference in the version numbers.
474 $difference = absint(absint($latestArray[2]) - absint($currentArray[2]));
475
476 // if the major versions mostly match then send the error file.
477 if ($difference <= 1) {
478 return true;
479 }
480 }
481
482 return (ABJ404_VERSION == $pluginInfo['version']);
483 }
484
485 /**
486 * @return array<string, mixed>
487 */
488 function importDataFromPluginRedirectioner() {
489 global $wpdb;
490
491 $oldTable = $wpdb->prefix . 'wbz404_redirects';
492 $newTable = $this->doTableNameReplacements('{wp_abj404_redirects}');
493 // wp_wbz404_redirects -- old table
494 // wp_abj404_redirects -- new table
495
496 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/importDataFromPluginRedirectioner.sql");
497 $query = $this->f->str_replace('{OLD_TABLE}', $oldTable, $query);
498 $query = $this->f->str_replace('{NEW_TABLE}', $newTable, $query);
499
500 $result = $this->queryAndGetResults($query);
501
502 $this->logger->infoMessage("Importing redirectioner SQL result: " .
503 wp_kses_post((string)json_encode($result)));
504
505 return $result;
506 }
507
508 /**
509 * @param string $query
510 * @return string
511 */
512 function doTableNameReplacements($query) {
513 global $wpdb;
514
515 $replacements = array();
516 $tables = (isset($wpdb->tables) && is_array($wpdb->tables)) ? $wpdb->tables : array();
517 // Resolve prefix once; null $wpdb (boot-time and unit-test contexts) and
518 // mocks without ->prefix both fall through to 'wp_' instead of triggering
519 // PHP 8+ "Attempt to read property on null" warnings. Infection's
520 // initial-tests phase exits non-zero on any such warning.
521 $prefix = isset($wpdb->prefix) ? $wpdb->prefix : 'wp_';
522 foreach ($tables as $tableName) {
523 $replacements['{wp_' . $tableName . '}'] = $prefix . $tableName;
524 }
525 // wpdb properties are not guaranteed on mocks; provide safe fallbacks.
526 $replacements['{wp_users}'] = isset($wpdb->users) ? $wpdb->users : ($prefix . 'users');
527 $replacements['{wp_prefix}'] = $prefix;
528 $replacements['{wp_prefix_lower}'] = $this->getLowercasePrefix();
529
530 // Resolve {wpdb_collate} so any SQL file can force a consistent collation
531 // on cross-table string expressions (prevents "Illegal mix of collations").
532 $wpdbCollate = 'utf8mb4_unicode_ci';
533 if (isset($wpdb->collate) && !empty($wpdb->collate)) {
534 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $wpdb->collate);
535 if ($sanitized !== '' && $sanitized !== null) {
536 $wpdbCollate = $sanitized;
537 }
538 }
539 $replacements['{wpdb_collate}'] = $wpdbCollate;
540
541 // wp database table replacements
542 $query = $this->f->str_replace(array_keys($replacements), array_values($replacements), $query);
543
544 // custom table replacements.
545 // for some strings (/404solution-site/%BA%D0%25/) the mb_ereg_replace doesn't work.
546 $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance();
547 $query = $fpreg->regexReplace('[{]wp_abj404_(.*?)[}]',
548 $this->getLowercasePrefix() . "abj404_\\1", $query);
549
550 return $query !== null ? $query : '';
551 }
552
553 /**
554 * Get the normalized (lowercase) prefix used for all plugin tables.
555 * This avoids case-sensitive MySQL filesystems from treating mixed-case
556 * prefixes as distinct tables.
557 *
558 * @return string
559 */
560 public function getLowercasePrefix() {
561 global $wpdb;
562 return $this->f->strtolower($wpdb->prefix ?? 'wp_');
563 }
564
565 /**
566 * Build a fully-qualified plugin table name using the normalized prefix.
567 *
568 * @param string $tableSuffix Table name without the WordPress prefix.
569 * @return string
570 */
571 public function getPrefixedTableName($tableSuffix) {
572 return $this->getLowercasePrefix() . ltrim($tableSuffix, '_');
573 }
574
575 /** Returns the create table statement.
576 * @param string $tableName
577 * @return string
578 */
579 function getCreateTableDDL($tableName) {
580 $query = "show create table " . $tableName;
581 $result = $this->queryAndGetResults($query, array('log_errors' => false, 'skip_repair' => true));
582 $rows = $result['rows'];
583
584 // Handle case where query returns no results (e.g., in test environment)
585 if (!is_array($rows) || empty($rows) || !isset($rows[0]) || !is_array($rows[0])) {
586 return '';
587 }
588
589 $row1 = array_values($rows[0]);
590 $existingTableSQL = $row1[1];
591
592 return $existingTableSQL;
593 }
594
595 /**
596 * Resolve a stable source identifier for safe logging.
597 *
598 * Resolution order (most-specific wins):
599 * 1. Explicit `/* abj404:src=ID *​/` marker prepended to inline SQL.
600 * Use this when the call site is non-obvious (helper wrappers,
601 * dynamically-built DDL) and you want a stable label that survives
602 * refactors.
603 * 2. Loaded `.sql` filename — detected via the `/* -- file.sql BEGIN -- *​/`
604 * wrapper that getDataSupplement() prepends in Functions.php.
605 * 3. Backtrace fallback — the closest non-DAO frame, formatted as
606 * `File::method` (or `File:line` if no enclosing method). Ensures
607 * every queryAndGetResults() call is traceable to its source even
608 * without an explicit marker.
609 *
610 * Apr/May 2026 error reports (38 of 43 emails) labeled SQL errors
611 * "SQL: inline-query", hiding which call site failed. After this change
612 * the literal sentinel "inline-query" is no longer returned — the
613 * backtrace fallback always supplies a meaningful identifier.
614 *
615 * @param string $query The SQL query (may contain marker or file wrapper)
616 * @return string Source identifier; never the literal "inline-query".
617 */
618 private function extractSqlFilename($query) {
619 if (is_string($query) && $query !== '') {
620 // 1. Explicit marker — accept identifier characters and a few
621 // common separators (::, #, ., -) so call sites can pass
622 // "Class::method", "Class::method#hint", or "module.action".
623 if (preg_match('/\/\*\s*abj404:src=([A-Za-z0-9_:#.\\\\\-]+)\s*\*\//i', $query, $m)) {
624 return $m[1];
625 }
626 // 2. Filename from BEGIN/END wrapper.
627 if (preg_match('/\/\*\s*-+\s*(.+?\.sql)\s+BEGIN\s*-+\s*\*\//i', $query, $m)) {
628 return basename($m[1]);
629 }
630 }
631 // 3. Backtrace fallback — find the closest caller outside DataAccess.php.
632 return $this->resolveCallerFromBacktrace();
633 }
634
635 /**
636 * Walk debug_backtrace() to find the nearest meaningful caller.
637 *
638 * The "meaningful" frame is the one whose body contains the original
639 * call to queryAndGetResults() (or to extractSqlFilename in tests) —
640 * i.e. the SQL-building call site we want to see in error reports.
641 *
642 * Skip rules:
643 * - Frames whose function is an internal DAO helper (extractSqlFilename,
644 * resolveCallerFromBacktrace, queryAndGetResults, retry/recovery
645 * wrappers). These are infrastructure, not the SQL source.
646 * - Frames whose function name is a `{closure...}` synthetic, which
647 * obscures the calling test/helper method.
648 *
649 * Trait methods on the DAO appear with class=ABJ_404_Solution_DataAccess
650 * (because traits are mixed into the using class), but the frame's `file`
651 * still points at the trait file. We surface the trait file basename in
652 * that case so error reports identify which trait the inline SQL came
653 * from instead of the generic `DataAccess`.
654 *
655 * @return string `Class::method`, `File::method`, or `unknown-source`.
656 */
657 private function resolveCallerFromBacktrace() {
658 static $internalMethods = array(
659 'extractSqlFilename' => true,
660 'resolveCallerFromBacktrace' => true,
661 'queryAndGetResults' => true,
662 'attemptInvalidDataRetry' => true,
663 'attemptMissingTableRepairAndRetry' => true,
664 'repairCorruptedTableAndRetry' => true,
665 'recoverFromCollationMismatchAndRetry' => true,
666 'call_user_func_array' => true,
667 'call_user_func' => true,
668 );
669 // 40 frames is comfortably deeper than any DAO call we see in
670 // practice (typical depth is 4–8); cheap enough on the rare error
671 // path and bounded enough for the budget hook's fast path.
672 $frames = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 40);
673 foreach ($frames as $frame) {
674 $fn = $frame['function'];
675 if ($fn === '' || isset($internalMethods[$fn])) {
676 continue;
677 }
678 // Closures bound to the DAO (and anonymous helper closures in
679 // test code) carry synthetic names like `{closure:/path:line}`.
680 // They obscure the meaningful calling method, so step past them.
681 if (strpos($fn, '{closure') !== false) {
682 continue;
683 }
684 $cls = isset($frame['class']) && is_string($frame['class']) ? $frame['class'] : '';
685 // Patchwork (Brain\Monkey's code instrumentation, used in tests)
686 // wraps userland calls in CallRerouting frames; PHPUnit wraps
687 // tests in TestCase invocation frames. Skip both so the
688 // resolved source reflects the actual SQL caller, not test
689 // harness plumbing. Production loads neither.
690 $fullFile = isset($frame['file']) && is_string($frame['file']) ? $frame['file'] : '';
691 if ($cls !== '' && (
692 strpos($cls, 'Patchwork') !== false ||
693 strpos($cls, 'PHPUnit\\') === 0
694 )) {
695 continue;
696 }
697 if (strpos($fn, 'Patchwork\\') !== false) {
698 continue;
699 }
700 if ($fullFile !== '' && (
701 strpos($fullFile, '/patchwork/') !== false ||
702 strpos($fullFile, '\\patchwork\\') !== false
703 )) {
704 continue;
705 }
706 $file = $fullFile !== '' ? basename($fullFile) : '';
707 $fileLabel = preg_replace('/\.php$/i', '', $file);
708 if (!is_string($fileLabel)) {
709 $fileLabel = $file;
710 }
711 // Trait methods report the using-class (DataAccess). The trait
712 // file basename is what tells us *which* trait, so prefer it.
713 if ($cls === 'ABJ_404_Solution_DataAccess'
714 && $fileLabel !== '' && $fileLabel !== 'DataAccess') {
715 return $fileLabel . '::' . $fn;
716 }
717 if ($cls !== '') {
718 $shortClass = $cls;
719 $nsPos = strrpos($shortClass, '\\');
720 if ($nsPos !== false) {
721 $shortClass = substr($shortClass, $nsPos + 1);
722 }
723 if (strpos($shortClass, 'ABJ_404_Solution_') === 0) {
724 $shortClass = substr($shortClass, strlen('ABJ_404_Solution_'));
725 }
726 return $shortClass . '::' . $fn;
727 }
728 if ($fileLabel !== '') {
729 return $fileLabel . '::' . $fn;
730 }
731 return $fn;
732 }
733 return 'unknown-source';
734 }
735
736 /**
737 * Sanitize SQL identifier-like collation names.
738 *
739 * @param string $collation
740 * @return string
741 */
742 private function sanitizeCollationIdentifier($collation) {
743 if (!is_string($collation) || $collation === '') {
744 return '';
745 }
746 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $collation);
747 return $sanitized !== null ? $sanitized : '';
748 }
749
750 /**
751 * Resolve an appropriate utf8mb4 collation for CAST/COLLATE comparisons.
752 *
753 * Prefer wpdb connection collation when it's utf8mb4, otherwise fall back
754 * to a safe default.
755 *
756 * @return string
757 */
758 private function getPreferredUtf8mb4Collation() {
759 global $wpdb;
760
761 if (isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) {
762 $wpdbCollation = $this->sanitizeCollationIdentifier((string)$wpdb->collate);
763 if ($wpdbCollation !== '' && stripos($wpdbCollation, 'utf8mb4') !== false) {
764 return $wpdbCollation;
765 }
766 }
767 return 'utf8mb4_unicode_ci';
768 }
769
770 /**
771 * Attempt one retry for invalid-data errors using wpdb's stripped query helper.
772 *
773 * @param string $query
774 * @param array<string, mixed> $result
775 * @return void
776 */
777 private function attemptInvalidDataRetry($query, &$result) {
778 if (self::$invalidDataRetryInProgress) {
779 return;
780 }
781
782 self::$invalidDataRetryInProgress = true;
783 try {
784 $retryQuery = $this->get_stripped_query_result($query);
785 if (!is_string($retryQuery) || trim($retryQuery) === '' || $retryQuery === $query) {
786 return;
787 }
788
789 global $wpdb;
790 $wpdb->flush();
791 $result['rows'] = $wpdb->get_results($retryQuery, $this->currentResultType);
792 $this->harvestWpdbResult($result);
793 } catch (Throwable $e) {
794 $this->logger->warn("Invalid-data retry failed: " . $e->getMessage());
795 } finally {
796 self::$invalidDataRetryInProgress = false;
797 }
798 }
799
800 /** @return void */
801 private function applyDiagnosticLatencyIfConfigured(): void {
802 if (!function_exists('abj404_get_simulated_db_latency_ms')) {
803 return;
804 }
805 $delayMs = absint(abj404_get_simulated_db_latency_ms());
806 if ($delayMs <= 0) {
807 return;
808 }
809 $delayMs = min(5000, $delayMs);
810 usleep($delayMs * 1000);
811 }
812
813 /**
814 * Harvest standard result fields from $wpdb after a query.
815 *
816 * @param array<string, mixed> $result The result array to populate.
817 * @return void
818 */
819 private function harvestWpdbResult(array &$result): void {
820 global $wpdb;
821 $result['last_error'] = (string)($wpdb->last_error ?? '');
822 $result['last_result'] = $wpdb->last_result ?? array();
823 $result['rows_affected'] = $wpdb->rows_affected ?? 0;
824 $result['insert_id'] = $wpdb->insert_id ?? 0;
825 }
826
827 /**
828 * Build a SQL-safe comma-separated list from recognized_post_types option.
829 *
830 * @param array<string, mixed> $options Plugin options array.
831 * @return string e.g. "'post', 'page'" or '' if empty.
832 */
833 function buildPostTypeSqlList(array $options): string {
834 $rptVal = $options['recognized_post_types'] ?? '';
835 $postTypes = $this->f->explodeNewline(is_string($rptVal) ? $rptVal : '');
836 $recognizedPostTypes = '';
837 foreach ($postTypes as $postType) {
838 $recognizedPostTypes .= "'" . trim($this->f->strtolower($postType)) . "', ";
839 }
840 return rtrim($recognizedPostTypes, ", ");
841 }
842
843 /**
844 * Build a SQL-safe comma-separated list from recognized_categories option.
845 *
846 * @param array<string, mixed> $options Plugin options array.
847 * @return string e.g. "'category', 'post_tag'" or '' if empty.
848 */
849 function buildCategorySqlList(array $options): string {
850 $rcVal = $options['recognized_categories'] ?? '';
851 $categories = $this->f->explodeNewline(is_string($rcVal) ? $rcVal : '');
852 $recognizedCategories = '';
853 foreach ($categories as $category) {
854 $recognizedCategories .= "'" . trim($this->f->strtolower($category)) . "', ";
855 }
856 return rtrim($recognizedCategories, ", ");
857 }
858
859 /**
860 * Set SQL session variables to allow large queries.
861 *
862 * Sets max_join_size and sql_big_selects for the current session only.
863 * Prevents "The SELECT would examine more than MAX_JOIN_SIZE rows" errors.
864 *
865 * @return void
866 */
867 function setSqlBigSelects(): void {
868 $ignoreErrorsOptions = array('log_errors' => false);
869 $this->queryAndGetResults("set session max_join_size = 18446744073709551615",
870 $ignoreErrorsOptions);
871 $this->queryAndGetResults("set session sql_big_selects = 1", $ignoreErrorsOptions);
872 }
873
874 /**
875 * Convenience: run a query that returns a single scalar value and return it
876 * as an int. The query must SELECT exactly one column from the first row;
877 * the column name is irrelevant — the first value of $rows[0] is taken.
878 *
879 * Returns 0 when the query fails, returns no rows, or the value is not
880 * scalar. Tightly typed so callers don't have to repeat the
881 * `is_array($result['rows']) && is_array($result['rows'][0]) && …`
882 * narrowing boilerplate at every COUNT(*) call site.
883 *
884 * @param string $query Any SELECT … that produces exactly one column.
885 * @param array<string, mixed> $options Options to forward to queryAndGetResults().
886 * @return int
887 */
888 public function queryScalarInt($query, $options = array()) {
889 $result = $this->queryAndGetResults($query, $options);
890 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
891 if (empty($rows) || !is_array($rows[0])) {
892 return 0;
893 }
894 $first = reset($rows[0]);
895 return is_scalar($first) ? (int)$first : 0;
896 }
897
898 /** Return the results of the query in a variable.
899 * @param string $query
900 * @param array<string, mixed> $options
901 * @return array<string, mixed>
902 */
903 function queryAndGetResults($query, $options = array()) {
904 global $wpdb;
905
906 // Ensure database connection is active (prevents "MySQL server has gone away" errors)
907 $this->ensureConnection();
908
909 $ignoreErrorStrings = array();
910
911 $options = array_merge(array('log_errors' => true,
912 'log_too_slow' => true, 'ignore_errors' => array(),
913 'query_params' => array(), 'skip_repair' => false,
914 'result_type' => ARRAY_A, 'timeout' => 0),
915 $options);
916 $resultType = $options['result_type'] === OBJECT ? OBJECT : ARRAY_A;
917 $this->currentResultType = $resultType;
918
919 $ignoreErrorStrings = is_array($options['ignore_errors']) ? $options['ignore_errors'] : array();
920 $queryParameters = is_array($options['query_params']) ? $options['query_params'] : array();
921
922 $query = $this->doTableNameReplacements($query);
923
924 if (!empty($queryParameters)) {
925 // WPDB::prepare array support varies across versions/mocks.
926 // Prefer varargs, but fall back to array-as-single-arg for older/custom mocks.
927 /** @var literal-string $queryLiteral */
928 $queryLiteral = $query;
929 try {
930 /** @var wpdb $wpdb */
931 $preparedResult = call_user_func_array(array($wpdb, 'prepare'), array_merge(array($queryLiteral), $queryParameters));
932 $query = is_string($preparedResult) ? $preparedResult : $queryLiteral;
933 } catch (Throwable $t) {
934 $preparedFallback = $wpdb->prepare($queryLiteral, $queryParameters);
935 $query = $preparedFallback !== null ? $preparedFallback : $queryLiteral;
936 }
937 }
938
939 // Apply a DB-level timeout to every query.
940 // Default timeout (60s) prevents any single query from blocking indefinitely.
941 $timeoutRaw = isset($options['timeout']) && is_numeric($options['timeout']) ? (int)$options['timeout'] : 0;
942 $timeoutSeconds = $timeoutRaw > 0 ? $timeoutRaw : 60;
943 $query = $this->applyQueryTimeout($query, $timeoutSeconds);
944
945 $this->applyDiagnosticLatencyIfConfigured();
946
947 $timer = new ABJ_404_Solution_Timer();
948
949 // When log_errors is false, also suppress $wpdb's own error output
950 // (prevents best-effort queries from leaking to debug.log when WP_DEBUG is on).
951 $suppressWpdbErrors = !$options['log_errors'] && method_exists($wpdb, 'suppress_errors');
952 $previousSuppressState = false;
953 if ($suppressWpdbErrors) {
954 /** @var wpdb $wpdb */
955 $previousSuppressState = $wpdb->suppress_errors(true);
956 }
957
958 // Route by query type: SELECT-style queries (SELECT, SHOW, EXPLAIN, DESCRIBE)
959 // produce result rows and use $wpdb->get_results(). Other queries (INSERT,
960 // UPDATE, DELETE, DDL, SET, ...) use $wpdb->query() — get_results() would
961 // call mysqli_num_fields() on a `true` result on PHP 8.1+ and TypeError.
962 // The 4.1.7 SET STATEMENT timeout wrapping also breaks wpdb's leading-keyword
963 // routing, so the detection looks PAST any SET STATEMENT prefix.
964 $producesRows = $this->queryProducesResultRows($query);
965
966 $result = array();
967 try {
968 if ($producesRows) {
969 $result['rows'] = $wpdb->get_results($query, $resultType);
970 } else {
971 $wpdb->query($query);
972 $result['rows'] = array();
973 }
974 } catch (Throwable $e) {
975 $result['elapsed_time'] = $timer->stop();
976 $this->logSqlThrowable($query, $e, $options, $producesRows);
977 if ($suppressWpdbErrors) {
978 /** @var wpdb $wpdb */
979 $wpdb->suppress_errors($previousSuppressState);
980 }
981 throw $e;
982 }
983
984 $result['elapsed_time'] = $timer->stop();
985 $elapsedMs = ((float)$result['elapsed_time']) * 1000.0;
986 if (function_exists('abj404_benchmark_record_db_query')) {
987 abj404_benchmark_record_db_query($elapsedMs);
988 }
989 if (function_exists('abj404_query_budget_record')
990 && class_exists('ABJ_404_Solution_QueryBudgetInstrumentation', false)
991 && ABJ_404_Solution_QueryBudgetInstrumentation::isEnabled()) {
992 // Resolve the source identifier only when the recorder is
993 // actually enabled — extractSqlFilename's debug_backtrace fallback
994 // costs ~100µs per call and runs on every query, so the gate
995 // matters for the always-loaded fast path. See
996 // ABJ_404_Solution_QueryBudgetInstrumentation for the contract.
997 abj404_query_budget_record($this->extractSqlFilename($query), $elapsedMs, $timeoutSeconds);
998 }
999 $this->harvestWpdbResult($result);
1000 $lastErrorForObservedLog = is_string($result['last_error'] ?? null) ? $result['last_error'] : '';
1001 if ($lastErrorForObservedLog === '' || !$this->isTransientConnectionError($lastErrorForObservedLog)) {
1002 $this->logObservedSqlError($query, $result, $options, $producesRows);
1003 }
1004
1005 if ($producesRows && !is_array($result['rows'])) {
1006 // In production (WP_DEBUG off), only log SQL filename to avoid PII exposure
1007 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
1008 $this->logger->errorMessage("Query result is not an array. Query: " . $sqlInfo,
1009 new Exception("Query result is not an array."));
1010 }
1011
1012 // SET STATEMENT timeout wrapper rejected by server (SUPER privilege
1013 // denied, ProxySQL syntax error, audit-firewall blacklist). Strip
1014 // the wrapper, retry, and cache the unsupported flag so subsequent
1015 // timeout-wrapped queries in this request skip the wrapper too.
1016 // Runs first among the recovery paths because the wrapper is the
1017 // outermost layer: every other retry path re-executes $query, so
1018 // leaving the wrapper in place would re-trigger the same rejection.
1019 $lastErrorForSetStatement = is_string($result['last_error'] ?? null) ? $result['last_error'] : '';
1020 if ($lastErrorForSetStatement !== ''
1021 && $this->classifySetStatementFailure($lastErrorForSetStatement)
1022 && $this->queryHasSetStatementWrapper($query)) {
1023 $this->retryWithoutSetStatementWrapper($query, $result, $resultType);
1024 // After this point $query holds the unwrapped form; downstream
1025 // retry paths see the new error (or none) on the unwrapped statement.
1026 $producesRows = $this->queryProducesResultRows($query);
1027 }
1028
1029 if ($result['last_error'] !== '' && $this->isTransientConnectionError($result['last_error'])) {
1030 // Retry once after reconnect for transient connection drops.
1031 $this->ensureConnection();
1032 $wpdb->flush();
1033 if ($producesRows) {
1034 $result['rows'] = $wpdb->get_results($query, $resultType);
1035 } else {
1036 $wpdb->query($query);
1037 $result['rows'] = array();
1038 }
1039 $this->harvestWpdbResult($result);
1040 }
1041
1042 if (!$options['skip_repair'] && $result['last_error'] !== '' && $this->isMissingPluginTableError($result['last_error'])) {
1043 $this->attemptMissingTableRepairAndRetry($query, $result);
1044 }
1045
1046 if ($result['last_error'] !== '' && $this->isInvalidDataError($result['last_error'])) {
1047 $this->attemptInvalidDataRetry($query, $result);
1048 }
1049
1050 // Lock wait timeout (errno 1205) and deadlock (errno 1213): retry once after a
1051 // brief pause. Both errors are transient on shared hosting and usually resolve
1052 // on the first retry. If the retry also fails, the error is surfaced below.
1053 if ($result['last_error'] !== '' && $this->isDeadlockOrLockTimeoutError($result['last_error'])) {
1054 /** @var wpdb $wpdb */
1055 usleep(50000); // 50 ms — enough for most short-lived locks to release
1056 if ($producesRows) {
1057 $result['rows'] = $wpdb->get_results($query, $resultType);
1058 } else {
1059 $wpdb->query($query);
1060 $result['rows'] = array();
1061 }
1062 $this->harvestWpdbResult($result);
1063 if ($result['last_error'] !== '' && $this->isDeadlockOrLockTimeoutError($result['last_error'])) {
1064 $this->setPluginDbNotice('lock_timeout', $this->localizeOrDefault('A database lock wait timeout occurred. If this persists, contact your host — another process may be holding a long-running lock.'), $result['last_error']);
1065 }
1066 }
1067
1068 // Collation mismatch ("Illegal mix of collations" / "Unknown collation"):
1069 // run correctCollations() (rate-limited 1×/hour) to converge plugin tables
1070 // back to a single utf8mb4 collation, then retry the query once. This
1071 // path is silent — the user is never notified about collation issues.
1072 if ($result['last_error'] !== '' && $this->isCollationError($result['last_error'])) {
1073 $this->recoverFromCollationMismatchAndRetry($query, $result, $producesRows, $resultType);
1074 }
1075
1076 // Query timeout (MySQL errno 3024 / MariaDB errno 1969): log the slow
1077 // query so it appears in debug reports, then return empty results.
1078 // Logged at WARN, not ERROR. Timeouts are a host max_statement_time
1079 // limit (server-side issue, not a plugin bug). Every caller checks
1080 // $result['timed_out'] for graceful fallback. errorMessage() would
1081 // trigger the daily developer email digest. Bruno's site
1082 // (showmetech.com.br, ~285K captured 404s) emailed every time
1083 // getHighImpactCapturedCount() exceeded 60s.
1084 if ($result['last_error'] !== '' && $this->isQueryTimeoutError($result['last_error'])) {
1085 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
1086 $this->logger->warn(
1087 'Query timed out after ' . $timeoutSeconds . 's. ' .
1088 'Query: ' . substr(preg_replace('/\s+/', ' ', trim($sqlInfo)) ?? $sqlInfo, 0, 500)
1089 );
1090 $result['rows'] = array();
1091 $result['timed_out'] = true;
1092 }
1093
1094 if ($result['last_error'] !== '') {
1095 $this->noteDatabaseIssueFromError($result['last_error']);
1096 }
1097
1098 // Restore $wpdb error reporting after all retry paths have completed.
1099 if ($suppressWpdbErrors) {
1100 /** @var wpdb $wpdb */
1101 $wpdb->suppress_errors($previousSuppressState);
1102 }
1103
1104 if ($options['log_errors'] && $result['last_error'] != '') {
1105 if ($this->f->strpos($result['last_error'],
1106 " is marked as crashed ") !== false) {
1107 $this->repairTable($result['last_error']);
1108 }
1109 if ($this->f->strpos($result['last_error'],
1110 "ALTER TABLE causes auto_increment resequencing") !== false &&
1111 $this->f->strpos($result['last_error'], "resulting in duplicate entry") !== false) {
1112 $this->repairDuplicateIDs($result['last_error'], $query);
1113 }
1114 if ($this->isIncorrectKeyFileError($result['last_error'])) {
1115 $this->repairCorruptedTableAndRetry($query, $result);
1116 }
1117
1118 // Self-heal short-circuit: repair-and-retry above clears last_error by reference; without this return the downstream classifier sees '' and emits a spurious ERROR-level "Ugh. SQL query error: ," entry that triggers the dev email digest.
1119 if ($result['last_error'] === '') { return $result; }
1120
1121 // ignore any specific errors.
1122 $reportError = true;
1123 foreach ($ignoreErrorStrings as $ignoreThis) {
1124 if (is_string($ignoreThis) && strpos($result['last_error'], $ignoreThis) !== false) {
1125 $reportError = false;
1126 break;
1127 }
1128 }
1129
1130 // Server-side and infrastructure errors are not plugin bugs. They are
1131 // already handled by dedicated repair/retry handlers above or by
1132 // noteDatabaseIssueFromError() (admin notice + write-block cooldown).
1133 // Log as WARN instead of ERROR to avoid triggering dev email reports.
1134 $lastErrorForClassification = is_string($result['last_error']) ? $result['last_error'] : '';
1135 if ($reportError && (
1136 $this->isDiskFullError($lastErrorForClassification) ||
1137 $this->isReadOnlyError($lastErrorForClassification) ||
1138 $this->isQuotaLimitError($lastErrorForClassification) ||
1139 $this->isInvalidDataError($lastErrorForClassification) ||
1140 $this->isCollationError($lastErrorForClassification) ||
1141 $this->isMissingPluginTableError($lastErrorForClassification) ||
1142 $this->isIncorrectKeyFileError($lastErrorForClassification) ||
1143 $this->isCrashedTableError($lastErrorForClassification) ||
1144 $this->isDeadlockOrLockTimeoutError($lastErrorForClassification) ||
1145 $this->isGaleraConflictError($lastErrorForClassification) ||
1146 $this->isTransientConnectionError($lastErrorForClassification) ||
1147 $this->isQueryTimeoutError($lastErrorForClassification) ||
1148 $this->isAccessDeniedError($lastErrorForClassification)
1149 )) {
1150 $this->logger->warn("Server-side DB issue (handled): " . $lastErrorForClassification);
1151 $reportError = false;
1152 }
1153
1154 if ($reportError) {
1155 $stripped_query = 'n/a';
1156 if ($this->isInvalidDataError($result['last_error'])) {
1157 $strippedResult = $this->get_stripped_query_result($query);
1158 $stripped_query = is_string($strippedResult) ? $strippedResult : 'n/a';
1159 }
1160
1161 $extraDataQuery = "select @@max_join_size as max_join_size, " .
1162 "@@sql_big_selects as sql_big_selects, " .
1163 "@@character_set_database as character_set_database";
1164 $someMySQLVariables = $wpdb->get_results($extraDataQuery, ARRAY_A);
1165 $variables = print_r($someMySQLVariables, true);
1166
1167 // In production (WP_DEBUG off), only log SQL filename to avoid PII exposure
1168 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
1169
1170 $dbVer = $wpdb->db_version();
1171 $this->logger->errorMessage("Ugh. SQL query error: " . (is_string($result['last_error']) ? $result['last_error'] : '') .
1172 ", SQL: " . $sqlInfo .
1173 ", Execution time: " . round($timer->getElapsedTime(), 2) .
1174 ", DB ver: " . (is_string($dbVer) ? $dbVer : 'unknown') .
1175 ", Variables: " . $variables .
1176 ", stripped_query: " . $stripped_query);
1177 }
1178
1179 } else {
1180 if ($options['log_too_slow'] && $timer->getElapsedTime() > 5) {
1181 // In production (WP_DEBUG off), only log SQL filename to avoid PII exposure
1182 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
1183 $this->logger->debugMessage("Slow query (" . round($timer->getElapsedTime(), 2) . " seconds): " .
1184 $sqlInfo);
1185 }
1186
1187 // Auto-clear the admin notice once queries succeed and cooldowns have expired.
1188 // Guard: only run when the query truly succeeded (this else branch also
1189 // fires when log_errors is false, which can include failed queries).
1190 if ($result['last_error'] === '') {
1191 // serverSideIssueNoted is set when an error occurs in this request;
1192 // also check once per request if a stale notice transient exists from
1193 // a previous request (the flag resets per-process).
1194 if (!$this->serverSideIssueNoted && !$this->serverSideIssueChecked) {
1195 $this->serverSideIssueChecked = true;
1196 $existing = $this->getRuntimeFlag('abj404_plugin_db_notice');
1197 // Exclude notice types cleared by a dedicated path, not the
1198 // generic write-block/quota cooldown model: stale_permalink_cache
1199 // (cleared by PermalinkCache flush) and missing_table (cleared
1200 // only by attemptMissingTableRepairAndRetry on repair success;
1201 // a separate abj404_missing_table_repair_cooldown gates retries
1202 // but is not consulted here).
1203 $excludedTypes = array('stale_permalink_cache', 'missing_table');
1204 if (is_array($existing) && !empty($existing['type'])
1205 && !in_array($existing['type'], $excludedTypes, true)) {
1206 $this->serverSideIssueNoted = true;
1207 }
1208 }
1209 if ($this->serverSideIssueNoted && !$this->isWriteBlockActive() && !$this->isQuotaCooldownActive()) {
1210 $this->clearServerSideDbNotice();
1211 }
1212 }
1213 }
1214
1215 return $result;
1216 }
1217
1218 // Engine-aware per-query timeout helpers + query-shape probes are
1219 // declared on the sibling ABJ_404_Solution_DataAccess_QueryTimeoutsTrait:
1220 // - queryStartsWithSelect / queryProducesResultRows
1221 // - applyQueryTimeout / applySelectTimeout
1222 // - applyNonLeadingSelectTimeout / applyStatementTimeout
1223 // - isMariaDB / applyTimeoutToInsertSelect
1224
1225 /**
1226 * @param string $key
1227 * @param mixed $value
1228 * @param int $ttlSeconds
1229 * @return void
1230 */
1231 private function setRuntimeFlag(string $key, $value, int $ttlSeconds): void {
1232 if (function_exists('set_transient')) {
1233 // allow-cache-empty: passthrough helper. Callers store admin-notice payloads, cooldown timestamps, and lock-state markers, not query results.
1234 set_transient($key, $value, $ttlSeconds);
1235 return;
1236 }
1237 if (function_exists('update_option')) {
1238 update_option($key, $value, false);
1239 }
1240 }
1241
1242 /**
1243 * @param string $key
1244 * @return mixed
1245 */
1246 private function getRuntimeFlag(string $key) {
1247 if (function_exists('get_transient')) {
1248 return get_transient($key);
1249 }
1250 if (function_exists('get_option')) {
1251 return get_option($key, false);
1252 }
1253 return false;
1254 }
1255
1256 /**
1257 * @param string $type
1258 * @param string $message
1259 * @param string $errorString
1260 * @return void
1261 */
1262 protected function setPluginDbNotice(string $type, string $message, string $errorString = ''): void {
1263 $payload = array(
1264 'type' => $type,
1265 'message' => $message,
1266 'timestamp' => $this->clock()->now(),
1267 'error_string' => $errorString,
1268 );
1269 $this->setRuntimeFlag('abj404_plugin_db_notice', $payload, self::DB_WRITE_BLOCK_COOLDOWN_SECONDS);
1270 }
1271
1272 /**
1273 * Clear the plugin DB notice only when its current type matches.
1274 *
1275 * @param string $type
1276 * @return void
1277 */
1278 protected function clearPluginDbNoticeIfType(string $type): void {
1279 $existing = $this->getRuntimeFlag('abj404_plugin_db_notice');
1280 if (!is_array($existing)) {
1281 return;
1282 }
1283 $currentType = isset($existing['type']) && is_string($existing['type']) ? $existing['type'] : '';
1284 if ($currentType !== $type) {
1285 return;
1286 }
1287 $this->clearServerSideDbNotice();
1288 }
1289
1290 /** @return void */
1291 private function clearServerSideDbNotice(): void {
1292 if (function_exists('delete_transient')) {
1293 delete_transient('abj404_plugin_db_notice');
1294 } elseif (function_exists('delete_option')) {
1295 delete_option('abj404_plugin_db_notice');
1296 }
1297 $this->serverSideIssueNoted = false;
1298 }
1299
1300 /** @param string $text @return string */
1301 private function localizeOrDefault(string $text): string {
1302 if (function_exists('__')) {
1303 return __($text, '404-solution');
1304 }
1305 return $text;
1306 }
1307
1308 /** @return bool */
1309 private function isWriteBlockActive(): bool {
1310 $rawDiskFlag = $this->getRuntimeFlag('abj404_db_disk_full_until');
1311 $diskUntil = is_scalar($rawDiskFlag) ? (int)$rawDiskFlag : 0;
1312 $rawReadOnlyFlag = $this->getRuntimeFlag('abj404_db_read_only_until');
1313 $readOnlyUntil = is_scalar($rawReadOnlyFlag) ? (int)$rawReadOnlyFlag : 0;
1314 $now = $this->clock()->now();
1315 return ($diskUntil > $now || $readOnlyUntil > $now);
1316 }
1317
1318 /** @return bool */
1319 private function shouldSkipNonEssentialDbWrites(): bool {
1320 return ($this->isQuotaCooldownActive() || $this->isWriteBlockActive());
1321 }
1322
1323 /**
1324 * Attempt REPAIR TABLE after errno 1034 ("Incorrect key file"), then retry the query once.
1325 * For plugin tables the retry is attempted after repair. For non-plugin tables the repair
1326 * is not our responsibility, but we surface a rate-limited admin notice.
1327 *
1328 * @param string $query
1329 * @param array<string, mixed> $result passed by reference
1330 * @return void
1331 */
1332 private function repairCorruptedTableAndRetry(string $query, array &$result): void {
1333 $errorMessage = is_string($result['last_error']) ? $result['last_error'] : '';
1334 // Delegate the REPAIR TABLE call (and the non-plugin-table notice) to the trait method.
1335 $this->repairTable($errorMessage);
1336
1337 // Only retry for plugin tables — they may now be healthy.
1338 if (stripos($errorMessage, 'abj404') !== false) {
1339 global $wpdb;
1340 $wpdb->flush();
1341 $result['rows'] = $wpdb->get_results($query, $this->currentResultType);
1342 $result['last_error'] = (string)($wpdb->last_error ?? '');
1343 $result['last_result'] = $wpdb->last_result ?? array();
1344 $result['rows_affected'] = $wpdb->rows_affected ?? 0;
1345 $result['insert_id'] = $wpdb->insert_id ?? 0;
1346 if ($result['last_error'] === '') {
1347 $this->logger->infoMessage("Retry after 'Incorrect key file' repair succeeded for plugin table.");
1348 }
1349 }
1350 }
1351
1352 /** Try to call strip_invalid_text_from_query and return the result.
1353 * @param string $query
1354 * @return NULL|string|WP_Error
1355 */
1356 function get_stripped_query_result($query) {
1357 try {
1358 if (!class_exists('wpdb')) {
1359 return null;
1360 }
1361 if (!method_exists('wpdb', 'strip_invalid_text_from_query')) {
1362 return null;
1363 }
1364
1365 $filename = ABJ404_PATH . 'includes/php/wordpress/WPDBExtension.php';
1366 if (!file_exists($filename)) {
1367 return null;
1368 }
1369 require_once $filename;
1370
1371 $my_custom_db = null;
1372 if (class_exists('ABJ_404_Solution_WPDBExtension_PHP7')) {
1373 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP7(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
1374
1375 } else if (class_exists('ABJ_404_Solution_WPDBExtension_PHP5')) {
1376 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP5(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
1377 }
1378 if ($my_custom_db == null) {
1379 return null;
1380 }
1381
1382 $result = $my_custom_db->public_strip_invalid_text_from_query($query);
1383
1384 if (is_wp_error($result)) {
1385 return 'WP_Error: ' . $result->get_error_message();
1386 }
1387
1388 return $result;
1389
1390 } catch (Throwable $e) {
1391 // Surface the swallowed failure so the support-bundle reader can
1392 // see the wpdb extension fell through. The function contract
1393 // (NULL|string|WP_Error) is preserved by returning null.
1394 $this->logger->warn(
1395 'get_stripped_query_result failed; returning null: ' . $e->getMessage()
1396 );
1397 return null;
1398 }
1399 }
1400
1401 /** @param string $errorMessage @return void */
1402 }
1403