-
-
Couldn't load subscription status.
- Fork 514
Add SchemaManager::waitForSearchIndexes()
#2830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Doctrine\ODM\MongoDB; | ||
|
|
||
| use RuntimeException; | ||
|
|
||
| use function implode; | ||
| use function sprintf; | ||
|
|
||
| /** | ||
| * Exception for schema-related issues. | ||
| */ | ||
| final class SchemaException extends RuntimeException | ||
| { | ||
| /** | ||
| * @internal | ||
| * | ||
| * @param string[] $missingIndexes | ||
| */ | ||
| public static function missingSearchIndex(string $documentClass, array $missingIndexes): self | ||
| { | ||
| return new self(sprintf('The document class "%s" is missing the following search index(es): "%s"', $documentClass, implode('", "', $missingIndexes))); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,12 +20,14 @@ | |
| use function array_diff_key; | ||
| use function array_filter; | ||
| use function array_keys; | ||
| use function array_map; | ||
| use function array_merge; | ||
| use function array_search; | ||
| use function array_unique; | ||
| use function array_values; | ||
| use function assert; | ||
| use function count; | ||
| use function hrtime; | ||
| use function implode; | ||
| use function in_array; | ||
| use function is_array; | ||
|
|
@@ -35,6 +37,7 @@ | |
| use function ksort; | ||
| use function sprintf; | ||
| use function str_contains; | ||
| use function usleep; | ||
|
|
||
| /** | ||
| * @phpstan-import-type IndexMapping from ClassMetadata | ||
|
|
@@ -337,6 +340,65 @@ public function createSearchIndexes(): void | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Wait until all search indexes are queryable for the given document classes. | ||
| * | ||
| * @param list<class-string>|null $classNames List of class names to check, or null to check all mapped classes | ||
| * @param positive-int $maxTimeMs Maximum time to wait in milliseconds (default: 10,000 ms) | ||
| * @param positive-int $waitTimeMs Time to wait between checks in milliseconds (default: 100 ms) | ||
| */ | ||
| public function waitForSearchIndexes(?array $classNames = null, int $maxTimeMs = 10_000, int $waitTimeMs = 100): void | ||
| { | ||
| if ($maxTimeMs < 1) { | ||
| throw new InvalidArgumentException('$maxTimeMs must be a positive number of milliseconds.'); | ||
| } | ||
|
|
||
| if ($waitTimeMs < 1) { | ||
| throw new InvalidArgumentException('$waitTimeMs must be a positive number of milliseconds.'); | ||
| } | ||
|
|
||
| $classes = $classNames === null ? $this->metadataFactory->getAllMetadata() : array_map($this->metadataFactory->getMetadataFor(...), $classNames); | ||
|
|
||
| /** @var array<class-string, string[]> $indexesToCheck Search indexes for each class */ | ||
| $indexesToCheck = []; | ||
| foreach ($classes as $class) { | ||
| if (! $class->hasSearchIndexes()) { | ||
| continue; | ||
| } | ||
|
|
||
| $indexesToCheck[$class->getName()] = array_column($class->getSearchIndexes(), 'name'); | ||
| } | ||
|
|
||
| $start = hrtime(true); | ||
| while ($indexesToCheck) { | ||
| if (hrtime(true) > $start + $maxTimeMs * 1_000_000) { | ||
| throw new MongoDBException(sprintf('Timed out waiting for search indexes to become queryable after %d ms. Search indexes are not ready for the following class(es): %s', $maxTimeMs, implode(', ', array_keys($indexesToCheck)))); | ||
| } | ||
|
|
||
| foreach ($indexesToCheck as $className => $indexNames) { | ||
| $collection = $this->dm->getDocumentCollection($className); | ||
|
|
||
| /** @var array<string, bool> $indexStatus Queryable status for each index name */ | ||
| $indexStatus = array_column(iterator_to_array($collection->listSearchIndexes([ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What a marvelous function |
||
| 'filter' => ['name' => ['$in' => array_keys($indexNames)]], | ||
| 'typeMap' => ['root' => 'array'], | ||
| ])), 'queryable', 'name'); | ||
|
|
||
| // Check that all indexes exist | ||
| $missingIndexes = array_diff_key($indexNames, array_keys($indexStatus)); | ||
| if ($missingIndexes) { | ||
| throw SchemaException::missingSearchIndex($className, $missingIndexes); | ||
| } | ||
|
|
||
| // Remove the indexes that are ready from the list of indexes to check | ||
| $indexesToCheck[$className] = array_keys(array_filter($indexStatus, static fn ($queryable) => ! $queryable)); | ||
| } | ||
|
|
||
| // Remove empty arrays and wait before checking again | ||
| ($indexesToCheck = array_filter($indexesToCheck)) && usleep($waitTimeMs * 1_000); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create search indexes for the given document class. | ||
| * | ||
|
|
@@ -368,7 +430,7 @@ public function createDocumentSearchIndexes(string $documentName): void | |
| $unprocessedNames = array_diff($definedNames, $createdNames); | ||
|
|
||
| if (! empty($unprocessedNames)) { | ||
| throw new InvalidArgumentException(sprintf('The following search indexes for %s were not created: %s', $class->name, implode(', ', $unprocessedNames))); | ||
| throw SchemaException::missingSearchIndex($class->name, $unprocessedNames); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,18 +7,16 @@ | |
| use Doctrine\ODM\MongoDB\Tests\BaseTestCase; | ||
| use Documents\CmsArticle; | ||
| use Documents\CmsUser; | ||
| use MongoDB\Driver\WriteConcern; | ||
| use PHPUnit\Framework\Attributes\Group; | ||
|
|
||
| use function sleep; | ||
|
|
||
| #[Group('atlas')] | ||
| class AtlasSearchTest extends BaseTestCase | ||
| { | ||
| public function testAtlasSearch(): void | ||
| { | ||
| $schemaManager = $this->dm->getSchemaManager(); | ||
| $schemaManager->createDocumentCollection(CmsArticle::class); | ||
| $schemaManager->createDocumentSearchIndexes(CmsArticle::class); | ||
|
|
||
| $user = new CmsUser(); | ||
| $user->status = 'active'; | ||
|
|
@@ -45,10 +43,15 @@ public function testAtlasSearch(): void | |
| $this->dm->persist($article1); | ||
| $this->dm->persist($article2); | ||
| $this->dm->persist($article3); | ||
| $this->dm->flush(); | ||
|
|
||
| // Write with majority concern to ensure data is visible for search | ||
| $this->dm->flush(['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FWIW, I asked about this in #atlas-search as I couldn't find this discussed in the Atlas Search docs. In any event, no action needed on your part here. |
||
|
|
||
| // Index must be created after data insertion, so the index status is not immediately "READY" | ||
| $schemaManager->createDocumentSearchIndexes(CmsArticle::class); | ||
|
|
||
| // Wait for the search index to be ready (Atlas Local needs time to build the index) | ||
| sleep(2); | ||
| $schemaManager->waitForSearchIndexes([CmsArticle::class, CmsUser::class]); | ||
|
|
||
| $results = $this->dm->createAggregationBuilder(CmsArticle::class) | ||
| ->search() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Doctrine\ODM\MongoDB\Tests\Functional; | ||
|
|
||
| use Doctrine\ODM\MongoDB\MongoDBException; | ||
| use Doctrine\ODM\MongoDB\SchemaException; | ||
| use Doctrine\ODM\MongoDB\Tests\BaseTestCase; | ||
| use Documents\CmsArticle; | ||
| use MongoDB\Driver\BulkWrite; | ||
| use PHPUnit\Framework\Attributes\Group; | ||
| use PHPUnit\Framework\Attributes\TestWith; | ||
|
|
||
| use function hrtime; | ||
|
|
||
| #[Group('atlas')] | ||
| class SchemaManagerWaitForSearchIndexesTest extends BaseTestCase | ||
| { | ||
| #[TestWith([0])] | ||
| #[TestWith([50_000])] | ||
| public function testWait(int $nbDocuments): void | ||
| { | ||
| $schemaManager = $this->dm->getSchemaManager(); | ||
| $collection = $this->dm->getDocumentCollection(CmsArticle::class); | ||
|
|
||
| $schemaManager->createDocumentCollection(CmsArticle::class); | ||
|
|
||
| if ($nbDocuments) { | ||
| $bulk = new BulkWrite(); | ||
| for ($i = 0; $i < $nbDocuments; $i++) { | ||
| $bulk->insert(['topic' => 'topic ' . $i, 'title' => 'title ' . $i, 'text' => 'text ' . $i]); | ||
| } | ||
|
|
||
| $collection->getManager()->executeBulkWrite($collection->getNamespace(), $bulk); | ||
| } | ||
|
|
||
| // The index must be created after data insertion, so the index status is not immediately "READY" | ||
| $schemaManager->createDocumentSearchIndexes(CmsArticle::class); | ||
|
|
||
| $this->assertNotSame('READY', $collection->listSearchIndexes(['name' => 'search_articles'])->current()['status']); | ||
|
|
||
| $start = hrtime(true); | ||
| $schemaManager->waitForSearchIndexes([CmsArticle::class]); | ||
| $timeMs = (hrtime(true) - $start) / 1_000_000; | ||
|
|
||
| $this->assertSame($nbDocuments, $collection->aggregate([ | ||
| [ | ||
| '$searchMeta' => [ | ||
| 'index' => 'search_articles', | ||
| 'exists' => ['path' => '_id'], | ||
| 'count' => ['type' => 'total'], | ||
| ], | ||
| ], | ||
| ])->toArray()[0]['count']['total'], 'All documents are indexed'); | ||
|
|
||
| $this->assertSame('READY', $collection->listSearchIndexes(['name' => 'search_articles'])->current()['status'], 'Ready after ' . $timeMs . ' ms'); | ||
| } | ||
|
|
||
| public function testErrors(): void | ||
| { | ||
| $schemaManager = $this->dm->getSchemaManager(); | ||
|
|
||
| // Search index missing | ||
| try { | ||
| $schemaManager->waitForSearchIndexes([CmsArticle::class]); | ||
| $this->fail('Expected SchemaException not thrown'); | ||
| } catch (SchemaException $exception) { | ||
| $this->assertSame('The document class "Documents\CmsArticle" is missing the following search index(es): "search_articles"', $exception->getMessage()); | ||
| } | ||
|
|
||
| $schemaManager->createDocumentCollection(CmsArticle::class); | ||
| $schemaManager->createDocumentSearchIndexes(CmsArticle::class); | ||
|
|
||
| // Timeout too short | ||
| try { | ||
| $schemaManager->waitForSearchIndexes([CmsArticle::class], 1); | ||
| $this->fail('Expected SchemaException not thrown'); | ||
| } catch (MongoDBException $exception) { | ||
| $this->assertSame('Timed out waiting for search indexes to become queryable after 1 ms. Search indexes are not ready for the following class(es): Documents\CmsArticle', $exception->getMessage()); | ||
| } | ||
|
|
||
| // Not specifying classes waits for all | ||
| try { | ||
| $schemaManager->waitForSearchIndexes(); | ||
| $this->fail('Expected SchemaException not thrown'); | ||
| } catch (SchemaException $exception) { | ||
| // The missing class varies depending on the test execution order, | ||
| // classes are added to the ClassMetadataFactory in the order they are used | ||
| $this->assertMatchesRegularExpression('#The document class "Documents\\\\(CmsAddress|VectorEmbedding)" is missing the following search index\(es\): "default"#', $exception->getMessage()); | ||
| } | ||
|
|
||
| // Remove the collection | ||
| $schemaManager->dropDocumentCollection(CmsArticle::class); | ||
|
|
||
| try { | ||
| $schemaManager->waitForSearchIndexes([CmsArticle::class]); | ||
| $this->fail('Expected SchemaException not thrown'); | ||
| } catch (SchemaException $exception) { | ||
| $this->assertSame('The document class "Documents\CmsArticle" is missing the following search index(es): "search_articles"', $exception->getMessage()); | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.