noticeState = $noticeState; $this->rebuildIfStillNeeded = $rebuildIfStillNeeded; $this->logger = $logger; $this->inspector = $inspector !== null ? $inspector : new ABJ_404_Solution_ScheduledEventInspector(); } /** * Record one observation of how far the rollup is behind the log table, and * escalate to an admin notice once it has been behind for longer than * NOTICE_THRESHOLD_SECONDS. * * @param int $currentMaxLogId MAX(logsv2.id) as observed now. * @param int $storedMaxLogId The watermark the last completed rebuild stamped. * @return void */ public function observe(int $currentMaxLogId, int $storedMaxLogId): void { if ($currentMaxLogId <= $storedMaxLogId) { $this->clearStallState(); return; } $firstStall = $this->readFirstStallInstant(); $now = abj_clock()->now(); if ($firstStall <= 0) { $this->noticeState->setDurableFlag(self::FIRST_STALL_FLAG, $now); return; } $age = $now - $firstStall; if ($age >= self::NOTICE_THRESHOLD_SECONDS) { $this->raiseStallNotice($age); } } /** * Forget the stall measurement and withdraw any notice this watchdog * raised. Called when the rollup has caught up, so recovery is silent. * * @return void */ public function clearStallState(): void { $this->noticeState->deleteDurableFlag(self::FIRST_STALL_FLAG); $this->noticeState->clearPluginDbNoticeIfType(self::NOTICE_TYPE); } /** * Open the post-response rebuild path, but only on evidence that the cron * event this plugin armed is not being executed. * * @return bool Whether a post-response rebuild was armed for this request. */ public function armDeferredRebuildIfCronIsNotExecuting(): bool { if ($this->deferredArmed || !function_exists('add_action')) { return false; } // Only an administrator's page render may carry the rebuild: cron runs // it inline, an AJAX request must not hold a worker open for it, and a // visitor's front-end request (scheduleHitsTableRebuild() is reached // from redirect matching) must never pay for it. On a buffering SAPI // the page is detached first; on a streaming SAPI (neither finish // function exists) the page has already been sent as it was produced. if (!ABJ_404_Solution_RequestKind::isAdminPageRender()) { return false; } if (!$this->cronIsNotExecuting()) { return false; } if (!$this->claimDeferredRebuildWindow()) { return false; } $this->deferredArmed = true; add_action('shutdown', function (): void { $this->runDeferredRebuild(); }); return true; } /** * Whether the rebuild event this plugin armed is so far past due that * WP-Cron cannot be executing it on this host. * * Behavioural rather than configuration-sniffing on purpose: DISABLE_WP_CRON * is set on plenty of sites that run cron perfectly well from a system * crontab, and it is unset on sites whose loopback request is refused with a * 403 by a security plugin. Only the store's own answer separates them. * * @return bool */ public function cronIsNotExecuting(): bool { try { $event = $this->inspector->currentEvent( ABJ_404_Solution_CronScheduler::HOOK_UPDATE_LOGS_HITS_TABLE ); } catch (\Throwable $e) { // An unreadable cron store cannot prove cron is dead, and guessing // in either direction is worse than reporting what happened. $this->logWarning( 'Could not read the logs-hits rebuild cron event: ' . get_class($e) . ': ' . $e->getMessage() ); return false; } if ($event === null) { return false; } return (abj_clock()->now() - $event['timestamp']) > self::CRON_OVERDUE_SECONDS; } /** * The post-response backstop itself. Detaches the response before doing any * work, so the page the visitor is waiting on is already delivered, and * never lets a failure escape: this runs after the response, where an * exception could only corrupt the tail of the output. * * @return void */ private function runDeferredRebuild(): void { try { ABJ_404_Solution_ResponseDetach::detach(); call_user_func($this->rebuildIfStillNeeded); } catch (\Throwable $e) { $this->logWarning( 'Post-response logs-hits rollup rebuild failed: ' . get_class($e) . ': ' . $e->getMessage() ); } } /** * Cross-request rate limit for the post-response rebuild. Recorded BEFORE * the rebuild runs so a rebuild that fails, or whose worker is killed, still * consumes its window instead of retrying on every page view. * * @return bool */ private function claimDeferredRebuildWindow(): bool { $rawLast = $this->noticeState->getRuntimeFlag(self::DEFERRED_REBUILD_FLAG); $lastAttempt = is_scalar($rawLast) ? (int)$rawLast : 0; $now = abj_clock()->now(); if ($lastAttempt > 0 && ($now - $lastAttempt) < self::DEFERRED_REBUILD_COOLDOWN_SECONDS) { return false; } $this->noticeState->setRuntimeFlag( self::DEFERRED_REBUILD_FLAG, $now, self::DEFERRED_REBUILD_COOLDOWN_SECONDS ); return true; } /** @return int The durable first-stall instant, or 0 when none is recorded. */ private function readFirstStallInstant(): int { $raw = $this->noticeState->getDurableFlag(self::FIRST_STALL_FLAG); return is_scalar($raw) ? (int)$raw : 0; } /** * Tell the admin, on the plugin's own screen, that the hit counts they are * reading are out of date and why. * * Written on every observation past the threshold rather than once, because * the payload it shares with the other database notices expires on its own * cooldown; a persistent condition has to keep saying so. A live notice of a * different type is left alone, since a write-block or a crashed table is * more urgent than stale columns and re-arms itself from its own path. * * @param int $ageSeconds How long the rollup has been behind. * @return void */ private function raiseStallNotice(int $ageSeconds): void { if ($this->foreignNoticeIsLive()) { return; } $hours = max(1, intval(floor($ageSeconds / 3600))); $template = 'The Hits and Last Used columns on the Page Redirects screen have been out of date for at least %d hour(s). 404 Solution has not been able to finish rebuilding its hit-count summary in that time.'; $message = sprintf( function_exists('__') ? __('The Hits and Last Used columns on the Page Redirects screen have been out of date for at least %d hour(s). 404 Solution has not been able to finish rebuilding its hit-count summary in that time.', '404-solution') : $template, $hours ); $this->noticeState->setPluginDbNotice( self::NOTICE_TYPE, $message, $this->stallGuidance(), '', ABJ_404_Solution_DatabaseNoticeStateHolder::SEVERITY_WARNING ); } /** * What the admin can actually do about it, which differs by cause: an event * that is sitting past due means the site's scheduler is not running it at * all, and an event that is being picked up means the rebuild itself is * failing and the plugin's log holds the reason. * * @return string */ private function stallGuidance(): string { $cronText = "This site's WP-Cron is not running the plugin's scheduled rebuild. 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. In the meantime 404 Solution retries the rebuild in the background after an admin page has finished loading."; $rebuildText = 'The scheduled rebuild is being picked up but is not completing. Enable debug logging on the 404 Solution Options screen and check the log for the reason the rebuild stopped.'; if ($this->cronIsNotExecuting()) { return function_exists('__') ? __("This site's WP-Cron is not running the plugin's scheduled rebuild. 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. In the meantime 404 Solution retries the rebuild in the background after an admin page has finished loading.", '404-solution') : $cronText; } return function_exists('__') ? __('The scheduled rebuild is being picked up but is not completing. Enable debug logging on the 404 Solution Options screen and check the log for the reason the rebuild stopped.', '404-solution') : $rebuildText; } /** @return bool Whether a live plugin database notice belongs to another subsystem. */ private function foreignNoticeIsLive(): bool { $existing = $this->noticeState->getRuntimeFlag('abj404_plugin_db_notice'); if (!is_array($existing)) { return false; } $type = isset($existing['type']) && is_string($existing['type']) ? $existing['type'] : ''; return $type !== '' && $type !== self::NOTICE_TYPE; } /** @param string $message @return void */ private function logWarning(string $message): void { if ($this->logger !== null && method_exists($this->logger, 'warn')) { $this->logger->warn($message); } } }