Skip to content

Add opt-in MongoDB multi-document transactions to GORM for MongoDB#15744

Open
codeconsole wants to merge 7 commits into
apache:8.0.xfrom
codeconsole:feat/mongo-server-transactions
Open

Add opt-in MongoDB multi-document transactions to GORM for MongoDB#15744
codeconsole wants to merge 7 commits into
apache:8.0.xfrom
codeconsole:feat/mongo-server-transactions

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

What

Adds opt-in MongoDB multi-document transactions to GORM for MongoDB.

Previously GORM for MongoDB never used a com.mongodb.client.ClientSession: every driver write was issued session-less and auto-committed, so a GORM transaction was only a client-side flush boundary — writes already flushed within it were not rolled back on failure (no server-side atomicity).

With grails.mongodb.transactional = true, a GORM transaction now starts and drives a real ClientSession transaction, so all reads and writes commit or roll back atomically:

Person.withTransaction {
    new Person(name: "Fred").save()
    new Person(name: "Wilma").save()
    // both commit together, or neither if an exception is thrown
}

How

  • New MongoTransaction (replaces the flush-only SessionOnlyTransaction when enabled): commit() flushes then commitTransaction() (with bounded retry on UnknownTransactionCommitResult), rollback() aborts; both close the session. On commit failure the GORM session cache is cleared.
  • AbstractMongoSession holds the active ClientSession, starts it in beginTransactionInternal(), and routes every read/write through small helpers that pass the session when a transaction is active and stay session-less otherwise.
  • The session is threaded through both session engines, both persisters, MongoQuery, and the MongoStaticApi/MongoEntity surface.
  • Core DatastoreTransactionManager is unchanged — it already orchestrates flush/commit/rollback; this just supplies a Transaction that drives a server transaction.

Opt-in and fallback

  • Default is off (grails.mongodb.transactional defaults to false) — no behavior change for existing apps.
  • Requires a replica set or sharded cluster. If a standalone topology is detected, the feature is disabled with a one-time warning and GORM falls back to the legacy flush behavior.

Boundaries

  • Identifier generation (the native Long counter) is intentionally left non-transactional, mirroring database sequence semantics.
  • Like GORM's transaction manager generally, a single flat transaction (PROPAGATION_REQUIRED) is supported; REQUIRES_NEW/NESTED are not.

Tests

  • MongoTransactionSpec — commit persists multiple docs; rollback discards them on the server; read-your-writes within a transaction; cross-collection atomic rollback; findOneAndDelete participates in the transaction; nested GORM REQUIRES_NEW.
  • MongoTransactionDisabledSpec — default-off keeps the legacy flush behavior.

Targets 8.0.x. Independent of #15743; this is also the prerequisite for a follow-up Spring Data MongoDB interop module.

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.

@borinquenkid borinquenkid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @codeconsole,

Please keep an eye on #15678 (GORM: Shared Mapping Registry O(M+N) Scaling), which introduces significant internal structural refactoring to how GormRegistry and MongoDatastore handle tenant routing and fallback resolution.

Since that optimization is targeting 8.0.x-hibernate7, your transaction changes here will be downstream from those modifications. It might be worth checking your diff against those updates to prevent initialization order regressions or multi-tenant signature mismatches when merging into the 8.0 release line.

…ve Long id tests

On a failed commit (or a flush failure during commit), MongoTransaction now explicitly
aborts the server transaction rather than relying on ClientSession.close() to abort it
implicitly. Adds tests covering native Long identifier generation inside a transaction:
ids are generated and persisted on commit, and the document is rolled back on failure
even though the id counter is intentionally not enrolled in the transaction.
…ined

The clientSession field is accessed only on the owning session's thread (per the
AbstractSession single-thread-confinement contract), so it needs no synchronization.
Adds a clarifying comment; no behavior change.
@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.00000% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.4804%. Comparing base (87c000a) to head (90e5103).
⚠️ Report is 69 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ails/datastore/mapping/mongo/MongoTransaction.java 51.0638% 18 Missing and 5 partials ⚠️
.../datastore/mapping/mongo/AbstractMongoSession.java 62.5000% 6 Missing and 6 partials ⚠️
...grails/datastore/mapping/mongo/MongoDatastore.java 50.0000% 9 Missing and 1 partial ⚠️
...g/grails/datastore/mapping/mongo/MongoSession.java 0.0000% 6 Missing ⚠️
...ils/datastore/gorm/mongo/api/MongoStaticApi.groovy 77.7778% 1 Missing and 1 partial ⚠️
...ore/mapping/mongo/engine/MongoEntityPersister.java 0.0000% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             8.0.x     #15744         +/-   ##
================================================
+ Coverage         0   49.4804%   +49.4804%     
- Complexity       0      16717      +16717     
================================================
  Files            0       1948       +1948     
  Lines            0      92564      +92564     
  Branches         0      16158      +16158     
================================================
+ Hits             0      45801      +45801     
- Misses           0      39643      +39643     
- Partials         0       7120       +7120     
Files with missing lines Coverage Δ
...s/datastore/mapping/mongo/MongoCodecSession.groovy 76.2820% <100.0000%> (ø)
...tions/AbstractMongoConnectionSourceSettings.groovy 70.0000% <ø> (ø)
...ping/mongo/engine/MongoCodecEntityPersister.groovy 80.7860% <100.0000%> (ø)
...ails/datastore/mapping/mongo/query/MongoQuery.java 73.7864% <100.0000%> (ø)
...ils/datastore/gorm/mongo/api/MongoStaticApi.groovy 62.5000% <77.7778%> (ø)
...ore/mapping/mongo/engine/MongoEntityPersister.java 10.4478% <0.0000%> (ø)
...g/grails/datastore/mapping/mongo/MongoSession.java 0.0000% <0.0000%> (ø)
...grails/datastore/mapping/mongo/MongoDatastore.java 70.7589% <50.0000%> (ø)
.../datastore/mapping/mongo/AbstractMongoSession.java 64.1304% <62.5000%> (ø)
...ails/datastore/mapping/mongo/MongoTransaction.java 51.0638% <51.0638%> (ø)

... and 1938 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading about the MongoDB transactions and asking AI made me think there is a gap here. Was whole-transaction retry on TransientTransactionError consciously deferred?

// ignored.
if (!warnedTimeoutIgnored) {
warnedTimeoutIgnored = true;
LOG.warn("A per-transaction timeout was requested but GORM for MongoDB does not apply it to the " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should throw an error here, not silently fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — throws TransactionUsageException on a non-default timeout now. doBegin rolls back the started session and rethrows as CannotCreateTransactionException, so nothing leaks.

@bito-code-review

Copy link
Copy Markdown

The implementation of MongoTransaction already includes explicit error handling for transaction operations. Specifically, commit() uses a try-finally block where a failed commit or flush triggers an explicit abortTransaction() call, and rollback() also calls abortTransaction() within a try-finally block to ensure the session is closed. These mechanisms prevent silent failures by ensuring that server-side transactions are aborted and the GORM session is cleared if an error occurs.

grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoTransaction.java

try {
            session.flush();
            commitWithRetry();
            committed = true;
        } finally {
            if (!committed) {
                if (clientSession.hasActiveTransaction()) {
                    try {
                        clientSession.abortTransaction();
                    }
                    catch (RuntimeException e) {
                        LOG.debug("Error aborting transaction after failed commit: {}", e.getMessage(), e);
                    }
                }
                // ...
            }
            close();
        }


@SuppressWarnings({"rawtypes", "unchecked"})
public DeleteResult deleteMany(com.mongodb.client.MongoCollection collection, Bson filter) {
return clientSession != null ? collection.deleteMany(clientSession, filter) : collection.deleteMany(filter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't all of these not null checks use the helper hasActiveTransaction() instead? Isn't the session only required for the transaction case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — all nine use hasActiveTransaction() now. Only the transactional case needs the session, and it falls back to the session-less overload if one ever outlives its transaction.

- setTimeout now throws TransactionUsageException for a non-default
  timeout instead of silently ignoring it. The server transaction is
  started before the manager applies a timeout, so it cannot be honored
  at this layer; DatastoreTransactionManager.doBegin catches it, rolls
  back the just-started ClientSession, and rethrows as
  CannotCreateTransactionException, so nothing leaks.
- All nine AbstractMongoSession driver helpers branch on
  hasActiveTransaction() rather than a raw clientSession null check, so a
  session that lingers after its transaction commits falls back to the
  session-less overload.
- Document on the MongoTransaction Javadoc that whole-transaction retry
  on TransientTransactionError is intentionally deferred: it requires
  re-executing the transaction body, which the Spring
  PlatformTransactionManager SPI cannot do.
- Add a spec asserting a per-transaction timeout is rejected and the
  datastore stays usable afterwards, and document the timeout behavior
  in advancedConfig.adoc.
@codeconsole

codeconsole commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Yes — deliberately, and it's a boundary rather than missing work.

Commit-retry is implemented: on an UnknownTransactionCommitResult the commit is retried automatically (it may already have succeeded, so retrying is safe).

Retrying the whole transaction on a TransientTransactionError is intentionally left to the application, because doing it automatically re-runs everything in the transaction body — including side effects like emails or HTTP calls — so it's only safe when that body is idempotent. Spring Data MongoDB's own MongoTransactionManager draws the same line: it surfaces transient errors and leaves retry to the application (e.g. Spring Retry). Code that needs it can wrap the call in its own retry.

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.
codeconsole added a commit to codeconsole/grails-core that referenced this pull request Jul 1, 2026
Bring MongoTransaction and AbstractMongoSession in line with the reviewed
Layer 1 code: MongoTransaction.commit() now explicitly aborts the
server-side transaction on a failed commit rather than relying on
close() to do so implicitly, and the AbstractMongoSession clientSession
field carries its threading/nullability contract note. This makes the
interop branch's copy identical to apache#15744.
@codeconsole codeconsole requested a review from jdaugherty July 1, 2026 03:58
@testlens-app

testlens-app Bot commented Jul 1, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 90e5103
▶️ Tests: 26569 executed
⚪️ Checks: 44/44 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants