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.
475 lines
15 KiB
PHP
475 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Vision;
|
|
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class VectorGatewayClient
|
|
{
|
|
private const CIRCUIT_KEY = 'vision.vector_gateway.circuit_open';
|
|
|
|
private const MAX_SEARCH_LIMIT = 100;
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
return (bool) config('vision.vector_gateway.enabled', true)
|
|
&& $this->baseUrl() !== ''
|
|
&& $this->apiKey() !== '';
|
|
}
|
|
|
|
/**
|
|
* True while the gateway is presumed down after a recent failure — callers
|
|
* on the request path should skip the network round trip entirely.
|
|
*/
|
|
public function circuitOpen(): bool
|
|
{
|
|
return Cache::has(self::CIRCUIT_KEY);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function tripCircuit(array $context = []): void
|
|
{
|
|
$alreadyOpen = $this->circuitOpen();
|
|
$seconds = max(1, (int) config('vision.vector_gateway.circuit_breaker_seconds', 30));
|
|
Cache::put(self::CIRCUIT_KEY, true, $seconds);
|
|
|
|
if ($alreadyOpen) {
|
|
return;
|
|
}
|
|
|
|
$this->logCircuitOpened($context, $seconds);
|
|
}
|
|
|
|
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
|
|
{
|
|
$response = $this->postJson(
|
|
$this->request(),
|
|
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
|
|
[
|
|
'url' => $imageUrl,
|
|
'id' => (string) $id,
|
|
'metadata' => $metadata,
|
|
]
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
|
|
}
|
|
|
|
$json = $response->json();
|
|
|
|
return is_array($json) ? $json : [];
|
|
}
|
|
|
|
public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array
|
|
{
|
|
$response = $this->request()
|
|
->attach('file', $contents, $filename)
|
|
->post(
|
|
$this->url((string) config('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file')),
|
|
[
|
|
'id' => (string) $id,
|
|
'metadata_json' => $metadata === [] ? null : json_encode($metadata, JSON_THROW_ON_ERROR),
|
|
]
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
|
|
}
|
|
|
|
$json = $response->json();
|
|
|
|
return is_array($json) ? $json : [];
|
|
}
|
|
|
|
/**
|
|
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
|
*/
|
|
public function searchByUrl(string $imageUrl, int $limit = 5): array
|
|
{
|
|
try {
|
|
$response = $this->postJson(
|
|
$this->searchRequest(),
|
|
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
|
[
|
|
'url' => $imageUrl,
|
|
'limit' => $this->clampSearchLimit($limit),
|
|
]
|
|
);
|
|
} catch (Throwable $e) {
|
|
throw $this->classifyThrowable('search_url', $e);
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
throw $this->classifyHttpFailure('search_url', $response);
|
|
}
|
|
|
|
return $this->extractMatches($response->json());
|
|
}
|
|
|
|
/**
|
|
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
|
*/
|
|
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
|
|
{
|
|
try {
|
|
$response = $this->searchRequest()
|
|
->attach('file', $contents, $filename)
|
|
->post(
|
|
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
|
[
|
|
'limit' => $this->clampSearchLimit($limit),
|
|
]
|
|
);
|
|
} catch (Throwable $e) {
|
|
throw $this->classifyThrowable('search_file', $e);
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
throw $this->classifyHttpFailure('search_file', $response);
|
|
}
|
|
|
|
return $this->extractMatches($response->json());
|
|
}
|
|
|
|
/**
|
|
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
|
*/
|
|
public function searchByUploadedFile(UploadedFile $file, int $limit = 5): array
|
|
{
|
|
$this->assertCircuitClosed();
|
|
|
|
$realPath = $file->getRealPath();
|
|
if (! is_string($realPath) || $realPath === '') {
|
|
throw new RuntimeException('Uploaded file has no readable temporary path for vector search.');
|
|
}
|
|
|
|
$contents = file_get_contents($realPath);
|
|
if (! is_string($contents) || $contents === '') {
|
|
throw new RuntimeException('Unable to read uploaded image bytes for vector search.');
|
|
}
|
|
|
|
try {
|
|
return $this->searchByFileContents($contents, $file->getClientOriginalName() ?: 'search-image', $limit);
|
|
} catch (VectorGatewayException $e) {
|
|
$this->tripIfCircuitWorthy([$e], 'uploaded_image');
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the circuit only when every attempted search failure is transient/gateway-wide.
|
|
*
|
|
* @param list<VectorGatewayException> $failures
|
|
* @param array{artwork_id?: int} $context
|
|
*/
|
|
public function tripIfCircuitWorthy(array $failures, string $source = 'unknown', array $context = []): void
|
|
{
|
|
if ($failures === []) {
|
|
return;
|
|
}
|
|
|
|
foreach ($failures as $failure) {
|
|
if (! $failure->circuitWorthy) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$this->tripCircuit([
|
|
'source' => $source,
|
|
'artwork_id' => isset($context['artwork_id']) ? (int) $context['artwork_id'] : null,
|
|
'failures' => array_map(static fn (VectorGatewayException $failure): array => [
|
|
'operation' => $failure->operation,
|
|
'http_status' => $failure->httpStatus,
|
|
'circuit_worthy' => $failure->circuitWorthy,
|
|
'exception_class' => $failure::class,
|
|
], $failures),
|
|
]);
|
|
}
|
|
|
|
public function assertCircuitClosed(): void
|
|
{
|
|
$this->guardCircuit();
|
|
}
|
|
|
|
public function deleteByIds(array $ids): array
|
|
{
|
|
$response = $this->postJson(
|
|
$this->request(),
|
|
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
|
|
[
|
|
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
|
|
]
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
throw new RuntimeException($this->failureMessage('Vector delete', $response));
|
|
}
|
|
|
|
$json = $response->json();
|
|
|
|
return is_array($json) ? $json : [];
|
|
}
|
|
|
|
/**
|
|
* Used by upsert/delete — only ever called from queued/console indexing
|
|
* jobs, so a more generous budget is fine.
|
|
*/
|
|
private function request(): PendingRequest
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
|
}
|
|
|
|
return Http::acceptJson()
|
|
->withHeaders([
|
|
'X-API-Key' => $this->apiKey(),
|
|
])
|
|
->connectTimeout(max(1, (int) config('vision.vector_gateway.connect_timeout_seconds', 5)))
|
|
->timeout(max(1, (int) config('vision.vector_gateway.timeout_seconds', 20)))
|
|
->retry(
|
|
max(0, (int) config('vision.vector_gateway.retries', 1)),
|
|
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
|
|
throw: false,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Used by search — runs synchronously inside web requests, so it gets a
|
|
* tight timeout budget and no retries to avoid pinning FPM workers.
|
|
*/
|
|
private function searchRequest(): PendingRequest
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
|
}
|
|
|
|
return Http::acceptJson()
|
|
->withHeaders([
|
|
'X-API-Key' => $this->apiKey(),
|
|
])
|
|
->connectTimeout(max(1, (int) config('vision.vector_gateway.search_connect_timeout_seconds', 2)))
|
|
->timeout(max(1, (int) config('vision.vector_gateway.search_timeout_seconds', 6)))
|
|
->retry(
|
|
max(0, (int) config('vision.vector_gateway.search_retries', 0)),
|
|
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
|
|
throw: false,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function logCircuitOpened(array $context, int $ttlSeconds): void
|
|
{
|
|
$payload = [
|
|
'source' => (string) ($context['source'] ?? 'unknown'),
|
|
'failures' => is_array($context['failures'] ?? null) ? $context['failures'] : [],
|
|
'circuit_ttl_seconds' => $ttlSeconds,
|
|
'php_sapi' => PHP_SAPI,
|
|
'running_in_console' => app()->runningInConsole(),
|
|
];
|
|
|
|
if (isset($context['artwork_id']) && is_int($context['artwork_id']) && $context['artwork_id'] > 0) {
|
|
$payload['artwork_id'] = $context['artwork_id'];
|
|
}
|
|
|
|
Log::warning('Vector gateway circuit opened', $payload);
|
|
}
|
|
|
|
private function clampSearchLimit(int $limit): int
|
|
{
|
|
return max(1, min(self::MAX_SEARCH_LIMIT, $limit));
|
|
}
|
|
|
|
private function guardCircuit(): void
|
|
{
|
|
if ($this->circuitOpen()) {
|
|
throw new VectorGatewayException(
|
|
'Vector gateway temporarily unavailable.',
|
|
'circuit',
|
|
null,
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
|
|
private function classifyHttpFailure(string $operation, Response $response): VectorGatewayException
|
|
{
|
|
$status = $response->status();
|
|
|
|
return new VectorGatewayException(
|
|
'Vector gateway '.$operation.' failed with HTTP '.$status.'.',
|
|
$operation,
|
|
$status,
|
|
$this->isCircuitWorthyStatus($status),
|
|
);
|
|
}
|
|
|
|
private function classifyThrowable(string $operation, Throwable $e): VectorGatewayException
|
|
{
|
|
if ($e instanceof VectorGatewayException) {
|
|
return $e;
|
|
}
|
|
|
|
if ($e instanceof RequestException && $e->response instanceof Response) {
|
|
return $this->classifyHttpFailure($operation, $e->response);
|
|
}
|
|
|
|
$circuitWorthy = $e instanceof ConnectionException || $this->looksLikeTimeout($e);
|
|
|
|
return new VectorGatewayException(
|
|
'Vector gateway '.$operation.' failed.',
|
|
$operation,
|
|
null,
|
|
$circuitWorthy,
|
|
$e,
|
|
);
|
|
}
|
|
|
|
private function isCircuitWorthyStatus(int $status): bool
|
|
{
|
|
return $status === 408 || $status === 429 || $status >= 500;
|
|
}
|
|
|
|
private function looksLikeTimeout(Throwable $e): bool
|
|
{
|
|
$message = strtolower($e->getMessage());
|
|
|
|
return str_contains($message, 'timed out')
|
|
|| str_contains($message, 'timeout')
|
|
|| str_contains($message, 'curl error 28')
|
|
|| str_contains($message, 'connection refused')
|
|
|| str_contains($message, 'could not resolve');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function postJson(PendingRequest $request, string $url, array $payload): Response
|
|
{
|
|
$response = $request->post($url, $payload);
|
|
|
|
if (! $response instanceof Response) {
|
|
throw new RuntimeException('Vector gateway request did not return an HTTP response.');
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function baseUrl(): string
|
|
{
|
|
return rtrim((string) config('vision.vector_gateway.base_url', ''), '/');
|
|
}
|
|
|
|
private function apiKey(): string
|
|
{
|
|
return trim((string) config('vision.vector_gateway.api_key', ''));
|
|
}
|
|
|
|
private function url(string $path): string
|
|
{
|
|
return $this->baseUrl() . '/' . ltrim($path, '/');
|
|
}
|
|
|
|
private function failureMessage(string $operation, Response $response): string
|
|
{
|
|
$body = trim($response->body());
|
|
|
|
if ($body === '') {
|
|
return $operation . ' failed with HTTP ' . $response->status() . '.';
|
|
}
|
|
|
|
return $operation . ' failed with HTTP ' . $response->status() . ': ' . $body;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $json
|
|
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
|
*/
|
|
private function extractMatches(mixed $json): array
|
|
{
|
|
$candidates = [];
|
|
|
|
if (is_array($json)) {
|
|
$candidates = $this->extractCandidateRows($json);
|
|
}
|
|
|
|
$results = [];
|
|
foreach ($candidates as $candidate) {
|
|
if (! is_array($candidate)) {
|
|
continue;
|
|
}
|
|
|
|
$id = $candidate['id']
|
|
?? $candidate['point_id']
|
|
?? $candidate['payload']['id']
|
|
?? $candidate['metadata']['id']
|
|
?? null;
|
|
|
|
if (! is_int($id) && ! is_string($id)) {
|
|
continue;
|
|
}
|
|
|
|
$score = $candidate['score']
|
|
?? $candidate['similarity']
|
|
?? $candidate['distance']
|
|
?? 0.0;
|
|
|
|
$metadata = $candidate['metadata'] ?? $candidate['payload'] ?? [];
|
|
if (! is_array($metadata)) {
|
|
$metadata = [];
|
|
}
|
|
|
|
$results[] = [
|
|
'id' => $id,
|
|
'score' => (float) $score,
|
|
'metadata' => $metadata,
|
|
];
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $json
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function extractCandidateRows(array $json): array
|
|
{
|
|
$keys = ['results', 'matches', 'points', 'data'];
|
|
|
|
foreach ($keys as $key) {
|
|
if (! isset($json[$key]) || ! is_array($json[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$value = $json[$key];
|
|
if (array_is_list($value)) {
|
|
return $value;
|
|
}
|
|
|
|
foreach (['results', 'matches', 'points', 'items'] as $nestedKey) {
|
|
if (isset($value[$nestedKey]) && is_array($value[$nestedKey]) && array_is_list($value[$nestedKey])) {
|
|
return $value[$nestedKey];
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_is_list($json) ? $json : [];
|
|
}
|
|
}
|