jdaugherty commented on code in PR #16208:
URL: https://github.com/apache/grails-core/pull/16208#discussion_r4062416285


##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -952,7 +1183,7 @@ protected void initializeIndices(final PersistentEntity 
entity) {
                     }
                     Document indexDef = new Document(compoundIndex);
                     createOrUpdateIndex(entity, collection, indexDef, 
indexAttributes,
-                            "compound index with definition [" + indexDef + 
"]");
+                            "compound index with definition [" + indexDef + 
"]", summary, existingIndexes);

Review Comment:
   Anchored on the changed line; the mutation is a few lines up at 
`compoundIndex.remove(INDEX_ATTRIBUTES)`, which is unchanged — but 
`buildIndexesAsync` changes what it costs.
   
   `getCompoundIndices()` returns the live `List<Map>` off `MongoCollection`, 
and the mapped form is one instance shared by every datastore built on the same 
`MongoMappingContext` — this one and each per-connection child. Removing the 
key consumes the declaration: the first build to reach it gets the attributes, 
every later build sees a compound index with none.
   
   That was already true of a second synchronous build, but it was hard to 
reach. Now the children's builds run concurrently on their own executors. For 
`compoundIndex name: 1, age: -1, indexAttributes: [unique: true]` with two 
connections configured, one connection gets the unique index and the other 
silently gets a non-unique one — or an IndexOptionsConflict, which this PR now 
counts into `summary.failures`. Two threads calling `remove` on the same 
`HashMap` also leaves the enclosing `for (Map compoundIndex : 
mappedForm.getCompoundIndices())` open to `ConcurrentModificationException`.
   
   Please read the attributes without mutating the mapping:
   
   ```java
   Map<?, ?> declaration = new LinkedHashMap<>(compoundIndex);
   Object o = declaration.remove(INDEX_ATTRIBUTES);
   Map indexAttributes = (o instanceof Map) ? (Map) o : null;
   Document indexDef = new Document(declaration);
   ```
   
   A test with two connections whose entity declares a compound index with 
`indexAttributes` would pin it.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -1033,23 +1285,26 @@ private void createOrUpdateIndex(PersistentEntity 
entity,
      * different options. A TTL difference is the common, safe case (e.g. a 
configurable retention
      * changed between restarts) and is updated in place via {@code collMod}; 
anything else needs an
      * explicit {@code recreateOnConflict:true} to authorise the 
drop-and-recreate.
+     *
+     * @return {@code true} if the index ended up in the declared state, 
{@code false} if the conflict
+     *         could not be resolved and the existing index was left as it was
      */
-    private void reconcileIndexConflict(PersistentEntity entity,
+    private boolean reconcileIndexConflict(PersistentEntity entity,
                                         
com.mongodb.client.MongoCollection<Document> collection,
+                                        ExistingIndexes existingIndexes,
                                         Document keys, IndexOptions desired, 
Long expireAfterSeconds,
                                         boolean recreateOnConflict, String 
descriptor, MongoCommandException original) {
-        Document existing;
-        try {
-            existing = findIndexByKeyPattern(collection, keys);
-        } catch (RuntimeException listError) {
+        List<Document> indexes = existingIndexes.get();

Review Comment:
   This replaces a fresh `findIndexByKeyPattern(collection, keys)` — which 
issued `listIndexes` at the moment of the conflict — with the snapshot taken 
before this entity's first `createIndex`. A conflict caused by something that 
appeared in between is invisible to that snapshot.
   
   Two instances starting together, or two connections whose builds this PR now 
deliberately runs concurrently: A lists the indexes, B creates `name_1` with 
`unique: true`, A's `createIndex` gets IndexOptionsConflict (85). A's snapshot 
has no `name_1`, so `indexes` has nothing to match, and this logs a failure 
where a fresh listing would have found the index and taken the TTL-update or 
`recreateOnConflict` path.
   
   Reusing the snapshot for the `contains()` classification is reasonable; the 
reconcile path needs current state. Re-listing on conflict (and refreshing the 
cached list with the result) keeps both.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -574,17 +598,188 @@ public ConnectionSources<MongoClient, 
MongoConnectionSourceSettings> getConnecti
     }
 
     /**
-     * Builds the MongoDB index for this datastore
+     * Builds the MongoDB index for this datastore.
+     *
+     * <p>Each index is created by a command that the server answers only once 
the index has been built,
+     * so with the default settings this blocks whoever creates the datastore 
— in an application, the
+     * startup thread — for as long as MongoDB takes to build every declared 
index. Enabling
+     * {@code grails.mongodb.buildIndexesAsync} hands the work to a background 
thread and returns
+     * immediately instead.
      */
     public void buildIndex() {
-        for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
-            // Only create Mongo templates for entities that are mapped with 
Mongo
-            if (!entity.isExternal()) {
-                if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
+        if (!buildIndexes) {
+            LOG.info("Index creation is disabled by [{} = false]. The indexes 
declared by the domain classes " +
+                    "will not be created or reconciled; the indexes already 
present on the server are left untouched.",
+                    MongoSettings.SETTING_BUILD_INDEXES);
+            return;
+        }
+        if (indexBuildExecutor == null) {
+            buildDeclaredIndexes();
+            return;
+        }
+        LOG.info("Building the indexes declared by the domain classes on a 
background thread ([{} = true]). " +
+                "Startup does not wait for them, so a query issued before its 
index exists is served without it.",
+                MongoSettings.SETTING_BUILD_INDEXES_ASYNC);
+        indexBuildExecutor.execute(() -> {
+            try {
+                buildDeclaredIndexes();
+            }
+            catch (Throwable e) {
+                // Nothing is waiting on this thread, so an error that would 
have failed startup has to be
+                // reported here or it is lost entirely.
+                if (indexBuildExecutor.isShutdown() || 
Thread.currentThread().isInterrupted()) {
+                    // toString rather than the message: an interrupted driver 
call can arrive wrapped in
+                    // an exception that carries no message of its own.
+                    LOG.debug("The background index build was abandoned 
because the datastore is shutting down: {}",
+                            e.toString(), e);
+                }
+                else {
+                    LOG.error("The background index build failed: {}. The 
application is running without the " +
+                            "indexes that were not created.", e.getMessage(), 
e);
+                }
+            }
+        });
+    }
+
+    /**
+     * Creates and reconciles the indexes declared by every entity mapped to 
this datastore, and reports
+     * what that cost. MongoDB answers each {@code createIndex} only once the 
index exists, so the elapsed
+     * time is the time the caller — startup, or the background build thread — 
actually spent waiting.
+     */
+    private void buildDeclaredIndexes() {
+        long startedAt = System.nanoTime();
+        IndexBuildSummary summary = new IndexBuildSummary();
+        IndexBuildSummary previousSummary = indexBuildSummary.get();
+        indexBuildSummary.set(summary);
+        try {
+            for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
+                // Only create Mongo templates for entities that are mapped 
with Mongo
+                if (!entity.isExternal()) {
+                    if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
 
-                initializeIndices(entity);
+                    summary.entities++;
+                    initializeIndices(entity);
+                }
             }
         }
+        finally {
+            if (previousSummary == null) {
+                indexBuildSummary.remove();
+            }
+            else {
+                indexBuildSummary.set(previousSummary);
+            }
+        }
+        long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startedAt);
+        if (summary.applied() == 0 && summary.failures == 0) {
+            LOG.debug("No indexes are declared by the {} domain class(es) 
mapped to database [{}]",
+                    summary.entities, defaultDatabase);
+            return;
+        }
+        String outcome = summary.classified ?
+                summary.created + " created, " + summary.alreadyPresent + " 
already present" :
+                summary.applied() + " index declaration(s) applied";
+        if (summary.failures == 0) {
+            LOG.info("Index build for database [{}] finished in {}ms: {}, from 
{} domain class(es)",
+                    defaultDatabase, elapsedMillis, outcome, summary.entities);
+        }
+        else {
+            LOG.warn("Index build for database [{}] finished in {}ms: {}, {} 
failed, from {} domain class(es). " +
+                    "The failures are reported above.",
+                    defaultDatabase, elapsedMillis, outcome, summary.failures, 
summary.entities);
+        }
+    }
+
+    /**
+     * The indexes a collection already had when the build reached it, listed 
once on first use and then
+     * reused. {@code createIndex} is idempotent and answers the same way 
whether or not it had to build
+     * anything — the driver hands back only the index name, discarding the 
{@code numIndexesBefore} /
+     * {@code numIndexesAfter} the server reports — so what was there 
beforehand is what distinguishes an
+     * index this build created from one it merely confirmed.
+     *
+     * <p>Listed lazily so that an entity declaring no indexes costs no round 
trip, and reused by the
+     * conflict path, which would otherwise list them again. Successful 
changes are recorded so later
+     * declarations on the same keys see the current name and TTL, and are not 
counted as new indexes.
+     */
+    private static final class ExistingIndexes {
+
+        private final com.mongodb.client.MongoCollection<Document> collection;
+
+        private final IndexBuildSummary summary;
+
+        private List<Document> indexes;
+
+        private boolean listed;
+
+        private ExistingIndexes(com.mongodb.client.MongoCollection<Document> 
collection, IndexBuildSummary summary) {
+            this.collection = collection;
+            this.summary = summary;
+        }
+
+        /**
+         * @return the known current indexes, or {@code null} if they could 
not be listed
+         */
+        private List<Document> get() {
+            if (!listed) {
+                listed = true;
+                try {
+                    indexes = collection.listIndexes().into(new ArrayList<>());
+                } catch (RuntimeException e) {
+                    // Not fatal: the build can still create indexes, it just 
cannot report which of them
+                    // were new. Losing the breakdown is not worth failing a 
startup over.
+                    LOG.debug("Could not list the existing indexes of 
collection [{}]: {}",
+                            collection.getNamespace().getCollectionName(), 
e.getMessage(), e);
+                    summary.classified = false;
+                }
+            }
+            return indexes;
+        }
+
+        private void record(Document keys, String name, Long 
expireAfterSeconds) {
+            if (indexes == null) {
+                return;
+            }
+            Document existing = findIndexByKeyPattern(indexes, keys);
+            if (existing != null) {
+                indexes.remove(existing);
+            }
+            Document index = new Document("key", new 
Document(keys)).append("name", name);
+            if (expireAfterSeconds != null) {
+                index.append(INDEX_EXPIRE_AFTER_SECONDS, expireAfterSeconds);
+            }
+            indexes.add(index);
+        }
+
+        private boolean contains(Document keys) {

Review Comment:
   `contains()` delegates to `findIndexByKeyPattern`, which deliberately 
matches *any* existing text index when the desired keys are a text index:
   
   ```java
   if (desiredIsText && isTextIndex((Document) key)) {
   ```
   
   That is right for the reconciliation caller — MongoDB allows one text index 
per collection, so any text index is the one that conflicts. It is wrong here, 
where the only question is whether *this* index already existed.
   
   Collection has a text index on `title`; a new declaration adds a text index 
on `body` with `recreateOnConflict: true`. `present` computes as `true`, the 
build then drops `title`'s index and creates `body`'s, and the summary reports 
`0 created, 1 already present`. Per the new guide text, the operator reads that 
as "a restart that changed no mappings" costing milliseconds — from a build 
that rebuilt a text index from scratch.
   
   `contains()` should compare strictly with `sameKeyPattern` and leave the 
text-index wildcard to `reconcileIndexConflict`.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -1241,6 +1500,14 @@ private boolean ownsClient() {
     @PreDestroy
     public void close() {
         MongoClient current = this.mongo;
+        shutDownIndexBuild();

Review Comment:
   `close()` shuts the index build down here; `stop()` — the CRaC path just 
above — does not. It closes the client and returns.
   
   With `buildIndexesAsync` and a checkpoint taken shortly after startup, 
`stop()` runs `this.mongo.close()` while `gorm-mongo-index-build-DEFAULT` is 
inside `createIndex`. The driver throws `IllegalStateException: state should 
be: open`, and in the new catch block in `buildIndex()` neither 
`indexBuildExecutor.isShutdown()` nor the interrupt flag is set — so a routine 
checkpoint logs "The background index build failed. The application is running 
without the indexes that were not created."
   
   It is also true after the restore: `start()` only rebuilds the client, so 
the indexes that had not been created yet never are.
   
   `stop()` should call `shutDownIndexBuild()` before closing the client, and 
`start()` should re-run the build (or the datastore should record that the 
build was cut short). Worth a test over the `stop()`/`start()` pair with an 
async build in flight.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -1282,7 +1579,12 @@ public void close() {
      * @return The {@link ConnectionSources}
      */
     protected static ConnectionSources<MongoClient, 
MongoConnectionSourceSettings> createDefaultConnectionSources(MongoClient 
mongoClient, PropertyResolver configuration, MongoMappingContext 
mappingContext, boolean closeable) {
-        MongoConnectionSourceSettings settings = new 
MongoConnectionSourceSettings();
+        // Bound from the configuration rather than left at the defaults: the 
client is supplied here, but
+        // the settings that describe how the datastore behaves (multiTenancy, 
stateless, transactional,
+        // buildIndexes, engine, flush mode) still come from grails.mongodb 
with grails.gorm fallbacks,
+        // exactly as they do when GORM creates the client itself. The 
connection details in them are
+        // unused - this client is already connected.
+        MongoConnectionSourceSettings settings = 
buildConnectionSourceSettings(configuration);
         settings.setDatabaseName(mappingContext.getDefaultDatabaseName());

Review Comment:
   Switching this from `new MongoConnectionSourceSettings()` to 
`buildConnectionSourceSettings(configuration)` makes the `setDatabaseName` on 
this line ineffective whenever the configuration carries a connection string.
   
   `AbstractMongoConnectionSourceSettings.getDatabase()` prefers the connection 
string's database:
   
   ```groovy
   if (connectionString != null) { return connectionString.database ?: 
databaseName }
   ```
   
   On this path `connectionString` was previously always null, so the mapping 
context's database always won. Now, with `grails.mongodb.url = 
mongodb://host/analytics` in the configuration, the constructor's 
`this.defaultDatabase = settings.getDatabase()` resolves to `analytics` and 
`registerEntity` maps every entity into it — even though the caller passed a 
`MongoMappingContext` built for a different database, and even though the 
client being used is the supplied one.
   
   The comment above states the intent: the connection details "are unused - 
this client is already connected". The database name is one of those details, 
and it is no longer unused. Clearing the bound connection string on this path, 
or setting the database where `getDatabase()` will actually read it, would keep 
the documented behaviour. Worth a test on the supplied-client constructor with 
a URL naming a different database than the mapping context.



##########
grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc:
##########
@@ -36,6 +49,8 @@ grails {
 }
 ----
 
+NOTE: These settings are read by name, so write them exactly as they are 
documented. Unlike Spring Boot's own configuration properties they are not 
relaxed-bound, and a kebab-case spelling such as `database-name` is not 
recognised — it is ignored, leaving the default in place.

Review Comment:
   This NOTE does not hold for applications running under Spring Boot, which is 
how these settings are normally read.
   
   `MongoDbGormAutoConfiguration` passes the context's 
`ConfigurableEnvironment` straight into `new MongoDatastore(mongo, environment, 
...)`. Boot attaches a `ConfigurationPropertySourcesPropertySource` to that 
environment, and that is what answers the `getProperty`/`containsProperty` 
calls the settings builder makes — so `grails.mongodb.database-name` in 
`application.yml` does resolve. A reader who follows this NOTE will believe a 
kebab-case setting is inert while it is in fact taking effect, which is the 
more dangerous direction for the error to run.
   
   The accompanying spec asserts the claim against 
`DatastoreUtils.createPropertyResolver(map)`, a bare `MapPropertySource` with 
no relaxed-binding source attached, so it cannot detect the difference: it pins 
the resolver's behaviour rather than the application's.
   
   Either scope the NOTE to the non-Boot path (a `PropertyResolver` constructed 
directly) and say that relaxed binding does apply under Boot, or drop it. If 
the intent really is that kebab-case never works, the test needs to go through 
a `ConfigurableEnvironment` with Boot's property source attached, since that is 
the configuration applications actually run.



##########
grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy:
##########
@@ -0,0 +1,170 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.mongo
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import com.mongodb.MongoException
+import com.mongodb.client.MongoClient
+import com.mongodb.client.MongoClients
+import grails.gorm.annotation.Entity
+import org.slf4j.LoggerFactory
+import spock.lang.Shared
+import spock.util.concurrent.PollingConditions
+
+import org.apache.grails.testing.mongo.AutoStartedMongoSpec
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.mongo.MongoDatastore
+import org.grails.datastore.mapping.mongo.config.MongoSettings
+
+/**
+ * Nothing waits on the background index build, so what it does when it goes 
wrong is only visible in the
+ * log. A build that fails has to say so loudly, because it can no longer fail 
startup; a build abandoned
+ * because the application is shutting down has to stay quiet, because nothing 
went wrong.
+ */
+class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec {
+
+    @Shared
+    MongoClient realClient
+
+    @Shared
+    Logger datastoreLogger
+
+    @Shared
+    ListAppender<ILoggingEvent> logged = new ListAppender<>()
+
+    @Shared
+    Level previousLevel
+
+    @Override
+    boolean shouldInitializeDatastore() {
+        false
+    }
+
+    void setupSpec() {
+        realClient = 
MongoClients.create(dbContainer.getReplicaSetUrl('backgroundFailureDb'))
+        datastoreLogger = 
LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger
+        previousLevel = datastoreLogger.level
+        datastoreLogger.level = Level.DEBUG
+        logged.start()
+        datastoreLogger.addAppender(logged)
+    }
+
+    void cleanupSpec() {
+        datastoreLogger?.detachAppender(logged)
+        datastoreLogger?.level = previousLevel
+        realClient?.close()
+    }
+
+    private MongoDatastore asyncDatastoreOn(MongoClient client, String 
database, Class... classes) {
+        new MongoDatastore(client, DatastoreUtils.createPropertyResolver([
+                'grails.mongodb.databaseName'              : database,
+                (MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true
+        ]), classes)
+    }
+
+    void "test a background build that fails reports the failure instead of 
losing it"() {
+        given:
+        def conditions = new PollingConditions(timeout: 30)
+        MongoClient broken = FailingMongoClient.wrap(realClient, 
'getCollection') {
+            throw new MongoException('the connection went away mid-build')
+        }
+
+        when: "the datastore is created, which does not wait for the build"
+        def datastore = asyncDatastoreOn(broken, 'backgroundFailureDb', 
FailedBackgroundThing)
+
+        then: "startup was not held up by, and did not fail because of, the 
broken build"
+        datastore.isBuildIndexesAsync()
+
+        and: "the failure is reported at error level, since nothing else would 
surface it"
+        conditions.eventually {
+            assert logged.list.any {

Review Comment:
   `ListAppender.list` is a plain `ArrayList`. `AppenderBase.doAppend` 
synchronizes the append, but nothing synchronizes iteration — and this iterates 
on the test thread precisely while `gorm-mongo-index-build-*` daemons are 
logging into the same appender, which is the point of the feature.
   
   So `logged.list.any { ... }` inside `conditions.eventually` can throw 
`ConcurrentModificationException` rather than passing or failing. Same shape in 
`backgroundBuildFailures()` here, in `eventsForThisDatabase()` in 
BuildIndexesFailureSummarySpec, BuildIndexesSummaryLogSpec and 
BuildIndexesUnreadableIndexListSpec, and in BuildIndexesPerConnectionSpec.
   
   Copying first is not enough on its own, since the copy iterates too. 
`doAppend` synchronizes on the appender, so `synchronized (logged) { new 
ArrayList<>(logged.list) }` gives a safe snapshot; a small `ListAppender` 
subclass backed by a `CopyOnWriteArrayList` would do as well. Worth applying 
across all of these — the daemons can outlive the datastore that started them, 
and these run with `maxParallelForks > 1`.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -574,17 +598,188 @@ public ConnectionSources<MongoClient, 
MongoConnectionSourceSettings> getConnecti
     }
 
     /**
-     * Builds the MongoDB index for this datastore
+     * Builds the MongoDB index for this datastore.
+     *
+     * <p>Each index is created by a command that the server answers only once 
the index has been built,
+     * so with the default settings this blocks whoever creates the datastore 
— in an application, the
+     * startup thread — for as long as MongoDB takes to build every declared 
index. Enabling
+     * {@code grails.mongodb.buildIndexesAsync} hands the work to a background 
thread and returns
+     * immediately instead.
      */
     public void buildIndex() {
-        for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
-            // Only create Mongo templates for entities that are mapped with 
Mongo
-            if (!entity.isExternal()) {
-                if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
+        if (!buildIndexes) {
+            LOG.info("Index creation is disabled by [{} = false]. The indexes 
declared by the domain classes " +
+                    "will not be created or reconciled; the indexes already 
present on the server are left untouched.",
+                    MongoSettings.SETTING_BUILD_INDEXES);
+            return;
+        }
+        if (indexBuildExecutor == null) {
+            buildDeclaredIndexes();
+            return;
+        }
+        LOG.info("Building the indexes declared by the domain classes on a 
background thread ([{} = true]). " +
+                "Startup does not wait for them, so a query issued before its 
index exists is served without it.",
+                MongoSettings.SETTING_BUILD_INDEXES_ASYNC);
+        indexBuildExecutor.execute(() -> {
+            try {
+                buildDeclaredIndexes();
+            }
+            catch (Throwable e) {
+                // Nothing is waiting on this thread, so an error that would 
have failed startup has to be
+                // reported here or it is lost entirely.
+                if (indexBuildExecutor.isShutdown() || 
Thread.currentThread().isInterrupted()) {
+                    // toString rather than the message: an interrupted driver 
call can arrive wrapped in
+                    // an exception that carries no message of its own.
+                    LOG.debug("The background index build was abandoned 
because the datastore is shutting down: {}",
+                            e.toString(), e);
+                }
+                else {
+                    LOG.error("The background index build failed: {}. The 
application is running without the " +
+                            "indexes that were not created.", e.getMessage(), 
e);
+                }
+            }
+        });
+    }
+
+    /**
+     * Creates and reconciles the indexes declared by every entity mapped to 
this datastore, and reports
+     * what that cost. MongoDB answers each {@code createIndex} only once the 
index exists, so the elapsed
+     * time is the time the caller — startup, or the background build thread — 
actually spent waiting.
+     */
+    private void buildDeclaredIndexes() {
+        long startedAt = System.nanoTime();
+        IndexBuildSummary summary = new IndexBuildSummary();
+        IndexBuildSummary previousSummary = indexBuildSummary.get();
+        indexBuildSummary.set(summary);
+        try {
+            for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
+                // Only create Mongo templates for entities that are mapped 
with Mongo
+                if (!entity.isExternal()) {
+                    if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
 
-                initializeIndices(entity);
+                    summary.entities++;
+                    initializeIndices(entity);
+                }
             }
         }
+        finally {
+            if (previousSummary == null) {
+                indexBuildSummary.remove();
+            }
+            else {
+                indexBuildSummary.set(previousSummary);
+            }
+        }
+        long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startedAt);

Review Comment:
   The summary block sits after the try/finally, so it is skipped whenever the 
loop exits by exception.
   
   `createOrUpdateIndex` catches only `MongoCommandException`. A 
`MongoSocketException`, a `MongoTimeoutException`, or the driver's 
`IllegalStateException` on a closed client, thrown while indexing entity 3 of 
30, propagates out of `initializeIndices` and out of the `try` above; the 
`finally` restores the ThreadLocal and everything from this line down is 
skipped. The remaining 27 entities are never indexed, and nothing reports how 
far the build got.
   
   That is the case the summary matters most for, and the guide leans on it — 
`queryIndexes.adoc` says of the async build that "this line is also the only 
signal that the build has finished". In async mode the operator is left with 
the single "The background index build failed" line from the executor and no 
idea what was applied before it.
   
   Moving the elapsed-time and summary logging into the `finally` (or wrapping 
the loop so the summary is always emitted, then rethrowing) would fix it. A 
test with a client that throws a non-`MongoCommandException` partway through 
the entities would pin it.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -574,17 +598,188 @@ public ConnectionSources<MongoClient, 
MongoConnectionSourceSettings> getConnecti
     }
 
     /**
-     * Builds the MongoDB index for this datastore
+     * Builds the MongoDB index for this datastore.
+     *
+     * <p>Each index is created by a command that the server answers only once 
the index has been built,
+     * so with the default settings this blocks whoever creates the datastore 
— in an application, the
+     * startup thread — for as long as MongoDB takes to build every declared 
index. Enabling
+     * {@code grails.mongodb.buildIndexesAsync} hands the work to a background 
thread and returns
+     * immediately instead.
      */
     public void buildIndex() {

Review Comment:
   `buildIndex()` is reached from `initialize(settings)`, which the constructor 
invokes on its last line as `this.gormEnhancer = initialize(settings)`. 
Synchronously that is a self-call on a not-quite-finished object — unpleasant, 
but ordered. With `buildIndexesAsync` it publishes `this` to another thread 
before the constructor returns: `gormEnhancer` is still null, and for a 
subclass neither the subclass constructor body nor its field initializers have 
run.
   
   `initializeIndices(PersistentEntity)` is documented in this PR as the 
extension point ("subclasses can customise index creation on either path"), so 
a subclass that overrides it and reads a field it sets in its own constructor 
now sees null or a default, depending on how quickly the worker starts. 
`BuildIndexesHookSpec.CustomIndexDatastore` exercises exactly this path and 
passes only because it holds no state. The per-connection children are in the 
same position — their `initialize()` override calls `super.buildIndex()`.
   
   Deferring the async submit to after construction — an 
`InitializingBean`/`afterPropertiesSet` step, or an explicit start hook — 
avoids it. At a minimum the hook's javadoc should say it may run on a 
background thread before the subclass is fully constructed.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -1241,6 +1500,14 @@ private boolean ownsClient() {
     @PreDestroy
     public void close() {
         MongoClient current = this.mongo;
+        shutDownIndexBuild();
+        // Over a snapshot: the connection sources listener can still add a 
child while this runs, and a
+        // ConcurrentModificationException here would escape before anything 
below had a chance to close.
+        for (MongoDatastore datastore : new 
ArrayList<>(datastoresByConnectionSource.values())) {

Review Comment:
   The copy does not provide the protection the comment describes. `new 
ArrayList<>(map.values())` calls `values().toArray()`, which iterates 
`datastoresByConnectionSource` exactly as the bare for-loop would, so a 
`newConnectionSource` callback putting into it concurrently throws 
`ConcurrentModificationException` out of the copy — and a concurrent `put` into 
a plain `LinkedHashMap` can corrupt the table outright, which copying cannot 
help with either.
   
   A child added after the snapshot is also never shut down, so its 
`gorm-mongo-index-build-<name>` thread keeps running against a client 
`connectionSources.close()` is about to close.
   
   Making `datastoresByConnectionSource` a `ConcurrentHashMap` (or 
synchronizing the put and the iteration on the same lock) would do what the 
comment claims; the copy and the comment can then both go.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -574,17 +598,188 @@ public ConnectionSources<MongoClient, 
MongoConnectionSourceSettings> getConnecti
     }
 
     /**
-     * Builds the MongoDB index for this datastore
+     * Builds the MongoDB index for this datastore.
+     *
+     * <p>Each index is created by a command that the server answers only once 
the index has been built,
+     * so with the default settings this blocks whoever creates the datastore 
— in an application, the
+     * startup thread — for as long as MongoDB takes to build every declared 
index. Enabling
+     * {@code grails.mongodb.buildIndexesAsync} hands the work to a background 
thread and returns
+     * immediately instead.
      */
     public void buildIndex() {
-        for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
-            // Only create Mongo templates for entities that are mapped with 
Mongo
-            if (!entity.isExternal()) {
-                if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
+        if (!buildIndexes) {
+            LOG.info("Index creation is disabled by [{} = false]. The indexes 
declared by the domain classes " +
+                    "will not be created or reconciled; the indexes already 
present on the server are left untouched.",
+                    MongoSettings.SETTING_BUILD_INDEXES);
+            return;
+        }
+        if (indexBuildExecutor == null) {
+            buildDeclaredIndexes();
+            return;
+        }
+        LOG.info("Building the indexes declared by the domain classes on a 
background thread ([{} = true]). " +
+                "Startup does not wait for them, so a query issued before its 
index exists is served without it.",
+                MongoSettings.SETTING_BUILD_INDEXES_ASYNC);
+        indexBuildExecutor.execute(() -> {

Review Comment:
   `close()` calls `shutDownIndexBuild()`, which calls 
`indexBuildExecutor.shutdownNow()`, and a shut-down executor stays shut down. 
Any later `buildIndex()` reaches this `execute` and throws 
`RejectedExecutionException` at the caller — after the INFO line just above has 
already announced that the build is under way on a background thread.
   
   `buildIndex()` is public, documented, and called directly by several specs 
in this PR (`TextIndexViaAttributesSpec`, `BuildIndexesDisabledSpec`, 
`BuildIndexesSummaryLogSpec`). The synchronous path in the same situation fails 
only if the client is actually closed, and fails with a driver error, so the 
two modes now diverge on a call that previously behaved the same either way.
   
   A guard on `indexBuildExecutor.isShutdown()` — logging and returning, or 
falling back to the synchronous path — plus a test that calls `buildIndex()` 
after `close()` would settle it.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -1269,6 +1536,36 @@ public void close() {
         }
     }
 
+    private void shutDownIndexBuild() {
+        if (indexBuildExecutor != null) {
+            // Interrupt every connection's build before closing its client. A 
build can run for minutes,
+            // so shutdown must not wait for it; the server carries on 
building what it was asked for.
+            indexBuildExecutor.shutdownNow();
+        }
+    }
+
+    /**
+     * Names the background index build thread after the connection it serves, 
so that a log line or a
+     * thread dump says which datastore is building indexes. The thread is a 
daemon: an index build in
+     * flight must not hold the JVM open, and abandoning the wait does not 
abandon the build — the server
+     * finishes an index it has been asked for whether or not a client is 
still listening.
+     */
+    private static final class IndexBuildThreadFactory implements 
ThreadFactory {

Review Comment:
   Minor: this module already has `api 'org.springframework:spring-context'`, 
so `org.springframework.scheduling.concurrent.CustomizableThreadFactory` is on 
the classpath. `new CustomizableThreadFactory("gorm-mongo-index-build-" + 
connectionName)` with `setDaemon(true)` produces the same thread and handles 
thread group and priority as well, which would let this class go.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -166,6 +171,19 @@ public class MongoDatastore extends AbstractDatastore 
implements MappingContext.
     protected final boolean stateless;
     protected final boolean codecEngine;
     protected final boolean transactionsEnabled;
+    protected final boolean buildIndexes;
+    protected final boolean buildIndexesAsync;
+
+    /**
+     * Runs the startup index build off the thread that creates the datastore 
when
+     * {@code grails.mongodb.buildIndexesAsync} is enabled; {@code null} 
otherwise. A single thread,
+     * so the indexes are still built one at a time per connection. The worker 
expires after one idle
+     * second, releasing the worker while allowing subsequent calls to {@link 
#buildIndex()}.
+     */
+    private final ExecutorService indexBuildExecutor;
+
+    /** The summary for the current build, scoped to its thread so the 
protected index hook is preserved. */
+    private final ThreadLocal<IndexBuildSummary> indexBuildSummary = new 
ThreadLocal<>();

Review Comment:
   Design note: the summary is threaded through two mechanisms at once. 
`buildDeclaredIndexes` sets this ThreadLocal, `initializeIndices(entity)` reads 
it back out, and hands it to `initializeIndices(entity, summary)`, which passes 
it on to `createOrUpdateIndex` and `ExistingIndexes`.
   
   The ThreadLocal exists only to preserve the one-argument protected hook's 
signature, and it brings two costs with it: the `previousSummary` save/restore 
in the `finally` guards a re-entrancy case that requires a subclass to call 
`buildIndex()` from inside its own `initializeIndices` override, and the 
`summary != null ? summary : new IndexBuildSummary()` fallback means a subclass 
that forgets `super.initializeIndices(entity)` silently counts nothing while 
still appearing to work.
   
   A small non-static `IndexBuild` holding the summary and the per-entity 
`ExistingIndexes`, passed explicitly, with the protected one-arg hook 
delegating to it, removes the ThreadLocal, the restore branch and the fallback.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java:
##########
@@ -574,17 +598,188 @@ public ConnectionSources<MongoClient, 
MongoConnectionSourceSettings> getConnecti
     }
 
     /**
-     * Builds the MongoDB index for this datastore
+     * Builds the MongoDB index for this datastore.
+     *
+     * <p>Each index is created by a command that the server answers only once 
the index has been built,
+     * so with the default settings this blocks whoever creates the datastore 
— in an application, the
+     * startup thread — for as long as MongoDB takes to build every declared 
index. Enabling
+     * {@code grails.mongodb.buildIndexesAsync} hands the work to a background 
thread and returns
+     * immediately instead.
      */
     public void buildIndex() {
-        for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
-            // Only create Mongo templates for entities that are mapped with 
Mongo
-            if (!entity.isExternal()) {
-                if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
+        if (!buildIndexes) {
+            LOG.info("Index creation is disabled by [{} = false]. The indexes 
declared by the domain classes " +
+                    "will not be created or reconciled; the indexes already 
present on the server are left untouched.",
+                    MongoSettings.SETTING_BUILD_INDEXES);
+            return;
+        }
+        if (indexBuildExecutor == null) {
+            buildDeclaredIndexes();
+            return;
+        }
+        LOG.info("Building the indexes declared by the domain classes on a 
background thread ([{} = true]). " +
+                "Startup does not wait for them, so a query issued before its 
index exists is served without it.",
+                MongoSettings.SETTING_BUILD_INDEXES_ASYNC);
+        indexBuildExecutor.execute(() -> {
+            try {
+                buildDeclaredIndexes();
+            }
+            catch (Throwable e) {
+                // Nothing is waiting on this thread, so an error that would 
have failed startup has to be
+                // reported here or it is lost entirely.
+                if (indexBuildExecutor.isShutdown() || 
Thread.currentThread().isInterrupted()) {
+                    // toString rather than the message: an interrupted driver 
call can arrive wrapped in
+                    // an exception that carries no message of its own.
+                    LOG.debug("The background index build was abandoned 
because the datastore is shutting down: {}",
+                            e.toString(), e);
+                }
+                else {
+                    LOG.error("The background index build failed: {}. The 
application is running without the " +
+                            "indexes that were not created.", e.getMessage(), 
e);
+                }
+            }
+        });
+    }
+
+    /**
+     * Creates and reconciles the indexes declared by every entity mapped to 
this datastore, and reports
+     * what that cost. MongoDB answers each {@code createIndex} only once the 
index exists, so the elapsed
+     * time is the time the caller — startup, or the background build thread — 
actually spent waiting.
+     */
+    private void buildDeclaredIndexes() {
+        long startedAt = System.nanoTime();
+        IndexBuildSummary summary = new IndexBuildSummary();
+        IndexBuildSummary previousSummary = indexBuildSummary.get();
+        indexBuildSummary.set(summary);
+        try {
+            for (PersistentEntity entity : 
this.mappingContext.getPersistentEntities()) {
+                // Only create Mongo templates for entities that are mapped 
with Mongo
+                if (!entity.isExternal()) {
+                    if (entity.isMultiTenant() && multiTenancyMode == 
MultiTenancySettings.MultiTenancyMode.SCHEMA) continue;
 
-                initializeIndices(entity);
+                    summary.entities++;
+                    initializeIndices(entity);
+                }
             }
         }
+        finally {
+            if (previousSummary == null) {
+                indexBuildSummary.remove();
+            }
+            else {
+                indexBuildSummary.set(previousSummary);
+            }
+        }
+        long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startedAt);
+        if (summary.applied() == 0 && summary.failures == 0) {
+            LOG.debug("No indexes are declared by the {} domain class(es) 
mapped to database [{}]",
+                    summary.entities, defaultDatabase);
+            return;
+        }
+        String outcome = summary.classified ?
+                summary.created + " created, " + summary.alreadyPresent + " 
already present" :
+                summary.applied() + " index declaration(s) applied";
+        if (summary.failures == 0) {
+            LOG.info("Index build for database [{}] finished in {}ms: {}, from 
{} domain class(es)",
+                    defaultDatabase, elapsedMillis, outcome, summary.entities);
+        }
+        else {
+            LOG.warn("Index build for database [{}] finished in {}ms: {}, {} 
failed, from {} domain class(es). " +
+                    "The failures are reported above.",
+                    defaultDatabase, elapsedMillis, outcome, summary.failures, 
summary.entities);
+        }
+    }
+
+    /**
+     * The indexes a collection already had when the build reached it, listed 
once on first use and then
+     * reused. {@code createIndex} is idempotent and answers the same way 
whether or not it had to build
+     * anything — the driver hands back only the index name, discarding the 
{@code numIndexesBefore} /
+     * {@code numIndexesAfter} the server reports — so what was there 
beforehand is what distinguishes an
+     * index this build created from one it merely confirmed.
+     *
+     * <p>Listed lazily so that an entity declaring no indexes costs no round 
trip, and reused by the
+     * conflict path, which would otherwise list them again. Successful 
changes are recorded so later
+     * declarations on the same keys see the current name and TTL, and are not 
counted as new indexes.
+     */
+    private static final class ExistingIndexes {
+
+        private final com.mongodb.client.MongoCollection<Document> collection;
+
+        private final IndexBuildSummary summary;
+
+        private List<Document> indexes;
+
+        private boolean listed;
+
+        private ExistingIndexes(com.mongodb.client.MongoCollection<Document> 
collection, IndexBuildSummary summary) {
+            this.collection = collection;
+            this.summary = summary;
+        }
+
+        /**
+         * @return the known current indexes, or {@code null} if they could 
not be listed
+         */
+        private List<Document> get() {
+            if (!listed) {
+                listed = true;
+                try {
+                    indexes = collection.listIndexes().into(new ArrayList<>());

Review Comment:
   This adds a `listIndexes` round trip per indexed entity on every startup, 
and its only product is the wording of one log line.
   
   `get()` is triggered by the first `contains(keys)` in `createOrUpdateIndex`, 
so every entity declaring at least one index pays for it; previously 
`listIndexes` ran only on the IndexOptionsConflict path. An application with 
200 indexed domain classes issues 200 commands it did not issue before. At 
30-50ms RTT against a remote deployment that is several seconds added to 
startup — the cost the `buildIndexesAsync` half of this PR exists to remove, 
reintroduced for everyone, including those who leave async off.
   
   The server already reports `numIndexesBefore`/`numIndexesAfter` on 
`createIndexes`; the driver's `createIndex` helper discards them, but 
`database.runCommand(new Document("createIndexes", ...))` would give the same 
created/already-present split with no extra round trip. Failing that, gate the 
snapshot on `LOG.isInfoEnabled()`, or make the classification opt-in.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to