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
+148 -19
View File
@@ -5,16 +5,22 @@ 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)
@@ -31,10 +37,20 @@ final class VectorGatewayClient
return Cache::has(self::CIRCUIT_KEY);
}
public function tripCircuit(): void
/**
* @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
@@ -84,25 +100,21 @@ final class VectorGatewayClient
*/
public function searchByUrl(string $imageUrl, int $limit = 5): array
{
$this->guardCircuit();
try {
$response = $this->postJson(
$this->searchRequest(),
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
[
'url' => $imageUrl,
'limit' => max(1, $limit),
'limit' => $this->clampSearchLimit($limit),
]
);
} catch (\Throwable $e) {
$this->tripCircuit();
throw $e;
} catch (Throwable $e) {
throw $this->classifyThrowable('search_url', $e);
}
if ($response->failed()) {
$this->tripCircuit();
throw new RuntimeException($this->failureMessage('Vector search', $response));
throw $this->classifyHttpFailure('search_url', $response);
}
return $this->extractMatches($response->json());
@@ -113,25 +125,21 @@ final class VectorGatewayClient
*/
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
{
$this->guardCircuit();
try {
$response = $this->searchRequest()
->attach('file', $contents, $filename)
->post(
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
[
'limit' => max(1, $limit),
'limit' => $this->clampSearchLimit($limit),
]
);
} catch (\Throwable $e) {
$this->tripCircuit();
throw $e;
} catch (Throwable $e) {
throw $this->classifyThrowable('search_file', $e);
}
if ($response->failed()) {
$this->tripCircuit();
throw new RuntimeException($this->failureMessage('Vector search', $response));
throw $this->classifyHttpFailure('search_file', $response);
}
return $this->extractMatches($response->json());
@@ -142,6 +150,8 @@ final class VectorGatewayClient
*/
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.');
@@ -152,7 +162,47 @@ final class VectorGatewayClient
throw new RuntimeException('Unable to read uploaded image bytes for vector search.');
}
return $this->searchByFileContents($contents, $file->getClientOriginalName() ?: 'search-image', $limit);
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
@@ -220,13 +270,92 @@ final class VectorGatewayClient
);
}
/**
* @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 RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
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
*/