jdaugherty commented on code in PR #16415:
URL: https://github.com/apache/grails-core/pull/16415#discussion_r4118021165


##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/TransactionObject.java:
##########
@@ -77,7 +118,7 @@ public boolean isNewSession() {
 
     @Override
     public boolean isRollbackOnly() {

Review Comment:
   `flush()` just below still flushes `sessionHolder.getSession()`, the session 
bound on top, while commit and rollback now act on the transaction's own 
session. A `status.flush()` made while a session is pushed on top flushes the 
wrong one. Using `transactionSession` when it is set, as 
`transactionSession(txObject)` does in the manager, keeps the three consistent.



##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/DatastoreTransactionManager.java:
##########
@@ -224,17 +280,52 @@ protected void doSetRollbackOnly(DefaultTransactionStatus 
status) throws Transac
         TransactionObject txObject = (TransactionObject) 
status.getTransaction();
         status.setRollbackOnly();
         txObject.getSessionHolder().setRollbackOnly();
+        // A joined transaction failed, so the whole transaction will roll 
back. Discard what is queued
+        // now: flushed explicitly before then, it would be written, and 
without a server-side
+        // transaction nothing could take it back.
+        Session session = txObject.getSessionHolder().getSession();
+        if (session != null) {
+            session.clear();
+        }
+    }
+
+    /**
+     * A committed or rolled back transaction can no longer be joined: code 
run from its
+     * {@code afterCommit} or {@code afterCompletion} callbacks begins one of 
its own, rather than joining
+     * a transaction that will not commit again and losing its writes.
+     */
+    private static void ended(TransactionObject txObject) {
+        
txObject.getSessionHolder().setTransactionActive(txObject.getTransactionSession(),
 false);
+    }
+
+    /**
+     * The session the transaction began on, which its commit flushes and its 
rollback clears, even if
+     * another has been bound on top of it since; the holder's current session 
for a transaction begun
+     * by a subclass that does not record one.
+     */
+    private static Session transactionSession(TransactionObject txObject) {
+        Session session = txObject.getTransactionSession();
+        return session != null ? session : 
txObject.getSessionHolder().getSession();
     }
 
     @Override
     protected void doCleanupAfterCompletion(Object transaction) {
         TransactionObject txObject = (TransactionObject) transaction;
+        SessionHolder sessionHolder = txObject.getSessionHolder();
 
-        // Un-bind the session holder from the thread.
-        if (txObject.isNewSessionHolder()) {
-            
DatastoreUtils.closeSession(txObject.getSessionHolder().getSession());
+        ended(txObject);
+        // A joined transaction that failed marked the holder rollback-only. A 
pre-bound holder outlives
+        // this transaction, and the next one begun on it must not inherit 
that mark.
+        sessionHolder.resetRollbackOnly();

Review Comment:
   The rollback-only mark is holder-wide, but a holder carries a stack of 
sessions, and a transaction begun on a session that `withNewSession` pushed on 
top runs its own `doCleanupAfterCompletion` against the same holder. Two things 
follow once a failed participant has marked the outer transaction rollback-only:
   
   1. The new-session transaction cannot commit. 
`DefaultTransactionStatus.isGlobalRollbackOnly()` reads 
`TransactionObject.isRollbackOnly()`, which is the shared holder's mark. 
Spring's `TransactionTemplate` throws `UnexpectedRollbackException` from that 
transaction; GORM's `withTransaction`, whose `inheritRollbackOnly` copies the 
mark onto the status, rolls it back silently. That is the "record the failure 
in a session of its own" pattern the new docs describe as separate ("a 
transaction begun in that session is its own, and commits when it returns").
   2. This line then erases the outer transaction's mark, so the outer commit 
goes ahead although a participant failed.
   
   Run against this head on the simple map datastore 
(`grails-datamapping-core-test`) and on a replica set with 
`grails.mongodb.transactional` on:
   
   ```groovy
   TestEntity.withTransaction {
       entity('A').save()
       try {
           TestEntity.withTransaction {
               entity('B').save()
               throw new IllegalStateException('inner')
           }
       }
       catch (IllegalStateException ignored) {
       }
       TestEntity.withNewSession {
           TestEntity.withTransaction { entity('audit').save() }
       }
       entity('C').save()
   }
   ```
   
   No exception is thrown anywhere, `audit` is never written, and `C` is 
committed: `TestEntity.list()*.name == ['C']` on both. Without the 
`withNewSession` block the same code rolls back as a whole, as the TCK feature 
checks. Through `TransactionTemplate` the inner `withNewSession` transaction 
throws `UnexpectedRollbackException` and the outer still commits (a mock-level 
check of the manager alone shows `transaction.commit()` invoked).
   
   Both go away if the mark is kept per transaction session, the way 
`transactionSessions` already is: `doSetRollbackOnly` marks 
`getSessionHolder().getSession()` (the session `isExistingTransaction` 
matched), `TransactionObject.isRollbackOnly()` asks for its transaction 
session, falling back to the holder's current session for a participant, and 
cleanup resets only `transactionSession(txObject)`. The holder-wide 
`setRollbackOnly()`/`isRollbackOnly()` inherited from `ResourceHolderSupport` 
can stay for anything that calls them directly. Hibernate is not affected only 
because its `withNewSession` binds a separate holder. The scenario above would 
make a good sixth TCK feature next to the existing `withNewSession` one.



##########
grails-data-mongodb/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc:
##########
@@ -53,6 +53,23 @@ class UserProfile {
 
 See <<idGeneration,Identity Generation>> for the full description of 
`storedAs`.
 
+==== A Transaction Started Inside Another Joins It
+
+A transactional service method called from another, or `withTransaction` 
inside `withTransaction`, used to begin a second transaction on the same 
session: it committed on its own, and the surrounding transaction's commit then 
did nothing, so the surrounding transaction's later writes could be lost. It 
now joins the surrounding transaction, and everything commits once when that 
one does, as on Hibernate.
+
+The effect most applications will notice: GORM for MongoDB does not flush 
before a query while a transaction is in progress, so a write a joined call 
leaves queued is not visible to a later query in the same transaction. It used 
to be, because the inner transaction flushed it when it returned:

Review Comment:
   Not for this PR, but worth an issue: `MongoQuery.flushBeforeQuery` skips the 
flush whenever synchronization is active because, without a server-side 
transaction, a flushed write cannot be taken back. With 
`grails.mongodb.transactional` on, that reason no longer holds: a flush inside 
the `ClientSession` transaction is aborted with it. Flushing before a query 
when the session `hasActiveTransaction()` would give the Hibernate visibility 
(a joined call's writes visible to later queries, `@Rollback` tests unchanged) 
for exactly the configuration this PR makes work, and this note would then 
apply only to the non-transactional setup.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to