diff --git a/lib/Doctrine/ODM/MongoDB/SchemaException.php b/lib/Doctrine/ODM/MongoDB/SchemaException.php new file mode 100644 index 000000000..048090030 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/SchemaException.php @@ -0,0 +1,26 @@ +|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 $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 $indexStatus Queryable status for each index name */ + $indexStatus = array_column(iterator_to_array($collection->listSearchIndexes([ + '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); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3e870535f..1adf28b62 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -384,18 +384,6 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - message: '#^Class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata has type alias SearchIndexDefinition with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 2 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - - message: '#^Class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata has type alias SearchIndexMapping with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 2 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - message: '#^Instanceof between Doctrine\\Persistence\\Reflection\\RuntimeReflectionProperty and ReflectionProperty will always evaluate to true\.$#' identifier: instanceof.alwaysTrue @@ -408,24 +396,12 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:addSearchIndex\(\) has parameter \$definition with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 2 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:getBucketName\(\) never returns null so it can be removed from the return type\.$#' identifier: return.unusedType count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:getSearchIndexes\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 2 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:mapField\(\) should return array\{type\: string, fieldName\: string, name\: string, isCascadeRemove\: bool, isCascadePersist\: bool, isCascadeRefresh\: bool, isCascadeMerge\: bool, isCascadeDetach\: bool, \.\.\.\} but returns array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}\.$#' identifier: return.type @@ -456,12 +432,6 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - message: '#^Property Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:\$searchIndexes type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 2 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - message: '#^Template type T is declared as covariant, but occurs in invariant position in property Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:\$name\.$#' identifier: generics.variance diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php index 636007c01..bcb28e548 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php @@ -7,10 +7,9 @@ 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 { @@ -18,7 +17,6 @@ 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)]); + + // 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() diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/SchemaManagerWaitForSearchIndexesTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/SchemaManagerWaitForSearchIndexesTest.php new file mode 100644 index 000000000..d90cc7737 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/SchemaManagerWaitForSearchIndexesTest.php @@ -0,0 +1,103 @@ +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()); + } + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php index 081dcbccb..05db805fc 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php @@ -6,10 +6,9 @@ use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Documents\VectorEmbedding; +use MongoDB\Driver\WriteConcern; use PHPUnit\Framework\Attributes\Group; -use function sleep; - #[Group('atlas')] class VectorSearchTest extends BaseTestCase { @@ -20,7 +19,6 @@ public function testAtlasVectorSearch(): void // Create the collection and vector search indexes $schemaManager->createDocumentCollection(VectorEmbedding::class); - $schemaManager->createDocumentSearchIndexes(VectorEmbedding::class); // Insert some test documents with vector embeddings $doc1 = new VectorEmbedding(); @@ -41,10 +39,14 @@ public function testAtlasVectorSearch(): void $this->dm->persist($doc1); $this->dm->persist($doc2); $this->dm->persist($doc3); - $this->dm->flush(); + // Write with majority concern to ensure data is visible for search + $this->dm->flush(['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]); + + // Index must be created after data insertion, so the index status is not immediately "READY" + $schemaManager->createDocumentSearchIndexes(VectorEmbedding::class); // Wait for search index to be ready (Atlas Local needs time to build the index) - sleep(2); + $schemaManager->waitForSearchIndexes([VectorEmbedding::class]); $results = $this->dm->createAggregationBuilder(VectorEmbedding::class) ->vectorSearch() diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index a0bef9827..c14c76c04 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php @@ -9,6 +9,7 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; +use Doctrine\ODM\MongoDB\SchemaException; use Doctrine\ODM\MongoDB\SchemaManager; use Documents\BaseDocument; use Documents\CmsAddress; @@ -418,8 +419,8 @@ public function testCreateDocumentSearchIndexesNotCreatedError(): void ->with($this->anything()) ->willReturn(['foo']); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The following search indexes for Documents\CmsArticle were not created: search_articles'); + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('The document class "Documents\CmsArticle" is missing the following search index(es): "search_articles"'); $this->schemaManager->createDocumentSearchIndexes(CmsArticle::class); } @@ -486,9 +487,9 @@ public function testCreateVectorSearchIndex(): void 'name' => 'vector_int', 'definition' => [ 'fields' => [ - ['type' => 'vector', 'path' => 'vectorInt', 'numDimensions' => 3, 'similarity' => 'cosine'], ['type' => 'filter', 'path' => 'filterField'], ['type' => 'filter', 'path' => 'not_mapped_filter'], + ['type' => 'vector', 'path' => 'vectorInt', 'numDimensions' => 3, 'similarity' => 'cosine'], ], ], ], diff --git a/tests/Documents/VectorEmbedding.php b/tests/Documents/VectorEmbedding.php index 184d65077..2a8cfbd7d 100644 --- a/tests/Documents/VectorEmbedding.php +++ b/tests/Documents/VectorEmbedding.php @@ -20,9 +20,9 @@ #[VectorSearchIndex( name: 'vector_int', fields: [ - ['type' => 'vector', 'path' => 'vectorInt', 'numDimensions' => 3, 'similarity' => ClassMetadata::VECTOR_SIMILARITY_COSINE], ['type' => 'filter', 'path' => 'filterField'], ['type' => 'filter', 'path' => 'not_mapped_filter'], + ['type' => 'vector', 'path' => 'vectorInt', 'numDimensions' => 3, 'similarity' => ClassMetadata::VECTOR_SIMILARITY_COSINE], ], )] class VectorEmbedding