,callable):mixed|null */ private static $operationTracer = null; /** @var ABJ_404_Solution_StatusCountsRepository */ private $statusCounts; /** @var ABJ_404_Solution_StatsRefreshLock */ private $refreshLock; /** @var callable(string):void */ private $warn; /** @var array Scopes whose shutdown backstop is armed for this request. */ private $deferredArmed = array(); public function __construct( ABJ_404_Solution_StatusCountsRepository $statusCounts, ABJ_404_Solution_StatsRefreshLock $refreshLock, callable $warn ) { $this->statusCounts = $statusCounts; $this->refreshLock = $refreshLock; $this->warn = $warn; } /** @param callable(string,array,callable):mixed|null $tracer */ public static function setOperationTracer($tracer): void { self::$operationTracer = $tracer; } /** * Counts plus the freshness state that produced them, so a caller can tell * "never computed" apart from "computed and genuinely zero". * * NOT a plain getter, which is why it is not named like one: a read that * finds the cache stale ENQUEUES a recomputation (a cron single event, plus * a `shutdown` backstop on non-AJAX requests). The enqueue deliberately * lives inside the read rather than in a separate command a caller has to * remember, because a caller who forgets it leaves the site rendering "-" * in every tab for as long as the cache stays cold. Nothing is ever * recomputed inline here; see the class doc block for the four bounds. * * @return array{counts: array, state: string} */ public function refreshingRedirectStatusCountsResult(): array { return self::trace('status_count_scope', array('scope' => self::SCOPE_REDIRECTS), function (): array { return $this->resolveRead( self::SCOPE_REDIRECTS, $this->statusCounts->readRedirectStatusCountsCache() ); } ); } /** * Flat legacy shape of refreshingRedirectStatusCountsResult(), and it * enqueues a refresh on the same terms. * * @return array */ public function refreshingRedirectStatusCounts(): array { return self::flatten($this->refreshingRedirectStatusCountsResult()); } /** * Captured-tab counts plus freshness state. Enqueues a recomputation when * the cache is stale, on the same terms as the redirect scope above. * * @return array{counts: array, state: string} */ public function refreshingCapturedStatusCountsResult(): array { return self::trace('status_count_scope', array('scope' => self::SCOPE_CAPTURED), function (): array { return $this->resolveRead( self::SCOPE_CAPTURED, $this->statusCounts->readCapturedStatusCountsCache() ); } ); } /** * Flat legacy shape of refreshingCapturedStatusCountsResult(), and it * enqueues a refresh on the same terms. * * @return array */ public function refreshingCapturedStatusCounts(): array { return self::flatten($this->refreshingCapturedStatusCountsResult()); } /** * The high-impact captured count, enqueuing a recomputation when its cache * is stale. Same read-enqueues-a-write contract as the two scopes above. * * @return int|null */ public function refreshingHighImpactCapturedCount(): ?int { return self::trace('status_count_scope', array('scope' => self::SCOPE_HIGH_IMPACT), function (): ?int { $state = $this->statusCounts->readHighImpactCapturedCountCache(); if ($state['needs_refresh']) { $this->scheduleRefresh(self::SCOPE_HIGH_IMPACT); } return $state['count']; } ); } /** * Background recomputation entry point. A direct foreground call can only * enqueue work; the cron listener and the shutdown backstop are the two * places the aggregate actually runs. */ public function refresh(string $scope): void { $refresher = $this->refresherFor($scope); if ($refresher === null) { call_user_func($this->warn, 'Ignoring unknown status-count refresh scope: ' . $scope); return; } if (!ABJ_404_Solution_RequestKind::isCron()) { $this->scheduleRefresh($scope); return; } $this->performRefresh($scope, $refresher['cron_timeout']); } /** * Admit each scope's queued refresh event on an authorized admin page * render of a site whose WP-Cron has proved stalled, through the shared * queued-event consumer. The admitted slice runs the same locked aggregate * the cron listener runs, at the deferred query budget, after the response * is detached, and consumes that scope's exact event only once its cache no * longer needs a refresh. Without the stall proof the event stays cron work. */ public function armAdminContinuations(): void { foreach (array(self::SCOPE_REDIRECTS, self::SCOPE_CAPTURED, self::SCOPE_HIGH_IMPACT) as $scope) { $refresher = $this->refresherFor($scope); if ($refresher === null) { continue; } (new ABJ_404_Solution_QueuedCronEventAdminContinuation(array( 'hook' => ABJ_404_Solution_CronScheduler::HOOK_REFRESH_STATUS_COUNTS, 'args' => array($scope), 'lockKey' => 'status_counts_refresh_' . $scope, 'label' => 'status count refresh (' . $scope . ')', 'sync' => abj_service('sync_utils'), 'clock' => abj_clock(), 'cron' => abj_cron_scheduler(), 'logger' => abj_service('logging'), 'pending' => $refresher['needs_refresh'], 'slice' => function (float $deadline) use ($scope, $refresher): array { // One aggregate is one step, bounded by its own query budget. unset($deadline); $outcome = $this->performRefresh($scope, self::DEFERRED_REFRESH_QUERY_TIMEOUT_SECONDS); $complete = !($refresher['needs_refresh'])(); // A held lock means another writer is computing this scope now. return array('complete' => $complete, 'progressed' => $complete || $outcome === 'busy'); }, 'describe' => function () use ($scope): array { return array('scope' => $scope, 'lastRecomputeFailure' => $this->statusCounts->lastRecomputeFailure()); }, )))->arm(); } } /** * Run one scope's aggregate under the distributed refresh lock. * * @return string 'refreshed', 'failed', 'busy' (another writer holds the lock) or 'unknown-scope'. */ private function performRefresh(string $scope, int $timeoutSeconds): string { $refresh = $this->refresherFor($scope); if ($refresh === null) { return 'unknown-scope'; } if (!$this->refreshLock->acquire($refresh['cache_key'])) { return 'busy'; } try { $refreshed = ($refresh['recompute'])($timeoutSeconds); if ($refreshed === true) { return 'refreshed'; } // The cause lives one layer down, flattened to `false` by the // recompute's bool contract; the repository keeps it alongside. $cause = $this->statusCounts->lastRecomputeFailure(); call_user_func($this->warn, 'Status-count refresh failed for scope ' . $scope . ($cause !== '' ? ' (' . $cause . ')' : ' (no cause recorded)') . '; retaining the last-known cache.' ); return 'failed'; } finally { $this->refreshLock->release($refresh['cache_key']); } } /** * Everything that differs between the three cron-written scopes, in one * place: where the cache lives, how to recompute it, how to ask whether it * still needs recomputing, and what the unattended query budget is. * * @return array{cache_key: string, recompute: callable(int):bool, needs_refresh: callable():bool, cron_timeout: int}|null */ private function refresherFor(string $scope): ?array { $statusCounts = $this->statusCounts; $refreshers = array( self::SCOPE_REDIRECTS => array( 'cache_key' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_REDIRECT_STATUS, 'recompute' => static fn(int $timeout): bool => $statusCounts->recomputeRedirectStatusCounts($timeout), 'needs_refresh' => static fn(): bool => $statusCounts->readRedirectStatusCountsCache()['needs_refresh'], 'cron_timeout' => ABJ_404_Solution_StatusCountsRepository::STATUS_QUERY_TIMEOUT_SECONDS, ), self::SCOPE_CAPTURED => array( 'cache_key' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS, 'recompute' => static fn(int $timeout): bool => $statusCounts->recomputeCapturedStatusCounts($timeout), 'needs_refresh' => static fn(): bool => $statusCounts->readCapturedStatusCountsCache()['needs_refresh'], 'cron_timeout' => ABJ_404_Solution_StatusCountsRepository::STATUS_QUERY_TIMEOUT_SECONDS, ), self::SCOPE_HIGH_IMPACT => array( 'cache_key' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED, 'recompute' => static fn(int $timeout): bool => $statusCounts->recomputeHighImpactCapturedCount($timeout), 'needs_refresh' => static fn(): bool => $statusCounts->readHighImpactCapturedCountCache()['needs_refresh'], 'cron_timeout' => ABJ_404_Solution_StatusCountsRepository::HIGH_IMPACT_QUERY_TIMEOUT_SECONDS, ), ); return $refreshers[$scope] ?? null; } /** * @param array{counts: array, needs_refresh: bool, incomplete: bool} $state * @return array{counts: array, state: string} */ private function resolveRead(string $scope, array $state): array { if ($state['needs_refresh']) { $this->scheduleRefresh($scope); } if ($state['incomplete']) { return array('counts' => $state['counts'], 'state' => self::STATE_UNCOMPUTED); } return array( 'counts' => $state['counts'], 'state' => $state['needs_refresh'] ? self::STATE_STALE : self::STATE_FRESH, ); } /** * Legacy flat shape: counts plus the `_incomplete` marker that the admin * tab renderer and the pagination/trash AJAX responses already understand. * * @param array{counts: array, state: string} $result * @return array */ private static function flatten(array $result): array { $counts = $result['counts']; if ($result['state'] === self::STATE_UNCOMPUTED) { $counts['_incomplete'] = 1; } return $counts; } private function scheduleRefresh(string $scope): void { $scheduler = self::trace( 'scheduler_resolution', array('scope' => $scope), static fn() => abj_cron_scheduler() ); self::trace( 'schedule_if_missing', array('scope' => $scope), static fn() => $scheduler->scheduleSingleIfMissing( ABJ_404_Solution_CronScheduler::HOOK_REFRESH_STATUS_COUNTS, 0, array($scope) ) ); $this->armDeferredRefresh($scope); } /** * Register the post-response backstop for one scope. Cron requests are * excluded because refresh() runs the aggregate inline there. AJAX is * excluded because scheduleRefresh() already enqueued the cron writer; * an aggregate on shutdown would retain the request's PHP worker. */ private function armDeferredRefresh(string $scope): void { if (ABJ_404_Solution_RequestKind::isCron() || ABJ_404_Solution_RequestKind::isAjax() || !empty($this->deferredArmed[$scope])) { return; } if ($this->refresherFor($scope) === null || !function_exists('add_action')) { return; } $this->deferredArmed[$scope] = true; add_action('shutdown', function () use ($scope): void { $this->runDeferredRefresh($scope); }); } /** * The shutdown backstop itself. Never lets a failure escape: this runs * after the response, where an exception would only corrupt the tail of * the output. */ private function runDeferredRefresh(string $scope): void { try { $refresher = $this->refresherFor($scope); // Re-read the cache here: between the foreground read and this // point the cron backlog may have drained, or a sibling request // may have filled it. if ($refresher === null || !($refresher['needs_refresh'])()) { return; } if (!$this->claimDeferredRefreshWindow($scope)) { return; } $this->performRefresh($scope, self::DEFERRED_REFRESH_QUERY_TIMEOUT_SECONDS); } catch (\Throwable $e) { call_user_func($this->warn, 'Deferred status-count refresh failed for scope ' . $scope . ': ' . get_class($e) . ': ' . $e->getMessage() ); } } /** * Cross-request rate limit. Recorded BEFORE the aggregate runs so a query * that fails (or is killed) still consumes the window. */ private function claimDeferredRefreshWindow(string $scope): bool { if (!function_exists('get_transient') || !function_exists('set_transient')) { return true; } $key = self::DEFERRED_REFRESH_COOLDOWN_KEY_PREFIX . $scope; if (get_transient($key) !== false) { return false; } set_transient($key, 1, self::DEFERRED_REFRESH_COOLDOWN_SECONDS); return true; } /** * @template T * @param array $fields * @param callable():T $work * @return T */ private static function trace(string $operation, array $fields, callable $work) { if (self::$operationTracer === null) { return $work(); } return (self::$operationTracer)($operation, $fields, $work); } }