This is an automated email from the ASF dual-hosted git repository.

ahuber pushed a commit to branch 3975-telemetry
in repository https://gitbox.apache.org/repos/asf/causeway.git


The following commit(s) were added to refs/heads/3975-telemetry by this push:
     new 9ec997faac2 CAUSEWAY-3975: adds transaction observation
9ec997faac2 is described below

commit 9ec997faac2cc98f50cac622e8b73386e0a7c9c9
Author: andi-huber <[email protected]>
AuthorDate: Thu Mar 26 12:48:21 2026 +0100

    CAUSEWAY-3975: adds transaction observation
---
 .../CausewayObservationIntegration.java            |   4 +-
 .../transaction/TransactionServiceDevNotes.adoc    |  37 +++++
 .../transaction/TransactionServiceSpring.java      | 111 ++++++++-------
 .../NoopTransactionSynchronizationService.java     |   2 +-
 .../transaction/scope/StackedTransactionScope.java | 155 ++++++++++-----------
 .../TransactionScopeBeanFactoryPostProcessor.java  |   3 +-
 6 files changed, 179 insertions(+), 133 deletions(-)

diff --git 
a/commons/src/main/java/org/apache/causeway/commons/internal/observation/CausewayObservationIntegration.java
 
b/commons/src/main/java/org/apache/causeway/commons/internal/observation/CausewayObservationIntegration.java
index 210926d8a7d..110f9a7b672 100644
--- 
a/commons/src/main/java/org/apache/causeway/commons/internal/observation/CausewayObservationIntegration.java
+++ 
b/commons/src/main/java/org/apache/causeway/commons/internal/observation/CausewayObservationIntegration.java
@@ -25,7 +25,7 @@
 
 import org.springframework.util.StringUtils;
 
-import lombok.Data;
+import lombok.Getter;
 import lombok.experimental.Accessors;
 
 import io.micrometer.common.KeyValue;
@@ -84,7 +84,7 @@ public ObservationProvider provider(final Class<?> bean) {
     /**
      * Helps if start and stop of an {@link Observation} happen in different 
code locations.
      */
-    @Data @Accessors(fluent = true)
+    @Getter @Accessors(fluent = true)
     public static final class ObservationClosure implements AutoCloseable {
 
         private Observation observation;
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceDevNotes.adoc
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceDevNotes.adoc
new file mode 100644
index 00000000000..808b2d56bcd
--- /dev/null
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceDevNotes.adoc
@@ -0,0 +1,37 @@
+= Transaction Service
+
+:Notice: 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. http://www.apache.org/licenses/LICENSE-2.0 . Unless required by 
applicable law or ag [...]
+
+[plantuml,fig-transaction-flow,svg]
+.Transactional Code Flow
+----
+@startuml
+
+boundary RequestCycle
+participant InteractionService
+participant TransactionService
+boundary JpaTransactionManager as "JpaTransactionManager\n(Spring)"
+
+RequestCycle -> InteractionService: open (root) Interaction Layer
+
+InteractionService -> TransactionService: onOpen - sets up the initial\n\
+transaction against (all available) \nPlatformTransactionManager(s);\n\
+also installs ObservationClosure
+
+TransactionService -> JpaTransactionManager: getTransaction(txDefn)
+TransactionService <-- JpaTransactionManager: new or existing Transaction
+InteractionService <-- TransactionService: Interaction opened
+
+RequestCycle -> InteractionService: closeInteractionLayers()
+
+InteractionService -> TransactionService: onClose
+TransactionService -> JpaTransactionManager: commit(txStatus) or 
rollback(txStatus)
+TransactionService <-- JpaTransactionManager: Transaction completed
+
+InteractionService <-- TransactionService : Observations closed\n\
+Interaction closed
+
+RequestCycle <-- InteractionService
+
+@enduml
+----
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceSpring.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceSpring.java
index 1c91bf6736b..7800ffeb7ea 100644
--- 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceSpring.java
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/transaction/TransactionServiceSpring.java
@@ -18,6 +18,7 @@
  */
 package org.apache.causeway.core.runtimeservices.transaction;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.Callable;
@@ -53,17 +54,20 @@
 import org.apache.causeway.commons.functional.ThrowingRunnable;
 import org.apache.causeway.commons.functional.Try;
 import org.apache.causeway.commons.internal.base._NullSafe;
-import org.apache.causeway.commons.internal.collections._Lists;
 import org.apache.causeway.commons.internal.debug._Probe;
 import org.apache.causeway.commons.internal.exceptions._Exceptions;
+import 
org.apache.causeway.commons.internal.observation.CausewayObservationIntegration;
+import 
org.apache.causeway.commons.internal.observation.CausewayObservationIntegration.ObservationClosure;
+import 
org.apache.causeway.commons.internal.observation.CausewayObservationIntegration.ObservationProvider;
 import org.apache.causeway.core.interaction.session.CausewayInteraction;
 import org.apache.causeway.core.runtime.flushmgmt.FlushMgmt;
 import 
org.apache.causeway.core.runtimeservices.CausewayModuleCoreRuntimeServices;
 import org.apache.causeway.core.transaction.events.TransactionCompletionStatus;
 
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
+import io.micrometer.observation.Observation;
+
 /**
  * Default implementation of {@link TransactionService}, which delegates to 
Spring's own transaction management
  * framework, such as {@link PlatformTransactionManager}.
@@ -87,13 +91,16 @@ public class TransactionServiceSpring
     private final Provider<InteractionLayerTracker> 
interactionLayerTrackerProvider;
     private final Can<PersistenceExceptionTranslator> 
persistenceExceptionTranslators;
     private final ConfigurableListableBeanFactory 
configurableListableBeanFactory;
+    private final ObservationProvider observationProvider;
 
     @Inject
     public TransactionServiceSpring(
             final List<PlatformTransactionManager> platformTransactionManagers,
             final List<PersistenceExceptionTranslator> 
persistenceExceptionTranslators,
             final Provider<InteractionLayerTracker> 
interactionLayerTrackerProvider,
-            final ConfigurableListableBeanFactory 
configurableListableBeanFactory
+            final ConfigurableListableBeanFactory 
configurableListableBeanFactory,
+            @Qualifier("causeway-runtimeservices")
+            final CausewayObservationIntegration observationIntegration
     ) {
 
         this.platformTransactionManagers = 
Can.ofCollection(platformTransactionManagers);
@@ -105,6 +112,8 @@ public TransactionServiceSpring(
         log.info("PersistenceExceptionTranslators: {}", 
persistenceExceptionTranslators);
 
         this.interactionLayerTrackerProvider = interactionLayerTrackerProvider;
+
+        this.observationProvider = observationIntegration.provider(getClass());
     }
 
     // -- API
@@ -118,7 +127,7 @@ public <T> Try<T> callTransactional(final 
TransactionDefinition def, final Calla
 
         try {
             TransactionStatus txStatus = 
platformTransactionManager.getTransaction(def);
-            registerTransactionSynchronizations(txStatus);
+            registerTransactionSynchronizations();
 
             result = Try.call(() -> {
 
@@ -145,9 +154,8 @@ public <T> Try<T> callTransactional(final 
TransactionDefinition def, final Calla
             // return the original failure cause (originating from calling the 
callable)
             // (so we don't shadow the original failure)
             // return the failure we just caught
-            if (result != null && result.isFailure()) {
+            if (result != null && result.isFailure())
                 return result;
-            }
 
             // otherwise, we thought we had a success, but now we have an 
exception thrown by either ,
             // the call to rollback or commit above.  We don't need to do 
anything though; if either of
@@ -159,7 +167,7 @@ public <T> Try<T> callTransactional(final 
TransactionDefinition def, final Calla
         return result;
     }
 
-    private void registerTransactionSynchronizations(final TransactionStatus 
txStatus) {
+    private void registerTransactionSynchronizations() {
         if (TransactionSynchronizationManager.isSynchronizationActive()) {
             
configurableListableBeanFactory.getBeansOfType(TransactionSynchronization.class)
                     .values()
@@ -184,9 +192,8 @@ public void flushTransaction() {
 
             var translatedEx = translateExceptionIfPossible(ex, txManager);
 
-            if(translatedEx instanceof RuntimeException) {
+            if(translatedEx instanceof RuntimeException)
                 throw ex;
-            }
 
             throw new RuntimeException(ex);
 
@@ -209,11 +216,10 @@ public TransactionState currentTransactionState() {
         return currentTransactionStatus()
         .map(txStatus->{
 
-            if(txStatus.isCompleted()) {
+            if(txStatus.isCompleted())
                 return txStatus.isRollbackOnly()
                         ? TransactionState.ABORTED
                         : TransactionState.COMMITTED;
-            }
 
             return txStatus.isRollbackOnly()
                     ? TransactionState.MUST_ABORT
@@ -235,9 +241,8 @@ public TransactionState currentTransactionState() {
     private PlatformTransactionManager transactionManagerForElseFail(final 
TransactionDefinition def) {
         if(def instanceof TransactionTemplate) {
             var txManager = ((TransactionTemplate)def).getTransactionManager();
-            if(txManager!=null) {
+            if(txManager!=null)
                 return txManager;
-            }
         }
         return platformTransactionManagers.getSingleton()
                 .orElseThrow(()->
@@ -264,9 +269,8 @@ private Optional<TransactionStatus> 
currentTransactionStatus() {
         
txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_MANDATORY);
 
         // not strictly required, but to prevent stack-trace creation later on
-        if(!TransactionSynchronizationManager.isActualTransactionActive()) {
+        if(!TransactionSynchronizationManager.isActualTransactionActive())
             return Optional.empty();
-        }
 
         // get current transaction else throw an exception
         return Try.call(()->
@@ -278,9 +282,8 @@ private Optional<TransactionStatus> 
currentTransactionStatus() {
 
     private Throwable translateExceptionIfPossible(final Throwable ex, final 
PlatformTransactionManager txManager) {
 
-        if(ex instanceof DataAccessException) {
+        if(ex instanceof DataAccessException)
             return ex; // nothing to do, already translated
-        }
 
         if(ex instanceof RuntimeException) {
 
@@ -291,15 +294,18 @@ private Throwable translateExceptionIfPossible(final 
Throwable ex, final Platfor
             .findFirst()
             .orElse(null);
 
-            if(translatedEx!=null) {
+            if(translatedEx!=null)
                 return translatedEx;
-            }
 
         }
 
         return ex;
     }
 
+    static class X extends Observation.Context {
+
+    }
+
     /**
      * For use only by {@link 
org.apache.causeway.core.runtimeservices.session.InteractionServiceDefault}, 
sets up
      * the initial transaction automatically against all available {@link 
PlatformTransactionManager}s.
@@ -309,36 +315,43 @@ private Throwable translateExceptionIfPossible(final 
Throwable ex, final Platfor
     public void onOpen(final @NonNull CausewayInteraction interaction) {
 
         txCounter.get().reset();
+        if (platformTransactionManagers.isEmpty()) return;
 
         if (log.isDebugEnabled()) {
             log.debug("opening on {}", _Probe.currentThreadId());
         }
 
-        if (!platformTransactionManagers.isEmpty()) {
-            var onCloseTasks = 
_Lists.<CloseTask>newArrayList(platformTransactionManagers.size());
+        var onCloseHandle = new OnCloseHandle(new 
ArrayList<>(platformTransactionManagers.size()), new ObservationClosure());
+        interaction.putAttribute(OnCloseHandle.class, onCloseHandle);
+
+        platformTransactionManagers.forEach(txManager -> {
 
-            interaction.putAttribute(OnCloseHandle.class, new 
OnCloseHandle(onCloseTasks));
+            var txDefn = new TransactionTemplate(txManager); // specify the 
txManager in question
+            
txDefn.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
 
-            platformTransactionManagers.forEach(txManager -> {
+            var obs = 
onCloseHandle.observationClosure().startAndOpenScope(observationProvider.get("Transaction"))
+                .observation()
+                .highCardinalityKeyValue("txManager", 
txManager.getClass().getName());
 
-                var txDefn = new TransactionTemplate(txManager); // specify 
the txManager in question
-                
txDefn.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
+            // either participate in existing or create new transaction
+            TransactionStatus txStatus = observationProvider.get("Transaction 
Creation")
+                    .observe(()->txManager.getTransaction(txDefn));
+            if(!txStatus.isNewTransaction()) {
+                // discard telemetry data when participating in existing 
transaction
+                obs.getContext().put("micrometer.discard", true);
+                // we are participating in an exiting transaction (or 
testing), nothing to do
+                return;
+            }
 
-                // either participate in existing or create new transaction
-                TransactionStatus txStatus = txManager.getTransaction(txDefn);
+            registerTransactionSynchronizations();
 
-                if(!txStatus.isNewTransaction()) {
-                    // we are participating in an exiting transaction (or 
testing), nothing to do
-                    return;
-                }
-                registerTransactionSynchronizations(txStatus);
-
-                // we have created a new transaction, so need to provide a 
CloseTask
-                onCloseTasks.add(
-                    new CloseTask(
-                        txStatus,
-                        txManager.getClass().getName(), // info to be used for 
display in case of errors
-                        () -> {
+            // we have created a new transaction, so need to provide a 
CloseTask
+            onCloseHandle.onCloseTasks().add(
+                new CloseTask(
+                    txStatus,
+                    txManager.getClass().getName(), // info to be used for 
display in case of errors
+                    ()->observationProvider.get("Transaction Completion")
+                        .observe(() -> {
                             
_Xray.txBeforeCompletion(interactionLayerTrackerProvider.get(), "tx: 
beforeCompletion");
                             final TransactionCompletionStatus event;
                             if (txStatus.isRollbackOnly()) {
@@ -349,13 +362,12 @@ public void onOpen(final @NonNull CausewayInteraction 
interaction) {
                                 event = TransactionCompletionStatus.COMMITTED;
                             }
                             
_Xray.txAfterCompletion(interactionLayerTrackerProvider.get(), 
String.format("tx: afterCompletion (%s)", event.name()));
-
                             txCounter.get().increment();
-                        }
-                    )
-                );
-            });
-        }
+                        })
+                )
+            );
+        });
+
     }
 
     /**
@@ -397,9 +409,10 @@ private record CloseTask(
             @NonNull ThrowingRunnable runnable) {
     }
 
-    @RequiredArgsConstructor
-    private static class OnCloseHandle {
-        private final @NonNull List<CloseTask> onCloseTasks;
+    private record OnCloseHandle(
+            List<CloseTask> onCloseTasks,
+            ObservationClosure observationClosure) {
+
         void requestRollback() {
             onCloseTasks.forEach(onCloseTask->{
                 onCloseTask.txStatus.setRollbackOnly();
@@ -407,7 +420,6 @@ void requestRollback() {
         }
         void runOnCloseTasks() {
             onCloseTasks.forEach(onCloseTask->{
-
                 try {
                     onCloseTask.runnable().run();
                 } catch(final Throwable ex) {
@@ -419,6 +431,7 @@ void runOnCloseTasks() {
                             ex);
                 }
             });
+            observationClosure.close();
         }
     }
 }
diff --git 
a/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/NoopTransactionSynchronizationService.java
 
b/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/NoopTransactionSynchronizationService.java
index 01769b5f51a..14cd95cbee8 100644
--- 
a/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/NoopTransactionSynchronizationService.java
+++ 
b/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/NoopTransactionSynchronizationService.java
@@ -22,7 +22,7 @@
 
 /**
  * This service, which does nothing in and of itself, exists in order to 
ensure that the {@link StackedTransactionScope}
- * is always initialized, findinag at least one {@link TransactionScope 
transaction-scope}d service.
+ * is always initialized, finding at least one {@link TransactionScope 
transaction-scope}d service.
  */
 @Service
 @TransactionScope
diff --git 
a/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/StackedTransactionScope.java
 
b/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/StackedTransactionScope.java
index f3dd41b30a5..61dd09a93bd 100644
--- 
a/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/StackedTransactionScope.java
+++ 
b/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/StackedTransactionScope.java
@@ -21,42 +21,27 @@
 import java.util.Stack;
 import java.util.UUID;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.beans.factory.ObjectFactory;
 import org.springframework.beans.factory.config.Scope;
-import org.jspecify.annotations.Nullable;
 import org.springframework.transaction.support.TransactionSynchronization;
 import 
org.springframework.transaction.support.TransactionSynchronizationManager;
 
+import org.apache.causeway.commons.internal.base._Refs;
+
 public class StackedTransactionScope implements Scope {
 
     @Override
     public Object get(final String name, final ObjectFactory<?> objectFactory) 
{
 
-        var transactionNestingLevelForThisThread = 
currentTransactionNestingLevelForThisThread();
-
-        ScopedObjectsHolder scopedObjects = (ScopedObjectsHolder) 
TransactionSynchronizationManager.getResource(currentTransactionNestingLevelForThisThread());
+        var scopedObjects = currentScopedObjectsHolder();
         if (scopedObjects == null) {
-            scopedObjects = new 
ScopedObjectsHolder(transactionNestingLevelForThisThread);
-            if (TransactionSynchronizationManager.isSynchronizationActive()) {
-                // this happen when TransactionSynchronization#afterCompletion 
is called.
-                // it's a catch-22 : we use TransactionSynchronization as a 
resource to hold the scoped objects,
-                // but those scoped objects can only be interacted with during 
the transaction, not after it.
-                //
-                // see the 'else' clause below for the handling if we 
encounter the ScopedObjectsHolder after the
-                // transaction was completed.
-                registerWithTransitionSynchronizationManager(scopedObjects);
-            } else {
-                scopedObjects.registered = false;
-            }
-            
TransactionSynchronizationManager.bindResource(transactionNestingLevelForThisThread,
 scopedObjects);
+            scopedObjects = createAndBindScopedObjectsHolder();
         } else {
-            if (TransactionSynchronizationManager.isSynchronizationActive()) {
-                // it's possible that this already-existing scopedObject was 
added when a synchronization wasn't active
-                // (see the 'if' block above) and so wouldn't be registered to 
TSM.  If that's the case, we register it now.
-                if (!scopedObjects.registered) {
-                    
registerWithTransitionSynchronizationManager(scopedObjects);
-                }
-            }
+            // it's possible that this already-existing scopedObject was added 
when a synchronization wasn't active
+            // (see the 'if' block above) and so wouldn't be registered to 
TSM.  If that's the case, we register it now.
+            
registerWithTransactionSynchronizationManagerIfNotAlready(scopedObjects);
         }
         // NOTE: Do NOT modify the following to use Map::computeIfAbsent. For 
details,
         // see 
https://github.com/spring-projects/spring-framework/issues/25801.
@@ -68,32 +53,52 @@ public Object get(final String name, final ObjectFactory<?> 
objectFactory) {
         return scopedObject;
     }
 
-    private void registerWithTransitionSynchronizationManager(final 
ScopedObjectsHolder scopedObjects) {
-        TransactionSynchronizationManager.registerSynchronization(new 
CleanupSynchronization(scopedObjects));
-        scopedObjects.registered = true;
+    private void 
registerWithTransactionSynchronizationManagerIfNotAlready(final 
ScopedObjectsHolder scopedObjects) {
+        if (scopedObjects.registered.isTrue()
+                || 
!TransactionSynchronizationManager.isSynchronizationActive()) return;
+        TransactionSynchronizationManager.registerSynchronization(new 
CleanupSynchronization(this, scopedObjects));
+        scopedObjects.registered.setValue(true);
     }
 
     @Override
     @Nullable
     public Object remove(final String name) {
-        var currentTransactionNestingLevel = 
currentTransactionNestingLevelForThisThread();
-        ScopedObjectsHolder scopedObjects = (ScopedObjectsHolder) 
TransactionSynchronizationManager.getResource(currentTransactionNestingLevel);
+        var scopedObjects = currentScopedObjectsHolder();
         if (scopedObjects != null) {
             scopedObjects.destructionCallbacks.remove(name);
             return scopedObjects.scopedInstances.remove(name);
-        } else {
+        } else
             return null;
-        }
     }
 
     @Override
     public void registerDestructionCallback(final String name, final Runnable 
callback) {
-        ScopedObjectsHolder scopedObjects = (ScopedObjectsHolder) 
TransactionSynchronizationManager.getResource(currentTransactionNestingLevelForThisThread());
+        var scopedObjects = currentScopedObjectsHolder();
         if (scopedObjects != null) {
             scopedObjects.destructionCallbacks.put(name, callback);
         }
     }
 
+    @Nullable
+    private ScopedObjectsHolder currentScopedObjectsHolder() {
+        return (ScopedObjectsHolder) TransactionSynchronizationManager
+                .getResource(currentTransactionNestingLevelForThisThread());
+    }
+
+    private ScopedObjectsHolder createAndBindScopedObjectsHolder() {
+        final UUID transactionNestingLevelForThisThread = 
currentTransactionNestingLevelForThisThread();
+        var scopedObjects = new 
ScopedObjectsHolder(transactionNestingLevelForThisThread);
+        // this happen when TransactionSynchronization#afterCompletion is 
called.
+        // it's a catch-22 : we use TransactionSynchronization as a resource 
to hold the scoped objects,
+        // but those scoped objects can only be interacted with during the 
transaction, not after it.
+        //
+        // see the 'else' clause below for the handling if we encounter the 
ScopedObjectsHolder after the
+        // transaction was completed.
+        
registerWithTransactionSynchronizationManagerIfNotAlready(scopedObjects);
+        
TransactionSynchronizationManager.bindResource(transactionNestingLevelForThisThread,
 scopedObjects);
+        return scopedObjects;
+    }
+
     /**
      * Holds a unique id for each nested transaction within the current thread.
      *
@@ -103,8 +108,8 @@ public void registerDestructionCallback(final String name, 
final Runnable callba
      * using an anonymous <code>new Object()</code>.
      * </p>
      */
-    private static final ThreadLocal<Stack<UUID>> 
transactionNestingLevelThreadLocal = ThreadLocal.withInitial(() -> {
-        Stack<UUID> stack = new Stack<>();
+    private static final ThreadLocal<Stack<UUID>> UUID_STACK = 
ThreadLocal.withInitial(() -> {
+        var stack = new Stack<UUID>();
         stack.push(UUID.randomUUID());
         return stack;
     });
@@ -113,29 +118,26 @@ public void registerDestructionCallback(final String 
name, final Runnable callba
      * Maintains a stack of keys representing nested transactions, where the 
top-most is the key managed by
      * {@link TransactionSynchronizationManager} holding the {@link 
ScopedObjectsHolder} for the current transaction.
      *
-     * <p>
-     * The keys themselves are {@link UUID}s, having no meaning in themselves 
other than their identity as the key
+     * <p>The keys are {@link UUID}s, having no meaning in themselves other 
than their identity as the key
      * into a hashmap.
      *
-     * <p>
-     * If a transaction is suspended, then the {@link 
CleanupSynchronization#suspend() suspend} callback is used
+     * <p>If a transaction is suspended, then the {@link 
CleanupSynchronization#suspend() suspend} callback is used
      * to pop a new key onto the stack, unbinding the previous key's resources 
(in other words, the
      * {@link org.apache.causeway.applib.annotation.TransactionScope 
transaction-scope}d beans of the suspended
-     * transaction) from {@link TransactionSynchronizationManager}.  As 
transaction-scoped beans are then resolved,
+     * transaction) from {@link TransactionSynchronizationManager}. As 
transaction-scoped beans are then resolved,
      * they will be associated with the new key.
      *
-     * <p>
-     * Conversely, when a transaction is resumed, then the process is 
reversed; the old key is popped, and the previous
+     * <p>Conversely, when a transaction is resumed, then the process is 
reversed; the old key is popped, and the previous
      * key is rebound to the {@link TransactionSynchronizationManager}, 
meaning that the previous transaction's
      * {@link org.apache.causeway.applib.annotation.TransactionScope 
transaction-scope}d beans are brought back.
      *
      * @see #currentTransactionNestingLevelForThisThread()
      * @see #pushToNewTransactionNestingLevelForThisThread()
      * @see #popToPreviousTransactionNestingLevelForThisThread()
-     * @see #transactionNestingLevelThreadLocal
+     * @see #UUID_STACK
      */
     private static Stack<UUID> transactionNestingLevelForThread() {
-        return transactionNestingLevelThreadLocal.get();
+        return UUID_STACK.get();
     }
 
     /**
@@ -174,48 +176,41 @@ public String getConversationId() {
     /**
      * Holder for scoped objects.
      */
-    static class ScopedObjectsHolder {
-
-        private final UUID transactionUuid;
-
-        ScopedObjectsHolder(UUID transactionUuid) {
-            this.transactionUuid = transactionUuid;
+    record ScopedObjectsHolder(
+            UUID transactionUuid,
+            Map<String, Object> scopedInstances,
+            Map<String, Runnable> destructionCallbacks,
+            /**
+             * Keeps track of whether these objects have been registered with 
{@link TransactionSynchronizationManager}.
+             *
+             * <p>This can only be done if
+             * {@link 
TransactionSynchronizationManager#isSynchronizationActive() synchronization is 
active}, which
+             * isn't the case for {@link ScopedObjectsHolder scoped objects} 
that are obtained as a result of the
+             * {@link TransactionSynchronization#afterCompletion(int)} 
callback.
+             * We use this flag to keep track in case they are reused in a 
subsequent transaction.
+             */
+            _Refs.BooleanReference registered) {
+
+        ScopedObjectsHolder(
+                final UUID transactionUuid) {
+            this(transactionUuid, new HashMap<>(), new LinkedHashMap<>(), new 
_Refs.BooleanReference(false));
         }
 
-        final Map<String, Object> scopedInstances = new HashMap<>();
-        final Map<String, Runnable> destructionCallbacks = new 
LinkedHashMap<>();
-
-        /**
-         * Keeps track of whether these objects have been registered with 
{@link TransactionSynchronizationManager}.
-         *
-         * <p>
-         * This can only be done if
-         * {@link TransactionSynchronizationManager#isSynchronizationActive() 
synchronization is active}, which
-         * isn't the case for {@link ScopedObjectsHolder scoped objects} that 
are obtained as a result of the
-         * {@link TransactionSynchronization#afterCompletion(int)} callback.  
We use this flag to keep track in
-         * case they are reused in a subsequent transaction.
-         * </p>
-         */
-        private boolean registered = false;
-
+        @Override
         public String toString() {
             return String.format(
-                    "uuid: %s, registered: %s, scopedInstances.size(): %d, 
destructionCallbacks.size(): %d",
-                    transactionUuid, registered, scopedInstances.size(), 
destructionCallbacks.size());
+                    "uuid: %s, registered: %b, scopedInstances.size(): %d, 
destructionCallbacks.size(): %d",
+                    transactionUuid, registered.isTrue(), 
scopedInstances.size(), destructionCallbacks.size());
         }
     }
 
-    private class CleanupSynchronization implements TransactionSynchronization 
{
-
-        private final ScopedObjectsHolder scopedObjects;
-
-        public CleanupSynchronization(final ScopedObjectsHolder scopedObjects) 
{
-            this.scopedObjects = scopedObjects;
-        }
+    private record CleanupSynchronization(
+            StackedTransactionScope scope,
+            ScopedObjectsHolder scopedObjects) implements 
TransactionSynchronization {
 
         @Override
         public void suspend() {
-            var transactionNestingLevelForThisThread = 
currentTransactionNestingLevelForThisThread();
+            var transactionNestingLevelForThisThread = 
scope.currentTransactionNestingLevelForThisThread();
             
TransactionSynchronizationManager.unbindResource(transactionNestingLevelForThisThread);
             pushToNewTransactionNestingLevelForThisThread();  // subsequent 
calls to obtain a @TransactionScope'd bean will be against this key
         }
@@ -223,17 +218,17 @@ public void suspend() {
         @Override
         public void resume() {
             popToPreviousTransactionNestingLevelForThisThread(); // the 
now-completed transaction's @TransactionScope'd beans are no longer required, 
and will be GC'd.
-            
TransactionSynchronizationManager.bindResource(currentTransactionNestingLevelForThisThread(),
 this.scopedObjects);
+            
TransactionSynchronizationManager.bindResource(scope.currentTransactionNestingLevelForThisThread(),
 scopedObjects);
         }
 
         @Override
         public void afterCompletion(final int status) {
-            
TransactionSynchronizationManager.unbindResourceIfPossible(StackedTransactionScope.this.currentTransactionNestingLevelForThisThread());
-            for (Runnable callback : 
this.scopedObjects.destructionCallbacks.values()) {
+            
TransactionSynchronizationManager.unbindResourceIfPossible(scope.currentTransactionNestingLevelForThisThread());
+            for (Runnable callback : 
scopedObjects.destructionCallbacks.values()) {
                 callback.run();
             }
-            this.scopedObjects.destructionCallbacks.clear();
-            this.scopedObjects.scopedInstances.clear();
+            scopedObjects.destructionCallbacks.clear();
+            scopedObjects.scopedInstances.clear();
         }
     }
 
diff --git 
a/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/TransactionScopeBeanFactoryPostProcessor.java
 
b/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/TransactionScopeBeanFactoryPostProcessor.java
index ebc022decde..42af05b351e 100644
--- 
a/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/TransactionScopeBeanFactoryPostProcessor.java
+++ 
b/core/transaction/src/main/java/org/apache/causeway/core/transaction/scope/TransactionScopeBeanFactoryPostProcessor.java
@@ -32,7 +32,8 @@ public class TransactionScopeBeanFactoryPostProcessor 
implements BeanFactoryPost
     public static final String SCOPE_NAME = 
org.apache.causeway.applib.annotation.TransactionScope.SCOPE_NAME;
 
     @Override
-    public void postProcessBeanFactory(ConfigurableListableBeanFactory 
beanFactory) throws BeansException {
+    public void postProcessBeanFactory(@SuppressWarnings("exports") final 
ConfigurableListableBeanFactory beanFactory)
+            throws BeansException {
         var transactionScope = new StackedTransactionScope();
         beanFactory.registerScope(SCOPE_NAME, transactionScope);
     }

Reply via email to