| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Hits-table lifecycle helpers extracted from |
| 9 |
* ABJ_404_Solution_DataAccess_ViewQueriesTrait to keep the parent trait under |
| 10 |
* the ModularityTest CLASS_LIMIT. Owns the *scheduling, locking, and |
| 11 |
* existence-probe* concerns for the `{wp_abj404_logs_hits}` rollup table, |
| 12 |
* plus the small log-id watermark helpers (getMaxLogId / getMinLogId / |
| 13 |
* getStoredMaxLogId) the scheduling path consults. The *rebuild* path |
| 14 |
* itself lives in ABJ_404_Solution_DataAccess_LogsHitsRebuildTrait |
| 15 |
* (DataAccessTrait_LogsHitsRebuild.php). |
| 16 |
* |
| 17 |
* Composed into ABJ_404_Solution_DataAccess alongside the other DAO traits. |
| 18 |
* Depends on host-class state and methods provided by the rest of DAO: |
| 19 |
* `queryAndGetResults`, `doTableNameReplacements`, `getRuntimeFlag`, |
| 20 |
* `setRuntimeFlag`, `getLowercasePrefix`, `shouldSkipNonEssentialDbWrites`, |
| 21 |
* `recordLogsHitsRollupStalenessSignal`, `hitsTableNeedsRebuild`, |
| 22 |
* `createRedirectsForViewHitsTable`, `getLogsHitsTableStatusRow`, the |
| 23 |
* `$logger` property, the `$hitsTableRebuildScheduled` static, and the |
| 24 |
* HITS_TABLE_* constants on the host class. |
| 25 |
*/ |
| 26 |
trait ABJ_404_Solution_DataAccess_ViewQueriesHitsLifecycleTrait { |
| 27 |
|
| 28 |
/** @return void */ |
| 29 |
function maybeUpdateRedirectsForViewHitsTable(): void { |
| 30 |
// Record that we checked during this request (used for admin tooltip UX). |
| 31 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_CHECKED_FLAG, time(), 86400); |
| 32 |
|
| 33 |
// Piggyback on the captured-404s tab render: also schedule a |
| 34 |
// 15-second logsv2.canonical_url backfill at shutdown if there's |
| 35 |
// legacy NULL-row backlog. The shutdown handler holds a worker |
| 36 |
// for the budget but the admin response is already flushed by |
| 37 |
// fastcgi_finish_request, so the user doesn't perceive the wait. |
| 38 |
// The function is internally deduped + gated on column existence, |
| 39 |
// probe results, and the backfill-complete option, so calling it |
| 40 |
// unconditionally is cheap. |
| 41 |
if (function_exists('abj_service')) { |
| 42 |
$upgradesEtc = abj_service('database_upgrades'); |
| 43 |
if (is_object($upgradesEtc) && method_exists($upgradesEtc, 'scheduleLogsv2CanonicalUrlBackfill')) { |
| 44 |
$upgradesEtc->scheduleLogsv2CanonicalUrlBackfill(); |
| 45 |
} |
| 46 |
} |
| 47 |
|
| 48 |
if ($this->shouldSkipNonEssentialDbWrites()) { |
| 49 |
$this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); |
| 50 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); |
| 51 |
return; |
| 52 |
} |
| 53 |
|
| 54 |
// Check if the table exists |
| 55 |
if (!$this->logsHitsTableExists()) { |
| 56 |
// Defer creation to shutdown hook so the admin page loads immediately. |
| 57 |
// The view query gracefully falls back to null hits columns when the |
| 58 |
// table doesn't exist (getRedirectsForViewQuery checks logsHitsTableExists). |
| 59 |
// On sites with large logsv2 tables the INSERT...SELECT that populates |
| 60 |
// the hits table can take minutes, which exceeds proxy timeouts (e.g. |
| 61 |
// Cloudflare's 100-second limit → HTTP 524). |
| 62 |
$this->logger->debugMessage(__FUNCTION__ . " table doesn't exist, deferring creation to shutdown hook."); |
| 63 |
$this->scheduleHitsTableRebuild(); |
| 64 |
return; |
| 65 |
} |
| 66 |
|
| 67 |
// Diagnostic: track the max_log_id age signal so a stalled rollup |
| 68 |
// surfaces a broken-cron admin notice instead of silently showing |
| 69 |
// stale hit-count columns. Self-heals when the gap closes. |
| 70 |
$this->recordLogsHitsRollupStalenessSignal(); |
| 71 |
|
| 72 |
// Check if rebuild is needed (logs have changed since last build) |
| 73 |
if (!$this->hitsTableNeedsRebuild()) { |
| 74 |
// No new log entries - skip rebuild to reduce server load |
| 75 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'not_needed', 86400); |
| 76 |
return; |
| 77 |
} |
| 78 |
|
| 79 |
// Table exists and logs have changed - defer to shutdown hook |
| 80 |
$this->scheduleHitsTableRebuild(); |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Schedule the hits table to be rebuilt at shutdown. |
| 85 |
* |
| 86 |
* Uses a static flag to ensure the hook is only registered once per request, |
| 87 |
* even if multiple calls to getRedirectsForView with hits sorting occur. |
| 88 |
* |
| 89 |
* The shutdown hook runs after the response is sent, so the admin sees the page |
| 90 |
* immediately with existing data, and fresh data is available on next load. |
| 91 |
*/ |
| 92 |
/** @return void */ |
| 93 |
function scheduleHitsTableRebuild(): void { |
| 94 |
if ($this->shouldSkipNonEssentialDbWrites()) { |
| 95 |
$this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); |
| 96 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'paused', 86400); |
| 97 |
return; |
| 98 |
} |
| 99 |
if (!self::$hitsTableRebuildScheduled) { |
| 100 |
if ($this->isHitsTableRebuildLocked()) { |
| 101 |
$this->logger->debugMessage(__FUNCTION__ . " skipping scheduling because another rebuild is already running."); |
| 102 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'running', 86400); |
| 103 |
return; |
| 104 |
} |
| 105 |
|
| 106 |
$rawScheduledFlag = $this->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); |
| 107 |
$lastScheduled = is_scalar($rawScheduledFlag) ? (int)$rawScheduledFlag : 0; |
| 108 |
if ($lastScheduled > 0 && (time() - $lastScheduled) < self::HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS) { |
| 109 |
$this->logger->debugMessage(__FUNCTION__ . " skipping scheduling due to cooldown."); |
| 110 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'cooldown', 86400); |
| 111 |
return; |
| 112 |
} |
| 113 |
|
| 114 |
self::$hitsTableRebuildScheduled = true; |
| 115 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG, time(), 86400); |
| 116 |
$this->setRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG, 'scheduled', 86400); |
| 117 |
if ($this->shouldScheduleHitsTableRebuildViaCron()) { |
| 118 |
$this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild via WP-Cron."); |
| 119 |
if (function_exists('wp_schedule_single_event')) { |
| 120 |
wp_schedule_single_event(time() + 5, 'abj404_updateLogsHitsTableAction'); |
| 121 |
} |
| 122 |
return; |
| 123 |
} |
| 124 |
|
| 125 |
$this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild for shutdown hook."); |
| 126 |
add_action('shutdown', function(): void { $this->createRedirectsForViewHitsTable(); }); |
| 127 |
} |
| 128 |
} |
| 129 |
|
| 130 |
/** @return bool */ |
| 131 |
private function shouldScheduleHitsTableRebuildViaCron(): bool { |
| 132 |
if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { |
| 133 |
return true; |
| 134 |
} |
| 135 |
$scriptName = isset($_SERVER['SCRIPT_NAME']) && is_string($_SERVER['SCRIPT_NAME']) |
| 136 |
? $_SERVER['SCRIPT_NAME'] : ''; |
| 137 |
if ($scriptName !== '' && basename($scriptName) === 'admin-ajax.php') { |
| 138 |
return true; |
| 139 |
} |
| 140 |
$pagenow = isset($GLOBALS['pagenow']) && is_string($GLOBALS['pagenow']) |
| 141 |
? $GLOBALS['pagenow'] : ''; |
| 142 |
return $pagenow === 'admin-ajax.php'; |
| 143 |
} |
| 144 |
|
| 145 |
private function getHitsTableRebuildLockOptionName(): string { |
| 146 |
return $this->getLowercasePrefix() . 'abj404_logs_hits_rebuild_lock'; |
| 147 |
} |
| 148 |
|
| 149 |
/** @return bool */ |
| 150 |
private function isHitsTableRebuildLocked(): bool { |
| 151 |
if (!function_exists('get_option')) { |
| 152 |
return false; |
| 153 |
} |
| 154 |
$lockValue = get_option($this->getHitsTableRebuildLockOptionName(), false); |
| 155 |
if ($lockValue === false || $lockValue === null || $lockValue === '') { |
| 156 |
return false; |
| 157 |
} |
| 158 |
// Defensive: if lock is corrupted (non-numeric), clear it so rebuilds can resume. |
| 159 |
if (!is_numeric($lockValue)) { |
| 160 |
if (function_exists('delete_option')) { |
| 161 |
delete_option($this->getHitsTableRebuildLockOptionName()); |
| 162 |
} |
| 163 |
return false; |
| 164 |
} |
| 165 |
$lockTimestamp = (int)$lockValue; |
| 166 |
if ($lockTimestamp > 0 && (time() - $lockTimestamp) > self::HITS_TABLE_REBUILD_LOCK_TTL_SECONDS) { |
| 167 |
if (function_exists('delete_option')) { |
| 168 |
delete_option($this->getHitsTableRebuildLockOptionName()); |
| 169 |
} |
| 170 |
return false; |
| 171 |
} |
| 172 |
return true; |
| 173 |
} |
| 174 |
|
| 175 |
/** @return int|null */ |
| 176 |
function getLogsHitsTableLastCheckedAt() { |
| 177 |
$rawTsFlag = $this->getRuntimeFlag(self::HITS_TABLE_LAST_CHECKED_FLAG); |
| 178 |
$ts = is_scalar($rawTsFlag) ? (int)$rawTsFlag : 0; |
| 179 |
return $ts > 0 ? $ts : null; |
| 180 |
} |
| 181 |
|
| 182 |
/** @return int|null */ |
| 183 |
function getLogsHitsTableLastScheduledAt() { |
| 184 |
$rawTsFlag2 = $this->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); |
| 185 |
$ts = is_scalar($rawTsFlag2) ? (int)$rawTsFlag2 : 0; |
| 186 |
return $ts > 0 ? $ts : null; |
| 187 |
} |
| 188 |
|
| 189 |
/** @return string */ |
| 190 |
function getLogsHitsTableLastDecision(): string { |
| 191 |
$v = $this->getRuntimeFlag(self::HITS_TABLE_LAST_DECISION_FLAG); |
| 192 |
return is_string($v) ? $v : ''; |
| 193 |
} |
| 194 |
|
| 195 |
/** @return bool */ |
| 196 |
private function acquireHitsTableRebuildLock(): bool { |
| 197 |
if (!function_exists('add_option')) { |
| 198 |
return true; |
| 199 |
} |
| 200 |
if ($this->isHitsTableRebuildLocked()) { |
| 201 |
return false; |
| 202 |
} |
| 203 |
return (bool)add_option( |
| 204 |
$this->getHitsTableRebuildLockOptionName(), |
| 205 |
(string)time(), |
| 206 |
'', |
| 207 |
false |
| 208 |
); |
| 209 |
} |
| 210 |
|
| 211 |
/** @return void */ |
| 212 |
private function releaseHitsTableRebuildLock(): void { |
| 213 |
if (function_exists('delete_option')) { |
| 214 |
delete_option($this->getHitsTableRebuildLockOptionName()); |
| 215 |
} |
| 216 |
} |
| 217 |
|
| 218 |
/** @return bool */ |
| 219 |
private function logsHitsTableExistsViaShowTables(): bool { |
| 220 |
global $wpdb; |
| 221 |
if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { |
| 222 |
return false; |
| 223 |
} |
| 224 |
$tableName = $this->doTableNameReplacements('{wp_abj404_logs_hits}'); |
| 225 |
/** @var wpdb $wpdb */ |
| 226 |
// DAO-bypass-approved: $wpdb->prepare is read-only string formatting; the resulting SQL is executed below via queryAndGetResults |
| 227 |
$showTablesQuery = $wpdb->prepare("SHOW TABLES LIKE %s", $tableName); |
| 228 |
if ($showTablesQuery === null) { |
| 229 |
return false; |
| 230 |
} |
| 231 |
$fallback = $this->queryAndGetResults($showTablesQuery, array('log_errors' => false)); |
| 232 |
if (empty($fallback['rows'])) { |
| 233 |
return false; |
| 234 |
} |
| 235 |
$fbRows = is_array($fallback['rows']) ? $fallback['rows'] : array(); |
| 236 |
$firstRow = isset($fbRows[0]) ? $fbRows[0] : null; |
| 237 |
if (!is_array($firstRow)) { |
| 238 |
return false; |
| 239 |
} |
| 240 |
$value = reset($firstRow); |
| 241 |
return ((string)$value === (string)$tableName); |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Check if the logs_hits table exists. |
| 246 |
* Used to verify table was created before using it in queries. |
| 247 |
* @return bool |
| 248 |
*/ |
| 249 |
function logsHitsTableExists() { |
| 250 |
$query = "SELECT 1 FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE() LIMIT 1"; |
| 251 |
$query = $this->doTableNameReplacements($query); |
| 252 |
$results = $this->queryAndGetResults($query); |
| 253 |
if ($results['rows'] != null && !empty($results['rows'])) { |
| 254 |
return true; |
| 255 |
} |
| 256 |
if (!empty($results['last_error'])) { |
| 257 |
// Some hosts restrict information_schema access; fall back to SHOW TABLES. |
| 258 |
return $this->logsHitsTableExistsViaShowTables(); |
| 259 |
} |
| 260 |
return false; |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* Get the maximum log ID from the logs table. |
| 265 |
* |
| 266 |
* Used to detect if logs have changed since the hits table was last built. |
| 267 |
* O(1) query using primary key index. |
| 268 |
* |
| 269 |
* @return int Maximum log ID, or 0 if table is empty |
| 270 |
*/ |
| 271 |
function getMaxLogId() { |
| 272 |
$query = "SELECT MAX(id) FROM {wp_abj404_logsv2}"; |
| 273 |
$query = $this->doTableNameReplacements($query); |
| 274 |
$results = $this->queryAndGetResults($query); |
| 275 |
|
| 276 |
$resultRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 277 |
if (empty($resultRows)) { |
| 278 |
return 0; |
| 279 |
} |
| 280 |
|
| 281 |
$row = $resultRows[0]; |
| 282 |
// Handle both object and array results |
| 283 |
$maxId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0); |
| 284 |
return (int)($maxId ?? 0); |
| 285 |
} |
| 286 |
|
| 287 |
/** @return int */ |
| 288 |
function getMinLogId() { |
| 289 |
$query = "SELECT MIN(id) FROM {wp_abj404_logsv2}"; |
| 290 |
$query = $this->doTableNameReplacements($query); |
| 291 |
$results = $this->queryAndGetResults($query); |
| 292 |
|
| 293 |
$resultRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 294 |
if (empty($resultRows)) { |
| 295 |
return 0; |
| 296 |
} |
| 297 |
|
| 298 |
$row = $resultRows[0]; |
| 299 |
$minId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0); |
| 300 |
return is_numeric($minId) ? (int)$minId : 0; |
| 301 |
} |
| 302 |
|
| 303 |
/** |
| 304 |
* Get the stored max log ID from the hits table comment. |
| 305 |
* |
| 306 |
* Comment format: "elapsed_time|max_log_id" (e.g., "0.35|12345") |
| 307 |
* |
| 308 |
* @return int Stored max log ID, or 0 if not found |
| 309 |
*/ |
| 310 |
function getStoredMaxLogId() { |
| 311 |
$query = "SELECT table_comment FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()"; |
| 312 |
$query = $this->doTableNameReplacements($query); |
| 313 |
$results = $this->queryAndGetResults($query); |
| 314 |
|
| 315 |
$storedRows = is_array($results['rows']) ? $results['rows'] : array(); |
| 316 |
if (empty($storedRows)) { |
| 317 |
if (!empty($results['last_error'])) { |
| 318 |
$statusRow = $this->getLogsHitsTableStatusRow(); |
| 319 |
$commentFromStatus = $statusRow['comment'] ?? ''; |
| 320 |
if ($commentFromStatus !== '') { |
| 321 |
$parts = explode('|', is_string($commentFromStatus) ? $commentFromStatus : ''); |
| 322 |
if (count($parts) >= 2) { |
| 323 |
return (int)$parts[1]; |
| 324 |
} |
| 325 |
} |
| 326 |
} |
| 327 |
return 0; |
| 328 |
} |
| 329 |
|
| 330 |
$row = is_array($storedRows[0] ?? null) ? $storedRows[0] : array(); |
| 331 |
$row = array_change_key_case($row); |
| 332 |
$comment = $row['table_comment'] ?? ''; |
| 333 |
|
| 334 |
// Parse comment format: "elapsed_time|max_log_id" |
| 335 |
$parts = explode('|', is_string($comment) ? $comment : ''); |
| 336 |
if (count($parts) >= 2) { |
| 337 |
return (int)$parts[1]; |
| 338 |
} |
| 339 |
|
| 340 |
// Old format (just elapsed time) or empty - treat as needing rebuild |
| 341 |
return 0; |
| 342 |
} |
| 343 |
} |
| 344 |
|