154 lines
5.3 KiB
PHP
154 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\BackfillArtworkVectorIndexJob;
|
|
use App\Jobs\SyncArtworkVectorIndexJob;
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkEmbedding;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use function Pest\Laravel\artisan;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function jobProperty(object $job, string $property): mixed
|
|
{
|
|
$reflection = new ReflectionProperty($job, $property);
|
|
$reflection->setAccessible(true);
|
|
|
|
return $reflection->getValue($job);
|
|
}
|
|
|
|
it('queues a resumable vector repair run from the command', function (): void {
|
|
Queue::fake();
|
|
|
|
artisan('artworks:vectors-repair', [
|
|
'--after-id' => 25,
|
|
'--batch' => 50,
|
|
'--public-only' => true,
|
|
'--stale-hours' => 24,
|
|
])->assertSuccessful();
|
|
|
|
Queue::assertPushed(BackfillArtworkVectorIndexJob::class, function (BackfillArtworkVectorIndexJob $job): bool {
|
|
return jobProperty($job, 'afterId') === 25
|
|
&& jobProperty($job, 'batchSize') === 50
|
|
&& jobProperty($job, 'publicOnly') === true
|
|
&& jobProperty($job, 'staleHours') === 24;
|
|
});
|
|
});
|
|
|
|
it('fans out queued vector repair only for artworks with local embeddings', function (): void {
|
|
Queue::fake();
|
|
|
|
$embeddedArtwork = Artwork::factory()->create([
|
|
'hash' => '1122334455667788',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
ArtworkEmbedding::query()->create([
|
|
'artwork_id' => $embeddedArtwork->id,
|
|
'model' => 'clip',
|
|
'model_version' => 'v1',
|
|
'algo_version' => 'clip-cosine-v1',
|
|
'dim' => 2,
|
|
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
|
|
'source_hash' => '1122334455667788',
|
|
'is_normalized' => true,
|
|
'generated_at' => now(),
|
|
'meta' => ['source' => 'clip'],
|
|
]);
|
|
|
|
Artwork::factory()->create([
|
|
'hash' => 'aabbccddeeff0011',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
|
|
(new BackfillArtworkVectorIndexJob(0, 100, true))->handle();
|
|
|
|
Queue::assertPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($embeddedArtwork): bool {
|
|
return jobProperty($job, 'artworkId') === $embeddedArtwork->id;
|
|
});
|
|
Queue::assertNotPushed(BackfillArtworkVectorIndexJob::class);
|
|
});
|
|
|
|
it('can target only stale or never-indexed artworks during queued repair', function (): void {
|
|
Queue::fake();
|
|
|
|
$staleArtwork = Artwork::factory()->create([
|
|
'hash' => '1111222233334444',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
'last_vector_indexed_at' => now()->subHours(48),
|
|
]);
|
|
ArtworkEmbedding::query()->create([
|
|
'artwork_id' => $staleArtwork->id,
|
|
'model' => 'clip',
|
|
'model_version' => 'v1',
|
|
'algo_version' => 'clip-cosine-v1',
|
|
'dim' => 2,
|
|
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
|
|
'source_hash' => '1111222233334444',
|
|
'is_normalized' => true,
|
|
'generated_at' => now(),
|
|
'meta' => ['source' => 'clip'],
|
|
]);
|
|
|
|
$missingArtwork = Artwork::factory()->create([
|
|
'hash' => '5555666677778888',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
'last_vector_indexed_at' => null,
|
|
]);
|
|
ArtworkEmbedding::query()->create([
|
|
'artwork_id' => $missingArtwork->id,
|
|
'model' => 'clip',
|
|
'model_version' => 'v1',
|
|
'algo_version' => 'clip-cosine-v1',
|
|
'dim' => 2,
|
|
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
|
|
'source_hash' => '5555666677778888',
|
|
'is_normalized' => true,
|
|
'generated_at' => now(),
|
|
'meta' => ['source' => 'clip'],
|
|
]);
|
|
|
|
$freshArtwork = Artwork::factory()->create([
|
|
'hash' => '9999aaaabbbbcccc',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
'last_vector_indexed_at' => now()->subHours(2),
|
|
]);
|
|
ArtworkEmbedding::query()->create([
|
|
'artwork_id' => $freshArtwork->id,
|
|
'model' => 'clip',
|
|
'model_version' => 'v1',
|
|
'algo_version' => 'clip-cosine-v1',
|
|
'dim' => 2,
|
|
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
|
|
'source_hash' => '9999aaaabbbbcccc',
|
|
'is_normalized' => true,
|
|
'generated_at' => now(),
|
|
'meta' => ['source' => 'clip'],
|
|
]);
|
|
|
|
(new BackfillArtworkVectorIndexJob(0, 100, true, 24))->handle();
|
|
|
|
Queue::assertPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($staleArtwork): bool {
|
|
return jobProperty($job, 'artworkId') === $staleArtwork->id;
|
|
});
|
|
Queue::assertPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($missingArtwork): bool {
|
|
return jobProperty($job, 'artworkId') === $missingArtwork->id;
|
|
});
|
|
Queue::assertNotPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($freshArtwork): bool {
|
|
return jobProperty($job, 'artworkId') === $freshArtwork->id;
|
|
});
|
|
Queue::assertNotPushed(BackfillArtworkVectorIndexJob::class);
|
|
});
|