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.
374 lines
15 KiB
PHP
374 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Artwork;
|
|
use App\Services\Vision\AiArtworkVectorSearchService;
|
|
use App\Services\Vision\VectorGatewayClient;
|
|
use App\Services\Vision\VectorGatewayException;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Log\Events\MessageLogged;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use function Pest\Laravel\getJson;
|
|
|
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
config()->set('vision.vector_gateway.enabled', true);
|
|
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
|
|
config()->set('vision.vector_gateway.api_key', 'test-key');
|
|
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
|
|
config()->set('vision.vector_gateway.search_file_endpoint', '/vectors/search/file');
|
|
config()->set('vision.vector_gateway.circuit_breaker_seconds', 30);
|
|
config()->set('cdn.files_url', 'https://files.skinbase.org');
|
|
config()->set('app.url', 'https://skinbase.test');
|
|
Storage::fake('public');
|
|
Cache::flush();
|
|
});
|
|
|
|
function similarPair(): array
|
|
{
|
|
$source = Artwork::factory()->create([
|
|
'title' => 'Circuit source',
|
|
'hash' => 'aa11bb22cc33',
|
|
'thumb_ext' => 'webp',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHour(),
|
|
]);
|
|
$match = Artwork::factory()->create([
|
|
'title' => 'Circuit match',
|
|
'hash' => 'dd44ee55ff66',
|
|
'thumb_ext' => 'webp',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHour(),
|
|
]);
|
|
|
|
return [$source, $match];
|
|
}
|
|
|
|
function fakeSuccessfulUrlSearch(Artwork $source, Artwork $match): array
|
|
{
|
|
return [
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search' => Http::response([
|
|
'results' => [
|
|
['id' => $source->id, 'score' => 1.0],
|
|
['id' => $match->id, 'score' => 0.91],
|
|
],
|
|
], 200),
|
|
];
|
|
}
|
|
|
|
it('does not call URL search when file search succeeds and leaves the circuit closed', function (): void {
|
|
[$source, $match] = similarPair();
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response([
|
|
'results' => [
|
|
['id' => $source->id, 'score' => 1.0],
|
|
['id' => $match->id, 'score' => 0.91],
|
|
],
|
|
], 200),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'should not be called'], 500),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id);
|
|
|
|
Http::assertNotSent(fn ($request): bool => $request->url() === 'https://vision.klevze.net/vectors/search');
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse();
|
|
});
|
|
|
|
it('falls back to URL search after file 400 and keeps the circuit closed', function (): void {
|
|
[$source, $match] = similarPair();
|
|
|
|
Http::fake(array_merge(fakeSuccessfulUrlSearch($source, $match), [
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'bad image'], 400),
|
|
]));
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id);
|
|
Http::assertSent(fn ($request): bool => $request->url() === 'https://vision.klevze.net/vectors/search');
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse();
|
|
});
|
|
|
|
it('falls back to URL search after file 500 and keeps the circuit closed', function (): void {
|
|
[$source, $match] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake(array_merge(fakeSuccessfulUrlSearch($source, $match), [
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'upstream'], 500),
|
|
]));
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id);
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse()
|
|
->and(openedCircuitLogs($logged))->toHaveCount(0);
|
|
});
|
|
|
|
it('falls back to URL search after a file connection failure and keeps the circuit closed', function (): void {
|
|
[$source, $match] = similarPair();
|
|
|
|
Http::fake(array_merge(fakeSuccessfulUrlSearch($source, $match), [
|
|
'https://vision.klevze.net/vectors/search/file' => function () {
|
|
throw new ConnectionException('cURL error 28: Operation timed out');
|
|
},
|
|
]));
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id);
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse();
|
|
});
|
|
|
|
function openedCircuitLogs(array $logged): array
|
|
{
|
|
return array_values(array_filter(
|
|
$logged,
|
|
static fn (MessageLogged $event): bool => $event->message === 'Vector gateway circuit opened',
|
|
));
|
|
}
|
|
|
|
it('opens the circuit when both search strategies fail transiently', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'down'], 503),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')
|
|
->assertStatus(502)
|
|
->assertJson([
|
|
'data' => [],
|
|
'reason' => 'vector_gateway_error',
|
|
])
|
|
->assertJsonMissingPath('message');
|
|
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeTrue();
|
|
|
|
$opened = openedCircuitLogs($logged);
|
|
expect($opened)->toHaveCount(1)
|
|
->and($opened[0]->level)->toBe('warning')
|
|
->and($opened[0]->context['source'] ?? null)->toBe('similar_ai')
|
|
->and($opened[0]->context['artwork_id'] ?? null)->toBe($source->id)
|
|
->and($opened[0]->context['circuit_ttl_seconds'] ?? null)->toBe(30)
|
|
->and($opened[0]->context)->toHaveKey('php_sapi')
|
|
->and($opened[0]->context)->toHaveKey('running_in_console');
|
|
|
|
$operations = collect($opened[0]->context['failures'] ?? [])->pluck('operation')->all();
|
|
$statuses = collect($opened[0]->context['failures'] ?? [])->pluck('http_status')->all();
|
|
expect($operations)->toContain('search_file')
|
|
->and($operations)->toContain('search_url')
|
|
->and($statuses)->toBe([503, 503]);
|
|
|
|
$encoded = json_encode($opened[0]->context);
|
|
expect($encoded)->not->toContain('test-key')
|
|
->and($encoded)->not->toContain('image-bytes')
|
|
->and($encoded)->not->toContain('vision.klevze.net')
|
|
->and($encoded)->not->toContain('files.skinbase.org');
|
|
});
|
|
|
|
it('does not open the circuit when both strategies fail with permanent 4xx', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'bad file'], 400),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad url'], 422),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502);
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse()
|
|
->and(openedCircuitLogs($logged))->toHaveCount(0);
|
|
});
|
|
|
|
it('fails fast when the circuit is already open without downloading or calling the gateway', function (): void {
|
|
[$source] = similarPair();
|
|
Cache::put('vision.vector_gateway.circuit_open', true, 30);
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake();
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')
|
|
->assertStatus(502)
|
|
->assertJsonPath('reason', 'vector_gateway_error')
|
|
->assertJsonMissingPath('message');
|
|
|
|
Http::assertNothingSent();
|
|
expect(openedCircuitLogs($logged))->toHaveCount(0);
|
|
|
|
$failFast = collect($logged)->first(fn (MessageLogged $event): bool => $event->message === 'Vector similarity search failed');
|
|
expect($failFast)->not->toBeNull()
|
|
->and($failFast->context['failure_stage'] ?? null)->toBe('circuit');
|
|
});
|
|
|
|
it('logs one structured warning on final similarity failure without leaking internals', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'x'], 500),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'y'], 500),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502);
|
|
|
|
$match = collect($logged)->first(fn (MessageLogged $event): bool => str_contains((string) $event->message, 'Vector similarity search failed'));
|
|
expect($match)->not->toBeNull()
|
|
->and($match->level)->toBe('warning')
|
|
->and($match->context['artwork_id'] ?? null)->toBe($source->id)
|
|
->and($match->context['route'] ?? null)->toBe('api.art.similar-ai')
|
|
->and($match->context)->toHaveKey('circuit_worthy')
|
|
->and($match->context)->toHaveKey('circuit_open')
|
|
->and(json_encode($match->context))->not->toContain('test-key')
|
|
->and(json_encode($match->context))->not->toContain('image-bytes');
|
|
});
|
|
|
|
it('does not trip the circuit when file gateway was never attempted and URL returns 502', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('not-found', 404),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad gateway'], 502),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'should not be called'], 500),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')
|
|
->assertStatus(502)
|
|
->assertJsonPath('reason', 'vector_gateway_error');
|
|
|
|
Http::assertNotSent(fn ($request): bool => $request->url() === 'https://vision.klevze.net/vectors/search/file');
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse()
|
|
->and(openedCircuitLogs($logged))->toHaveCount(0);
|
|
});
|
|
|
|
it('does not trip the circuit when image download throws before the file gateway', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => function () {
|
|
throw new RuntimeException('cdn unavailable');
|
|
},
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad gateway'], 502),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502);
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse()
|
|
->and(openedCircuitLogs($logged))->toHaveCount(0);
|
|
});
|
|
|
|
it('does not trip the circuit when file gateway is 422 and URL is 502', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'unprocessable'], 422),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad gateway'], 502),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502);
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse()
|
|
->and(openedCircuitLogs($logged))->toHaveCount(0);
|
|
});
|
|
|
|
it('does not trip the circuit when file gateway is 503 and URL is 422', function (): void {
|
|
[$source] = similarPair();
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503),
|
|
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad url'], 422),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502);
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse()
|
|
->and(openedCircuitLogs($logged))->toHaveCount(0);
|
|
});
|
|
|
|
it('attributes uploaded-image transient failures as uploaded_image', function (): void {
|
|
$logged = [];
|
|
Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void {
|
|
$logged[] = $event;
|
|
});
|
|
|
|
Http::fake([
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503),
|
|
]);
|
|
|
|
$file = UploadedFile::fake()->image('query.png', 64, 64);
|
|
|
|
expect(fn () => app(AiArtworkVectorSearchService::class)->searchByUploadedImage($file, 12))
|
|
->toThrow(VectorGatewayException::class);
|
|
|
|
expect(app(VectorGatewayClient::class)->circuitOpen())->toBeTrue();
|
|
|
|
$opened = openedCircuitLogs($logged);
|
|
expect($opened)->toHaveCount(1)
|
|
->and($opened[0]->context['source'] ?? null)->toBe('uploaded_image')
|
|
->and($opened[0]->context['failures'][0]['operation'] ?? null)->toBe('search_file')
|
|
->and($opened[0]->context['failures'][0]['http_status'] ?? null)->toBe(503)
|
|
->and(json_encode($opened[0]->context))->not->toContain('test-key');
|
|
});
|
|
|
|
it('serves similar-ai from cache without contacting the gateway', function (): void {
|
|
[$source, $match] = similarPair();
|
|
|
|
Http::fake([
|
|
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
|
'https://vision.klevze.net/vectors/search/file' => Http::response([
|
|
'results' => [
|
|
['id' => $source->id, 'score' => 1.0],
|
|
['id' => $match->id, 'score' => 0.91],
|
|
],
|
|
], 200),
|
|
]);
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')->assertOk();
|
|
|
|
Http::fake(function () {
|
|
throw new RuntimeException('gateway should not be called on cache hit');
|
|
});
|
|
|
|
getJson('/api/art/'.$source->id.'/similar-ai')
|
|
->assertOk()
|
|
->assertJsonPath('data.0.id', $match->id);
|
|
});
|