| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
require_once __DIR__ . '/LogsRepositoryInterface.php'; |
| 8 |
|
| 9 |
/** |
| 10 |
* Log insertion, querying, hits rebuild, GDPR, and lookup operations. |
| 11 |
* |
| 12 |
* Extracted from the DataAccess monolith (Phase 3 of the DataAccess refactor). |
| 13 |
* Methods originate from three sources: |
| 14 |
* - DataAccessTrait_Logs (entirely absorbed) |
| 15 |
* - DataAccessTrait_LogsHitsRebuild (entirely absorbed) |
| 16 |
* - DataAccessTrait_Maintenance::correctDuplicateLookupValues() (relocated) |
| 17 |
* - DataAccessTrait_ViewQueriesHitsLifecycle (hits lifecycle methods relocated) |
| 18 |
* |
| 19 |
* Receives a DatabaseCore instance for all query execution. |
| 20 |
*/ |
| 21 |
class ABJ_404_Solution_LogsRepository implements ABJ_404_Solution_LogsRepositoryInterface { |
| 22 |
|
| 23 |
const UPDATE_LOGS_HITS_TABLE_HOOK = 'abj404_updateLogsHitsTableAction'; |
| 24 |
|
| 25 |
/** @var int Maximum age in seconds before hits table is considered stale */ |
| 26 |
const HITS_TABLE_MAX_AGE_SECONDS = 300; |
| 27 |
/** @var int Minimum interval between hits-table rebuild schedules (server-side dedupe). */ |
| 28 |
const HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS = 30; |
| 29 |
/** @var int Cross-request lock timeout for logs-hits rebuild jobs. */ |
| 30 |
const HITS_TABLE_REBUILD_LOCK_TTL_SECONDS = 180; |
| 31 |
/** @var int Number of logsv2 IDs to process per chunk during pre-aggregation. */ |
| 32 |
const HITS_TABLE_PREAGG_CHUNK_SIZE = 100000; |
| 33 |
/** @var int Direct-path threshold for hits-table rebuild. */ |
| 34 |
const HITS_TABLE_DIRECT_PATH_THRESHOLD = 5000; |
| 35 |
/** @var int Max age for cached daily-activity trend data. */ |
| 36 |
const TREND_DATA_CACHE_TTL_SECONDS = 900; |
| 37 |
|
| 38 |
/** @var string Runtime flag: last time we checked whether logs-hits needs rebuild. */ |
| 39 |
const HITS_TABLE_LAST_CHECKED_FLAG = 'abj404_logs_hits_last_checked_at'; |
| 40 |
/** @var string Runtime flag: last time we scheduled a rebuild. */ |
| 41 |
const HITS_TABLE_LAST_SCHEDULED_FLAG = 'abj404_logs_hits_last_scheduled_at'; |
| 42 |
/** @var string Runtime flag: last schedule decision. */ |
| 43 |
const HITS_TABLE_LAST_DECISION_FLAG = 'abj404_logs_hits_last_decision'; |
| 44 |
/** @var string Runtime flag: last successful hits-table rebuild completion. */ |
| 45 |
const HITS_TABLE_LAST_REFRESHED_FLAG = 'abj404_logs_hits_last_refreshed_at'; |
| 46 |
/** @var string Runtime flag: first stale detection timestamp. */ |
| 47 |
const HITS_TABLE_FIRST_STALE_DETECTED_FLAG = 'abj404_logs_hits_first_stale_detected_at'; |
| 48 |
/** @var string Deduplicated admin-notice transient for stale logs_hits rollup. */ |
| 49 |
const HITS_TABLE_STALE_NOTICE_TRANSIENT = 'abj404_logs_hits_rollup_stale'; |
| 50 |
/** @var int Minimum age (seconds) of stale gap before surfacing admin notice. */ |
| 51 |
const HITS_TABLE_STALE_NOTICE_THRESHOLD_SECONDS = 3600; |
| 52 |
|
| 53 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 54 |
private $dbCore; |
| 55 |
|
| 56 |
/** @var ABJ_404_Solution_Functions */ |
| 57 |
private $f; |
| 58 |
|
| 59 |
/** @var ABJ_404_Solution_Logging */ |
| 60 |
private $logger; |
| 61 |
|
| 62 |
/** @var ABJ_404_Solution_RebuildHealthState|null */ |
| 63 |
private $rebuildHealth; |
| 64 |
|
| 65 |
/** @var array<int, array<string, mixed>> Queue of log entries to be flushed at shutdown */ |
| 66 |
private static $logQueue = []; |
| 67 |
|
| 68 |
/** @var bool Whether shutdown hook has been registered */ |
| 69 |
private static $shutdownHookRegistered = false; |
| 70 |
|
| 71 |
/** @var bool Prevent re-entrancy during flush */ |
| 72 |
private static $isFlushingLogQueue = false; |
| 73 |
|
| 74 |
/** @var bool Whether the hits table rebuild has been scheduled for this request */ |
| 75 |
private static $hitsTableRebuildScheduled = false; |
| 76 |
|
| 77 |
/** |
| 78 |
* @param ABJ_404_Solution_DatabaseCore $dbCore |
| 79 |
* @param ABJ_404_Solution_Functions|null $functions |
| 80 |
* @param ABJ_404_Solution_Logging|null $logging |
| 81 |
* @param ABJ_404_Solution_RebuildHealthState|null $rebuildHealth |
| 82 |
*/ |
| 83 |
public function __construct( |
| 84 |
ABJ_404_Solution_DatabaseCore $dbCore, |
| 85 |
$functions = null, |
| 86 |
$logging = null, |
| 87 |
$rebuildHealth = null |
| 88 |
) { |
| 89 |
$this->dbCore = $dbCore; |
| 90 |
$this->f = $functions !== null ? $functions : abj_service('functions'); |
| 91 |
$this->logger = $logging !== null ? $logging : abj_service('logging'); |
| 92 |
$this->rebuildHealth = $rebuildHealth instanceof ABJ_404_Solution_RebuildHealthState |
| 93 |
? $rebuildHealth |
| 94 |
: $this->resolveRebuildHealthState(); |
| 95 |
} |
| 96 |
|
| 97 |
/** @return ABJ_404_Solution_RebuildHealthState|null */ |
| 98 |
private function resolveRebuildHealthState(): ?ABJ_404_Solution_RebuildHealthState { |
| 99 |
if (function_exists('abj_service') |
| 100 |
&& class_exists('ABJ_404_Solution_ServiceContainer') |
| 101 |
&& ABJ_404_Solution_ServiceContainer::safeHas('rebuild_health')) { |
| 102 |
try { |
| 103 |
$service = abj_service('rebuild_health'); |
| 104 |
if ($service instanceof ABJ_404_Solution_RebuildHealthState) { |
| 105 |
return $service; |
| 106 |
} |
| 107 |
} catch (Throwable $t) { |
| 108 |
$this->logger->debugMessage(__FUNCTION__ . ' rebuild_health service unavailable: ' . $t->getMessage()); |
| 109 |
} |
| 110 |
} |
| 111 |
return null; |
| 112 |
} |
| 113 |
|
| 114 |
// ========================================================================= |
| 115 |
// Log data population and querying (from DataAccessTrait_Logs) |
| 116 |
// ========================================================================= |
| 117 |
|
| 118 |
/** @inheritDoc */ |
| 119 |
function populateLogsData($rows) { |
| 120 |
if (empty($rows)) { |
| 121 |
return $rows; |
| 122 |
} |
| 123 |
|
| 124 |
$urls = array(); |
| 125 |
foreach ($rows as $row) { |
| 126 |
if ($row['url'] != null && !empty($row['url'])) { |
| 127 |
$variants = $this->buildHitsLookupUrlVariants($row['url']); |
| 128 |
foreach ($variants as $variant) { |
| 129 |
$urls[] = $variant; |
| 130 |
} |
| 131 |
} |
| 132 |
} |
| 133 |
|
| 134 |
if (empty($urls)) { |
| 135 |
return $rows; |
| 136 |
} |
| 137 |
|
| 138 |
$urls = array_values(array_unique($urls)); |
| 139 |
|
| 140 |
if (!$this->logsHitsTableExists()) { |
| 141 |
$this->scheduleHitsTableRebuild(); |
| 142 |
return $rows; |
| 143 |
} |
| 144 |
|
| 145 |
$logsHitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}'); |
| 146 |
$batchSize = 200; |
| 147 |
$logsResults = array(); |
| 148 |
$urlChunks = array_chunk($urls, $batchSize); |
| 149 |
|
| 150 |
foreach ($urlChunks as $urlChunk) { |
| 151 |
$placeholders = implode(',', array_fill(0, count($urlChunk), '%s')); |
| 152 |
$sql = "SELECT requested_url, logsid, last_used, logshits " |
| 153 |
. "FROM {$logsHitsTable} " |
| 154 |
. "WHERE BINARY requested_url IN ($placeholders)"; |
| 155 |
|
| 156 |
$chunkResult = $this->dbCore->queryAndGetResults($sql, array( |
| 157 |
'query_params' => $urlChunk, |
| 158 |
'log_too_slow' => false, |
| 159 |
)); |
| 160 |
|
| 161 |
if (!empty($chunkResult['timed_out']) || |
| 162 |
(isset($chunkResult['last_error']) && $chunkResult['last_error'] != '')) { |
| 163 |
$errRaw = $chunkResult['last_error'] ?? ''; |
| 164 |
$err = is_string($errRaw) ? $errRaw : ''; |
| 165 |
if ($err !== '' && strpos($err, 'logs_hits') !== false) { |
| 166 |
$this->scheduleHitsTableRebuild(); |
| 167 |
} |
| 168 |
return $rows; |
| 169 |
} |
| 170 |
|
| 171 |
$chunkResults = is_array($chunkResult['rows'] ?? null) ? $chunkResult['rows'] : array(); |
| 172 |
if (!empty($chunkResults)) { |
| 173 |
$logsResults = array_merge($logsResults, $chunkResults); |
| 174 |
} |
| 175 |
} |
| 176 |
|
| 177 |
$logsDataByUrl = array(); |
| 178 |
foreach ($logsResults as $logRow) { |
| 179 |
$canonicalUrl = $this->canonicalizeUrlForHitsMatch($logRow['requested_url'] ?? ''); |
| 180 |
if ($canonicalUrl === '') { |
| 181 |
continue; |
| 182 |
} |
| 183 |
if (!isset($logsDataByUrl[$canonicalUrl])) { |
| 184 |
$logsDataByUrl[$canonicalUrl] = array( |
| 185 |
'logsid' => (int)($logRow['logsid'] ?? 0), |
| 186 |
'logshits' => (int)($logRow['logshits'] ?? 0), |
| 187 |
'last_used' => (int)($logRow['last_used'] ?? 0), |
| 188 |
); |
| 189 |
continue; |
| 190 |
} |
| 191 |
$existing = $logsDataByUrl[$canonicalUrl]; |
| 192 |
$currentLogsid = (int)($logRow['logsid'] ?? 0); |
| 193 |
$existingLogsid = (int)$existing['logsid']; |
| 194 |
$logsDataByUrl[$canonicalUrl]['logsid'] = ($existingLogsid > 0 && $currentLogsid > 0) |
| 195 |
? min($existingLogsid, $currentLogsid) |
| 196 |
: max($existingLogsid, $currentLogsid); |
| 197 |
$logsDataByUrl[$canonicalUrl]['logshits'] = (int)$existing['logshits'] + (int)($logRow['logshits'] ?? 0); |
| 198 |
$logsDataByUrl[$canonicalUrl]['last_used'] = max((int)$existing['last_used'], (int)($logRow['last_used'] ?? 0)); |
| 199 |
} |
| 200 |
|
| 201 |
foreach ($rows as &$row) { |
| 202 |
if ($row['url'] != null && !empty($row['url'])) { |
| 203 |
$canonicalUrl = $this->canonicalizeUrlForHitsMatch($row['url']); |
| 204 |
if (isset($logsDataByUrl[$canonicalUrl])) { |
| 205 |
$logData = $logsDataByUrl[$canonicalUrl]; |
| 206 |
$row['logsid'] = $logData['logsid']; |
| 207 |
$row['logshits'] = $logData['logshits']; |
| 208 |
$row['last_used'] = $logData['last_used']; |
| 209 |
} |
| 210 |
} |
| 211 |
} |
| 212 |
|
| 213 |
return $rows; |
| 214 |
} |
| 215 |
|
| 216 |
/** @param mixed $url @return string */ |
| 217 |
private function canonicalizeUrlForHitsMatch($url): string { |
| 218 |
if (!is_string($url)) { |
| 219 |
return ''; |
| 220 |
} |
| 221 |
$url = trim($url); |
| 222 |
if ($url === '') { |
| 223 |
return ''; |
| 224 |
} |
| 225 |
$fragment = ''; |
| 226 |
$fragmentPos = strpos($url, '#'); |
| 227 |
if ($fragmentPos !== false) { |
| 228 |
$fragment = substr($url, $fragmentPos); |
| 229 |
$url = substr($url, 0, $fragmentPos); |
| 230 |
} |
| 231 |
$query = ''; |
| 232 |
$queryPos = strpos($url, '?'); |
| 233 |
if ($queryPos !== false) { |
| 234 |
$query = substr($url, $queryPos); |
| 235 |
$url = substr($url, 0, $queryPos); |
| 236 |
} |
| 237 |
$path = trim($url, '/'); |
| 238 |
$normalizedPath = ($path === '') ? '/' : '/' . $path; |
| 239 |
return $normalizedPath . $query . $fragment; |
| 240 |
} |
| 241 |
|
| 242 |
/** @param mixed $url @return array<int, string> */ |
| 243 |
private function buildHitsLookupUrlVariants($url) { |
| 244 |
$variants = array(); |
| 245 |
if (!is_string($url)) { |
| 246 |
return $variants; |
| 247 |
} |
| 248 |
$raw = trim($url); |
| 249 |
if ($raw !== '') { |
| 250 |
$variants[] = $raw; |
| 251 |
} |
| 252 |
$canonical = $this->canonicalizeUrlForHitsMatch($url); |
| 253 |
if ($canonical !== '') { |
| 254 |
$variants[] = $canonical; |
| 255 |
$parts = $this->splitCanonicalHitsUrl($canonical); |
| 256 |
$pathPart = $parts['path']; |
| 257 |
$suffixPart = $parts['suffix']; |
| 258 |
$pathVariants = array($pathPart); |
| 259 |
$noLeadingPath = ltrim($pathPart, '/'); |
| 260 |
if ($noLeadingPath !== '') { |
| 261 |
$pathVariants[] = $noLeadingPath; |
| 262 |
} |
| 263 |
if ($pathPart !== '/') { |
| 264 |
if (substr($pathPart, -1) === '/') { |
| 265 |
$toggleTrailingPath = rtrim($pathPart, '/'); |
| 266 |
} else { |
| 267 |
$toggleTrailingPath = $pathPart . '/'; |
| 268 |
} |
| 269 |
$pathVariants[] = $toggleTrailingPath; |
| 270 |
$toggleNoLeadingPath = ltrim($toggleTrailingPath, '/'); |
| 271 |
if ($toggleNoLeadingPath !== '') { |
| 272 |
$pathVariants[] = $toggleNoLeadingPath; |
| 273 |
} |
| 274 |
} |
| 275 |
foreach (array_unique($pathVariants) as $pathVariant) { |
| 276 |
$variants[] = $pathVariant . $suffixPart; |
| 277 |
} |
| 278 |
} |
| 279 |
return array_values(array_unique($variants)); |
| 280 |
} |
| 281 |
|
| 282 |
/** @return array{path: string, suffix: string} */ |
| 283 |
private function splitCanonicalHitsUrl(string $canonicalUrl): array { |
| 284 |
$firstQueryPos = strpos($canonicalUrl, '?'); |
| 285 |
$firstFragmentPos = strpos($canonicalUrl, '#'); |
| 286 |
if ($firstQueryPos === false && $firstFragmentPos === false) { |
| 287 |
return array('path' => $canonicalUrl, 'suffix' => ''); |
| 288 |
} |
| 289 |
if ($firstQueryPos === false) { |
| 290 |
$splitPos = $firstFragmentPos; |
| 291 |
} elseif ($firstFragmentPos === false) { |
| 292 |
$splitPos = $firstQueryPos; |
| 293 |
} else { |
| 294 |
$splitPos = min($firstQueryPos, $firstFragmentPos); |
| 295 |
} |
| 296 |
return array( |
| 297 |
'path' => substr($canonicalUrl, 0, $splitPos), |
| 298 |
'suffix' => substr($canonicalUrl, $splitPos), |
| 299 |
); |
| 300 |
} |
| 301 |
|
| 302 |
/** @inheritDoc */ |
| 303 |
function getDistinctLoggedUrls(): array { |
| 304 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getDistinctLoggedUrls.sql"); |
| 305 |
$results = $this->dbCore->queryAndGetResults($query); |
| 306 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 307 |
$urls = array(); |
| 308 |
foreach ($rows as $row) { |
| 309 |
$url = isset($row['requested_url']) && is_string($row['requested_url']) ? $row['requested_url'] : ''; |
| 310 |
if ($url !== '') { |
| 311 |
$urls[] = $url; |
| 312 |
} |
| 313 |
} |
| 314 |
return $urls; |
| 315 |
} |
| 316 |
|
| 317 |
/** @inheritDoc */ |
| 318 |
function getLogsIDandURL($specificURL = '') { |
| 319 |
global $wpdb; |
| 320 |
$whereClause = ''; |
| 321 |
if ($specificURL != '') { |
| 322 |
$specificURL = $this->f->sanitizeInvalidUTF8($specificURL); |
| 323 |
$escapedURL = esc_sql($specificURL); |
| 324 |
$whereClause = "where requested_url = '" . $escapedURL . "'"; |
| 325 |
} |
| 326 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogsIDandURL.sql"); |
| 327 |
$query = $this->f->str_replace('{where_clause_here}', $whereClause, $query); |
| 328 |
$results = $this->dbCore->queryAndGetResults($query); |
| 329 |
return is_array($results['rows']) ? $results['rows'] : array(); |
| 330 |
} |
| 331 |
|
| 332 |
/** @inheritDoc */ |
| 333 |
function getLogsIDandURLLike($specificURL, $limitResults) { |
| 334 |
global $wpdb; |
| 335 |
$whereClause = ''; |
| 336 |
if ($specificURL != '') { |
| 337 |
$likePattern = '%' . $wpdb->esc_like($specificURL) . '%'; |
| 338 |
$escapedURL = esc_sql($likePattern); |
| 339 |
$whereClause = "where lower(requested_url) like lower('" . $escapedURL . "')\n"; |
| 340 |
$whereClause .= "and min_log_id = true"; |
| 341 |
} |
| 342 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogsIDandURLForAjax.sql"); |
| 343 |
$query = $this->f->str_replace('{where_clause_here}', $whereClause, $query); |
| 344 |
$query = $this->f->str_replace('{limit-results}', 'limit ' . absint($limitResults), $query); |
| 345 |
$results = $this->dbCore->queryAndGetResults($query); |
| 346 |
return is_array($results['rows']) ? $results['rows'] : array(); |
| 347 |
} |
| 348 |
|
| 349 |
/** @inheritDoc */ |
| 350 |
function getLogRecords($tableOptions) { |
| 351 |
$abj404logic = abj_service('plugin_logic'); |
| 352 |
$logsid_included = ''; |
| 353 |
$logsid = ''; |
| 354 |
$rawLogsId = $tableOptions['logsid']; |
| 355 |
if ($rawLogsId != 0) { |
| 356 |
$logsid_included = 'specific logs id included. */'; |
| 357 |
$logsid = esc_sql($abj404logic->sanitizeForSQL(is_scalar($rawLogsId) ? (string)$rawLogsId : '')); |
| 358 |
} |
| 359 |
$orderbyExpressionByName = array( |
| 360 |
'timestamp' => '{wp_abj404_logsv2}.timestamp', |
| 361 |
'requested_url' => '{wp_abj404_logsv2}.requested_url', |
| 362 |
'url' => 'url', |
| 363 |
'id' => '{wp_abj404_logsv2}.id', |
| 364 |
'min_log_id' => '{wp_abj404_logsv2}.min_log_id', |
| 365 |
); |
| 366 |
$rawOrderByVal = $tableOptions['orderby']; |
| 367 |
$orderby = sanitize_text_field($abj404logic->sanitizeForSQL(is_string($rawOrderByVal) ? $rawOrderByVal : '')); |
| 368 |
$orderby = array_key_exists($orderby, $orderbyExpressionByName) ? $orderby : 'timestamp'; |
| 369 |
$orderbyExpression = $orderbyExpressionByName[$orderby]; |
| 370 |
$rawOrderVal2 = $tableOptions['order']; |
| 371 |
$order = strtoupper(sanitize_text_field($abj404logic->sanitizeForSQL(is_string($rawOrderVal2) ? $rawOrderVal2 : ''))); |
| 372 |
if (!in_array($order, array('ASC', 'DESC'), true)) { |
| 373 |
$order = 'DESC'; |
| 374 |
} |
| 375 |
$paged = absint(is_scalar($tableOptions['paged'] ?? 1) ? ($tableOptions['paged'] ?? 1) : 1); |
| 376 |
if ($paged < 1) { $paged = 1; } |
| 377 |
$perpage = absint(is_scalar($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) ? ($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) : ABJ404_OPTION_DEFAULT_PERPAGE); |
| 378 |
if ($perpage < 1) { $perpage = ABJ404_OPTION_DEFAULT_PERPAGE; } |
| 379 |
$start = ($paged - 1) * $perpage; |
| 380 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogRecords.sql"); |
| 381 |
$query = $this->f->str_replace('{logsid_included}', $logsid_included, $query); |
| 382 |
$query = $this->f->str_replace('{logsid}', $logsid, $query); |
| 383 |
$query = $this->f->str_replace('{orderby}', $orderbyExpression, $query); |
| 384 |
$query = $this->f->str_replace('{order}', $order, $query); |
| 385 |
$query = $this->f->str_replace('{start}', (string)$start, $query); |
| 386 |
$query = $this->f->str_replace('{perpage}', (string)$perpage, $query); |
| 387 |
$results = $this->dbCore->queryAndGetResults($query); |
| 388 |
$rawRows = $results['rows']; |
| 389 |
return is_array($rawRows) ? $rawRows : array(); |
| 390 |
} |
| 391 |
|
| 392 |
/** @inheritDoc */ |
| 393 |
public function getLogsv2IdsForLookupValue($lkupValue, $page = 1, $perPage = 100) { |
| 394 |
$lkupValue = trim($lkupValue); |
| 395 |
if ($lkupValue === '') { return array(); } |
| 396 |
$page = max(1, absint($page)); |
| 397 |
$perPage = max(1, min(500, absint($perPage))); |
| 398 |
$offset = ($page - 1) * $perPage; |
| 399 |
$logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}"); |
| 400 |
$lookupTable = $this->dbCore->doTableNameReplacements("{wp_abj404_lookup}"); |
| 401 |
$sql = "SELECT l.id FROM `{$logsTable}` l INNER JOIN `{$lookupTable}` u ON l.username = u.id WHERE u.lkup_value = %s ORDER BY l.id DESC LIMIT %d OFFSET %d"; |
| 402 |
$result = $this->dbCore->queryAndGetResults($sql, array('query_params' => array($lkupValue, $perPage, $offset))); |
| 403 |
if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { return array(); } |
| 404 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 405 |
$ids = array(); |
| 406 |
foreach ($rows as $row) { |
| 407 |
if (is_array($row) && isset($row['id'])) { $ids[] = absint($row['id']); } |
| 408 |
} |
| 409 |
return array_values(array_filter($ids)); |
| 410 |
} |
| 411 |
|
| 412 |
/** @inheritDoc */ |
| 413 |
public function getLogsv2RowsForLookupValue($lkupValue, $page = 1, $perPage = 50) { |
| 414 |
$ids = $this->getLogsv2IdsForLookupValue($lkupValue, $page, $perPage); |
| 415 |
if (empty($ids)) { return array(); } |
| 416 |
$logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}"); |
| 417 |
$placeholders = implode(',', array_fill(0, count($ids), '%d')); |
| 418 |
$sql = "SELECT id, timestamp, user_ip, referrer, requested_url, requested_url_detail, dest_url FROM `{$logsTable}` WHERE id IN ({$placeholders}) ORDER BY id DESC"; |
| 419 |
$result = $this->dbCore->queryAndGetResults($sql, array('query_params' => $ids)); |
| 420 |
if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { return array(); } |
| 421 |
return is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 422 |
} |
| 423 |
|
| 424 |
/** @inheritDoc */ |
| 425 |
public function anonymizeLogsv2RowsByIds($ids) { |
| 426 |
if (!is_array($ids) || empty($ids)) { return true; } |
| 427 |
$ids = array_values(array_filter(array_map('absint', $ids))); |
| 428 |
if (empty($ids)) { return true; } |
| 429 |
$logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}"); |
| 430 |
$placeholders = implode(',', array_fill(0, count($ids), '%d')); |
| 431 |
$sql = "UPDATE `{$logsTable}` SET user_ip = %s, referrer = NULL, requested_url_detail = NULL, username = NULL WHERE id IN ({$placeholders})"; |
| 432 |
$params = array_merge(array('(Anonymized)'), $ids); |
| 433 |
$result = $this->dbCore->queryAndGetResults($sql, array('query_params' => $params)); |
| 434 |
if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { return false; } |
| 435 |
return true; |
| 436 |
} |
| 437 |
|
| 438 |
// ========================================================================= |
| 439 |
// Log insertion and queue (from DataAccessTrait_Logs) |
| 440 |
// ========================================================================= |
| 441 |
|
| 442 |
/** @inheritDoc */ |
| 443 |
function logRedirectHit(string $requested_url, string $action, string $matchReason, ?string $requestedURLDetail = null, ?array $pipelineTrace = null): void { |
| 444 |
global $wpdb; |
| 445 |
$abj404logic = abj_service('plugin_logic'); |
| 446 |
$logTableName = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}"); |
| 447 |
$now = time(); |
| 448 |
$requested_url = preg_replace('/[\x00-\x1F\x7F]/u', '', $requested_url) ?? $requested_url; |
| 449 |
$requested_url = $abj404logic->normalizeToRelativePath($requested_url); |
| 450 |
|
| 451 |
try { |
| 452 |
static $requestedUrlColumnMeta = null; |
| 453 |
if ($requestedUrlColumnMeta === null && function_exists('get_transient')) { |
| 454 |
$requestedUrlColumnMeta = get_transient('abj404_logs_requested_url_column_meta'); |
| 455 |
if ($requestedUrlColumnMeta === false) { $requestedUrlColumnMeta = null; } |
| 456 |
} |
| 457 |
if ($requestedUrlColumnMeta === null && function_exists('get_transient')) { |
| 458 |
$legacyCharset = get_transient('abj404_logs_requested_url_charset'); |
| 459 |
if (is_string($legacyCharset) && $legacyCharset !== '') { |
| 460 |
$requestedUrlColumnMeta = array('charset_name' => $legacyCharset, 'collation_name' => null); |
| 461 |
} |
| 462 |
} |
| 463 |
$dbName = defined('DB_NAME') ? (string)DB_NAME : ''; |
| 464 |
if ($requestedUrlColumnMeta === null && $dbName !== '' && is_object($wpdb)) { |
| 465 |
$getCharsetQuery = $wpdb->prepare("SELECT character_set_name as charset_name, collation_name as collation_name \n FROM information_schema.columns \n WHERE lower(table_schema) = lower(%s) \n AND lower(table_name) = lower(%s) \n AND lower(column_name) = lower(%s) ", $dbName, $logTableName, 'requested_url'); |
| 466 |
$resultArray = $wpdb->get_results($getCharsetQuery, ARRAY_A); |
| 467 |
if (!empty($resultArray)) { |
| 468 |
$requestedUrlColumnMeta = array( |
| 469 |
'charset_name' => $resultArray[0]['charset_name'] ?? $resultArray[0]['CHARSET_NAME'] ?? null, |
| 470 |
'collation_name' => $resultArray[0]['collation_name'] ?? $resultArray[0]['COLLATION_NAME'] ?? null, |
| 471 |
); |
| 472 |
if (function_exists('set_transient')) { |
| 473 |
// @cache-write-audit: opt-out - schema metadata probe cache, not user-query result data. |
| 474 |
$ttl = defined('WEEK_IN_SECONDS') ? WEEK_IN_SECONDS : 604800; |
| 475 |
set_transient('abj404_logs_requested_url_column_meta', $requestedUrlColumnMeta, $ttl); |
| 476 |
if (!empty($requestedUrlColumnMeta['charset_name'])) { |
| 477 |
set_transient('abj404_logs_requested_url_charset', $requestedUrlColumnMeta['charset_name'], $ttl); |
| 478 |
} |
| 479 |
} |
| 480 |
} |
| 481 |
} |
| 482 |
$requestedUrlCharset = is_array($requestedUrlColumnMeta) ? ($requestedUrlColumnMeta['charset_name'] ?? null) : null; |
| 483 |
$requestedUrlCollation = is_array($requestedUrlColumnMeta) ? ($requestedUrlColumnMeta['collation_name'] ?? null) : null; |
| 484 |
if (!empty($requestedUrlCharset) && strpos(strtolower($requestedUrlCharset), 'utf8') === false) { |
| 485 |
$requested_url = $this->f->encodeUrlForLegacyMatch($requested_url); |
| 486 |
if (function_exists('get_transient') && function_exists('set_transient')) { |
| 487 |
$warnKey = 'abj404_warned_logs_charset_mismatch'; |
| 488 |
$warnVal = $logTableName . '|' . strtolower($requestedUrlCharset); |
| 489 |
$already = get_transient($warnKey); |
| 490 |
if ($already !== $warnVal) { |
| 491 |
$ttl = defined('WEEK_IN_SECONDS') ? WEEK_IN_SECONDS : 604800; |
| 492 |
// @cache-write-audit: opt-out - charset warning dedup marker, not query result data. |
| 493 |
set_transient($warnKey, $warnVal, $ttl); |
| 494 |
$this->logger->warn("Logs table column charset is '{$requestedUrlCharset}' for {$logTableName}. URL-encoding stored requested URLs to avoid charset issues."); |
| 495 |
} |
| 496 |
} |
| 497 |
} |
| 498 |
} catch (Exception $e) { |
| 499 |
$this->logger->debugMessage(__FUNCTION__ . " error. Issue getting character set for table: " . $logTableName . ", column: requested_url. Error message: " . $e->getMessage()); |
| 500 |
} |
| 501 |
|
| 502 |
$options = $abj404logic->getOptions(true); |
| 503 |
$referer = function_exists('wp_get_referer') ? wp_get_referer() : ''; |
| 504 |
if ($referer !== null && $referer !== false) { |
| 505 |
$referer = function_exists('esc_url_raw') ? esc_url_raw($referer) : (string)$referer; |
| 506 |
$referer = substr($referer, 0, 512); |
| 507 |
} else { |
| 508 |
$referer = ''; |
| 509 |
} |
| 510 |
$current_user = function_exists('wp_get_current_user') |
| 511 |
? ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user()) |
| 512 |
: null; |
| 513 |
$current_user_name = $current_user !== null ? $current_user->getLogin() : ''; |
| 514 |
$remoteAddrRaw = $_SERVER['REMOTE_ADDR'] ?? ''; |
| 515 |
$ipAddressToSave = is_string($remoteAddrRaw) ? $remoteAddrRaw : ''; |
| 516 |
$ipAddressToSave = filter_var($ipAddressToSave, FILTER_VALIDATE_IP) |
| 517 |
? (function_exists('esc_sql') ? esc_sql($ipAddressToSave) : $ipAddressToSave) |
| 518 |
: ''; |
| 519 |
if (!array_key_exists('log_raw_ips', $options) || $options['log_raw_ips'] != '1') { |
| 520 |
$ipAddressToSave = $this->f->md5lastOctet($ipAddressToSave); |
| 521 |
} |
| 522 |
if (!empty($ipAddressToSave)) { |
| 523 |
$ipAddressToSave = substr($ipAddressToSave, 0, 512); |
| 524 |
} else { |
| 525 |
$ipAddressToSave = '(Unknown)'; |
| 526 |
} |
| 527 |
|
| 528 |
$minLogID = false; |
| 529 |
$comparisonCollation = $this->dbCore->sanitizeCollationIdentifier(isset($requestedUrlCollation) ? (string)$requestedUrlCollation : ''); |
| 530 |
if ($comparisonCollation === '' || stripos($comparisonCollation, 'utf8mb4') === false) { |
| 531 |
$comparisonCollation = $this->dbCore->getPreferredUtf8mb4Collation(); |
| 532 |
} |
| 533 |
$requestedUrlCharsetLower = isset($requestedUrlCharset) ? strtolower((string)$requestedUrlCharset) : ''; |
| 534 |
$canUseUtf8Cast = ($requestedUrlCharsetLower === '' || strpos($requestedUrlCharsetLower, 'utf8') !== false); |
| 535 |
if ($canUseUtf8Cast) { |
| 536 |
$checkMinIDSql = "SELECT id FROM `" . $logTableName . "` \n WHERE CAST(requested_url AS CHAR CHARACTER SET utf8mb4) COLLATE " . $comparisonCollation . " = %s \n LIMIT 1"; |
| 537 |
} else { |
| 538 |
$checkMinIDSql = "SELECT id FROM `" . $logTableName . "` \n WHERE requested_url = %s \n LIMIT 1"; |
| 539 |
} |
| 540 |
$primaryResult = $this->dbCore->queryAndGetResults($checkMinIDSql, array('query_params' => array($requested_url), 'log_errors' => false)); |
| 541 |
$checkMinIDQueryResults = is_array($primaryResult['rows'] ?? null) ? $primaryResult['rows'] : array(); |
| 542 |
$lastErrorRaw = $primaryResult['last_error'] ?? ''; |
| 543 |
$lastError = is_string($lastErrorRaw) ? $lastErrorRaw : ''; |
| 544 |
if ($lastError !== '' && $this->dbCore->isInvalidDataError($lastError) && $canUseUtf8Cast) { |
| 545 |
$fallbackResult = $this->dbCore->queryAndGetResults("SELECT id FROM `" . $logTableName . "` \n WHERE requested_url = %s \n LIMIT 1", array('query_params' => array($requested_url), 'log_errors' => false)); |
| 546 |
$checkMinIDQueryResults = is_array($fallbackResult['rows'] ?? null) ? $fallbackResult['rows'] : array(); |
| 547 |
} |
| 548 |
if (empty($checkMinIDQueryResults)) { $minLogID = true; } |
| 549 |
|
| 550 |
if (trim($action) != "404") { |
| 551 |
$action = function_exists('esc_url_raw') ? esc_url_raw($action) : $action; |
| 552 |
} |
| 553 |
|
| 554 |
$helperFunctions = abj_service('functions'); |
| 555 |
$reasonMessage = trim(implode(", ", array_filter(array(abj_service('request_context')->ignore_doprocess ?: '', abj_service('request_context')->ignore_donotprocess ?: '')))); |
| 556 |
$permalinksKept = '(not set)'; |
| 557 |
$ctx = abj_service('request_context'); |
| 558 |
if ($this->logger->isDebug() && !empty($ctx->permalinks_found)) { |
| 559 |
$permalinksKept = $ctx->permalinks_kept; |
| 560 |
} |
| 561 |
$requestUri = is_string($_SERVER['REQUEST_URI'] ?? '') ? (string)($_SERVER['REQUEST_URI'] ?? '') : ''; |
| 562 |
$escapeHtml = function($value) { |
| 563 |
return function_exists('esc_html') ? esc_html($value) : htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); |
| 564 |
}; |
| 565 |
$this->logger->debugMessage("Logging redirect. Referer: " . $escapeHtml($referer) . " | Current user: " . $current_user_name . " | From: " . $helperFunctions->normalizeUrlString($requestUri) . $escapeHtml(" to: ") . $escapeHtml($action) . ', Reason: ' . $matchReason . ", Ignore msg(s): " . $reasonMessage . ', Execution time: ' . round((float)$helperFunctions->getExecutionTime(), 2) . ' seconds, permalinks found: ' . $permalinksKept); |
| 566 |
|
| 567 |
$usernameLookupID = $this->insertLookupValueAndGetID($current_user_name); |
| 568 |
|
| 569 |
$reqUrlForLog = function_exists('esc_url_raw') ? esc_url_raw($requested_url) : $requested_url; |
| 570 |
$reqUrlForLogStr = is_string($reqUrlForLog) ? $reqUrlForLog : ''; |
| 571 |
$this->queueLogEntry([ |
| 572 |
'timestamp' => $now, 'user_ip' => $ipAddressToSave, 'referrer' => $referer, |
| 573 |
'dest_url' => $action, 'requested_url' => $reqUrlForLogStr, |
| 574 |
'requested_url_detail' => $requestedURLDetail, 'username' => $usernameLookupID, |
| 575 |
'min_log_id' => $minLogID, 'engine' => substr($matchReason, 0, 64), |
| 576 |
'pipeline_trace' => $this->serializePipelineTrace($pipelineTrace), |
| 577 |
'canonical_url' => '/' . trim($reqUrlForLogStr, '/'), |
| 578 |
]); |
| 579 |
} |
| 580 |
|
| 581 |
/** @inheritDoc */ |
| 582 |
function queueLogEntry(array $entry): void { |
| 583 |
self::$logQueue[] = $entry; |
| 584 |
if (!self::$shutdownHookRegistered) { |
| 585 |
self::$shutdownHookRegistered = true; |
| 586 |
add_action('shutdown', [$this, 'flushLogQueue'], 9); |
| 587 |
} |
| 588 |
} |
| 589 |
|
| 590 |
/** @inheritDoc */ |
| 591 |
function flushLogQueue(): void { |
| 592 |
if (self::$isFlushingLogQueue) { return; } |
| 593 |
self::$isFlushingLogQueue = true; |
| 594 |
if (empty(self::$logQueue)) { |
| 595 |
self::$shutdownHookRegistered = false; |
| 596 |
self::$isFlushingLogQueue = false; |
| 597 |
return; |
| 598 |
} |
| 599 |
|
| 600 |
global $wpdb; |
| 601 |
$tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}'); |
| 602 |
|
| 603 |
$columns = array_keys(self::$logQueue[0]); |
| 604 |
$validatedColumns = []; |
| 605 |
foreach ($columns as $col) { |
| 606 |
if (preg_match('/^[a-z_][a-z0-9_]*$/i', $col)) { $validatedColumns[] = $col; } |
| 607 |
} |
| 608 |
$schemaColumns = $this->dbCore->getTableColumnNames($tableName); |
| 609 |
if (!empty($schemaColumns)) { $validatedColumns = array_intersect($validatedColumns, $schemaColumns); } |
| 610 |
if (empty($validatedColumns)) { |
| 611 |
self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false; |
| 612 |
return; |
| 613 |
} |
| 614 |
|
| 615 |
$columnList = '`' . implode('`, `', $validatedColumns) . '`'; |
| 616 |
$sanitizedEntries = []; |
| 617 |
foreach (self::$logQueue as $entry) { |
| 618 |
foreach ($entry as $val) { |
| 619 |
if (is_object($val) || is_array($val)) { break; } |
| 620 |
} |
| 621 |
$entryColumns = array_keys($entry); |
| 622 |
$missingCols = array_diff($validatedColumns, $entryColumns); |
| 623 |
if (!empty($missingCols)) { continue; } |
| 624 |
$sanitized = $this->sanitizeLogEntry($entry); |
| 625 |
if ($sanitized === null) { continue; } |
| 626 |
$sanitizedEntries[] = $sanitized; |
| 627 |
} |
| 628 |
if (empty($sanitizedEntries)) { |
| 629 |
self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false; |
| 630 |
return; |
| 631 |
} |
| 632 |
|
| 633 |
$formats = []; |
| 634 |
$flattenedValues = []; |
| 635 |
foreach ($sanitizedEntries as $entry) { |
| 636 |
$rowFormats = []; |
| 637 |
foreach ($validatedColumns as $col) { |
| 638 |
$value = $entry[$col]; |
| 639 |
if ($value === null) { $rowFormats[] = 'NULL'; continue; } |
| 640 |
if (is_int($value)) { $rowFormats[] = '%d'; } else { $rowFormats[] = '%s'; } |
| 641 |
$flattenedValues[] = $value; |
| 642 |
} |
| 643 |
$formats[] = '(' . implode(', ', $rowFormats) . ')'; |
| 644 |
} |
| 645 |
|
| 646 |
$sql = "INSERT IGNORE INTO `{$tableName}` ({$columnList}) VALUES " . implode(', ', $formats); |
| 647 |
$prepared = $wpdb->prepare($sql, $flattenedValues); |
| 648 |
$wpdb->flush(); |
| 649 |
$result = $wpdb->query($prepared); |
| 650 |
|
| 651 |
if ($result === false && !empty($wpdb->last_error)) { |
| 652 |
$batchError = $wpdb->last_error; |
| 653 |
|
| 654 |
if ($this->isTableFullError($batchError)) { |
| 655 |
$trimmed = $this->autoTrimLogsv2IfNeeded($tableName, $batchError); |
| 656 |
if ($trimmed) { |
| 657 |
$wpdb->flush(); |
| 658 |
$retryResult = $wpdb->query($prepared); |
| 659 |
if ($retryResult !== false) { |
| 660 |
self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false; |
| 661 |
return; |
| 662 |
} |
| 663 |
$batchError = $wpdb->last_error; |
| 664 |
} |
| 665 |
$this->setLogsv2FullNotice($batchError); |
| 666 |
} |
| 667 |
|
| 668 |
if ($this->isCommandsOutOfSyncError($batchError)) { |
| 669 |
$isolated = $this->getIsolatedWpdb(); |
| 670 |
if ($isolated !== null) { |
| 671 |
$isolated->flush(); |
| 672 |
$isolatedPrepared = $isolated->prepare($sql, $flattenedValues); |
| 673 |
$isolatedResult = $isolated->query($isolatedPrepared !== null ? $isolatedPrepared : $sql); |
| 674 |
if ($isolatedResult !== false) { |
| 675 |
self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false; |
| 676 |
$context = $this->getWpdbRecentQueryContextForLogs(); |
| 677 |
$suffix = ($context !== '') ? " | savequeries_context={$context}" : ''; |
| 678 |
$this->logger->warn("flushLogQueue batch INSERT succeeded using isolated DB connection (commands out of sync on shared connection).{$suffix}"); |
| 679 |
return; |
| 680 |
} |
| 681 |
$batchError .= " | isolated_error=" . $isolated->last_error; |
| 682 |
} else { |
| 683 |
$batchError .= " | isolated_error=no_isolated_connection"; |
| 684 |
} |
| 685 |
} |
| 686 |
|
| 687 |
$successCount = 0; $failCount = 0; $failureDetails = []; |
| 688 |
foreach ($sanitizedEntries as $index => $entry) { |
| 689 |
$rowFormats = []; $rowValues = []; |
| 690 |
foreach ($validatedColumns as $col) { |
| 691 |
$value = $entry[$col]; |
| 692 |
if ($value === null) { $rowFormats[] = 'NULL'; } else { $rowFormats[] = is_int($value) ? '%d' : '%s'; $rowValues[] = $value; } |
| 693 |
} |
| 694 |
$rowPlaceholder = '(' . implode(', ', $rowFormats) . ')'; |
| 695 |
/** @var literal-string $singleSqlTemplate */ |
| 696 |
$singleSqlTemplate = "INSERT IGNORE INTO `{$tableName}` ({$columnList}) VALUES {$rowPlaceholder}"; |
| 697 |
$singleSql = $wpdb->prepare($singleSqlTemplate, $rowValues); |
| 698 |
$wpdb->flush(); |
| 699 |
$singleResult = $wpdb->query((string)$singleSql); |
| 700 |
|
| 701 |
if ($singleResult === false && !empty($wpdb->last_error)) { |
| 702 |
$lastError = $wpdb->last_error; |
| 703 |
if ($this->isCommandsOutOfSyncError($wpdb->last_error)) { |
| 704 |
$isolated = $this->getIsolatedWpdb(); |
| 705 |
if ($isolated !== null) { |
| 706 |
$isolated->flush(); |
| 707 |
$isolatedSingleSql = $isolated->prepare($singleSqlTemplate, $rowValues); |
| 708 |
$isolatedSingleResult = $isolated->query((string)$isolatedSingleSql); |
| 709 |
if ($isolatedSingleResult !== false) { $successCount++; continue; } |
| 710 |
$lastError = $lastError . " | isolated_error=" . $isolated->last_error; |
| 711 |
} else { |
| 712 |
$lastError = $lastError . " | isolated_error=no_isolated_connection"; |
| 713 |
} |
| 714 |
} |
| 715 |
$failCount++; |
| 716 |
$payload = function_exists('wp_json_encode') ? wp_json_encode($entry) : json_encode($entry); |
| 717 |
if (is_string($payload) && strlen($payload) > 1024) { $payload = substr($payload, 0, 1024) . '...'; } |
| 718 |
$failureDetails[] = ['index' => $index, 'error' => $lastError, 'payload' => $payload]; |
| 719 |
} else { |
| 720 |
$successCount++; |
| 721 |
} |
| 722 |
} |
| 723 |
|
| 724 |
if ($failCount > 0) { |
| 725 |
$detailsParts = []; |
| 726 |
foreach (array_slice($failureDetails, 0, 3) as $detail) { |
| 727 |
$detailsParts[] = "entry {$detail['index']}: {$detail['error']} | payload={$detail['payload']}"; |
| 728 |
} |
| 729 |
$detailsSuffix = count($failureDetails) > 3 ? ' | (additional failures omitted)' : ''; |
| 730 |
$context = $this->getWpdbRecentQueryContextForLogs(); |
| 731 |
$contextSuffix = ($context !== '') ? (" | savequeries_context=" . $context) : ''; |
| 732 |
if ($this->dbCore->classifyAndHandleInfrastructureError($batchError)) { |
| 733 |
$this->logger->warn("flushLogQueue recovery incomplete: {$successCount} inserted, {$failCount} failed. | batch_error=" . $batchError . " | failures=" . implode(' || ', $detailsParts) . $detailsSuffix . $contextSuffix); |
| 734 |
} else { |
| 735 |
$this->logger->errorMessage("flushLogQueue recovery incomplete: {$successCount} inserted, {$failCount} failed. | batch_error=" . $batchError . " | failures=" . implode(' || ', $detailsParts) . $detailsSuffix . $contextSuffix); |
| 736 |
} |
| 737 |
} else { |
| 738 |
$this->logger->warn("flushLogQueue batch INSERT failed but recovered: all {$successCount} entries inserted individually. | batch_error=" . $batchError); |
| 739 |
} |
| 740 |
} |
| 741 |
|
| 742 |
self::$logQueue = []; self::$shutdownHookRegistered = false; self::$isFlushingLogQueue = false; |
| 743 |
} |
| 744 |
|
| 745 |
private function isCommandsOutOfSyncError(string $error): bool { |
| 746 |
return stripos($error, 'commands out of sync') !== false; |
| 747 |
} |
| 748 |
|
| 749 |
/** @param string $error @return bool */ |
| 750 |
public function isTableFullError(string $error): bool { |
| 751 |
$lower = strtolower($error); |
| 752 |
return stripos($lower, 'is full') !== false || stripos($lower, 'table full') !== false; |
| 753 |
} |
| 754 |
|
| 755 |
/** @param string $tableName @param string $errorMessage @return bool */ |
| 756 |
public function autoTrimLogsv2IfNeeded(string $tableName, string $errorMessage): bool { |
| 757 |
if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName) || strpos($tableName, 'abj404_logsv2') === false) { |
| 758 |
$this->logger->warn("autoTrimLogsv2IfNeeded: rejected unexpected table name: " . substr($tableName, 0, 100)); |
| 759 |
return false; |
| 760 |
} |
| 761 |
$cooldownKey = 'abj404_logsv2_trim_cooldown_until'; |
| 762 |
$alreadyTrimmed = function_exists('get_transient') ? get_transient($cooldownKey) : false; |
| 763 |
if ($alreadyTrimmed) { return false; } |
| 764 |
global $wpdb; |
| 765 |
$trimSql = "DELETE FROM `{$tableName}` ORDER BY timestamp ASC LIMIT 1000"; |
| 766 |
$wpdb->query($trimSql); |
| 767 |
$ttl = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600; |
| 768 |
// @cache-write-audit: opt-out - log-trim cooldown marker, not query result data. |
| 769 |
if (function_exists('set_transient')) { set_transient($cooldownKey, 1, $ttl); } |
| 770 |
if (!empty($wpdb->last_error)) { |
| 771 |
$this->logger->warn("Log table full: auto-trim failed: " . $wpdb->last_error); |
| 772 |
} else { |
| 773 |
$this->logger->warn("Log table full: auto-trimmed 1000 oldest entries to free space."); |
| 774 |
} |
| 775 |
return true; |
| 776 |
} |
| 777 |
|
| 778 |
/** @param string $errorMessage @return void */ |
| 779 |
private function setLogsv2FullNotice(string $errorMessage): void { |
| 780 |
$message = $this->dbCore->localizeOrDefault('The 404 Solution log table is full and cannot accept new entries. This is usually caused by a full disk. Please contact your host or manually prune the logs table.'); |
| 781 |
$this->dbCore->setPluginDbNotice('log_table_full', $message, $errorMessage); |
| 782 |
} |
| 783 |
|
| 784 |
/** @return wpdb|null */ |
| 785 |
public function getIsolatedWpdb(): ?wpdb { |
| 786 |
static $isolated = null; |
| 787 |
if ($isolated !== null) { return $isolated; } |
| 788 |
if (!class_exists('wpdb')) { return null; } |
| 789 |
if (!defined('DB_USER') || !defined('DB_PASSWORD') || !defined('DB_NAME') || !defined('DB_HOST')) { |
| 790 |
static $warnedNoDbConsts = false; |
| 791 |
if (!$warnedNoDbConsts) { $warnedNoDbConsts = true; $this->logger->warn(__METHOD__ . ': DB_USER/DB_PASSWORD/DB_NAME/DB_HOST undefined; isolated wpdb unavailable'); } |
| 792 |
return null; |
| 793 |
} |
| 794 |
// phpcs:ignore WordPress.DB.RestrictedClasses.mysql__wpdb |
| 795 |
$isolated = new wpdb(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST); |
| 796 |
$isolated->show_errors(false); |
| 797 |
$isolated->suppress_errors(true); |
| 798 |
return $isolated; |
| 799 |
} |
| 800 |
|
| 801 |
/** @return string */ |
| 802 |
private function getWpdbRecentQueryContextForLogs(): string { |
| 803 |
global $wpdb; |
| 804 |
if (!isset($wpdb) || !is_object($wpdb)) { return ''; } |
| 805 |
if (!defined('SAVEQUERIES') || SAVEQUERIES !== true) { return ''; } |
| 806 |
if (empty($wpdb->queries) || !is_array($wpdb->queries)) { return ''; } |
| 807 |
$recent = array_slice($wpdb->queries, -5); |
| 808 |
$parts = []; |
| 809 |
foreach ($recent as $q) { |
| 810 |
$sql = $q[0] ?? ''; $time = $q[1] ?? null; $caller = $q[2] ?? ''; |
| 811 |
$hash = is_string($sql) ? substr(sha1($sql), 0, 10) : 'n/a'; |
| 812 |
$who = $this->extractWpComponentFromString(is_string($caller) ? $caller : ''); |
| 813 |
$t = is_numeric($time) ? round((float)$time, 3) : 'n/a'; |
| 814 |
$parts[] = "{$who}:{$hash}@{$t}"; |
| 815 |
} |
| 816 |
return implode(', ', $parts); |
| 817 |
} |
| 818 |
|
| 819 |
private function extractWpComponentFromString(string $text): string { |
| 820 |
$normalized = str_replace('\\', '/', $text); |
| 821 |
foreach (array('/wp-content/mu-plugins/' => 'mu-plugin', '/wp-content/plugins/' => 'plugin', '/wp-content/themes/' => 'theme') as $needle => $label) { |
| 822 |
$pos = strpos($normalized, $needle); |
| 823 |
if ($pos !== false) { |
| 824 |
$rest = substr($normalized, $pos + strlen($needle)); |
| 825 |
$name = explode('/', ltrim($rest, '/'))[0] ?? ''; |
| 826 |
return $name !== '' ? "{$label}:{$name}" : "{$label}:unknown"; |
| 827 |
} |
| 828 |
} |
| 829 |
return 'unknown'; |
| 830 |
} |
| 831 |
|
| 832 |
/** @param array<string, mixed> $entry @return array<string, mixed>|null */ |
| 833 |
public function sanitizeLogEntry(array $entry): ?array { |
| 834 |
$required = array('timestamp', 'user_ip', 'referrer', 'dest_url', 'requested_url', 'requested_url_detail', 'username', 'min_log_id', 'engine'); |
| 835 |
foreach ($required as $key) { if (!array_key_exists($key, $entry)) { return null; } } |
| 836 |
$normalizeString = function($value, $maxLen) { |
| 837 |
if (is_object($value) || is_array($value)) { return null; } |
| 838 |
$str = (string)$value; |
| 839 |
if (function_exists('mb_convert_encoding')) { $str = mb_convert_encoding($str, 'UTF-8', 'UTF-8'); } |
| 840 |
return substr($str, 0, $maxLen); |
| 841 |
}; |
| 842 |
$sanitized = array(); |
| 843 |
$tsVal = $entry['timestamp'] ?? time(); |
| 844 |
$sanitized['timestamp'] = absint(is_scalar($tsVal) ? $tsVal : time()); |
| 845 |
$sanitized['user_ip'] = $normalizeString($entry['user_ip'], 512); |
| 846 |
$sanitized['referrer'] = $normalizeString($entry['referrer'], 512); |
| 847 |
$sanitized['dest_url'] = $normalizeString($entry['dest_url'], 512); |
| 848 |
$sanitized['requested_url'] = $normalizeString($entry['requested_url'], 2048); |
| 849 |
$sanitized['requested_url_detail'] = $normalizeString($entry['requested_url_detail'], 2048); |
| 850 |
$reqUrlSafe = is_string($sanitized['requested_url']) ? $sanitized['requested_url'] : ''; |
| 851 |
if (array_key_exists('canonical_url', $entry) && is_string($entry['canonical_url'])) { $canonical = $entry['canonical_url']; } else { $canonical = '/' . trim($reqUrlSafe, '/'); } |
| 852 |
$sanitized['canonical_url'] = substr($canonical, 0, 2048); |
| 853 |
$usernameVal = $entry['username'] ?? null; |
| 854 |
$sanitized['username'] = ($usernameVal === null || !is_scalar($usernameVal)) ? null : absint($usernameVal); |
| 855 |
$minLogIdVal = $entry['min_log_id'] ?? null; |
| 856 |
$sanitized['min_log_id'] = ($minLogIdVal === null || !is_scalar($minLogIdVal)) ? null : absint($minLogIdVal); |
| 857 |
$sanitized['engine'] = $normalizeString($entry['engine'], 64); |
| 858 |
if (array_key_exists('pipeline_trace', $entry)) { |
| 859 |
$traceVal = $entry['pipeline_trace']; |
| 860 |
$sanitized['pipeline_trace'] = ($traceVal === null || is_string($traceVal)) ? $traceVal : null; |
| 861 |
} else { |
| 862 |
$sanitized['pipeline_trace'] = null; |
| 863 |
} |
| 864 |
if ($sanitized['requested_url'] === '' || $sanitized['dest_url'] === '') { return null; } |
| 865 |
return $sanitized; |
| 866 |
} |
| 867 |
|
| 868 |
/** @param array<int, array{step: string, outcome: string, detail: string}>|null $trace @return string|null */ |
| 869 |
private function serializePipelineTrace(?array $trace): ?string { |
| 870 |
if ($trace === null || empty($trace)) { return null; } |
| 871 |
$json = json_encode($trace); |
| 872 |
if ($json === false) { return null; } |
| 873 |
$compressed = gzcompress($json, 6); |
| 874 |
if ($compressed === false) { return null; } |
| 875 |
return base64_encode($compressed); |
| 876 |
} |
| 877 |
|
| 878 |
/** @inheritDoc */ |
| 879 |
public static function decompressPipelineTrace(?string $raw): ?array { |
| 880 |
if ($raw === null || $raw === '') { return null; } |
| 881 |
$decoded = base64_decode($raw, true); |
| 882 |
if ($decoded === false) { return null; } |
| 883 |
$json = @gzuncompress($decoded); |
| 884 |
if ($json === false) { return null; } |
| 885 |
$result = json_decode($json, true); |
| 886 |
return is_array($result) ? $result : null; |
| 887 |
} |
| 888 |
|
| 889 |
// ========================================================================= |
| 890 |
// Lookup table (from DataAccessTrait_Logs + Maintenance) |
| 891 |
// ========================================================================= |
| 892 |
|
| 893 |
/** @inheritDoc */ |
| 894 |
function insertLookupValueAndGetID($valueToInsert) { |
| 895 |
global $wpdb; |
| 896 |
$query = "INSERT INTO {wp_abj404_lookup} (lkup_value) VALUES (%s) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"; |
| 897 |
$this->dbCore->queryAndGetResults($query, array('query_params' => array($valueToInsert))); |
| 898 |
return intval($wpdb->insert_id); |
| 899 |
} |
| 900 |
|
| 901 |
/** @inheritDoc */ |
| 902 |
function getLookupIDForUser($userName) { |
| 903 |
$query = "select id from {wp_abj404_lookup} where lkup_value = %s"; |
| 904 |
$results = $this->dbCore->queryAndGetResults($query, array('query_params' => array($userName))); |
| 905 |
$lookupRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 906 |
if (count($lookupRows) > 0) { |
| 907 |
$row1 = is_array($lookupRows[0]) ? $lookupRows[0] : array(); |
| 908 |
$id = isset($row1['id']) ? $row1['id'] : 0; |
| 909 |
return is_scalar($id) ? intval($id) : 0; |
| 910 |
} |
| 911 |
return -1; |
| 912 |
} |
| 913 |
|
| 914 |
/** @inheritDoc */ |
| 915 |
public function correctDuplicateLookupValues(): void { |
| 916 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/correctLookupTableIssue.sql"); |
| 917 |
$this->dbCore->queryAndGetResults($query, array('log_errors' => false, 'skip_repair' => true)); |
| 918 |
} |
| 919 |
|
| 920 |
/** @inheritDoc */ |
| 921 |
public function getDailyActivityTrend(int $days = 30): array { |
| 922 |
$days = max(1, min(90, $days)); |
| 923 |
$blogId = 1; |
| 924 |
if (function_exists('get_current_blog_id')) { |
| 925 |
$blogId = function_exists('absint') ? absint(get_current_blog_id()) : abs(intval(get_current_blog_id())); |
| 926 |
if ($blogId <= 0) { $blogId = 1; } |
| 927 |
} |
| 928 |
$maxLogId = 0; |
| 929 |
try { |
| 930 |
$maxLogId = intval($this->getMaxLogId()); |
| 931 |
if ($maxLogId < 0) { $maxLogId = 0; } |
| 932 |
} catch (Throwable $e) { |
| 933 |
$this->logger->debugMessage(__FUNCTION__ . ' getMaxLogId() failed: ' . $e->getMessage() . '. Falling back to maxLogId=0 (cache key uses 0).'); |
| 934 |
$maxLogId = 0; |
| 935 |
} |
| 936 |
$cacheKey = 'abj404_trend_v1_' . $blogId . '_' . $days . '_' . $maxLogId; |
| 937 |
if (function_exists('get_transient')) { $cached = get_transient($cacheKey); if (is_array($cached)) { return $cached; } } |
| 938 |
$logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}'); |
| 939 |
$cutoff = time() - ($days * 86400); |
| 940 |
$notFoundDest = '404'; |
| 941 |
$query = "SELECT DATE(FROM_UNIXTIME(`timestamp`)) AS `date`, SUM(CASE WHEN `dest_url` = %s THEN 1 ELSE 0 END) AS `hits_404`, SUM(CASE WHEN `dest_url` <> %s THEN 1 ELSE 0 END) AS `hits_redirect` FROM " . $logsTable . " WHERE `timestamp` >= " . intval($cutoff) . " GROUP BY DATE(FROM_UNIXTIME(`timestamp`)) ORDER BY `date` ASC"; |
| 942 |
$result = $this->dbCore->queryAndGetResults($query, array('query_params' => array($notFoundDest, $notFoundDest))); |
| 943 |
$hadError = !empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] !== ''); |
| 944 |
$rows = (isset($result['rows']) && is_array($result['rows'])) ? $result['rows'] : array(); |
| 945 |
$byDate = array(); |
| 946 |
foreach ($rows as $row) { |
| 947 |
if (!is_array($row)) { continue; } |
| 948 |
$d = isset($row['date']) ? (string)$row['date'] : ''; |
| 949 |
if ($d === '') { continue; } |
| 950 |
$byDate[$d] = array('date' => $d, 'hits_404' => intval($row['hits_404'] ?? 0), 'hits_redirect' => intval($row['hits_redirect'] ?? 0), 'new_captures' => intval($row['hits_404'] ?? 0)); |
| 951 |
} |
| 952 |
$output = array(); |
| 953 |
for ($i = $days - 1; $i >= 0; $i--) { |
| 954 |
$d = date('Y-m-d', time() - ($i * 86400)); |
| 955 |
$output[] = isset($byDate[$d]) ? $byDate[$d] : array('date' => $d, 'hits_404' => 0, 'hits_redirect' => 0, 'new_captures' => 0); |
| 956 |
} |
| 957 |
if (!$hadError && function_exists('set_transient')) { set_transient($cacheKey, $output, self::TREND_DATA_CACHE_TTL_SECONDS); } |
| 958 |
return $output; |
| 959 |
} |
| 960 |
|
| 961 |
// ========================================================================= |
| 962 |
// Collation resolution for hits rebuild (Phase 1, c632) |
| 963 |
// ========================================================================= |
| 964 |
|
| 965 |
/** |
| 966 |
* Resolve the collation from the abj404_redirects.canonical_url column, |
| 967 |
* the actual join partner for the hits rebuild phase2 JOIN. |
| 968 |
* |
| 969 |
* Falls back to utf8mb4_unicode_ci if the column query fails. |
| 970 |
* |
| 971 |
* @return string Sanitized collation identifier. |
| 972 |
*/ |
| 973 |
public function resolveHitsJoinCollation(): string { |
| 974 |
$redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}'); |
| 975 |
return $this->dbCore->getColumnCollationString($redirectsTable, 'canonical_url'); |
| 976 |
} |
| 977 |
|
| 978 |
// ========================================================================= |
| 979 |
// Hits table rebuild (from DataAccessTrait_LogsHitsRebuild) |
| 980 |
// ========================================================================= |
| 981 |
|
| 982 |
/** @inheritDoc */ |
| 983 |
function recordLogsHitsRollupStalenessSignal(): void { |
| 984 |
$currentMaxLogId = $this->getMaxLogId(); |
| 985 |
$storedMaxLogId = $this->getStoredMaxLogId(); |
| 986 |
if ($currentMaxLogId <= $storedMaxLogId) { $this->clearLogsHitsRollupStaleSignal(); return; } |
| 987 |
$rawFirstStale = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); |
| 988 |
$firstStale = is_scalar($rawFirstStale) ? (int)$rawFirstStale : 0; |
| 989 |
if ($firstStale <= 0) { $this->dbCore->setRuntimeFlag(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG, time(), 86400); return; } |
| 990 |
$age = time() - $firstStale; |
| 991 |
if ($age >= self::HITS_TABLE_STALE_NOTICE_THRESHOLD_SECONDS) { $this->setLogsHitsRollupStaleNotice($age); } |
| 992 |
} |
| 993 |
|
| 994 |
private function clearLogsHitsRollupStaleSignal(): void { |
| 995 |
if (function_exists('delete_transient')) { delete_transient(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); delete_transient(self::HITS_TABLE_STALE_NOTICE_TRANSIENT); return; } |
| 996 |
if (function_exists('delete_option')) { delete_option(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); delete_option(self::HITS_TABLE_STALE_NOTICE_TRANSIENT); } |
| 997 |
} |
| 998 |
|
| 999 |
/** @param int $ageSeconds @return void */ |
| 1000 |
private function setLogsHitsRollupStaleNotice(int $ageSeconds): void { |
| 1001 |
if (!function_exists('set_transient')) { return; } |
| 1002 |
$key = self::HITS_TABLE_STALE_NOTICE_TRANSIENT; |
| 1003 |
if (function_exists('get_transient') && get_transient($key) !== false) { return; } |
| 1004 |
$hours = max(1, intval(floor($ageSeconds / 3600))); |
| 1005 |
$template = $this->dbCore->localizeOrDefault('The 404 Solution redirects-hits rollup has been behind MAX(logsv2.id) for at least %d hour(s). The cron-driven rebuild event (abj404_updateLogsHitsTableAction) does not appear to be firing, so the redirects list will show stale "hits" and "last hit" columns until cron resumes. To resolve: if DISABLE_WP_CRON is set in wp-config.php either remove it, or configure a system cron job that requests wp-cron.php periodically. To force a rebuild right now in your browser, open the 404 Solution Redirects page with ?abj404_force_view_rebuild=1 appended to the URL.'); |
| 1006 |
$payload = array('type' => 'logs_hits_rollup_stale', 'message' => sprintf($template, $hours), 'timestamp' => time(), 'error_string' => '', 'age_hours' => $hours); |
| 1007 |
// allow-cache-empty: intentional notice payload; error_string is empty by definition for stale-rollup state. |
| 1008 |
set_transient($key, $payload, 86400); |
| 1009 |
} |
| 1010 |
|
| 1011 |
/** @inheritDoc */ |
| 1012 |
function hitsTableNeedsRebuild() { |
| 1013 |
$storedMaxId = $this->getStoredMaxLogId(); |
| 1014 |
$currentMaxId = $this->getMaxLogId(); |
| 1015 |
if ($currentMaxId != $storedMaxId) { $this->logger->debugMessage(__FUNCTION__ . " rebuild=yes (max_id changed: stored=$storedMaxId, current=$currentMaxId)"); return true; } |
| 1016 |
$lastUpdated = $this->getLogsHitsTableLastUpdated(); |
| 1017 |
if ($lastUpdated !== null) { $age = time() - $lastUpdated; if ($age > self::HITS_TABLE_MAX_AGE_SECONDS) { $this->logger->debugMessage(__FUNCTION__ . " rebuild=yes (stale: age={$age}s > " . self::HITS_TABLE_MAX_AGE_SECONDS . "s)"); return true; } } |
| 1018 |
$this->logger->debugMessage(__FUNCTION__ . " rebuild=no (max_id=$currentMaxId unchanged, not stale)"); |
| 1019 |
return false; |
| 1020 |
} |
| 1021 |
|
| 1022 |
/** @inheritDoc */ |
| 1023 |
function getLogsHitsTableLastUpdated() { |
| 1024 |
$rawRefreshedFlag = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_REFRESHED_FLAG); |
| 1025 |
$runtimeRefreshedAt = is_scalar($rawRefreshedFlag) ? (int)$rawRefreshedFlag : 0; |
| 1026 |
$runtimeRefreshedAt = $runtimeRefreshedAt > 0 ? $runtimeRefreshedAt : null; |
| 1027 |
$query = "SELECT create_time FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()"; |
| 1028 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 1029 |
$results = $this->dbCore->queryAndGetResults($query); |
| 1030 |
if ($results['rows'] == null || empty($results['rows'])) { |
| 1031 |
if (!empty($results['last_error'])) { |
| 1032 |
$statusRow = $this->getLogsHitsTableStatusRow(); |
| 1033 |
$dateValue = is_array($statusRow) ? ($statusRow['update_time'] ?? ($statusRow['create_time'] ?? '')) : ''; |
| 1034 |
if ($dateValue !== '') { $fallbackTimestamp = strtotime(is_string($dateValue) ? $dateValue : ''); if ($fallbackTimestamp !== false) { if ($runtimeRefreshedAt !== null && $runtimeRefreshedAt > $fallbackTimestamp) { return $runtimeRefreshedAt; } return $fallbackTimestamp; } } |
| 1035 |
} |
| 1036 |
return $runtimeRefreshedAt; |
| 1037 |
} |
| 1038 |
$hitsRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 1039 |
$row = is_array($hitsRows[0] ?? null) ? $hitsRows[0] : array(); |
| 1040 |
$row = array_change_key_case($row); |
| 1041 |
$createTime = $row['create_time'] ?? null; |
| 1042 |
if ($createTime === null) { return $runtimeRefreshedAt; } |
| 1043 |
$schemaTimestamp = strtotime(is_string($createTime) ? $createTime : ''); |
| 1044 |
if ($schemaTimestamp === false) { return $runtimeRefreshedAt; } |
| 1045 |
if ($runtimeRefreshedAt !== null && $runtimeRefreshedAt > $schemaTimestamp) { return $runtimeRefreshedAt; } |
| 1046 |
return $schemaTimestamp; |
| 1047 |
} |
| 1048 |
|
| 1049 |
/** @return array<string, mixed> */ |
| 1050 |
private function getLogsHitsTableStatusRow() { |
| 1051 |
global $wpdb; |
| 1052 |
if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { return array(); } |
| 1053 |
$tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}'); |
| 1054 |
$query = $wpdb->prepare("SHOW TABLE STATUS LIKE %s", $tableName); |
| 1055 |
if ($query === null) { return array(); } |
| 1056 |
$results = $this->dbCore->queryAndGetResults($query, array('log_errors' => false)); |
| 1057 |
if (!is_array($results['rows']) || empty($results['rows']) || !is_array($results['rows'][0])) { return array(); } |
| 1058 |
return array_change_key_case($results['rows'][0], CASE_LOWER); |
| 1059 |
} |
| 1060 |
|
| 1061 |
/** @inheritDoc */ |
| 1062 |
function getLogsHitsTableLastUpdatedHuman() { |
| 1063 |
$timestamp = $this->getLogsHitsTableLastUpdated(); |
| 1064 |
if ($timestamp === null) { return ''; } |
| 1065 |
$diff = time() - $timestamp; |
| 1066 |
if ($diff < 60) { return __('Just now', '404-solution'); } |
| 1067 |
elseif ($diff < 3600) { $minutes = (int)floor($diff / 60); return sprintf(_n('%d minute ago', '%d minutes ago', $minutes, '404-solution'), $minutes); } |
| 1068 |
elseif ($diff < 86400) { $hours = (int)floor($diff / 3600); return sprintf(_n('%d hour ago', '%d hours ago', $hours, '404-solution'), $hours); } |
| 1069 |
else { $days = (int)floor($diff / 86400); return sprintf(_n('%d day ago', '%d days ago', $days, '404-solution'), $days); } |
| 1070 |
} |
| 1071 |
|
| 1072 |
/** @inheritDoc */ |
| 1073 |
function createRedirectsForViewHitsTable(): bool { |
| 1074 |
$wasRefreshed = false; |
| 1075 |
if ($this->rebuildHealth !== null && !$this->rebuildHealth->beginExpensiveRebuildAttempt()) { |
| 1076 |
$this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild health gate is closed."); |
| 1077 |
$this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); |
| 1078 |
return false; |
| 1079 |
} |
| 1080 |
if ($this->dbCore->shouldSkipNonEssentialDbWrites()) { $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return false; } |
| 1081 |
if (!$this->acquireHitsTableRebuildLock()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild lock is already held."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'running', 86400); return false; } |
| 1082 |
$preAggTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}_preagg"); |
| 1083 |
try { |
| 1084 |
$finalDestTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}"); |
| 1085 |
$tempDestTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}_temp"); |
| 1086 |
$this->dbCore->queryAndGetResults("drop table if exists " . $tempDestTable); |
| 1087 |
$resolvedCollation = $this->resolveHitsJoinCollation(); |
| 1088 |
$createTempTableQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/createLogsHitsTempTable.sql"); |
| 1089 |
$createTempTableQuery = $this->dbCore->doTableNameReplacements($createTempTableQuery); |
| 1090 |
$createTempTableQuery = str_replace('{COLLATION}', $resolvedCollation, $createTempTableQuery); |
| 1091 |
$this->dbCore->queryAndGetResults($createTempTableQuery); |
| 1092 |
// @cache-write-audit: opt-out - truncates an unpublished temp table before rebuilding it. |
| 1093 |
$this->dbCore->queryAndGetResults("truncate table " . $tempDestTable); |
| 1094 |
$maxLogIdSnapshot = $this->getMaxLogId(); |
| 1095 |
$minLogId = $this->getMinLogId(); |
| 1096 |
$idRange = $maxLogIdSnapshot - $minLogId; |
| 1097 |
$chunkSize = $this->getHitsRebuildChunkSize($idRange); |
| 1098 |
if ($idRange <= self::HITS_TABLE_DIRECT_PATH_THRESHOLD) { $results = $this->hitsTableInsertDirect($tempDestTable); } else { $results = $this->hitsTableInsertChunked($tempDestTable, $preAggTable, $minLogId, $maxLogIdSnapshot, $chunkSize); } |
| 1099 |
if ($results === false || !empty($results['timed_out']) || !empty($results['last_error'])) { |
| 1100 |
$errorMessage = $results === false ? 'Hits rebuild phase 1 chunk failed.' : (string)($results['last_error'] ?? 'Hits rebuild timed out.'); |
| 1101 |
$this->recordHitsRebuildFailure($errorMessage); |
| 1102 |
if ($idRange > self::HITS_TABLE_DIRECT_PATH_THRESHOLD && $results !== false && (!empty($results['timed_out']) || !empty($results['last_error']))) { |
| 1103 |
$this->recordHitsChunkFailure(); |
| 1104 |
} |
| 1105 |
$this->dbCore->queryAndGetResults("drop table if exists " . $tempDestTable); $this->logger->debugMessage(__FUNCTION__ . " INSERT timed out or errored; aborting rebuild."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return false; |
| 1106 |
} |
| 1107 |
$elapsedTime = $results['elapsed_time']; |
| 1108 |
$comment = $elapsedTime . '|' . $maxLogIdSnapshot; |
| 1109 |
// @utf8-audit: opt-out — rebuild table comment is synthesized from numeric timing and ID values. |
| 1110 |
$comment = substr(esc_sql($comment), 0, 2048); |
| 1111 |
$this->dbCore->queryAndGetResults(sprintf("ALTER TABLE %s COMMENT '%s'", $tempDestTable, $comment)); |
| 1112 |
$statements = array("drop table if exists " . $finalDestTable, "rename table " . $tempDestTable . ' to ' . $finalDestTable); |
| 1113 |
$this->dbCore->executeAsTransaction($statements); |
| 1114 |
$this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_REFRESHED_FLAG, time(), 86400); |
| 1115 |
$this->recordHitsRebuildSuccess($chunkSize); |
| 1116 |
$this->clearLogsHitsRollupStaleSignal(); |
| 1117 |
$wasRefreshed = true; |
| 1118 |
$this->logger->debugMessage(__FUNCTION__ . " refreshed " . $finalDestTable . " in " . $elapsedTime . " seconds."); |
| 1119 |
} catch (Throwable $e) { |
| 1120 |
$this->recordHitsRebuildFailure($e->getMessage()); |
| 1121 |
$this->logger->errorMessage(__FUNCTION__ . " failed: " . $e->getMessage(), $e instanceof \Exception ? $e : null); |
| 1122 |
$this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); |
| 1123 |
} finally { |
| 1124 |
$this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable); |
| 1125 |
$this->releaseHitsTableRebuildLock(); |
| 1126 |
} |
| 1127 |
return $wasRefreshed; |
| 1128 |
} |
| 1129 |
|
| 1130 |
/** @param string $tempDestTable @return array<string, mixed> */ |
| 1131 |
private function hitsTableInsertDirect(string $tempDestTable): array { |
| 1132 |
$ttSelectQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getRedirectsForViewTempTable.sql"); |
| 1133 |
if ($this->isLogsv2CanonicalUrlBackfillComplete()) { $ttSelectQuery = $this->dropLogsv2CanonicalCoalesceWrap($ttSelectQuery); } |
| 1134 |
$ttSelectQuery = $this->dbCore->doTableNameReplacements($ttSelectQuery); |
| 1135 |
$ttInsertQuery = "/* abj404:src=LogsRepository::hitsTableInsertDirect */ insert into " . $tempDestTable . " (requested_url, logsid, last_used, logshits, failed_hits) \n " . $ttSelectQuery; |
| 1136 |
return $this->dbCore->queryAndGetResults($ttInsertQuery, array('log_too_slow' => false, 'timeout' => 60)); |
| 1137 |
} |
| 1138 |
|
| 1139 |
/** @return bool */ |
| 1140 |
private function isLogsv2CanonicalUrlBackfillComplete(): bool { |
| 1141 |
if (!function_exists('get_option')) { return false; } |
| 1142 |
return (bool)get_option('abj404_logsv2_canonical_url_backfill_complete'); |
| 1143 |
} |
| 1144 |
|
| 1145 |
/** @param string $sql @return string */ |
| 1146 |
private function dropLogsv2CanonicalCoalesceWrap(string $sql): string { |
| 1147 |
$pattern = '/COALESCE\(\{wp_abj404_logsv2\}\.canonical_url,\s*CONCAT\(\'\/\',\s*TRIM\(BOTH\s+\'\/\'\s+FROM\s+\{wp_abj404_logsv2\}\.requested_url\)\)\)/'; |
| 1148 |
$result = preg_replace($pattern, '{wp_abj404_logsv2}.canonical_url', $sql); |
| 1149 |
return is_string($result) ? $result : $sql; |
| 1150 |
} |
| 1151 |
|
| 1152 |
/** @return array<string, mixed>|false */ |
| 1153 |
private function hitsTableInsertChunked(string $tempDestTable, string $preAggTable, int $minId, int $maxId, int $chunkSize) { |
| 1154 |
$logsv2Table = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}"); |
| 1155 |
$redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}"); |
| 1156 |
$resolvedCollation = $this->resolveHitsJoinCollation(); |
| 1157 |
$startTime = microtime(true); |
| 1158 |
$this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable); |
| 1159 |
$createPreAggQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/createLogsHitsPreAggTempTable.sql"); |
| 1160 |
$createPreAggQuery = $this->dbCore->doTableNameReplacements($createPreAggQuery); |
| 1161 |
$createPreAggQuery = str_replace('{COLLATION}', $resolvedCollation, $createPreAggQuery); |
| 1162 |
$this->dbCore->queryAndGetResults($createPreAggQuery); |
| 1163 |
$logsv2CanonicalExpr = $this->isLogsv2CanonicalUrlBackfillComplete() ? "canonical_url" : "COALESCE(canonical_url, CONCAT('/', TRIM(BOTH '/' FROM requested_url)))"; |
| 1164 |
for ($start = $minId; $start <= $maxId; $start += $chunkSize) { |
| 1165 |
$end = $start + $chunkSize; |
| 1166 |
$chunkQuery = "/* abj404:src=LogsRepository::hitsTableInsertChunked#phase1Chunk */ INSERT INTO " . $preAggTable . " (requested_url, logsid, last_used, logshits, failed_hits) SELECT " . $logsv2CanonicalExpr . ", MIN(id), MAX(timestamp), COUNT(*), SUM(CASE WHEN dest_url = '' OR dest_url IS NULL THEN 1 ELSE 0 END) FROM " . $logsv2Table . " WHERE id >= %d AND id < %d GROUP BY " . $logsv2CanonicalExpr; |
| 1167 |
$chunkResult = $this->dbCore->queryAndGetResults($chunkQuery, array('log_too_slow' => false, 'timeout' => 10, 'query_params' => array($start, $end))); |
| 1168 |
if (!empty($chunkResult['timed_out']) || !empty($chunkResult['last_error'])) { $this->recordHitsChunkFailure(); $this->logger->debugMessage(__FUNCTION__ . " Phase 1 chunk failed at id range [{$start}, {$end}); aborting."); return false; } |
| 1169 |
} |
| 1170 |
$phase2Query = "/* abj404:src=LogsRepository::hitsTableInsertChunked#phase2Aggregate */ INSERT INTO " . $tempDestTable . " (requested_url, logsid, last_used, logshits, failed_hits) SELECT a.requested_url, MIN(a.logsid), MAX(a.last_used), SUM(a.logshits), SUM(a.failed_hits) FROM " . $preAggTable . " a INNER JOIN " . $redirectsTable . " r ON a.requested_url = (COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url))) COLLATE " . $resolvedCollation . ") GROUP BY a.requested_url"; |
| 1171 |
$results = $this->dbCore->queryAndGetResults($phase2Query, array('log_too_slow' => false, 'timeout' => 60)); |
| 1172 |
$results['elapsed_time'] = round(microtime(true) - $startTime, 3); |
| 1173 |
$this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable); |
| 1174 |
return $results; |
| 1175 |
} |
| 1176 |
|
| 1177 |
/** @param int $idRange @return int */ |
| 1178 |
private function getHitsRebuildChunkSize(int $idRange): int { |
| 1179 |
if ($this->rebuildHealth === null) { |
| 1180 |
return self::HITS_TABLE_PREAGG_CHUNK_SIZE; |
| 1181 |
} |
| 1182 |
return $this->rebuildHealth->getHitsChunkSize($idRange); |
| 1183 |
} |
| 1184 |
|
| 1185 |
/** @return void */ |
| 1186 |
private function recordHitsChunkFailure(): void { |
| 1187 |
if ($this->rebuildHealth !== null) { |
| 1188 |
$this->rebuildHealth->recordHitsChunkFailure(); |
| 1189 |
} |
| 1190 |
} |
| 1191 |
|
| 1192 |
/** @param int $chunkSize @return void */ |
| 1193 |
private function recordHitsRebuildSuccess(int $chunkSize): void { |
| 1194 |
if ($this->rebuildHealth === null) { |
| 1195 |
return; |
| 1196 |
} |
| 1197 |
$this->rebuildHealth->recordFullRebuildSuccess($chunkSize); |
| 1198 |
$this->rebuildHealth->recordSuccess(); |
| 1199 |
} |
| 1200 |
|
| 1201 |
/** @param string $message @return void */ |
| 1202 |
private function recordHitsRebuildFailure(string $message): void { |
| 1203 |
if ($this->rebuildHealth === null) { |
| 1204 |
return; |
| 1205 |
} |
| 1206 |
$this->rebuildHealth->recordFailure($message, $this->rebuildHealth->classifyError($message)); |
| 1207 |
} |
| 1208 |
|
| 1209 |
// ========================================================================= |
| 1210 |
// Hits table lifecycle (from DataAccessTrait_ViewQueriesHitsLifecycle) |
| 1211 |
// ========================================================================= |
| 1212 |
|
| 1213 |
/** @inheritDoc */ |
| 1214 |
function logsHitsTableExists() { |
| 1215 |
$query = "SELECT 1 FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE() LIMIT 1"; |
| 1216 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 1217 |
$results = $this->dbCore->queryAndGetResults($query); |
| 1218 |
if ($results['rows'] != null && !empty($results['rows'])) { return true; } |
| 1219 |
if (!empty($results['last_error'])) { return $this->logsHitsTableExistsViaShowTables(); } |
| 1220 |
return false; |
| 1221 |
} |
| 1222 |
|
| 1223 |
/** @return bool */ |
| 1224 |
private function logsHitsTableExistsViaShowTables(): bool { |
| 1225 |
global $wpdb; |
| 1226 |
if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { return false; } |
| 1227 |
$tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}'); |
| 1228 |
$showTablesQuery = $wpdb->prepare("SHOW TABLES LIKE %s", $tableName); |
| 1229 |
if ($showTablesQuery === null) { return false; } |
| 1230 |
$fallback = $this->dbCore->queryAndGetResults($showTablesQuery, array('log_errors' => false)); |
| 1231 |
if (empty($fallback['rows'])) { return false; } |
| 1232 |
$fbRows = is_array($fallback['rows']) ? $fallback['rows'] : array(); |
| 1233 |
$firstRow = isset($fbRows[0]) ? $fbRows[0] : null; |
| 1234 |
if (!is_array($firstRow)) { return false; } |
| 1235 |
$value = reset($firstRow); |
| 1236 |
return ((string)$value === (string)$tableName); |
| 1237 |
} |
| 1238 |
|
| 1239 |
/** @inheritDoc */ |
| 1240 |
function scheduleHitsTableRebuild(): void { |
| 1241 |
if ($this->rebuildHealth !== null && !$this->rebuildHealth->mayStartExpensiveRebuild()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild health gate is closed."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return; } |
| 1242 |
if ($this->dbCore->shouldSkipNonEssentialDbWrites()) { $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); return; } |
| 1243 |
if (!self::$hitsTableRebuildScheduled) { |
| 1244 |
if ($this->isHitsTableRebuildLocked()) { $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling because another rebuild is already running."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'running', 86400); return; } |
| 1245 |
$rawScheduledFlag = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); |
| 1246 |
$lastScheduled = is_scalar($rawScheduledFlag) ? (int)$rawScheduledFlag : 0; |
| 1247 |
if ($lastScheduled > 0 && (time() - $lastScheduled) < self::HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS) { $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling due to cooldown."); $this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'cooldown', 86400); return; } |
| 1248 |
self::$hitsTableRebuildScheduled = true; |
| 1249 |
$this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG, time(), 86400); |
| 1250 |
$this->dbCore->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'scheduled', 86400); |
| 1251 |
if ($this->shouldScheduleHitsTableRebuildViaCron()) { $this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild via WP-Cron."); if (function_exists('wp_schedule_single_event')) { wp_schedule_single_event(time() + 5, 'abj404_updateLogsHitsTableAction'); } return; } |
| 1252 |
$this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild for shutdown hook."); |
| 1253 |
add_action('shutdown', function(): void { $this->createRedirectsForViewHitsTable(); }); |
| 1254 |
} |
| 1255 |
} |
| 1256 |
|
| 1257 |
/** @return bool */ |
| 1258 |
private function shouldScheduleHitsTableRebuildViaCron(): bool { |
| 1259 |
if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { return true; } |
| 1260 |
$scriptName = isset($_SERVER['SCRIPT_NAME']) && is_string($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : ''; |
| 1261 |
if ($scriptName !== '' && basename($scriptName) === 'admin-ajax.php') { return true; } |
| 1262 |
$pagenow = isset($GLOBALS['pagenow']) && is_string($GLOBALS['pagenow']) ? $GLOBALS['pagenow'] : ''; |
| 1263 |
return $pagenow === 'admin-ajax.php'; |
| 1264 |
} |
| 1265 |
|
| 1266 |
private function getHitsTableRebuildLockOptionName(): string { return $this->dbCore->getLowercasePrefix() . 'abj404_logs_hits_rebuild_lock'; } |
| 1267 |
|
| 1268 |
/** @return bool */ |
| 1269 |
private function isHitsTableRebuildLocked(): bool { |
| 1270 |
if (!function_exists('get_option')) { return false; } |
| 1271 |
$lockValue = get_option($this->getHitsTableRebuildLockOptionName(), false); |
| 1272 |
if ($lockValue === false || $lockValue === null || $lockValue === '') { return false; } |
| 1273 |
if (!is_numeric($lockValue)) { if (function_exists('delete_option')) { delete_option($this->getHitsTableRebuildLockOptionName()); } return false; } |
| 1274 |
$lockTimestamp = (int)$lockValue; |
| 1275 |
if ($lockTimestamp > 0 && (time() - $lockTimestamp) > self::HITS_TABLE_REBUILD_LOCK_TTL_SECONDS) { if (function_exists('delete_option')) { delete_option($this->getHitsTableRebuildLockOptionName()); } return false; } |
| 1276 |
return true; |
| 1277 |
} |
| 1278 |
|
| 1279 |
/** @return bool */ |
| 1280 |
private function acquireHitsTableRebuildLock(): bool { |
| 1281 |
if (!function_exists('add_option')) { return true; } |
| 1282 |
if ($this->isHitsTableRebuildLocked()) { return false; } |
| 1283 |
return (bool)add_option($this->getHitsTableRebuildLockOptionName(), (string)time(), '', false); |
| 1284 |
} |
| 1285 |
|
| 1286 |
/** @return void */ |
| 1287 |
private function releaseHitsTableRebuildLock(): void { if (function_exists('delete_option')) { delete_option($this->getHitsTableRebuildLockOptionName()); } } |
| 1288 |
|
| 1289 |
/** @inheritDoc */ |
| 1290 |
function getMaxLogId() { |
| 1291 |
$query = "SELECT MAX(id) FROM {wp_abj404_logsv2}"; |
| 1292 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 1293 |
$results = $this->dbCore->queryAndGetResults($query); |
| 1294 |
$resultRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 1295 |
if (empty($resultRows)) { return 0; } |
| 1296 |
$row = $resultRows[0]; |
| 1297 |
$maxId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0); |
| 1298 |
return (int)($maxId ?? 0); |
| 1299 |
} |
| 1300 |
|
| 1301 |
/** @inheritDoc */ |
| 1302 |
function getMinLogId() { |
| 1303 |
$query = "SELECT MIN(id) FROM {wp_abj404_logsv2}"; |
| 1304 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 1305 |
$results = $this->dbCore->queryAndGetResults($query); |
| 1306 |
$resultRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 1307 |
if (empty($resultRows)) { return 0; } |
| 1308 |
$row = $resultRows[0]; |
| 1309 |
$minId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0); |
| 1310 |
return is_numeric($minId) ? (int)$minId : 0; |
| 1311 |
} |
| 1312 |
|
| 1313 |
/** @inheritDoc */ |
| 1314 |
function getStoredMaxLogId() { |
| 1315 |
$query = "SELECT table_comment FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()"; |
| 1316 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 1317 |
$results = $this->dbCore->queryAndGetResults($query); |
| 1318 |
$storedRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 1319 |
if (empty($storedRows)) { |
| 1320 |
if (!empty($results['last_error'])) { $statusRow = $this->getLogsHitsTableStatusRow(); $commentFromStatus = $statusRow['comment'] ?? ''; if ($commentFromStatus !== '') { $parts = explode('|', is_string($commentFromStatus) ? $commentFromStatus : ''); if (count($parts) >= 2) { return (int)$parts[1]; } } } |
| 1321 |
return 0; |
| 1322 |
} |
| 1323 |
$row = is_array($storedRows[0] ?? null) ? $storedRows[0] : array(); |
| 1324 |
$row = array_change_key_case($row); |
| 1325 |
$comment = $row['table_comment'] ?? ''; |
| 1326 |
$parts = explode('|', is_string($comment) ? $comment : ''); |
| 1327 |
if (count($parts) >= 2) { return (int)$parts[1]; } |
| 1328 |
return 0; |
| 1329 |
} |
| 1330 |
|
| 1331 |
/** @inheritDoc */ |
| 1332 |
function getLogsHitsTableLastCheckedAt() { $rawTsFlag = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_CHECKED_FLAG); $ts = is_scalar($rawTsFlag) ? (int)$rawTsFlag : 0; return $ts > 0 ? $ts : null; } |
| 1333 |
|
| 1334 |
/** @inheritDoc */ |
| 1335 |
function getLogsHitsTableLastScheduledAt() { $rawTsFlag2 = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); $ts = is_scalar($rawTsFlag2) ? (int)$rawTsFlag2 : 0; return $ts > 0 ? $ts : null; } |
| 1336 |
|
| 1337 |
/** @inheritDoc */ |
| 1338 |
function getLogsHitsTableLastDecision(): string { $v = $this->dbCore->getRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG); return is_string($v) ? $v : ''; } |
| 1339 |
} |
| 1340 |
|