Try to gain API speed

This commit is contained in:
DariusIII
2026-07-14 13:26:18 +02:00
parent 4a54077480
commit 064154ad12
19 changed files with 450 additions and 106 deletions
+12 -1
View File
@@ -159,6 +159,8 @@ STATUS_PROBE_REDIS_ONLY_WHEN_USED=true
# Horizon (Redis queue dashboard and workers) # Horizon (Redis queue dashboard and workers)
HORIZON_PREFIX=horizon: HORIZON_PREFIX=horizon:
# Priority-ordered queues consumed by Horizon.
HORIZON_QUEUES=api-audit,default
SESSION_DRIVER=redis SESSION_DRIVER=redis
# Fast TCP probe before other middleware; switch cache+session to file when Redis host is unreachable (set false to disable) # Fast TCP probe before other middleware; switch cache+session to file when Redis host is unreachable (set false to disable)
@@ -302,6 +304,16 @@ FORUM_FRONTEND_ENABLED=false
STREAM_FORK_OUTPUT=false STREAM_FORK_OUTPUT=false
# API hot-path caching, asynchronous audit persistence, and instrumentation.
API_RELEASE_CACHE_TTL=600
API_RELEASE_CACHE_JITTER=60
API_RELEASE_CACHE_STALE_TTL=900
API_RELEASE_CACHE_LOCK_TTL=15
API_ASYNC_AUDIT=true
API_AUDIT_QUEUE=api-audit
API_ACCESS_UPDATE_INTERVAL=60
API_METRICS_SAMPLE_RATE=0.01
TINYMCE_API_KEY= TINYMCE_API_KEY=
TMUX_USE_POWERLINE=true TMUX_USE_POWERLINE=true
@@ -346,4 +358,3 @@ SAIL_XDEBUG_MODE=off
#ELASTICSEARCH_HOST=elasticsearch #ELASTICSEARCH_HOST=elasticsearch
#MAIL_HOST=mailpit #MAIL_HOST=mailpit
#MAIL_PORT=1025 #MAIL_PORT=1025
+15 -17
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Events\UserAccessedApi;
use App\Http\Controllers\BasePageController; use App\Http\Controllers\BasePageController;
use App\Http\Controllers\GetNzbController; use App\Http\Controllers\GetNzbController;
use App\Models\Category; use App\Models\Category;
@@ -12,7 +11,6 @@ use App\Models\Release;
use App\Models\ReleaseNfo; use App\Models\ReleaseNfo;
use App\Models\Settings; use App\Models\Settings;
use App\Models\User; use App\Models\User;
use App\Models\UserRequest;
use App\Services\Api\ApiCapabilitiesService; use App\Services\Api\ApiCapabilitiesService;
use App\Services\Api\ApiQueryParameters; use App\Services\Api\ApiQueryParameters;
use App\Services\Api\ApiReleaseRowCache; use App\Services\Api\ApiReleaseRowCache;
@@ -49,7 +47,7 @@ class ApiController extends BasePageController
private ?ApiQueryParameters $queryParameters = null; private ?ApiQueryParameters $queryParameters = null;
private ?ApiUsageService $usageService = null; private ApiUsageService $usageService;
private ApiUserResolver $userResolver; private ApiUserResolver $userResolver;
@@ -153,7 +151,8 @@ class ApiController extends BasePageController
$apiKey = $request->input('apikey'); $apiKey = $request->input('apikey');
// Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2) // Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2)
$res = $this->userResolver->v1((string) $apiKey); $resolved = $request->attributes->get('nntmux.api_user');
$res = $resolved instanceof User ? $resolved : $this->userResolver->v1((string) $apiKey);
if ($res === null) { if ($res === null) {
return showApiError(100, 'Incorrect user credentials (wrong API key)'); return showApiError(100, 'Incorrect user credentials (wrong API key)');
@@ -177,9 +176,8 @@ class ApiController extends BasePageController
$grabs = (int) ($userStats->grab_count ?? 0); $grabs = (int) ($userStats->grab_count ?? 0);
} }
// Record user access to the api, if its been called by a user (i.e. capabilities request do not require a user to be logged in or key provided). // Quota enforcement happens before endpoint-specific request recording.
if ($uid !== '') { if ($uid !== '') {
event(new UserAccessedApi($res, $request->ip()));
if ($thisRequests > $maxRequests) { if ($thisRequests > $maxRequests) {
return showApiError(500, 'Request limit reached ('.$thisRequests.'/'.$maxRequests.')'); return showApiError(500, 'Request limit reached ('.$thisRequests.'/'.$maxRequests.')');
} }
@@ -218,7 +216,7 @@ class ApiController extends BasePageController
return $sort; return $sort;
} }
$groupName = $this->group($request); $groupName = $this->group($request);
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$categoryID = $this->categoryID($request); $categoryID = $this->categoryID($request);
$limit = $this->limit($request); $limit = $this->limit($request);
@@ -278,7 +276,7 @@ class ApiController extends BasePageController
if (! is_string($sort)) { if (! is_string($sort)) {
return $sort; return $sort;
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$categoryID = $this->categoryID($request); $categoryID = $this->categoryID($request);
$limit = $this->limit($request); $limit = $this->limit($request);
@@ -380,7 +378,7 @@ class ApiController extends BasePageController
if (! is_string($sort)) { if (! is_string($sort)) {
return $sort; return $sort;
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$categoryID = $this->categoryID($request); $categoryID = $this->categoryID($request);
$limit = $this->limit($request); $limit = $this->limit($request);
@@ -469,7 +467,7 @@ class ApiController extends BasePageController
return $sort; return $sort;
} }
$groupName = $this->group($request); $groupName = $this->group($request);
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$categoryID = $this->categoryID($request); $categoryID = $this->categoryID($request);
$limit = $this->limit($request); $limit = $this->limit($request);
@@ -533,7 +531,7 @@ class ApiController extends BasePageController
return $sort; return $sort;
} }
$groupName = $this->group($request); $groupName = $this->group($request);
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$categoryID = $this->categoryID($request); $categoryID = $this->categoryID($request);
$limit = $this->limit($request); $limit = $this->limit($request);
@@ -599,7 +597,7 @@ class ApiController extends BasePageController
if (! is_string($sort)) { if (! is_string($sort)) {
return $sort; return $sort;
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$limit = $this->limit($request); $limit = $this->limit($request);
$categoryID = $this->categoryID($request); $categoryID = $this->categoryID($request);
$relData = $this->releaseRowCache->remember('v1', 'anime', [ $relData = $this->releaseRowCache->remember('v1', 'anime', [
@@ -632,7 +630,7 @@ class ApiController extends BasePageController
if ($emptyParameterError !== null) { if ($emptyParameterError !== null) {
return $emptyParameterError; return $emptyParameterError;
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$relData = Release::checkGuidForApi($request->input('id')); $relData = Release::checkGuidForApi($request->input('id'));
if ($relData) { if ($relData) {
$request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, $res); $request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, $res);
@@ -648,7 +646,7 @@ class ApiController extends BasePageController
return showApiError(200, 'Missing parameter (guid is required for single release details)'); return showApiError(200, 'Missing parameter (guid is required for single release details)');
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$guid = $request->input('id'); $guid = $request->input('id');
$data = $this->releaseRowCache->remember('v1', 'details', [ $data = $this->releaseRowCache->remember('v1', 'details', [
'guid' => $guid, 'guid' => $guid,
@@ -662,7 +660,7 @@ class ApiController extends BasePageController
return showApiError(200, 'Missing parameter (id is required for retrieving an NFO)'); return showApiError(200, 'Missing parameter (id is required for retrieving an NFO)');
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$rel = Release::query()->where('guid', $request->input('id'))->first(['id', 'searchname']); $rel = Release::query()->where('guid', $request->input('id'))->first(['id', 'searchname']);
if ($rel) { if ($rel) {
@@ -717,7 +715,7 @@ class ApiController extends BasePageController
return showApiError(600, 'Failed to load upload (no file)'); return showApiError(600, 'Failed to load upload (no file)');
} }
UserRequest::addApiRequest($uid, $request->getRequestUri()); $this->usageService->record($res, $request);
$nzbFile = $request->file('file'); $nzbFile = $request->file('file');
@@ -930,7 +928,7 @@ class ApiController extends BasePageController
private function usage(): ApiUsageService private function usage(): ApiUsageService
{ {
return $this->usageService ?? new ApiUsageService; return $this->usageService;
} }
public function addCoverURL(mixed &$releases, callable $getCoverURL): void public function addCoverURL(mixed &$releases, callable $getCoverURL): void
+11 -4
View File
@@ -83,7 +83,8 @@ class ApiV2Controller extends BasePageController
} }
$apiToken = $request->input('api_token'); $apiToken = $request->input('api_token');
$user = $this->userResolver->v2((string) $apiToken); $resolved = $request->attributes->get('nntmux.api_user');
$user = $resolved instanceof User ? $resolved : $this->userResolver->v2((string) $apiToken);
if (! $user || ! $user->hasVerifiedEmail()) { if (! $user || ! $user->hasVerifiedEmail()) {
return apiJsonError(100); return apiJsonError(100);
@@ -191,9 +192,15 @@ class ApiV2Controller extends BasePageController
return $sort; return $sort;
} }
public function capabilities(): JsonResponse public function capabilities(Request $request): JsonResponse
{ {
return $this->jsonResponse($this->capabilitiesService->v2()); $response = $this->jsonResponse($this->capabilitiesService->v2());
$response->setPublic();
$response->setMaxAge(300);
$response->setEtag(hash('sha256', (string) $response->getContent()));
$response->isNotModified($request);
return $response;
} }
/** /**
@@ -660,7 +667,7 @@ class ApiV2Controller extends BasePageController
$guid = $request->input('id'); $guid = $request->input('id');
$relData = $this->releaseRowCache->remember('v2', 'details', [ $relData = $this->releaseRowCache->remember('v2', 'details', [
'guid' => $guid, 'guid' => $guid,
], fn () => Release::getByGuidForApi($guid)); ], fn () => Release::getByGuidForApi($guid, false));
if ($relData === null) { if ($relData === null) {
return $this->jsonResponse(['error' => 'No such item'], 404); return $this->jsonResponse(['error' => 'No such item'], 404);
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
final class MeasureApiPerformance
{
/** @param Closure(Request): Response $next */
public function handle(Request $request, Closure $next): Response
{
if (! $request->is('api/v1/*', 'api/v2/*') || ! $this->sampled()) {
return $next($request);
}
$connection = DB::connection();
$connection->flushQueryLog();
$connection->enableQueryLog();
$startedAt = hrtime(true);
$memoryAtStart = memory_get_usage(true);
try {
$response = $next($request);
} finally {
$queries = $connection->getQueryLog();
$connection->disableQueryLog();
}
$durationMs = (hrtime(true) - $startedAt) / 1_000_000;
$queryMs = array_sum(array_map(static fn (array $query): float => (float) ($query['time'] ?? 0), $queries));
$metrics = [
'route' => $request->route()?->getActionName(),
'status' => $response->getStatusCode(),
'duration_ms' => round($durationMs, 2),
'query_count' => count($queries),
'query_ms' => round($queryMs, 2),
'memory_delta_bytes' => max(0, memory_get_usage(true) - $memoryAtStart),
'response_bytes' => strlen((string) $response->getContent()),
'release_cache' => $request->attributes->get('nntmux.api_release_cache', 'unused'),
];
Log::channel(config('logging.default'))->info('API performance', $metrics);
$response->headers->set('Server-Timing', sprintf('app;dur=%.2f, db;dur=%.2f;desc="%d queries"', $durationMs, $queryMs, count($queries)));
return $response;
}
private function sampled(): bool
{
$rate = min(1.0, max(0.0, (float) config('nntmux.api.metrics_sample_rate', 0.01)));
return $rate > 0.0 && random_int(1, 1_000_000) <= (int) ($rate * 1_000_000);
}
}
@@ -6,11 +6,11 @@ namespace App\Http\Middleware;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Models\User; use App\Models\User;
use App\Services\Api\ApiUserResolver;
use Closure; use Closure;
use Illuminate\Cache\RateLimiter; use Illuminate\Cache\RateLimiter;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class ThrottleApiRequestsByToken class ThrottleApiRequestsByToken
@@ -19,7 +19,10 @@ class ThrottleApiRequestsByToken
private const int DECAY_SECONDS = 60; private const int DECAY_SECONDS = 60;
public function __construct(private readonly RateLimiter $limiter) {} public function __construct(
private readonly RateLimiter $limiter,
private readonly ApiUserResolver $userResolver,
) {}
/** /**
* Handle an incoming request. * Handle an incoming request.
@@ -34,6 +37,8 @@ class ThrottleApiRequestsByToken
return $next($request); return $next($request);
} }
$request->attributes->set('nntmux.api_user', $user);
$maxAttempts = max(1, (int) ($user->rate_limit ?: self::DEFAULT_RATE_LIMIT)); $maxAttempts = max(1, (int) ($user->rate_limit ?: self::DEFAULT_RATE_LIMIT));
$rateLimitKey = $this->rateLimitKey($user->id); $rateLimitKey = $this->rateLimitKey($user->id);
@@ -60,11 +65,9 @@ class ThrottleApiRequestsByToken
return null; return null;
} }
$user = Cache::remember('api_rate_limit_user:'.md5($apiToken), 300, static function () use ($apiToken) { $user = $request->filled('api_token')
return User::verifiedApiTokenQuery($apiToken) ? $this->userResolver->v2($apiToken)
->select(['id', 'roles_id', 'api_token', 'rate_limit']) : $this->userResolver->v1($apiToken);
->first();
});
if ($user?->roles_id === UserRole::DISABLED->value) { if ($user?->roles_id === UserRole::DISABLED->value) {
return null; return null;
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\UserRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Throwable;
#[Backoff([1, 5, 30])]
#[FailOnTimeout]
final class RecordApiUsage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 4;
public int $timeout = 15;
public function __construct(
public readonly int $userId,
public readonly string $requestUri,
public readonly ?string $ip,
public readonly string $occurredAt,
) {
$this->onQueue((string) config('nntmux.api.audit_queue', 'api-audit'));
}
public function handle(): void
{
UserRequest::query()->insert([
'users_id' => $this->userId,
'request' => $this->requestUri,
'timestamp' => $this->occurredAt,
]);
$lockKey = 'api_access_update:'.$this->userId;
$interval = max(1, (int) config('nntmux.api.access_update_interval', 60));
try {
if (! Cache::add($lockKey, true, $interval)) {
return;
}
} catch (Throwable) {
// Cache degradation must not retry and duplicate the durable audit row.
}
$update = ['apiaccess' => $this->occurredAt];
if ($this->ip !== null) {
$update['host'] = $this->ip;
}
DB::table('users')->where('id', $this->userId)->update($update);
}
}
+26 -16
View File
@@ -552,14 +552,13 @@ class Release extends Model
* Lighter version of getByGuid() optimized for API details responses. * Lighter version of getByGuid() optimized for API details responses.
* Skips video.tvInfo, releaseGroup, and fields not used by DetailsTransformer/XML_Response. * Skips video.tvInfo, releaseGroup, and fields not used by DetailsTransformer/XML_Response.
*/ */
public static function getByGuidForApi(mixed $guid): mixed public static function getByGuidForApi(mixed $guid, bool $includeV1CompatibilityFields = true): mixed
{ {
$categoryNameExpression = DB::connection()->getDriverName() === 'sqlite' $categoryNameExpression = DB::connection()->getDriverName() === 'sqlite'
? "cp.title || ' > ' || c.title AS category_name" ? "cp.title || ' > ' || c.title AS category_name"
: "CONCAT(cp.title, ' > ', c.title) AS category_name"; : "CONCAT(cp.title, ' > ', c.title) AS category_name";
$query = DB::table('releases') $columns = [
->select([
'releases.id', 'releases.id',
'releases.searchname', 'releases.searchname',
'releases.guid', 'releases.guid',
@@ -572,18 +571,7 @@ class Release extends Model
'releases.grabs', 'releases.grabs',
'releases.comments', 'releases.comments',
'releases.adddate', 'releases.adddate',
'releases.videos_id',
'releases.tv_episodes_id',
'releases.haspreview',
'releases.nfostatus',
'releases.movieinfo_id',
'releases.musicinfo_id',
'releases.consoleinfo_id',
'c.root_categories_id as parentid',
'cp.title as parent_category',
'c.title as sub_category',
DB::raw($categoryNameExpression), DB::raw($categoryNameExpression),
'g.name as group_name',
'v.tvdb', 'v.tvdb',
'v.trakt', 'v.trakt',
'v.tvrage', 'v.tvrage',
@@ -597,10 +585,28 @@ class Release extends Model
'tve.series', 'tve.series',
'tve.episode', 'tve.episode',
'tve.firstaired', 'tve.firstaired',
]) ];
if ($includeV1CompatibilityFields) {
array_push($columns,
'releases.videos_id',
'releases.tv_episodes_id',
'releases.haspreview',
'releases.nfostatus',
'releases.movieinfo_id',
'releases.musicinfo_id',
'releases.consoleinfo_id',
'c.root_categories_id as parentid',
'cp.title as parent_category',
'c.title as sub_category',
'g.name as group_name',
);
}
$query = DB::table('releases')
->select($columns)
->leftJoin('categories as c', 'c.id', '=', 'releases.categories_id') ->leftJoin('categories as c', 'c.id', '=', 'releases.categories_id')
->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id') ->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id')
->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id')
->leftJoin('videos as v', function ($join): void { ->leftJoin('videos as v', function ($join): void {
$join->on('releases.videos_id', '=', 'v.id') $join->on('releases.videos_id', '=', 'v.id')
->where('releases.videos_id', '>', 0); ->where('releases.videos_id', '>', 0);
@@ -614,6 +620,10 @@ class Release extends Model
->where('releases.movieinfo_id', '>', 0); ->where('releases.movieinfo_id', '>', 0);
}); });
if ($includeV1CompatibilityFields) {
$query->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id');
}
if (is_array($guid)) { if (is_array($guid)) {
$query->whereIn('releases.guid', $guid); $query->whereIn('releases.guid', $guid);
} else { } else {
-1
View File
@@ -43,7 +43,6 @@ class UserApiCacheObserver
$tokenHash = md5($apiToken); $tokenHash = md5($apiToken);
Cache::forget('api_user:'.$tokenHash); Cache::forget('api_user:'.$tokenHash);
Cache::forget('api_rate_limit_user:'.$tokenHash);
Cache::forget('api_user_stats:'.$user->id); Cache::forget('api_user_stats:'.$user->id);
Cache::forget(User::categoryExclusionCacheKey($user->id)); Cache::forget(User::categoryExclusionCacheKey($user->id));
} }
+15 -2
View File
@@ -42,7 +42,7 @@ final readonly class ApiCapabilitiesService
], ],
]); ]);
$status = $this->registrationStatus->resolve(); $status = $this->registrationStatus();
$data['registration'] = $this->registration($status); $data['registration'] = $this->registration($status);
$data['categories'] = $includeCatalogs ? Category::getForMenu() : null; $data['categories'] = $includeCatalogs ? Category::getForMenu() : null;
$data['groups'] = $includeCatalogs ? $this->groups() : null; $data['groups'] = $includeCatalogs ? $this->groups() : null;
@@ -79,7 +79,7 @@ final readonly class ApiCapabilitiesService
]; ];
}); });
$status = $this->registrationStatus->resolve(); $status = $this->registrationStatus();
$data['registration'] = $this->registration($status); $data['registration'] = $this->registration($status);
return $data; return $data;
@@ -126,4 +126,17 @@ final readonly class ApiCapabilitiesService
'open' => $status['is_open'] ? 'yes' : 'no', 'open' => $status['is_open'] ? 'yes' : 'no',
]; ];
} }
/** @return array{available: bool, is_open: bool} */
private function registrationStatus(): array
{
return Cache::remember('api_registration_status', 15, function (): array {
$status = $this->registrationStatus->resolve();
return [
'available' => $status['available'],
'is_open' => $status['is_open'],
];
});
}
} }
+51 -26
View File
@@ -14,16 +14,6 @@ class ApiReleaseRowCache
private const NULL_SENTINEL = ['__nntmux_api_release_row_cache_null' => true]; private const NULL_SENTINEL = ['__nntmux_api_release_row_cache_null' => true];
private const BASE_TTL_SECONDS = 600;
private const MAX_TTL_JITTER_SECONDS = 60;
private const LOCK_TTL_SECONDS = 10;
private const WAIT_ATTEMPTS = 30;
private const WAIT_MICROSECONDS = 100_000;
/** /**
* Cache release rows only. User-specific response fields should be built after this returns. * Cache release rows only. User-specific response fields should be built after this returns.
* *
@@ -36,44 +26,44 @@ class ApiReleaseRowCache
[$hit, $cached] = $this->getCachedValue($cacheKey); [$hit, $cached] = $this->getCachedValue($cacheKey);
if ($hit) { if ($hit) {
$this->markRequest('hit');
$this->logCacheEvent('hit', $apiVersion, $scope); $this->logCacheEvent('hit', $apiVersion, $scope);
return $cached; return $cached;
} }
$lockKey = $cacheKey.':lock'; $lock = Cache::lock($cacheKey.':lock', max(1, (int) config('nntmux.api.release_cache_lock_ttl', 15)));
if (Cache::add($lockKey, true, self::LOCK_TTL_SECONDS)) { if ($lock->get()) {
try { try {
[$hit, $cached] = $this->getCachedValue($cacheKey); [$hit, $cached] = $this->getCachedValue($cacheKey);
if ($hit) { if ($hit) {
$this->markRequest('hit_after_lock');
$this->logCacheEvent('hit_after_lock', $apiVersion, $scope); $this->logCacheEvent('hit_after_lock', $apiVersion, $scope);
return $cached; return $cached;
} }
$this->logCacheEvent('miss_lock_owner', $apiVersion, $scope); $this->logCacheEvent('miss_lock_owner', $apiVersion, $scope);
$this->markRequest('miss');
$rows = $callback(); $rows = $callback();
$this->putCachedValue($cacheKey, $rows); $this->putCachedValue($cacheKey, $rows);
return $rows; return $rows;
} finally { } finally {
Cache::forget($lockKey); $lock->release();
} }
} }
$this->logCacheEvent('lock_wait', $apiVersion, $scope); [$staleHit, $stale] = $this->getCachedValue($cacheKey.':stale');
for ($attempt = 0; $attempt < self::WAIT_ATTEMPTS; $attempt++) { if ($staleHit) {
usleep(self::WAIT_MICROSECONDS); $this->markRequest('stale');
$this->logCacheEvent('stale_while_refresh', $apiVersion, $scope);
[$hit, $cached] = $this->getCachedValue($cacheKey); return $stale;
if ($hit) {
$this->logCacheEvent('hit_after_wait', $apiVersion, $scope);
return $cached;
}
} }
$this->logCacheEvent('miss_after_wait', $apiVersion, $scope); $this->logCacheEvent('miss_lock_contended', $apiVersion, $scope);
$this->markRequest('miss_contended');
$rows = $callback(); $rows = $callback();
$this->putCachedValue($cacheKey, $rows); $this->putCachedValue($cacheKey, $rows);
@@ -88,13 +78,16 @@ class ApiReleaseRowCache
return 'api_release_rows:'.$apiVersion.':'.$scope.':'.md5(serialize([ return 'api_release_rows:'.$apiVersion.':'.$scope.':'.md5(serialize([
'driver' => Search::getCurrentDriver(), 'driver' => Search::getCurrentDriver(),
'release_version' => Cache::get('releases:cache_version', 1), 'release_version' => Cache::get('releases:cache_version', 1),
'parameters' => $parameters, 'parameters' => $this->normalize($parameters),
])); ]));
} }
private function ttlSeconds(): int private function ttlSeconds(): int
{ {
return self::BASE_TTL_SECONDS + mt_rand(0, self::MAX_TTL_JITTER_SECONDS); $ttl = max(1, (int) config('nntmux.api.release_cache_ttl', 600));
$jitter = max(0, (int) config('nntmux.api.release_cache_jitter', 60));
return $ttl + ($jitter > 0 ? random_int(0, $jitter) : 0);
} }
/** /**
@@ -116,11 +109,34 @@ class ApiReleaseRowCache
private function putCachedValue(string $cacheKey, mixed $value): void private function putCachedValue(string $cacheKey, mixed $value): void
{ {
$cached = $value === null ? self::NULL_SENTINEL : $value;
$staleTtl = max($this->ttlSeconds(), (int) config('nntmux.api.release_cache_stale_ttl', 900));
Cache::put( Cache::put(
$cacheKey, $cacheKey,
$value === null ? self::NULL_SENTINEL : $value, $cached,
$this->ttlSeconds() $this->ttlSeconds()
); );
Cache::put($cacheKey.':stale', $cached, $staleTtl);
}
/** @param array<string, mixed> $parameters
* @return array<string, mixed>
*/
private function normalize(array $parameters): array
{
ksort($parameters);
foreach ($parameters as &$value) {
if (is_array($value)) {
$value = array_is_list($value) ? array_values(array_unique($value, SORT_REGULAR)) : $this->normalize($value);
if (array_is_list($value)) {
sort($value);
}
} elseif (is_string($value)) {
$value = trim($value);
}
}
return $parameters;
} }
private function logCacheEvent(string $event, string $apiVersion, string $scope): void private function logCacheEvent(string $event, string $apiVersion, string $scope): void
@@ -134,4 +150,13 @@ class ApiReleaseRowCache
'scope' => $scope, 'scope' => $scope,
]); ]);
} }
private function markRequest(string $status): void
{
if (app()->runningInConsole()) {
return;
}
request()->attributes->set('nntmux.api_release_cache', $status);
}
} }
+21 -4
View File
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Services\Api; namespace App\Services\Api;
use App\Events\UserAccessedApi; use App\Jobs\RecordApiUsage;
use App\Models\User; use App\Models\User;
use App\Models\UserRequest;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Throwable;
final class ApiUsageService final class ApiUsageService
{ {
@@ -30,7 +30,24 @@ final class ApiUsageService
public function record(User $user, Request $request): void public function record(User $user, Request $request): void
{ {
UserRequest::addApiRequest($user->id, $request->getRequestUri()); $job = new RecordApiUsage(
event(new UserAccessedApi($user, $request->ip())); $user->id,
$request->getRequestUri(),
$request->ip(),
now()->toDateTimeString(),
);
if ((bool) config('nntmux.api.async_audit', true)) {
try {
dispatch($job);
} catch (Throwable) {
// Preserve audit and quota correctness when the queue backend is unavailable.
$job->handle();
}
return;
}
$job->handle();
} }
} }
@@ -348,5 +348,6 @@ class RegistrationStatusService
private function forgetDashboardSnapshot(): void private function forgetDashboardSnapshot(): void
{ {
Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);
Cache::forget('api_registration_status');
} }
} }
+2
View File
@@ -8,6 +8,7 @@ use App\Http\Middleware\EnforceSessionToken;
use App\Http\Middleware\EnsureAuthenticatedUsersAreVerified; use App\Http\Middleware\EnsureAuthenticatedUsersAreVerified;
use App\Http\Middleware\ForceJsonOnAPI; use App\Http\Middleware\ForceJsonOnAPI;
use App\Http\Middleware\Google2FAMiddleware; use App\Http\Middleware\Google2FAMiddleware;
use App\Http\Middleware\MeasureApiPerformance;
use App\Http\Middleware\NoCacheForAuthenticatedUsers; use App\Http\Middleware\NoCacheForAuthenticatedUsers;
use App\Http\Middleware\SetUserTimezone; use App\Http\Middleware\SetUserTimezone;
use App\Http\Middleware\ThrottleApiRequestsByToken; use App\Http\Middleware\ThrottleApiRequestsByToken;
@@ -60,6 +61,7 @@ return Application::configure(basePath: dirname(__DIR__))
PreventRequestsDuringMaintenance::class, PreventRequestsDuringMaintenance::class,
ForceJsonOnAPI::class, ForceJsonOnAPI::class,
BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP
MeasureApiPerformance::class,
]); ]);
$middleware->replace( $middleware->replace(
+1 -1
View File
@@ -199,7 +199,7 @@ return [
'defaults' => [ 'defaults' => [
'supervisor-1' => [ 'supervisor-1' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['default'], 'queue' => array_values(array_filter(array_map('trim', explode(',', env('HORIZON_QUEUES', 'api-audit,default'))))),
'balance' => 'auto', 'balance' => 'auto',
'autoScalingStrategy' => 'time', 'autoScalingStrategy' => 'time',
'maxProcesses' => 1, 'maxProcesses' => 1,
+10
View File
@@ -21,6 +21,16 @@ return [
'purge_inactive_users' => env('PURGE_INACTIVE_USERS', false), 'purge_inactive_users' => env('PURGE_INACTIVE_USERS', false),
'purge_inactive_users_days' => env('PURGE_INACTIVE_USERS_DAYS', 180), 'purge_inactive_users_days' => env('PURGE_INACTIVE_USERS_DAYS', 180),
'mysql_search_fallback' => env('MYSQL_SEARCH_FALLBACK', false), // Disable MySQL LIKE fallback when Manticore/Elasticsearch return no results 'mysql_search_fallback' => env('MYSQL_SEARCH_FALLBACK', false), // Disable MySQL LIKE fallback when Manticore/Elasticsearch return no results
'api' => [
'release_cache_ttl' => (int) env('API_RELEASE_CACHE_TTL', 600),
'release_cache_jitter' => (int) env('API_RELEASE_CACHE_JITTER', 60),
'release_cache_stale_ttl' => (int) env('API_RELEASE_CACHE_STALE_TTL', 900),
'release_cache_lock_ttl' => (int) env('API_RELEASE_CACHE_LOCK_TTL', 15),
'async_audit' => (bool) env('API_ASYNC_AUDIT', true),
'audit_queue' => env('API_AUDIT_QUEUE', 'api-audit'),
'access_update_interval' => (int) env('API_ACCESS_UPDATE_INTERVAL', 60),
'metrics_sample_rate' => (float) env('API_METRICS_SAMPLE_RATE', 0.01),
],
'block_proxy_indexer_apps' => (bool) env('BLOCK_PROXY_INDEXER_APPS', false), 'block_proxy_indexer_apps' => (bool) env('BLOCK_PROXY_INDEXER_APPS', false),
'block_proxy_indexer_app_user_agents' => env('BLOCK_PROXY_INDEXER_APP_USER_AGENTS', 'Prowlarr/,NZBHydra2'), 'block_proxy_indexer_app_user_agents' => env('BLOCK_PROXY_INDEXER_APP_USER_AGENTS', 'Prowlarr/,NZBHydra2'),
-12
View File
@@ -372,18 +372,6 @@ parameters:
count: 1 count: 1
path: app/Http/Controllers/CoverController.php path: app/Http/Controllers/CoverController.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$roles_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Middleware/ThrottleApiRequestsByToken.php
-
message: '#^Method App\\Http\\Middleware\\ThrottleApiRequestsByToken\:\:resolveUser\(\) should return App\\Models\\User\|null but returns Illuminate\\Database\\Eloquent\\Model\|null\.$#'
identifier: return.type
count: 1
path: app/Http/Middleware/ThrottleApiRequestsByToken.php
- -
message: '#^Method App\\Http\\Requests\\Admin\\RegistrationPeriodRequest\:\:after\(\) return type has no value type specified in iterable type array\.$#' message: '#^Method App\\Http\\Requests\\Admin\\RegistrationPeriodRequest\:\:after\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue identifier: missingType.iterableValue
+1
View File
@@ -19,6 +19,7 @@
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="API_ASYNC_AUDIT" value="false"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
</php> </php>
<logging/> <logging/>
+64
View File
@@ -56,6 +56,34 @@ class ApiPerformanceHelpersTest extends TestCase
$this->assertSame(1, $calls); $this->assertSame(1, $calls);
} }
public function test_release_row_cache_canonicalizes_equivalent_parameters(): void
{
Search::shouldReceive('getCurrentDriver')->twice()->andReturn('testing');
$calls = 0;
$cache = new ApiReleaseRowCache;
$first = $cache->remember('v2', 'search', [
'category' => [5030, 5040, 5030],
'id' => ' ubuntu ',
], function () use (&$calls): array {
$calls++;
return ['result'];
});
$second = $cache->remember('v2', 'search', [
'id' => 'ubuntu',
'category' => [5040, 5030],
], function () use (&$calls): array {
$calls++;
return ['different'];
});
$this->assertSame(['result'], $first);
$this->assertSame(['result'], $second);
$this->assertSame(1, $calls);
}
public function test_get_by_guid_for_api_returns_plain_row_without_model_hydration(): void public function test_get_by_guid_for_api_returns_plain_row_without_model_hydration(): void
{ {
$this->createReleaseDetailsSchema(); $this->createReleaseDetailsSchema();
@@ -104,6 +132,42 @@ class ApiPerformanceHelpersTest extends TestCase
$this->assertSame('alt.binaries.test', $row->group_name); $this->assertSame('alt.binaries.test', $row->group_name);
} }
public function test_v2_details_query_omits_v1_compatibility_fields(): void
{
$this->createReleaseDetailsSchema();
DB::table('root_categories')->insert(['id' => 5000, 'title' => 'TV']);
DB::table('categories')->insert(['id' => 5030, 'title' => 'SD', 'root_categories_id' => 5000]);
DB::table('releases')->insert([
'id' => 1,
'searchname' => 'Ubuntu.Release',
'guid' => 'release-guid',
'postdate' => '2026-01-02 00:00:00',
'categories_id' => 5030,
'size' => 123456,
'totalpart' => 10,
'passwordstatus' => 0,
'grabs' => 2,
'comments' => 1,
'adddate' => '2026-01-03 00:00:00',
'videos_id' => 0,
'tv_episodes_id' => 0,
'haspreview' => 0,
'nfostatus' => 0,
'movieinfo_id' => 0,
'musicinfo_id' => 0,
'consoleinfo_id' => 0,
]);
$row = Release::getByGuidForApi('release-guid', false);
$this->assertInstanceOf(\stdClass::class, $row);
$this->assertSame('TV > SD', $row->category_name);
$this->assertObjectNotHasProperty('group_name', $row);
$this->assertObjectNotHasProperty('haspreview', $row);
$this->assertObjectNotHasProperty('nfostatus', $row);
}
public function test_release_data_fast_array_matches_existing_data_output(): void public function test_release_data_fast_array_matches_existing_data_output(): void
{ {
$release = (object) [ $release = (object) [
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Jobs\RecordApiUsage;
use App\Models\User;
use App\Services\Api\ApiUsageService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class ApiUsageServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
'cache.default' => 'array',
]);
DB::purge();
DB::reconnect();
Cache::flush();
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->dateTime('apiaccess')->nullable();
$table->string('host')->nullable();
});
Schema::create('user_requests', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('users_id');
$table->text('request');
$table->dateTime('timestamp');
});
DB::table('users')->insert(['id' => 1]);
}
public function test_async_record_dispatches_id_only_audit_job(): void
{
Queue::fake();
config(['nntmux.api.async_audit' => true]);
$user = new User;
$user->id = 1;
$request = Request::create('/api/v2/search?api_token=secret&id=test', 'GET');
(new ApiUsageService)->record($user, $request);
Queue::assertPushed(RecordApiUsage::class, static fn (RecordApiUsage $job): bool => $job->userId === 1 && $job->requestUri === '/api/v2/search?api_token=secret&id=test'
);
$this->assertSame(0, DB::table('user_requests')->count());
}
public function test_audit_job_persists_each_request_and_coalesces_user_updates(): void
{
config(['nntmux.api.access_update_interval' => 60]);
(new RecordApiUsage(1, '/api/v2/search?id=one', '192.0.2.1', '2026-07-14 10:00:00'))->handle();
(new RecordApiUsage(1, '/api/v2/search?id=two', '192.0.2.2', '2026-07-14 10:00:01'))->handle();
$this->assertSame(2, DB::table('user_requests')->count());
$this->assertSame('192.0.2.1', DB::table('users')->where('id', 1)->value('host'));
}
}