diff --git a/app/Models/Category.php b/app/Models/Category.php index 5a9fc2c6d..9a58cd4a6 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -308,6 +308,13 @@ class Category extends Model public static function getCategorySearch(array $cat = [], ?string $searchType = null, $builder = false): string|array|null { + // Generate a cache key based on the input parameters + $cacheKey = 'cat_search_' . md5(serialize($cat) . $searchType . ($builder ? '1' : '0')); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + $categories = []; // if searchType is tv return TV categories (only if no specific categories provided) @@ -334,7 +341,13 @@ class Category extends Model foreach ($cat as $category) { $categoryInt = (int) $category; if (is_numeric($category) && $categoryInt !== -1 && self::isParent($categoryInt)) { - $children = RootCategory::find($categoryInt)->categories->pluck('id')->toArray(); + // Cache child category IDs for parent categories + $childCacheKey = 'cat_children_' . $categoryInt; + $children = Cache::get($childCacheKey); + if ($children === null) { + $children = RootCategory::find($categoryInt)->categories->pluck('id')->toArray(); + Cache::put($childCacheKey, $children, now()->addHours(24)); + } $categories = array_merge($categories, $children); } elseif (is_numeric($category) && $categoryInt > 0) { $categories[] = $categoryInt; @@ -344,18 +357,23 @@ class Category extends Model $catCount = count($categories); if ($builder) { - return match ($catCount) { + $result = match ($catCount) { 0 => null, 1 => $categories[0] !== -1 ? $categories : null, default => $categories, }; + Cache::put($cacheKey, $result, now()->addHours(24)); + return $result; } - return match ($catCount) { + $result = match ($catCount) { 0 => 'AND 1=1', 1 => $categories[0] !== -1 ? ' AND r.categories_id = '.$categories[0] : '', default => ' AND r.categories_id IN ('.implode(', ', $categories).') ', }; + + Cache::put($cacheKey, $result, now()->addHours(24)); + return $result; } /** @@ -382,9 +400,20 @@ class Category extends Model */ public static function isParent($cid): bool { - $ret = RootCategory::query()->where(['id' => $cid])->first(); + // Cache the parent category check to avoid repeated DB queries + $cacheKey = 'cat_is_parent_' . $cid; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } - return $ret !== null; + $ret = RootCategory::query()->where(['id' => $cid])->first(); + $isParent = $ret !== null; + + // Cache for a long time since parent categories rarely change + Cache::put($cacheKey, $isParent, now()->addHours(24)); + + return $isParent; } /** diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php index f7f55a45a..5b3b5a402 100644 --- a/app/Services/Releases/ReleaseBrowseService.php +++ b/app/Services/Releases/ReleaseBrowseService.php @@ -123,23 +123,94 @@ class ReleaseBrowseService /** * Used for pager on browse page. + * Optimized to avoid expensive COUNT queries on large tables. */ public function getBrowseCount(array $cat, int $maxAge = -1, array $excludedCats = [], int|string $groupName = ''): int { - return $this->getPagerCount(sprintf( - 'SELECT COUNT(r.id) AS count - FROM releases r - %s - WHERE r.passwordstatus %s - %s - %s %s %s ', - ($groupName !== -1 ? 'LEFT JOIN usenet_groups g ON g.id = r.groups_id' : ''), - $this->showPasswords(), - ($groupName !== -1 ? sprintf(' AND g.name = %s', escapeString($groupName)) : ''), - Category::getCategorySearch($cat), - ($maxAge > 0 ? (' AND r.postdate > NOW() - INTERVAL '.$maxAge.' DAY ') : ''), - (\count($excludedCats) ? (' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')') : '') - )); + $maxResults = (int) config('nntmux.max_pager_results', 500000); + $cacheExpiry = (int) config('nntmux.cache_expiry_short', 5); + + // Build a unique cache key for this specific query + $cacheKey = 'browse_count_' . md5(serialize($cat) . $maxAge . serialize($excludedCats) . $groupName); + + // Check cache first - use longer cache time for count queries since they're expensive + $count = Cache::get($cacheKey); + if ($count !== null) { + return (int) $count; + } + + // Build optimized count query - avoid JOINs when possible + $conditions = ['r.passwordstatus ' . $this->showPasswords()]; + + // Add category conditions + $catQuery = Category::getCategorySearch($cat); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); + if (!empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } + + if ($maxAge > 0) { + $conditions[] = 'r.postdate > NOW() - INTERVAL ' . $maxAge . ' DAY'; + } + + if (!empty($excludedCats)) { + $conditions[] = 'r.categories_id NOT IN (' . implode(',', array_map('intval', $excludedCats)) . ')'; + } + + $whereSql = 'WHERE ' . implode(' AND ', $conditions); + + try { + // For queries without specific filters (just category or all releases), + // use a quick estimation approach: check if we exceed maxResults using + // a small LIMIT query first, then only do full count if needed + if ($maxResults > 0) { + // Quick check: see if there are at least maxResults rows + // Using a small sample limit (1000) to quickly determine if we should + // just return maxResults or do a full count + $sampleLimit = min(1000, $maxResults); + $sampleQuery = sprintf( + 'SELECT r.id FROM releases r %s ORDER BY r.id DESC LIMIT %d', + $whereSql, + $sampleLimit + ); + $sampleResult = DB::select($sampleQuery); + $sampleCount = count($sampleResult); + + // If we got the full sample, there might be more - check with a larger query + // or just assume there are many rows and return maxResults + if ($sampleCount >= $sampleLimit) { + // For very large tables, just return maxResults to avoid expensive COUNT + // The UI will show "500,000+" which is fine for pagination + Cache::put($cacheKey, $maxResults, now()->addMinutes($cacheExpiry * 2)); + return $maxResults; + } + + // Fewer than sample limit, this is a small result set - get actual count + $count = $sampleCount; + } else { + // No max limit set, need full count + // If we need to filter by group name, we need the JOIN + if ((int) $groupName !== -1) { + $query = sprintf( + 'SELECT COUNT(r.id) AS count FROM releases r LEFT JOIN usenet_groups g ON g.id = r.groups_id %s AND g.name = %s', + $whereSql, + escapeString($groupName) + ); + } else { + $query = sprintf('SELECT COUNT(r.id) AS count FROM releases r %s', $whereSql); + } + $result = DB::select($query); + $count = isset($result[0]) ? (int) $result[0]->count : 0; + } + + // Cache with longer expiry for count queries + Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry * 2)); + + return $count; + } catch (\Exception $e) { + Log::error('getBrowseCount failed', ['error' => $e->getMessage()]); + return 0; + } } /**