dbCore = $dbCore !== null ? $dbCore : abj_service('db_core'); $this->logger = $logging !== null ? $logging : abj_service('logging'); $this->similarity = $similarity instanceof ABJ_404_Solution_NGramSimilarity ? $similarity : new ABJ_404_Solution_NGramSimilarity(); $this->coveragePolicyResolver = $coveragePolicyResolver; $this->rowDecoder = new ABJ_404_Solution_NGramCacheRowDecoder($this->logger); } /** * Store N-grams for a page. * * @param int $pageId * @param string $url * @param string $urlNormalized * @param array $ngrams * @param string $type Entity type: 'post', 'page', 'category', 'tag' * @param bool $skipInvalidation Skip coverage invalidation (for bulk operations) * @return bool */ public function storeNGrams($pageId, $url, $urlNormalized, $ngrams, $type = 'post', $skipInvalidation = false) { if (!is_numeric($pageId) || $pageId <= 0) { $this->logger->errorMessage("Invalid page ID for N-gram storage: " . var_export($pageId, true)); return false; } if (!is_array($ngrams) || !isset($ngrams['bi']) || !isset($ngrams['tri'])) { $this->logger->errorMessage("Invalid N-gram structure for page ID {$pageId}"); return false; } if (!is_array($ngrams['bi']) || !is_array($ngrams['tri'])) { $this->logger->errorMessage("Invalid N-gram array types for page ID {$pageId}"); return false; } $ngramJson = json_encode($ngrams); if ($ngramJson === false) { $this->logger->errorMessage("Failed to JSON encode N-grams for page ID {$pageId}"); return false; } $ngramCount = count($ngrams['bi']) + count($ngrams['tri']); $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); // REPLACE = DELETE + INSERT. Routed through DAO for timeout/retry/recovery. $queryResult = $this->dbCore->queryAndGetResults( "REPLACE INTO {$table} (id, type, url, url_normalized, ngrams, ngram_count, last_updated) VALUES (%d, %s, %s, %s, %s, %d, %d)", ['query_params' => [ (int)$pageId, $type, $url, $urlNormalized, $ngramJson, $ngramCount, abj_clock()->wpNow(), ]] ); $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; if ($lastError !== '') { global $wpdb; $dbName = isset($wpdb->dbname) && is_string($wpdb->dbname) ? $wpdb->dbname : ''; $errorContext = sprintf( "Failed to store N-grams for page ID %d: %s, Table: %s, Prefix: %s, DB: %s", $pageId, $lastError, $table, $this->dbCore->tableNameResolver()->getLowercasePrefix(), $dbName ); if (is_multisite()) { $errorContext .= sprintf(", Blog ID: %d", get_current_blog_id()); } if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($lastError)) { $this->logger->errorMessage($errorContext); } return false; } if (!$this->querySucceeded($queryResult)) { // REPLACE may have committed before the driver returned false. // Invalidate conservatively, but never report the row as stored. $this->cacheCountMemo = null; $this->invalidateCoverageCaches(); $this->logger->warn("N-gram store did not confirm success for page ID {$pageId}."); return false; } $this->cacheCountMemo = null; if (!$skipInvalidation) { $this->invalidateCoverageCaches(); } return true; } /** * Get N-grams for a specific page. * * @param int $pageId * @param string $type * @return array{bi: array, tri: array}|null */ public function getNGramsForPage($pageId, $type = 'post') { $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); $queryResult = $this->dbCore->queryAndGetResults( "SELECT ngrams FROM {$table} WHERE id = %d AND type = %s", ['query_params' => [$pageId, $type]] ); $rows = isset($queryResult['rows']) && is_array($queryResult['rows']) ? $queryResult['rows'] : []; $first = $rows[0] ?? null; if (!is_array($first) || !isset($first['ngrams']) || !is_string($first['ngrams'])) { return null; } return $this->rowDecoder->decodePage($first['ngrams']); } /** * Get all cached N-grams for similarity queries. * * Loads entire table into memory; only safe for small caches. Above * CACHE_LOAD_LIMIT (1000) callers should use getCachedNGramsFiltered(). * * @param string|null $type When non-null, restrict the load to a single * entity type ('post', 'category', 'tag', ...). Null preserves the * historical all-types scan (the posts path is unaffected). * @param ABJ_404_Solution_MatchingTimeBudget|null $budget Request time budget (null = unbounded). * @return array> */ public function getAllCachedNGrams(?string $type = null, ?ABJ_404_Solution_MatchingTimeBudget $budget = null) { $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); $count = ($type !== null) ? $this->getCacheCountForType((string)$type) : $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$table}"); if ($count > 10000) { $this->logger->errorMessage("CRITICAL: N-gram cache has {$count} entries. Cannot load into memory. Feature disabled for this request."); return []; } if ($count > 5000) { $this->logger->infoMessage("WARNING: N-gram cache has {$count} entries. This may cause memory issues."); } if ($type !== null) { $listResult = $this->dbCore->queryAndGetResults( "SELECT id, url, url_normalized, ngrams, ngram_count FROM {$table} WHERE type = %s", ['query_params' => [(string)$type]] ); } else { $listResult = $this->dbCore->queryAndGetResults( "SELECT id, url, url_normalized, ngrams, ngram_count FROM {$table}" ); } $results = isset($listResult['rows']) && is_array($listResult['rows']) ? $listResult['rows'] : []; if (empty($results)) { return []; } return $this->rowDecoder->decodeAllRows($results, $budget); } /** * Get cached N-grams with database-side range filtering. * * Two-range strategy avoids filesort from ORDER BY ABS(): splits the * query into below-target (DESC) and above-target (ASC), then merges * by proximity to target in PHP. * * @param ABJ_404_Solution_NGramCountRangeQuery $query The ngram_count window, * row limit, order target and optional single entity type. A null type * preserves the all-types scan. * @param ABJ_404_Solution_MatchingTimeBudget|null $budget Request time budget (null = unbounded). * @return array> */ public function getCachedNGramsFiltered(ABJ_404_Solution_NGramCountRangeQuery $query, ?ABJ_404_Solution_MatchingTimeBudget $budget = null) { $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); $limit = $query->limit(); $halfLimit = (int)ceil($limit / 2); $resultsBelow = $this->fetchBelowTarget($table, $query, $halfLimit, 0); $belowCount = count($resultsBelow); $aboveLimit = $limit - $belowCount; $resultsAbove = $this->fetchAboveTarget($table, $query, $aboveLimit, 0); $aboveCount = count($resultsAbove); $totalFetched = $belowCount + $aboveCount; // Balance for skewed distributions: if one side hit its cap and the // other has headroom, pull more from the saturated side. if ($totalFetched < $limit && $belowCount === $halfLimit) { $extra = $this->fetchBelowTarget($table, $query, $limit - $totalFetched, $belowCount); $resultsBelow = array_merge($resultsBelow, $extra); $totalFetched = count($resultsBelow) + $aboveCount; } if ($totalFetched < $limit && $aboveCount === $aboveLimit) { $extra = $this->fetchAboveTarget($table, $query, $limit - $totalFetched, $aboveCount); $resultsAbove = array_merge($resultsAbove, $extra); } $merged = $this->similarity->mergeByProximity($resultsBelow, $resultsAbove, $query->orderTarget(), $limit); return $this->rowDecoder->decodeFilteredRows($merged, $budget); } /** * Rows in [minCount, orderTarget], nearest the target first. * * @param string $table * @param ABJ_404_Solution_NGramCountRangeQuery $query * @param int $limit * @param int $offset * @return array */ private function fetchBelowTarget($table, ABJ_404_Solution_NGramCountRangeQuery $query, $limit, $offset) { $type = $query->type(); $typeClause = ($type !== null) ? " AND type = %s" : ''; $params = ($type !== null) ? [$query->minCount(), $query->orderTarget(), $type, $limit, $offset] : [$query->minCount(), $query->orderTarget(), $limit, $offset]; $result = $this->dbCore->queryAndGetResults( "SELECT id, url, url_normalized, ngrams, ngram_count FROM {$table} WHERE ngram_count >= %d AND ngram_count <= %d{$typeClause} ORDER BY ngram_count DESC, id DESC LIMIT %d OFFSET %d", ['query_params' => $params] ); return isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : []; } /** * Rows in (orderTarget, maxCount], nearest the target first. * * @param string $table * @param ABJ_404_Solution_NGramCountRangeQuery $query * @param int $limit * @param int $offset * @return array */ private function fetchAboveTarget($table, ABJ_404_Solution_NGramCountRangeQuery $query, $limit, $offset) { $type = $query->type(); $typeClause = ($type !== null) ? " AND type = %s" : ''; $params = ($type !== null) ? [$query->orderTarget(), $query->maxCount(), $type, $limit, $offset] : [$query->orderTarget(), $query->maxCount(), $limit, $offset]; $result = $this->dbCore->queryAndGetResults( "SELECT id, url, url_normalized, ngrams, ngram_count FROM {$table} WHERE ngram_count > %d AND ngram_count <= %d{$typeClause} ORDER BY ngram_count ASC, id ASC LIMIT %d OFFSET %d", ['query_params' => $params] ); return isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : []; } /** * Invalidate (delete) N-grams for a specific page. * * @param int $pageId * @param string $type * @return bool */ public function invalidatePage($pageId, $type = 'post') { $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); $queryResult = $this->dbCore->queryAndGetResults( "DELETE FROM {$table} WHERE id = %d AND type = %s", ['query_params' => [(int)$pageId, $type]] ); $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : ''; $success = $lastError === '' && $this->querySucceeded($queryResult); if (!$success) { // A DELETE can commit before a false native return. Do not claim // deletion, but clear derived cache state so a committed delete is // not hidden by this request's memo or coverage ratio. $this->cacheCountMemo = null; $this->invalidateCoverageCaches(); if ($lastError === '') { $this->logger->warn("N-gram invalidation did not confirm success for page ID {$pageId}."); } } else { $this->cacheCountMemo = null; $this->invalidateCoverageCaches(); } return $success; } /** * Get cache entry count (memoized per-request). * * Use this instead of getCacheStats() when only the count is needed. * * @return int */ public function getCacheCount() { if ($this->cacheCountMemo !== null) { return $this->cacheCountMemo; } global $wpdb; $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); if (!isset($wpdb) || !is_object($wpdb) || !is_callable([$wpdb, 'get_var'])) { // Test environments / very early bootstrap: treat as no cache. $this->cacheCountMemo = 0; return $this->cacheCountMemo; } $this->cacheCountMemo = $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$table}"); return $this->cacheCountMemo; } /** * Count cache entries of a single type ('post', 'category', 'tag', ...). * * Used by the type-scoped term prefilter to make its load-limit decision * and to feed the term coverage policy's readiness gate. Not memoized: * the term path calls this at most twice per request (count + ratio), * both against the indexed `type` column. * * @param string $type * @return int */ public function getCacheCountForType(string $type): int { $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); return $this->dbCore->queryScalarInt( "SELECT COUNT(*) AS c FROM {$table} WHERE type = %s", ['query_params' => [$type]] ); } /** * Reset per-request memoization. * * Used after bulk operations that bypass storeNGrams() (e.g. TRUNCATE * during rebuild) so the next getCacheCount() reads fresh state. * * @return void */ public function resetMemo() { $this->cacheCountMemo = null; } /** * Get cache statistics for admin display. * * @return array */ public function getCacheStats() { $table = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache'); $totalEntries = $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$table}"); $postsEntries = $this->getCacheCountForType('post'); $categoryEntries = $this->getCacheCountForType('category'); $tagEntries = $this->getCacheCountForType('tag'); $lastUpdatedResult = $this->dbCore->queryAndGetResults( "SELECT MAX(last_updated) AS m FROM {$table}" ); $lastUpdatedRows = isset($lastUpdatedResult['rows']) && is_array($lastUpdatedResult['rows']) ? $lastUpdatedResult['rows'] : []; $lastUpdatedFirst = $lastUpdatedRows[0] ?? null; $lastUpdated = is_array($lastUpdatedFirst) && isset($lastUpdatedFirst['m']) ? $lastUpdatedFirst['m'] : null; return [ 'total_entries' => $totalEntries, 'posts_entries' => $postsEntries, 'category_entries' => $categoryEntries, 'tag_entries' => $tagEntries, 'last_updated' => $lastUpdated, ]; } /** * Invalidate coverage caches by resolving the policy lazily. * * Lazy resolution avoids constructor-time coupling between the * repository and coverage policy services. * * @return void */ private function invalidateCoverageCaches() { $resolver = $this->coveragePolicyResolver; if (!is_callable($resolver)) { return; } $policy = $resolver(); if ($policy instanceof ABJ_404_Solution_NGramCoveragePolicy) { $policy->invalidateCoverageCaches(); } } /** @param array $result */ private function querySucceeded(array $result): bool { return array_key_exists('query_succeeded', $result) && $result['query_succeeded'] === true && (!array_key_exists('timed_out', $result) || $result['timed_out'] === false); } }