*/ private $redirectsForViewCountRequestCache = array(); /** @var ABJ_404_Solution_ViewReadOutcome Classifier for the last row-read outcome (i455). */ private $lastReadOutcome; /** * @param ABJ_404_Solution_DatabaseCore $dbCore * @param ABJ_404_Solution_ViewQueryBuilder $queryBuilder * @param ABJ_404_Solution_ViewDiagnostics $diagnostics * @param ABJ_404_Solution_ViewCacheInvalidator $cacheInvalidator * @param ABJ_404_Solution_RedirectsViewLiveResolver $liveResolver * @param ABJ_404_Solution_Logging $logger */ public function __construct( ABJ_404_Solution_DatabaseCore $dbCore, ABJ_404_Solution_ViewQueryBuilder $queryBuilder, ABJ_404_Solution_ViewDiagnostics $diagnostics, ABJ_404_Solution_ViewCacheInvalidator $cacheInvalidator, ABJ_404_Solution_RedirectsViewLiveResolver $liveResolver, $logger ) { $this->dbCore = $dbCore; $this->queryBuilder = $queryBuilder; $this->diagnostics = $diagnostics; $this->cacheInvalidator = $cacheInvalidator; $this->liveResolver = $liveResolver; $this->logger = $logger; $this->lastReadOutcome = new ABJ_404_Solution_ViewReadOutcome(); } /** * Single-table redirects read for one page (Denorm Step 3b): fetch the * ordered/filtered page off wp_abj404_redirects, then resolve the visible * rows' derived/display values live and write the four denorm columns back. * This is the live read path that replaced the staged view_done read; it is * the single source of truth for the live single-table read (ViewReadService * delegates its readRedirectsSingleTable() here). * * The derived columns are still READ (and live-resolved for display) when * present, but the live write-back can be suppressed per-request via the * `_abj404_suppress_denorm_writeback` tableOptions flag: a change-detection * probe (the detect-only signature poll) resolves values to compute its * signature but must not mutate rows. The full-render path leaves the flag * unset, so it still persists fresh values (the freshness mechanism). * * @param string $sub * @param array $tableOptions * @return array> */ public function readRedirectsSingleTable(string $sub, array $tableOptions): array { $readiness = $this->liveResolver->schemaReadiness(); $derivedPresent = $readiness->derivedColumnsPresent(); // Tell the query builder whether each narrow sort key is SAFE to ORDER BY, // index-ordered. The single authority is RedirectsViewLiveResolver:: // sortKeyReadyForColumn (column exists AND its composite indexes exist AND // the legacy-row drain latch is set). The header UI consults the same // predicate via ViewReadService::isSortReadyForOrderby, so the sort the // query refuses to order by is exactly the one the header disables. When a // key is not ready the query builder serves the safe default instead of a // wide-column filesort that, on a large captured table, can exceed a shared // host's max_statement_time. Same _abj404_* option convention as the // timeout / suppress-writeback flags. $tableOptions['_abj404_dest_sort_key_present'] = $readiness->sortKeyReadyForColumn('dest_sort_key'); $tableOptions['_abj404_url_sort_key_present'] = $readiness->sortKeyReadyForColumn('url_sort_key'); $rows = $this->queryBuilder->readRedirectsSingleTable($sub, $tableOptions, $derivedPresent); $persist = $derivedPresent && empty($tableOptions['_abj404_suppress_denorm_writeback']); return $this->liveResolver->resolveAndPersistVisibleRows($rows, $persist); } /** * Single-table filtered count against wp_abj404_redirects (Denorm Step 3b). * * @param string $sub * @param array $tableOptions * @return int */ public function countRedirectsSingleTable(string $sub, array $tableOptions): int { return $this->queryBuilder->countRedirectsSingleTable($sub, $tableOptions, $this->liveResolver->schemaReadiness()->derivedColumnsPresent()); } /** * @param string $sub * @param array $tableOptions * @return array */ public function getRedirectsForView($sub, $tableOptions) { $tableOptionsArray = is_array($tableOptions) ? $tableOptions : array(); $throwOnQueryError = !empty($tableOptionsArray['_abj404_throw_on_view_query_error']); try { $rows = $this->readRedirectsSingleTable((string)$sub, $tableOptionsArray); } catch (Throwable $e) { $this->lastReadOutcome->markErrored(); if ($throwOnQueryError) { $failureMarker = '/* single-table: ' . $e->getMessage() . ' */'; $diagnostics = $this->diagnostics->captureViewQueryFailureDiagnostics( (string)$sub, $failureMarker, $tableOptionsArray, array('last_error' => $e->getMessage(), 'timed_out' => false) ); $diagnostics['failed_query_label'] = 'getRedirectsForView'; $diagnostics['staged_error'] = $e->getMessage(); $message = 'getRedirectsForView failed; last_error=' . $e->getMessage() . '; timed_out=false; sql_source=' . $failureMarker; throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics); } $this->logger->errorMessage('[single-table] getRedirectsForView failed: ' . $e->getMessage(), $e instanceof \Exception ? $e : null); return array(); } $this->logger->debugMessage(sprintf( '[single-table] getRedirectsForView returned %d rows for page %s', count($rows), (string)$sub )); $this->finalizeReadStatusForRows($rows, (string)$sub, $tableOptionsArray); return $rows; } /** * Record how to read the last getRedirectsForView() result (i455). The * live source count is probed lazily, only for empty rows. * * @param array $rows * @param string $sub * @param array $tableOptions * @return void */ private function finalizeReadStatusForRows(array $rows, string $sub, array $tableOptions): void { $this->lastReadOutcome->classifyRows($rows, $tableOptions, function () use ($sub, $tableOptions): int { try { return $this->getRedirectsForViewCount($sub, $tableOptions); } catch (Throwable $e) { $this->logger->debugMessage('[single-table] live-count probe for empty read failed: ' . $e->getMessage()); return -1; } }); } /** * Whether the last getRedirectsForView() result is NOT a trustworthy empty * listing (errored/stale-empty). See ABJ_404_Solution_ViewReadOutcome. * * @return bool */ public function lastRedirectsViewReadWasIncomplete(): bool { return $this->lastReadOutcome->wasIncomplete(); } /** * @param string $sub * @param array $tableOptions * @return int Negative when the count query was incomplete or unavailable. */ public function getRedirectsForViewCount(string $sub, array $tableOptions): int { $queryTimeout = isset($tableOptions['_abj404_query_timeout']) && is_numeric($tableOptions['_abj404_query_timeout']) ? max(1, intval($tableOptions['_abj404_query_timeout'])) : 0; $throwOnQueryError = !empty($tableOptions['_abj404_throw_on_view_query_error']); $requestCountCacheKey = (string)$sub . '|' . md5(serialize($tableOptions)); if (array_key_exists($requestCountCacheKey, $this->redirectsForViewCountRequestCache)) { return intval($this->redirectsForViewCountRequestCache[$requestCountCacheKey]); } $rawFilterText = is_string($tableOptions['filterText'] ?? null) ? $tableOptions['filterText'] : ''; if ($rawFilterText !== '') { return $this->getFilteredViewCount($sub, $tableOptions, $throwOnQueryError, $requestCountCacheKey); } return $this->getUnfilteredViewCount($sub, $tableOptions, $queryTimeout, $throwOnQueryError, $requestCountCacheKey); } /** * @param string $sub * @param array $tableOptions * @param bool $throwOnQueryError * @param string $requestCountCacheKey * @return int */ private function getFilteredViewCount( string $sub, array $tableOptions, bool $throwOnQueryError, string $requestCountCacheKey ): int { try { $countValue = $this->countRedirectsSingleTable((string)$sub, $tableOptions); $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue; return $countValue; } catch (Throwable $e) { return $this->handleFilteredCountFailure($sub, $tableOptions, $throwOnQueryError, $requestCountCacheKey, $e); } } /** * @param string $sub * @param array $tableOptions * @param bool $throwOnQueryError * @param string $requestCountCacheKey * @param Throwable $e * @return int */ private function handleFilteredCountFailure( string $sub, array $tableOptions, bool $throwOnQueryError, string $requestCountCacheKey, Throwable $e ): int { if ($throwOnQueryError) { $failureMarker = '/* single-table-count: ' . $e->getMessage() . ' */'; $diagnostics = $this->diagnostics->captureViewQueryFailureDiagnostics( (string)$sub, $failureMarker, $tableOptions, array('last_error' => $e->getMessage(), 'timed_out' => false) ); $diagnostics['failed_query_label'] = 'getRedirectsForViewCount'; $diagnostics['staged_error'] = $e->getMessage(); throw new ABJ_404_Solution_ViewQueryFailureException($e->getMessage(), $diagnostics); } $this->logger->errorMessage('[single-table] getRedirectsForViewCount failed: ' . $e->getMessage(), $e instanceof \Exception ? $e : null); $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; return -1; } /** * @param string $sub * @param array $tableOptions * @param int $queryTimeout * @param bool $throwOnQueryError * @param string $requestCountCacheKey * @return int */ private function getUnfilteredViewCount( string $sub, array $tableOptions, int $queryTimeout, bool $throwOnQueryError, string $requestCountCacheKey ): int { $query = $this->queryBuilder->getOptimizedRedirectsForViewCountQuery($sub, $tableOptions); $this->cacheInvalidator->setSqlBigSelects(); $queryOptions = $queryTimeout > 0 ? array('timeout' => $queryTimeout) : array(); $results = $this->dbCore->queryAndGetResults($query, $queryOptions); $lastErrorRaw = $results['last_error'] ?? ''; $lastError = is_string($lastErrorRaw) ? $lastErrorRaw : ''; $queryFailed = !empty($results['timed_out']) || trim($lastError) !== ''; if ($queryFailed) { if ($throwOnQueryError) { $message = $this->diagnostics->formatViewQueryFailureMessage('getRedirectsForViewCount', $query, $results); $diagnostics = $this->diagnostics->captureViewQueryFailureDiagnostics($sub, $query, $tableOptions, $results); $diagnostics['failed_query_label'] = 'getRedirectsForViewCount'; throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics); } $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; return -1; } $rows = is_array($results['rows']) ? $results['rows'] : array(); if (empty($rows) || !is_array($rows[0])) { $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; return -1; } $row = is_array($rows[0] ?? null) ? $rows[0] : array(); $rawCount = $row['count'] ?? $row['COUNT(*)'] ?? reset($row); if (!is_numeric($rawCount)) { $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; return -1; } $countValue = intval($rawCount); $this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue; return $countValue; } }