,callable):mixed|null */ private static $operationTracer = null; /** @var ABJ_404_Solution_DatabaseQueryInterface */ private $dbCore; /** @var string See lastRecomputeFailure(). */ private $lastRecomputeFailure = ''; /** @var ABJ_404_Solution_LogsRepository */ private $logsRepo; /** @var ABJ_404_Solution_ViewQueryBuilder */ private $queryBuilder; /** @var ABJ_404_Solution_TableReadinessGate */ private $readiness; /** * @param ABJ_404_Solution_DatabaseQueryInterface $dbCore * @param ABJ_404_Solution_LogsRepository $logsRepo * @param ABJ_404_Solution_ViewQueryBuilder $queryBuilder * @param ABJ_404_Solution_TableReadinessGate $readiness */ public function __construct( ABJ_404_Solution_DatabaseQueryInterface $dbCore, ABJ_404_Solution_LogsRepository $logsRepo, ABJ_404_Solution_ViewQueryBuilder $queryBuilder, ABJ_404_Solution_TableReadinessGate $readiness ) { $this->dbCore = $dbCore; $this->logsRepo = $logsRepo; $this->queryBuilder = $queryBuilder; $this->readiness = $readiness; } /** @param callable(string,array,callable):mixed|null $tracer */ public static function setOperationTracer($tracer): void { self::$operationTracer = $tracer; } /** * Why the most recent recompute*() returned false ('' after a success). The * bool flattens "table absent", "timed out" and "query error"; the caller * that logs (the refresh coordinator) knows the scope, this class the cause. */ public function lastRecomputeFailure(): string { return $this->lastRecomputeFailure; } /** Record a failed recompute and return false, so each exit stays one line. */ private function failRecompute(string $reason): bool { $this->lastRecomputeFailure = $reason; return false; } /** @param mixed $result A queryAndGetResults() result. */ private function describeQueryFailure($result, int $timeoutSeconds): string { if (is_array($result) && !empty($result['timed_out'])) { return 'aggregate timed out (budget ' . $timeoutSeconds . 's)'; } $error = (is_array($result) && isset($result['last_error']) && is_string($result['last_error'])) ? $result['last_error'] : ''; if ($error !== '') { return 'aggregate query error: ' . substr($error, 0, 300); } return 'aggregate query did not confirm success.'; } /** * Read redirect counts without touching the database. * * @return array{counts: array, needs_refresh: bool, incomplete: bool} */ public function readRedirectStatusCountsCache(): array { return self::trace( 'status_cache_read', array('scope' => 'redirects'), function (): array { return $this->readStatusCountsCache( self::CACHE_KEY_REDIRECT_STATUS, self::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN, 'redirect_current', 'redirect_last_known' ); } ); } /** * Recompute redirect counts. Called from the cron listener and from the * coordinator's shutdown backstop, which passes a smaller query budget. * * @param int|null $timeoutSeconds Null uses the unattended budget. */ public function recomputeRedirectStatusCounts(?int $timeoutSeconds = null): bool { return $this->recomputeScope( ABJ_404_Solution_StatusCountBuckets::SCOPE_REDIRECTS, self::CACHE_KEY_REDIRECT_STATUS, self::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN, $timeoutSeconds ); } /** * Read captured counts without touching the database. * * @return array{counts: array, needs_refresh: bool, incomplete: bool} */ public function readCapturedStatusCountsCache(): array { return self::trace( 'status_cache_read', array('scope' => 'captured'), function (): array { return $this->readStatusCountsCache( self::CACHE_KEY_CAPTURED_STATUS, self::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN, 'captured_current', 'captured_last_known' ); } ); } /** * Recompute captured counts. Called from the cron listener and from the * coordinator's shutdown backstop, which passes a smaller query budget. * * @param int|null $timeoutSeconds Null uses the unattended budget. */ public function recomputeCapturedStatusCounts(?int $timeoutSeconds = null): bool { return $this->recomputeScope( ABJ_404_Solution_StatusCountBuckets::SCOPE_CAPTURED, self::CACHE_KEY_CAPTURED_STATUS, self::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN, $timeoutSeconds ); } /** * Run one scope's bucket aggregate and cache it. * * The redirect and captured scopes differ only in which statuses they * cover and what the per-status buckets are called, so both the SELECT * list and the result mapping are generated from * ABJ_404_Solution_StatusCountBuckets. That is deliberate rather than * merely tidy: the incremental delta path applied at mutation time reads * the same definition, and a second hand-written copy of the bucket rules * here could disagree with it without any test noticing (the aggregate * would just quietly overwrite the delta on the next cron tick). * * @param string $scope One of the StatusCountBuckets SCOPE_* constants. * @param string $cacheKey * @param string $lastKnownKey * @param int|null $timeoutSeconds Null uses the unattended budget. */ private function recomputeScope( string $scope, string $cacheKey, string $lastKnownKey, ?int $timeoutSeconds ): bool { if ($this->readiness->isKnownAbsent('{wp_abj404_redirects}')) { return $this->failRecompute('redirects table not present'); } $query = $this->dbCore->doTableNameReplacements(self::buildScopeAggregateQuery($scope)); $budget = self::resolveTimeout($timeoutSeconds, self::STATUS_QUERY_TIMEOUT_SECONDS); $result = $this->dbCore->queryAndGetResults($query, array('timeout' => $budget)); $hadError = !array_key_exists('query_succeeded', $result) || $result['query_succeeded'] !== true || !empty($result['last_error']) || !empty($result['timed_out']); if ($hadError) { return $this->failRecompute($this->describeQueryFailure($result, $budget)); } $rows = is_array($result['rows']) ? $result['rows'] : array(); $counts = ABJ_404_Solution_StatusCountBuckets::zeroCounts($scope); if (!empty($rows)) { $row = is_array($rows[0] ?? null) ? $rows[0] : array(); foreach (array_keys($counts) as $bucket) { $counts[$bucket] = self::scalarToInt($row[$bucket] ?? 0); } } set_transient($cacheKey, $counts, self::STATUS_CACHE_TTL); set_transient($lastKnownKey, $counts, self::STATUS_LAST_KNOWN_CACHE_TTL); $this->lastRecomputeFailure = ''; return true; } /** * The SUM(CASE WHEN ...) aggregate for one scope. Every column is aliased * to its bucket name so the result maps straight onto the cached shape. * * @param string $scope One of the StatusCountBuckets SCOPE_* constants. */ private static function buildScopeAggregateQuery(string $scope): string { $statusBuckets = ABJ_404_Solution_StatusCountBuckets::bucketsByStatus($scope); $selects = array( "SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) as `" . ABJ_404_Solution_StatusCountBuckets::BUCKET_ALL . "`", ); foreach ($statusBuckets as $status => $bucket) { $selects[] = "SUM(CASE WHEN disabled = 0 AND status = " . intval($status) . " THEN 1 ELSE 0 END) as `" . $bucket . "`"; } $selects[] = "SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) as `" . ABJ_404_Solution_StatusCountBuckets::BUCKET_TRASH . "`"; return "SELECT\n " . implode(",\n ", $selects) . "\n FROM {wp_abj404_redirects}" . "\n WHERE status IN (" . implode(', ', array_map('intval', array_keys($statusBuckets))) . ")"; } /** * @return array{counts: array, needs_refresh: bool, incomplete: bool} */ private function readStatusCountsCache( string $currentKey, string $lastKnownKey, string $currentFamily, string $lastKnownFamily ): array { $current = self::trace( 'transient_read', array('family' => $currentFamily, 'expected' => 'array'), static fn() => get_transient($currentKey) ); if (is_array($current)) { /** @var array $current */ return array('counts' => $current, 'needs_refresh' => false, 'incomplete' => false); } $lastKnown = self::trace( 'transient_read', array('family' => $lastKnownFamily, 'expected' => 'array'), static fn() => get_transient($lastKnownKey) ); if (is_array($lastKnown)) { /** @var array $lastKnown */ return array('counts' => $lastKnown, 'needs_refresh' => true, 'incomplete' => false); } return array('counts' => array(), 'needs_refresh' => true, 'incomplete' => true); } /** * Read the high-impact count without touching the database. * * @return array{count:?int,needs_refresh:bool} */ public function readHighImpactCapturedCountCache(): array { return self::trace( 'status_cache_read', array('scope' => 'high_impact'), static function (): array { $current = self::trace( 'transient_read', array('family' => 'high_impact_current', 'expected' => 'numeric'), static fn() => get_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED) ); $currentCount = ABJ_404_Solution_ExactInteger::read($current, 0); if ($currentCount !== null) { return array('count' => $currentCount, 'needs_refresh' => false); } $lastKnown = self::trace( 'transient_read', array('family' => 'high_impact_last_known', 'expected' => 'numeric'), static fn() => get_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN) ); $lastKnownCount = ABJ_404_Solution_ExactInteger::read($lastKnown, 0); if ($lastKnownCount !== null) { return array('count' => $lastKnownCount, 'needs_refresh' => true); } return array('count' => null, 'needs_refresh' => true); } ); } /** * Recompute the high-impact count. Called from the cron listener and from * the coordinator's shutdown backstop, which passes a smaller query budget. * * @param int|null $timeoutSeconds Null uses the unattended budget. */ public function recomputeHighImpactCapturedCount(?int $timeoutSeconds = null): bool { if ($this->readiness->isKnownAbsent('{wp_abj404_redirects}')) { return $this->failRecompute('redirects table not present'); } if (!$this->logsRepo->logsHitsTableExists()) { $this->logsRepo->scheduleHitsTableRebuild(); return $this->failRecompute('logs_hits table not present; rebuild scheduled'); } $query = $this->queryBuilder->buildHighImpactCapturedCountQuery(); $budget = self::resolveTimeout($timeoutSeconds, self::HIGH_IMPACT_QUERY_TIMEOUT_SECONDS); $result = $this->dbCore->queryAndGetResults($query, array('timeout' => $budget)); $timedOut = !empty($result['timed_out']); $hadError = !array_key_exists('query_succeeded', $result) || $result['query_succeeded'] !== true || !empty($result['last_error']) || $timedOut; $rows = is_array($result['rows']) ? $result['rows'] : array(); $firstRow = (!empty($rows) && is_array($rows[0] ?? null)) ? $rows[0] : array(); $count = self::scalarToInt($firstRow['cnt'] ?? 0); if ($timedOut) { $this->logsRepo->scheduleHitsTableRebuild(); return $this->failRecompute($this->describeQueryFailure($result, $budget) . '; rebuild scheduled'); } if ($hadError) { return $this->failRecompute($this->describeQueryFailure($result, $budget)); } if ($count === 0 && $this->isHitsTableEmpty()) { $this->logsRepo->scheduleHitsTableRebuild(); return $this->failRecompute('logs_hits table empty; rebuild scheduled'); } set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED, $count, self::STATUS_CACHE_TTL); set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN, $count, self::STATUS_LAST_KNOWN_CACHE_TTL); $this->lastRecomputeFailure = ''; return true; } /** * Probe the hits table for "is this rollup empty?" to distinguish a real * zero from a not-yet-rebuilt state. * * @return bool */ private function isHitsTableEmpty(): bool { $check = "SELECT 1 FROM {wp_abj404_logs_hits} LIMIT 1"; $check = $this->dbCore->doTableNameReplacements($check); $result = $this->dbCore->queryAndGetResults($check); if (!array_key_exists('query_succeeded', $result) || $result['query_succeeded'] !== true || !empty($result['last_error']) || !empty($result['timed_out'])) { return false; } $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); return empty($rows); } /** * A caller-supplied budget never exceeds the unattended one: the deferred * foreground path may only ask for LESS time, never more. */ private static function resolveTimeout(?int $requested, int $unattended): int { if ($requested === null || $requested < 1) { return $unattended; } return min($requested, $unattended); } /** * @param mixed $value * @return int */ private static function scalarToInt($value): int { return is_scalar($value) ? intval($value) : 0; } /** * @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); } }