diff --git a/src/Contracts/Batch.php b/src/Contracts/Batch.php new file mode 100644 index 00000000..0e55a0d7 --- /dev/null +++ b/src/Contracts/Batch.php @@ -0,0 +1,147 @@ +, + * stats: RawBatchStats, + * duration: non-empty-string|null, + * startedAt: non-empty-string, + * finishedAt: non-empty-string|null, + * progress: RawBatchProgress|null, + * batchStrategy?: non-empty-string|null + * } + */ +final class Batch implements \ArrayAccess +{ + /** + * @param non-negative-int $uid + * @param non-empty-string|null $duration + * @param non-empty-string|null $batchStrategy + * @param RawBatch $raw + */ + public function __construct( + private readonly int $uid, + private readonly TaskDetails $details, + private readonly BatchStats $stats, + private readonly ?string $duration, + private readonly \DateTimeImmutable $startedAt, + private readonly ?\DateTimeImmutable $finishedAt, + private readonly ?BatchProgress $progress, + private readonly ?string $batchStrategy, + private readonly array $raw, + ) { + } + + /** + * @return non-negative-int + */ + public function getUid(): int + { + return $this->uid; + } + + public function getDetails(): TaskDetails + { + return $this->details; + } + + public function getStats(): BatchStats + { + return $this->stats; + } + + /** + * @return non-empty-string|null + */ + public function getDuration(): ?string + { + return $this->duration; + } + + public function getStartedAt(): \DateTimeImmutable + { + return $this->startedAt; + } + + public function getFinishedAt(): ?\DateTimeImmutable + { + return $this->finishedAt; + } + + /** + * Real-time progress while the batch is processing; null when finished. + * When present, `percentage` is documented by Meilisearch as 0.0–100.0. + */ + public function getProgress(): ?BatchProgress + { + return $this->progress; + } + + /** + * Free-form reason why the batch stopped accepting tasks (not a closed enum). + * + * @return non-empty-string|null + */ + public function getBatchStrategy(): ?string + { + return $this->batchStrategy; + } + + /** + * @return RawBatch + */ + public function toArray(): array + { + return $this->raw; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('The Batch object is immutable.'); + } + + public function offsetExists(mixed $offset): bool + { + return \array_key_exists($offset, $this->raw); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('The Batch object is immutable.'); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->raw[$offset] ?? null; + } + + /** + * @param RawBatch $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['uid'], + UnknownTaskDetails::fromArray($data['details']), + BatchStats::fromArray($data['stats']), + $data['duration'], + new \DateTimeImmutable($data['startedAt']), + null !== $data['finishedAt'] + ? new \DateTimeImmutable($data['finishedAt']) : null, + null !== $data['progress'] ? BatchProgress::fromArray($data['progress']) : null, + $data['batchStrategy'] ?? null, + $data, + ); + } +} diff --git a/src/Contracts/BatchEmbedderRequests.php b/src/Contracts/BatchEmbedderRequests.php new file mode 100644 index 00000000..c8d67c9e --- /dev/null +++ b/src/Contracts/BatchEmbedderRequests.php @@ -0,0 +1,63 @@ +total; + } + + /** + * @return non-negative-int + */ + public function getFailed(): int + { + return $this->failed; + } + + /** + * @return non-empty-string|null + */ + public function getLastError(): ?string + { + return $this->lastError; + } + + /** + * @param RawBatchEmbedderRequests $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['total'], + $data['failed'], + $data['lastError'] ?? null, + ); + } +} diff --git a/src/Contracts/BatchProgress.php b/src/Contracts/BatchProgress.php new file mode 100644 index 00000000..8c5a5bab --- /dev/null +++ b/src/Contracts/BatchProgress.php @@ -0,0 +1,52 @@ +, + * percentage: float + * } + */ +final class BatchProgress +{ + /** + * @param list $steps + */ + public function __construct( + private readonly array $steps, + private readonly float $percentage, + ) { + } + + /** + * @return list + */ + public function getSteps(): array + { + return $this->steps; + } + + public function getPercentage(): float + { + return $this->percentage; + } + + /** + * @param RawBatchProgress $data + */ + public static function fromArray(array $data): self + { + return new self( + array_map( + static fn (array $step) => BatchProgressStep::fromArray($step), + $data['steps'], + ), + $data['percentage'], + ); + } +} diff --git a/src/Contracts/BatchProgressStep.php b/src/Contracts/BatchProgressStep.php new file mode 100644 index 00000000..f17f33a3 --- /dev/null +++ b/src/Contracts/BatchProgressStep.php @@ -0,0 +1,63 @@ +currentStep; + } + + /** + * @return non-negative-int + */ + public function getFinished(): int + { + return $this->finished; + } + + /** + * @return non-negative-int + */ + public function getTotal(): int + { + return $this->total; + } + + /** + * @param RawBatchProgressStep $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['currentStep'], + $data['finished'], + $data['total'], + ); + } +} diff --git a/src/Contracts/BatchStats.php b/src/Contracts/BatchStats.php new file mode 100644 index 00000000..ba1d37b3 --- /dev/null +++ b/src/Contracts/BatchStats.php @@ -0,0 +1,131 @@ +, + * types: array, + * indexUids: array, + * progressTrace?: array, + * writeChannelCongestion?: RawWriteChannelCongestion|null, + * internalDatabaseSizes?: array, + * embedderRequests?: RawBatchEmbedderRequests + * } + */ +final class BatchStats +{ + /** + * @param non-negative-int $totalNbTasks + * @param array $status + * @param array $types + * @param array $indexUids + * @param array|null $progressTrace + * @param RawWriteChannelCongestion|null $writeChannelCongestion + * @param array|null $internalDatabaseSizes + */ + public function __construct( + private readonly int $totalNbTasks, + private readonly array $status, + private readonly array $types, + private readonly array $indexUids, + private readonly ?array $progressTrace = null, + private readonly ?array $writeChannelCongestion = null, + private readonly ?array $internalDatabaseSizes = null, + private readonly ?BatchEmbedderRequests $embedderRequests = null, + ) { + } + + /** + * @return non-negative-int + */ + public function getTotalNbTasks(): int + { + return $this->totalNbTasks; + } + + /** + * @return array + */ + public function getStatus(): array + { + return $this->status; + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @return array + */ + public function getIndexUids(): array + { + return $this->indexUids; + } + + /** + * @return array|null + */ + public function getProgressTrace(): ?array + { + return $this->progressTrace; + } + + /** + * @return RawWriteChannelCongestion|null + */ + public function getWriteChannelCongestion(): ?array + { + return $this->writeChannelCongestion; + } + + /** + * @return array|null + */ + public function getInternalDatabaseSizes(): ?array + { + return $this->internalDatabaseSizes; + } + + public function getEmbedderRequests(): ?BatchEmbedderRequests + { + return $this->embedderRequests; + } + + /** + * @param RawBatchStats $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['totalNbTasks'], + $data['status'], + $data['types'], + $data['indexUids'], + $data['progressTrace'] ?? null, + $data['writeChannelCongestion'] ?? null, + $data['internalDatabaseSizes'] ?? null, + isset($data['embedderRequests']) + ? BatchEmbedderRequests::fromArray($data['embedderRequests']) + : null, + ); + } +} diff --git a/src/Contracts/BatchesResults.php b/src/Contracts/BatchesResults.php index 7a9e7dbc..c8278ca2 100644 --- a/src/Contracts/BatchesResults.php +++ b/src/Contracts/BatchesResults.php @@ -4,7 +4,10 @@ namespace Meilisearch\Contracts; -class BatchesResults extends Data +/** + * @phpstan-import-type RawBatch from Batch + */ +final class BatchesResults extends Data { /** * @var non-negative-int @@ -26,18 +29,27 @@ class BatchesResults extends Data */ private int $total; + /** + * @param array{ + * results: list, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } $params + */ public function __construct(array $params) { parent::__construct($params['results']); $this->from = $params['from'] ?? 0; - $this->limit = $params['limit'] ?? 0; + $this->limit = $params['limit']; $this->next = $params['next'] ?? 0; - $this->total = $params['total'] ?? 0; + $this->total = $params['total']; } /** - * @return array + * @return list */ public function getResults(): array { @@ -76,10 +88,19 @@ public function getTotal(): int return $this->total; } + /** + * @return array{ + * results: list, + * from: non-negative-int, + * limit: non-negative-int, + * next: non-negative-int, + * total: non-negative-int + * } + */ public function toArray(): array { return [ - 'results' => $this->data, + 'results' => array_map(static fn (Batch $batch) => $batch->toArray(), $this->data), 'next' => $this->next, 'limit' => $this->limit, 'from' => $this->from, diff --git a/src/Endpoints/Batches.php b/src/Endpoints/Batches.php index 6562b93c..a8d54525 100644 --- a/src/Endpoints/Batches.php +++ b/src/Endpoints/Batches.php @@ -4,19 +4,58 @@ namespace Meilisearch\Endpoints; +use Meilisearch\Contracts\Batch; use Meilisearch\Contracts\Endpoint; +/** + * @phpstan-import-type RawBatch from Batch + * + * @phpstan-type RawBatches array{ + * results: list, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } + * @phpstan-type BatchesResponse array{ + * results: list, + * from: non-negative-int|null, + * limit: non-negative-int, + * next: non-negative-int|null, + * total: non-negative-int + * } + */ class Batches extends Endpoint { protected const PATH = '/batches'; - public function get(int $batchUid): array + public function get(int $batchUid): Batch { - return $this->http->get(self::PATH.'/'.$batchUid); + /** @var RawBatch $rawBatch */ + $rawBatch = $this->http->get(self::PATH.'/'.$batchUid); + + return Batch::fromArray($rawBatch); } + /** + * @return BatchesResponse + */ public function all(array $query = []): array { - return $this->http->get(self::PATH.'/', $query); + $rawData = $this->http->get(self::PATH.'/', $query); + /** @var RawBatches $rawBatches */ + $rawBatches = $rawData; + $results = array_map( + static fn (array $batch): Batch => Batch::fromArray($batch), + $rawBatches['results'], + ); + + return [ + 'results' => $results, + 'from' => $rawBatches['from'], + 'limit' => $rawBatches['limit'], + 'next' => $rawBatches['next'], + 'total' => $rawBatches['total'], + ]; } } diff --git a/src/Endpoints/Delegates/HandlesBatches.php b/src/Endpoints/Delegates/HandlesBatches.php index 542bd221..26f53ae8 100644 --- a/src/Endpoints/Delegates/HandlesBatches.php +++ b/src/Endpoints/Delegates/HandlesBatches.php @@ -4,6 +4,7 @@ namespace Meilisearch\Endpoints\Delegates; +use Meilisearch\Contracts\Batch; use Meilisearch\Contracts\BatchesQuery; use Meilisearch\Contracts\BatchesResults; use Meilisearch\Endpoints\Batches; @@ -12,7 +13,7 @@ trait HandlesBatches { protected Batches $batches; - public function getBatch(int $uid): array + public function getBatch(int $uid): Batch { return $this->batches->get($uid); } diff --git a/tests/Contracts/BatchProgressTest.php b/tests/Contracts/BatchProgressTest.php new file mode 100644 index 00000000..527e6df0 --- /dev/null +++ b/tests/Contracts/BatchProgressTest.php @@ -0,0 +1,101 @@ +getSteps()); + self::assertSame(50.0, $progress->getPercentage()); + } + + public function testFromArray(): void + { + $progress = BatchProgress::fromArray([ + 'steps' => [ + ['currentStep' => 'indexing', 'finished' => 1, 'total' => 2], + ], + 'percentage' => 50.0, + ]); + + self::assertEquals([ + new BatchProgressStep( + currentStep: 'indexing', + finished: 1, + total: 2, + ), + ], $progress->getSteps()); + self::assertSame(50.0, $progress->getPercentage()); + } + + public function testFromArrayMapsMultipleSteps(): void + { + $progress = BatchProgress::fromArray([ + 'steps' => [ + ['currentStep' => 'extractingWords', 'finished' => 2, 'total' => 2], + ['currentStep' => 'indexing', 'finished' => 1, 'total' => 3], + ], + 'percentage' => 33.3, + ]); + + self::assertEquals([ + new BatchProgressStep( + currentStep: 'extractingWords', + finished: 2, + total: 2, + ), + new BatchProgressStep( + currentStep: 'indexing', + finished: 1, + total: 3, + ), + ], $progress->getSteps()); + self::assertSame(33.3, $progress->getPercentage()); + } + + public function testProgressStepConstruct(): void + { + $step = new BatchProgressStep( + currentStep: 'indexing', + finished: 1, + total: 2, + ); + + self::assertSame('indexing', $step->getCurrentStep()); + self::assertSame(1, $step->getFinished()); + self::assertSame(2, $step->getTotal()); + } + + public function testProgressStepFromArray(): void + { + $step = BatchProgressStep::fromArray([ + 'currentStep' => 'indexing', + 'finished' => 1, + 'total' => 2, + ]); + + self::assertSame('indexing', $step->getCurrentStep()); + self::assertSame(1, $step->getFinished()); + self::assertSame(2, $step->getTotal()); + } +} diff --git a/tests/Contracts/BatchStatsTest.php b/tests/Contracts/BatchStatsTest.php new file mode 100644 index 00000000..f75a0de3 --- /dev/null +++ b/tests/Contracts/BatchStatsTest.php @@ -0,0 +1,173 @@ + 1], + types: ['documentAdditionOrUpdate' => 1], + indexUids: ['movies' => 1], + progressTrace: ['processing tasks > indexing' => '2.40s'], + writeChannelCongestion: [ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], + internalDatabaseSizes: ['documents' => '25.41 MiB (+25.41 MiB)'], + embedderRequests: $embedderRequests, + ); + + self::assertSame(1, $stats->getTotalNbTasks()); + self::assertSame(['succeeded' => 1], $stats->getStatus()); + self::assertSame(['documentAdditionOrUpdate' => 1], $stats->getTypes()); + self::assertSame(['movies' => 1], $stats->getIndexUids()); + self::assertSame(['processing tasks > indexing' => '2.40s'], $stats->getProgressTrace()); + self::assertSame([ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], $stats->getWriteChannelCongestion()); + self::assertSame(['documents' => '25.41 MiB (+25.41 MiB)'], $stats->getInternalDatabaseSizes()); + self::assertSame($embedderRequests, $stats->getEmbedderRequests()); + } + + public function testConstructWithOptionalFieldsNull(): void + { + $stats = new BatchStats( + totalNbTasks: 2, + status: ['succeeded' => 2], + types: ['documentAdditionOrUpdate' => 2], + indexUids: ['books' => 2], + ); + + self::assertSame(2, $stats->getTotalNbTasks()); + self::assertSame(['succeeded' => 2], $stats->getStatus()); + self::assertSame(['documentAdditionOrUpdate' => 2], $stats->getTypes()); + self::assertSame(['books' => 2], $stats->getIndexUids()); + self::assertNull($stats->getProgressTrace()); + self::assertNull($stats->getWriteChannelCongestion()); + self::assertNull($stats->getInternalDatabaseSizes()); + self::assertNull($stats->getEmbedderRequests()); + } + + public function testFromArray(): void + { + $stats = BatchStats::fromArray([ + 'totalNbTasks' => 1, + 'status' => ['succeeded' => 1], + 'types' => ['documentAdditionOrUpdate' => 1], + 'indexUids' => ['movies' => 1], + 'progressTrace' => ['processing tasks > indexing' => '2.40s'], + 'writeChannelCongestion' => [ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], + 'internalDatabaseSizes' => ['documents' => '25.41 MiB (+25.41 MiB)'], + 'embedderRequests' => [ + 'total' => 10, + 'failed' => 2, + 'lastError' => 'timeout', + ], + ]); + + self::assertSame(1, $stats->getTotalNbTasks()); + self::assertSame(['succeeded' => 1], $stats->getStatus()); + self::assertSame(['documentAdditionOrUpdate' => 1], $stats->getTypes()); + self::assertSame(['movies' => 1], $stats->getIndexUids()); + self::assertSame(['processing tasks > indexing' => '2.40s'], $stats->getProgressTrace()); + self::assertSame([ + 'attempts' => 2608482, + 'blocking_attempts' => 0, + 'blocking_ratio' => 0.0, + ], $stats->getWriteChannelCongestion()); + self::assertSame(['documents' => '25.41 MiB (+25.41 MiB)'], $stats->getInternalDatabaseSizes()); + self::assertEquals(new BatchEmbedderRequests( + total: 10, + failed: 2, + lastError: 'timeout', + ), $stats->getEmbedderRequests()); + } + + public function testFromArrayWithOptionalFieldsAbsent(): void + { + $stats = BatchStats::fromArray([ + 'totalNbTasks' => 1, + 'status' => ['succeeded' => 1], + 'types' => ['documentAdditionOrUpdate' => 1], + 'indexUids' => ['movies' => 1], + ]); + + self::assertSame(1, $stats->getTotalNbTasks()); + self::assertNull($stats->getProgressTrace()); + self::assertNull($stats->getWriteChannelCongestion()); + self::assertNull($stats->getInternalDatabaseSizes()); + self::assertNull($stats->getEmbedderRequests()); + } + + public function testEmbedderRequestsConstruct(): void + { + $requests = new BatchEmbedderRequests( + total: 5, + failed: 1, + lastError: 'rate limited', + ); + + self::assertSame(5, $requests->getTotal()); + self::assertSame(1, $requests->getFailed()); + self::assertSame('rate limited', $requests->getLastError()); + } + + public function testEmbedderRequestsConstructWithOptionalLastErrorNull(): void + { + $requests = new BatchEmbedderRequests( + total: 3, + failed: 0, + ); + + self::assertSame(3, $requests->getTotal()); + self::assertSame(0, $requests->getFailed()); + self::assertNull($requests->getLastError()); + } + + public function testEmbedderRequestsFromArray(): void + { + $requests = BatchEmbedderRequests::fromArray([ + 'total' => 10, + 'failed' => 2, + 'lastError' => 'timeout', + ]); + + self::assertSame(10, $requests->getTotal()); + self::assertSame(2, $requests->getFailed()); + self::assertSame('timeout', $requests->getLastError()); + } + + public function testEmbedderRequestsFromArrayWithOptionalLastErrorAbsent(): void + { + $requests = BatchEmbedderRequests::fromArray([ + 'total' => 4, + 'failed' => 0, + ]); + + self::assertSame(4, $requests->getTotal()); + self::assertSame(0, $requests->getFailed()); + self::assertNull($requests->getLastError()); + } +} diff --git a/tests/Contracts/BatchesResultsTest.php b/tests/Contracts/BatchesResultsTest.php new file mode 100644 index 00000000..92f3a279 --- /dev/null +++ b/tests/Contracts/BatchesResultsTest.php @@ -0,0 +1,71 @@ + 1, + 'details' => ['receivedDocuments' => 1], + 'stats' => [ + 'totalNbTasks' => 1, + 'status' => ['succeeded' => 1], + 'types' => ['documentAdditionOrUpdate' => 1], + 'indexUids' => ['movies' => 1], + ], + 'duration' => 'PT1S', + 'startedAt' => '2025-04-09T10:28:12.236789Z', + 'finishedAt' => '2025-04-09T10:28:13.236789Z', + 'progress' => null, + ]; + $secondRaw = [ + 'uid' => 2, + 'details' => ['receivedDocuments' => 2], + 'stats' => [ + 'totalNbTasks' => 2, + 'status' => ['succeeded' => 2], + 'types' => ['documentAdditionOrUpdate' => 2], + 'indexUids' => ['books' => 2], + ], + 'duration' => null, + 'startedAt' => '2025-04-09T11:28:12.236789Z', + 'finishedAt' => null, + 'progress' => [ + 'steps' => [ + ['currentStep' => 'indexing', 'finished' => 1, 'total' => 2], + ], + 'percentage' => 50.0, + ], + ]; + + $firstBatch = Batch::fromArray($firstRaw); + $secondBatch = Batch::fromArray($secondRaw); + + $results = new BatchesResults([ + 'results' => [$firstBatch, $secondBatch], + 'from' => 1, + 'limit' => 2, + 'next' => 3, + 'total' => 5, + ]); + + $array = $results->toArray(); + + self::assertSame(1, $array['from']); + self::assertSame(2, $array['limit']); + self::assertSame(3, $array['next']); + self::assertSame(5, $array['total']); + self::assertSame( + [$firstBatch->toArray(), $secondBatch->toArray()], + $array['results'], + ); + } +} diff --git a/tests/Endpoints/BatchesTest.php b/tests/Endpoints/BatchesTest.php index 828256d3..5f4052fe 100644 --- a/tests/Endpoints/BatchesTest.php +++ b/tests/Endpoints/BatchesTest.php @@ -5,6 +5,7 @@ namespace Tests\Endpoints; use Meilisearch\Contracts\BatchesQuery; +use Meilisearch\Contracts\TaskDetails\UnknownTaskDetails; use Meilisearch\Contracts\TasksQuery; use Tests\TestCase; @@ -29,7 +30,7 @@ public function testGetAllBatchesWithIndexUidFilters(): void { $response = $this->client->getBatches((new BatchesQuery())->setIndexUids([$this->indexName])); foreach ($response->getResults() as $result) { - self::assertArrayHasKey($this->indexName, $result['stats']['indexUids']); + self::assertArrayHasKey($this->indexName, $result->getStats()->getIndexUids()); } } @@ -51,24 +52,24 @@ public function testGetAllBatchesInReverseOrder(): void ->setAfterEnqueuedAt($startDate) ->setReverse(true) ); - self::assertSame($batches->getResults(), array_reverse($reversedBatches->getResults())); + $batchUids = array_map(static fn ($b) => $b->getUid(), $batches->getResults()); + $reversedUids = array_map(static fn ($b) => $b->getUid(), $reversedBatches->getResults()); + self::assertSame($batchUids, array_reverse($reversedUids)); } public function testGetOneBatch(): void { $batches = $this->client->getBatches(); - $response = $this->client->getBatch($batches->getResults()[0]['uid']); + $first = $batches->getResults()[0]; + $response = $this->client->getBatch($first->getUid()); - self::assertSame($batches->getResults()[0]['uid'], $response['uid']); - self::assertArrayHasKey('details', $response); - self::assertArrayHasKey('totalNbTasks', $response['stats']); - self::assertArrayHasKey('status', $response['stats']); - self::assertArrayHasKey('types', $response['stats']); - self::assertArrayHasKey('indexUids', $response['stats']); - self::assertArrayHasKey('progressTrace', $response['stats']); - self::assertArrayHasKey('duration', $response); - self::assertArrayHasKey('startedAt', $response); - self::assertArrayHasKey('finishedAt', $response); - self::assertArrayHasKey('progress', $response); + self::assertSame($first->getUid(), $response->getUid()); + self::assertInstanceOf(UnknownTaskDetails::class, $response->getDetails()); + $stats = $response->getStats(); + self::assertSame($stats->getTotalNbTasks(), array_sum($stats->getStatus())); + self::assertNotEmpty($stats->getStatus()); + self::assertNotEmpty($stats->getTypes()); + self::assertNotNull($stats->getProgressTrace()); + self::assertSame($response->toArray()['batchStrategy'] ?? null, $response->getBatchStrategy()); } }