dbCore = $dbCore; $this->logger = $logging !== null ? $logging : abj_service('logging'); $this->rebuildHealth = $rebuildHealth instanceof ABJ_404_Solution_RebuildHealthState ? $rebuildHealth : $this->resolveRebuildHealthState(); $this->noticeState = $noticeState !== null ? $noticeState : $dbCore->noticeState(); $this->rebuildLock = new ABJ_404_Solution_LogsHitsRebuildLock($dbCore); $this->joinHelper = new ABJ_404_Solution_LogsHitsCanonicalUrlJoinHelper($dbCore); $this->rebuilder = new ABJ_404_Solution_LogsHitsTableRebuilder( $this->dbCore, $this->logger, $this->rebuildHealth, $this->joinHelper, array($this->rebuildLock, 'renew') ); } /** @return ABJ_404_Solution_RebuildHealthState|null */ private function resolveRebuildHealthState(): ?ABJ_404_Solution_RebuildHealthState { if (function_exists('abj_service_optional')) { $service = abj_service_optional('rebuild_health'); if ($service instanceof ABJ_404_Solution_RebuildHealthState) { return $service; } } return null; } /** * Resolve the collation from the abj404_redirects.canonical_url column, * the actual join partner for the hits rebuild phase2 JOIN. Delegates * to the canonical-url join helper. * * @return string Sanitized collation identifier. */ public function resolveHitsJoinCollation(): string { return $this->joinHelper->resolveHitsJoinCollation(); } // ========================================================================= // Staleness signaling // ========================================================================= /** @inheritDoc */ public function recordLogsHitsRollupStalenessSignal(): void { $currentMaxLogId = $this->getMaxLogId(); $storedMaxLogId = $this->getStoredMaxLogId(); if ($currentMaxLogId <= $storedMaxLogId) { $this->clearLogsHitsRollupStaleSignal(); return; } $rawFirstStale = $this->noticeState->getRuntimeFlag(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); $firstStale = is_scalar($rawFirstStale) ? (int)$rawFirstStale : 0; if ($firstStale <= 0) { $this->noticeState->setRuntimeFlag(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG, abj_clock()->now(), 86400); return; } $age = abj_clock()->now() - $firstStale; if ($age >= self::HITS_TABLE_STALE_NOTICE_THRESHOLD_SECONDS) { $this->setLogsHitsRollupStaleNotice($age); } } private function clearLogsHitsRollupStaleSignal(): void { if (function_exists('delete_transient')) { delete_transient(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); delete_transient(self::HITS_TABLE_STALE_NOTICE_TRANSIENT); return; } if (function_exists('delete_option')) { delete_option(self::HITS_TABLE_FIRST_STALE_DETECTED_FLAG); delete_option(self::HITS_TABLE_STALE_NOTICE_TRANSIENT); } } /** @param int $ageSeconds @return void */ private function setLogsHitsRollupStaleNotice(int $ageSeconds): void { if (!function_exists('set_transient')) { return; } $key = self::HITS_TABLE_STALE_NOTICE_TRANSIENT; if (function_exists('get_transient') && get_transient($key) !== false) { return; } $hours = max(1, intval(floor($ageSeconds / 3600))); $message = sprintf( function_exists('__') ? __('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.', '404-solution') : '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.', $hours ); $payload = array('type' => 'logs_hits_rollup_stale', 'message' => $message, 'timestamp' => abj_clock()->now(), 'error_string' => '', 'age_hours' => $hours); // allow-cache-empty: intentional notice payload; error_string is empty by definition for stale-rollup state. set_transient($key, $payload, 86400); } // ========================================================================= // Rebuild readiness queries // ========================================================================= /** @inheritDoc */ public function hitsTableNeedsRebuild() { $storedMaxId = $this->getStoredMaxLogId(); $currentMaxId = $this->getMaxLogId(); if ($currentMaxId != $storedMaxId) { $this->logger->debugMessage(__FUNCTION__ . " rebuild=yes (max_id changed: stored=$storedMaxId, current=$currentMaxId)"); return true; } $lastUpdated = $this->getLogsHitsTableLastUpdated(); if ($lastUpdated !== null) { $age = abj_clock()->now() - $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; } } $this->logger->debugMessage(__FUNCTION__ . " rebuild=no (max_id=$currentMaxId unchanged, not stale)"); return false; } /** @inheritDoc */ public function getLogsHitsTableLastUpdated() { $rawRefreshedFlag = $this->noticeState->getRuntimeFlag(self::HITS_TABLE_LAST_REFRESHED_FLAG); $runtimeRefreshedAt = is_scalar($rawRefreshedFlag) ? (int)$rawRefreshedFlag : 0; $runtimeRefreshedAt = $runtimeRefreshedAt > 0 ? $runtimeRefreshedAt : null; $schemaTimestamp = $this->readSchemaFreshnessEpoch(); if ($schemaTimestamp === null) { return $runtimeRefreshedAt; } if ($runtimeRefreshedAt !== null && $runtimeRefreshedAt > $schemaTimestamp) { return $runtimeRefreshedAt; } return $schemaTimestamp; } /** * The rollup table's own creation instant, as a true UTC epoch, read from * information_schema. * * The server is asked for UNIX_TIMESTAMP(create_time) rather than * create_time itself, because create_time is DISPLAYED in the MySQL session * timezone: one instant reads as "2026-08-13 21:52:51" at +00:00 and * "2026-08-14 06:52:51" at +09:00. Only the epoch form is timezone-invariant, * and it is the form the caller needs, since it compares this against * abj_clock()->now(), a true UTC epoch. * * @return int|null Epoch seconds, or null when the server has no usable * answer (no such table, or the read was refused and the * fallback could not date it either). */ private function readSchemaFreshnessEpoch(): ?int { $query = "SELECT UNIX_TIMESTAMP(create_time) AS create_time_epoch FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()"; $query = $this->dbCore->doTableNameReplacements($query); $results = $this->dbCore->queryAndGetResults($query); if ($results['rows'] == null || empty($results['rows'])) { // Rows AND an error means the read was refused (restricted grants // on managed hosts), which is the only case worth a second attempt. return empty($results['last_error']) ? null : $this->readStatusFallbackFreshnessEpoch(); } $hitsRows = is_array($results['rows']) ? $results['rows'] : array(); $row = is_array($hitsRows[0] ?? null) ? array_change_key_case($hitsRows[0]) : array(); $createTimeEpoch = $row['create_time_epoch'] ?? null; if (!is_numeric($createTimeEpoch)) { return null; } $schemaTimestamp = (int)$createTimeEpoch; return $schemaTimestamp > 0 ? $schemaTimestamp : null; } /** * The same instant read through SHOW TABLE STATUS, for hosts that refuse * information_schema. Prefers Update_time, which InnoDB leaves NULL until * the table is written, and falls back to Create_time. * * @return int|null Epoch seconds, or null when the row carries no usable date. */ private function readStatusFallbackFreshnessEpoch(): ?int { $statusRow = $this->getLogsHitsTableStatusRow(); $dateValue = is_array($statusRow) ? ($statusRow['update_time'] ?? ($statusRow['create_time'] ?? '')) : ''; if (!is_string($dateValue) || $dateValue === '') { return null; } return $this->mysqlDatetimeToEpoch($dateValue); } /** * Convert a datetime string the database server just rendered back into a * true UTC epoch, using that same server's session timezone. * * SHOW TABLE STATUS cannot be wrapped in an expression the way * information_schema's create_time can, so its Create_time / Update_time * arrive here as a string already rendered in the MySQL session timezone * (normally SYSTEM, i.e. the DB host's OS timezone). Handing that string to * PHP's strtotime() would re-read it in PHP's default timezone, which * WordPress pins to UTC -- two systems rendering and parsing the same * instant under different implicit timezones, which puts the rollup age out * by the server's whole UTC offset. Asking the server to convert its own * rendering keeps interpretation on the side that produced it, so the two * cannot disagree. * * @param string $mysqlDatetime A datetime as rendered by the server. * @return int|null Epoch seconds, or null when the server cannot convert it * (unparseable, zero date, or a failed conversion query). */ private function mysqlDatetimeToEpoch(string $mysqlDatetime): ?int { $results = $this->dbCore->queryAndGetResults( "SELECT UNIX_TIMESTAMP(%s) AS `epoch`", array('query_params' => array($mysqlDatetime), 'log_errors' => false) ); $rows = isset($results['rows']) && is_array($results['rows']) ? $results['rows'] : array(); $row = is_array($rows[0] ?? null) ? array_change_key_case($rows[0]) : array(); $epoch = $row['epoch'] ?? null; if (!is_numeric($epoch)) { return null; } $epoch = (int)$epoch; return $epoch > 0 ? $epoch : null; } /** @return array */ private function getLogsHitsTableStatusRow() { global $wpdb; if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { return array(); } $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}'); $query = $wpdb->prepare("SHOW TABLE STATUS LIKE %s", $tableName); if ($query === null) { return array(); } $results = $this->dbCore->queryAndGetResults($query, array('log_errors' => false)); if (!is_array($results['rows']) || empty($results['rows']) || !is_array($results['rows'][0])) { return array(); } // Lower-cased explicitly rather than through array_change_key_case(), // which is typed as preserving the input's (here unknown) key type and // so cannot satisfy this method's declared string-keyed contract. // MySQL and MariaDB disagree on the case of SHOW TABLE STATUS column // names, which is the whole reason the keys are normalized at all // (defensive philosophy #5, case-insensitive metadata access). $row = array(); foreach ($results['rows'][0] as $column => $value) { $row[strtolower((string)$column)] = $value; } return $row; } // ========================================================================= // Rebuild pipeline (direct + chunked) // ========================================================================= /** * @inheritDoc * * Coordinates a rollup rebuild: enforces the health gate, write cooldown, * and cross-request lock, snapshots the logsv2 id range (so the tracking * test subclass and the pre-insert watermark both observe the same values), * then delegates the materialize-and-swap SQL to the rebuild engine. On a * successful refresh it stamps the freshness flag, clears the staleness * signal, and writes the denorm hit columns back onto the redirects rows. */ public function createRedirectsForViewHitsTable(): bool { if ($this->rebuildHealth !== null && !$this->rebuildHealth->beginExpensiveRebuildAttempt()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild health gate is closed."); return false; } if ($this->noticeState->shouldSkipNonEssentialDbWrites()) { $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); return false; } if (!$this->rebuildLock->acquire()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild lock is already held."); return false; } try { $maxLogIdSnapshot = $this->getMaxLogId(); $minLogId = $this->getMinLogId(); $result = $this->rebuilder->rebuildAndSwap($minLogId, $maxLogIdSnapshot); if (!$result['refreshed']) { return false; } $this->noticeState->setRuntimeFlag(self::HITS_TABLE_LAST_REFRESHED_FLAG, abj_clock()->now(), 86400); $this->clearLogsHitsRollupStaleSignal(); $this->writeBackDenormHitsColumns(); return true; } catch (Throwable $e) { // The rebuild engine self-handles its own SQL/Throwable failures and // signals them via the return value; reaching here means a post-swap // bookkeeping step (freshness flag, staleness clear, denorm write-back) // threw. Return false rather than letting the exception escape the // cron / shutdown listener. $this->logger->errorMessage(__FUNCTION__ . " post-rebuild step failed: " . $e->getMessage(), $e instanceof \Exception ? $e : null); return false; } finally { $this->rebuildLock->release(); } } /** * After a successful rollup rebuild, push the freshly rolled-up hit count + * last-used timestamp from wp_abj404_logs_hits back onto the redirects rows * (Denorm Step 3c). This keeps the STORED logshits / last_used columns - * which order the full off-page result set - current in real time instead of * waiting for the nightly reconcile. Delegated to the denorm maintenance * service, resolved lazily so a minimal wiring (or a context where the * service is unregistered) degrades to a no-op rather than fataling. * * @return void */ private function writeBackDenormHitsColumns(): void { $maintenance = new ABJ_404_Solution_RedirectsDenormMaintenanceService( $this->dbCore, $this->logger ); $maintenance->writeBackLogsHitsColumns(); } // ========================================================================= // Table existence + scheduling // ========================================================================= /** @inheritDoc */ public function logsHitsTableExists() { $query = "SELECT 1 FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE() LIMIT 1"; $query = $this->dbCore->doTableNameReplacements($query); $results = $this->dbCore->queryAndGetResults($query); if ($results['rows'] != null && !empty($results['rows'])) { return true; } if (!empty($results['last_error'])) { return $this->logsHitsTableExistsViaShowTables(); } return false; } /** @return bool */ private function logsHitsTableExistsViaShowTables(): bool { global $wpdb; if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) { return false; } $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}'); $showTablesQuery = $wpdb->prepare("SHOW TABLES LIKE %s", $tableName); if ($showTablesQuery === null) { return false; } $fallback = $this->dbCore->queryAndGetResults($showTablesQuery, array('log_errors' => false)); if (empty($fallback['rows'])) { return false; } $fbRows = is_array($fallback['rows']) ? $fallback['rows'] : array(); $firstRow = isset($fbRows[0]) ? $fbRows[0] : null; if (!is_array($firstRow)) { return false; } $value = reset($firstRow); return ((string)$value === (string)$tableName); } /** @inheritDoc */ public function scheduleHitsTableRebuild(): void { if ($this->rebuildHealth !== null && !$this->rebuildHealth->mayStartExpensiveRebuild()) { $this->logger->debugMessage(__FUNCTION__ . " skipped because rebuild health gate is closed."); return; } if ($this->noticeState->shouldSkipNonEssentialDbWrites()) { $this->logger->debugMessage(__FUNCTION__ . " skipped due to temporary DB write cooldown."); return; } if (!self::$hitsTableRebuildScheduled) { if ($this->rebuildLock->isHeld()) { $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling because another rebuild is already running."); return; } $rawScheduledFlag = $this->noticeState->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); $lastScheduled = is_scalar($rawScheduledFlag) ? (int)$rawScheduledFlag : 0; if ($lastScheduled > 0 && (abj_clock()->now() - $lastScheduled) < self::HITS_TABLE_SCHEDULE_COOLDOWN_SECONDS) { $this->logger->debugMessage(__FUNCTION__ . " skipping scheduling due to cooldown."); return; } self::$hitsTableRebuildScheduled = true; $this->noticeState->setRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG, abj_clock()->now(), 86400); // A full rollup rebuild is background work in every request // context. PHP/LSAPI servers may send response headers early but // buffer the body until WordPress shutdown callbacks finish, so a // shutdown rebuild can keep the admin page blank for the entire // database operation. The cron listener is the single execution // boundary for this expensive pipeline. $this->logger->debugMessage(__FUNCTION__ . " scheduling hits table rebuild via WP-Cron."); abj_cron_scheduler()->scheduleSingle( ABJ_404_Solution_CronScheduler::HOOK_UPDATE_LOGS_HITS_TABLE, 5 ); } } // ========================================================================= // logsv2 / logs_hits id queries // ========================================================================= /** @inheritDoc */ public function getMaxLogId() { // allow-unbounded-select: MAX(id) aggregate; returns a single row $query = "SELECT MAX(id) FROM {wp_abj404_logsv2}"; $query = $this->dbCore->doTableNameReplacements($query); $results = $this->dbCore->queryAndGetResults($query); $resultRows = is_array($results['rows']) ? $results['rows'] : array(); if (empty($resultRows)) { return 0; } $row = $resultRows[0]; $maxId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0); return (int)($maxId ?? 0); } /** @inheritDoc */ public function getMinLogId() { // allow-unbounded-select: MIN(id) aggregate; returns a single row $query = "SELECT MIN(id) FROM {wp_abj404_logsv2}"; $query = $this->dbCore->doTableNameReplacements($query); $results = $this->dbCore->queryAndGetResults($query); $resultRows = is_array($results['rows']) ? $results['rows'] : array(); if (empty($resultRows)) { return 0; } $row = $resultRows[0]; $minId = is_array($row) ? array_values($row)[0] : (array_values((array)$row)[0] ?? 0); return is_numeric($minId) ? (int)$minId : 0; } /** @inheritDoc */ public function getStoredMaxLogId() { $query = "SELECT table_comment FROM information_schema.tables WHERE table_name = '{wp_abj404_logs_hits}' AND table_schema = DATABASE()"; $query = $this->dbCore->doTableNameReplacements($query); $results = $this->dbCore->queryAndGetResults($query); $storedRows = is_array($results['rows']) ? $results['rows'] : array(); if (empty($storedRows)) { 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]; } } } return 0; } $row = is_array($storedRows[0] ?? null) ? $storedRows[0] : array(); $row = array_change_key_case($row); $comment = $row['table_comment'] ?? ''; $parts = explode('|', is_string($comment) ? $comment : ''); if (count($parts) >= 2) { return (int)$parts[1]; } return 0; } /** @inheritDoc */ public function getLogsHitsTableLastScheduledAt() { $rawTsFlag2 = $this->noticeState->getRuntimeFlag(self::HITS_TABLE_LAST_SCHEDULED_FLAG); $ts = is_scalar($rawTsFlag2) ? (int)$rawTsFlag2 : 0; return $ts > 0 ? $ts : null; } }