Skip to content
6 changes: 5 additions & 1 deletion grails-data-mongodb/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,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'
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ 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}.
*
* <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.
*
* @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.
*
* <p>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
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
* 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

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <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')

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<String> recorded = new ConcurrentLinkedQueue<>()

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

List<String> 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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading