Skip to content

Give read-only transactions the same meaning on Mongo as on Hibernate - #16214

Open
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:fix/readonly-no-flush-8.0.x
Open

Give read-only transactions the same meaning on Mongo as on Hibernate#16214
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:fix/readonly-no-flush-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

@ReadOnly means different things depending on which datastore is underneath.

On Hibernate it genuinely suppresses the flush:

// GrailsHibernateTransactionManager:55
if (definition.isReadOnly()) {
    holder.session.setHibernateFlushMode(FlushMode.MANUAL)
}

On the DatastoreTransactionManager path there is no equivalent. doBegin sets FlushModeType.COMMIT — already the Session default, and as strict as the JPA enum gets, since it has no MANUAL/NEVER — and doCommit's guard is then defeated by the transaction's own commit:

// DatastoreTransactionManager.doCommit
if (!status.isReadOnly()) {
    if (session != null) { ... session.flush(); }   // skipped when read-only
}
transaction.commit();                               // ...which flushes anyway

Both implementations flush unconditionally — MongoTransaction.commit() and SessionOnlyTransaction.commit(). So a read-only transaction writes whatever the surrounding session had queued.

That matters because a read-only transaction has no pending operations of its own. Anything it flushes belongs to the caller's session, and gets written at a moment the caller did not choose. The sharp version is a read taken during a flush — a referential check in a validator, or a beforeInsert hook — where the commit re-validates the entity being saved and the validator reads again, recursing until the stack is gone.

Change

Session.beginTransaction(TransactionDefinition) already existed for exactly this; AbstractSession discarded the argument:

public Transaction beginTransaction(TransactionDefinition definition) {
    transaction = beginTransactionInternal();   // definition dropped
    return transaction;
}

It now passes the definition to an overridable beginTransactionInternal(TransactionDefinition) whose default delegates to the existing no-arg method, so datastores that do not override it — Neo4j and the simple map datastore — keep exactly the path they had today. Mongo overrides it, and MongoTransaction / SessionOnlyTransaction decline to flush when the definition is read-only.

Read-write commits are untouched.

Tests

Three added, on both paths — server-side transactions (MongoTransactionSpec) and the session-only fallback (MongoTransactionDisabledSpec):

  • a read-only transaction commits without flushing the surrounding session (both paths)
  • a read-write transaction still flushes it (guards the scope of the change)

Both read-only tests fail on 8.0.x without the source change and pass with it; the read-write test passes either way. Full :grails-datastore-core:test, :grails-data-mongodb-core:test and :grails-data-simple:test are green.

Notes

Independent of #16212, which removes a @ReadOnly from GormService in scaffolding. Neither depends on the other; either can land alone. #16212 is where this surfaced.

This is a behaviour change: an application that writes inside an @ReadOnly method on a DatastoreTransactionManager datastore gets those writes at commit today, and would not after this. Hibernate already behaves that way, so this makes the two consistent rather than introducing a new rule. If it should wait for a major, or land with a warning when a read-only commit finds pending operations, I can rework it.

@readonly means different things depending on the datastore. On Hibernate it
genuinely suppresses the flush:

    // GrailsHibernateTransactionManager:55
    if (definition.isReadOnly()) {
        holder.session.setHibernateFlushMode(FlushMode.MANUAL)
    }

On the DatastoreTransactionManager path there was no equivalent. doBegin set
FlushModeType.COMMIT, which is already the Session default and is as strict as
the JPA enum gets, and doCommit's `if (!status.isReadOnly())` guard was then
defeated by transaction.commit() — both MongoTransaction and
SessionOnlyTransaction flush unconditionally. A read-only transaction therefore
wrote whatever the surrounding session had queued.

Session.beginTransaction(TransactionDefinition) already existed for this;
AbstractSession discarded the argument. It now passes the definition to an
overridable beginTransactionInternal(TransactionDefinition) whose default
delegates to the no-arg version, so datastores that do not override it — Neo4j
and the simple map datastore — keep exactly the path they had. Mongo overrides
it, and both transaction types decline to flush when the definition is
read-only.

A read-only transaction has no pending operations of its own, so the only
writes this suppresses are ones a read had no business flushing. Read-write
commits are unchanged, which the added tests assert alongside the read-only
case on both the server-transaction and session-only paths.
@codeconsole
codeconsole force-pushed the fix/readonly-no-flush-8.0.x branch from fd1f4f3 to 05fb17e Compare August 24, 2026 23:50
@codeconsole codeconsole changed the title Honour read-only transactions on the DatastoreTransactionManager path Give read-only transactions the same meaning on Mongo as on Hibernate Aug 24, 2026
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.35294% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.7484%. Comparing base (c1b532a) to head (f25990c).
⚠️ Report is 198 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...e/mapping/transactions/SessionOnlyTransaction.java 66.6667% 2 Missing ⚠️
.../datastore/mapping/mongo/AbstractMongoSession.java 75.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16214        +/-   ##
==================================================
+ Coverage     54.7365%   54.7484%   +0.0119%     
- Complexity      20470      20479         +9     
==================================================
  Files            2103       2103                
  Lines          101077     101086         +9     
  Branches        17928      17930         +2     
==================================================
+ Hits            55326      55343        +17     
+ Misses          37876      37867         -9     
- Partials         7875       7876         +1     
Files with missing lines Coverage Δ
...ails/datastore/mapping/mongo/MongoTransaction.java 53.0612% <100.0000%> (+1.9974%) ⬆️
...grails/datastore/mapping/core/AbstractSession.java 69.5279% <100.0000%> (-0.1495%) ⬇️
...ping/transactions/DatastoreTransactionManager.java 53.6842% <100.0000%> (ø)
.../datastore/mapping/mongo/AbstractMongoSession.java 64.8936% <75.0000%> (-0.3238%) ⬇️
...e/mapping/transactions/SessionOnlyTransaction.java 70.0000% <66.6667%> (-5.0000%) ⬇️

... and 2 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.

Passing the definition down left both transaction types with the
constructor they had before read-only existed, and nothing in the tree
calls either one now.

MongoTransaction's has only ever appeared in the 8.0.0 milestones: apache#15744
added the class as an internal replacement for SessionOnlyTransaction with
AbstractMongoSession as its only caller, and the Spring Data interop that
followed in apache#15745 takes the ClientSession off the session rather than
constructing a transaction of its own. It is removed rather than carried
into the release.

SessionOnlyTransaction's shipped in 7.0.x and in GORM before that, where
an out-of-tree datastore may be constructing it, so it is deprecated for
removal in favour of the constructor that states whether the transaction
is read-only instead of assuming it is not.
GormSharedSessionMongoTransactionManager extends
DatastoreTransactionManager, so it inherited the read-only handling
without a specification naming it. A read-only transaction there now
discards a queued GORM write while a MongoTemplate write, which went
straight into the shared ClientSession, still commits: one transaction,
two write paths, and only one of them answers to the flag.

The existing unified specifications all save with flush: true, so none of
them would notice the flush on commit going away. Both new cases save
without flushing. The read-only one fails without the change and the
read-write one holds its scope, and neither needs Docker.
@testlens-app

testlens-app Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: f25990c
▶️ Tests: 18463 executed
⚪️ Checks: 89/89 completed


Learn more about TestLens at testlens.app/docs.

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.

1 participant