Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b2691d4
Add grails.mongodb.buildIndexes to skip MongoDB index creation on sta…
codeconsole Aug 23, 2026
2c25dfd
Apply the configured settings when GORM is given an existing MongoClient
codeconsole Aug 23, 2026
5680135
Add grails.mongodb.buildIndexesAsync to build indexes off the startup…
codeconsole Aug 23, 2026
d0d6847
Report what the index build applied and how long it took
codeconsole Aug 23, 2026
bf19174
Report which indexes the build created and which were already there
codeconsole Aug 23, 2026
14ac0c0
Document the MongoDB settings in application.yml, not only applicatio…
codeconsole Aug 23, 2026
6fbb305
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Aug 24, 2026
b873801
Cover the index build's failure and degradation paths
codeconsole Sep 1, 2026
51c977e
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Sep 2, 2026
c431a33
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Sep 3, 2026
2cf25ea
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Sep 4, 2026
fcac207
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Sep 5, 2026
2324ebc
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Sep 8, 2026
ee362fa
Merge branch '8.0.x' into feature/mongodb-skip-index-build-8.0.x
codeconsole Sep 14, 2026
1726f84
Fix MongoDB index build lifecycle and review findings
codeconsole Sep 15, 2026
211ef8d
Fix MongoDB CI logging assertions and activate failOnError test
codeconsole Sep 15, 2026
63776a4
Merge remote-tracking branch 'upstream/8.0.x' into feature/mongodb-sk…
codeconsole Sep 20, 2026
5e3403e
Close the connection source children over a snapshot
codeconsole Sep 20, 2026
4710351
Point the documented logger category at the MongoDatastore logger
codeconsole Sep 20, 2026
4ebb343
Document everything an externally-supplied client now picks up
codeconsole Sep 20, 2026
423ac93
Pin the camel case setting names against a Spring Boot environment
codeconsole Sep 21, 2026
4a4733a
Read a compound index's attributes without consuming the declaration
codeconsole Sep 21, 2026
8a7e1ff
Keep the mapping context's database when the client is supplied
codeconsole Sep 21, 2026
4c07412
Pin the tenant resolver a supplied client ends up with
codeconsole Sep 21, 2026
f4bb946
Hold the per-connection children in a ConcurrentHashMap
codeconsole Sep 21, 2026
fd13f1a
Read captured log events through a copy-on-write list
codeconsole Sep 21, 2026
8482825
Report, interrupt and resume the index build through its whole lifecycle
codeconsole Sep 21, 2026
6fe3738
Stop SchemaBasedMultiTenancySpec dropping MongoDB's config database
codeconsole Sep 21, 2026
abc944e
Merge remote-tracking branch 'upstream/8.0.x' into feature/mongodb-sk…
codeconsole Sep 21, 2026
5cafdb2
Describe the two index-build settings in the plugin's configuration m…
codeconsole Sep 21, 2026
9a88f6e
Compare index key patterns in order
codeconsole Sep 21, 2026
0a12094
Put the logback note beside the dependency rather than in an empty cl…
codeconsole Sep 21, 2026
d8adb08
Tighten the index build's lifecycle, reporting and per-collection lis…
codeconsole Sep 21, 2026
0ffc921
Say which connection details a supplied client leaves out
codeconsole Sep 21, 2026
c6a64e8
Keep the index build's breakdown, errors and restarts honest per conn…
codeconsole Sep 21, 2026
7d927e1
Let an application build its indexes on demand with buildIndexes off
codeconsole Sep 21, 2026
91a7eb4
Announce a background index build from its own thread and document ev…
codeconsole Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion grails-data-mongodb/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ dependencies {
// test: GenericWebApplicationContext requires ServletContext on the classpath
}

testImplementation 'org.slf4j:slf4j-nop' // Prevents warning about missing slf4j implementation during compilation and tests
// 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'
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,30 @@ interface MongoSettings extends Settings {
@Deprecated
String SETTING_ENGINE = 'grails.mongodb.engine'

/**
* Whether GORM creates and reconciles the indexes declared in domain class mapping blocks by
* itself when the datastore starts. Defaults to {@code true}.
*
* <p>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. The application can still
* build them when it chooses with {@code MongoDatastore.buildIndex()}.
*
* @since 8.0
*/
String SETTING_BUILD_INDEXES = 'grails.mongodb.buildIndexes'

/**
* 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
*/
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ abstract class AbstractMongoConnectionSourceSettings extends ConnectionSourceSet
*/
boolean decimalType = true

/**
* 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 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
*/
boolean buildIndexesAsync = false

/**
* Nested settings for domains with {@code String id}. Holds the global default
* {@code defaultStoredAs} switch plus any future string-id configuration.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/*
* 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 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 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.
*
* <p>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-default-')

and: "not by the thread that created the datastore, which did not wait for it"
asyncRecorder.threads.first() != creatingThread

when: "the startup worker has no more work"
Thread startupWorker = asyncRecorder.workers.first()
startupWorker.join(10000)

then: "it is released without closing the datastore"
startupWorker.daemon
!startupWorker.alive

when: "another build is explicitly requested after the worker has exited"
asyncDatastore.buildIndex()

then: "a new worker performs the build"
conditions.eventually {
assert asyncRecorder.workers.size() == 2
}
!asyncRecorder.workers.last().is(startupWorker)

when:
Thread subsequentWorker = asyncRecorder.workers.last()
subsequentWorker.join(10000)

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()
}
}

/**
* Records the thread each {@code createIndexes} command was issued from.
*/
class CreateIndexThreadRecorder implements CommandListener {

private final Queue<Thread> recorded = new ConcurrentLinkedQueue<>()

@Override
void commandStarted(CommandStartedEvent event) {
if (event.commandName == 'createIndexes') {
recorded.add(Thread.currentThread())
}
}

List<String> getThreads() {
recorded.collect { it.name }.unique()
}

List<Thread> getWorkers() {
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
}
}

@Entity
class AnnouncedIndexThing {
String name

static mapping = {
version false
collection 'announcedIndexThing'
name index: true
}
}
Loading
Loading