From b2691d485d45b8b64403b013cd044d9ca2b79a41 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 23 Aug 2026 12:37:40 -0700 Subject: [PATCH 01/28] Add grails.mongodb.buildIndexes to skip MongoDB index creation on startup GORM created and reconciled every index declared in a domain class mapping block each time the datastore started, with no way to switch it off. That is unwanted when deploying against live data whose indexes are managed separately: the deployment builds indexes against production collections and reconciles the index set the running application depends on. Setting grails.mongodb.buildIndexes = false leaves the server's indexes exactly as they are - no createIndex or collMod command is issued for any domain class, including for domain classes registered after startup. Persistence and querying are unaffected and continue to use whichever indexes already exist. The setting is resolved per connection like every other connection setting, so index building can be switched off globally and left on for an individual connection. --- .../mapping/mongo/MongoDatastore.java | 25 ++++ .../mapping/mongo/config/MongoSettings.groovy | 12 ++ ...stractMongoConnectionSourceSettings.groovy | 11 ++ .../mongo/BuildIndexesDisabledSpec.groovy | 117 ++++++++++++++++++ .../BuildIndexesEnabledByDefaultSpec.groovy | 77 ++++++++++++ .../BuildIndexesPerConnectionSpec.groovy | 101 +++++++++++++++ .../MongoConnectionSourceSettingsSpec.groovy | 15 +++ .../gettingStarted/advancedConfig.adoc | 6 + .../asciidoc/introduction/releaseNotes.adoc | 1 + .../docs/asciidoc/querying/queryIndexes.adoc | 20 +++ 10 files changed, 385 insertions(+) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesEnabledByDefaultSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 1be229be4e9..83d07ca3ac6 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -162,6 +162,7 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. protected final boolean stateless; protected final boolean codecEngine; protected final boolean transactionsEnabled; + protected final boolean buildIndexes; private volatile Boolean transactionsSupported; private volatile boolean warnedTransactionsUnsupported = false; protected CodecRegistry codecRegistry; @@ -205,6 +206,7 @@ public MongoDatastore(final ConnectionSources getConnecti * Builds the MongoDB index for this datastore */ public void buildIndex() { + 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; + } for (PersistentEntity entity : this.mappingContext.getPersistentEntities()) { // Only create Mongo templates for entities that are mapped with Mongo if (!entity.isExternal()) { @@ -754,6 +762,18 @@ public boolean isTransactionsEnabled() { } } + /** + * Whether GORM creates and reconciles the indexes declared in the domain class mapping blocks when + * the datastore starts. Disabled with {@code grails.mongodb.buildIndexes = false}, which leaves the + * indexes on the server exactly as they are. + * + * @return {@code true} if declared indexes are created on startup + * @since 8.0 + */ + public boolean isBuildIndexes() { + return buildIndexes; + } + public String getDatabaseName(PersistentEntity entity) { if (entity.isMultiTenant() && multiTenancyMode == MultiTenancySettings.MultiTenancyMode.SCHEMA) { return Tenants.currentId(getClass()).toString(); @@ -921,6 +941,11 @@ protected void registerEventListeners(ConfigurableApplicationEventPublisher even * @param entity The entity */ protected void initializeIndices(final PersistentEntity entity) { + if (!buildIndexes) { + LOG.debug("Index creation is disabled by [{} = false]. Skipping the indexes declared by entity [{}].", + MongoSettings.SETTING_BUILD_INDEXES, entity.getName()); + return; + } final com.mongodb.client.MongoCollection collection = getCollection(entity); final ClassMapping classMapping = entity.getMapping(); if (classMapping != null) { diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy index 4a2bbf15fbd..5737e516c55 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy @@ -95,6 +95,18 @@ interface MongoSettings extends Settings { String SETTING_ENGINE = 'grails.mongodb.engine' + /** + * Whether GORM creates and reconciles the indexes declared in domain class mapping blocks + * when the datastore starts. Defaults to {@code true}. + * + *

Set to {@code false} to leave the indexes on the server exactly as they are, which is + * useful when deploying against live data where index changes are applied separately by a + * DBA or a migration step rather than by the application on startup. + * + * @since 8.0 + */ + String SETTING_BUILD_INDEXES = 'grails.mongodb.buildIndexes' + /** * Global default storage type for {@code String id} fields when no per-domain * {@code id storedAs: ...} mapping is declared. Accepted values are the names (or hex diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy index 09f6045427e..5f9dfd30f17 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy @@ -97,6 +97,17 @@ abstract class AbstractMongoConnectionSourceSettings extends ConnectionSourceSet */ boolean decimalType = true + /** + * Whether GORM creates and reconciles the indexes declared in domain class mapping blocks + * when the datastore starts. When {@code false} the indexes on the server are left exactly + * as they are and no {@code createIndex} or {@code collMod} command is issued; queries are + * unaffected and continue to use whatever indexes already exist. Bound from + * {@code grails.mongodb.buildIndexes}. + * + * @since 8.0 + */ + boolean buildIndexes = true + /** * Nested settings for domains with {@code String id}. Holds the global default * {@code defaultStoredAs} switch plus any future string-id configuration. diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy new file mode 100644 index 00000000000..188a4affa74 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy @@ -0,0 +1,117 @@ +/* + * 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 grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.mongo.MongoDatastore +import org.grails.datastore.mapping.mongo.config.MongoSettings + +/** + * Verifies that with {@code grails.mongodb.buildIndexes = false} GORM issues no index commands: the + * indexes declared in the mapping block are neither created nor reconciled, so whatever indexes are + * already on the server are left untouched. Persistence and querying are unaffected. + * + * @see BuildIndexesEnabledByDefaultSpec for the default behavior with the same mapping declarations + */ +class BuildIndexesDisabledSpec extends AutoStartedMongoSpec { + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + Map config = [ + 'grails.mongodb.url' : dbContainer.getReplicaSetUrl('myDb'), + (MongoSettings.SETTING_BUILD_INDEXES) : false + ] + datastore = new MongoDatastore(config, SkippedIndexThing) + } + + private static List declaredIndexKeys() { + SkippedIndexThing.withNewSession { + SkippedIndexThing.collection.listIndexes()*.key + } + } + + void "test the datastore reports index building disabled"() { + expect: + !datastore.isBuildIndexes() + } + + void "test no declared index is created on startup"() { + when: "a document is written so the collection certainly exists" + SkippedIndexThing.withNewSession { + new SkippedIndexThing(name: 'Fred', age: 42).save(flush: true) + } + + then: "only the implicit _id index is present - neither the property index nor the compound index was created" + declaredIndexKeys() == [[_id: 1]] + } + + void "test an explicit index build is a no-op while disabled"() { + when: "index building is requested directly" + datastore.buildIndex() + + then: "no index was created" + declaredIndexKeys() == [[_id: 1]] + } + + void "test registering an entity after startup does not create its indexes"() { + given: + def entity = datastore.mappingContext.getPersistentEntity(SkippedIndexThing.name) + + when: "the entity is re-registered, the path that indexes a domain class added after startup" + datastore.persistentEntityAdded(entity) + + then: "no index was created" + declaredIndexKeys() == [[_id: 1]] + } + + void "test reads and writes are unaffected when index building is disabled"() { + when: + SkippedIndexThing.withNewSession { + new SkippedIndexThing(name: 'Wilma', age: 41).save(flush: true) + } + + then: "the unindexed property is still queryable" + SkippedIndexThing.withNewSession { SkippedIndexThing.findByName('Wilma') }?.age == 41 + } +} + +@Entity +class SkippedIndexThing { + String name + Integer age + + static mapping = { + version false + collection 'skippedIndexThing' + name index: true + compoundIndex name: 1, age: -1 + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesEnabledByDefaultSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesEnabledByDefaultSpec.groovy new file mode 100644 index 00000000000..a2ac56c338d --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesEnabledByDefaultSpec.groovy @@ -0,0 +1,77 @@ +/* + * 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 grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * The control for {@link BuildIndexesDisabledSpec}: with {@code grails.mongodb.buildIndexes} left at its + * default the very same mapping declarations do produce indexes on startup, so the absence of indexes in + * that specification is attributable to the setting and not to the mapping. + */ +class BuildIndexesEnabledByDefaultSpec extends AutoStartedMongoSpec { + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + // No grails.mongodb.buildIndexes => default true + datastore = new MongoDatastore(['grails.mongodb.url': dbContainer.getReplicaSetUrl('myDb')] as Map, BuiltIndexThing) + } + + void "test index building is enabled by default"() { + expect: + datastore.isBuildIndexes() + } + + void "test the declared indexes are created on startup"() { + when: + List indexKeys = BuiltIndexThing.withNewSession { + BuiltIndexThing.collection.listIndexes()*.key + } + + then: "the property index and the compound index are both present alongside the implicit _id index" + [name: 1] in indexKeys + [name: 1, age: -1] in indexKeys + } +} + +@Entity +class BuiltIndexThing { + String name + Integer age + + static mapping = { + version false + collection 'builtIndexThing' + name index: true + compoundIndex name: 1, age: -1 + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy new file mode 100644 index 00000000000..68845a635f1 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -0,0 +1,101 @@ +/* + * 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.connections + +import grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.connections.ConnectionSource +import org.grails.datastore.mapping.mongo.MongoDatastore +import org.grails.datastore.mapping.mongo.config.MongoSettings + +/** + * Verifies that {@code buildIndexes} is resolved per connection: a connection inherits the top level + * setting unless it declares its own, so index building can be switched off globally and left on for an + * individual connection (or the other way round). + */ +class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + Map config = [ + 'grails.mongodb.url' : "mongodb://${mongoHost}:${mongoPort}/skippedDb" as String, + (MongoSettings.SETTING_BUILD_INDEXES): false, + 'grails.mongodb.connections' : [ + 'indexed': [ + 'url' : "mongodb://${mongoHost}:${mongoPort}/indexedDb" as String, + 'buildIndexes': true + ], + 'inherits': [ + 'url': "mongodb://${mongoHost}:${mongoPort}/inheritsDb" as String + ] + ] + ] + datastore = new MongoDatastore(config, PerConnectionThing) + } + + void "test a connection can override the global setting"() { + expect: "the default connection is disabled by the top level setting" + !datastore.isBuildIndexes() + + and: "the connection that declares its own setting builds indexes" + datastore.getDatastoreForConnection('indexed').isBuildIndexes() + + and: "a connection that declares nothing inherits the top level setting" + !datastore.getDatastoreForConnection('inherits').isBuildIndexes() + } + + void "test only the connection with index building enabled has the declared index"() { + when: "a document is written through each connection so every collection exists" + PerConnectionThing.withNewSession { + new PerConnectionThing(name: 'Fred').save(flush: true) + } + PerConnectionThing.indexed.withNewSession { + new PerConnectionThing(name: 'Fred').save(flush: true) + } + + then: "the disabled connection has only the implicit _id index" + PerConnectionThing.collection.listIndexes()*.key == [[_id: 1]] + + and: "the enabled connection has the index declared in the mapping" + [name: 1] in PerConnectionThing.indexed.collection.listIndexes()*.key + } +} + +@Entity +class PerConnectionThing { + String name + + static mapping = { + version false + collection 'perConnectionThing' + connection ConnectionSource.ALL + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy index c28ccafd37e..68ec7f56691 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy @@ -49,6 +49,21 @@ class MongoConnectionSourceSettingsSpec extends Specification { settings.options.build().readPreference == ReadPreference.secondary() } + void "test index building is enabled unless it is switched off in configuration"() { + when: "no buildIndexes setting is supplied" + def settings = new MongoConnectionSourceSettingsBuilder(DatastoreUtils.createPropertyResolver([:])).build() + + then: "declared indexes are built on startup" + settings.buildIndexes + + when: "the setting is switched off" + def resolver = DatastoreUtils.createPropertyResolver([(MongoSettings.SETTING_BUILD_INDEXES): 'false']) + settings = new MongoConnectionSourceSettingsBuilder(resolver).build() + + then: "index building is disabled" + !settings.buildIndexes + } + void "test mongo client settings builder with URL"() { when:"using a property resolver" Map myMap = ['grails.mongodb.url': 'mongodb://foo:bar@mycompany/mydb?maxPoolSize=5'] diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc index e3213027cb2..6ea960b42f1 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc @@ -87,6 +87,7 @@ grails { username = ".." // the username to connect with password = ".." // the password to connect with stateless = false // whether to use stateless sessions by default + buildIndexes = true // whether to create the indexes declared in mapping blocks on startup // Alternatively, using 'url' // url = "mongodb://localhost/mydb" @@ -125,6 +126,11 @@ The `*` method is used to indicate that the setting applies to all properties. You can also set a global default for the storage type of `String id` fields via `grails.mongodb.stringIds.defaultStoredAs` (values `string` or `objectid`). See <> for details. +==== Index Creation on Startup + + +GORM creates and reconciles the indexes declared in your mapping blocks each time the datastore starts. Set `grails.mongodb.buildIndexes = false` to leave the indexes on the server untouched instead — see <> for details and when to use it. + ==== Multi-Document Transactions diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index 243bb756785..c466196ad91 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -25,6 +25,7 @@ Below are the details of the changes across releases: * TTL indexes via `indexAttributes: [expireAfterSeconds: N]` in the mapping DSL * Text and other special indexes via `indexAttributes: [type: 'text']` * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` +* Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false` ==== 7.1 diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index 2c4bc61a020..620c8656374 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -142,6 +142,26 @@ WARNING: Dropping and recreating an index rebuilds it from scratch, during which A change to a TTL index's `expireAfterSeconds` is handled automatically and does not require `recreateOnConflict`, because GORM updates the expiry in place rather than rebuilding the index. +==== Disabling Index Creation on Startup + + +By default GORM creates and reconciles every index declared in a mapping block when the datastore starts. Set `buildIndexes` to `false` to switch that off: + +[source,groovy] +---- +grails { + mongodb { + buildIndexes = false + } +} +---- + +With this setting no `createIndex` or `collMod` command is issued for any domain class, and the indexes already present on the server are left exactly as they are. Queries are unaffected and continue to use whichever indexes exist. This is useful when deploying against live data whose indexes are managed separately — by a DBA or a migration step — so that a deployment does not build an index against a large production collection, and so an application running against an older index set does not have those indexes reconciled underneath it. + +The setting applies to every connection when declared at the top level, and can be set per connection inside a `connections` block. It also suppresses index creation for domain classes registered after startup. + +NOTE: Because nothing is created, a collection that has never been initialised with `buildIndexes` enabled will have no declared indexes at all. Turn the setting off only where the indexes are already in place or are applied by other means. The setting governs only the indexes GORM derives from the mapping blocks; an explicit `createIndex` call made by application code against a collection is unaffected. + ==== Indexing using the 'index' method From 2c25dfd0b3b0e0fee219cf66401a402bc9e7dd85 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 23 Aug 2026 13:27:36 -0700 Subject: [PATCH 02/28] Apply the configured settings when GORM is given an existing MongoClient The constructors that take a MongoClient (or a MongoClientSettings.Builder) built the default connection source from a bare MongoConnectionSourceSettings, so every setting under grails.mongodb that describes how the datastore behaves - stateless, transactional, engine, flush mode, decimalType, buildIndexes - was silently left at its default. Only databaseName survived, because it is set explicitly from the mapping context. That path is not obscure: Spring Boot's MongoDB auto-configuration contributes a MongoClient bean, and MongoDbGormAutoConfiguration hands that client to GORM whenever it is present, so a Spring Boot application configuring GORM through grails.mongodb was being ignored. The settings are now bound from the configuration on this path too. The connection details in them go unused, as the client is supplied already connected. --- .../mapping/mongo/MongoDatastore.java | 6 +- .../SuppliedMongoClientSettingsSpec.groovy | 94 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 83d07ca3ac6..74d247955a7 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -1298,7 +1298,11 @@ public void close() { * @return The {@link ConnectionSources} */ protected static ConnectionSources 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 (stateless, transactional, buildIndexes, + // engine, flush mode) still come from grails.mongodb, exactly as they do when GORM creates the + // client itself. The connection details in them are unused - this client is already connected. + MongoConnectionSourceSettings settings = new MongoConnectionSourceSettingsBuilder(configuration).build(); settings.setDatabaseName(mappingContext.getDefaultDatabaseName()); ConnectionSource defaultConnectionSource = new DefaultConnectionSource<>(ConnectionSource.DEFAULT, mongoClient, settings, closeable); return new InMemoryConnectionSources<>(defaultConnectionSource, new MongoConnectionSourceFactory(), configuration); diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy new file mode 100644 index 00000000000..74ec48eaa48 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy @@ -0,0 +1,94 @@ +/* + * 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.connections + +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +import spock.lang.Shared + +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 + +/** + * An application that hands GORM an existing {@code MongoClient} - which is what happens whenever a + * {@code MongoClient} bean is already present, as with Spring Boot's MongoDB auto-configuration - must + * still have its {@code grails.mongodb} settings applied. Only the connection details are taken from the + * supplied client; everything describing how the datastore behaves still comes from the configuration. + */ +class SuppliedMongoClientSettingsSpec extends AutoStartedMongoSpec { + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient mongoClient + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + mongoClient = MongoClients.create(dbContainer.getReplicaSetUrl('suppliedClientDb')) + Map config = [ + 'grails.mongodb.databaseName' : 'suppliedClientDb', + (MongoSettings.SETTING_BUILD_INDEXES): false, + 'grails.mongodb.transactional' : true + ] + datastore = new MongoDatastore(mongoClient, DatastoreUtils.createPropertyResolver(config), SuppliedClientThing) + } + + void cleanupSpec() { + mongoClient?.close() + } + + void "test the configured settings are applied to a datastore built on a supplied client"() { + expect: "the index setting is taken from the configuration rather than left at its default" + !datastore.isBuildIndexes() + + and: "so is any other datastore setting" + datastore.isTransactionsEnabled() + } + + void "test the configured settings take effect and not merely report"() { + when: "a document is written so the collection certainly exists" + SuppliedClientThing.withNewSession { + new SuppliedClientThing(name: 'Fred').save(flush: true) + } + + then: "the index declared in the mapping was not created, as configured" + SuppliedClientThing.collection.listIndexes()*.key == [[_id: 1]] + } +} + +@Entity +class SuppliedClientThing { + String name + + static mapping = { + version false + collection 'suppliedClientThing' + name index: true + } +} From 568013535e86d231a9dc45110ba035bd7df0c5da Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 23 Aug 2026 13:28:00 -0700 Subject: [PATCH 03/28] Add grails.mongodb.buildIndexesAsync to build indexes off the startup thread MongoDB answers a createIndex command only once the index has been built, and the datastore builds every declared index from its constructor, so an application waits at startup for all of them in turn. On an empty collection that is instant; on a large existing collection it is minutes of a deployment spent waiting. Setting grails.mongodb.buildIndexesAsync = true hands the startup build to a single daemon thread per connection, named gorm-mongo-index-build-, and returns immediately. Indexes are still built one at a time rather than all at once against the server. Two consequences are documented rather than hidden: a query issued before its index exists is served without it (a unique index likewise constrains nothing until the build completes), and a build failure can no longer fail startup, so it is logged at error level instead. The setting covers the startup build only - a domain class registered later is still indexed on the registering thread, which keeps the tenant context that path can depend on. --- .../mapping/mongo/MongoDatastore.java | 91 ++++++++- .../mapping/mongo/config/MongoSettings.groovy | 11 ++ ...stractMongoConnectionSourceSettings.groovy | 11 ++ .../gorm/mongo/BuildIndexesAsyncSpec.groovy | 178 ++++++++++++++++++ .../MongoConnectionSourceSettingsSpec.groovy | 15 ++ .../gettingStarted/advancedConfig.adoc | 3 +- .../asciidoc/introduction/releaseNotes.adoc | 3 +- .../docs/asciidoc/querying/queryIndexes.adoc | 27 +++ 8 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 74d247955a7..903b7a6ab9f 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -25,6 +25,9 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import groovy.lang.Closure; @@ -163,6 +166,14 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. 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 rather than all at once against the server. + */ + private final ExecutorService indexBuildExecutor; private volatile Boolean transactionsSupported; private volatile boolean warnedTransactionsUnsupported = false; protected CodecRegistry codecRegistry; @@ -207,6 +218,10 @@ public MongoDatastore(final ConnectionSources getConnecti } /** - * Builds the MongoDB index for this datastore + * Builds the MongoDB index for this datastore. + * + *

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() { if (!buildIndexes) { @@ -576,6 +597,36 @@ public void buildIndex() { 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()) { + LOG.debug("The background index build was abandoned because the datastore is shutting down: {}", + e.getMessage(), 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. + */ + private void buildDeclaredIndexes() { for (PersistentEntity entity : this.mappingContext.getPersistentEntities()) { // Only create Mongo templates for entities that are mapped with Mongo if (!entity.isExternal()) { @@ -774,6 +825,17 @@ public boolean isBuildIndexes() { return buildIndexes; } + /** + * Whether the startup index build runs on a background thread instead of blocking the thread that + * creates the datastore. Enabled with {@code grails.mongodb.buildIndexesAsync = true}. + * + * @return {@code true} if declared indexes are built asynchronously + * @since 8.0 + */ + public boolean isBuildIndexesAsync() { + return buildIndexesAsync; + } + public String getDatabaseName(PersistentEntity entity) { if (entity.isMultiTenant() && multiTenancyMode == MultiTenancySettings.MultiTenancyMode.SCHEMA) { return Tenants.currentId(getClass()).toString(); @@ -1257,6 +1319,11 @@ private boolean ownsClient() { @PreDestroy public void close() { MongoClient current = this.mongo; + if (indexBuildExecutor != null) { + // Interrupt rather than wait: an index build can run for minutes and shutdown must not wait + // for it. The server carries on building what it was asked for. + indexBuildExecutor.shutdownNow(); + } try { super.destroy(); } catch (Exception e) { @@ -1285,6 +1352,28 @@ public void close() { } } + /** + * 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 { + + private final String connectionName; + + private IndexBuildThreadFactory(String connectionName) { + this.connectionName = connectionName; + } + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "gorm-mongo-index-build-" + connectionName); + thread.setDaemon(true); + return thread; + } + } + /** * Creates the connection sources for a {@link MongoClient}. * diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy index 5737e516c55..3da71ac088f 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy @@ -107,6 +107,17 @@ interface MongoSettings extends Settings { */ String SETTING_BUILD_INDEXES = 'grails.mongodb.buildIndexes' + /** + * Whether the startup index build runs on a background thread rather than blocking the thread + * that creates the datastore. Defaults to {@code false}, which is the historical behavior: + * startup waits for MongoDB to finish building every declared index. + * + *

Has no effect when {@link #SETTING_BUILD_INDEXES} is {@code false}. + * + * @since 8.0 + */ + String SETTING_BUILD_INDEXES_ASYNC = 'grails.mongodb.buildIndexesAsync' + /** * Global default storage type for {@code String id} fields when no per-domain * {@code id storedAs: ...} mapping is declared. Accepted values are the names (or hex diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy index 5f9dfd30f17..ab6957aeaa6 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy @@ -108,6 +108,17 @@ abstract class AbstractMongoConnectionSourceSettings extends ConnectionSourceSet */ boolean buildIndexes = true + /** + * Whether the startup index build runs on a background thread instead of blocking the thread that + * creates the datastore. MongoDB answers a {@code createIndex} command only once the index has been + * built, so with the default {@code false} an application waits at startup for every declared index. + * Ignored when {@link #buildIndexes} is {@code false}. Bound from + * {@code grails.mongodb.buildIndexesAsync}. + * + * @since 8.0 + */ + boolean buildIndexesAsync = false + /** * Nested settings for domains with {@code String id}. Holds the global default * {@code defaultStoredAs} switch plus any future string-id configuration. diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy new file mode 100644 index 00000000000..32f83f75bdf --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy @@ -0,0 +1,178 @@ +/* + * 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.ConcurrentLinkedQueue + +import com.mongodb.ConnectionString +import com.mongodb.MongoClientSettings +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import com.mongodb.event.CommandListener +import com.mongodb.event.CommandStartedEvent +import grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +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 + +/** + * MongoDB answers a {@code createIndexes} command only once the index has been built, so by default + * whoever creates the datastore - in an application, the startup thread - waits for every declared index. + * This specification pins both halves of that: the default build runs on the calling thread, and with + * {@code grails.mongodb.buildIndexesAsync = true} it runs on a background thread instead. + * + *

Which thread issued the command is observed through a driver {@link CommandListener}, which the + * synchronous driver invokes on the thread making the call. + */ +class BuildIndexesAsyncSpec extends AutoStartedMongoSpec { + + @Shared + @AutoCleanup + MongoDatastore blockingDatastore + + @Shared + @AutoCleanup + MongoDatastore asyncDatastore + + @Shared + MongoClient blockingClient + + @Shared + MongoClient asyncClient + + @Shared + CreateIndexThreadRecorder blockingRecorder = new CreateIndexThreadRecorder() + + @Shared + CreateIndexThreadRecorder asyncRecorder = new CreateIndexThreadRecorder() + + @Shared + String creatingThread + + @Override + boolean shouldInitializeDatastore() { + false + } + + private MongoClient clientFor(String database, CommandListener listener) { + MongoClients.create(MongoClientSettings.builder() + .applyConnectionString(new ConnectionString(dbContainer.getReplicaSetUrl(database))) + .addCommandListener(listener) + .build()) + } + + void setupSpec() { + creatingThread = Thread.currentThread().name + + blockingClient = clientFor('blockingIndexDb', blockingRecorder) + blockingDatastore = new MongoDatastore(blockingClient, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': 'blockingIndexDb']), + BlockingIndexThing) + + asyncClient = clientFor('asyncIndexDb', asyncRecorder) + asyncDatastore = new MongoDatastore(asyncClient, + DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.databaseName' : 'asyncIndexDb', + (MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true + ]), + AsyncIndexThing) + } + + void cleanupSpec() { + blockingClient?.close() + asyncClient?.close() + } + + void "test the index build blocks the thread creating the datastore by default"() { + expect: "the setting is off" + !blockingDatastore.isBuildIndexesAsync() + + and: "the index already exists by the time the constructor has returned" + [name: 1] in BlockingIndexThing.collection.listIndexes()*.key + + and: "it was built by the thread that created the datastore, which therefore waited for it" + blockingRecorder.threads == [creatingThread] + } + + void "test the index build runs on a background thread when enabled"() { + given: + def conditions = new PollingConditions(timeout: 30) + + expect: "the setting is on" + asyncDatastore.isBuildIndexesAsync() + + when: "the background build has had a chance to run" + conditions.eventually { + assert [name: 1] in AsyncIndexThing.collection.listIndexes()*.key + } + + then: "the command was issued by the datastore's own index build thread" + asyncRecorder.threads.size() == 1 + asyncRecorder.threads.first().startsWith('gorm-mongo-index-build') + + and: "not by the thread that created the datastore, which did not wait for it" + asyncRecorder.threads.first() != creatingThread + } +} + +/** + * Records the thread each {@code createIndexes} command was issued from. + */ +class CreateIndexThreadRecorder implements CommandListener { + + private final Queue recorded = new ConcurrentLinkedQueue<>() + + @Override + void commandStarted(CommandStartedEvent event) { + if (event.commandName == 'createIndexes') { + recorded.add(Thread.currentThread().name) + } + } + + List getThreads() { + recorded.toList().unique() + } +} + +@Entity +class BlockingIndexThing { + String name + + static mapping = { + version false + collection 'blockingIndexThing' + name index: true + } +} + +@Entity +class AsyncIndexThing { + String name + + static mapping = { + version false + collection 'asyncIndexThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy index 68ec7f56691..0bfd431e1a4 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy @@ -64,6 +64,21 @@ class MongoConnectionSourceSettingsSpec extends Specification { !settings.buildIndexes } + void "test the index build is synchronous unless it is switched to asynchronous in configuration"() { + when: "no buildIndexesAsync setting is supplied" + def settings = new MongoConnectionSourceSettingsBuilder(DatastoreUtils.createPropertyResolver([:])).build() + + then: "the index build blocks the thread creating the datastore" + !settings.buildIndexesAsync + + when: "the setting is switched on" + def resolver = DatastoreUtils.createPropertyResolver([(MongoSettings.SETTING_BUILD_INDEXES_ASYNC): 'true']) + settings = new MongoConnectionSourceSettingsBuilder(resolver).build() + + then: "the index build is asynchronous" + settings.buildIndexesAsync + } + void "test mongo client settings builder with URL"() { when:"using a property resolver" Map myMap = ['grails.mongodb.url': 'mongodb://foo:bar@mycompany/mydb?maxPoolSize=5'] diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc index 6ea960b42f1..0628f5d581d 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc @@ -88,6 +88,7 @@ grails { password = ".." // the password to connect with stateless = false // whether to use stateless sessions by default buildIndexes = true // whether to create the indexes declared in mapping blocks on startup + buildIndexesAsync = false // whether that index build runs on a background thread instead of blocking startup // Alternatively, using 'url' // url = "mongodb://localhost/mydb" @@ -129,7 +130,7 @@ You can also set a global default for the storage type of `String id` fields via ==== Index Creation on Startup -GORM creates and reconciles the indexes declared in your mapping blocks each time the datastore starts. Set `grails.mongodb.buildIndexes = false` to leave the indexes on the server untouched instead — see <> for details and when to use it. +GORM creates and reconciles the indexes declared in your mapping blocks each time the datastore starts, and startup waits for MongoDB to finish building them. Set `grails.mongodb.buildIndexes = false` to leave the indexes on the server untouched instead, or `grails.mongodb.buildIndexesAsync = true` to keep building them but on a background thread — see <> for details and when to use each. ==== Multi-Document Transactions diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index c466196ad91..b347591db05 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -25,7 +25,8 @@ Below are the details of the changes across releases: * TTL indexes via `indexAttributes: [expireAfterSeconds: N]` in the mapping DSL * Text and other special indexes via `indexAttributes: [type: 'text']` * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` -* Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false` +* Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` +* `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot ==== 7.1 diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index 620c8656374..8af878cd01e 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -162,6 +162,33 @@ The setting applies to every connection when declared at the top level, and can NOTE: Because nothing is created, a collection that has never been initialised with `buildIndexes` enabled will have no declared indexes at all. Turn the setting off only where the indexes are already in place or are applied by other means. The setting governs only the indexes GORM derives from the mapping blocks; an explicit `createIndex` call made by application code against a collection is unaffected. +==== Building Indexes in the Background + + +MongoDB answers a `createIndex` command only once the index has been built, so by default the thread that creates the datastore — in an application, the startup thread — waits for every declared index before the application finishes starting. On an empty collection that is instant; on a large existing collection an index build can take minutes, and a deployment waits for all of them in turn. + +Set `buildIndexesAsync` to have the startup index build run on a background thread instead: + +[source,groovy] +---- +grails { + mongodb { + buildIndexesAsync = true + } +} +---- + +Startup then continues without waiting. The indexes are still built one at a time, on a single daemon thread per connection named `gorm-mongo-index-build-`, so enabling this does not launch several index builds against the server at once. + +Two consequences are worth planning for: + +* A query issued before its index has been built is served without it — correctly, but with the performance of an unindexed query. The same applies to a `unique` index: it constrains nothing until the build finishes. +* Because startup no longer waits, a failure to build an index can no longer fail startup. It is logged at error level, and the application runs without that index. With the default synchronous build the exception propagates and the application does not start. + +The setting is ignored when `buildIndexes` is `false`, and it applies only to the index build performed when the datastore starts — a domain class registered after startup is indexed on the thread registering it. + +NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. + ==== Indexing using the 'index' method From d0d68476fd3ca3314e962c6d91949002cdfb8326 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 23 Aug 2026 13:42:51 -0700 Subject: [PATCH 04/28] Report what the index build applied and how long it took A successful index build said nothing at all: only conflicts, TTL updates and failures were logged, so there was no way to tell how much of startup went on building indexes, and with buildIndexesAsync no signal that the background build had finished. The build now logs one summary when it completes: Applied 7 index declaration(s) from 3 domain class(es) to database [myDb] in 412ms The count is declarations applied rather than indexes built, because createIndex is idempotent and the server accepts one that already exists without doing any work - claiming otherwise would be wrong on every restart after the first. A build with failures is logged at warn and reports how many. Each index also logs its own elapsed time at debug, which is how to find the one slow index behind a slow summary. The tests now run against logback rather than the no-op SLF4J binding, as the sibling data modules do, so that a test can assert on what was logged; the root logger is quietened to WARN so this does not add noise to the build. --- grails-data-mongodb/core/build.gradle | 6 +- .../mapping/mongo/MongoDatastore.java | 78 +++++++++-- .../mongo/BuildIndexesSummaryLogSpec.groovy | 130 ++++++++++++++++++ .../core/src/test/resources/logback-test.xml | 34 +++++ .../asciidoc/introduction/releaseNotes.adoc | 1 + .../docs/asciidoc/querying/queryIndexes.adoc | 22 +++ 6 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/resources/logback-test.xml diff --git a/grails-data-mongodb/core/build.gradle b/grails-data-mongodb/core/build.gradle index 26ee0ce5e5b..c11d4210ffa 100644 --- a/grails-data-mongodb/core/build.gradle +++ b/grails-data-mongodb/core/build.gradle @@ -144,7 +144,11 @@ dependencies { // test: GenericWebApplicationContext requires ServletContext on the classpath } - testImplementation 'org.slf4j:slf4j-nop' // Prevents warning about missing slf4j implementation during compilation and tests + testImplementation 'ch.qos.logback:logback-classic', { + // test: a real SLF4J binding, so that the log a test asserts on is actually emitted. + // Quietened to WARN by src/test/resources/logback-test.xml; a test raises the level of the + // one logger it is interested in. + } testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:testcontainers-mongodb' testImplementation 'org.testcontainers:testcontainers-spock' diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 903b7a6ab9f..32a6c1227cf 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -624,17 +624,52 @@ public void buildIndex() { } /** - * Creates and reconciles the indexes declared by every entity mapped to this datastore. + * 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(); 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, summary); } } + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + if (summary.indexes == 0) { + LOG.debug("No indexes are declared by the {} domain class(es) mapped to database [{}]", + summary.entities, defaultDatabase); + } + else if (summary.failures == 0) { + LOG.info("Applied {} index declaration(s) from {} domain class(es) to database [{}] in {}ms", + summary.indexes, summary.entities, defaultDatabase, elapsedMillis); + } + else { + LOG.warn("Applied {} of {} index declaration(s) from {} domain class(es) to database [{}] in {}ms; " + + "{} failed and are reported above", + summary.indexes - summary.failures, summary.indexes, summary.entities, defaultDatabase, + elapsedMillis, summary.failures); + } + } + + /** + * Counts the work one index build did, so that it can be summarised once at the end rather than a line + * per index. An index declaration is counted as applied when the server has accepted it, which says + * nothing about whether it had to be built: {@code createIndex} is idempotent and an index that already + * exists is accepted without work. + */ + private static final class IndexBuildSummary { + + private int entities; + + private int indexes; + + private int failures; } /** @@ -1003,6 +1038,10 @@ protected void registerEventListeners(ConfigurableApplicationEventPublisher even * @param entity The entity */ protected void initializeIndices(final PersistentEntity entity) { + initializeIndices(entity, new IndexBuildSummary()); + } + + private void initializeIndices(final PersistentEntity entity, final IndexBuildSummary summary) { if (!buildIndexes) { LOG.debug("Index creation is disabled by [{} = false]. Skipping the indexes declared by entity [{}].", MongoSettings.SETTING_BUILD_INDEXES, entity.getName()); @@ -1016,7 +1055,7 @@ protected void initializeIndices(final PersistentEntity entity) { List indices = mappedForm.getIndices(); for (MongoCollection.Index index : indices) { createOrUpdateIndex(entity, collection, new Document(index.getDefinition()), - index.getOptions(), "with definition [" + index.getDefinition() + "]"); + index.getOptions(), "with definition [" + index.getDefinition() + "]", summary); } for (Map compoundIndex : mappedForm.getCompoundIndices()) { @@ -1030,7 +1069,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); } } } @@ -1055,7 +1094,7 @@ protected void initializeIndices(final PersistentEntity entity) { } } createOrUpdateIndex(entity, collection, dbObject, options, - "on property [" + property.getName() + "]"); + "on property [" + property.getName() + "]", summary); } } @@ -1076,7 +1115,9 @@ protected void initializeIndices(final PersistentEntity entity) { */ private void createOrUpdateIndex(PersistentEntity entity, com.mongodb.client.MongoCollection collection, - Document keys, Map rawOptions, String descriptor) { + Document keys, Map rawOptions, String descriptor, + IndexBuildSummary summary) { + summary.indexes++; Map options = rawOptions != null ? new HashMap<>(rawOptions) : new HashMap<>(); // Control flag — not a Mongo index option. @@ -1093,13 +1134,19 @@ private void createOrUpdateIndex(PersistentEntity entity, indexOptions.expireAfter(expireAfterSeconds, TimeUnit.SECONDS); } + long startedAt = System.nanoTime(); try { collection.createIndex(keys, indexOptions); + LOG.debug("Applied index for entity [{}] {} in {}ms", entity.getName(), descriptor, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)); } catch (MongoCommandException e) { if (e.getErrorCode() == INDEX_OPTIONS_CONFLICT_CODE) { - reconcileIndexConflict(entity, collection, keys, indexOptions, - expireAfterSeconds, recreateOnConflict, descriptor, e); + if (!reconcileIndexConflict(entity, collection, keys, indexOptions, + expireAfterSeconds, recreateOnConflict, descriptor, e)) { + summary.failures++; + } } else { + summary.failures++; LOG.error("Failed to create index for entity [{}] {}: {}", entity.getName(), descriptor, e.getMessage(), e); } @@ -1111,8 +1158,11 @@ 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 collection, Document keys, IndexOptions desired, Long expireAfterSeconds, boolean recreateOnConflict, String descriptor, MongoCommandException original) { @@ -1122,12 +1172,12 @@ private void reconcileIndexConflict(PersistentEntity entity, } catch (RuntimeException listError) { LOG.error("Failed to create index for entity [{}] {} and could not inspect existing indexes: {}", entity.getName(), descriptor, listError.getMessage(), original); - return; + return false; } if (existing == null) { LOG.error("Failed to create index for entity [{}] {}: {}", entity.getName(), descriptor, original.getMessage(), original); - return; + return false; } String existingName = existing.getString("name"); @@ -1144,7 +1194,7 @@ private void reconcileIndexConflict(PersistentEntity entity, .append(INDEX_EXPIRE_AFTER_SECONDS, expireAfterSeconds))); LOG.info("Updated TTL of index [{}] on entity [{}] to {}s", existingName, entity.getName(), expireAfterSeconds); - return; + return true; } catch (MongoCommandException collModError) { // collMod can't make every change (e.g. add a TTL to a non-TTL index on older // servers) — fall through to recreate (if authorised) rather than fail outright. @@ -1158,17 +1208,19 @@ private void reconcileIndexConflict(PersistentEntity entity, collection.dropIndex(existingName); collection.createIndex(keys, desired); LOG.info("Recreated index [{}] on entity [{}] {}", existingName, entity.getName(), descriptor); + return true; } catch (MongoCommandException recreateError) { LOG.error("Failed to recreate index [{}] on entity [{}] {}: {}", existingName, entity.getName(), descriptor, recreateError.getMessage(), recreateError); + return false; } - return; } LOG.error( "Index conflict for entity [{}] {}: an index [{}] already exists on the same keys with different options. " + "Declare indexAttributes:[recreateOnConflict:true] to drop and recreate it. Original error: {}", entity.getName(), descriptor, existingName, original.getMessage()); + return false; } /** diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy new file mode 100644 index 00000000000..b6f51f6c7c9 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy @@ -0,0 +1,130 @@ +/* + * 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 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 grails.gorm.annotation.Entity +import org.slf4j.LoggerFactory +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.AbstractDatastore +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * An index build that succeeds says so once, at the end: how many index declarations it applied, how + * many domain classes they came from, and how long the caller spent waiting for them. That summary is + * the only signal a background build has finished at all. + */ +class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'summaryLogDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + Logger datastoreLogger + + @Shared + ListAppender logged = new ListAppender<>() + + @Shared + Level previousLevel + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + // The datastore logs through the logger its base class declares + datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + previousLevel = datastoreLogger.level + datastoreLogger.level = Level.INFO + logged.start() + datastoreLogger.addAppender(logged) + + datastore = new MongoDatastore( + ['grails.mongodb.url': dbContainer.getReplicaSetUrl(DATABASE)] as Map, + SummaryLoggedThing, OtherSummaryLoggedThing) + } + + void cleanupSpec() { + datastoreLogger?.detachAppender(logged) + datastoreLogger?.level = previousLevel + } + + /** + * Other specifications create datastores of their own in this JVM, so the summary is picked out by + * the database this specification uses rather than by being the only message logged. + */ + private String summaryMessage() { + logged.list.collect { it.formattedMessage } + .find { it.contains("index declaration(s)") && it.contains("database [$DATABASE]") } + } + + void "test a successful index build logs one summary of what it applied and what it cost"() { + expect: "the build reported itself" + summaryMessage() != null + + and: "it counted every declared index: two on one domain class, one on the other" + summaryMessage().contains('Applied 3 index declaration(s)') + + and: "and the domain classes they came from" + summaryMessage().contains('from 2 domain class(es)') + + and: "and how long the caller waited" + summaryMessage() ==~ /.* in \d+ms$/ + } + + void "test the summary is logged once for the build rather than once per index"() { + expect: + logged.list.count { it.formattedMessage.contains("database [$DATABASE]") } == 1 + } +} + +@Entity +class SummaryLoggedThing { + String name + Integer age + + static mapping = { + version false + collection 'summaryLoggedThing' + name index: true + compoundIndex name: 1, age: -1 + } +} + +@Entity +class OtherSummaryLoggedThing { + String title + + static mapping = { + version false + collection 'otherSummaryLoggedThing' + title index: true + } +} diff --git a/grails-data-mongodb/core/src/test/resources/logback-test.xml b/grails-data-mongodb/core/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..4d81bf0a99a --- /dev/null +++ b/grails-data-mongodb/core/src/test/resources/logback-test.xml @@ -0,0 +1,34 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index b347591db05..fb22c77506a 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -27,6 +27,7 @@ Below are the details of the changes across releases: * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` * Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` * `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot +* The index build reports what it applied and how long it took, in one summary line per build ==== 7.1 diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index 8af878cd01e..a33dabab874 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -189,6 +189,28 @@ The setting is ignored when `buildIndexes` is `false`, and it applies only to th NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. +==== What the Index Build Reports + + +An index build that finishes without error logs one summary line at `INFO`: + +---- +Applied 7 index declaration(s) from 3 domain class(es) to database [myDb] in 412ms +---- + +The elapsed time is what the caller actually spent waiting — startup with the default settings, or the background thread when `buildIndexesAsync` is enabled, where this line is also the only signal that the build has finished. + +"Applied" means the server accepted the declaration, not that it had to build anything: `createIndex` is idempotent, so on every restart after the first most declarations are accepted immediately because the index is already there. If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. + +To find which index is the slow one, enable `DEBUG` logging for `org.grails.datastore.mapping.core`, which adds a line per index with its own elapsed time: + +[source,yaml] +---- +logging: + level: + org.grails.datastore.mapping.core: DEBUG +---- + ==== Indexing using the 'index' method From bf1917416fd3517d08af03f478a87308269aa8f5 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 23 Aug 2026 13:54:10 -0700 Subject: [PATCH 05/28] Report which indexes the build created and which were already there The previous summary counted declarations applied and said, in as many words, that it could not tell whether any of them did work. That was wrong about the server: createIndexes answers with numIndexesBefore, numIndexesAfter and a "note: all indexes already exist", and it is the driver's createIndex helper that discards the response and returns only the index name. Rather than hand-roll the command to read that response - the mapping from IndexOptions to an index specification is not worth reimplementing - the build lists the indexes a collection already has, once per collection, and reports: Index build for database [myDb] finished in 412ms: 2 created, 5 already present, from 3 domain class(es) That split is what makes the elapsed time mean something. Measured against MongoDB 7.0 with 200k documents: creating an index took 172ms on the calling thread, re-issuing the same declaration 1.2ms. A restart that changed no mappings reports everything as already present, so a summary reporting created indexes is the one that accounts for a slow start. The listing is lazy, so an entity declaring no indexes costs no round trip, and it replaces the listing the conflict path used to make for itself. If it cannot be read the build still runs and the summary falls back to reporting how many declarations were applied. --- .../mapping/mongo/MongoDatastore.java | 132 ++++++++++++++---- .../mongo/BuildIndexesSummaryLogSpec.groovy | 51 ++++--- .../asciidoc/introduction/releaseNotes.adoc | 2 +- .../docs/asciidoc/querying/queryIndexes.adoc | 4 +- 4 files changed, 142 insertions(+), 47 deletions(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 32a6c1227cf..c5478a29605 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -641,35 +641,99 @@ private void buildDeclaredIndexes() { } } long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); - if (summary.indexes == 0) { + 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; } - else if (summary.failures == 0) { - LOG.info("Applied {} index declaration(s) from {} domain class(es) to database [{}] in {}ms", - summary.indexes, summary.entities, defaultDatabase, elapsedMillis); + 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("Applied {} of {} index declaration(s) from {} domain class(es) to database [{}] in {}ms; " + - "{} failed and are reported above", - summary.indexes - summary.failures, summary.indexes, summary.entities, defaultDatabase, - elapsedMillis, summary.failures); + 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. + * + *

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. + */ + private static final class ExistingIndexes { + + private final com.mongodb.client.MongoCollection collection; + + private final IndexBuildSummary summary; + + private List indexes; + + private boolean listed; + + private ExistingIndexes(com.mongodb.client.MongoCollection collection, IndexBuildSummary summary) { + this.collection = collection; + this.summary = summary; + } + + /** + * @return the indexes present before the build, or {@code null} if they could not be listed + */ + private List 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 boolean contains(Document keys) { + List existing = get(); + return existing != null && findIndexByKeyPattern(existing, keys) != null; } } /** * Counts the work one index build did, so that it can be summarised once at the end rather than a line - * per index. An index declaration is counted as applied when the server has accepted it, which says - * nothing about whether it had to be built: {@code createIndex} is idempotent and an index that already - * exists is accepted without work. + * per index. */ private static final class IndexBuildSummary { private int entities; - private int indexes; + private int created; + + private int alreadyPresent; private int failures; + + /** + * False once an entity's existing indexes could not be listed, which is the only thing that + * separates a created index from one that was already there. The summary then falls back to + * reporting how many declarations were applied without saying which did work. + */ + private boolean classified = true; + + private int applied() { + return created + alreadyPresent; + } } /** @@ -1048,6 +1112,7 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu return; } final com.mongodb.client.MongoCollection collection = getCollection(entity); + final ExistingIndexes existingIndexes = new ExistingIndexes(collection, summary); final ClassMapping classMapping = entity.getMapping(); if (classMapping != null) { final MongoCollection mappedForm = classMapping.getMappedForm(); @@ -1055,7 +1120,8 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu List indices = mappedForm.getIndices(); for (MongoCollection.Index index : indices) { createOrUpdateIndex(entity, collection, new Document(index.getDefinition()), - index.getOptions(), "with definition [" + index.getDefinition() + "]", summary); + index.getOptions(), "with definition [" + index.getDefinition() + "]", + summary, existingIndexes); } for (Map compoundIndex : mappedForm.getCompoundIndices()) { @@ -1069,7 +1135,7 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu } Document indexDef = new Document(compoundIndex); createOrUpdateIndex(entity, collection, indexDef, indexAttributes, - "compound index with definition [" + indexDef + "]", summary); + "compound index with definition [" + indexDef + "]", summary, existingIndexes); } } } @@ -1094,7 +1160,7 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu } } createOrUpdateIndex(entity, collection, dbObject, options, - "on property [" + property.getName() + "]", summary); + "on property [" + property.getName() + "]", summary, existingIndexes); } } @@ -1116,8 +1182,7 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu private void createOrUpdateIndex(PersistentEntity entity, com.mongodb.client.MongoCollection collection, Document keys, Map rawOptions, String descriptor, - IndexBuildSummary summary) { - summary.indexes++; + IndexBuildSummary summary, ExistingIndexes existingIndexes) { Map options = rawOptions != null ? new HashMap<>(rawOptions) : new HashMap<>(); // Control flag — not a Mongo index option. @@ -1134,15 +1199,28 @@ private void createOrUpdateIndex(PersistentEntity entity, indexOptions.expireAfter(expireAfterSeconds, TimeUnit.SECONDS); } + // Asked before the index is created, while the answer still means something. + boolean present = existingIndexes.contains(keys); long startedAt = System.nanoTime(); try { collection.createIndex(keys, indexOptions); - LOG.debug("Applied index for entity [{}] {} in {}ms", entity.getName(), descriptor, - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)); + if (present) { + summary.alreadyPresent++; + } + else { + summary.created++; + } + LOG.debug("{} index for entity [{}] {} in {}ms", present ? "Confirmed" : "Created", + entity.getName(), descriptor, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)); } catch (MongoCommandException e) { if (e.getErrorCode() == INDEX_OPTIONS_CONFLICT_CODE) { - if (!reconcileIndexConflict(entity, collection, keys, indexOptions, + if (reconcileIndexConflict(entity, collection, existingIndexes, keys, indexOptions, expireAfterSeconds, recreateOnConflict, descriptor, e)) { + // A conflict means an index was already on these keys; reconciling it changed the one + // that was there rather than adding one. + summary.alreadyPresent++; + } + else { summary.failures++; } } else { @@ -1164,16 +1242,16 @@ private void createOrUpdateIndex(PersistentEntity entity, */ private boolean reconcileIndexConflict(PersistentEntity entity, com.mongodb.client.MongoCollection 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 indexes = existingIndexes.get(); + if (indexes == null) { LOG.error("Failed to create index for entity [{}] {} and could not inspect existing indexes: {}", - entity.getName(), descriptor, listError.getMessage(), original); + entity.getName(), descriptor, original.getMessage(), original); return false; } + Document existing = findIndexByKeyPattern(indexes, keys); if (existing == null) { LOG.error("Failed to create index for entity [{}] {}: {}", entity.getName(), descriptor, original.getMessage(), original); @@ -1233,9 +1311,9 @@ private boolean reconcileIndexConflict(PersistentEntity entity, * existing text index is unambiguously the one a newly-declared text index conflicts with — * match it regardless of its key shape or name so {@code recreateOnConflict} can absorb it.

*/ - private static Document findIndexByKeyPattern(com.mongodb.client.MongoCollection collection, Document keys) { + private static Document findIndexByKeyPattern(Iterable indexes, Document keys) { boolean desiredIsText = isTextIndex(keys); - for (Document idx : collection.listIndexes()) { + for (Document idx : indexes) { Object key = idx.get("key"); if (!(key instanceof Document)) { continue; diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy index b6f51f6c7c9..df881733c0a 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy @@ -32,9 +32,10 @@ import org.grails.datastore.mapping.core.AbstractDatastore import org.grails.datastore.mapping.mongo.MongoDatastore /** - * An index build that succeeds says so once, at the end: how many index declarations it applied, how - * many domain classes they came from, and how long the caller spent waiting for them. That summary is - * the only signal a background build has finished at all. + * An index build that succeeds says so once, at the end: what it created, what was already there, how many + * domain classes it covered, and how long the caller spent waiting. That summary is the only signal a + * background build has finished at all, and the created/already-present split is what makes the elapsed + * time interpretable — a build that created nothing had nothing to wait for. */ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { @@ -53,6 +54,9 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { @Shared Level previousLevel + @Shared + List startupMessages + @Override boolean shouldInitializeDatastore() { false @@ -69,6 +73,9 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { datastore = new MongoDatastore( ['grails.mongodb.url': dbContainer.getReplicaSetUrl(DATABASE)] as Map, SummaryLoggedThing, OtherSummaryLoggedThing) + + // Snapshotted so that a feature triggering another build cannot change what the startup build said + startupMessages = messagesForThisDatabase() } void cleanupSpec() { @@ -77,31 +84,41 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { } /** - * Other specifications create datastores of their own in this JVM, so the summary is picked out by - * the database this specification uses rather than by being the only message logged. + * Other specifications create datastores of their own in this JVM, so the summaries are picked out by + * the database this specification uses rather than by being the only messages logged. */ - private String summaryMessage() { - logged.list.collect { it.formattedMessage } - .find { it.contains("index declaration(s)") && it.contains("database [$DATABASE]") } + private List messagesForThisDatabase() { + logged.list.collect { it.formattedMessage }.findAll { it.contains("database [$DATABASE]") } } void "test a successful index build logs one summary of what it applied and what it cost"() { - expect: "the build reported itself" - summaryMessage() != null + given: + String summary = startupMessages.first() - and: "it counted every declared index: two on one domain class, one on the other" - summaryMessage().contains('Applied 3 index declaration(s)') + expect: "exactly one summary for the build, not one line per index" + startupMessages.size() == 1 + + and: "it counted every declared index as created: two on one domain class, one on the other" + summary.contains('3 created, 0 already present') and: "and the domain classes they came from" - summaryMessage().contains('from 2 domain class(es)') + summary.contains('from 2 domain class(es)') and: "and how long the caller waited" - summaryMessage() ==~ /.* in \d+ms$/ + summary ==~ /Index build for database \[$DATABASE] finished in \d+ms: .*/ } - void "test the summary is logged once for the build rather than once per index"() { - expect: - logged.list.count { it.formattedMessage.contains("database [$DATABASE]") } == 1 + void "test a repeated build reports the indexes as already present rather than created"() { + given: "the summaries logged so far" + int before = messagesForThisDatabase().size() + + when: "the same declarations are applied again, as they would be on the next restart" + datastore.buildIndex() + + then: "the build reports that it created nothing, which is why it cost next to nothing" + List since = messagesForThisDatabase().drop(before) + since.size() == 1 + since.first().contains('0 created, 3 already present') } } diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index fb22c77506a..eb5e122c9ad 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -27,7 +27,7 @@ Below are the details of the changes across releases: * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` * Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` * `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot -* The index build reports what it applied and how long it took, in one summary line per build +* The index build reports what it created, what was already present and how long it took, in one summary line per build ==== 7.1 diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index a33dabab874..461acd8106b 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -195,12 +195,12 @@ NOTE: If the application shuts down while a background build is still running, G An index build that finishes without error logs one summary line at `INFO`: ---- -Applied 7 index declaration(s) from 3 domain class(es) to database [myDb] in 412ms +Index build for database [myDb] finished in 412ms: 2 created, 5 already present, from 3 domain class(es) ---- The elapsed time is what the caller actually spent waiting — startup with the default settings, or the background thread when `buildIndexesAsync` is enabled, where this line is also the only signal that the build has finished. -"Applied" means the server accepted the declaration, not that it had to build anything: `createIndex` is idempotent, so on every restart after the first most declarations are accepted immediately because the index is already there. If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. +The split between created and already present is what makes that time interpretable. MongoDB answers a `createIndex` for an index it already has immediately and without building anything, so a restart that changed no mappings reports everything as already present and costs milliseconds; a line reporting indexes created is the one that accounts for a slow start. If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. To find which index is the slow one, enable `DEBUG` logging for `org.grails.datastore.mapping.core`, which adds a line per index with its own elapsed time: From 14ac0c0fb16c99185d9d6cd9f60d6632673c28db Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 23 Aug 2026 16:13:07 -0700 Subject: [PATCH 06/28] Document the MongoDB settings in application.yml, not only application.groovy The configuration examples were written only in the application.groovy DSL, which reads as though the Groovy form were the only one; application.yml is what a generated Grails application actually ships with. Both forms are now shown, and the settings are noted as being read by name: they are not relaxed-bound the way Spring Boot's own properties are, so a kebab-case spelling is ignored and silently leaves the default in place. --- .../gettingStarted/advancedConfig.adoc | 17 +++++++++- .../docs/asciidoc/querying/queryIndexes.adoc | 34 ++++++++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc index 0628f5d581d..4db4c8ee2f5 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc @@ -21,7 +21,20 @@ under the License. ==== Mongo Database Connection Configuration -As mentioned the GORM for MongoDB plugin will configure all the defaults for you, but if you wish to customize those defaults you can do so in the `grails-app/conf/application.groovy` file: +As mentioned the GORM for MongoDB plugin will configure all the defaults for you, but if you wish to customize those defaults you can do so in `grails-app/conf/application.yml`: + +[source,yaml] +---- +grails: + mongodb: + host: localhost + port: 27017 + username: blah + password: blah + databaseName: foo +---- + +or equivalently in `grails-app/conf/application.groovy`: [source,groovy] ---- @@ -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. + The `databaseName` setting configures the default database name. If not specified the `databaseName` will default to the name of your application. You can also customize the MongoDB connection settings using an `options` block: diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index 461acd8106b..1a797e017d4 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -145,7 +145,16 @@ A change to a TTL index's `expireAfterSeconds` is handled automatically and does ==== Disabling Index Creation on Startup -By default GORM creates and reconciles every index declared in a mapping block when the datastore starts. Set `buildIndexes` to `false` to switch that off: +By default GORM creates and reconciles every index declared in a mapping block when the datastore starts. Set `buildIndexes` to `false` in `grails-app/conf/application.yml` to switch that off: + +[source,yaml] +---- +grails: + mongodb: + buildIndexes: false +---- + +or, in `application.groovy`: [source,groovy] ---- @@ -158,7 +167,18 @@ grails { With this setting no `createIndex` or `collMod` command is issued for any domain class, and the indexes already present on the server are left exactly as they are. Queries are unaffected and continue to use whichever indexes exist. This is useful when deploying against live data whose indexes are managed separately — by a DBA or a migration step — so that a deployment does not build an index against a large production collection, and so an application running against an older index set does not have those indexes reconciled underneath it. -The setting applies to every connection when declared at the top level, and can be set per connection inside a `connections` block. It also suppresses index creation for domain classes registered after startup. +It also suppresses index creation for domain classes registered after startup. Declared at the top level the setting applies to every connection, and each connection can override it: + +[source,yaml] +---- +grails: + mongodb: + buildIndexes: false + connections: + reporting: + url: mongodb://localhost/reporting + buildIndexes: true +---- NOTE: Because nothing is created, a collection that has never been initialised with `buildIndexes` enabled will have no declared indexes at all. Turn the setting off only where the indexes are already in place or are applied by other means. The setting governs only the indexes GORM derives from the mapping blocks; an explicit `createIndex` call made by application code against a collection is unaffected. @@ -169,13 +189,11 @@ MongoDB answers a `createIndex` command only once the index has been built, so b Set `buildIndexesAsync` to have the startup index build run on a background thread instead: -[source,groovy] +[source,yaml] ---- -grails { - mongodb { - buildIndexesAsync = true - } -} +grails: + mongodb: + buildIndexesAsync: true ---- Startup then continues without waiting. The indexes are still built one at a time, on a single daemon thread per connection named `gorm-mongo-index-build-`, so enabling this does not launch several index builds against the server at once. From b873801b55a3026acdf74e87ea7ab35cabf39d29 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 31 Aug 2026 23:23:32 -0700 Subject: [PATCH 07/28] Cover the index build's failure and degradation paths Codecov put the patch at 75%, and what it was missing was every path where something goes wrong - which is where behaviour that has been claimed in the documentation most needs pinning. Four of them are now covered: - a declaration the server refuses, and one whose conflict cannot be reconciled, are counted and reported: the build carries on, the summary is raised to WARN and says how many failed - a background build that fails logs the failure at error level, since nothing waits on that thread to notice it - a background build interrupted by shutdown is logged as the shutdown it is, not as a failure - a role that may create an index but not list the existing ones still gets its indexes, with the summary falling back to how many declarations it applied The last two need conditions a server will not produce on demand, so the driver is wrapped in a proxy that makes one operation fail or block. Only that one method is intercepted; everything else is the real driver talking to the real server, and the datastore under test is the real one built through its public constructor. Patch coverage is now 97.8%. What remains is two error branches in conflict reconciliation that predate this change and are not reachable without a server that contradicts itself. --- .../mapping/mongo/MongoDatastore.java | 4 +- .../BuildIndexesBackgroundFailureSpec.groovy | 171 ++++++++++++++++++ .../BuildIndexesFailureSummarySpec.groovy | 150 +++++++++++++++ ...BuildIndexesUnreadableIndexListSpec.groovy | 151 ++++++++++++++++ .../gorm/mongo/FailingMongoClient.groovy | 77 ++++++++ 5 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index c5478a29605..695540c74a7 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -612,8 +612,10 @@ public void buildIndex() { // 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.getMessage(), e); + e.toString(), e); } else { LOG.error("The background index build failed: {}. The application is running without the " + diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy new file mode 100644 index 00000000000..7f4a443c05a --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy @@ -0,0 +1,171 @@ +/* + * 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.AbstractDatastore +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 logged = new ListAppender<>() + + @Shared + Level previousLevel + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl('backgroundFailureDb')) + datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) 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 { + it.level == Level.ERROR && it.formattedMessage.contains('The background index build failed') + } + } + + cleanup: + datastore?.close() + } + + void "test a background build abandoned at shutdown is not reported as a failure"() { + given: + def conditions = new PollingConditions(timeout: 30) + def buildReached = new CountDownLatch(1) + def neverReleased = new CountDownLatch(1) + // The appender is shared with the preceding feature, which logs an error of its own on purpose + int errorsBefore = backgroundBuildFailures() + MongoClient blocking = FailingMongoClient.wrap(realClient, 'createIndex') { + buildReached.countDown() + neverReleased.await() + } + + when: "the build is under way on its background thread" + def datastore = asyncDatastoreOn(blocking, 'backgroundFailureDb', AbandonedBackgroundThing) + + then: + buildReached.await(30, TimeUnit.SECONDS) + + when: "the datastore is closed while the build is still running" + datastore.close() + + then: "the interruption is reported as the shutdown it is, not as a failure" + conditions.eventually { + assert logged.list.any { + it.level == Level.DEBUG && it.formattedMessage.contains('abandoned because the datastore is shutting down') + } + } + + and: "no error is logged for it" + backgroundBuildFailures() == errorsBefore + } + + private int backgroundBuildFailures() { + logged.list.count { + it.level == Level.ERROR && it.formattedMessage.contains('The background index build failed') + } + } +} + +@Entity +class FailedBackgroundThing { + String name + + static mapping = { + version false + collection 'failedBackgroundThing' + name index: true + } +} + +@Entity +class AbandonedBackgroundThing { + String name + + static mapping = { + version false + collection 'abandonedBackgroundThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy new file mode 100644 index 00000000000..68c172ebbc2 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy @@ -0,0 +1,150 @@ +/* + * 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 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.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +import org.bson.Document +import org.slf4j.LoggerFactory +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.AbstractDatastore +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * An index the server refuses is not allowed to stop the application from starting: the failure is + * reported and the rest of the build carries on. The summary then goes out at {@code WARN} and says how + * many declarations failed, so a build that half worked cannot pass for a clean one. + */ +class BuildIndexesFailureSummarySpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'failedIndexDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient setupClient + + @Shared + Logger datastoreLogger + + @Shared + ListAppender logged = new ListAppender<>() + + @Shared + Level previousLevel + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + String url = dbContainer.getReplicaSetUrl(DATABASE) + setupClient = MongoClients.create(url) + def database = setupClient.getDatabase(DATABASE) + + // An index on the same keys as the mapping declares, but without the unique option it asks for. + // MongoDB reports IndexOptionsConflict for the declaration, and without recreateOnConflict GORM + // is not authorised to drop the existing one. + database.getCollection('conflictingThing').createIndex(new Document('name', 1)) + + database.getCollection('rejectedThing').insertOne(new Document('code', 'first')) + + datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + previousLevel = datastoreLogger.level + datastoreLogger.level = Level.INFO + logged.start() + datastoreLogger.addAppender(logged) + + datastore = new MongoDatastore(['grails.mongodb.url': url] as Map, ConflictingThing, RejectedThing) + } + + void cleanupSpec() { + datastoreLogger?.detachAppender(logged) + datastoreLogger?.level = previousLevel + setupClient?.close() + } + + private List eventsForThisDatabase() { + logged.list.findAll { it.formattedMessage.contains("database [$DATABASE]") } + } + + void "test an index declaration the server refuses does not stop the datastore from starting"() { + expect: "the datastore is usable" + RejectedThing.withNewSession { RejectedThing.count() } == 1 + + and: "the conflicting declaration left the index that was already there untouched" + ConflictingThing.withNewSession { + ConflictingThing.collection.listIndexes().find { it.key == [name: 1] }.unique == null + } + + and: "and the declaration the server refused outright created nothing" + RejectedThing.withNewSession { + RejectedThing.collection.listIndexes()*.key == [[_id: 1]] + } + } + + void "test the summary is logged at warn and reports how many declarations failed"() { + given: + def summary = eventsForThisDatabase().first() + + expect: "one summary, raised to WARN because not everything was applied" + eventsForThisDatabase().size() == 1 + summary.level == Level.WARN + + and: "both failures are counted: the unresolved conflict and the declaration the server refused" + summary.formattedMessage.contains('2 failed') + + and: "the failures themselves were reported individually, naming the entity each came from" + logged.list.any { it.level == Level.ERROR && it.formattedMessage.contains('ConflictingThing') } + logged.list.any { it.level == Level.ERROR && it.formattedMessage.contains('RejectedThing') } + } +} + +@Entity +class ConflictingThing { + String name + + static mapping = { + version false + collection 'conflictingThing' + name index: true, indexAttributes: [unique: true] + } +} + +@Entity +class RejectedThing { + String code + + static mapping = { + version false + collection 'rejectedThing' + code index: true, indexAttributes: [type: 'nosuchindexplugin'] + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy new file mode 100644 index 00000000000..36f65a692f4 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy @@ -0,0 +1,151 @@ +/* + * 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.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 com.mongodb.client.model.IndexOptions +import org.bson.Document +import org.slf4j.LoggerFactory +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.AbstractDatastore +import org.grails.datastore.mapping.core.DatastoreUtils +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * Telling an index this build created from one that was already there means reading the indexes the + * collection already has, which a role holding {@code createIndex} but not {@code listIndexes} is not + * allowed to do. Losing that breakdown must not cost the indexes themselves: the build goes ahead and the + * summary says only how many declarations it applied. + */ +class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'unlistableIndexDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient realClient + + @Shared + Logger datastoreLogger + + @Shared + ListAppender logged = new ListAppender<>() + + @Shared + Level previousLevel + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl(DATABASE)) + // A TTL index whose expiry differs from the one the mapping declares. MongoDB reports that as an + // IndexOptionsConflict, which GORM reconciles in place - but reconciling means reading the indexes + // that are there, which this role is not allowed to do. + realClient.getDatabase(DATABASE).getCollection('unlistableConflictThing') + .createIndex(new Document('created', 1), new IndexOptions().expireAfter(999L, TimeUnit.SECONDS)) + + datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + previousLevel = datastoreLogger.level + datastoreLogger.level = Level.INFO + logged.start() + datastoreLogger.addAppender(logged) + + MongoClient cannotList = FailingMongoClient.wrap(realClient, 'listIndexes') { + throw new MongoException('not authorized on unlistableIndexDb to execute command listIndexes') + } + datastore = new MongoDatastore(cannotList, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': DATABASE]), + UnlistableIndexThing, UnlistableConflictThing) + } + + void cleanupSpec() { + datastoreLogger?.detachAppender(logged) + datastoreLogger?.level = previousLevel + realClient?.close() + } + + void "test the declared indexes are still created when the existing ones cannot be listed"() { + expect: + realClient.getDatabase(DATABASE).getCollection('unlistableIndexThing').listIndexes()*.key.contains([name: 1]) + } + + void "test the summary falls back to reporting how many declarations were applied"() { + given: + def summary = logged.list.find { it.formattedMessage.contains("database [$DATABASE]") } + + expect: "the build reported itself" + summary != null + + and: "without claiming which indexes it created, because that could not be established" + summary.formattedMessage.contains('1 index declaration(s) applied') + !summary.formattedMessage.contains('created') + + and: "the declaration that conflicted is counted as failed, since it could not be reconciled blind" + summary.formattedMessage.contains('1 failed') + } + + void "test a conflict that cannot be reconciled without the index list is reported, not swallowed"() { + expect: + logged.list.any { + it.level == Level.ERROR && + it.formattedMessage.contains('could not inspect existing indexes') && + it.formattedMessage.contains('UnlistableConflictThing') + } + } +} + +@Entity +class UnlistableConflictThing { + Date created + + static mapping = { + version false + collection 'unlistableConflictThing' + created index: true, indexAttributes: [expireAfterSeconds: 100] + } +} + +@Entity +class UnlistableIndexThing { + String name + + static mapping = { + version false + collection 'unlistableIndexThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy new file mode 100644 index 00000000000..88aabfd03a4 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy @@ -0,0 +1,77 @@ +/* + * 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.lang.reflect.InvocationHandler +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoCollection +import com.mongodb.client.MongoDatabase +import groovy.transform.CompileStatic + +/** + * Wraps a real {@link MongoClient} so that one driver operation misbehaves, which is the only way to + * reach conditions the server will not produce on demand — a role that may create an index but not list + * the existing ones, or a connection that fails while a build is running on a background thread. + * + *

Everything else is the real driver talking to a real server: only the named method is intercepted, + * and the databases and collections handed out are wrapped in turn so that the interception survives the + * calls GORM makes to derive a collection. + */ +@CompileStatic +class FailingMongoClient { + + /** + * @param delegate the real client to pass everything else through to + * @param failingMethod the driver method to intercept, by name + * @param onCall what to do in its place — throw, or block until released + */ + static MongoClient wrap(MongoClient delegate, String failingMethod, Closure onCall) { + (MongoClient) proxy(MongoClient, delegate, failingMethod, onCall) + } + + private static Object proxy(Class iface, Object target, String failingMethod, Closure onCall) { + Proxy.newProxyInstance(iface.classLoader, [iface] as Class[], new InvocationHandler() { + + @Override + Object invoke(Object proxyInstance, Method method, Object[] args) { + if (method.name == failingMethod) { + return onCall.call() + } + Object result + try { + result = method.invoke(target, args) + } + catch (InvocationTargetException e) { + throw e.cause + } + if (result instanceof MongoDatabase) { + return proxy(MongoDatabase, result, failingMethod, onCall) + } + if (result instanceof MongoCollection) { + return proxy(MongoCollection, result, failingMethod, onCall) + } + return result + } + }) + } +} From 1726f84d3a5ec8a32b5940936f875d7674604fde Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 10:14:30 -0700 Subject: [PATCH 08/28] Fix MongoDB index build lifecycle and review findings --- .../mapping/mongo/MongoDatastore.java | 104 +++++++++++++----- .../gorm/mongo/BuildIndexesAsyncSpec.groovy | 32 +++++- .../gorm/mongo/BuildIndexesHookSpec.groovy | 96 ++++++++++++++++ .../mongo/BuildIndexesSummaryLogSpec.groovy | 18 ++- .../mongo/TextIndexViaAttributesSpec.groovy | 31 +++++- .../BuildIndexesPerConnectionSpec.groovy | 101 +++++++++++++++++ .../SuppliedMongoClientSettingsSpec.groovy | 67 +++++++++++ .../MongoConnectionSourceSettingsSpec.groovy | 29 +++++ .../asciidoc/introduction/releaseNotes.adoc | 2 +- .../docs/asciidoc/querying/queryIndexes.adoc | 4 +- 10 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 695540c74a7..cc450133c80 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -26,8 +26,9 @@ import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import groovy.lang.Closure; @@ -80,6 +81,7 @@ import org.grails.datastore.mapping.core.Session; import org.grails.datastore.mapping.core.StatelessDatastore; import org.grails.datastore.mapping.core.connections.ConnectionSource; +import org.grails.datastore.mapping.core.connections.ConnectionSourceSettingsBuilder; import org.grails.datastore.mapping.core.connections.ConnectionSources; import org.grails.datastore.mapping.core.connections.ConnectionSourcesInitializer; import org.grails.datastore.mapping.core.connections.ConnectionSourcesListener; @@ -171,9 +173,13 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. /** * 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 rather than all at once against the server. + * 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 = new ThreadLocal<>(); private volatile Boolean transactionsSupported; private volatile boolean warnedTransactionsUnsupported = false; protected CodecRegistry codecRegistry; @@ -220,7 +226,8 @@ public MongoDatastore(final ConnectionSources(), + new IndexBuildThreadFactory(defaultConnectionSource.getName())) : null; codecRegistry = CodecRegistries.fromRegistries( CodecRegistries.fromProviders(new CodecExtensions(), new PersistentEntityCodeRegistry()), @@ -633,13 +640,25 @@ public void buildIndex() { private void buildDeclaredIndexes() { long startedAt = System.nanoTime(); IndexBuildSummary summary = new IndexBuildSummary(); - 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; + 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; - summary.entities++; - initializeIndices(entity, summary); + summary.entities++; + initializeIndices(entity); + } + } + } + finally { + if (previousSummary == null) { + indexBuildSummary.remove(); + } + else { + indexBuildSummary.set(previousSummary); } } long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); @@ -670,7 +689,8 @@ private void buildDeclaredIndexes() { * index this build created from one it merely confirmed. * *

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. + * 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 { @@ -688,7 +708,7 @@ private ExistingIndexes(com.mongodb.client.MongoCollection collection, } /** - * @return the indexes present before the build, or {@code null} if they could not be listed + * @return the known current indexes, or {@code null} if they could not be listed */ private List get() { if (!listed) { @@ -706,6 +726,21 @@ private List get() { 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) { List existing = get(); return existing != null && findIndexByKeyPattern(existing, keys) != null; @@ -1099,12 +1134,14 @@ protected void registerEventListeners(ConfigurableApplicationEventPublisher even } /** - * Indexes any properties that are mapped with index:true + * Indexes any properties that are mapped with index:true. Called for both startup builds and + * entities registered later, so subclasses can customise index creation on either path. * * @param entity The entity */ protected void initializeIndices(final PersistentEntity entity) { - initializeIndices(entity, new IndexBuildSummary()); + IndexBuildSummary summary = indexBuildSummary.get(); + initializeIndices(entity, summary != null ? summary : new IndexBuildSummary()); } private void initializeIndices(final PersistentEntity entity, final IndexBuildSummary summary) { @@ -1205,7 +1242,8 @@ private void createOrUpdateIndex(PersistentEntity entity, boolean present = existingIndexes.contains(keys); long startedAt = System.nanoTime(); try { - collection.createIndex(keys, indexOptions); + String indexName = collection.createIndex(keys, indexOptions); + existingIndexes.record(keys, indexName, expireAfterSeconds); if (present) { summary.alreadyPresent++; } @@ -1272,6 +1310,7 @@ private boolean reconcileIndexConflict(PersistentEntity entity, .runCommand(new Document("collMod", getCollectionName(entity)) .append("index", new Document("name", existingName) .append(INDEX_EXPIRE_AFTER_SECONDS, expireAfterSeconds))); + existingIndexes.record(keys, existingName, expireAfterSeconds); LOG.info("Updated TTL of index [{}] on entity [{}] to {}s", existingName, entity.getName(), expireAfterSeconds); return true; @@ -1286,7 +1325,8 @@ private boolean reconcileIndexConflict(PersistentEntity entity, if (recreateOnConflict) { try { collection.dropIndex(existingName); - collection.createIndex(keys, desired); + String indexName = collection.createIndex(keys, desired); + existingIndexes.record(keys, indexName, expireAfterSeconds); LOG.info("Recreated index [{}] on entity [{}] {}", existingName, entity.getName(), descriptor); return true; } catch (MongoCommandException recreateError) { @@ -1451,10 +1491,11 @@ private boolean ownsClient() { @PreDestroy public void close() { MongoClient current = this.mongo; - if (indexBuildExecutor != null) { - // Interrupt rather than wait: an index build can run for minutes and shutdown must not wait - // for it. The server carries on building what it was asked for. - indexBuildExecutor.shutdownNow(); + shutDownIndexBuild(); + for (MongoDatastore datastore : datastoresByConnectionSource.values()) { + if (datastore != this) { + datastore.shutDownIndexBuild(); + } } try { super.destroy(); @@ -1484,6 +1525,14 @@ 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 @@ -1520,10 +1569,11 @@ public Thread newThread(Runnable runnable) { */ protected static ConnectionSources createDefaultConnectionSources(MongoClient mongoClient, PropertyResolver configuration, MongoMappingContext mappingContext, boolean closeable) { // Bound from the configuration rather than left at the defaults: the client is supplied here, but - // the settings that describe how the datastore behaves (stateless, transactional, buildIndexes, - // engine, flush mode) still come from grails.mongodb, exactly as they do when GORM creates the - // client itself. The connection details in them are unused - this client is already connected. - MongoConnectionSourceSettings settings = new MongoConnectionSourceSettingsBuilder(configuration).build(); + // 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()); ConnectionSource defaultConnectionSource = new DefaultConnectionSource<>(ConnectionSource.DEFAULT, mongoClient, settings, closeable); return new InMemoryConnectionSources<>(defaultConnectionSource, new MongoConnectionSourceFactory(), configuration); @@ -1543,13 +1593,17 @@ protected static MongoMappingContext createMappingContext(ConnectionSources recorded = new ConcurrentLinkedQueue<>() + private final Queue recorded = new ConcurrentLinkedQueue<>() @Override void commandStarted(CommandStartedEvent event) { if (event.commandName == 'createIndexes') { - recorded.add(Thread.currentThread().name) + recorded.add(Thread.currentThread()) } } List getThreads() { + recorded.collect { it.name }.unique() + } + + List getWorkers() { recorded.toList().unique() } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy new file mode 100644 index 00000000000..4ebdec3e5a3 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy @@ -0,0 +1,96 @@ +/* + * 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 grails.gorm.annotation.Entity +import org.bson.Document +import spock.util.concurrent.PollingConditions + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.mongo.MongoDatastore + +class BuildIndexesHookSpec extends AutoStartedMongoSpec { + + @Override + boolean shouldInitializeDatastore() { + false + } + + void "test the protected index hook customises startup and later entities with async #async"() { + given: + def conditions = new PollingConditions(timeout: 30) + + when: + def datastore = new CustomIndexDatastore([ + 'grails.mongodb.url': dbContainer.getReplicaSetUrl("indexHook${async}"), + 'grails.mongodb.buildIndexesAsync': async + ]) + + then: + conditions.eventually { + assert [name: 1] in StartupHookThing.collection.listIndexes()*.key + assert [custom: 1] in StartupHookThing.collection.listIndexes()*.key + } + + when: "another domain class is registered after startup" + datastore.mappingContext.addPersistentEntity(LaterHookThing) + + then: + [name: 1] in LaterHookThing.collection.listIndexes()*.key + [custom: 1] in LaterHookThing.collection.listIndexes()*.key + + cleanup: + datastore?.close() + + where: + async << [false, true] + } +} + +class CustomIndexDatastore extends MongoDatastore { + + CustomIndexDatastore(Map configuration) { + super(configuration, StartupHookThing) + } + + @Override + protected void initializeIndices(PersistentEntity entity) { + super.initializeIndices(entity) + getCollection(entity).createIndex(new Document('custom', 1)) + } +} + +@Entity +class StartupHookThing { + String name + + static mapping = { + name index: true + } +} + +@Entity +class LaterHookThing { + String name + + static mapping = { + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy index df881733c0a..70ab9587392 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy @@ -72,7 +72,7 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { datastore = new MongoDatastore( ['grails.mongodb.url': dbContainer.getReplicaSetUrl(DATABASE)] as Map, - SummaryLoggedThing, OtherSummaryLoggedThing) + SummaryLoggedThing, OtherSummaryLoggedThing, UnindexedSummaryThing) // Snapshotted so that a feature triggering another build cannot change what the startup build said startupMessages = messagesForThisDatabase() @@ -98,11 +98,11 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { expect: "exactly one summary for the build, not one line per index" startupMessages.size() == 1 - and: "it counted every declared index as created: two on one domain class, one on the other" - summary.contains('3 created, 0 already present') + and: "a duplicate declaration confirms the index just created instead of counting it twice" + summary.contains('3 created, 1 already present') - and: "and the domain classes they came from" - summary.contains('from 2 domain class(es)') + and: "the class count includes a class that declares no indexes" + summary.contains('from 3 domain class(es)') and: "and how long the caller waited" summary ==~ /Index build for database \[$DATABASE] finished in \d+ms: .*/ @@ -118,7 +118,7 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { then: "the build reports that it created nothing, which is why it cost next to nothing" List since = messagesForThisDatabase().drop(before) since.size() == 1 - since.first().contains('0 created, 3 already present') + since.first().contains('0 created, 4 already present') } } @@ -132,6 +132,7 @@ class SummaryLoggedThing { collection 'summaryLoggedThing' name index: true compoundIndex name: 1, age: -1 + compoundIndex name: 1 } } @@ -145,3 +146,8 @@ class OtherSummaryLoggedThing { title index: true } } + +@Entity +class UnindexedSummaryThing { + String name +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/TextIndexViaAttributesSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/TextIndexViaAttributesSpec.groovy index e1fe3840eb6..8fc447c358e 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/TextIndexViaAttributesSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/TextIndexViaAttributesSpec.groovy @@ -34,7 +34,7 @@ import org.bson.types.ObjectId class TextIndexViaAttributesSpec extends GrailsDataTckSpec { void setupSpec() { - manager.registerDomainClasses(TextThing) + manager.registerDomainClasses(TextThing, RepeatedTextThing) } private void initIndexes() { @@ -67,6 +67,22 @@ class TextIndexViaAttributesSpec extends GrailsDataTckSpec() + Logger logger = LoggerFactory.getLogger(AbstractDatastore) as Logger + Level previousLevel = logger.level + def logged = new ListAppender() + logger.level = Level.DEBUG + logged.start() + logger.addAppender(logged) + def factory = new MongoConnectionSourceFactory() { + @Override + ConnectionSource create(String name, MongoConnectionSourceSettings settings) { + def source = super.create(name, settings) + if (name != 'indexedAsync') { + return source + } + MongoClient blocking = FailingMongoClient.wrap(source.source, 'createIndex') { + worker.set(Thread.currentThread()) + buildReached.countDown() + releaseBuild.await() + } + new DefaultConnectionSource(name, blocking, settings) + } + } + Map childConfig = [url: dbContainer.getReplicaSetUrl('asyncChildDb'), buildIndexes: true] + Map config = [ + 'grails.mongodb.url': dbContainer.getReplicaSetUrl('asyncParentDb'), + 'grails.mongodb.buildIndexes': false, + 'grails.mongodb.buildIndexesAsync': true, + 'grails.mongodb.connections': addedAtRuntime ? [:] : [indexedAsync: childConfig] + ] + + when: + def parent = new MongoDatastore(DatastoreUtils.createPropertyResolver(config), factory, + new DefaultApplicationEventPublisher(), AsyncPerConnectionThing) + if (addedAtRuntime) { + parent.connectionSources.addConnectionSource('indexedAsync', childConfig) + } + + then: + parent.getDatastoreForConnection('indexedAsync').isBuildIndexesAsync() + buildReached.await(30, TimeUnit.SECONDS) + worker.get().name == 'gorm-mongo-index-build-indexedAsync' + + when: "the parent closes while its child is still building" + parent.close() + worker.get().join(10000) + + then: "the child worker is interrupted and terminates" + !worker.get().alive + + and: "the abandoned build is classified as shutdown rather than an error" + logged.list.any { + it.threadName == worker.get().name && it.level == Level.DEBUG && + it.formattedMessage.contains('abandoned because the datastore is shutting down') + } + !logged.list.any { + it.threadName == worker.get().name && it.level == Level.ERROR + } + + cleanup: + releaseBuild.countDown() + parent?.close() + worker.get()?.join(10000) + logger.detachAppender(logged) + logger.level = previousLevel + logged.stop() + + where: + addedAtRuntime << [false, true] + } + void "test a connection can override the global setting"() { expect: "the default connection is disabled by the top level setting" !datastore.isBuildIndexes() @@ -99,3 +190,13 @@ class PerConnectionThing { name index: true } } + +@Entity +class AsyncPerConnectionThing { + String name + + static mapping = { + connection ConnectionSource.ALL + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy index 74ec48eaa48..f32d7405f0b 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy @@ -20,6 +20,7 @@ package org.grails.datastore.gorm.mongo.connections import com.mongodb.client.MongoClient import com.mongodb.client.MongoClients +import grails.gorm.MultiTenant import grails.gorm.annotation.Entity import spock.lang.AutoCleanup import spock.lang.Shared @@ -28,6 +29,7 @@ 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 +import org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode /** * An application that hands GORM an existing {@code MongoClient} - which is what happens whenever a @@ -71,6 +73,65 @@ class SuppliedMongoClientSettingsSpec extends AutoStartedMongoSpec { datastore.isTransactionsEnabled() } + void "test a supplied client applies #prefix multi-tenancy mode #mode"() { + given: + def configuration = DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.databaseName': 'suppliedTenantDb', + ("${prefix}.multiTenancy.mode".toString()): mode.name() + ]) + + when: + def tenantDatastore = new MongoDatastore(mongoClient, configuration, new Class[0]) + + then: "both the datastore and the settings used by GORM static APIs agree" + tenantDatastore.multiTenancyMode == mode + tenantDatastore.connectionSources.defaultConnectionSource.settings.multiTenancy.mode == mode + + cleanup: + tenantDatastore?.close() + + where: + [prefix, mode] << [['grails.gorm', 'grails.mongodb'], MultiTenancyMode.values().toList()].combinations() + } + + void "test MongoDB tenancy settings override the global GORM fallback for a supplied client"() { + when: + def tenantDatastore = new MongoDatastore(mongoClient, DatastoreUtils.createPropertyResolver([ + 'grails.gorm.multiTenancy.mode': 'DISCRIMINATOR', + 'grails.mongodb.multiTenancy.mode': 'NONE' + ]), new Class[0]) + + then: + tenantDatastore.multiTenancyMode == MultiTenancyMode.NONE + tenantDatastore.connectionSources.defaultConnectionSource.settings.multiTenancy.mode == MultiTenancyMode.NONE + + cleanup: + tenantDatastore?.close() + } + + void "test configured tenant discrimination isolates data with a supplied client"() { + given: + def tenantDatastore = new MongoDatastore(mongoClient, DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.databaseName': 'suppliedTenantIsolationDb', + 'grails.gorm.multiTenancy.mode': 'DISCRIMINATOR' + ]), SuppliedTenantThing) + + when: + SuppliedTenantThing.withTenant('first') { + new SuppliedTenantThing(name: 'First tenant').save(flush: true, failOnError: true) + } + SuppliedTenantThing.withTenant('second') { + new SuppliedTenantThing(name: 'Second tenant').save(flush: true, failOnError: true) + } + + then: + SuppliedTenantThing.withTenant('first') { SuppliedTenantThing.list()*.name } == ['First tenant'] + SuppliedTenantThing.withTenant('second') { SuppliedTenantThing.list()*.name } == ['Second tenant'] + + cleanup: + tenantDatastore?.close() + } + void "test the configured settings take effect and not merely report"() { when: "a document is written so the collection certainly exists" SuppliedClientThing.withNewSession { @@ -92,3 +153,9 @@ class SuppliedClientThing { name index: true } } + +@Entity +class SuppliedTenantThing implements MultiTenant { + String tenantId + String name +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy index 0bfd431e1a4..374d634f523 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy @@ -79,6 +79,35 @@ class MongoConnectionSourceSettingsSpec extends Specification { settings.buildIndexesAsync } + void "test MongoDB setting names require the documented camel case spelling"() { + given: + def defaults = new MongoConnectionSourceSettings() + + when: + def settings = new MongoConnectionSourceSettingsBuilder(DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.database-name': 'ignoredDb', + 'grails.mongodb.build-indexes': false, + 'grails.mongodb.build-indexes-async': true + ])).build() + + then: + settings.databaseName == defaults.databaseName + settings.buildIndexes + !settings.buildIndexesAsync + + when: + settings = new MongoConnectionSourceSettingsBuilder(DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.databaseName': 'configuredDb', + 'grails.mongodb.buildIndexes': false, + 'grails.mongodb.buildIndexesAsync': true + ])).build() + + then: + settings.databaseName == 'configuredDb' + !settings.buildIndexes + settings.buildIndexesAsync + } + void "test mongo client settings builder with URL"() { when:"using a property resolver" Map myMap = ['grails.mongodb.url': 'mongodb://foo:bar@mycompany/mydb?maxPoolSize=5'] diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index eb5e122c9ad..225b50f1e74 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -26,7 +26,7 @@ Below are the details of the changes across releases: * Text and other special indexes via `indexAttributes: [type: 'text']` * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` * Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` -* `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot +* `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot. This also activates configured multi-tenancy (`grails.gorm.multiTenancy` or `grails.mongodb.multiTenancy`), which was previously ignored on this path. Applications using a supplied client must review their tenant resolver and tenant data: tenant discrimination and routing now follow the configured mode instead of `NONE`. * The index build reports what it created, what was already present and how long it took, in one summary line per build ==== 7.1 diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index 1a797e017d4..e9e64e9bd2c 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -196,7 +196,7 @@ grails: buildIndexesAsync: true ---- -Startup then continues without waiting. The indexes are still built one at a time, on a single daemon thread per connection named `gorm-mongo-index-build-`, so enabling this does not launch several index builds against the server at once. +Startup then continues without waiting. The indexes are still built one at a time per connection, on a daemon thread named `gorm-mongo-index-build-`. Different connections can build indexes concurrently, including when they use the same MongoDB server. The thread is released after the build finishes and it has been idle for one second. Two consequences are worth planning for: @@ -216,6 +216,8 @@ An index build that finishes without error logs one summary line at `INFO`: Index build for database [myDb] finished in 412ms: 2 created, 5 already present, from 3 domain class(es) ---- +The domain class count includes every class considered for indexing, including classes that declare no indexes. The created and already-present counts describe index declarations; declaring the same keys twice can report one created and one already present. + The elapsed time is what the caller actually spent waiting — startup with the default settings, or the background thread when `buildIndexesAsync` is enabled, where this line is also the only signal that the build has finished. The split between created and already present is what makes that time interpretable. MongoDB answers a `createIndex` for an index it already has immediately and without building anything, so a restart that changed no mappings reports everything as already present and costs milliseconds; a line reporting indexes created is the one that accounts for a slow start. If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. From 211ef8de17ab666609d7efb5bb30e27ced5c40c6 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 15 Sep 2026 13:56:08 -0700 Subject: [PATCH 09/28] Fix MongoDB CI logging assertions and activate failOnError test --- .../gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy | 3 +-- .../gorm/mongo/BuildIndexesFailureSummarySpec.groovy | 3 +-- .../datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy | 5 ++--- .../gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy | 3 +-- .../mongo/connections/BuildIndexesPerConnectionSpec.groovy | 3 +-- .../test/groovy/functional/tests/LocalMongoUnitSpec.groovy | 2 -- 6 files changed, 6 insertions(+), 13 deletions(-) diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy index 7f4a443c05a..ab3eff9b850 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy @@ -34,7 +34,6 @@ import spock.lang.Shared import spock.util.concurrent.PollingConditions import org.apache.grails.testing.mongo.AutoStartedMongoSpec -import org.grails.datastore.mapping.core.AbstractDatastore import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.mongo.MongoDatastore import org.grails.datastore.mapping.mongo.config.MongoSettings @@ -65,7 +64,7 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { void setupSpec() { realClient = MongoClients.create(dbContainer.getReplicaSetUrl('backgroundFailureDb')) - datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger previousLevel = datastoreLogger.level datastoreLogger.level = Level.DEBUG logged.start() diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy index 68c172ebbc2..668c81fd338 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy @@ -31,7 +31,6 @@ import spock.lang.AutoCleanup import spock.lang.Shared import org.apache.grails.testing.mongo.AutoStartedMongoSpec -import org.grails.datastore.mapping.core.AbstractDatastore import org.grails.datastore.mapping.mongo.MongoDatastore /** @@ -76,7 +75,7 @@ class BuildIndexesFailureSummarySpec extends AutoStartedMongoSpec { database.getCollection('rejectedThing').insertOne(new Document('code', 'first')) - datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger previousLevel = datastoreLogger.level datastoreLogger.level = Level.INFO logged.start() diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy index 70ab9587392..b9c675fbb92 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy @@ -28,7 +28,6 @@ import spock.lang.AutoCleanup import spock.lang.Shared import org.apache.grails.testing.mongo.AutoStartedMongoSpec -import org.grails.datastore.mapping.core.AbstractDatastore import org.grails.datastore.mapping.mongo.MongoDatastore /** @@ -63,8 +62,8 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { } void setupSpec() { - // The datastore logs through the logger its base class declares - datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + // Capture both inherited and MongoDatastore-specific logging categories. + datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger previousLevel = datastoreLogger.level datastoreLogger.level = Level.INFO logged.start() diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy index 36f65a692f4..1c94b02ba2b 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy @@ -35,7 +35,6 @@ import spock.lang.AutoCleanup import spock.lang.Shared import org.apache.grails.testing.mongo.AutoStartedMongoSpec -import org.grails.datastore.mapping.core.AbstractDatastore import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.mongo.MongoDatastore @@ -78,7 +77,7 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { realClient.getDatabase(DATABASE).getCollection('unlistableConflictThing') .createIndex(new Document('created', 1), new IndexOptions().expireAfter(999L, TimeUnit.SECONDS)) - datastoreLogger = LoggerFactory.getLogger(AbstractDatastore) as Logger + datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger previousLevel = datastoreLogger.level datastoreLogger.level = Level.INFO logged.start() diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy index 50532dee30a..ec59018281c 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -35,7 +35,6 @@ import spock.lang.Shared import org.apache.grails.testing.mongo.AutoStartedMongoSpec import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher import org.grails.datastore.gorm.mongo.FailingMongoClient -import org.grails.datastore.mapping.core.AbstractDatastore import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.core.connections.ConnectionSource import org.grails.datastore.mapping.core.connections.DefaultConnectionSource @@ -82,7 +81,7 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { def buildReached = new CountDownLatch(1) def releaseBuild = new CountDownLatch(1) def worker = new AtomicReference() - Logger logger = LoggerFactory.getLogger(AbstractDatastore) as Logger + Logger logger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger Level previousLevel = logger.level def logged = new ListAppender() logger.level = Level.DEBUG diff --git a/grails-test-examples/mongodb/base/src/test/groovy/functional/tests/LocalMongoUnitSpec.groovy b/grails-test-examples/mongodb/base/src/test/groovy/functional/tests/LocalMongoUnitSpec.groovy index 2565c1ffcb9..571117c68b7 100644 --- a/grails-test-examples/mongodb/base/src/test/groovy/functional/tests/LocalMongoUnitSpec.groovy +++ b/grails-test-examples/mongodb/base/src/test/groovy/functional/tests/LocalMongoUnitSpec.groovy @@ -25,7 +25,6 @@ import org.apache.grails.testing.mongo.AbstractMongoGrailsExtension import org.testcontainers.containers.MongoDBContainer import org.testcontainers.utility.DockerImageName import spock.lang.AutoCleanup -import spock.lang.PendingFeature import spock.lang.Shared class LocalMongoUnitSpec extends MongoSpec implements EmbeddedMongoClient { @@ -50,7 +49,6 @@ class LocalMongoUnitSpec extends MongoSpec implements EmbeddedMongoClient { } //tag::structure[] - @PendingFeature(reason = 'A ValidationException is not thrown') void "test fail on error"() { when: From 5e3403efe52744178b6b49a04c458f7d8de2fe7b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 20 Sep 2026 15:40:26 -0700 Subject: [PATCH 10/28] Close the connection source children over a snapshot close() interrupted each child's index build by iterating the map of child datastores directly, while the connection sources listener can still add one for a runtime addConnectionSource. A concurrent add is unlikely during shutdown, but the ConcurrentModificationException would escape close() before super.destroy() and connectionSources.close() ran, leaving everything open. Iterating a copy costs nothing and removes the window. The map's own thread-safety is unchanged and predates this. --- .../org/grails/datastore/mapping/mongo/MongoDatastore.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 20da1960a4d..32965c43c34 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -1501,7 +1501,9 @@ private boolean ownsClient() { public void close() { MongoClient current = this.mongo; shutDownIndexBuild(); - for (MongoDatastore datastore : datastoresByConnectionSource.values()) { + // 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())) { if (datastore != this) { datastore.shutDownIndexBuild(); } From 4710351bbe84a92ea8545bb18345f50606c24b52 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 20 Sep 2026 15:40:26 -0700 Subject: [PATCH 11/28] Point the documented logger category at the MongoDatastore logger The per-index DEBUG line was documented as coming from org.grails.datastore.mapping.core, which was true while MongoDatastore logged through the logger AbstractDatastore declares. It has had its own logger since 16338, so with 8.0.x merged in, that line and the build summary are emitted under org.grails.datastore.mapping.mongo and a reader following the old category would see nothing. The specs already assert against the shared org.grails.datastore.mapping parent, so they cover the logger either way. --- .../docs/src/docs/asciidoc/querying/queryIndexes.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index e9e64e9bd2c..c2d7ec99654 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -222,13 +222,13 @@ The elapsed time is what the caller actually spent waiting — startup with the The split between created and already present is what makes that time interpretable. MongoDB answers a `createIndex` for an index it already has immediately and without building anything, so a restart that changed no mappings reports everything as already present and costs milliseconds; a line reporting indexes created is the one that accounts for a slow start. If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. -To find which index is the slow one, enable `DEBUG` logging for `org.grails.datastore.mapping.core`, which adds a line per index with its own elapsed time: +To find which index is the slow one, enable `DEBUG` logging for `org.grails.datastore.mapping.mongo`, which adds a line per index with its own elapsed time: [source,yaml] ---- logging: level: - org.grails.datastore.mapping.core: DEBUG + org.grails.datastore.mapping.mongo: DEBUG ---- ==== Indexing using the 'index' method From 4ebb343fce3f3977b1eeea2f6d713a5112a68cb9 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 20 Sep 2026 15:40:26 -0700 Subject: [PATCH 12/28] Document everything an externally-supplied client now picks up Applying the configuration to a supplied MongoClient switches on the whole grails.gorm block, not just multi-tenancy: failOnError turns an invalid save() into a ValidationException, and the default mapping and constraints, autoFlush, markDirty and flushMode all start taking effect on a path that ignored them. The release note named only multi-tenancy, which undersells what an application handing GORM its own client is in for. The detail moves to the upgrade notes, beside the note on the client lifecycle, since it has the same audience. Multi-tenancy gets the step that is easy to miss: a TenantResolver bean is injected into the connection source factory, and there is no factory on this path, so the resolver has to be named by class or the mode is active with NoTenantResolver throwing TenantNotFoundException on every tenant-scoped operation. --- .../asciidoc/introduction/releaseNotes.adoc | 2 +- .../asciidoc/introduction/upgradeNotes.adoc | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index 225b50f1e74..742bb8618cb 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -26,7 +26,7 @@ Below are the details of the changes across releases: * Text and other special indexes via `indexAttributes: [type: 'text']` * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` * Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` -* `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot. This also activates configured multi-tenancy (`grails.gorm.multiTenancy` or `grails.mongodb.multiTenancy`), which was previously ignored on this path. Applications using a supplied client must review their tenant resolver and tenant data: tenant discrimination and routing now follow the configured mode instead of `NONE`. +* `grails.gorm` and `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot. Configuration that was previously ignored on this path — `failOnError`, the default mapping and constraints, flush behaviour and multi-tenancy — now takes effect. See <> before upgrading an application that supplies its own client. * The index build reports what it created, what was already present and how long it took, in one summary line per build ==== 7.1 diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc index c825db8f4bd..cd806673ee8 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc @@ -84,6 +84,30 @@ abstract class BookService { Please note that with autowire by-type as the default, when multiple beans for same type are found the application with throw Exception. Use the Spring `@Qualifier annotation for https://docs.spring.io/spring-framework/docs/5.3.10/reference/html/core.html#beans-autowired-annotation-qualifiers[Fine-tuning Annotation Based Autowiring with Qualifiers]. +==== Configuration Is Now Applied to an Externally-Supplied Client + +When GORM is handed a `MongoClient` that it did not create — which is what happens whenever a `MongoClient` bean is already present, as with Spring Boot's MongoDB auto-configuration — it built its settings from the defaults and applied only the database name. Everything else configured under `grails.gorm` and `grails.mongodb` was silently ignored on that path. Those settings are now applied, exactly as they always were when GORM creates the client itself. + +Nothing in your configuration changes. What changes is that configuration you already have starts taking effect: + +* `grails.gorm.failOnError` — an invalid `save()` now throws `ValidationException` instead of returning `null`. An application that has had this set for years while running without it will start throwing on the first invalid save. +* `grails.gorm.default.mapping` and `grails.gorm.default.constraints` — now applied to every domain class. +* `grails.gorm.autoFlush`, `grails.gorm.markDirty` and `grails.gorm.flushMode` — now honoured. +* `grails.gorm.multiTenancy` / `grails.mongodb.multiTenancy` — tenant discrimination and routing now follow the configured mode instead of `NONE`, so review your tenant resolver and your existing data before upgrading. + +Multi-tenancy needs one extra step on this path. A `TenantResolver` registered as a Spring bean is injected into the connection source factory, and there is no factory involved when the client is supplied, so the bean is never consulted. Configure the resolver by class instead: + +[source,yaml] +---- +grails: + gorm: + multiTenancy: + mode: DISCRIMINATOR + tenantResolverClass: com.example.MyTenantResolver +---- + +Without it the mode is active but the resolver is `NoTenantResolver`, which throws `TenantNotFoundException` on every tenant-scoped operation. Settings under `grails.mongodb` continue to take precedence over the `grails.gorm` equivalents. + ==== MongoClient Lifecycle for an Externally-Supplied Client When you construct a `MongoDatastore` with a `MongoClient` that you created yourself, GORM no longer closes that client when the datastore shuts down. The client is owned by whoever created it, so closing it is now your responsibility. From 423ac9334582a47c77a35029dda079c47913acd5 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 09:24:31 -0700 Subject: [PATCH 13/28] Pin the camel case setting names against a Spring Boot environment The existing feature asserted that a kebab-case grails.mongodb key is ignored, but through a bare MapPropertySource, which has no relaxed binding to begin with and so could not tell whether an application would see a difference. The new feature builds the settings from a StandardEnvironment with Boot's ConfigurationPropertySources attached, which is what an application hands GORM. Boot's attached source only answers names in canonical form, and a camel case name is not one, so the lookup falls through to the underlying sources and matches literally. The kebab-case value is present under its own name and never found under the one the settings builder asks for. --- .../MongoConnectionSourceSettingsSpec.groovy | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy index 374d634f523..c92e092c05e 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoConnectionSourceSettingsSpec.groovy @@ -22,6 +22,9 @@ import com.mongodb.ReadPreference import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettings import org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettingsBuilder +import org.springframework.boot.context.properties.source.ConfigurationPropertySources +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.StandardEnvironment import spock.lang.Specification /** * Created by graemerocher on 29/06/16. @@ -108,6 +111,29 @@ class MongoConnectionSourceSettingsSpec extends Specification { settings.buildIndexesAsync } + void "test a kebab-case setting name is not relaxed-bound under Spring Boot either"() { + given: "the environment a Spring Boot application hands GORM, with Boot's relaxed-binding source attached" + def environment = new StandardEnvironment() + environment.propertySources.addLast(new MapPropertySource('application.yml', [ + 'grails.mongodb.database-name' : 'kebabDb', + 'grails.mongodb.build-indexes' : 'false', + 'grails.mongodb.build-indexes-async': 'true' + ] as Map)) + ConfigurationPropertySources.attach(environment) + + expect: "the value is there under the name it was written with, but not under the one GORM asks for" + environment.getProperty('grails.mongodb.database-name') == 'kebabDb' + environment.getProperty('grails.mongodb.databaseName') == null + + when: + def settings = new MongoConnectionSourceSettingsBuilder(environment).build() + + then: "the settings are looked up by their camel case names, which Boot's source does not answer" + settings.databaseName == new MongoConnectionSourceSettings().databaseName + settings.buildIndexes + !settings.buildIndexesAsync + } + void "test mongo client settings builder with URL"() { when:"using a property resolver" Map myMap = ['grails.mongodb.url': 'mongodb://foo:bar@mycompany/mydb?maxPoolSize=5'] From 4a4733ac096fe4c3eb0451485a6d9e79fa56372f Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 09:26:01 -0700 Subject: [PATCH 14/28] Read a compound index's attributes without consuming the declaration The mapped form is one instance shared by the parent datastore and every per-connection child, and each builds from it. The build took the compound index's indexAttributes out of the declaration with remove(), so the first build to reach it got the declared options and every later build saw a compound index with none. With two connections and the default synchronous build, the children build inside the parent's constructor before the parent does: the second connection got the unique index it was declared with and the default connection a plain one on the same keys. That predates this branch; running the builds on background threads made it a data race as well. The attributes are now read from a copy, and the declaration is left as it was. --- .../mapping/mongo/MongoDatastore.java | 16 ++-- .../BuildIndexesSharedDeclarationSpec.groovy | 79 +++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesSharedDeclarationSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 32965c43c34..065e3204a91 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -1173,15 +1173,13 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu } for (Map compoundIndex : mappedForm.getCompoundIndices()) { - - Map indexAttributes = null; - if (compoundIndex.containsKey(INDEX_ATTRIBUTES)) { - Object o = compoundIndex.remove(INDEX_ATTRIBUTES); - if (o instanceof Map) { - indexAttributes = (Map) o; - } - } - Document indexDef = new Document(compoundIndex); + // A copy, because the declaration is shared: every connection builds from the same + // mapping, and taking the attributes out of the original would leave the next build + // an index with none. + Map declaration = new LinkedHashMap(compoundIndex); + Object attributes = declaration.remove(INDEX_ATTRIBUTES); + Map indexAttributes = attributes instanceof Map ? (Map) attributes : null; + Document indexDef = new Document(declaration); createOrUpdateIndex(entity, collection, indexDef, indexAttributes, "compound index with definition [" + indexDef + "]", summary, existingIndexes); } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesSharedDeclarationSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesSharedDeclarationSpec.groovy new file mode 100644 index 00000000000..cd441f40d8a --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesSharedDeclarationSpec.groovy @@ -0,0 +1,79 @@ +/* + * 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.connections + +import grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.connections.ConnectionSource +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * Every connection builds its indexes from the same mapping declaration, so reading the declaration must + * leave it as it was. A compound index's options live inside the declaration itself, under + * {@code indexAttributes}, and a build that consumed them would hand the first connection a unique index + * and every later one a plain index on the same keys. + */ +class BuildIndexesSharedDeclarationSpec extends AutoStartedMongoSpec { + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + datastore = new MongoDatastore([ + 'grails.mongodb.url' : "mongodb://${mongoHost}:${mongoPort}/sharedDefaultDb" as String, + 'grails.mongodb.connections': [ + 'second': ['url': "mongodb://${mongoHost}:${mongoPort}/sharedSecondDb" as String] + ] + ] as Map, SharedDeclarationThing) + } + + private static Map compoundIndexOn(def staticApi) { + staticApi.collection.listIndexes().find { it.key == [name: 1, age: -1] } as Map + } + + void "test every connection gets the compound index options that were declared"() { + expect: "the default connection's compound index is unique" + compoundIndexOn(SharedDeclarationThing)?.unique == true + + and: "and so is the other connection's, built from the same declaration" + compoundIndexOn(SharedDeclarationThing.second)?.unique == true + } +} + +@Entity +class SharedDeclarationThing { + String name + Integer age + + static mapping = { + version false + collection 'sharedDeclarationThing' + connection ConnectionSource.ALL + compoundIndex name: 1, age: -1, indexAttributes: [unique: true] + } +} From 8a7e1ff4c49915d8b8658950caf568e9ae8000af Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 09:27:52 -0700 Subject: [PATCH 15/28] Keep the mapping context's database when the client is supplied Seeding a supplied client's settings from the configuration brought the configured connection string with it, and AbstractMongoConnectionSourceSettings prefers that string's database over the database name. So with grails.mongodb.url naming one database, a caller that passed a MongoMappingContext built for another now had every entity mapped into the URL's database - on a path that had always used the mapping context's. The URL is a connection detail, and this client arrives already connected, so it is dropped here. That is what the comment above the line already said happened. --- .../mapping/mongo/MongoDatastore.java | 4 +++- .../SuppliedMongoClientSettingsSpec.groovy | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 065e3204a91..501577e9af6 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -1581,8 +1581,10 @@ protected static ConnectionSources c // 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. + // unused - this client is already connected - so a configured URL is dropped: its database would + // otherwise take precedence over the mapping context's, which is the one this path always used. MongoConnectionSourceSettings settings = buildConnectionSourceSettings(configuration); + settings.url(null); settings.setDatabaseName(mappingContext.getDefaultDatabaseName()); ConnectionSource defaultConnectionSource = new DefaultConnectionSource<>(ConnectionSource.DEFAULT, mongoClient, settings, closeable); return new InMemoryConnectionSources<>(defaultConnectionSource, new MongoConnectionSourceFactory(), configuration); diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy index f32d7405f0b..b929aa179bd 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy @@ -26,8 +26,10 @@ import spock.lang.AutoCleanup import spock.lang.Shared import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.mongo.MongoDatastore +import org.grails.datastore.mapping.mongo.config.MongoMappingContext import org.grails.datastore.mapping.mongo.config.MongoSettings import org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode @@ -73,6 +75,24 @@ class SuppliedMongoClientSettingsSpec extends AutoStartedMongoSpec { datastore.isTransactionsEnabled() } + void "test the database of a supplied mapping context is not overridden by a configured URL"() { + given: "a mapping context the caller built for one database, and configuration whose URL names another" + def mappingContext = new MongoMappingContext('mappingContextDb') + def configuration = DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.url': 'mongodb://localhost/urlDb' + ]) + + when: "the datastore is built on the supplied client" + def contextDatastore = new MongoDatastore(mongoClient, configuration, mappingContext, + new DefaultApplicationEventPublisher()) + + then: "the connection details in the configuration go unused, the database name among them" + contextDatastore.defaultDatabase == 'mappingContextDb' + + cleanup: + contextDatastore?.close() + } + void "test a supplied client applies #prefix multi-tenancy mode #mode"() { given: def configuration = DatastoreUtils.createPropertyResolver([ From 4c074122ebc880e9610f657d161a304a0393e408 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 09:28:31 -0700 Subject: [PATCH 16/28] Pin the tenant resolver a supplied client ends up with The upgrade note tells an application supplying its own client to name its tenant resolver through tenantResolverClass, and says that without it the resolver is NoTenantResolver. It is the security-relevant part of that note, and nothing exercised it: the discriminator isolation feature goes through withTenant, which never consults the resolver. Both halves are now asserted: the named class is what the datastore resolves with, and an unnamed one leaves NoTenantResolver in place. --- .../SuppliedMongoClientSettingsSpec.groovy | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy index b929aa179bd..973e468fd24 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SuppliedMongoClientSettingsSpec.groovy @@ -32,6 +32,8 @@ import org.grails.datastore.mapping.mongo.MongoDatastore import org.grails.datastore.mapping.mongo.config.MongoMappingContext import org.grails.datastore.mapping.mongo.config.MongoSettings import org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode +import org.grails.datastore.mapping.multitenancy.resolvers.NoTenantResolver +import org.grails.datastore.mapping.multitenancy.resolvers.SystemPropertyTenantResolver /** * An application that hands GORM an existing {@code MongoClient} - which is what happens whenever a @@ -114,6 +116,29 @@ class SuppliedMongoClientSettingsSpec extends AutoStartedMongoSpec { [prefix, mode] << [['grails.gorm', 'grails.mongodb'], MultiTenancyMode.values().toList()].combinations() } + void "test a supplied client takes its tenant resolver from tenantResolverClass, and only from there"() { + when: "the resolver is named by class, the only way to supply one when the client is supplied" + def named = new MongoDatastore(mongoClient, DatastoreUtils.createPropertyResolver([ + 'grails.gorm.multiTenancy.mode' : 'DISCRIMINATOR', + 'grails.gorm.multiTenancy.tenantResolverClass': SystemPropertyTenantResolver.name + ]), new Class[0]) + + then: + named.tenantResolver instanceof SystemPropertyTenantResolver + + when: "no resolver is named" + def unnamed = new MongoDatastore(mongoClient, DatastoreUtils.createPropertyResolver([ + 'grails.gorm.multiTenancy.mode': 'DISCRIMINATOR' + ]), new Class[0]) + + then: "the mode is active with a resolver that refuses every tenant-scoped operation" + unnamed.tenantResolver instanceof NoTenantResolver + + cleanup: + named?.close() + unnamed?.close() + } + void "test MongoDB tenancy settings override the global GORM fallback for a supplied client"() { when: def tenantDatastore = new MongoDatastore(mongoClient, DatastoreUtils.createPropertyResolver([ From f4bb9466e60c004845804da7798e1d6bf8a94221 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 09:36:44 -0700 Subject: [PATCH 17/28] Hold the per-connection children in a ConcurrentHashMap The copy close() iterated did not protect anything: new ArrayList<>(values()) iterates the map exactly as the loop it replaced did, so a child added by the connection sources listener during close() could still throw ConcurrentModificationException out of it, and a concurrent put into a plain LinkedHashMap can corrupt the table outright. A ConcurrentHashMap tolerates the listener writing while close() iterates, so the copy and the comment claiming it helped both go. Nothing depends on the insertion order the LinkedHashMap kept: close() is the only place the map is iterated, and it shuts every child down regardless of order. --- .../grails/datastore/mapping/mongo/MongoDatastore.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 501577e9af6..20f8baedac0 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -192,7 +192,11 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. protected final GormEnhancer gormEnhancer; protected final ConnectionSources connectionSources; protected final FlushModeType defaultFlushMode; - protected final Map datastoresByConnectionSource = new LinkedHashMap<>(); + /** + * Concurrent because it is written by the connection sources listener, which can add a child for a + * connection registered at runtime, while {@link #close()} may be iterating it. + */ + protected final Map datastoresByConnectionSource = new ConcurrentHashMap<>(); protected final MultiTenancySettings.MultiTenancyMode multiTenancyMode; protected final TenantResolver tenantResolver; protected final AutoTimestampEventListener autoTimestampEventListener; @@ -1499,9 +1503,7 @@ private boolean ownsClient() { 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())) { + for (MongoDatastore datastore : datastoresByConnectionSource.values()) { if (datastore != this) { datastore.shutDownIndexBuild(); } From fd13f1a3d663f52daca828a606c1e3fc5f3c772b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 09:38:58 -0700 Subject: [PATCH 18/28] Read captured log events through a copy-on-write list The five specs asserting on the index build's log lines read a logback ListAppender's list while the build threads were still appending to it. AppenderBase synchronises the append but not the read, so iterating the list while a gorm-mongo-index-build thread logged could throw ConcurrentModificationException rather than pass or fail - and those threads can outlive the datastore that started them, with the specs running in parallel forks. A shared CapturedLog now backs the appender with a CopyOnWriteArrayList, hands out a snapshot, and puts the logger's level back when closed. It replaces the attach/level/restore sequence each of the five specs carried on its own. --- .../BuildIndexesBackgroundFailureSpec.groovy | 27 ++----- .../BuildIndexesFailureSummarySpec.groovy | 26 ++----- .../mongo/BuildIndexesSummaryLogSpec.groovy | 23 ++---- ...BuildIndexesUnreadableIndexListSpec.groovy | 25 ++----- .../datastore/gorm/mongo/CapturedLog.groovy | 70 +++++++++++++++++++ .../BuildIndexesPerConnectionSpec.groovy | 20 ++---- 6 files changed, 96 insertions(+), 95 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/CapturedLog.groovy diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy index ab3eff9b850..018be2a56aa 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy @@ -22,14 +22,10 @@ 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 @@ -49,13 +45,7 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { MongoClient realClient @Shared - Logger datastoreLogger - - @Shared - ListAppender logged = new ListAppender<>() - - @Shared - Level previousLevel + CapturedLog log @Override boolean shouldInitializeDatastore() { @@ -64,16 +54,11 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { 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) + log = new CapturedLog('org.grails.datastore.mapping', Level.DEBUG) } void cleanupSpec() { - datastoreLogger?.detachAppender(logged) - datastoreLogger?.level = previousLevel + log?.close() realClient?.close() } @@ -99,7 +84,7 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { and: "the failure is reported at error level, since nothing else would surface it" conditions.eventually { - assert logged.list.any { + assert log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('The background index build failed') } } @@ -131,7 +116,7 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { then: "the interruption is reported as the shutdown it is, not as a failure" conditions.eventually { - assert logged.list.any { + assert log.events.any { it.level == Level.DEBUG && it.formattedMessage.contains('abandoned because the datastore is shutting down') } } @@ -141,7 +126,7 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { } private int backgroundBuildFailures() { - logged.list.count { + log.events.count { it.level == Level.ERROR && it.formattedMessage.contains('The background index build failed') } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy index 668c81fd338..c9b7f713d82 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesFailureSummarySpec.groovy @@ -19,14 +19,11 @@ package org.grails.datastore.gorm.mongo 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.client.MongoClient import com.mongodb.client.MongoClients import grails.gorm.annotation.Entity import org.bson.Document -import org.slf4j.LoggerFactory import spock.lang.AutoCleanup import spock.lang.Shared @@ -50,13 +47,7 @@ class BuildIndexesFailureSummarySpec extends AutoStartedMongoSpec { MongoClient setupClient @Shared - Logger datastoreLogger - - @Shared - ListAppender logged = new ListAppender<>() - - @Shared - Level previousLevel + CapturedLog log @Override boolean shouldInitializeDatastore() { @@ -75,23 +66,18 @@ class BuildIndexesFailureSummarySpec extends AutoStartedMongoSpec { database.getCollection('rejectedThing').insertOne(new Document('code', 'first')) - datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger - previousLevel = datastoreLogger.level - datastoreLogger.level = Level.INFO - logged.start() - datastoreLogger.addAppender(logged) + log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) datastore = new MongoDatastore(['grails.mongodb.url': url] as Map, ConflictingThing, RejectedThing) } void cleanupSpec() { - datastoreLogger?.detachAppender(logged) - datastoreLogger?.level = previousLevel + log?.close() setupClient?.close() } private List eventsForThisDatabase() { - logged.list.findAll { it.formattedMessage.contains("database [$DATABASE]") } + log.events.findAll { it.formattedMessage.contains("database [$DATABASE]") } } void "test an index declaration the server refuses does not stop the datastore from starting"() { @@ -121,8 +107,8 @@ class BuildIndexesFailureSummarySpec extends AutoStartedMongoSpec { summary.formattedMessage.contains('2 failed') and: "the failures themselves were reported individually, naming the entity each came from" - logged.list.any { it.level == Level.ERROR && it.formattedMessage.contains('ConflictingThing') } - logged.list.any { it.level == Level.ERROR && it.formattedMessage.contains('RejectedThing') } + log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('ConflictingThing') } + log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('RejectedThing') } } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy index b9c675fbb92..bb99ba16c2b 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesSummaryLogSpec.groovy @@ -19,11 +19,7 @@ package org.grails.datastore.gorm.mongo 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 grails.gorm.annotation.Entity -import org.slf4j.LoggerFactory import spock.lang.AutoCleanup import spock.lang.Shared @@ -45,13 +41,7 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { MongoDatastore datastore @Shared - Logger datastoreLogger - - @Shared - ListAppender logged = new ListAppender<>() - - @Shared - Level previousLevel + CapturedLog log @Shared List startupMessages @@ -63,11 +53,7 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { void setupSpec() { // Capture both inherited and MongoDatastore-specific logging categories. - datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger - previousLevel = datastoreLogger.level - datastoreLogger.level = Level.INFO - logged.start() - datastoreLogger.addAppender(logged) + log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) datastore = new MongoDatastore( ['grails.mongodb.url': dbContainer.getReplicaSetUrl(DATABASE)] as Map, @@ -78,8 +64,7 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { } void cleanupSpec() { - datastoreLogger?.detachAppender(logged) - datastoreLogger?.level = previousLevel + log?.close() } /** @@ -87,7 +72,7 @@ class BuildIndexesSummaryLogSpec extends AutoStartedMongoSpec { * the database this specification uses rather than by being the only messages logged. */ private List messagesForThisDatabase() { - logged.list.collect { it.formattedMessage }.findAll { it.contains("database [$DATABASE]") } + log.events.collect { it.formattedMessage }.findAll { it.contains("database [$DATABASE]") } } void "test a successful index build logs one summary of what it applied and what it cost"() { diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy index 1c94b02ba2b..9f4d9fbc456 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy @@ -21,16 +21,12 @@ package org.grails.datastore.gorm.mongo 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 com.mongodb.client.model.IndexOptions import org.bson.Document -import org.slf4j.LoggerFactory import spock.lang.AutoCleanup import spock.lang.Shared @@ -56,13 +52,7 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { MongoClient realClient @Shared - Logger datastoreLogger - - @Shared - ListAppender logged = new ListAppender<>() - - @Shared - Level previousLevel + CapturedLog log @Override boolean shouldInitializeDatastore() { @@ -77,11 +67,7 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { realClient.getDatabase(DATABASE).getCollection('unlistableConflictThing') .createIndex(new Document('created', 1), new IndexOptions().expireAfter(999L, TimeUnit.SECONDS)) - datastoreLogger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger - previousLevel = datastoreLogger.level - datastoreLogger.level = Level.INFO - logged.start() - datastoreLogger.addAppender(logged) + log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) MongoClient cannotList = FailingMongoClient.wrap(realClient, 'listIndexes') { throw new MongoException('not authorized on unlistableIndexDb to execute command listIndexes') @@ -92,8 +78,7 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { } void cleanupSpec() { - datastoreLogger?.detachAppender(logged) - datastoreLogger?.level = previousLevel + log?.close() realClient?.close() } @@ -104,7 +89,7 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { void "test the summary falls back to reporting how many declarations were applied"() { given: - def summary = logged.list.find { it.formattedMessage.contains("database [$DATABASE]") } + def summary = log.events.find { it.formattedMessage.contains("database [$DATABASE]") } expect: "the build reported itself" summary != null @@ -119,7 +104,7 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { void "test a conflict that cannot be reconciled without the index list is reported, not swallowed"() { expect: - logged.list.any { + log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('could not inspect existing indexes') && it.formattedMessage.contains('UnlistableConflictThing') diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/CapturedLog.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/CapturedLog.groovy new file mode 100644 index 00000000000..7f5b0f1152c --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/CapturedLog.groovy @@ -0,0 +1,70 @@ +/* + * 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.CopyOnWriteArrayList + +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 groovy.transform.CompileStatic +import org.slf4j.LoggerFactory + +/** + * Captures what one logger emits for the length of a test, then puts the logger back as it was. + * + *

The index build logs from background threads, which can keep running while a test reads what was + * captured and can outlive the datastore that started them. A plain {@link ListAppender} synchronises + * the append but not the read, so iterating it while a build thread logs can throw + * {@link java.util.ConcurrentModificationException}. The list here is copy-on-write, so every read sees + * a stable snapshot. + */ +@CompileStatic +class CapturedLog implements AutoCloseable { + + private final Logger logger + + private final Level previousLevel + + private final ListAppender appender = new ListAppender<>() + + CapturedLog(String loggerName, Level level) { + logger = LoggerFactory.getLogger(loggerName) as Logger + previousLevel = logger.level + logger.level = level + appender.list = new CopyOnWriteArrayList() + appender.start() + logger.addAppender(appender) + } + + /** + * @return what has been captured so far + */ + List getEvents() { + new ArrayList<>(appender.list) + } + + @Override + void close() { + logger.detachAppender(appender) + appender.stop() + logger.level = previousLevel + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy index ec59018281c..cb4261b7fde 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -23,17 +23,14 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference 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.client.MongoClient import grails.gorm.annotation.Entity -import org.slf4j.LoggerFactory import spock.lang.AutoCleanup import spock.lang.Shared import org.apache.grails.testing.mongo.AutoStartedMongoSpec import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher +import org.grails.datastore.gorm.mongo.CapturedLog import org.grails.datastore.gorm.mongo.FailingMongoClient import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.core.connections.ConnectionSource @@ -81,12 +78,7 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { def buildReached = new CountDownLatch(1) def releaseBuild = new CountDownLatch(1) def worker = new AtomicReference() - Logger logger = LoggerFactory.getLogger('org.grails.datastore.mapping') as Logger - Level previousLevel = logger.level - def logged = new ListAppender() - logger.level = Level.DEBUG - logged.start() - logger.addAppender(logged) + def log = new CapturedLog('org.grails.datastore.mapping', Level.DEBUG) def factory = new MongoConnectionSourceFactory() { @Override ConnectionSource create(String name, MongoConnectionSourceSettings settings) { @@ -130,11 +122,11 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { !worker.get().alive and: "the abandoned build is classified as shutdown rather than an error" - logged.list.any { + log.events.any { it.threadName == worker.get().name && it.level == Level.DEBUG && it.formattedMessage.contains('abandoned because the datastore is shutting down') } - !logged.list.any { + !log.events.any { it.threadName == worker.get().name && it.level == Level.ERROR } @@ -142,9 +134,7 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { releaseBuild.countDown() parent?.close() worker.get()?.join(10000) - logger.detachAppender(logged) - logger.level = previousLevel - logged.stop() + log.close() where: addedAtRuntime << [false, true] From 848282548a9abe36845bd79f9574f0da7157dbb2 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 10:08:01 -0700 Subject: [PATCH 19/28] Report, interrupt and resume the index build through its whole lifecycle Addresses the index-build findings from review, all in the same build loop: - A build that stops partway - a lost connection or a timeout thrown past the MongoCommandException handling - now still logs its summary, as "did not finish ... at N of M domain class(es)", at WARN, or at DEBUG when it was abandoned for shutdown. A build on the calling thread still propagates the exception, after the summary. - stop() interrupts a background build before it closes the client, instead of leaving it to fail against the closed client and be logged as a failure, and records that it was cut short. start() builds a fresh executor, since a shut down one is terminal, and runs the build again. - buildIndex() after close() logs a warning instead of throwing RejectedExecutionException; while stopped it defers the build to start(). - A conflict re-lists the collection's indexes instead of reconciling against the listing taken before the build started, so an index created since - by another instance, or another connection building concurrently - is found and reconciled rather than reported as a failure. - An index recreateOnConflict dropped and built again is counted as recreated. It was counted as already present, which is what a restart that changed nothing reports, for what may have been a full rebuild. contains() keeps matching any text index: MongoDB reports an existing text index under a synthetic key, so a strict comparison would report every unchanged text index as created. - The listIndexes that separates created from already-present indexes is only issued when the summary will be logged, so an application with the logger above INFO does not pay a round trip per indexed collection for a line it never sees. - initializeIndices(PersistentEntity) documents that an asynchronous build can call it before a subclass constructor has finished. Moving the build out of the constructor would break standalone use, where nothing calls a lifecycle callback. - The worker comes from Spring's CustomizableThreadFactory rather than a class of its own, and is named gorm-mongo-index-build--. FailingMongoClient now hands its interceptor a closure that makes the real call, so a test can let some calls through and fail or alter others. --- .../mapping/mongo/MongoDatastore.java | 381 +++++++++++++----- .../gorm/mongo/BuildIndexesAsyncSpec.groovy | 2 +- .../BuildIndexesClassificationCostSpec.groovy | 112 +++++ .../mongo/BuildIndexesLifecycleSpec.groovy | 178 ++++++++ .../BuildIndexesRecreateSummarySpec.groovy | 95 +++++ .../BuildIndexesStaleSnapshotSpec.groovy | 124 ++++++ .../BuildIndexesUnfinishedSummarySpec.groovy | 143 +++++++ .../gorm/mongo/FailingMongoClient.groovy | 23 +- .../BuildIndexesPerConnectionSpec.groovy | 2 +- .../docs/asciidoc/querying/queryIndexes.adoc | 18 +- 10 files changed, 954 insertions(+), 124 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesRecreateSummarySpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesStaleSnapshotSpec.groovy create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnfinishedSummarySpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 20f8baedac0..a448c5696f8 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -27,9 +27,10 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadFactory; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import groovy.lang.Closure; @@ -55,6 +56,7 @@ import org.springframework.context.SmartLifecycle; import org.springframework.context.support.StaticMessageSource; import org.springframework.core.env.PropertyResolver; +import org.springframework.scheduling.concurrent.CustomizableThreadFactory; import org.springframework.transaction.PlatformTransactionManager; import grails.gorm.multitenancy.Tenants; @@ -179,8 +181,17 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. * {@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()}. + * + *

Not final: a shut down executor cannot be restarted, so {@link #start()} replaces the one + * {@link #stop()} shut down. */ - private final ExecutorService indexBuildExecutor; + private volatile ExecutorService indexBuildExecutor; + + /** Background builds submitted and not yet finished, which is how {@link #stop()} knows it cut one short. */ + private final AtomicInteger indexBuildsInFlight = new AtomicInteger(); + + /** Set when a build was cut short by {@link #stop()} or requested while stopped, for {@link #start()} to run. */ + private volatile boolean indexBuildPending; /** The summary for the current build, scoped to its thread so the protected index hook is preserved. */ private final ThreadLocal indexBuildSummary = new ThreadLocal<>(); @@ -239,8 +250,7 @@ public MongoDatastore(final ConnectionSources(), - new IndexBuildThreadFactory(defaultConnectionSource.getName())) : + newIndexBuildExecutor(defaultConnectionSource.getName()) : null; codecRegistry = CodecRegistries.fromRegistries( CodecRegistries.fromProviders(new CodecExtensions(), new PersistentEntityCodeRegistry()), @@ -617,80 +627,151 @@ public void buildIndex() { MongoSettings.SETTING_BUILD_INDEXES); return; } - if (indexBuildExecutor == null) { - buildDeclaredIndexes(); + ExecutorService executor = this.indexBuildExecutor; + if (executor == null) { + runIndexBuild(null); 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(() -> { + if (!executor.isShutdown()) { + 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); + indexBuildsInFlight.incrementAndGet(); try { - buildDeclaredIndexes(); + executor.execute(() -> { + try { + runIndexBuild(executor); + } + finally { + indexBuildsInFlight.decrementAndGet(); + } + }); + return; } - 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); - } + catch (RejectedExecutionException e) { + // Shut down between the check and the submission. + indexBuildsInFlight.decrementAndGet(); } - }); + } + if (running) { + LOG.warn("An index build was requested after the datastore was closed, so it was not started."); + } + else { + indexBuildPending = true; + LOG.info("An index build was requested while the datastore is stopped; it will run when the datastore is restarted."); + } } /** - * 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. + * Runs one build and reports it, whether it finished or not. + * + * @param executor the executor running it, or {@code null} for a build on the caller's thread, whose + * failure is left to propagate to the caller */ - private void buildDeclaredIndexes() { + private void runIndexBuild(ExecutorService executor) { long startedAt = System.nanoTime(); - IndexBuildSummary summary = new IndexBuildSummary(); - IndexBuildSummary previousSummary = indexBuildSummary.get(); - indexBuildSummary.set(summary); + // Telling a created index from one that was already there costs a listIndexes per indexed + // collection, and its only product is the summary, so it is skipped when that would not be logged. + IndexBuildSummary summary = new IndexBuildSummary(LOG.isInfoEnabled()); + Throwable failure = null; 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; - - summary.entities++; - initializeIndices(entity); - } - } + buildDeclaredIndexes(summary); } - finally { - if (previousSummary == null) { - indexBuildSummary.remove(); + catch (Throwable e) { + failure = e; + } + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + if (failure == null) { + logFinishedIndexBuild(summary, elapsedMillis); + return; + } + if (executor == null) { + logUnfinishedIndexBuild(summary, elapsedMillis, false); + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; } - else { - indexBuildSummary.set(previousSummary); + if (failure instanceof Error) { + throw (Error) failure; } + throw new IllegalStateException(failure); } - long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); - if (summary.applied() == 0 && summary.failures == 0) { + // Nothing is waiting on this thread, so an error that would have failed startup has to be + // reported here or it is lost entirely. + boolean abandoned = executor.isShutdown() || Thread.currentThread().isInterrupted(); + if (abandoned) { + // 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: {}", + failure.toString(), failure); + } + else { + LOG.error("The background index build failed: {}. The application is running without the " + + "indexes that were not created.", failure.getMessage(), failure); + } + logUnfinishedIndexBuild(summary, elapsedMillis, abandoned); + } + + private void logFinishedIndexBuild(IndexBuildSummary summary, long elapsedMillis) { + if (summary.applied() == 0 && summary.recreated == 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); + defaultDatabase, elapsedMillis, summary.describe(), summary.entities); } else { - LOG.warn("Index build for database [{}] finished in {}ms: {}, {} failed, from {} domain class(es). " + + LOG.warn("Index build for database [{}] finished in {}ms: {}, from {} domain class(es). " + "The failures are reported above.", - defaultDatabase, elapsedMillis, outcome, summary.failures, summary.entities); + defaultDatabase, elapsedMillis, summary.describe(), summary.entities); + } + } + + /** + * Reports how far a build got before it stopped, which is what an operator needs when a background + * build fails partway: the error names the cause, this says what was applied before it. + */ + private void logUnfinishedIndexBuild(IndexBuildSummary summary, long elapsedMillis, boolean abandoned) { + String message = "Index build for database [{}] did not finish, stopping after {}ms at {} of {} domain class(es): {}"; + if (abandoned) { + LOG.debug(message, defaultDatabase, elapsedMillis, summary.entities, summary.entitiesTotal, summary.describe()); + } + else { + LOG.warn(message, defaultDatabase, elapsedMillis, summary.entities, summary.entitiesTotal, summary.describe()); + } + } + + /** + * Creates and reconciles the indexes declared by every entity mapped to this datastore. MongoDB answers + * each {@code createIndex} only once the index exists, so the time this takes is the time the caller — + * startup, or the background build thread — actually spends waiting. + */ + private void buildDeclaredIndexes(IndexBuildSummary summary) { + List entities = new ArrayList<>(); + for (PersistentEntity entity : this.mappingContext.getPersistentEntities()) { + // Only create Mongo templates for entities that are mapped with Mongo + if (!entity.isExternal() && + !(entity.isMultiTenant() && multiTenancyMode == MultiTenancySettings.MultiTenancyMode.SCHEMA)) { + entities.add(entity); + } + } + summary.entitiesTotal = entities.size(); + IndexBuildSummary previousSummary = indexBuildSummary.get(); + indexBuildSummary.set(summary); + try { + for (PersistentEntity entity : entities) { + initializeIndices(entity); + summary.entities++; + } + } + finally { + if (previousSummary == null) { + indexBuildSummary.remove(); + } + else { + indexBuildSummary.set(previousSummary); + } } } @@ -701,9 +782,10 @@ private void buildDeclaredIndexes() { * {@code numIndexesAfter} the server reports — so what was there beforehand is what distinguishes an * index this build created from one it merely confirmed. * - *

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. + *

Listed lazily so that an entity declaring no indexes costs no round trip, and not at all when the + * summary is not being classified. Successful changes are recorded so later declarations on the same + * keys see the current name and TTL, and are not counted as new indexes. A conflict re-lists instead + * of trusting the snapshot: whatever the server reports as conflicting may have appeared since. */ private static final class ExistingIndexes { @@ -724,17 +806,25 @@ private ExistingIndexes(com.mongodb.client.MongoCollection collection, * @return the known current indexes, or {@code null} if they could not be listed */ private List 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 listed ? indexes : refresh(); + } + + /** + * Lists the indexes from the server now, replacing what was known. + * + * @return the current indexes, or {@code null} if they could not be listed + */ + private List refresh() { + 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); + indexes = null; + summary.classified = false; } return indexes; } @@ -755,6 +845,9 @@ private void record(Document keys, String name, Long expireAfterSeconds) { } private boolean contains(Document keys) { + if (!summary.classified) { + return false; + } List existing = get(); return existing != null && findIndexByKeyPattern(existing, keys) != null; } @@ -766,24 +859,68 @@ private boolean contains(Document keys) { */ private static final class IndexBuildSummary { + /** Domain classes whose indexes have been applied. */ private int entities; + /** Domain classes the build set out to cover. */ + private int entitiesTotal; + private int created; + /** Dropped and built again for {@code recreateOnConflict}, which costs a full build. */ + private int recreated; + private int alreadyPresent; private int failures; /** - * False once an entity's existing indexes could not be listed, which is the only thing that - * separates a created index from one that was already there. The summary then falls back to - * reporting how many declarations were applied without saying which did work. + * False when classification was not asked for, or once an entity's existing indexes could not be + * listed, which is the only thing that separates a created index from one that was already there. + * The summary then falls back to reporting how many declarations were applied without saying + * which did work. */ - private boolean classified = true; + private boolean classified; + + private IndexBuildSummary(boolean classified) { + this.classified = classified; + } + /** Declarations applied other than by a drop and recreate. */ private int applied() { return created + alreadyPresent; } + + private String describe() { + StringBuilder outcome = new StringBuilder(); + if (classified) { + outcome.append(created).append(" created, "); + if (recreated > 0) { + outcome.append(recreated).append(" recreated, "); + } + outcome.append(alreadyPresent).append(" already present"); + } + else { + outcome.append(applied()).append(" index declaration(s) applied"); + if (recreated > 0) { + outcome.append(", ").append(recreated).append(" recreated"); + } + } + if (failures > 0) { + outcome.append(", ").append(failures).append(" failed"); + } + return outcome.toString(); + } + } + + /** How a conflicting declaration was resolved. */ + private enum Reconciliation { + /** The existing index was changed in place, with no rebuild. */ + UPDATED, + /** The existing index was dropped and the declared one built from scratch. */ + RECREATED, + /** The existing index was left as it was. */ + FAILED } /** @@ -1150,11 +1287,16 @@ protected void registerEventListeners(ConfigurableApplicationEventPublisher even * Indexes any properties that are mapped with index:true. Called for both startup builds and * entities registered later, so subclasses can customise index creation on either path. * + *

With {@code grails.mongodb.buildIndexesAsync} enabled the startup build calls this on a background + * thread, and it can do so before the constructor of a subclass has finished. An override must not + * depend on state that its own constructor or field initializers set up. + * * @param entity The entity */ protected void initializeIndices(final PersistentEntity entity) { IndexBuildSummary summary = indexBuildSummary.get(); - initializeIndices(entity, summary != null ? summary : new IndexBuildSummary()); + // Outside a build nothing reports the counts, so there is nothing to classify for. + initializeIndices(entity, summary != null ? summary : new IndexBuildSummary(false)); } private void initializeIndices(final PersistentEntity entity, final IndexBuildSummary summary) { @@ -1265,12 +1407,16 @@ private void createOrUpdateIndex(PersistentEntity entity, entity.getName(), descriptor, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)); } catch (MongoCommandException e) { if (e.getErrorCode() == INDEX_OPTIONS_CONFLICT_CODE) { - if (reconcileIndexConflict(entity, collection, existingIndexes, keys, indexOptions, - expireAfterSeconds, recreateOnConflict, descriptor, e)) { - // A conflict means an index was already on these keys; reconciling it changed the one - // that was there rather than adding one. + Reconciliation reconciliation = reconcileIndexConflict(entity, collection, existingIndexes, keys, + indexOptions, expireAfterSeconds, recreateOnConflict, descriptor, e); + if (reconciliation == Reconciliation.UPDATED) { + // An index was already on these keys and was changed in place rather than added. summary.alreadyPresent++; } + else if (reconciliation == Reconciliation.RECREATED) { + // Counted apart: a rebuild costs as much as a new index, whatever was there before. + summary.recreated++; + } else { summary.failures++; } @@ -1288,25 +1434,26 @@ private void createOrUpdateIndex(PersistentEntity entity, * 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 + * @return how the conflict was resolved */ - private boolean reconcileIndexConflict(PersistentEntity entity, + private Reconciliation reconcileIndexConflict(PersistentEntity entity, com.mongodb.client.MongoCollection collection, ExistingIndexes existingIndexes, Document keys, IndexOptions desired, Long expireAfterSeconds, boolean recreateOnConflict, String descriptor, MongoCommandException original) { - List indexes = existingIndexes.get(); + // Listed afresh: the index the server just reported may not have existed when this collection was + // first listed - another instance or another connection can have created it since. + List indexes = existingIndexes.refresh(); if (indexes == null) { LOG.error("Failed to create index for entity [{}] {} and could not inspect existing indexes: {}", entity.getName(), descriptor, original.getMessage(), original); - return false; + return Reconciliation.FAILED; } Document existing = findIndexByKeyPattern(indexes, keys); if (existing == null) { LOG.error("Failed to create index for entity [{}] {}: {}", entity.getName(), descriptor, original.getMessage(), original); - return false; + return Reconciliation.FAILED; } String existingName = existing.getString("name"); @@ -1324,7 +1471,7 @@ private boolean reconcileIndexConflict(PersistentEntity entity, existingIndexes.record(keys, existingName, expireAfterSeconds); LOG.info("Updated TTL of index [{}] on entity [{}] to {}s", existingName, entity.getName(), expireAfterSeconds); - return true; + return Reconciliation.UPDATED; } catch (MongoCommandException collModError) { // collMod can't make every change (e.g. add a TTL to a non-TTL index on older // servers) — fall through to recreate (if authorised) rather than fail outright. @@ -1339,11 +1486,11 @@ private boolean reconcileIndexConflict(PersistentEntity entity, String indexName = collection.createIndex(keys, desired); existingIndexes.record(keys, indexName, expireAfterSeconds); LOG.info("Recreated index [{}] on entity [{}] {}", existingName, entity.getName(), descriptor); - return true; + return Reconciliation.RECREATED; } catch (MongoCommandException recreateError) { LOG.error("Failed to recreate index [{}] on entity [{}] {}: {}", existingName, entity.getName(), descriptor, recreateError.getMessage(), recreateError); - return false; + return Reconciliation.FAILED; } } @@ -1351,7 +1498,7 @@ private boolean reconcileIndexConflict(PersistentEntity entity, "Index conflict for entity [{}] {}: an index [{}] already exists on the same keys with different options. " + "Declare indexAttributes:[recreateOnConflict:true] to drop and recreate it. Original error: {}", entity.getName(), descriptor, existingName, original.getMessage()); - return false; + return Reconciliation.FAILED; } /** @@ -1454,12 +1601,18 @@ public void persistentEntityAdded(PersistentEntity entity) { *

A client the application supplied is left alone. Its lifecycle belongs to whoever * created it, and this datastore stays {@link #isRunning() running} so that * {@link #start()} does not later replace something it does not own. + * + *

A background index build still running is interrupted first, rather than failing against the + * closed client, and {@link #start()} runs it again. */ @Override public void stop() { if (!this.running || !ownsClient()) { return; } + if (shutDownIndexBuild()) { + indexBuildPending = true; + } this.mongo.close(); this.running = false; } @@ -1467,6 +1620,8 @@ public void stop() { /** * Builds a replacement {@link MongoClient} after a restore, using the same factory and * configuration the original was built from, so settings applied at startup still apply. + * A background index build that {@link #stop()} cut short, or that was requested while stopped, + * runs again on a fresh executor. */ @Override public void start() { @@ -1476,7 +1631,14 @@ public void start() { this.mongo = connectionSources.getFactory() .create(ConnectionSource.DEFAULT, connectionSources.getBaseConfiguration()) .getSource(); + if (this.buildIndexes && this.buildIndexesAsync) { + this.indexBuildExecutor = newIndexBuildExecutor(connectionSources.getDefaultConnectionSource().getName()); + } this.running = true; + if (indexBuildPending) { + indexBuildPending = false; + buildIndex(); + } } @Override @@ -1536,34 +1698,33 @@ 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(); + /** + * Interrupts the background index build, if there is one. A build can run for minutes, so shutdown + * must not wait for it; the server carries on building what it was asked for. + * + * @return whether a build was running or queued, and so was cut short + */ + private boolean shutDownIndexBuild() { + ExecutorService executor = this.indexBuildExecutor; + if (executor == null) { + return false; } + boolean cutShort = indexBuildsInFlight.get() > 0; + // A build still queued never runs, so it never takes itself off the count. + indexBuildsInFlight.addAndGet(-executor.shutdownNow().size()); + return cutShort; } /** - * 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. + * One worker, named after the connection it serves so that a log line or a thread dump says which + * datastore is building indexes, and released after a second without work. The worker 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 { - - private final String connectionName; - - private IndexBuildThreadFactory(String connectionName) { - this.connectionName = connectionName; - } - - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "gorm-mongo-index-build-" + connectionName); - thread.setDaemon(true); - return thread; - } + private static ExecutorService newIndexBuildExecutor(String connectionName) { + CustomizableThreadFactory threadFactory = new CustomizableThreadFactory("gorm-mongo-index-build-" + connectionName + "-"); + threadFactory.setDaemon(true); + return new ThreadPoolExecutor(0, 1, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), threadFactory); } /** diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy index cc9d283a972..2ca5f2ad741 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy @@ -129,7 +129,7 @@ class BuildIndexesAsyncSpec extends AutoStartedMongoSpec { then: "the command was issued by the datastore's own index build thread" asyncRecorder.threads.size() == 1 - asyncRecorder.threads.first().startsWith('gorm-mongo-index-build') + asyncRecorder.threads.first().startsWith('gorm-mongo-index-build-default-') and: "not by the thread that created the datastore, which did not wait for it" asyncRecorder.threads.first() != creatingThread diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy new file mode 100644 index 00000000000..86cd1514830 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy @@ -0,0 +1,112 @@ +/* + * 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.atomic.AtomicInteger + +import ch.qos.logback.classic.Level +import com.mongodb.ConnectionString +import com.mongodb.MongoClientSettings +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import com.mongodb.event.CommandListener +import com.mongodb.event.CommandStartedEvent +import grails.gorm.annotation.Entity +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.DatastoreUtils +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * Telling a created index from one that was already there costs a {@code listIndexes} per indexed + * collection, and the only thing it feeds is the summary line. An application that does not log that line + * does not pay for it. + */ +class BuildIndexesClassificationCostSpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'classificationCostDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient countingClient + + @Shared + AtomicInteger listings = new AtomicInteger() + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + countingClient = MongoClients.create(MongoClientSettings.builder() + .applyConnectionString(new ConnectionString(dbContainer.getReplicaSetUrl(DATABASE))) + .addCommandListener(new CommandListener() { + @Override + void commandStarted(CommandStartedEvent event) { + if (event.commandName == 'listIndexes') { + listings.incrementAndGet() + } + } + }) + .build()) + } + + void cleanupSpec() { + countingClient?.close() + } + + void "test the existing indexes are listed only when the summary will be logged"() { + when: "the datastore builds its indexes with the summary not logged" + def quiet = new CapturedLog('org.grails.datastore.mapping.mongo', Level.WARN) + datastore = new MongoDatastore(countingClient, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': DATABASE]), ClassifiedThing) + quiet.close() + + then: "nothing was listed" + listings.get() == 0 + + when: "the same build runs with the summary logged" + def logged = new CapturedLog('org.grails.datastore.mapping.mongo', Level.INFO) + datastore.buildIndex() + + then: "the collection is listed so the summary can say what was already there" + listings.get() == 1 + logged.events*.formattedMessage.find { it.contains("database [$DATABASE]") }.contains('0 created, 1 already present') + + cleanup: + logged?.close() + } +} + +@Entity +class ClassifiedThing { + String name + + static mapping = { + version false + collection 'classifiedThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy new file mode 100644 index 00000000000..b55d143d5c1 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy @@ -0,0 +1,178 @@ +/* + * 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 java.util.concurrent.atomic.AtomicBoolean + +import ch.qos.logback.classic.Level +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +import spock.lang.Shared +import spock.util.concurrent.PollingConditions + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.mongo.MongoDatastore +import org.grails.datastore.mapping.mongo.config.MongoSettings + +/** + * A background build belongs to the datastore that started it. Stopping the datastore for a checkpoint + * interrupts the build rather than letting it fail against the closed client, and restarting it runs the + * build again; closing it for good turns a later request for a build into a warning rather than an + * exception. + */ +class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { + + @Shared + MongoClient realClient + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl('lifecycleDb')) + } + + void cleanupSpec() { + realClient?.close() + } + + private Map asyncConfig(String database) { + ['grails.mongodb.url' : dbContainer.getReplicaSetUrl(database), + (MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true] as Map + } + + void "test a build cut short by stop() is not reported as a failure and runs again on start()"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.DEBUG) + CheckpointedDatastore.REACHED = new CountDownLatch(1) + CheckpointedDatastore.RELEASE = new CountDownLatch(1) + CheckpointedDatastore.BLOCK_ONCE.set(true) + def indexes = { -> realClient.getDatabase('checkpointDb').getCollection('checkpointedThing').listIndexes()*.key } + + when: "the background build is under way" + def datastore = new CheckpointedDatastore(asyncConfig('checkpointDb'), CheckpointedThing) + + then: + CheckpointedDatastore.REACHED.await(30, TimeUnit.SECONDS) + + when: "the datastore is stopped for a checkpoint while the build is still running" + datastore.stop() + + then: "the build is abandoned as a shutdown and reports how far it got, without a failure" + conditions.eventually { + assert log.events.any { + it.level == Level.DEBUG && it.formattedMessage.contains('database [checkpointDb] did not finish') + } + } + !log.events.any { + it.level.isGreaterOrEqual(Level.WARN) && it.formattedMessage.contains('database [checkpointDb]') + } + + and: "the declared index was not created" + !([name: 1] in indexes()) + + when: "the datastore is restarted" + datastore.start() + + then: "the build that was cut short runs again and completes" + conditions.eventually { + assert [name: 1] in indexes() + } + + cleanup: + CheckpointedDatastore.RELEASE?.countDown() + datastore?.close() + log?.close() + } + + void "test a build requested after close() is refused with a warning rather than an exception"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + def datastore = new MongoDatastore(asyncConfig('closedDb'), ClosedBuildThing) + datastore.close() + + when: + datastore.buildIndex() + + then: + notThrown(Exception) + log.events.any { + it.level == Level.WARN && it.formattedMessage.contains('requested after the datastore was closed') + } + + cleanup: + log?.close() + } +} + +/** + * Blocks the first index build in the protected hook, so that a test can act while the build is under + * way. The latches are static: with an asynchronous build the hook can run before this class's own + * constructor has finished, which is exactly what the hook's documentation warns an override about. + */ +class CheckpointedDatastore extends MongoDatastore { + + static final AtomicBoolean BLOCK_ONCE = new AtomicBoolean() + + static volatile CountDownLatch REACHED + + static volatile CountDownLatch RELEASE + + CheckpointedDatastore(Map configuration, Class... classes) { + super(configuration, classes) + } + + @Override + protected void initializeIndices(PersistentEntity entity) { + if (BLOCK_ONCE.compareAndSet(true, false)) { + REACHED.countDown() + RELEASE.await() + } + super.initializeIndices(entity) + } +} + +@Entity +class CheckpointedThing { + String name + + static mapping = { + version false + collection 'checkpointedThing' + name index: true + } +} + +@Entity +class ClosedBuildThing { + String name + + static mapping = { + version false + collection 'closedBuildThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesRecreateSummarySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesRecreateSummarySpec.groovy new file mode 100644 index 00000000000..eac95b698b6 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesRecreateSummarySpec.groovy @@ -0,0 +1,95 @@ +/* + * 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 ch.qos.logback.classic.Level +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +import org.bson.Document +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * A declaration that {@code recreateOnConflict} satisfies by dropping the index that was there and building + * the declared one costs a full build. The summary says so, instead of counting it with the indexes that + * were already present, which is what a restart that changed nothing reports. + */ +class BuildIndexesRecreateSummarySpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'recreateSummaryDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient setupClient + + @Shared + CapturedLog log + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + String url = dbContainer.getReplicaSetUrl(DATABASE) + setupClient = MongoClients.create(url) + // A text index on another field. MongoDB allows one text index per collection, so the declaration + // conflicts with it, and recreateOnConflict replaces it. + setupClient.getDatabase(DATABASE).getCollection('recreatedThing').createIndex(new Document('title', 'text')) + + log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + datastore = new MongoDatastore(['grails.mongodb.url': url] as Map, RecreatedThing) + } + + void cleanupSpec() { + log?.close() + setupClient?.close() + } + + void "test an index dropped and built again is reported as recreated, not as already present"() { + given: + String summary = log.events*.formattedMessage.find { it.contains("database [$DATABASE]") } + + expect: + summary.contains('0 created, 1 recreated, 0 already present') + + and: "the text index is now the one declared" + setupClient.getDatabase(DATABASE).getCollection('recreatedThing').listIndexes() + .find { it.key == [_fts: 'text', _ftsx: 1] }.weights == [body: 1] + } +} + +@Entity +class RecreatedThing { + String title + String body + + static mapping = { + version false + collection 'recreatedThing' + body index: true, indexAttributes: [type: 'text', recreateOnConflict: true] + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesStaleSnapshotSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesStaleSnapshotSpec.groovy new file mode 100644 index 00000000000..da90179bd15 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesStaleSnapshotSpec.groovy @@ -0,0 +1,124 @@ +/* + * 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.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +import ch.qos.logback.classic.Level +import com.mongodb.client.ListIndexesIterable +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import com.mongodb.client.model.IndexOptions +import grails.gorm.annotation.Entity +import org.bson.Document +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.DatastoreUtils +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * The build lists a collection's indexes once, before creating any, to tell a created index from one that + * was already there. An index that appears after that listing - created by another instance starting at + * the same time, or another connection building concurrently - is what the server then reports as + * conflicting, so reconciling the conflict has to look at the server again rather than at the listing. + */ +class BuildIndexesStaleSnapshotSpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'staleSnapshotDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient realClient + + @Shared + CapturedLog log + + @Shared + AtomicInteger listings = new AtomicInteger() + + @Override + boolean shouldInitializeDatastore() { + false + } + + private static ListIndexesIterable noIndexes() { + (ListIndexesIterable) Proxy.newProxyInstance(ListIndexesIterable.classLoader, + [ListIndexesIterable] as Class[], { Object proxy, Method method, Object[] args -> + if (method.name == 'into') { + return args[0] + } + throw new UnsupportedOperationException(method.name) + } as InvocationHandler) + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl(DATABASE)) + // Already on the server with a different expiry, as another instance would have left it + realClient.getDatabase(DATABASE).getCollection('staleSnapshotThing') + .createIndex(new Document('created', 1), new IndexOptions().expireAfter(999L, TimeUnit.SECONDS)) + + // The first listing predates that index; every later one is the server's answer + MongoClient staleFirstListing = FailingMongoClient.wrap(realClient, 'listIndexes') { Closure proceed -> + listings.incrementAndGet() == 1 ? noIndexes() : proceed() + } + + // Classification only lists when the summary will be logged + log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + datastore = new MongoDatastore(staleFirstListing, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': DATABASE]), StaleSnapshotThing) + } + + void cleanupSpec() { + log?.close() + realClient?.close() + } + + void "test a conflict is reconciled against the server, not the listing taken before it"() { + expect: "the stale listing was used, and the conflict listed again" + listings.get() == 2 + + and: "the index that appeared afterwards was reconciled in place to the declared expiry" + realClient.getDatabase(DATABASE).getCollection('staleSnapshotThing').listIndexes() + .find { it.key == [created: 1] }.expireAfterSeconds == 100 + + and: "it is reported as an index that was already there, with nothing failed" + log.events*.formattedMessage.find { it.contains("database [$DATABASE]") }.contains('0 created, 1 already present') + !log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('StaleSnapshotThing') } + } +} + +@Entity +class StaleSnapshotThing { + Date created + + static mapping = { + version false + collection 'staleSnapshotThing' + created index: true, indexAttributes: [expireAfterSeconds: 100] + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnfinishedSummarySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnfinishedSummarySpec.groovy new file mode 100644 index 00000000000..d85e53c3731 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnfinishedSummarySpec.groovy @@ -0,0 +1,143 @@ +/* + * 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.atomic.AtomicInteger + +import ch.qos.logback.classic.Level +import com.mongodb.MongoSocketReadException +import com.mongodb.ServerAddress +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +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 + +/** + * A build that stops partway - a dropped connection, a timeout - still reports how far it got. In a + * background build that is all an operator has to go on besides the error itself: which indexes are + * there, and which domain classes never had theirs applied. + */ +class BuildIndexesUnfinishedSummarySpec extends AutoStartedMongoSpec { + + @Shared + MongoClient realClient + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl('unfinishedDb')) + } + + void cleanupSpec() { + realClient?.close() + } + + /** + * Lets the first index through and loses the connection on the second, so the build stops after one + * of the two domain classes. + */ + private MongoClient connectionLostOnSecondIndex() { + def created = new AtomicInteger() + FailingMongoClient.wrap(realClient, 'createIndex') { Closure proceed -> + if (created.incrementAndGet() > 1) { + throw new MongoSocketReadException('Prematurely reached end of stream', new ServerAddress()) + } + proceed() + } + } + + private static String unfinishedSummary(CapturedLog log, String database) { + log.events.find { + it.level == Level.WARN && it.formattedMessage.contains("database [$database] did not finish") + }?.formattedMessage + } + + void "test a background build that stops partway reports how far it got"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + + when: + def datastore = new MongoDatastore(connectionLostOnSecondIndex(), DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.databaseName' : 'unfinishedAsyncDb', + (MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true + ]), FirstUnfinishedThing, SecondUnfinishedThing) + + then: "the failure is reported, and so is what had been applied before it" + conditions.eventually { + assert unfinishedSummary(log, 'unfinishedAsyncDb') != null + } + unfinishedSummary(log, 'unfinishedAsyncDb').contains('at 1 of 2 domain class(es): 1 created, 0 already present') + log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('The background index build failed') } + + cleanup: + datastore?.close() + log?.close() + } + + void "test a build on the calling thread that stops partway reports how far it got and still fails"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + + when: + new MongoDatastore(connectionLostOnSecondIndex(), DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.databaseName': 'unfinishedSyncDb' + ]), FirstUnfinishedThing, SecondUnfinishedThing) + + then: "the exception still fails startup, as it always has" + thrown(MongoSocketReadException) + + and: "but not before saying what was applied" + unfinishedSummary(log, 'unfinishedSyncDb').contains('at 1 of 2 domain class(es): 1 created, 0 already present') + + cleanup: + log?.close() + } +} + +@Entity +class FirstUnfinishedThing { + String name + + static mapping = { + version false + collection 'firstUnfinishedThing' + name index: true + } +} + +@Entity +class SecondUnfinishedThing { + String name + + static mapping = { + version false + collection 'secondUnfinishedThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy index 88aabfd03a4..9de4f8387e5 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy @@ -43,7 +43,9 @@ class FailingMongoClient { /** * @param delegate the real client to pass everything else through to * @param failingMethod the driver method to intercept, by name - * @param onCall what to do in its place — throw, or block until released + * @param onCall what to do in its place — throw, block until released, or decide call by call. It + * receives a closure that makes the real call, for an interception that lets some calls + * through. */ static MongoClient wrap(MongoClient delegate, String failingMethod, Closure onCall) { (MongoClient) proxy(MongoClient, delegate, failingMethod, onCall) @@ -55,15 +57,9 @@ class FailingMongoClient { @Override Object invoke(Object proxyInstance, Method method, Object[] args) { if (method.name == failingMethod) { - return onCall.call() - } - Object result - try { - result = method.invoke(target, args) - } - catch (InvocationTargetException e) { - throw e.cause + return onCall.call({ -> invokeTarget(target, method, args) }) } + Object result = invokeTarget(target, method, args) if (result instanceof MongoDatabase) { return proxy(MongoDatabase, result, failingMethod, onCall) } @@ -74,4 +70,13 @@ class FailingMongoClient { } }) } + + private static Object invokeTarget(Object target, Method method, Object[] args) { + try { + return method.invoke(target, args) + } + catch (InvocationTargetException e) { + throw e.cause + } + } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy index cb4261b7fde..0a855fb5d29 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -112,7 +112,7 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { then: parent.getDatastoreForConnection('indexedAsync').isBuildIndexesAsync() buildReached.await(30, TimeUnit.SECONDS) - worker.get().name == 'gorm-mongo-index-build-indexedAsync' + worker.get().name.startsWith('gorm-mongo-index-build-indexedAsync-') when: "the parent closes while its child is still building" parent.close() diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index c2d7ec99654..c45829d7e67 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -196,7 +196,7 @@ grails: buildIndexesAsync: true ---- -Startup then continues without waiting. The indexes are still built one at a time per connection, on a daemon thread named `gorm-mongo-index-build-`. Different connections can build indexes concurrently, including when they use the same MongoDB server. The thread is released after the build finishes and it has been idle for one second. +Startup then continues without waiting. The indexes are still built one at a time per connection, on a daemon thread named `gorm-mongo-index-build--`, where `` counts the workers started for that connection. Different connections can build indexes concurrently, including when they use the same MongoDB server. The thread is released after the build finishes and it has been idle for one second. Two consequences are worth planning for: @@ -205,7 +205,7 @@ Two consequences are worth planning for: The setting is ignored when `buildIndexes` is `false`, and it applies only to the index build performed when the datastore starts — a domain class registered after startup is indexed on the thread registering it. -NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. +NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. A datastore stopped for a checkpoint and restored (with CRaC) runs a build it cut short again once it is restarted. ==== What the Index Build Reports @@ -220,7 +220,19 @@ The domain class count includes every class considered for indexing, including c The elapsed time is what the caller actually spent waiting — startup with the default settings, or the background thread when `buildIndexesAsync` is enabled, where this line is also the only signal that the build has finished. -The split between created and already present is what makes that time interpretable. MongoDB answers a `createIndex` for an index it already has immediately and without building anything, so a restart that changed no mappings reports everything as already present and costs milliseconds; a line reporting indexes created is the one that accounts for a slow start. If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. +The split between created and already present is what makes that time interpretable. MongoDB answers a `createIndex` for an index it already has immediately and without building anything, so a restart that changed no mappings reports everything as already present and costs milliseconds; a line reporting indexes created is the one that accounts for a slow start. An index that `recreateOnConflict` dropped and built again costs as much as a new one, and is counted apart as recreated: + +---- +Index build for database [myDb] finished in 9315ms: 0 created, 1 recreated, 6 already present, from 3 domain class(es) +---- + +If any declaration failed, the summary is logged at `WARN` instead and reports how many; the failures themselves are logged individually as they happen. A build that stops partway — a lost connection or a timeout — reports how far it got before stopping, also at `WARN`: + +---- +Index build for database [myDb] did not finish, stopping after 812ms at 1 of 3 domain class(es): 1 created, 0 already present +---- + +Telling a created index from one that was already there takes one `listIndexes` command per collection that declares indexes. It is only issued when the summary will be logged, that is when `INFO` is enabled for `org.grails.datastore.mapping.mongo`; with that logger at `WARN` the build skips it, and a summary for a build with failures reports how many declarations were applied instead. To find which index is the slow one, enable `DEBUG` logging for `org.grails.datastore.mapping.mongo`, which adds a line per index with its own elapsed time: From 6fe37380ea823d926c3b9ae4bf6a8dbe73360541 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 13:16:59 -0700 Subject: [PATCH 20/28] Stop SchemaBasedMultiTenancySpec dropping MongoDB's config database The feature cleaned up with CompanyB.eachTenant { CompanyB.DB.drop() }. In SCHEMA mode every database on the server is a tenant, MongoDB's own admin, config and local included; admin and local refuse the drop and the catch swallowed it, but config was dropped - on the test server the fork shares with every other AutoStartedMongoSpec. That takes config.image_collection with it. The next retryable findAndModify on that server - GORM's native id generation, in any later specification - makes mongod recreate the collection inside the write, which fails the invariant '_times._recursionDepth == 1 || !opCtx->writesAreReplicated()' in op_observer.cpp and aborts the server (exit 133). Every specification after it in the fork then finds nothing listening. It surfaced on this branch because the new specification classes changed how CI spreads classes over its four forks, putting this spec ahead of SuppliedMongoClientSettingsSpec in one of them: seven failures in the MongoDB 7.0, MongoDB 8.0 and coverage jobs, all against the same dead server. The crash was caught with docker events and the container's own log. The cleanup now drops only the two tenant databases the feature uses. --- .../SchemaBasedMultiTenancySpec.groovy | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SchemaBasedMultiTenancySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SchemaBasedMultiTenancySpec.groovy index 86c5df3da36..7abe09a3b09 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SchemaBasedMultiTenancySpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/SchemaBasedMultiTenancySpec.groovy @@ -76,14 +76,13 @@ class SchemaBasedMultiTenancySpec extends AutoStartedMongoSpec { } void "Test persist and retrieve entities with multi tenancy"() { - setup: - CompanyB.eachTenant { - try { - CompanyB.DB.drop() - } catch(e) { - // continue - } - + setup: "the tenants this feature uses start empty" + // Not eachTenant: in SCHEMA mode every database on the server is a tenant, MongoDB's own admin, config + // and local included. Dropping config on the shared test server removes config.image_collection, and + // the next retryable findAndModify - native id generation, in any later specification - then aborts + // mongod when it recreates that collection inside the write. + ['test1', 'test2'].each { String tenantId -> + datastore.mongoClient.getDatabase(tenantId).drop() } when:"A tenant id is present" From 5cafdb298eca41e075e2ce7710532837572e7e9c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 13:22:25 -0700 Subject: [PATCH 21/28] Describe the two index-build settings in the plugin's configuration metadata The plugin ships hand-written metadata for its grails.mongodb settings, which is what an IDE completes and documents from, and neither grails.mongodb.buildIndexes nor grails.mongodb.buildIndexesAsync was in it. Both now are, with their defaults, and both appear in the spring-configuration-metadata.json the plugin jar is built with. --- .../additional-spring-configuration-metadata.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 998fb14858b..1955f7c6dcc 100644 --- a/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -66,6 +66,18 @@ "description": "Whether to use Decimal128 for BigDecimal values instead of Double.", "defaultValue": false }, + { + "name": "grails.mongodb.buildIndexes", + "type": "java.lang.Boolean", + "description": "Whether to create the indexes declared in domain class mapping blocks when the datastore starts. When false, the indexes already on the server are left untouched.", + "defaultValue": true + }, + { + "name": "grails.mongodb.buildIndexesAsync", + "type": "java.lang.Boolean", + "description": "Whether the startup index build runs on a background thread instead of blocking startup. Ignored when grails.mongodb.buildIndexes is false.", + "defaultValue": false + }, { "name": "grails.mongodb.codecs", "type": "java.util.List", From 9a88f6ecd1e9e2cda7951f7272f7103f48b5f9db Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 13:25:01 -0700 Subject: [PATCH 22/28] Compare index key patterns in order sameKeyPattern checked that two key patterns had the same keys and directions, not that they came in the same order. To MongoDB {a: 1, b: 1} and {b: 1, a: 1} are two indexes, and one cannot serve the other's sort. That was harmless while the comparison only ran after the server reported an IndexOptionsConflict, which it does only for the same key pattern. The build now also uses it to tell a new index from an existing one, before createIndex, so a compound index declared in a different order from one already on the server was reported as already present while MongoDB created it - and recording it then displaced the existing index from the snapshot, so a later declaration of that one was reported as created. The keys are now compared position by position; the text-index special case, checked first, is unchanged. --- .../mapping/mongo/MongoDatastore.java | 15 ++- .../mongo/BuildIndexesKeyOrderSpec.groovy | 93 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesKeyOrderSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index a448c5696f8..ecc82e517e8 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -20,6 +20,7 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -1544,16 +1545,22 @@ private static boolean isTextIndex(Document key) { return false; } + /** + * Whether two key patterns describe the same index. Order counts: {@code {a: 1, b: 1}} and + * {@code {b: 1, a: 1}} are two indexes to MongoDB, and one cannot serve the other's sort. + */ private static boolean sameKeyPattern(Document existingKey, Document desiredKey) { if (existingKey.size() != desiredKey.size()) { return false; } - for (Map.Entry entry : desiredKey.entrySet()) { - if (!existingKey.containsKey(entry.getKey())) { + Iterator> existing = existingKey.entrySet().iterator(); + for (Map.Entry desired : desiredKey.entrySet()) { + Map.Entry actual = existing.next(); + if (!actual.getKey().equals(desired.getKey())) { return false; } - Object a = existingKey.get(entry.getKey()); - Object b = entry.getValue(); + Object a = actual.getValue(); + Object b = desired.getValue(); if (a instanceof Number && b instanceof Number) { if (((Number) a).doubleValue() != ((Number) b).doubleValue()) { return false; diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesKeyOrderSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesKeyOrderSpec.groovy new file mode 100644 index 00000000000..d244b360ee9 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesKeyOrderSpec.groovy @@ -0,0 +1,93 @@ +/* + * 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 ch.qos.logback.classic.Level +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +import org.bson.Document +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * The order of a compound index's keys is part of the index: MongoDB keeps {@code {a: 1, b: 1}} and + * {@code {b: 1, a: 1}} as two indexes, and one cannot serve the other's sort. A declaration whose keys + * come in a different order from an existing index is therefore a new index, and the summary has to say + * it was created. + */ +class BuildIndexesKeyOrderSpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'keyOrderDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient setupClient + + @Shared + CapturedLog log + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + String url = dbContainer.getReplicaSetUrl(DATABASE) + setupClient = MongoClients.create(url) + setupClient.getDatabase(DATABASE).getCollection('keyOrderThing').createIndex(new Document('a', 1).append('b', 1)) + + log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + datastore = new MongoDatastore(['grails.mongodb.url': url] as Map, KeyOrderThing) + } + + void cleanupSpec() { + log?.close() + setupClient?.close() + } + + void "test a compound index declared in a different key order is created, not taken for the existing one"() { + expect: "both orders now exist on the server, as two indexes" + def keys = setupClient.getDatabase(DATABASE).getCollection('keyOrderThing').listIndexes()*.key*.keySet()*.toList() + ['a', 'b'] in keys + ['b', 'a'] in keys + + and: "the summary counts the reversed declaration as created and the matching one as already present" + log.events*.formattedMessage.find { it.contains("database [$DATABASE]") }.contains('1 created, 1 already present') + } +} + +@Entity +class KeyOrderThing { + Integer a + Integer b + + static mapping = { + version false + collection 'keyOrderThing' + compoundIndex b: 1, a: 1 + compoundIndex a: 1, b: 1 + } +} From 0a12094fa4ace047062049f50024573fc2f8b0d3 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 13:47:13 -0700 Subject: [PATCH 23/28] Put the logback note beside the dependency rather than in an empty closure The closure configured nothing; every other entry in the file uses one to configure something and keeps its note alongside. --- grails-data-mongodb/core/build.gradle | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/grails-data-mongodb/core/build.gradle b/grails-data-mongodb/core/build.gradle index 1e17eeda811..d5b707b3fb9 100644 --- a/grails-data-mongodb/core/build.gradle +++ b/grails-data-mongodb/core/build.gradle @@ -149,11 +149,10 @@ dependencies { // test: GenericWebApplicationContext requires ServletContext on the classpath } - testImplementation 'ch.qos.logback:logback-classic', { - // test: a real SLF4J binding, so that the log a test asserts on is actually emitted. - // Quietened to WARN by src/test/resources/logback-test.xml; a test raises the level of the - // one logger it is interested in. - } + // test: a real SLF4J binding, so that the log a test asserts on is actually emitted. + // Quietened to WARN by src/test/resources/logback-test.xml; a test raises the level of the + // one logger it is interested in. + testImplementation 'ch.qos.logback:logback-classic' testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:testcontainers-mongodb' testImplementation 'org.testcontainers:testcontainers-spock' From d8adb08fcfc2d74fee39644db431c2efbaeab70a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 13:47:14 -0700 Subject: [PATCH 24/28] Tighten the index build's lifecycle, reporting and per-collection listing Follows up the review of the previous round: - A per-connection child starts its index build only once it is registered, and a connection added at runtime starts it only if the datastore is not closing. close() sets that flag before walking the children, so it either finds a child and shuts its build down or the build never starts; before, a child added during close() could run on unseen against a closed client and log its shutdown as a failure. - stop() and start() cover every connection, as close() does: each child's build is interrupted before the client goes and resumed afterwards. - Whether a build was cut short is now exact. A build interrupted by stop() records it as it exits, so one that finishes in the meantime is not re-run; a queued build is taken from shutdownNow(). start() waits for the interrupted worker to exit before deciding, rather than racing it. - buildIndex() tells closed from stopped with a flag close() sets, so a datastore stopped and then closed warns instead of promising a restart. - runIndexBuild catches Exception rather than Throwable, so an Error propagates as it is; an InterruptedException restores the interrupt flag; and a build on the calling thread rethrows the original exception, checked or not, instead of wrapping it in IllegalStateException. - The listing is cached per collection for the whole build rather than per entity, so classes sharing a collection list it once and see each other's indexes. - An unreadable listing is reported with its own reason again, as on 8.0.x, rather than with the conflict's message. - The per-index DEBUG line says Applied when nothing classified the index, instead of claiming every index outside a build was created. --- .../mapping/mongo/MongoDatastore.java | 172 +++++++++++++----- .../BuildIndexesClassificationCostSpec.groovy | 90 +++++++-- .../gorm/mongo/BuildIndexesHookSpec.groovy | 45 +++++ .../mongo/BuildIndexesLifecycleSpec.groovy | 118 ++++++++++++ ...BuildIndexesUnreadableIndexListSpec.groovy | 5 +- .../BuildIndexesPerConnectionSpec.groovy | 89 +++++++++ .../docs/asciidoc/querying/queryIndexes.adoc | 4 +- 7 files changed, 455 insertions(+), 68 deletions(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index ecc82e517e8..fbeb5f27635 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -31,7 +31,6 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import groovy.lang.Closure; @@ -40,6 +39,7 @@ import com.mongodb.MongoClientSettings; import com.mongodb.MongoCommandException; +import com.mongodb.MongoNamespace; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoIterable; import com.mongodb.client.model.IndexOptions; @@ -188,12 +188,22 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. */ private volatile ExecutorService indexBuildExecutor; - /** Background builds submitted and not yet finished, which is how {@link #stop()} knows it cut one short. */ - private final AtomicInteger indexBuildsInFlight = new AtomicInteger(); - - /** Set when a build was cut short by {@link #stop()} or requested while stopped, for {@link #start()} to run. */ + /** + * Set when a build did not run to the end because the datastore was stopping, or was requested while it + * was stopped, for {@link #start()} to run again. A build interrupted by {@link #stop()} sets it itself + * as it exits, so a build that finished in the meantime is not run twice. + */ private volatile boolean indexBuildPending; + /** + * Set first thing in {@link #close()}. A connection added at runtime starts its index build only while + * this is clear, and only after it is registered, so a close cannot miss a build that has started. + */ + private volatile boolean closed; + + /** How long {@link #start()} waits for a build that {@link #stop()} interrupted to finish exiting. */ + private static final long INDEX_BUILD_EXIT_TIMEOUT_SECONDS = 10; + /** The summary for the current build, scoped to its thread so the protected index hook is preserved. */ private final ThreadLocal indexBuildSummary = new ThreadLocal<>(); private volatile Boolean transactionsSupported; @@ -278,6 +288,9 @@ public MongoDatastore(final ConnectionSources() { @@ -286,6 +299,11 @@ public void newConnectionSource(final ConnectionSource { - try { - runIndexBuild(executor); - } - finally { - indexBuildsInFlight.decrementAndGet(); - } - }); + executor.execute(() -> runIndexBuild(executor)); return; } catch (RejectedExecutionException e) { // Shut down between the check and the submission. - indexBuildsInFlight.decrementAndGet(); } } - if (running) { + if (closed) { LOG.warn("An index build was requested after the datastore was closed, so it was not started."); } else { @@ -674,12 +683,17 @@ private void runIndexBuild(ExecutorService executor) { // Telling a created index from one that was already there costs a listIndexes per indexed // collection, and its only product is the summary, so it is skipped when that would not be logged. IndexBuildSummary summary = new IndexBuildSummary(LOG.isInfoEnabled()); - Throwable failure = null; + Exception failure = null; try { buildDeclaredIndexes(summary); } - catch (Throwable e) { + catch (Exception e) { + // An Error is left to propagate as it is, without the summary. failure = e; + if (e instanceof InterruptedException) { + // Caught here rather than by whoever interrupted, so the flag it cleared is put back. + Thread.currentThread().interrupt(); + } } long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); if (failure == null) { @@ -688,18 +702,15 @@ private void runIndexBuild(ExecutorService executor) { } if (executor == null) { logUnfinishedIndexBuild(summary, elapsedMillis, false); - if (failure instanceof RuntimeException) { - throw (RuntimeException) failure; - } - if (failure instanceof Error) { - throw (Error) failure; - } - throw new IllegalStateException(failure); + // Unchanged, checked or not: an initializeIndices override may throw one without declaring it. + MongoDatastore.rethrow(failure); + return; } // Nothing is waiting on this thread, so an error that would have failed startup has to be // reported here or it is lost entirely. boolean abandoned = executor.isShutdown() || Thread.currentThread().isInterrupted(); if (abandoned) { + indexBuildPending = true; // 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: {}", @@ -712,6 +723,11 @@ private void runIndexBuild(ExecutorService executor) { logUnfinishedIndexBuild(summary, elapsedMillis, abandoned); } + @SuppressWarnings("unchecked") + private static void rethrow(Exception failure) throws E { + throw (E) failure; + } + private void logFinishedIndexBuild(IndexBuildSummary summary, long elapsedMillis) { if (summary.applied() == 0 && summary.recreated == 0 && summary.failures == 0) { LOG.debug("No indexes are declared by the {} domain class(es) mapped to database [{}]", @@ -798,6 +814,9 @@ private static final class ExistingIndexes { private boolean listed; + /** Why the last listing failed, for the conflict it leaves unreconciled to report. */ + private RuntimeException listingFailure; + private ExistingIndexes(com.mongodb.client.MongoCollection collection, IndexBuildSummary summary) { this.collection = collection; this.summary = summary; @@ -819,12 +838,14 @@ private List refresh() { listed = true; try { indexes = collection.listIndexes().into(new ArrayList<>()); + listingFailure = null; } 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); indexes = null; + listingFailure = e; summary.classified = false; } return indexes; @@ -883,10 +904,21 @@ private static final class IndexBuildSummary { */ private boolean classified; + /** + * One listing per collection rather than per entity: every class in an inheritance hierarchy maps + * to its root's collection, and several classes can name the same one. Sharing it also keeps them + * consistent, so a second class declaring keys the first has just created sees them as present. + */ + private final Map existingIndexes = new HashMap<>(); + private IndexBuildSummary(boolean classified) { this.classified = classified; } + private ExistingIndexes existingIndexesOf(com.mongodb.client.MongoCollection collection) { + return existingIndexes.computeIfAbsent(collection.getNamespace(), namespace -> new ExistingIndexes(collection, this)); + } + /** Declarations applied other than by a drop and recreate. */ private int applied() { return created + alreadyPresent; @@ -1307,7 +1339,7 @@ private void initializeIndices(final PersistentEntity entity, final IndexBuildSu return; } final com.mongodb.client.MongoCollection collection = getCollection(entity); - final ExistingIndexes existingIndexes = new ExistingIndexes(collection, summary); + final ExistingIndexes existingIndexes = summary.existingIndexesOf(collection); final ClassMapping classMapping = entity.getMapping(); if (classMapping != null) { final MongoCollection mappedForm = classMapping.getMappedForm(); @@ -1404,7 +1436,9 @@ private void createOrUpdateIndex(PersistentEntity entity, else { summary.created++; } - LOG.debug("{} index for entity [{}] {} in {}ms", present ? "Confirmed" : "Created", + // Unclassified, nothing says whether the index was new, so the line does not claim either. + String applied = !summary.classified ? "Applied" : present ? "Confirmed" : "Created"; + LOG.debug("{} index for entity [{}] {} in {}ms", applied, entity.getName(), descriptor, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)); } catch (MongoCommandException e) { if (e.getErrorCode() == INDEX_OPTIONS_CONFLICT_CODE) { @@ -1446,8 +1480,10 @@ private Reconciliation reconcileIndexConflict(PersistentEntity entity, // first listed - another instance or another connection can have created it since. List indexes = existingIndexes.refresh(); if (indexes == null) { + // The reason the listing failed, which is what an operator can act on - often a missing + // listIndexes privilege - with the conflict that made it matter attached. LOG.error("Failed to create index for entity [{}] {} and could not inspect existing indexes: {}", - entity.getName(), descriptor, original.getMessage(), original); + entity.getName(), descriptor, existingIndexes.listingFailure.getMessage(), original); return Reconciliation.FAILED; } Document existing = findIndexByKeyPattern(indexes, keys); @@ -1609,16 +1645,16 @@ public void persistentEntityAdded(PersistentEntity entity) { * created it, and this datastore stays {@link #isRunning() running} so that * {@link #start()} does not later replace something it does not own. * - *

A background index build still running is interrupted first, rather than failing against the - * closed client, and {@link #start()} runs it again. + *

A background index build still running, on this connection or any other, is interrupted first + * rather than left to fail against a closed client, and {@link #start()} runs it again. */ @Override public void stop() { if (!this.running || !ownsClient()) { return; } - if (shutDownIndexBuild()) { - indexBuildPending = true; + for (MongoDatastore datastore : datastoresAndChildren()) { + datastore.stopIndexBuild(); } this.mongo.close(); this.running = false; @@ -1628,7 +1664,7 @@ public void stop() { * Builds a replacement {@link MongoClient} after a restore, using the same factory and * configuration the original was built from, so settings applied at startup still apply. * A background index build that {@link #stop()} cut short, or that was requested while stopped, - * runs again on a fresh executor. + * runs again on a fresh executor, on every connection. */ @Override public void start() { @@ -1638,10 +1674,54 @@ public void start() { this.mongo = connectionSources.getFactory() .create(ConnectionSource.DEFAULT, connectionSources.getBaseConfiguration()) .getSource(); - if (this.buildIndexes && this.buildIndexesAsync) { - this.indexBuildExecutor = newIndexBuildExecutor(connectionSources.getDefaultConnectionSource().getName()); - } this.running = true; + for (MongoDatastore datastore : datastoresAndChildren()) { + datastore.resumeIndexBuild(); + } + } + + /** + * This datastore followed by the per-connection children, which have no lifecycle of their own. + */ + private List datastoresAndChildren() { + List datastores = new ArrayList<>(); + datastores.add(this); + for (MongoDatastore datastore : datastoresByConnectionSource.values()) { + if (datastore != this) { + datastores.add(datastore); + } + } + return datastores; + } + + private void stopIndexBuild() { + if (shutDownIndexBuild()) { + indexBuildPending = true; + } + } + + /** + * Replaces the executor {@link #stop()} shut down and runs any build it cut short. A build that was + * running when it was interrupted records that it was cut short as it exits, so this waits for it to + * have exited before deciding. + */ + private void resumeIndexBuild() { + ExecutorService stopped = this.indexBuildExecutor; + if (stopped == null) { + return; + } + try { + if (!stopped.awaitTermination(INDEX_BUILD_EXIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + // Still running: it may yet finish, but assuming it will not costs only a repeat of work + // that is idempotent. + indexBuildPending = true; + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + indexBuildPending = true; + } + this.indexBuildExecutor = newIndexBuildExecutor(connectionSources.getDefaultConnectionSource().getName()); if (indexBuildPending) { indexBuildPending = false; buildIndex(); @@ -1671,11 +1751,11 @@ private boolean ownsClient() { @PreDestroy public void close() { MongoClient current = this.mongo; - shutDownIndexBuild(); - for (MongoDatastore datastore : datastoresByConnectionSource.values()) { - if (datastore != this) { - datastore.shutDownIndexBuild(); - } + // Set before the children are walked; see the connection sources listener. + closed = true; + for (MongoDatastore datastore : datastoresAndChildren()) { + datastore.closed = true; + datastore.shutDownIndexBuild(); } try { super.destroy(); @@ -1709,17 +1789,15 @@ public void close() { * Interrupts the background index build, if there is one. A build can run for minutes, so shutdown * must not wait for it; the server carries on building what it was asked for. * - * @return whether a build was running or queued, and so was cut short + * @return whether a build was still queued, and so never ran */ private boolean shutDownIndexBuild() { ExecutorService executor = this.indexBuildExecutor; if (executor == null) { return false; } - boolean cutShort = indexBuildsInFlight.get() > 0; - // A build still queued never runs, so it never takes itself off the count. - indexBuildsInFlight.addAndGet(-executor.shutdownNow().size()); - return cutShort; + // A build already running reports for itself whether it was cut short; one still queued never runs. + return !executor.shutdownNow().isEmpty(); } /** diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy index 86cd1514830..217940e77b8 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesClassificationCostSpec.groovy @@ -36,9 +36,9 @@ import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.mongo.MongoDatastore /** - * Telling a created index from one that was already there costs a {@code listIndexes} per indexed - * collection, and the only thing it feeds is the summary line. An application that does not log that line - * does not pay for it. + * Telling a created index from one that was already there costs a {@code listIndexes} per collection that + * declares indexes, and the only thing it feeds is the summary line. It is paid once per collection + * however many domain classes map to it, and not at all when the summary is not logged. */ class BuildIndexesClassificationCostSpec extends AutoStartedMongoSpec { @@ -54,6 +54,12 @@ class BuildIndexesClassificationCostSpec extends AutoStartedMongoSpec { @Shared AtomicInteger listings = new AtomicInteger() + @Shared + int startupListings + + @Shared + String startupSummary + @Override boolean shouldInitializeDatastore() { false @@ -71,32 +77,60 @@ class BuildIndexesClassificationCostSpec extends AutoStartedMongoSpec { } }) .build()) + + def log = new CapturedLog('org.grails.datastore.mapping.mongo', Level.INFO) + datastore = new MongoDatastore(countingClient, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': DATABASE]), + ClassifiedThing, FirstSharedCollectionThing, SecondSharedCollectionThing) + startupListings = listings.get() + startupSummary = log.events*.formattedMessage.find { it.contains("database [$DATABASE]") } + log.close() } void cleanupSpec() { countingClient?.close() } - void "test the existing indexes are listed only when the summary will be logged"() { - when: "the datastore builds its indexes with the summary not logged" - def quiet = new CapturedLog('org.grails.datastore.mapping.mongo', Level.WARN) - datastore = new MongoDatastore(countingClient, - DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': DATABASE]), ClassifiedThing) - quiet.close() + void "test a collection shared by several domain classes is listed once"() { + expect: "one listing for each of the two collections, not one for each of the three classes" + startupListings == 2 + } + + void "test classes sharing a collection see each other's indexes"() { + expect: "the second class declaring the keys the first has just created finds them already there" + startupSummary.contains('2 created, 1 already present') + } - then: "nothing was listed" - listings.get() == 0 + void "test the existing indexes are not listed when the summary will not be logged"() { + given: + int before = listings.get() + def quiet = new CapturedLog('org.grails.datastore.mapping.mongo', Level.WARN) - when: "the same build runs with the summary logged" - def logged = new CapturedLog('org.grails.datastore.mapping.mongo', Level.INFO) + when: datastore.buildIndex() - then: "the collection is listed so the summary can say what was already there" - listings.get() == 1 - logged.events*.formattedMessage.find { it.contains("database [$DATABASE]") }.contains('0 created, 1 already present') + then: + listings.get() == before + + cleanup: + quiet?.close() + } + + void "test an index applied outside a build is logged as applied, without being classified"() { + given: + int before = listings.get() + def debug = new CapturedLog('org.grails.datastore.mapping.mongo', Level.DEBUG) + + when: "a domain class is registered after startup, when no summary will report the counts" + datastore.persistentEntityAdded(datastore.mappingContext.getPersistentEntity(ClassifiedThing.name)) + + then: "nothing is listed, and the per-index line does not claim the index was created" + listings.get() == before + debug.events*.formattedMessage.any { it.startsWith("Applied index for entity [${ClassifiedThing.name}]") } + !debug.events*.formattedMessage.any { it.startsWith("Created index for entity [${ClassifiedThing.name}]") } cleanup: - logged?.close() + debug?.close() } } @@ -110,3 +144,25 @@ class ClassifiedThing { name index: true } } + +@Entity +class FirstSharedCollectionThing { + String code + + static mapping = { + version false + collection 'sharedCollection' + code index: true + } +} + +@Entity +class SecondSharedCollectionThing { + String code + + static mapping = { + version false + collection 'sharedCollection' + code index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy index 4ebdec3e5a3..794783aae4d 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesHookSpec.groovy @@ -18,13 +18,17 @@ */ package org.grails.datastore.gorm.mongo +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients import grails.gorm.annotation.Entity import org.bson.Document import spock.util.concurrent.PollingConditions import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.mongo.MongoDatastore +import org.springframework.core.env.PropertyResolver class BuildIndexesHookSpec extends AutoStartedMongoSpec { @@ -62,6 +66,47 @@ class BuildIndexesHookSpec extends AutoStartedMongoSpec { where: async << [false, true] } + + void "test a checked exception from the hook reaches the caller of a synchronous build unchanged"() { + given: + MongoClient client = MongoClients.create(dbContainer.getReplicaSetUrl('hookFailureDb')) + + when: "an override throws a checked exception it does not declare, as Groovy allows" + new InterruptedHookDatastore(client, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': 'hookFailureDb']), HookFailureThing) + + then: "the caller gets that exception, not a wrapper around it" + def e = thrown(InterruptedException) + e.message == 'interrupted in the hook' + + and: "the interrupt it stands for is not lost" + Thread.interrupted() + + cleanup: + Thread.interrupted() + client?.close() + } +} + +class InterruptedHookDatastore extends MongoDatastore { + + InterruptedHookDatastore(MongoClient client, PropertyResolver configuration, Class... classes) { + super(client, configuration, classes) + } + + @Override + protected void initializeIndices(PersistentEntity entity) { + throw new InterruptedException('interrupted in the hook') + } +} + +@Entity +class HookFailureThing { + String name + + static mapping = { + name index: true + } } class CustomIndexDatastore extends MongoDatastore { diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy index b55d143d5c1..2c207fa7594 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy @@ -108,6 +108,72 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { log?.close() } + void "test a build that finishes despite stop() is not run again on start()"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + SlowToStopDatastore.REACHED = new CountDownLatch(1) + SlowToStopDatastore.RELEASE = new CountDownLatch(1) + String caller = Thread.currentThread().name + def buildsStartedHere = { -> + log.events.count { + it.threadName == caller && it.formattedMessage.startsWith('Building the indexes declared by the domain classes') + } + } + + when: "the build has applied every index and is on its way out when the datastore is stopped" + def datastore = new SlowToStopDatastore(asyncConfig('slowToStopDb'), SlowToStopThing) + SlowToStopDatastore.REACHED.await(30, TimeUnit.SECONDS) + int before = buildsStartedHere() + datastore.stop() + SlowToStopDatastore.RELEASE.countDown() + + and: "restarted" + datastore.start() + + then: "the build finished, so the restart does not run it again" + buildsStartedHere() == before + log.events.any { it.formattedMessage.contains('Index build for database [slowToStopDb] finished') } + + cleanup: + SlowToStopDatastore.RELEASE?.countDown() + datastore?.close() + log?.close() + } + + void "test a build requested while stopped runs when the datastore is restarted"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + def collection = realClient.getDatabase('deferredDb').getCollection('deferredBuildThing') + def datastore = new MongoDatastore(asyncConfig('deferredDb'), DeferredBuildThing) + conditions.eventually { + assert [name: 1] in collection.listIndexes()*.key + } + + when: "the datastore is stopped, and a build is requested while it is" + datastore.stop() + datastore.buildIndex() + + then: "the request is deferred, not refused" + notThrown(Exception) + log.events.any { + it.level == Level.INFO && it.formattedMessage.contains('will run when the datastore is restarted') + } + + when: "the index is dropped on the server and the datastore restarted" + collection.dropIndex('name_1') + datastore.start() + + then: "the deferred build runs and puts it back" + conditions.eventually { + assert [name: 1] in collection.listIndexes()*.key + } + + cleanup: + datastore?.close() + log?.close() + } + void "test a build requested after close() is refused with a warning rather than an exception"() { given: def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) @@ -155,6 +221,58 @@ class CheckpointedDatastore extends MongoDatastore { } } +/** + * Holds the build up after it has applied every index, and does not respond to being interrupted: the + * build then finishes normally even though {@link MongoDatastore#stop()} ran while it was under way. + */ +class SlowToStopDatastore extends MongoDatastore { + + static volatile CountDownLatch REACHED + + static volatile CountDownLatch RELEASE + + SlowToStopDatastore(Map configuration, Class... classes) { + super(configuration, classes) + } + + @Override + protected void initializeIndices(PersistentEntity entity) { + super.initializeIndices(entity) + REACHED.countDown() + while (true) { + try { + RELEASE.await() + return + } + catch (InterruptedException ignored) { + // A step that does not respond to interruption, as a blocking call may not + } + } + } +} + +@Entity +class SlowToStopThing { + String name + + static mapping = { + version false + collection 'slowToStopThing' + name index: true + } +} + +@Entity +class DeferredBuildThing { + String name + + static mapping = { + version false + collection 'deferredBuildThing' + name index: true + } +} + @Entity class CheckpointedThing { String name diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy index 9f4d9fbc456..6e8499bd52a 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesUnreadableIndexListSpec.groovy @@ -103,11 +103,12 @@ class BuildIndexesUnreadableIndexListSpec extends AutoStartedMongoSpec { } void "test a conflict that cannot be reconciled without the index list is reported, not swallowed"() { - expect: + expect: "the error names the entity and says why the listing failed, which is what can be acted on" log.events.any { it.level == Level.ERROR && it.formattedMessage.contains('could not inspect existing indexes') && - it.formattedMessage.contains('UnlistableConflictThing') + it.formattedMessage.contains('UnlistableConflictThing') && + it.formattedMessage.contains('not authorized on unlistableIndexDb to execute command listIndexes') } } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy index 0a855fb5d29..a71b4e1d1ab 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -20,13 +20,16 @@ package org.grails.datastore.gorm.mongo.connections import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import ch.qos.logback.classic.Level import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients import grails.gorm.annotation.Entity import spock.lang.AutoCleanup import spock.lang.Shared +import spock.util.concurrent.PollingConditions import org.apache.grails.testing.mongo.AutoStartedMongoSpec import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher @@ -140,6 +143,92 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { addedAtRuntime << [false, true] } + void "test stop() and start() interrupt and resume a named connection's build"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.DEBUG) + def buildReached = new CountDownLatch(1) + def releaseBuild = new CountDownLatch(1) + def blockOnce = new AtomicBoolean(true) + def factory = new MongoConnectionSourceFactory() { + @Override + ConnectionSource create(String name, MongoConnectionSourceSettings settings) { + def source = super.create(name, settings) + if (name != 'checkpointedChild') { + return source + } + MongoClient blocking = FailingMongoClient.wrap(source.source, 'createIndex') { Closure proceed -> + if (blockOnce.compareAndSet(true, false)) { + buildReached.countDown() + releaseBuild.await() + } + proceed() + } + new DefaultConnectionSource(name, blocking, settings) + } + } + MongoClient inspector = MongoClients.create(dbContainer.getReplicaSetUrl('checkpointedChildDb')) + def childIndexes = { -> inspector.getDatabase('checkpointedChildDb').getCollection('asyncPerConnectionThing').listIndexes()*.key } + + when: "the named connection's build is under way when the datastore is stopped for a checkpoint" + def parent = new MongoDatastore(DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.url' : dbContainer.getReplicaSetUrl('checkpointedParentDb'), + 'grails.mongodb.buildIndexes' : false, + 'grails.mongodb.buildIndexesAsync': true, + 'grails.mongodb.connections' : [checkpointedChild: [url: dbContainer.getReplicaSetUrl('checkpointedChildDb'), buildIndexes: true]] + ]), factory, new DefaultApplicationEventPublisher(), AsyncPerConnectionThing) + buildReached.await(30, TimeUnit.SECONDS) + parent.stop() + + then: "the child's build is abandoned as a shutdown, not reported as a failure" + conditions.eventually { + assert log.events.any { + it.level == Level.DEBUG && it.formattedMessage.contains('database [checkpointedChildDb] did not finish') + } + } + !log.events.any { it.level == Level.ERROR && it.threadName.startsWith('gorm-mongo-index-build-checkpointedChild-') } + !([name: 1] in childIndexes()) + + when: "the datastore is restarted" + parent.start() + + then: "the child's build runs again and completes" + conditions.eventually { + assert [name: 1] in childIndexes() + } + + cleanup: + releaseBuild.countDown() + parent?.close() + inspector?.close() + log?.close() + } + + void "test a connection added after close() starts no index build"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + String caller = Thread.currentThread().name + def parent = new MongoDatastore(DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.url' : dbContainer.getReplicaSetUrl('closedParentDb'), + 'grails.mongodb.buildIndexes' : false, + 'grails.mongodb.buildIndexesAsync': true + ]), AsyncPerConnectionThing) + parent.close() + + when: + parent.connectionSources.addConnectionSource('addedAfterClose', + [url: dbContainer.getReplicaSetUrl('addedAfterCloseDb'), buildIndexes: true]) + + then: "a build would have announced itself on this thread before returning; none did" + !log.events.any { + it.threadName == caller && it.formattedMessage.startsWith('Building the indexes declared by the domain classes') + } + + cleanup: + parent?.connectionSources?.getConnectionSource('addedAfterClose')?.close() + log?.close() + } + void "test a connection can override the global setting"() { expect: "the default connection is disabled by the top level setting" !datastore.isBuildIndexes() diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index c45829d7e67..58107e5013f 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -196,7 +196,7 @@ grails: buildIndexesAsync: true ---- -Startup then continues without waiting. The indexes are still built one at a time per connection, on a daemon thread named `gorm-mongo-index-build--`, where `` counts the workers started for that connection. Different connections can build indexes concurrently, including when they use the same MongoDB server. The thread is released after the build finishes and it has been idle for one second. +Startup then continues without waiting. The indexes are still built one at a time per connection, on a daemon thread named after its connection, such as `gorm-mongo-index-build-default-1`. Different connections can build indexes concurrently, including when they use the same MongoDB server. The thread is released after the build finishes and it has been idle for one second. Two consequences are worth planning for: @@ -205,7 +205,7 @@ Two consequences are worth planning for: The setting is ignored when `buildIndexes` is `false`, and it applies only to the index build performed when the datastore starts — a domain class registered after startup is indexed on the thread registering it. -NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. A datastore stopped for a checkpoint and restored (with CRaC) runs a build it cut short again once it is restarted. +NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. A datastore stopped for a checkpoint and restored (with CRaC) runs a build it cut short again once it is restarted, on every connection. ==== What the Index Build Reports From 0ffc9215e712908ef99f438041f3ffe57c31f771 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 16:20:38 -0700 Subject: [PATCH 25/28] Say which connection details a supplied client leaves out The upgrade note listed everything that starts applying to an externally supplied client without saying that the connection details are the exception: grails.mongodb.url is not applied, since the client is already connected. A connection under grails.mongodb.connections without a url of its own therefore falls back to the host, port, username and password settings - localhost:27017 unless they are set - rather than to grails.mongodb.url. The note now says so, and that such a connection should declare its own url. --- .../docs/src/docs/asciidoc/introduction/upgradeNotes.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc index cd806673ee8..e6a6aaaa508 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc @@ -108,6 +108,8 @@ grails: Without it the mode is active but the resolver is `NoTenantResolver`, which throws `TenantNotFoundException` on every tenant-scoped operation. Settings under `grails.mongodb` continue to take precedence over the `grails.gorm` equivalents. +The connection details are the exception, since the supplied client is already connected: `grails.mongodb.url` is not applied to it. That also matters for a connection declared under `grails.mongodb.connections` without a `url` of its own, which falls back to the `host`, `port`, `username` and `password` settings — `localhost:27017` unless those are set — rather than to `grails.mongodb.url`. Give each such connection its own `url`. + ==== MongoClient Lifecycle for an Externally-Supplied Client When you construct a `MongoDatastore` with a `MongoClient` that you created yourself, GORM no longer closes that client when the datastore shuts down. The client is owned by whoever created it, so closing it is now your responsibility. From c6a64e8d1e41b318a50a8cf88f9a41a28de183fc Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 16:20:38 -0700 Subject: [PATCH 26/28] Keep the index build's breakdown, errors and restarts honest per connection Follows up the review of the previous round: - Classification is per collection. A listing that fails - first or re-listing after a conflict - leaves that collection's declarations unclassified instead of discarding the breakdown the other collections established, and the summary reports them as "applied without a listing" alongside it. - A background build that fails while the datastore shuts down is reported as abandoned only when the failure is one the shutdown produces: an interruption, the driver refusing the closed client, or a socket closed under a call. Anything else - a duplicate key on a unique index - stays at ERROR and is not marked for re-running on restore. - buildIndex() checks closed before either mode runs, so a build on the calling thread is refused after close() as a background one is, instead of running against the client being closed. - start() no longer waits on an executor that was never shut down - a connection added while stopped - and so no longer starts a duplicate build of it; the wait for an interrupted build to exit is one second, not ten. - Every line buildIndex() logs names its connection, the disabled message names the setting where an operator would look for it, and the background build is announced only once it has been submitted. - codecRegistry is volatile, since the background build reads it while the @Autowired setters may still be replacing it. FailingMongoClient can pass its interceptor the collection being called, so a test can make one collection's listing fail and not another's. --- .../mapping/mongo/MongoDatastore.java | 143 +++++++++++++----- .../BuildIndexesBackgroundFailureSpec.groovy | 43 ++++++ .../mongo/BuildIndexesLifecycleSpec.groovy | 35 ++++- ...ildIndexesPartialClassificationSpec.groovy | 141 +++++++++++++++++ .../gorm/mongo/FailingMongoClient.groovy | 6 +- .../BuildIndexesPerConnectionSpec.groovy | 42 +++++ 6 files changed, 372 insertions(+), 38 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesPartialClassificationSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index fbeb5f27635..8d8849c4e76 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -39,7 +39,9 @@ import com.mongodb.MongoClientSettings; import com.mongodb.MongoCommandException; +import com.mongodb.MongoInterruptedException; import com.mongodb.MongoNamespace; +import com.mongodb.MongoSocketException; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoIterable; import com.mongodb.client.model.IndexOptions; @@ -201,14 +203,19 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext. */ private volatile boolean closed; - /** How long {@link #start()} waits for a build that {@link #stop()} interrupted to finish exiting. */ - private static final long INDEX_BUILD_EXIT_TIMEOUT_SECONDS = 10; + /** + * How long {@link #start()} waits for a build that {@link #stop()} interrupted to finish exiting. One that is + * going to exit does so within milliseconds of the interrupt. + */ + private static final long INDEX_BUILD_EXIT_TIMEOUT_MILLIS = 1000; /** The summary for the current build, scoped to its thread so the protected index hook is preserved. */ private final ThreadLocal indexBuildSummary = new ThreadLocal<>(); private volatile Boolean transactionsSupported; private volatile boolean warnedTransactionsUnsupported = false; - protected CodecRegistry codecRegistry; + // Volatile: an asynchronous index build reads it from its own thread while the @Autowired setters below + // can still be replacing it. + protected volatile CodecRegistry codecRegistry; protected final ConfigurableApplicationEventPublisher eventPublisher; protected final PlatformTransactionManager transactionManager; protected final GormEnhancer gormEnhancer; @@ -640,10 +647,17 @@ public ConnectionSources getConnecti * immediately instead. */ public void buildIndex() { + String connection = connectionName(); 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); + LOG.info("Index creation is disabled for connection [{}] 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.", connection, buildIndexesSettingName()); + return; + } + // Before either mode: a build on the calling thread would otherwise run against the client close() is closing. + if (closed) { + LOG.warn("An index build was requested for connection [{}] after the datastore was closed, so it was not started.", + connection); return; } ExecutorService executor = this.indexBuildExecutor; @@ -652,11 +666,12 @@ public void buildIndex() { return; } if (!executor.isShutdown()) { - 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); try { executor.execute(() -> runIndexBuild(executor)); + // Once submitted, so that it is only ever said of a build that is under way. + LOG.info("Building the indexes declared by the domain classes for connection [{}] on a background " + + "thread. Startup does not wait for them, so a query issued before its index exists is served " + + "without it.", connection); return; } catch (RejectedExecutionException e) { @@ -664,12 +679,31 @@ public void buildIndex() { } } if (closed) { - LOG.warn("An index build was requested after the datastore was closed, so it was not started."); + LOG.warn("An index build was requested for connection [{}] after the datastore was closed, so it was not started.", + connection); } else { indexBuildPending = true; - LOG.info("An index build was requested while the datastore is stopped; it will run when the datastore is restarted."); + LOG.info("An index build was requested for connection [{}] while the datastore is stopped; it will run when " + + "the datastore is restarted.", connection); + } + } + + private String connectionName() { + return connectionSources.getDefaultConnectionSource().getName(); + } + + /** + * The setting that turned index building off, as an operator would look for it: a named connection takes + * its own value if it declares one and inherits the top level one otherwise, so both are named. + */ + private String buildIndexesSettingName() { + String connection = connectionName(); + if (ConnectionSource.DEFAULT.equals(connection)) { + return MongoSettings.SETTING_BUILD_INDEXES; } + return MongoSettings.SETTING_CONNECTIONS + "." + connection + ".buildIndexes (or " + + MongoSettings.SETTING_BUILD_INDEXES + ")"; } /** @@ -708,7 +742,7 @@ private void runIndexBuild(ExecutorService executor) { } // Nothing is waiting on this thread, so an error that would have failed startup has to be // reported here or it is lost entirely. - boolean abandoned = executor.isShutdown() || Thread.currentThread().isInterrupted(); + boolean abandoned = executor.isShutdown() && explainedByShutdown(failure); if (abandoned) { indexBuildPending = true; // toString rather than the message: an interrupted driver call can arrive wrapped in @@ -723,6 +757,22 @@ private void runIndexBuild(ExecutorService executor) { logUnfinishedIndexBuild(summary, elapsedMillis, abandoned); } + /** + * Whether a failure is one that shutting the datastore down produces: an interruption, the driver refusing a + * client that has been closed, or a socket closed under a call in progress. Anything else - a duplicate key + * on a unique index, say - is a genuine failure that merely coincided with the shutdown, and is not to be + * reported as an orderly one. + */ + private static boolean explainedByShutdown(Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause() == cause ? null : cause.getCause()) { + if (cause instanceof InterruptedException || cause instanceof MongoInterruptedException || + cause instanceof IllegalStateException || cause instanceof MongoSocketException) { + return true; + } + } + return false; + } + @SuppressWarnings("unchecked") private static void rethrow(Exception failure) throws E { throw (E) failure; @@ -817,6 +867,12 @@ private static final class ExistingIndexes { /** Why the last listing failed, for the conflict it leaves unreconciled to report. */ private RuntimeException listingFailure; + /** + * False once a listing of this collection has failed, whether its first or a re-listing after a conflict. + * Its declarations are then applied without being classified; other collections keep their breakdown. + */ + private boolean readable = true; + private ExistingIndexes(com.mongodb.client.MongoCollection collection, IndexBuildSummary summary) { this.collection = collection; this.summary = summary; @@ -839,6 +895,7 @@ private List refresh() { try { indexes = collection.listIndexes().into(new ArrayList<>()); listingFailure = null; + readable = true; } 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. @@ -846,7 +903,7 @@ private List refresh() { collection.getNamespace().getCollectionName(), e.getMessage(), e); indexes = null; listingFailure = e; - summary.classified = false; + readable = false; } return indexes; } @@ -866,12 +923,19 @@ private void record(Document keys, String name, Long expireAfterSeconds) { indexes.add(index); } - private boolean contains(Document keys) { - if (!summary.classified) { - return false; + /** + * @return whether an index on these keys was already there, or {@code null} if that is not known: the + * build is not classifying, or this collection's indexes could not be listed + */ + private Boolean contains(Document keys) { + if (!summary.classifying || !readable) { + return null; } List existing = get(); - return existing != null && findIndexByKeyPattern(existing, keys) != null; + if (existing == null) { + return null; + } + return findIndexByKeyPattern(existing, keys) != null; } } @@ -896,13 +960,14 @@ private static final class IndexBuildSummary { private int failures; + /** Applied on a collection whose existing indexes could not be listed, so neither created nor present. */ + private int unclassified; + /** - * False when classification was not asked for, or once an entity's existing indexes could not be - * listed, which is the only thing that separates a created index from one that was already there. - * The summary then falls back to reporting how many declarations were applied without saying - * which did work. + * Whether created and already-present indexes are to be told apart at all, which costs a listing per + * collection and is only worth it when the summary will be logged. */ - private boolean classified; + private final boolean classifying; /** * One listing per collection rather than per entity: every class in an inheritance hierarchy maps @@ -911,8 +976,8 @@ private static final class IndexBuildSummary { */ private final Map existingIndexes = new HashMap<>(); - private IndexBuildSummary(boolean classified) { - this.classified = classified; + private IndexBuildSummary(boolean classifying) { + this.classifying = classifying; } private ExistingIndexes existingIndexesOf(com.mongodb.client.MongoCollection collection) { @@ -921,20 +986,25 @@ private ExistingIndexes existingIndexesOf(com.mongodb.client.MongoCollection 0 || unclassified == 0) { outcome.append(created).append(" created, "); if (recreated > 0) { outcome.append(recreated).append(" recreated, "); } outcome.append(alreadyPresent).append(" already present"); + if (unclassified > 0) { + // Partial: what the collections that could be listed established still stands. + outcome.append(", ").append(unclassified).append(" applied without a listing"); + } } else { - outcome.append(applied()).append(" index declaration(s) applied"); + // Nothing was classified, whether because it was not asked for or no collection could be listed. + outcome.append(unclassified).append(" index declaration(s) applied"); if (recreated > 0) { outcome.append(", ").append(recreated).append(" recreated"); } @@ -1334,8 +1404,8 @@ protected void initializeIndices(final PersistentEntity entity) { private void initializeIndices(final PersistentEntity entity, final IndexBuildSummary summary) { if (!buildIndexes) { - LOG.debug("Index creation is disabled by [{} = false]. Skipping the indexes declared by entity [{}].", - MongoSettings.SETTING_BUILD_INDEXES, entity.getName()); + LOG.debug("Index creation is disabled for connection [{}] by [{} = false]. Skipping the indexes declared " + + "by entity [{}].", connectionName(), buildIndexesSettingName(), entity.getName()); return; } final com.mongodb.client.MongoCollection collection = getCollection(entity); @@ -1425,19 +1495,22 @@ private void createOrUpdateIndex(PersistentEntity entity, } // Asked before the index is created, while the answer still means something. - boolean present = existingIndexes.contains(keys); + Boolean present = existingIndexes.contains(keys); long startedAt = System.nanoTime(); try { String indexName = collection.createIndex(keys, indexOptions); existingIndexes.record(keys, indexName, expireAfterSeconds); - if (present) { + if (present == null) { + summary.unclassified++; + } + else if (present) { summary.alreadyPresent++; } else { summary.created++; } // Unclassified, nothing says whether the index was new, so the line does not claim either. - String applied = !summary.classified ? "Applied" : present ? "Confirmed" : "Created"; + String applied = present == null ? "Applied" : present ? "Confirmed" : "Created"; LOG.debug("{} index for entity [{}] {} in {}ms", applied, entity.getName(), descriptor, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)); } catch (MongoCommandException e) { @@ -1707,11 +1780,13 @@ private void stopIndexBuild() { */ private void resumeIndexBuild() { ExecutorService stopped = this.indexBuildExecutor; - if (stopped == null) { + if (stopped == null || !stopped.isShutdown()) { + // Never shut down - a connection added while the datastore was stopped - so no build of it was + // interrupted, and one requested since was submitted rather than deferred. return; } try { - if (!stopped.awaitTermination(INDEX_BUILD_EXIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + if (!stopped.awaitTermination(INDEX_BUILD_EXIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { // Still running: it may yet finish, but assuming it will not costs only a repeat of work // that is idempotent. indexBuildPending = true; diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy index 018be2a56aa..8121fcedc49 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesBackgroundFailureSpec.groovy @@ -125,6 +125,38 @@ class BuildIndexesBackgroundFailureSpec extends AutoStartedMongoSpec { backgroundBuildFailures() == errorsBefore } + void "test a genuine failure that coincides with shutdown is still reported as an error"() { + given: + def conditions = new PollingConditions(timeout: 30) + def buildReached = new CountDownLatch(1) + def release = new CountDownLatch(1) + int errorsBefore = backgroundBuildFailures() + MongoClient failsDuringShutdown = FailingMongoClient.wrap(realClient, 'createIndex') { + buildReached.countDown() + // A command already on the server does not stop for the interrupt; this one then fails in its own right + while (true) { + try { + release.await() + break + } + catch (InterruptedException ignored) { + } + } + throw new MongoException('E11000 duplicate key error collection: backgroundFailureDb.shutdownFailureThing') + } + def datastore = asyncDatastoreOn(failsDuringShutdown, 'backgroundFailureDb', ShutdownFailureThing) + buildReached.await(30, TimeUnit.SECONDS) + + when: "the datastore is closed while the build is on its way to failing" + datastore.close() + release.countDown() + + then: "the shutdown does not explain a duplicate key, so it is reported as the failure it is" + conditions.eventually { + assert backgroundBuildFailures() == errorsBefore + 1 + } + } + private int backgroundBuildFailures() { log.events.count { it.level == Level.ERROR && it.formattedMessage.contains('The background index build failed') @@ -143,6 +175,17 @@ class FailedBackgroundThing { } } +@Entity +class ShutdownFailureThing { + String name + + static mapping = { + version false + collection 'shutdownFailureThing' + name index: true + } +} + @Entity class AbandonedBackgroundThing { String name diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy index 2c207fa7594..2d94464b9f7 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy @@ -157,7 +157,7 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { then: "the request is deferred, not refused" notThrown(Exception) log.events.any { - it.level == Level.INFO && it.formattedMessage.contains('will run when the datastore is restarted') + it.level == Level.INFO && it.formattedMessage.contains('for connection [default] while the datastore is stopped; it will run when the datastore is restarted') } when: "the index is dropped on the server and the datastore restarted" @@ -174,6 +174,26 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { log?.close() } + void "test a build on the calling thread requested after close() is refused with a warning"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + def datastore = new MongoDatastore(['grails.mongodb.url': dbContainer.getReplicaSetUrl('closedSyncDb')] as Map, + ClosedSyncThing) + datastore.close() + + when: "a synchronous build is requested, which would otherwise run against the closed client" + datastore.buildIndex() + + then: + notThrown(Exception) + log.events.any { + it.level == Level.WARN && it.formattedMessage.contains('requested for connection [default] after the datastore was closed') + } + + cleanup: + log?.close() + } + void "test a build requested after close() is refused with a warning rather than an exception"() { given: def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) @@ -186,7 +206,7 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { then: notThrown(Exception) log.events.any { - it.level == Level.WARN && it.formattedMessage.contains('requested after the datastore was closed') + it.level == Level.WARN && it.formattedMessage.contains('requested for connection [default] after the datastore was closed') } cleanup: @@ -251,6 +271,17 @@ class SlowToStopDatastore extends MongoDatastore { } } +@Entity +class ClosedSyncThing { + String name + + static mapping = { + version false + collection 'closedSyncThing' + name index: true + } +} + @Entity class SlowToStopThing { String name diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesPartialClassificationSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesPartialClassificationSpec.groovy new file mode 100644 index 00000000000..98f8376aaf3 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesPartialClassificationSpec.groovy @@ -0,0 +1,141 @@ +/* + * 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.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +import ch.qos.logback.classic.Level +import com.mongodb.MongoException +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import com.mongodb.client.MongoCollection +import com.mongodb.client.model.IndexOptions +import grails.gorm.annotation.Entity +import org.bson.Document +import spock.lang.AutoCleanup +import spock.lang.Shared + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.DatastoreUtils +import org.grails.datastore.mapping.mongo.MongoDatastore + +/** + * A collection whose indexes cannot be listed - on its first listing, or on the re-listing a conflict + * makes - cannot say which of its indexes were new. That is a reason not to classify that collection, not + * to discard what the other collections' listings established. + */ +class BuildIndexesPartialClassificationSpec extends AutoStartedMongoSpec { + + static final String DATABASE = 'partialClassificationDb' + + @Shared + @AutoCleanup + MongoDatastore datastore + + @Shared + MongoClient realClient + + @Shared + String summary + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl(DATABASE)) + // Conflicts with the declared expiry, so its collection is listed a second time to reconcile it + realClient.getDatabase(DATABASE).getCollection('relistedThing') + .createIndex(new Document('created', 1), new IndexOptions().expireAfter(999L, TimeUnit.SECONDS)) + + def relistings = new AtomicInteger() + MongoClient partlyUnlistable = FailingMongoClient.wrap(realClient, 'listIndexes') { Closure proceed, MongoCollection collection -> + String name = collection.namespace.collectionName + if (name == 'unlistedThing') { + throw new MongoException('not authorized on partialClassificationDb to execute command listIndexes') + } + if (name == 'relistedThing' && relistings.incrementAndGet() > 1) { + throw new MongoException('connection reset while re-listing') + } + proceed() + } + + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + datastore = new MongoDatastore(partlyUnlistable, + DatastoreUtils.createPropertyResolver(['grails.mongodb.databaseName': DATABASE]), + ListedThing, UnlistedThing, RelistedThing) + summary = log.events*.formattedMessage.find { it.contains("database [$DATABASE]") } + log.close() + } + + void cleanupSpec() { + realClient?.close() + } + + void "test a collection that could not be listed is left unclassified without discarding the others"() { + expect: "the listed collection's index is still counted as created" + summary.contains('1 created, 0 already present') + + and: "the collection whose listing was refused is applied without being classified" + summary.contains('1 applied without a listing') + + and: "the conflict that could not be re-listed is a failure, as before" + summary.contains('1 failed') + } + + void "test the unlisted collection's index is still created"() { + expect: + [name: 1] in realClient.getDatabase(DATABASE).getCollection('unlistedThing').listIndexes()*.key + } +} + +@Entity +class ListedThing { + String name + + static mapping = { + version false + collection 'listedThing' + name index: true + } +} + +@Entity +class UnlistedThing { + String name + + static mapping = { + version false + collection 'unlistedThing' + name index: true + } +} + +@Entity +class RelistedThing { + Date created + + static mapping = { + version false + collection 'relistedThing' + created index: true, indexAttributes: [expireAfterSeconds: 100] + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy index 9de4f8387e5..985026ce652 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FailingMongoClient.groovy @@ -45,7 +45,7 @@ class FailingMongoClient { * @param failingMethod the driver method to intercept, by name * @param onCall what to do in its place — throw, block until released, or decide call by call. It * receives a closure that makes the real call, for an interception that lets some calls - * through. + * through, and, if it takes a second parameter, the database or collection being called. */ static MongoClient wrap(MongoClient delegate, String failingMethod, Closure onCall) { (MongoClient) proxy(MongoClient, delegate, failingMethod, onCall) @@ -57,7 +57,9 @@ class FailingMongoClient { @Override Object invoke(Object proxyInstance, Method method, Object[] args) { if (method.name == failingMethod) { - return onCall.call({ -> invokeTarget(target, method, args) }) + Closure proceed = { -> invokeTarget(target, method, args) } + // A two-argument interceptor also gets the object called, to tell one collection from another + return onCall.maximumNumberOfParameters >= 2 ? onCall.call(proceed, target) : onCall.call(proceed) } Object result = invokeTarget(target, method, args) if (result instanceof MongoDatabase) { diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy index a71b4e1d1ab..0bccb4beee6 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -178,6 +178,13 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { 'grails.mongodb.connections' : [checkpointedChild: [url: dbContainer.getReplicaSetUrl('checkpointedChildDb'), buildIndexes: true]] ]), factory, new DefaultApplicationEventPublisher(), AsyncPerConnectionThing) buildReached.await(30, TimeUnit.SECONDS) + + then: "the build announces which connection it is for" + log.events.any { + it.formattedMessage.startsWith('Building the indexes declared by the domain classes for connection [checkpointedChild]') + } + + when: parent.stop() then: "the child's build is abandoned as a shutdown, not reported as a failure" @@ -204,6 +211,41 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { log?.close() } + void "test start() neither waits on nor re-runs a connection added while the datastore was stopped"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + String caller = Thread.currentThread().name + def parent = new MongoDatastore(DatastoreUtils.createPropertyResolver([ + 'grails.mongodb.url' : dbContainer.getReplicaSetUrl('stoppedParentDb'), + 'grails.mongodb.buildIndexes' : false, + 'grails.mongodb.buildIndexesAsync': true + ]), AsyncPerConnectionThing) + parent.stop() + parent.connectionSources.addConnectionSource('addedWhileStopped', + [url: dbContainer.getReplicaSetUrl('addedWhileStoppedDb'), buildIndexes: true]) + conditions.eventually { + assert log.events.any { it.formattedMessage.contains('database [addedWhileStoppedDb] finished') } + } + def announced = { -> + log.events.count { + it.threadName == caller && + it.formattedMessage.startsWith('Building the indexes declared by the domain classes for connection [addedWhileStopped]') + } + } + int before = announced() + + when: "the datastore is restarted" + parent.start() + + then: "that connection's build was never interrupted, so start() starts no second one" + announced() == before + + cleanup: + parent?.close() + log?.close() + } + void "test a connection added after close() starts no index build"() { given: def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) From 7d927e10be7aacf32e81a27acb3106855de93844 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 16:42:33 -0700 Subject: [PATCH 27/28] Let an application build its indexes on demand with buildIndexes off grails.mongodb.buildIndexes = false made MongoDatastore.buildIndex() a no-op, so an application that deploys without touching its indexes had no way to build them later - after a deployment has been verified, from an administrative action, or on a schedule - short of flipping the setting and restarting. The setting now governs only the builds GORM starts by itself: at startup, for each named connection, for a connection added at runtime and for a domain class registered later. An explicit buildIndex() always builds, refusing after close() and deferring while stopped as before. It honours buildIndexesAsync, so the index build executor now exists whenever builds are asynchronous rather than only when GORM builds by itself; it holds no thread until a build is submitted. Each named connection builds its own domain classes, through its own datastore. The guide gains a "Building Indexes on Demand" section with the example, and the configuration reference, release notes, setting Javadoc and configuration metadata describe the setting as what GORM does by itself. --- .../mapping/mongo/MongoDatastore.java | 69 ++++--- .../mapping/mongo/config/MongoSettings.groovy | 17 +- ...stractMongoConnectionSourceSettings.groovy | 20 +- .../mongo/BuildIndexesDisabledSpec.groovy | 13 +- .../mongo/BuildIndexesOnDemandSpec.groovy | 192 ++++++++++++++++++ .../gettingStarted/advancedConfig.adoc | 6 +- .../asciidoc/introduction/releaseNotes.adoc | 2 +- .../docs/asciidoc/querying/queryIndexes.adoc | 38 +++- ...itional-spring-configuration-metadata.json | 4 +- 9 files changed, 296 insertions(+), 65 deletions(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index 8d8849c4e76..bbee1c96729 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -267,7 +267,9 @@ public MongoDatastore(final ConnectionSources getConnecti } /** - * Builds the MongoDB index for this datastore. + * Creates and reconciles the indexes declared by the domain classes mapped to this datastore's connection. + * + *

GORM calls this itself when the datastore starts, unless {@code grails.mongodb.buildIndexes} is + * {@code false}; that setting only stops GORM building by itself, so an application that leaves indexes + * alone at startup can call this when it chooses to build them. Each named connection builds its own + * domain classes: call it on {@link #getDatastoreForConnection(String)} for those. * *

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. + * so this blocks the calling thread for as long as MongoDB takes to build every declared index. With + * {@code grails.mongodb.buildIndexesAsync} enabled the work goes to a background thread and this + * returns immediately instead. */ public void buildIndex() { String connection = connectionName(); - if (!buildIndexes) { - LOG.info("Index creation is disabled for connection [{}] 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.", connection, buildIndexesSettingName()); - return; - } // Before either mode: a build on the calling thread would otherwise run against the client close() is closing. if (closed) { LOG.warn("An index build was requested for connection [{}] after the datastore was closed, so it was not started.", @@ -689,6 +689,20 @@ public void buildIndex() { } } + /** + * The builds GORM starts by itself - at startup, and for a connection added at runtime - which + * {@code grails.mongodb.buildIndexes = false} turns off. An explicit {@link #buildIndex()} is not affected. + */ + private void buildIndexAutomatically() { + if (!buildIndexes) { + LOG.info("Index creation on startup is disabled for connection [{}] by [{} = false]. The indexes already " + + "present on the server are left untouched; call buildIndex() to create the declared ones.", + connectionName(), buildIndexesSettingName()); + return; + } + buildIndex(); + } + private String connectionName() { return connectionSources.getDefaultConnectionSource().getName(); } @@ -1203,11 +1217,12 @@ public boolean isTransactionsEnabled() { } /** - * Whether GORM creates and reconciles the indexes declared in the domain class mapping blocks when - * the datastore starts. Disabled with {@code grails.mongodb.buildIndexes = false}, which leaves the - * indexes on the server exactly as they are. + * Whether GORM creates and reconciles the indexes declared in the domain class mapping blocks by itself: + * when the datastore starts, and for connections and domain classes added later. Disabled with + * {@code grails.mongodb.buildIndexes = false}, which leaves the indexes on the server as they are until + * {@link #buildIndex()} is called. * - * @return {@code true} if declared indexes are created on startup + * @return {@code true} if GORM builds the declared indexes by itself * @since 8.0 */ public boolean isBuildIndexes() { @@ -1215,8 +1230,9 @@ public boolean isBuildIndexes() { } /** - * Whether the startup index build runs on a background thread instead of blocking the thread that - * creates the datastore. Enabled with {@code grails.mongodb.buildIndexesAsync = true}. + * Whether index builds run on a background thread instead of blocking the thread that starts them - the + * one GORM starts on startup, and any {@link #buildIndex()} the application calls. Enabled with + * {@code grails.mongodb.buildIndexesAsync = true}. * * @return {@code true} if declared indexes are built asynchronously * @since 8.0 @@ -1316,7 +1332,7 @@ public void persistentEntityAdded(PersistentEntity entity) { } }); - buildIndex(); + buildIndexAutomatically(); return new MongoGormEnhancer(this, transactionManager, settings) { @Override @@ -1403,11 +1419,6 @@ protected void initializeIndices(final PersistentEntity entity) { } private void initializeIndices(final PersistentEntity entity, final IndexBuildSummary summary) { - if (!buildIndexes) { - LOG.debug("Index creation is disabled for connection [{}] by [{} = false]. Skipping the indexes declared " + - "by entity [{}].", connectionName(), buildIndexesSettingName(), entity.getName()); - return; - } final com.mongodb.client.MongoCollection collection = getCollection(entity); final ExistingIndexes existingIndexes = summary.existingIndexesOf(collection); final ClassMapping classMapping = entity.getMapping(); @@ -1694,6 +1705,12 @@ String getMongoFieldNameForProperty(PersistentProperty property) } public void persistentEntityAdded(PersistentEntity entity) { + // GORM indexing a domain class registered after startup by itself, so it follows the setting. + if (!buildIndexes) { + LOG.debug("Index creation is disabled for connection [{}] by [{} = false]. Skipping the indexes declared " + + "by entity [{}].", connectionName(), buildIndexesSettingName(), entity.getName()); + return; + } initializeIndices(entity); } diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy index ea97e2398de..c1dcfb19730 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy @@ -104,23 +104,24 @@ interface MongoSettings extends Settings { String SETTING_ENGINE = 'grails.mongodb.engine' /** - * Whether GORM creates and reconciles the indexes declared in domain class mapping blocks - * when the datastore starts. Defaults to {@code true}. + * Whether GORM creates and reconciles the indexes declared in domain class mapping blocks by + * itself when the datastore starts. Defaults to {@code true}. * *

Set to {@code false} to leave the indexes on the server exactly as they are, which is * useful when deploying against live data where index changes are applied separately by a - * DBA or a migration step rather than by the application on startup. + * DBA or a migration step rather than by the application on startup. The application can still + * build them when it chooses with {@code MongoDatastore.buildIndex()}. * * @since 8.0 */ String SETTING_BUILD_INDEXES = 'grails.mongodb.buildIndexes' /** - * Whether the startup index build runs on a background thread rather than blocking the thread - * that creates the datastore. Defaults to {@code false}, which is the historical behavior: - * startup waits for MongoDB to finish building every declared index. - * - *

Has no effect when {@link #SETTING_BUILD_INDEXES} is {@code false}. + * Whether index builds run on a background thread rather than blocking the thread that starts + * them: the one GORM runs when the datastore starts, and any the application starts with + * {@code MongoDatastore.buildIndex()}, including with {@link #SETTING_BUILD_INDEXES} off. + * Defaults to {@code false}, which is the historical behavior: startup waits for MongoDB to + * finish building every declared index. * * @since 8.0 */ diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy index ab6957aeaa6..42d52109431 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/connections/AbstractMongoConnectionSourceSettings.groovy @@ -98,22 +98,22 @@ abstract class AbstractMongoConnectionSourceSettings extends ConnectionSourceSet boolean decimalType = true /** - * Whether GORM creates and reconciles the indexes declared in domain class mapping blocks - * when the datastore starts. When {@code false} the indexes on the server are left exactly - * as they are and no {@code createIndex} or {@code collMod} command is issued; queries are - * unaffected and continue to use whatever indexes already exist. Bound from - * {@code grails.mongodb.buildIndexes}. + * Whether GORM creates and reconciles the indexes declared in domain class mapping blocks by + * itself when the datastore starts. When {@code false} the indexes on the server are left exactly + * as they are and GORM issues no {@code createIndex} or {@code collMod} command of its own until + * the application calls {@code MongoDatastore.buildIndex()}; queries are unaffected and continue to + * use whatever indexes already exist. Bound from {@code grails.mongodb.buildIndexes}. * * @since 8.0 */ boolean buildIndexes = true /** - * Whether the startup index build runs on a background thread instead of blocking the thread that - * creates the datastore. MongoDB answers a {@code createIndex} command only once the index has been - * built, so with the default {@code false} an application waits at startup for every declared index. - * Ignored when {@link #buildIndexes} is {@code false}. Bound from - * {@code grails.mongodb.buildIndexesAsync}. + * Whether index builds run on a background thread instead of blocking the thread that starts them: + * the startup build, and any started with {@code MongoDatastore.buildIndex()}, whatever + * {@link #buildIndexes} says. MongoDB answers a {@code createIndex} command only once the index has + * been built, so with the default {@code false} an application waits at startup for every declared + * index. Bound from {@code grails.mongodb.buildIndexesAsync}. * * @since 8.0 */ diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy index 188a4affa74..9ebbaf15e20 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesDisabledSpec.groovy @@ -27,11 +27,12 @@ import org.grails.datastore.mapping.mongo.MongoDatastore import org.grails.datastore.mapping.mongo.config.MongoSettings /** - * Verifies that with {@code grails.mongodb.buildIndexes = false} GORM issues no index commands: the - * indexes declared in the mapping block are neither created nor reconciled, so whatever indexes are + * Verifies that with {@code grails.mongodb.buildIndexes = false} GORM issues no index commands of its own: + * the indexes declared in the mapping block are neither created nor reconciled, so whatever indexes are * already on the server are left untouched. Persistence and querying are unaffected. * * @see BuildIndexesEnabledByDefaultSpec for the default behavior with the same mapping declarations + * @see BuildIndexesOnDemandSpec for the application building them when it chooses */ class BuildIndexesDisabledSpec extends AutoStartedMongoSpec { @@ -73,14 +74,6 @@ class BuildIndexesDisabledSpec extends AutoStartedMongoSpec { declaredIndexKeys() == [[_id: 1]] } - void "test an explicit index build is a no-op while disabled"() { - when: "index building is requested directly" - datastore.buildIndex() - - then: "no index was created" - declaredIndexKeys() == [[_id: 1]] - } - void "test registering an entity after startup does not create its indexes"() { given: def entity = datastore.mappingContext.getPersistentEntity(SkippedIndexThing.name) diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy new file mode 100644 index 00000000000..5af7235ce96 --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy @@ -0,0 +1,192 @@ +/* + * 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 ch.qos.logback.classic.Level +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import grails.gorm.annotation.Entity +import spock.lang.Shared +import spock.util.concurrent.PollingConditions + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.core.connections.ConnectionSource +import org.grails.datastore.mapping.mongo.MongoDatastore +import org.grails.datastore.mapping.mongo.config.MongoSettings + +/** + * {@code grails.mongodb.buildIndexes = false} stops GORM building indexes by itself. It does not stop the + * application building them when it chooses to, by calling {@link MongoDatastore#buildIndex()} - once a + * deployment has been verified, say, or from an administrative action. + */ +class BuildIndexesOnDemandSpec extends AutoStartedMongoSpec { + + @Shared + MongoClient realClient + + @Override + boolean shouldInitializeDatastore() { + false + } + + void setupSpec() { + realClient = MongoClients.create(dbContainer.getReplicaSetUrl('onDemandDb')) + } + + void cleanupSpec() { + realClient?.close() + } + + private List indexKeys(String database, String collection) { + realClient.getDatabase(database).getCollection(collection).listIndexes()*.key + } + + private Map config(String database, Map extra = [:]) { + ['grails.mongodb.url' : dbContainer.getReplicaSetUrl(database), + (MongoSettings.SETTING_BUILD_INDEXES): false] + extra + } + + void "test an explicit build creates the declared indexes while GORM does not build them by itself"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + def datastore = new MongoDatastore(config('onDemandSyncDb'), OnDemandSyncThing) + + expect: "nothing was built at startup" + !([name: 1] in indexKeys('onDemandSyncDb', 'onDemandSyncThing')) + + when: "the application asks for the build" + datastore.buildIndex() + + then: "it runs on the calling thread, creates the declared index and reports it like the startup build" + [name: 1] in indexKeys('onDemandSyncDb', 'onDemandSyncThing') + log.events*.formattedMessage.any { + it.startsWith('Index build for database [onDemandSyncDb] finished') && it.contains('1 created, 0 already present') + } + + cleanup: + datastore?.close() + log?.close() + } + + void "test an explicit build runs on the background thread when builds are asynchronous"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + def datastore = new MongoDatastore(config('onDemandAsyncDb', [(MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true]), + OnDemandAsyncThing) + + when: + datastore.buildIndex() + + then: "the declared index is built, and by the connection's own index build thread" + conditions.eventually { + assert [name: 1] in indexKeys('onDemandAsyncDb', 'onDemandAsyncThing') + assert log.events.any { + it.formattedMessage.startsWith('Index build for database [onDemandAsyncDb] finished') && + it.threadName.startsWith('gorm-mongo-index-build-default-') + } + } + + cleanup: + datastore?.close() + log?.close() + } + + void "test a named connection is built through its own datastore"() { + given: + def datastore = new MongoDatastore(config('onDemandDefaultDb', [ + 'grails.mongodb.connections': [reporting: [url: dbContainer.getReplicaSetUrl('onDemandReportingDb')]] + ]), OnDemandConnectionThing) + + when: + (datastore.getDatastoreForConnection('reporting') as MongoDatastore).buildIndex() + + then: "that connection's index is built, and the default connection is left as it was" + [name: 1] in indexKeys('onDemandReportingDb', 'onDemandConnectionThing') + !([name: 1] in indexKeys('onDemandDefaultDb', 'onDemandConnectionThing')) + + cleanup: + datastore?.close() + } + + void "test a restart does not build what the setting says GORM should not build by itself"() { + given: + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + String caller = Thread.currentThread().name + def datastore = new MongoDatastore(config('onDemandRestartDb', [(MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true]), + OnDemandRestartThing) + + when: + datastore.stop() + datastore.start() + + then: + !log.events.any { it.threadName == caller && it.formattedMessage.startsWith('Building the indexes declared') } + !([name: 1] in indexKeys('onDemandRestartDb', 'onDemandRestartThing')) + + cleanup: + datastore?.close() + log?.close() + } +} + +@Entity +class OnDemandSyncThing { + String name + + static mapping = { + version false + collection 'onDemandSyncThing' + name index: true + } +} + +@Entity +class OnDemandAsyncThing { + String name + + static mapping = { + version false + collection 'onDemandAsyncThing' + name index: true + } +} + +@Entity +class OnDemandConnectionThing { + String name + + static mapping = { + version false + collection 'onDemandConnectionThing' + connection ConnectionSource.ALL + name index: true + } +} + +@Entity +class OnDemandRestartThing { + String name + + static mapping = { + version false + collection 'onDemandRestartThing' + name index: true + } +} diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc index d85edc87945..623dafca4fb 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc @@ -102,8 +102,8 @@ grails { username = ".." // the username to connect with password = ".." // the password to connect with stateless = false // whether to use stateless sessions by default - buildIndexes = true // whether to create the indexes declared in mapping blocks on startup - buildIndexesAsync = false // whether that index build runs on a background thread instead of blocking startup + buildIndexes = true // whether GORM creates the indexes declared in mapping blocks by itself on startup + buildIndexesAsync = false // whether index builds run on a background thread instead of blocking the caller // Alternatively, using 'url' // url = "mongodb://localhost/mydb" @@ -161,7 +161,7 @@ Selecting it logs a warning at startup. ==== Index Creation on Startup -GORM creates and reconciles the indexes declared in your mapping blocks each time the datastore starts, and startup waits for MongoDB to finish building them. Set `grails.mongodb.buildIndexes = false` to leave the indexes on the server untouched instead, or `grails.mongodb.buildIndexesAsync = true` to keep building them but on a background thread — see <> for details and when to use each. +GORM creates and reconciles the indexes declared in your mapping blocks each time the datastore starts, and startup waits for MongoDB to finish building them. Set `grails.mongodb.buildIndexes = false` to leave the indexes on the server untouched instead, and build them with `MongoDatastore.buildIndex()` when you choose, or `grails.mongodb.buildIndexesAsync = true` to keep building them but on a background thread — see <> for details and when to use each. ==== Multi-Document Transactions diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc index 742bb8618cb..0d5c4f059f7 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/introduction/releaseNotes.adoc @@ -25,7 +25,7 @@ Below are the details of the changes across releases: * TTL indexes via `indexAttributes: [expireAfterSeconds: N]` in the mapping DSL * Text and other special indexes via `indexAttributes: [type: 'text']` * In-place reconciliation of changed index options, with opt-in `indexAttributes: [recreateOnConflict: true]` -* Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` +* Index creation on startup can be switched off with `grails.mongodb.buildIndexes = false`, and run on demand with `MongoDatastore.buildIndex()`, or moved off the startup thread with `grails.mongodb.buildIndexesAsync = true` * `grails.gorm` and `grails.mongodb` settings are now applied when GORM is given an existing `MongoClient`, such as the one contributed by Spring Boot. Configuration that was previously ignored on this path — `failOnError`, the default mapping and constraints, flush behaviour and multi-tenancy — now takes effect. See <> before upgrading an application that supplies its own client. * The index build reports what it created, what was already present and how long it took, in one summary line per build diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index 58107e5013f..f8b89abe8b9 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -165,7 +165,7 @@ grails { } ---- -With this setting no `createIndex` or `collMod` command is issued for any domain class, and the indexes already present on the server are left exactly as they are. Queries are unaffected and continue to use whichever indexes exist. This is useful when deploying against live data whose indexes are managed separately — by a DBA or a migration step — so that a deployment does not build an index against a large production collection, and so an application running against an older index set does not have those indexes reconciled underneath it. +With this setting GORM issues no `createIndex` or `collMod` command by itself, for any domain class, and the indexes already present on the server are left exactly as they are until the application builds them (see <> below). Queries are unaffected and continue to use whichever indexes exist. This is useful when deploying against live data whose indexes are managed separately — by a DBA or a migration step — so that a deployment does not build an index against a large production collection, and so an application running against an older index set does not have those indexes reconciled underneath it. It also suppresses index creation for domain classes registered after startup. Declared at the top level the setting applies to every connection, and each connection can override it: @@ -180,14 +180,42 @@ grails: buildIndexes: true ---- -NOTE: Because nothing is created, a collection that has never been initialised with `buildIndexes` enabled will have no declared indexes at all. Turn the setting off only where the indexes are already in place or are applied by other means. The setting governs only the indexes GORM derives from the mapping blocks; an explicit `createIndex` call made by application code against a collection is unaffected. +NOTE: Because nothing is created at startup, a collection that has never been built with `buildIndexes` enabled, or by an explicit build, has no declared indexes at all. Turn the setting off only where the indexes are already in place or are applied by other means. The setting governs only the indexes GORM derives from the mapping blocks; an explicit `createIndex` call made by application code against a collection is unaffected. + +[[buildIndexesOnDemand]] +===== Building Indexes on Demand + +Turning `buildIndexes` off stops GORM building indexes by itself. It does not stop the application building them when it chooses: once a deployment has been verified, from an administrative action, or on a schedule. Call `buildIndex()` on the `MongoDatastore` bean: + +[source,groovy] +---- +import org.grails.datastore.mapping.mongo.MongoDatastore + +class IndexMaintenanceService { + + MongoDatastore mongoDatastore + + void buildIndexes() { + mongoDatastore.buildIndex() + } +} +---- + +This creates and reconciles the declared indexes exactly as the startup build would, and logs the same summary. It blocks the calling thread until MongoDB has built every index, unless `buildIndexesAsync` is enabled, in which case it returns at once and the build runs on the background thread. Each named connection builds its own domain classes, so build those through that connection's datastore: + +[source,groovy] +---- +mongoDatastore.getDatastoreForConnection('reporting').buildIndex() +---- + +A build requested after the datastore has been closed is not started, and one requested while it is stopped for a checkpoint runs when it is restarted. ==== Building Indexes in the Background MongoDB answers a `createIndex` command only once the index has been built, so by default the thread that creates the datastore — in an application, the startup thread — waits for every declared index before the application finishes starting. On an empty collection that is instant; on a large existing collection an index build can take minutes, and a deployment waits for all of them in turn. -Set `buildIndexesAsync` to have the startup index build run on a background thread instead: +Set `buildIndexesAsync` to have index builds run on a background thread instead, both the one GORM runs at startup and any the application starts with `buildIndex()`: [source,yaml] ---- @@ -203,7 +231,7 @@ Two consequences are worth planning for: * A query issued before its index has been built is served without it — correctly, but with the performance of an unindexed query. The same applies to a `unique` index: it constrains nothing until the build finishes. * Because startup no longer waits, a failure to build an index can no longer fail startup. It is logged at error level, and the application runs without that index. With the default synchronous build the exception propagates and the application does not start. -The setting is ignored when `buildIndexes` is `false`, and it applies only to the index build performed when the datastore starts — a domain class registered after startup is indexed on the thread registering it. +The setting also applies with `buildIndexes` set to `false`, to the builds the application starts with `buildIndex()`. A domain class registered after startup is indexed on the thread registering it. NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. A datastore stopped for a checkpoint and restored (with CRaC) runs a build it cut short again once it is restarted, on every connection. @@ -218,7 +246,7 @@ Index build for database [myDb] finished in 412ms: 2 created, 5 already present, The domain class count includes every class considered for indexing, including classes that declare no indexes. The created and already-present counts describe index declarations; declaring the same keys twice can report one created and one already present. -The elapsed time is what the caller actually spent waiting — startup with the default settings, or the background thread when `buildIndexesAsync` is enabled, where this line is also the only signal that the build has finished. +The elapsed time is what the caller actually spent waiting — startup, or whoever called `buildIndex()`, with the default settings, or the background thread when `buildIndexesAsync` is enabled, where this line is also the only signal that the build has finished. The split between created and already present is what makes that time interpretable. MongoDB answers a `createIndex` for an index it already has immediately and without building anything, so a restart that changed no mappings reports everything as already present and costs milliseconds; a line reporting indexes created is the one that accounts for a slow start. An index that `recreateOnConflict` dropped and built again costs as much as a new one, and is counted apart as recreated: diff --git a/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 1955f7c6dcc..80c63039b4c 100644 --- a/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -69,13 +69,13 @@ { "name": "grails.mongodb.buildIndexes", "type": "java.lang.Boolean", - "description": "Whether to create the indexes declared in domain class mapping blocks when the datastore starts. When false, the indexes already on the server are left untouched.", + "description": "Whether GORM creates the indexes declared in domain class mapping blocks by itself when the datastore starts. When false, the indexes already on the server are left untouched until MongoDatastore.buildIndex() is called.", "defaultValue": true }, { "name": "grails.mongodb.buildIndexesAsync", "type": "java.lang.Boolean", - "description": "Whether the startup index build runs on a background thread instead of blocking startup. Ignored when grails.mongodb.buildIndexes is false.", + "description": "Whether index builds, the one on startup and any started with MongoDatastore.buildIndex(), run on a background thread instead of blocking the thread that starts them.", "defaultValue": false }, { From 91a7eb489bb29eb79bf9f31d1f7cc62ea8adfd1f Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 21 Sep 2026 17:31:14 -0700 Subject: [PATCH 28/28] Announce a background index build from its own thread and document every summary form The announcement that a build is running on a background thread was logged by the submitting thread after the task was handed over, so a fast build's own lines could come before it. It is now the first thing the submitted task logs. start() logs, on its own thread, when it resumes a build that was pending while the datastore was stopped. The guide now explains the "applied without a listing" count, and says that the "index declaration(s) applied" form appears whenever nothing could be classified, not only for a build with failures. --- .../mapping/mongo/MongoDatastore.java | 15 ++++--- .../gorm/mongo/BuildIndexesAsyncSpec.groovy | 41 +++++++++++++++++++ .../mongo/BuildIndexesLifecycleSpec.groovy | 17 ++++---- .../mongo/BuildIndexesOnDemandSpec.groovy | 5 +-- .../BuildIndexesPerConnectionSpec.groovy | 24 +++-------- .../docs/asciidoc/querying/queryIndexes.adoc | 16 +++++++- 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java index bbee1c96729..23c1af5e202 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoDatastore.java @@ -667,11 +667,14 @@ public void buildIndex() { } if (!executor.isShutdown()) { try { - executor.execute(() -> runIndexBuild(executor)); - // Once submitted, so that it is only ever said of a build that is under way. - LOG.info("Building the indexes declared by the domain classes for connection [{}] on a background " + - "thread. Startup does not wait for them, so a query issued before its index exists is served " + - "without it.", connection); + executor.execute(() -> { + // The first thing the build does: said only of a build that is under way, and ahead of + // everything it logs, which it would not be if the submitting thread said it. + LOG.info("Building the indexes declared by the domain classes for connection [{}] on a " + + "background thread. Startup does not wait for them, so a query issued before its index " + + "exists is served without it.", connection); + runIndexBuild(executor); + }); return; } catch (RejectedExecutionException e) { @@ -1816,6 +1819,8 @@ private void resumeIndexBuild() { this.indexBuildExecutor = newIndexBuildExecutor(connectionSources.getDefaultConnectionSource().getName()); if (indexBuildPending) { indexBuildPending = false; + LOG.info("Resuming the index build for connection [{}] that was pending while the datastore was stopped.", + connectionName()); buildIndex(); } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy index 2ca5f2ad741..1142a958d75 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesAsyncSpec.groovy @@ -20,6 +20,7 @@ package org.grails.datastore.gorm.mongo import java.util.concurrent.ConcurrentLinkedQueue +import ch.qos.logback.classic.Level import com.mongodb.ConnectionString import com.mongodb.MongoClientSettings import com.mongodb.client.MongoClient @@ -158,6 +159,35 @@ class BuildIndexesAsyncSpec extends AutoStartedMongoSpec { then: !subsequentWorker.alive } + + void "test a background build is announced by its own thread, ahead of everything it logs"() { + given: + def conditions = new PollingConditions(timeout: 30) + def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) + def isAnnouncement = { + it.formattedMessage.startsWith('Building the indexes declared by the domain classes for connection [default] on a background thread') + } + def isSummary = { it.formattedMessage.startsWith('Index build for database [announcedIndexDb] finished') } + + when: + def datastore = new MongoDatastore(['grails.mongodb.url' : dbContainer.getReplicaSetUrl('announcedIndexDb'), + (MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true] as Map, + AnnouncedIndexThing) + conditions.eventually { + assert log.events.any(isSummary) + } + def events = log.events + def announcement = events.find(isAnnouncement) + def summary = events.find(isSummary) + + then: "said by the thread that goes on to build, so it cannot trail the summary a fast build logs" + announcement.threadName == summary.threadName + events.indexOf(announcement) < events.indexOf(summary) + + cleanup: + datastore?.close() + log?.close() + } } /** @@ -204,3 +234,14 @@ class AsyncIndexThing { name index: true } } + +@Entity +class AnnouncedIndexThing { + String name + + static mapping = { + version false + collection 'announcedIndexThing' + name index: true + } +} diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy index 2d94464b9f7..3033d82871a 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesLifecycleSpec.groovy @@ -97,7 +97,12 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { when: "the datastore is restarted" datastore.start() - then: "the build that was cut short runs again and completes" + then: "the build that was cut short is resumed" + log.events.any { + it.level == Level.INFO && it.formattedMessage.contains('Resuming the index build for connection [default]') + } + + and: "runs again and completes" conditions.eventually { assert [name: 1] in indexes() } @@ -113,17 +118,10 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) SlowToStopDatastore.REACHED = new CountDownLatch(1) SlowToStopDatastore.RELEASE = new CountDownLatch(1) - String caller = Thread.currentThread().name - def buildsStartedHere = { -> - log.events.count { - it.threadName == caller && it.formattedMessage.startsWith('Building the indexes declared by the domain classes') - } - } when: "the build has applied every index and is on its way out when the datastore is stopped" def datastore = new SlowToStopDatastore(asyncConfig('slowToStopDb'), SlowToStopThing) SlowToStopDatastore.REACHED.await(30, TimeUnit.SECONDS) - int before = buildsStartedHere() datastore.stop() SlowToStopDatastore.RELEASE.countDown() @@ -131,8 +129,8 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { datastore.start() then: "the build finished, so the restart does not run it again" - buildsStartedHere() == before log.events.any { it.formattedMessage.contains('Index build for database [slowToStopDb] finished') } + !log.events.any { it.formattedMessage.startsWith('Resuming the index build') } cleanup: SlowToStopDatastore.RELEASE?.countDown() @@ -165,6 +163,7 @@ class BuildIndexesLifecycleSpec extends AutoStartedMongoSpec { datastore.start() then: "the deferred build runs and puts it back" + log.events.any { it.formattedMessage.contains('Resuming the index build for connection [default]') } conditions.eventually { assert [name: 1] in collection.listIndexes()*.key } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy index 5af7235ce96..b2f16251a41 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/BuildIndexesOnDemandSpec.groovy @@ -128,7 +128,6 @@ class BuildIndexesOnDemandSpec extends AutoStartedMongoSpec { void "test a restart does not build what the setting says GORM should not build by itself"() { given: def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) - String caller = Thread.currentThread().name def datastore = new MongoDatastore(config('onDemandRestartDb', [(MongoSettings.SETTING_BUILD_INDEXES_ASYNC): true]), OnDemandRestartThing) @@ -136,8 +135,8 @@ class BuildIndexesOnDemandSpec extends AutoStartedMongoSpec { datastore.stop() datastore.start() - then: - !log.events.any { it.threadName == caller && it.formattedMessage.startsWith('Building the indexes declared') } + then: "start() says so before returning when it resumes a build; it did not" + !log.events.any { it.formattedMessage.startsWith('Resuming the index build') } !([name: 1] in indexKeys('onDemandRestartDb', 'onDemandRestartThing')) cleanup: diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy index 0bccb4beee6..5219bd9e3cb 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/connections/BuildIndexesPerConnectionSpec.groovy @@ -199,7 +199,8 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { when: "the datastore is restarted" parent.start() - then: "the child's build runs again and completes" + then: "the child's build is resumed, runs again and completes" + log.events.any { it.formattedMessage.contains('Resuming the index build for connection [checkpointedChild]') } conditions.eventually { assert [name: 1] in childIndexes() } @@ -215,7 +216,6 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { given: def conditions = new PollingConditions(timeout: 30) def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) - String caller = Thread.currentThread().name def parent = new MongoDatastore(DatastoreUtils.createPropertyResolver([ 'grails.mongodb.url' : dbContainer.getReplicaSetUrl('stoppedParentDb'), 'grails.mongodb.buildIndexes' : false, @@ -227,19 +227,12 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { conditions.eventually { assert log.events.any { it.formattedMessage.contains('database [addedWhileStoppedDb] finished') } } - def announced = { -> - log.events.count { - it.threadName == caller && - it.formattedMessage.startsWith('Building the indexes declared by the domain classes for connection [addedWhileStopped]') - } - } - int before = announced() when: "the datastore is restarted" parent.start() - then: "that connection's build was never interrupted, so start() starts no second one" - announced() == before + then: "that connection's build was never interrupted, so start() resumes no second one" + !log.events.any { it.formattedMessage.startsWith('Resuming the index build for connection [addedWhileStopped]') } cleanup: parent?.close() @@ -248,8 +241,6 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { void "test a connection added after close() starts no index build"() { given: - def log = new CapturedLog('org.grails.datastore.mapping', Level.INFO) - String caller = Thread.currentThread().name def parent = new MongoDatastore(DatastoreUtils.createPropertyResolver([ 'grails.mongodb.url' : dbContainer.getReplicaSetUrl('closedParentDb'), 'grails.mongodb.buildIndexes' : false, @@ -261,14 +252,11 @@ class BuildIndexesPerConnectionSpec extends AutoStartedMongoSpec { parent.connectionSources.addConnectionSource('addedAfterClose', [url: dbContainer.getReplicaSetUrl('addedAfterCloseDb'), buildIndexes: true]) - then: "a build would have announced itself on this thread before returning; none did" - !log.events.any { - it.threadName == caller && it.formattedMessage.startsWith('Building the indexes declared by the domain classes') - } + then: "a build would have started its thread before returning, and kept it for a second after finishing; none did" + !Thread.getAllStackTraces().keySet().any { it.name.startsWith('gorm-mongo-index-build-addedAfterClose-') } cleanup: parent?.connectionSources?.getConnectionSource('addedAfterClose')?.close() - log?.close() } void "test a connection can override the global setting"() { diff --git a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc index f8b89abe8b9..b707f395e07 100644 --- a/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc +++ b/grails-data-mongodb/docs/src/docs/asciidoc/querying/queryIndexes.adoc @@ -233,7 +233,7 @@ Two consequences are worth planning for: The setting also applies with `buildIndexes` set to `false`, to the builds the application starts with `buildIndex()`. A domain class registered after startup is indexed on the thread registering it. -NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. A datastore stopped for a checkpoint and restored (with CRaC) runs a build it cut short again once it is restarted, on every connection. +NOTE: If the application shuts down while a background build is still running, GORM stops waiting for it, but the server carries on building the index it was asked for. A datastore stopped for a checkpoint and restored (with CRaC) runs a build it cut short again once it is restarted, on every connection, and logs that it is resuming it. ==== What the Index Build Reports @@ -260,7 +260,19 @@ If any declaration failed, the summary is logged at `WARN` instead and reports h Index build for database [myDb] did not finish, stopping after 812ms at 1 of 3 domain class(es): 1 created, 0 already present ---- -Telling a created index from one that was already there takes one `listIndexes` command per collection that declares indexes. It is only issued when the summary will be logged, that is when `INFO` is enabled for `org.grails.datastore.mapping.mongo`; with that logger at `WARN` the build skips it, and a summary for a build with failures reports how many declarations were applied instead. +Telling a created index from one that was already there takes one `listIndexes` command per collection that declares indexes. It is only issued when the summary will be logged, that is when `INFO` is enabled for `org.grails.datastore.mapping.mongo`. + +A collection whose indexes cannot be listed still has its declarations applied; only the split between created and already present is missing for it. The summary counts those declarations separately, and the other collections keep their split: + +---- +Index build for database [myDb] finished in 412ms: 2 created, 5 already present, 3 applied without a listing, from 3 domain class(es) +---- + +When nothing could be classified — the logger is at `WARN`, so the build skips `listIndexes` and a summary is logged only for a build that failed or did not finish, or no collection's indexes could be listed — the summary reports how many declarations were applied instead: + +---- +Index build for database [myDb] finished in 412ms: 10 index declaration(s) applied, 1 failed, from 3 domain class(es) +---- To find which index is the slow one, enable `DEBUG` logging for `org.grails.datastore.mapping.mongo`, which adds a line per index with its own elapsed time: