Skip to content

Remove unintended @ReadOnly from GormService - #16212

Open
codeconsole wants to merge 1 commit into
apache:8.0.xfrom
codeconsole:fix/gorm-service-readonly-8.0.x
Open

Remove unintended @ReadOnly from GormService#16212
codeconsole wants to merge 1 commit into
apache:8.0.xfrom
codeconsole:fix/gorm-service-readonly-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

When I originally created GormService, the purpose was to provide CRUD operations implemented using GORM. It was modeled after RestfulController used by the scaffolding plugin. The intent was to have a RestfulServiceController that used a service layer instead of direct datastore access. I mistakenly added @ReadOnly. @ReadOnly should not be there because GORM get does not happen in a transaction so GormService get should not either.

Problem

GormService is annotated @ReadOnly at class level, so the transactional AST transform wraps get/list/count in a GrailsTransactionTemplate. With the default REQUIRED propagation, a read taken outside an existing transaction becomes the outermost transaction — and committing it flushes the session:

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

So a read can write. @ReadOnly suppresses the transaction manager's own flush, but not the one inside commit().

That becomes a hang when such a read runs during a flush. A referential check in a validator is the ordinary case:

static constraints = {
    principalId validator: { String val -> Principal.get(val) != null ?: 'principal.missing' }
}

If Principal.get(...) reaches GormService, the commit of its read-only transaction flushes the session, the flush re-validates the entity being saved, the validator reads again, and the cycle repeats until the stack is exhausted. It surfaces well away from the cause — as IllegalStateException: Transaction synchronization is not active thrown out of the commit unwind, with the StackOverflowError lost. beforeInsert/beforeUpdate hooks are exposed the same way.

Change

Drop the class-level @ReadOnly from GormService. The write methods keep their own @Transactional.

The annotation is redundant where scaffolded reads are actually served: RestfulServiceController already declares @ReadOnly at class level, so that path keeps its read-only boundary either way. The service annotation only takes effect when the service is called outside one — which is exactly where opening and committing a transaction is unwanted, and where callers reasonably expect get(id) to behave like the GORM static call it delegates to.

Tests

:grails-scaffolding:test plus check on :grails-test-examples-scaffolding, :grails-test-examples-scaffolding-fields, and :grails-test-examples-hibernate7-scaffolding-fields (integration tests included) all pass.

Related

#16214 aligns the behaviour underneath this. @ReadOnly suppresses the flush on Hibernate but not on the DatastoreTransactionManager path, where doCommit's if (!status.isReadOnly()) guard is defeated by transaction.commit() flushing unconditionally in both MongoTransaction and SessionOnlyTransaction.

The two are independent. This one removes a @ReadOnly that a scaffold service should not carry regardless of what read-only ends up meaning; #16214 makes read-only mean the same thing on both paths. Either can land without the other.

(Minor, noticed while tracing it: the // Just set to NEVER in case of a new Session for this transaction. comment in doBegin is inherited from Spring's HibernateTransactionManager, which sets Hibernate's own FlushMode.MANUAL. jakarta.persistence.FlushModeType offers only AUTO and COMMIT, so the code is already as strict as the enum allows and the comment is what is stale. #16214 corrects it.)

GormService carries a class-level @readonly, so the transactional AST
transform wraps get/list/count in a GrailsTransactionTemplate. With the
default REQUIRED propagation, a read taken outside an existing
transaction becomes the outermost transaction, and committing it flushes
the session: DatastoreTransactionManager.doCommit skips its own flush
when the transaction is read-only, then calls transaction.commit(), and
MongoTransaction.commit() flushes unconditionally.

A read can therefore write. When one of these reads runs inside a flush
— a referential check in a validator, or a beforeInsert hook — the
commit re-validates the entity being saved, the validator reads again,
and the cycle repeats until the stack is exhausted.

The annotation is also redundant where scaffolded reads are actually
served: RestfulServiceController already declares @readonly at class
level, so that path keeps its read-only boundary either way. The service
annotation only takes effect when the service is called outside one,
which is precisely where opening and committing a transaction is
unwanted.

The write methods keep their own @transactional.
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 54.1155%. Comparing base (e40cb27) to head (608b8ea).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16212        +/-   ##
==================================================
- Coverage     54.1238%   54.1155%   -0.0084%     
+ Complexity      20307      20302         -5     
==================================================
  Files            2107       2107                
  Lines          101144     101143         -1     
  Branches        17921      17921                
==================================================
- Hits            54743      54734         -9     
- Misses          38595      38602         +7     
- Partials         7806       7807         +1     
Files with missing lines Coverage Δ
...roovy/grails/plugin/scaffolding/GormService.groovy 0.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.

@testlens-app

testlens-app Bot commented Aug 25, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 608b8ea
▶️ Tests: 69321 executed
⚪️ Checks: 83/83 completed


Learn more about TestLens at testlens.app/docs.

@codeconsole codeconsole changed the title Do not open a transaction for scaffold service reads Remove unintended @ReadOnly from GormService Aug 25, 2026
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