Ship production optimization M1-M12.5A: queues, metrics, HTTP observability, and vector search reliability.

Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
This commit is contained in:
2026-08-25 07:58:47 +02:00
parent f52879edbb
commit 8a80aae21e
114 changed files with 9293 additions and 284 deletions
@@ -8,12 +8,18 @@ use App\Models\Artwork;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
final class AiArtworkVectorSearchService
{
private const MAX_SIMILAR_RESULTS = 120;
/** qdrant-svc /vectors/search* reject limit > 100. */
private const VECTOR_GATEWAY_MAX_SEARCH_RESULTS = 100;
/**
* Public similar-ai may request at most 99 items because we fetch one extra
* candidate so the source artwork can be excluded from the result set.
*/
private const MAX_ARTWORK_SIMILAR_RESULTS = 99;
public function __construct(
private readonly VectorGatewayClient $client,
@@ -31,12 +37,13 @@ final class AiArtworkVectorSearchService
*/
public function similarToArtwork(Artwork $artwork, int $limit = 12): array
{
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$safeLimit = max(1, min(self::MAX_ARTWORK_SIMILAR_RESULTS, $limit));
$cacheKey = sprintf('rec:artwork:%d:similar-ai:%d', $artwork->id, $safeLimit);
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
return Cache::remember($cacheKey, $ttl, function () use ($artwork, $safeLimit): array {
$matches = $this->searchMatchesForArtwork($artwork, $safeLimit + 1);
$gatewayLimit = min(self::VECTOR_GATEWAY_MAX_SEARCH_RESULTS, $safeLimit + 1);
$matches = $this->searchMatchesForArtwork($artwork, $gatewayLimit);
return $this->resolveMatches($matches, $safeLimit, $artwork->id);
});
@@ -47,7 +54,7 @@ final class AiArtworkVectorSearchService
*/
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
{
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$safeLimit = max(1, min(self::VECTOR_GATEWAY_MAX_SEARCH_RESULTS, $limit));
$matches = $this->client->searchByUploadedFile($file, $safeLimit);
return $this->resolveMatches($matches, $safeLimit);
@@ -93,15 +100,15 @@ final class AiArtworkVectorSearchService
return [];
}
if ($this->client->circuitOpen()) {
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
}
$this->client->assertCircuitClosed();
$fileFailure = null;
$fileGatewayAttempted = false;
try {
$payload = $this->downloadArtworkImage($artwork, $url);
if ($payload !== null) {
$fileGatewayAttempted = true;
return $this->client->searchByFileContents($payload['contents'], $payload['filename'], $limit);
}
} catch (Throwable $e) {
@@ -110,24 +117,69 @@ final class AiArtworkVectorSearchService
try {
return $this->client->searchByUrl($url, $limit);
} catch (Throwable $e) {
throw $this->normalizeSearchFailure($fileFailure, $e);
} catch (Throwable $fallbackFailure) {
if ($this->shouldTripSimilarAiCircuit($fileGatewayAttempted, $fileFailure, $fallbackFailure)) {
$this->client->tripIfCircuitWorthy(
[$fileFailure, $fallbackFailure],
'similar_ai',
['artwork_id' => (int) $artwork->id],
);
}
throw $this->normalizeSearchFailure($fileFailure, $fallbackFailure);
}
}
private function normalizeSearchFailure(?Throwable $fileFailure, Throwable $fallbackFailure): RuntimeException
private function normalizeSearchFailure(?Throwable $fileFailure, Throwable $fallbackFailure): VectorGatewayException
{
if ($fileFailure === null) {
return $fallbackFailure instanceof RuntimeException
? $fallbackFailure
: new RuntimeException($fallbackFailure->getMessage(), 0, $fallbackFailure);
if ($fallbackFailure instanceof VectorGatewayException && $fileFailure === null) {
return $fallbackFailure;
}
return new RuntimeException(sprintf(
'Vector search failed via file endpoint (%s) and URL fallback (%s).',
$fileFailure->getMessage(),
$fallbackFailure->getMessage(),
), 0, $fallbackFailure);
$status = $fallbackFailure instanceof VectorGatewayException
? $fallbackFailure->httpStatus
: null;
$circuitWorthy = $this->isCircuitWorthyFailure($fileFailure)
&& $this->isCircuitWorthyFailure($fallbackFailure);
if ($fileFailure === null) {
return $fallbackFailure instanceof VectorGatewayException
? $fallbackFailure
: new VectorGatewayException(
'Vector gateway search failed.',
'search_url',
$status,
$this->isCircuitWorthyFailure($fallbackFailure),
$fallbackFailure,
);
}
return new VectorGatewayException(
'Vector gateway search failed.',
'search_combined',
$status,
$circuitWorthy,
$fallbackFailure,
);
}
private function isCircuitWorthyFailure(Throwable $failure): bool
{
return $failure instanceof VectorGatewayException && $failure->circuitWorthy;
}
private function shouldTripSimilarAiCircuit(
bool $fileGatewayAttempted,
?Throwable $fileFailure,
Throwable $fallbackFailure,
): bool {
return $fileGatewayAttempted
&& $fileFailure instanceof VectorGatewayException
&& $fileFailure->operation === 'search_file'
&& $fileFailure->circuitWorthy
&& $fallbackFailure instanceof VectorGatewayException
&& $fallbackFailure->operation === 'search_url'
&& $fallbackFailure->circuitWorthy;
}
/**