From dfe1234cf68023f6ac4765042b5e57a96e1131a5 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Fri, 5 Jun 2026 01:08:45 -0700
Subject: [PATCH 1/6] Add opt-in MongoDB multi-document transactions to GORM
for MongoDB
GORM for MongoDB previously treated a transaction as a client-side flush
boundary: pending writes were batched and flushed on commit, but each write
auto-committed individually and nothing rolled back when a later operation failed.
This adds real server-side transactions backed by a com.mongodb.client.ClientSession.
When grails.mongodb.transactional is enabled (default false), a GORM transaction
starts a ClientSession and MongoDB transaction and every read and write for the
session runs within it, committing or aborting atomically. A new MongoTransaction
drives the commit (retrying on an UnknownTransactionCommitResult) and the abort, and
closes the session afterwards.
The feature is opt-in and degrades gracefully: a standalone topology is detected at
runtime and falls back to the legacy flush-only behavior with a one-time warning.
Identifier generation for native Long ids is intentionally left non-transactional,
mirroring the semantics of database sequences.
---
.../gorm/mongo/api/MongoStaticApi.groovy | 21 +--
.../mapping/mongo/AbstractMongoSession.java | 127 ++++++++++++++
.../mapping/mongo/MongoCodecSession.groovy | 14 +-
.../mapping/mongo/MongoDatastore.java | 53 ++++++
.../datastore/mapping/mongo/MongoSession.java | 21 +--
.../mapping/mongo/MongoTransaction.java | 156 +++++++++++++++++
...stractMongoConnectionSourceSettings.groovy | 10 ++
.../engine/MongoCodecEntityPersister.groovy | 5 +-
.../mongo/engine/MongoEntityPersister.java | 4 +-
.../mapping/mongo/query/MongoQuery.java | 9 +-
.../MongoTransactionDisabledSpec.groovy | 76 ++++++++
.../transactions/MongoTransactionSpec.groovy | 163 ++++++++++++++++++
.../gettingStarted/advancedConfig.adoc | 29 ++++
13 files changed, 640 insertions(+), 48 deletions(-)
create mode 100644 grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java
create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/transactions/MongoTransactionDisabledSpec.groovy
create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/transactions/MongoTransactionSpec.groovy
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
index f460cfb2c2c..8d549d1d975 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
@@ -71,9 +71,9 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations
def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
filter = wrapFilterWithMultiTenancy(filter)
- return session.getCollection(entity)
+ MongoCollection collection = session.getCollection(entity)
.withDocumentClass(persistentClass)
- .find(filter)
+ return session.find(collection, filter)
}
}
@@ -84,10 +84,8 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations mongoCollection = session.getCollection(entity)
.withDocumentClass(persistentClass)
- D result = options ? mongoCollection
- .findOneAndDelete(filter, options) :
- mongoCollection
- .findOneAndDelete(filter)
+ D result = options ? session.findOneAndDelete(mongoCollection, filter, options) :
+ session.findOneAndDelete(mongoCollection, filter)
return result
}
@@ -97,8 +95,7 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations
def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
filter = wrapFilterWithMultiTenancy(filter)
- return session.getCollection(entity)
- .countDocuments(filter)
+ return session.countDocuments(session.getCollection(entity), filter)
}
}
@@ -201,7 +198,7 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations newPipeline = preparePipeline(pipeline)
- AggregateIterable aggregateIterable = mongoCollection.aggregate(newPipeline)
+ AggregateIterable aggregateIterable = session.aggregate(mongoCollection, newPipeline)
if (doWithAggregate != null) {
aggregateIterable = doWithAggregate.apply(aggregateIterable)
}
@@ -216,7 +213,7 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations newPipeline = preparePipeline(pipeline)
def mongoCollection = session.getCollection(persistentEntity)
.withReadPreference(readPreference)
- def aggregateIterable = mongoCollection.aggregate(newPipeline)
+ def aggregateIterable = session.aggregate(mongoCollection, newPipeline)
if (doWithAggregate != null) {
aggregateIterable = doWithAggregate.apply(aggregateIterable)
}
@@ -243,7 +240,7 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations extends GormStaticApi implements MongoAllOperations
protected MongoDatastore mongoDatastore;
protected WriteConcern writeConcern = null;
protected boolean errorOccured = false;
+ protected ClientSession clientSession;
protected Map mongoCollections = new ConcurrentHashMap<>();
protected Map mongoDatabases = new ConcurrentHashMap<>();
@@ -200,6 +213,120 @@ public MongoMappingContext getMappingContext() {
return (MongoMappingContext) super.getMappingContext();
}
+ /**
+ * @return the active {@link ClientSession} for the current MongoDB transaction, or {@code null}
+ * if no server-side transaction is in progress
+ */
+ public ClientSession getClientSession() {
+ return clientSession;
+ }
+
+ /**
+ * @return {@code true} if a server-side MongoDB transaction is currently active on this session
+ */
+ public boolean hasActiveTransaction() {
+ return clientSession != null && clientSession.hasActiveTransaction();
+ }
+
+ /**
+ * Detaches the {@link ClientSession} from this session once its transaction has completed.
+ * Called by {@link MongoTransaction} after commit or rollback closes the session.
+ */
+ void clearClientSession() {
+ this.clientSession = null;
+ }
+
+ /**
+ * Closes and detaches the {@link ClientSession} if one is still attached. Used defensively when a
+ * transaction did not complete through {@link MongoTransaction}, so a session is never leaked.
+ */
+ protected void closeClientSessionQuietly() {
+ if (clientSession != null) {
+ try {
+ clientSession.close();
+ }
+ catch (RuntimeException ignored) {
+ // best effort
+ }
+ finally {
+ clientSession = null;
+ }
+ }
+ }
+
+ @Override
+ public void disconnect() {
+ try {
+ closeClientSessionQuietly();
+ }
+ finally {
+ super.disconnect();
+ }
+ }
+
+ @Override
+ protected Transaction beginTransactionInternal() {
+ if (getDatastore().isTransactionsEnabled()) {
+ // Defensive: if a previous transaction did not complete cleanly, close its orphaned
+ // session before starting a new one so it cannot leak.
+ closeClientSessionQuietly();
+ ClientSession session = getNativeInterface().startSession();
+ try {
+ session.startTransaction();
+ }
+ catch (RuntimeException e) {
+ session.close();
+ throw e;
+ }
+ this.clientSession = session;
+ return new MongoTransaction(this, session);
+ }
+ return new SessionOnlyTransaction<>(getNativeInterface(), this);
+ }
+
+ // The driver exposes a session-less and a ClientSession overload for every operation, and the
+ // session argument cannot be null. These helpers branch once so call sites stay readable and
+ // behave identically (session-less) when no transaction is active.
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public BulkWriteResult bulkWrite(com.mongodb.client.MongoCollection collection, List extends WriteModel> writes) {
+ return clientSession != null ? collection.bulkWrite(clientSession, writes) : collection.bulkWrite(writes);
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public DeleteResult deleteMany(com.mongodb.client.MongoCollection collection, Bson filter) {
+ return clientSession != null ? collection.deleteMany(clientSession, filter) : collection.deleteMany(filter);
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public UpdateResult updateMany(com.mongodb.client.MongoCollection collection, Bson filter, Bson update, UpdateOptions options) {
+ return clientSession != null ? collection.updateMany(clientSession, filter, update, options) : collection.updateMany(filter, update, options);
+ }
+
+ public FindIterable find(com.mongodb.client.MongoCollection collection, Bson filter) {
+ return clientSession != null ? collection.find(clientSession, filter) : collection.find(filter);
+ }
+
+ public FindIterable find(com.mongodb.client.MongoCollection> collection, Bson filter, Class resultClass) {
+ return clientSession != null ? collection.find(clientSession, filter, resultClass) : collection.find(filter, resultClass);
+ }
+
+ public AggregateIterable aggregate(com.mongodb.client.MongoCollection collection, List extends Bson> pipeline) {
+ return clientSession != null ? collection.aggregate(clientSession, pipeline) : collection.aggregate(pipeline);
+ }
+
+ public T findOneAndDelete(com.mongodb.client.MongoCollection collection, Bson filter) {
+ return clientSession != null ? collection.findOneAndDelete(clientSession, filter) : collection.findOneAndDelete(filter);
+ }
+
+ public T findOneAndDelete(com.mongodb.client.MongoCollection collection, Bson filter, FindOneAndDeleteOptions options) {
+ return clientSession != null ? collection.findOneAndDelete(clientSession, filter, options) : collection.findOneAndDelete(filter, options);
+ }
+
+ public long countDocuments(com.mongodb.client.MongoCollection> collection, Bson filter) {
+ return clientSession != null ? collection.countDocuments(clientSession, filter) : collection.countDocuments(filter);
+ }
+
/**
* Decodes the given entity type from the given native object type
*
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy
index 29ae2d06635..b721929640a 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy
@@ -67,8 +67,6 @@ import org.grails.datastore.mapping.mongo.engine.codecs.PersistentEntityCodec
import org.grails.datastore.mapping.mongo.query.MongoQuery
import org.grails.datastore.mapping.query.Query
import org.grails.datastore.mapping.query.api.QueryableCriteria
-import org.grails.datastore.mapping.transactions.SessionOnlyTransaction
-import org.grails.datastore.mapping.transactions.Transaction
/**
* A MongoDB session for codec mapping style
@@ -236,8 +234,7 @@ class MongoCodecSession extends AbstractMongoSession {
final List> writes = writeModels[persistentEntity]
if (writes) {
- final BulkWriteResult bulkWriteResult = collection
- .bulkWrite(writes)
+ final BulkWriteResult bulkWriteResult = bulkWrite(collection, writes)
final boolean isAcknowledged = wc.isAcknowledged()
if (!bulkWriteResult.wasAcknowledged() && isAcknowledged) {
@@ -306,11 +303,6 @@ class MongoCodecSession extends AbstractMongoSession {
MongoIdCoercion.coerceIdToStoredType(nativeKey, entity)
}
- @Override
- protected Transaction beginTransactionInternal() {
- return new SessionOnlyTransaction(getNativeInterface(), this)
- }
-
@Override
protected MongoCodecEntityPersister createPersister(Class cls, MappingContext mappingContext) {
return mongoCodecEntityPersisterMap[cls]
@@ -322,7 +314,7 @@ class MongoCodecSession extends AbstractMongoSession {
final Document nativeQuery = buildNativeDocumentQueryFromCriteria(criteria, entity)
final MongoCollection collection = getCollection(entity)
- final DeleteResult deleteResult = collection.deleteMany((Bson) nativeQuery)
+ final DeleteResult deleteResult = deleteMany(collection, (Bson) nativeQuery)
if (deleteResult.wasAcknowledged()) {
return deleteResult.deletedCount
}
@@ -347,7 +339,7 @@ class MongoCodecSession extends AbstractMongoSession {
}
}
}
- final UpdateResult updateResult = collection.updateMany(nativeQuery, new Document(MONGO_SET_OPERATOR, properties), updateOptions)
+ final UpdateResult updateResult = updateMany(collection, nativeQuery, new Document(MONGO_SET_OPERATOR, properties), updateOptions)
if (updateResult.wasAcknowledged()) {
try {
return updateResult.modifiedCount
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 a5a1116c0e9..2139c361053 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
@@ -35,6 +35,7 @@
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoIterable;
import com.mongodb.client.model.IndexOptions;
+import com.mongodb.connection.ClusterType;
import org.bson.Document;
import org.bson.codecs.Codec;
import org.bson.codecs.configuration.CodecProvider;
@@ -133,6 +134,9 @@ public class MongoDatastore extends AbstractDatastore implements MappingContext.
protected final Map mongoDatabases = new ConcurrentHashMap<>();
protected final boolean stateless;
protected final boolean codecEngine;
+ protected final boolean transactionsEnabled;
+ private volatile Boolean transactionsSupported;
+ private volatile boolean warnedTransactionsUnsupported = false;
protected CodecRegistry codecRegistry;
protected final ConfigurableApplicationEventPublisher eventPublisher;
protected final PlatformTransactionManager transactionManager;
@@ -173,6 +177,7 @@ public MongoDatastore(final ConnectionSources collection = getCollection(persistentEntity);
final WriteConcern wc = getWriteConcern();
if (wc != null) {
collection = collection.withWriteConcern(wc);
@@ -207,8 +204,7 @@ public void flush(WriteConcern writeConcern) {
final List> writes = writeModels.get(persistentEntity);
if (!writes.isEmpty()) {
- final com.mongodb.bulk.BulkWriteResult bulkWriteResult = collection
- .bulkWrite(writes);
+ final com.mongodb.bulk.BulkWriteResult bulkWriteResult = bulkWrite(collection, writes);
if (!bulkWriteResult.wasAcknowledged()) {
errorOccured = true;
@@ -286,11 +282,6 @@ protected Persister createPersister(@SuppressWarnings("rawtypes") Class cls, Map
return entity == null ? null : new MongoEntityPersister(mappingContext, entity, this, publisher);
}
- @Override
- protected Transaction beginTransactionInternal() {
- return new SessionOnlyTransaction<>(getNativeInterface(), this);
- }
-
@Override
public void delete(Iterable objects) {
final Map toDelete = getDeleteMap(objects);
@@ -342,8 +333,8 @@ public long deleteAll(QueryableCriteria criteria) {
final PersistentEntity entity = criteria.getPersistentEntity();
final Document nativeQuery = buildNativeDocumentQueryFromCriteria(criteria, entity);
- final com.mongodb.client.MongoCollection collection = getCollection(entity);
- final DeleteResult deleteResult = collection.deleteMany(nativeQuery);
+ final com.mongodb.client.MongoCollection collection = getCollection(entity);
+ final DeleteResult deleteResult = deleteMany(collection, nativeQuery);
if (deleteResult.wasAcknowledged()) {
return deleteResult.getDeletedCount();
}
@@ -356,10 +347,10 @@ public long deleteAll(QueryableCriteria criteria) {
public long updateAll(QueryableCriteria criteria, Map properties) {
final PersistentEntity entity = criteria.getPersistentEntity();
final Document nativeQuery = buildNativeDocumentQueryFromCriteria(criteria, entity);
- final com.mongodb.client.MongoCollection collection = getCollection(entity);
+ final com.mongodb.client.MongoCollection collection = getCollection(entity);
final UpdateOptions updateOptions = new UpdateOptions();
updateOptions.upsert(false);
- final UpdateResult updateResult = collection.updateMany(nativeQuery, new Document("$set", properties), updateOptions);
+ final UpdateResult updateResult = updateMany(collection, nativeQuery, new Document("$set", properties), updateOptions);
if (updateResult.wasAcknowledged()) {
try {
return updateResult.getModifiedCount();
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java
new file mode 100644
index 00000000000..267271bb363
--- /dev/null
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java
@@ -0,0 +1,156 @@
+/*
+ * 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.mapping.mongo;
+
+import com.mongodb.MongoException;
+import com.mongodb.client.ClientSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.grails.datastore.mapping.transactions.Transaction;
+
+/**
+ * A {@link Transaction} backed by a real MongoDB multi-document transaction on a
+ * {@link ClientSession}. Unlike the legacy
+ * {@link org.grails.datastore.mapping.transactions.SessionOnlyTransaction} (which only flushes the
+ * GORM session), this commits or aborts a server-side transaction so multiple writes are atomic.
+ *
+ *
The {@link ClientSession} is started with an active transaction before this object is
+ * constructed; {@link #commit()} flushes the GORM session (a no-op when the
+ * {@link org.grails.datastore.mapping.transactions.DatastoreTransactionManager} already flushed)
+ * and commits the server transaction, while {@link #rollback()} aborts it. Both close the
+ * {@link ClientSession} and detach it from the owning session.
+ *
+ * @since 8.0
+ */
+public class MongoTransaction implements Transaction {
+
+ /**
+ * Maximum number of times {@link ClientSession#commitTransaction()} is retried when the server
+ * reports an {@code UnknownTransactionCommitResult} (i.e. the commit outcome is unknown and the
+ * operation is safe to retry).
+ */
+ private static final int MAX_COMMIT_RETRIES = 3;
+
+ private static final Logger LOG = LoggerFactory.getLogger(MongoTransaction.class);
+
+ private static volatile boolean warnedTimeoutIgnored = false;
+
+ private final AbstractMongoSession session;
+ private final ClientSession clientSession;
+ private boolean active = true;
+
+ public MongoTransaction(AbstractMongoSession session, ClientSession clientSession) {
+ this.session = session;
+ this.clientSession = clientSession;
+ }
+
+ @Override
+ public void commit() {
+ if (!active) {
+ return;
+ }
+ boolean committed = false;
+ try {
+ // Flush pending GORM operations into the active transaction. When driven by the
+ // DatastoreTransactionManager the session was already flushed, so this clears nothing
+ // and is a no-op; it covers callers that commit the transaction directly.
+ session.flush();
+ commitWithRetry();
+ committed = true;
+ } finally {
+ if (!committed) {
+ // The commit failed and the server transaction is aborted/unknown. Discard the GORM
+ // session's pending operations and first-level cache so a reused session cannot return
+ // entities that were never committed.
+ try {
+ session.clear();
+ }
+ catch (RuntimeException e) {
+ LOG.debug("Error clearing session after failed transaction commit: {}", e.getMessage(), e);
+ }
+ }
+ close();
+ }
+ }
+
+ @Override
+ public void rollback() {
+ if (!active) {
+ return;
+ }
+ try {
+ if (clientSession.hasActiveTransaction()) {
+ clientSession.abortTransaction();
+ }
+ } finally {
+ close();
+ }
+ }
+
+ @Override
+ public ClientSession getNativeTransaction() {
+ return clientSession;
+ }
+
+ @Override
+ public boolean isActive() {
+ return active;
+ }
+
+ @Override
+ public void setTimeout(int timeout) {
+ // The transaction is started before the manager applies a timeout, so a per-transaction
+ // timeout cannot be applied to the server-side transaction; the server enforces its own
+ // transactionLifetimeLimitSeconds instead. Warn once so a configured timeout is not silently
+ // ignored.
+ if (!warnedTimeoutIgnored) {
+ warnedTimeoutIgnored = true;
+ LOG.warn("A per-transaction timeout was requested but GORM for MongoDB does not apply it to the " +
+ "server-side MongoDB transaction; the server's transactionLifetimeLimitSeconds applies instead.");
+ }
+ }
+
+ private void commitWithRetry() {
+ int attempts = 0;
+ while (true) {
+ try {
+ clientSession.commitTransaction();
+ return;
+ }
+ catch (MongoException e) {
+ if (attempts++ < MAX_COMMIT_RETRIES &&
+ e.hasErrorLabel(MongoException.UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL)) {
+ continue;
+ }
+ throw e;
+ }
+ }
+ }
+
+ private void close() {
+ active = false;
+ try {
+ clientSession.close();
+ }
+ finally {
+ session.clearClientSession();
+ }
+ }
+}
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 025f4be9250..09f6045427e 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
@@ -80,6 +80,16 @@ abstract class AbstractMongoConnectionSourceSettings extends ConnectionSourceSet
*/
boolean stateless = false
+ /**
+ * Whether GORM should use real MongoDB multi-document transactions (a server-side
+ * {@code ClientSession}) for transactional operations. Requires a replica set or sharded
+ * cluster. When {@code false} (the default) a GORM transaction remains a client-side flush
+ * boundary, preserving the historical behavior. Bound from {@code grails.mongodb.transactional}.
+ *
+ * @since 8.0
+ */
+ boolean transactional = false
+
/**
* Whether to use the decimal128 type for BigDecimal values
*
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoCodecEntityPersister.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoCodecEntityPersister.groovy
index 7178b6b0029..c876adf9473 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoCodecEntityPersister.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoCodecEntityPersister.groovy
@@ -156,11 +156,10 @@ class MongoCodecEntityPersister extends ThirdPartyCacheEntityPersister
*
+ *
Commit is retried on an {@code UnknownTransactionCommitResult} error label. Whole-transaction
+ * retry on a {@code TransientTransactionError} is intentionally not handled here: it requires
+ * re-executing the transaction body, which the
+ * {@link org.grails.datastore.mapping.transactions.DatastoreTransactionManager} — a Spring
+ * {@code PlatformTransactionManager} — cannot do (it only begins, commits and rolls back; the
+ * application code between those is not re-runnable at this layer). Such retry belongs to the
+ * transaction orchestration layer if added later.
+ *
* @since 8.0
*/
public class MongoTransaction implements Transaction {
@@ -50,8 +61,6 @@ public class MongoTransaction implements Transaction {
private static final Logger LOG = LoggerFactory.getLogger(MongoTransaction.class);
- private static volatile boolean warnedTimeoutIgnored = false;
-
private final AbstractMongoSession session;
private final ClientSession clientSession;
private boolean active = true;
@@ -125,14 +134,13 @@ public boolean isActive() {
@Override
public void setTimeout(int timeout) {
- // The transaction is started before the manager applies a timeout, so a per-transaction
- // timeout cannot be applied to the server-side transaction; the server enforces its own
- // transactionLifetimeLimitSeconds instead. Warn once so a configured timeout is not silently
- // ignored.
- if (!warnedTimeoutIgnored) {
- warnedTimeoutIgnored = true;
- LOG.warn("A per-transaction timeout was requested but GORM for MongoDB does not apply it to the " +
- "server-side MongoDB transaction; the server's transactionLifetimeLimitSeconds applies instead.");
+ if (timeout != TransactionDefinition.TIMEOUT_DEFAULT) {
+ // The server-side transaction is started before the manager applies a timeout, so a
+ // per-transaction timeout cannot be honored here; the server's transactionLifetimeLimitSeconds
+ // governs the maximum transaction duration. Fail rather than silently ignore the request.
+ throw new TransactionUsageException("A per-transaction timeout (" + timeout + "s) is not supported by " +
+ "GORM for MongoDB transactions; the server's transactionLifetimeLimitSeconds governs transaction " +
+ "duration. Remove the timeout from the transaction definition.");
}
}
diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/transactions/MongoTransactionSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/transactions/MongoTransactionSpec.groovy
index 4838d154532..a190855d49b 100644
--- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/transactions/MongoTransactionSpec.groovy
+++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/transactions/MongoTransactionSpec.groovy
@@ -23,7 +23,10 @@ import grails.gorm.annotation.Entity
import com.mongodb.client.model.Filters
import org.apache.grails.testing.mongo.AutoStartedMongoSpec
import org.grails.datastore.mapping.mongo.MongoDatastore
+import org.springframework.transaction.CannotCreateTransactionException
import org.springframework.transaction.TransactionDefinition
+import org.springframework.transaction.TransactionUsageException
+import org.springframework.transaction.support.TransactionTemplate
import spock.lang.AutoCleanup
import spock.lang.Shared
@@ -179,6 +182,30 @@ class MongoTransactionSpec extends AutoStartedMongoSpec {
and: "the document was rolled back even though the id counter is not enrolled in the transaction"
TxCounter.withNewSession { TxCounter.count() } == 0
}
+
+ void "test a per-transaction timeout is rejected rather than silently ignored"() {
+ given: "a transaction template that requests an explicit timeout"
+ def txTemplate = new TransactionTemplate(datastore.transactionManager)
+ txTemplate.timeout = 5
+
+ when: "a transactional operation is attempted"
+ txTemplate.execute {
+ new TxPerson(name: "Fred").save(flush: true)
+ }
+
+ then: "beginning the transaction is refused, wrapping the usage exception that explains why"
+ def e = thrown(CannotCreateTransactionException)
+ e.cause instanceof TransactionUsageException
+
+ and: "nothing was persisted"
+ TxPerson.withNewSession { TxPerson.count() } == 0
+
+ and: "the datastore remains usable - the rejected transaction's session was cleaned up, not leaked"
+ TxPerson.withTransaction {
+ new TxPerson(name: "Wilma").save()
+ }
+ TxPerson.withNewSession { TxPerson.count() } == 1
+ }
}
@Entity
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 ef8ff1d8eb4..4ea2d842159 100644
--- a/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc
+++ b/grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc
@@ -153,3 +153,5 @@ Person.withTransaction {
----
NOTE: Multi-document transactions require a replica set or a sharded cluster; they are not supported on a standalone `mongod`. If `transactional` is enabled but a standalone topology is detected, GORM logs a warning once and falls back to the default client-side flush behavior. Identifier generation for native (`Long`) identity uses an independent counter and is intentionally not enrolled in the transaction, mirroring the non-transactional semantics of database sequences.
+
+WARNING: A per-transaction timeout is not supported for MongoDB server-side transactions. The session and transaction are started before the transaction manager applies a timeout, so a timeout on the `withTransaction` or `@Transactional` definition cannot be honored. Rather than silently ignore it, GORM throws a `TransactionUsageException` when a non-default timeout is requested. The server enforces its own maximum transaction duration via `transactionLifetimeLimitSeconds` (60 seconds by default).
From 90e5103ea938da452102e9a9686df29abd0b5a7d Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Tue, 30 Jun 2026 20:48:47 -0700
Subject: [PATCH 5/6] Frame TransientTransactionError retry as an application
concern
Reword the MongoTransaction Javadoc so it no longer reads as deferred
work: whole-transaction retry on a TransientTransactionError is left to
the application (as Spring Data MongoDB's own transaction manager does),
since re-running the transaction body would repeat its side effects.
---
.../org/grails/datastore/mapping/mongo/MongoTransaction.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java
index 0638973acd2..48490c3d8eb 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java
@@ -45,8 +45,9 @@
* re-executing the transaction body, which the
* {@link org.grails.datastore.mapping.transactions.DatastoreTransactionManager} — a Spring
* {@code PlatformTransactionManager} — cannot do (it only begins, commits and rolls back; the
- * application code between those is not re-runnable at this layer). Such retry belongs to the
- * transaction orchestration layer if added later.
+ * application code between those is not re-runnable at this layer). Such retry is left to the
+ * application (as Spring Data MongoDB's own {@code MongoTransactionManager} does), since re-running
+ * the body would repeat its side effects.
*
* @since 8.0
*/
From 134146a4e6f47d3a077f42c8fea39a22c4fe618b Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Fri, 3 Jul 2026 09:14:42 -0700
Subject: [PATCH 6/6] Rename MongoStaticApi session variable to mongoSession
for consistency
---
.../gorm/mongo/api/MongoStaticApi.groovy | 128 +++++++++---------
1 file changed, 64 insertions(+), 64 deletions(-)
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
index 8d549d1d975..a19f38e433c 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
@@ -68,34 +68,34 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations find(Bson filter) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
filter = wrapFilterWithMultiTenancy(filter)
- MongoCollection collection = session.getCollection(entity)
+ MongoCollection collection = mongoSession.getCollection(entity)
.withDocumentClass(persistentClass)
- return session.find(collection, filter)
+ return mongoSession.find(collection, filter)
}
}
@Override
D findOneAndDelete(Bson filter, FindOneAndDeleteOptions options = null) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
filter = wrapFilterWithMultiTenancy(filter)
- MongoCollection mongoCollection = session.getCollection(entity)
+ MongoCollection mongoCollection = mongoSession.getCollection(entity)
.withDocumentClass(persistentClass)
- D result = options ? session.findOneAndDelete(mongoCollection, filter, options) :
- session.findOneAndDelete(mongoCollection, filter)
+ D result = options ? mongoSession.findOneAndDelete(mongoCollection, filter, options) :
+ mongoSession.findOneAndDelete(mongoCollection, filter)
return result
}
}
Number count(Bson filter) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
filter = wrapFilterWithMultiTenancy(filter)
- return session.countDocuments(session.getCollection(entity), filter)
+ return mongoSession.countDocuments(mongoSession.getCollection(entity), filter)
}
}
@@ -109,9 +109,9 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations
- def databaseName = session.getDatabase(session.mappingContext.getPersistentEntity(persistentClass.name))
- session.getNativeInterface()
+ (MongoDatabase) withSession({ AbstractMongoSession mongoSession ->
+ def databaseName = mongoSession.getDatabase(mongoSession.mappingContext.getPersistentEntity(persistentClass.name))
+ mongoSession.getNativeInterface()
.getDatabase(databaseName)
})
@@ -119,64 +119,64 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
- return session.getCollectionName(entity)
+ (String) withSession({ AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ return mongoSession.getCollectionName(entity)
})
}
@Override
MongoCollection getCollection() {
- (MongoCollection) withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
- return session.getCollection(entity)
+ (MongoCollection) withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ return mongoSession.getCollection(entity)
}
}
@Override
def T withCollection(String collectionName, Closure callable) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
- final previous = session.useCollection(entity, collectionName)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ final previous = mongoSession.useCollection(entity, collectionName)
try {
- def dbName = session.getDatabase(entity)
- MongoClient mongoClient = (MongoClient) session.getNativeInterface()
+ def dbName = mongoSession.getDatabase(entity)
+ MongoClient mongoClient = (MongoClient) mongoSession.getNativeInterface()
MongoDatabase db = mongoClient.getDatabase(dbName)
def coll = db.getCollection(collectionName)
return callable.call(coll)
} finally {
- session.useCollection(entity, previous)
+ mongoSession.useCollection(entity, previous)
}
}
}
@Override
String useCollection(String collectionName) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
- session.useCollection(entity, collectionName)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ mongoSession.useCollection(entity, collectionName)
}
}
@Override
def T withDatabase(String databaseName, Closure callable) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
- final previous = session.useDatabase(entity, databaseName)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ final previous = mongoSession.useDatabase(entity, databaseName)
try {
- MongoDatabase db = session.getNativeInterface().getDatabase(databaseName)
+ MongoDatabase db = mongoSession.getNativeInterface().getDatabase(databaseName)
return callable.call(db)
} finally {
- session.useDatabase(entity, previous)
+ mongoSession.useDatabase(entity, previous)
}
}
}
@Override
String useDatabase(String databaseName) {
- withSession { AbstractMongoSession session ->
- def entity = session.mappingContext.getPersistentEntity(persistentClass.name)
- session.useDatabase(entity, databaseName)
+ withSession { AbstractMongoSession mongoSession ->
+ def entity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ mongoSession.useDatabase(entity, databaseName)
}
}
@@ -187,47 +187,47 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations aggregate(List pipeline, Function doWithAggregate = Function.identity()) {
- (List) withSession({ AbstractMongoSession session ->
- def persistentEntity = session.mappingContext.getPersistentEntity(persistentClass.name)
- def mongoCollection = session.getCollection(persistentEntity)
- if (session instanceof MongoCodecSession) {
- MongoDatastore datastore = (MongoDatastore)session.getDatastore()
+ (List) withSession({ AbstractMongoSession mongoSession ->
+ def persistentEntity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ def mongoCollection = mongoSession.getCollection(persistentEntity)
+ if (mongoSession instanceof MongoCodecSession) {
+ MongoDatastore datastore = (MongoDatastore)mongoSession.getDatastore()
mongoCollection = mongoCollection
.withDocumentClass(persistentEntity.javaClass)
.withCodecRegistry(datastore.getCodecRegistry())
}
List extends Bson> newPipeline = preparePipeline(pipeline)
- AggregateIterable aggregateIterable = session.aggregate(mongoCollection, newPipeline)
+ AggregateIterable aggregateIterable = mongoSession.aggregate(mongoCollection, newPipeline)
if (doWithAggregate != null) {
aggregateIterable = doWithAggregate.apply(aggregateIterable)
}
- new MongoQuery.MongoResultList(aggregateIterable.iterator(), 0, (EntityPersister)session.getPersister(persistentEntity) as EntityPersister)
+ new MongoQuery.MongoResultList(aggregateIterable.iterator(), 0, (EntityPersister)mongoSession.getPersister(persistentEntity) as EntityPersister)
})
}
@Override
List aggregate(List pipeline, Function doWithAggregate, ReadPreference readPreference) {
- (List) withSession({ AbstractMongoSession session ->
- def persistentEntity = session.mappingContext.getPersistentEntity(persistentClass.name)
+ (List) withSession({ AbstractMongoSession mongoSession ->
+ def persistentEntity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
List extends Bson> newPipeline = preparePipeline(pipeline)
- def mongoCollection = session.getCollection(persistentEntity)
+ def mongoCollection = mongoSession.getCollection(persistentEntity)
.withReadPreference(readPreference)
- def aggregateIterable = session.aggregate(mongoCollection, newPipeline)
+ def aggregateIterable = mongoSession.aggregate(mongoCollection, newPipeline)
if (doWithAggregate != null) {
aggregateIterable = doWithAggregate.apply(aggregateIterable)
}
- new MongoQuery.MongoResultList(aggregateIterable.iterator(), 0, (EntityPersister)session.getPersister(persistentEntity))
+ new MongoQuery.MongoResultList(aggregateIterable.iterator(), 0, (EntityPersister)mongoSession.getPersister(persistentEntity))
})
}
@Override
List search(String query, Map options = Collections.emptyMap()) {
- (List) withSession({ AbstractMongoSession session ->
- def persistentEntity = session.mappingContext.getPersistentEntity(persistentClass.name)
- def coll = session.getCollection(persistentEntity)
- if (session instanceof MongoCodecSession) {
- MongoDatastore datastore = (MongoDatastore)session.datastore
+ (List) withSession({ AbstractMongoSession mongoSession ->
+ def persistentEntity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
+ def coll = mongoSession.getCollection(persistentEntity)
+ if (mongoSession instanceof MongoCodecSession) {
+ MongoDatastore datastore = (MongoDatastore)mongoSession.datastore
coll = coll
.withDocumentClass(persistentEntity.javaClass)
.withCodecRegistry(datastore.codecRegistry)
@@ -240,29 +240,29 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations 0) cursor.skip(offset)
if (max > -1) cursor.limit(max)
- new MongoQuery.MongoResultList(cursor.iterator(), offset, (EntityPersister)session.getPersister(persistentEntity))
+ new MongoQuery.MongoResultList(cursor.iterator(), offset, (EntityPersister)mongoSession.getPersister(persistentEntity))
})
}
@Override
List searchTop(String query, int limit = 5, Map options = Collections.emptyMap()) {
- (List) withSession({ AbstractMongoSession session ->
- def persistentEntity = session.mappingContext.getPersistentEntity(persistentClass.name)
+ (List) withSession({ AbstractMongoSession mongoSession ->
+ def persistentEntity = mongoSession.mappingContext.getPersistentEntity(persistentClass.name)
- MongoCollection coll = session.getCollection(persistentEntity)
- if (session instanceof MongoCodecSession) {
- MongoDatastore datastore = (MongoDatastore)session.datastore
+ MongoCollection coll = mongoSession.getCollection(persistentEntity)
+ if (mongoSession instanceof MongoCodecSession) {
+ MongoDatastore datastore = (MongoDatastore)mongoSession.datastore
coll = coll
.withDocumentClass(persistentEntity.javaClass)
.withCodecRegistry(datastore.codecRegistry)
}
- EntityPersister persister = (EntityPersister)session.getPersister(persistentEntity)
+ EntityPersister persister = (EntityPersister)mongoSession.getPersister(persistentEntity)
Bson search
if (options.language) {
@@ -274,7 +274,7 @@ class MongoStaticApi extends GormStaticApi implements MongoAllOperations