You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@isis.apache.org by ah...@apache.org on 2021/04/06 09:12:37 UTC

[isis] branch 2573_entity.ch.tr created (now d6e8521)

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

ahuber pushed a change to branch 2573_entity.ch.tr
in repository https://gitbox.apache.org/repos/asf/isis.git.


      at d6e8521  ISIS-2573: entity change tracking: convert interaction scoped to tx-scoped

This branch includes the following new commits:

     new d6e8521  ISIS-2573: entity change tracking: convert interaction scoped to tx-scoped

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


[isis] 01/01: ISIS-2573: entity change tracking: convert interaction scoped to tx-scoped

Posted by ah...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ahuber pushed a commit to branch 2573_entity.ch.tr
in repository https://gitbox.apache.org/repos/asf/isis.git

commit d6e8521c95a337f86d4571ea02f2154d4f864e42
Author: ahuber@apache.org <ah...@luna>
AuthorDate: Tue Apr 6 11:12:15 2021 +0200

    ISIS-2573: entity change tracking: convert interaction scoped to
    tx-scoped
---
 .../isis/applib/annotation/InteractionScope.java   |   2 +-
 .../core/interaction/scope/InteractionScope.java   |  28 ++-
 .../scope/InteractionScopeLifecycleHandler.java    |   4 +-
 .../session/InteractionFactoryDefault.java         |  12 +-
 .../changetracking/EntityChangeTrackerDefault.java | 192 +++++++--------------
 .../changetracking/_ChangingEntitiesFactory.java   |   4 +-
 .../changetracking/_TransactionScopedContext.java  | 161 +++++++++++++++++
 .../core/transaction/changetracking/_Xray.java     |  47 ++++-
 .../jdo/JdoEntityPropertyChangePublishingTest.java |   1 +
 .../applayer/ApplicationLayerTestFactory.java      |  66 +++----
 .../EntityPropertyChangeSubscriberForTesting.java  |   3 +-
 11 files changed, 328 insertions(+), 192 deletions(-)

diff --git a/api/applib/src/main/java/org/apache/isis/applib/annotation/InteractionScope.java b/api/applib/src/main/java/org/apache/isis/applib/annotation/InteractionScope.java
index a8b2632..821ebc3 100644
--- a/api/applib/src/main/java/org/apache/isis/applib/annotation/InteractionScope.java
+++ b/api/applib/src/main/java/org/apache/isis/applib/annotation/InteractionScope.java
@@ -28,7 +28,7 @@ import org.springframework.context.annotation.Scope;
 
 /**
  * {@code @InteractionScope} is a specialization of {@link Scope @Scope} for a
- * component whose lifecycle is bound to the current top-level Interaction,
+ * component whose lifecycle is bound to the current Interaction,
  * in other words that it is private to the &quot;current user&quot;.
  *
  * <p>Specifically, {@code @InteractionScope} is a <em>composed annotation</em> that
diff --git a/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScope.java b/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScope.java
index d73ec8b..9a4dd01 100644
--- a/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScope.java
+++ b/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScope.java
@@ -39,7 +39,10 @@ import lombok.extern.log4j.Log4j2;
  * @since 2.0
  */
 @Log4j2
-class InteractionScope implements Scope, InteractionScopeLifecycleHandler {
+class InteractionScope 
+implements 
+    Scope, 
+    InteractionScopeLifecycleHandler {
     
     @Inject private InteractionTracker isisInteractionTracker;
 
@@ -49,7 +52,7 @@ class InteractionScope implements Scope, InteractionScopeLifecycleHandler {
         Object instance;
         Runnable destructionCallback;
         void preDestroy() {
-            log.debug("destroy isis-session scoped {}", name);
+            log.debug("destroy isis-interaction scoped {}", name);
             if(destructionCallback!=null) {
                 destructionCallback.run();
             }
@@ -75,15 +78,26 @@ class InteractionScope implements Scope, InteractionScopeLifecycleHandler {
         
         val existingScopedObject = scopedObjects.get().get(name);
         if(existingScopedObject!=null) {
+            
+            _Probe.errOut("INTERACTION_SCOPE [%s:%s] reuse existing %s", 
+                    _Probe.currentThreadId(),
+                    getConversationId(),
+                    Integer.toHexString(existingScopedObject.hashCode()));
+            
             return existingScopedObject.getInstance();
         }
         
         val newScopedObject = ScopedObject.of(name); 
         scopedObjects.get().put(name, newScopedObject); // just set a stub with a name only
         
-        log.debug("create new isis-session scoped {}", name);
+        log.debug("create new isis-interaction scoped {}", name);
         newScopedObject.setInstance(objectFactory.getObject()); // triggers call to registerDestructionCallback
         
+        _Probe.errOut("INTERACTION_SCOPE [%s:%s] create new %s",
+                _Probe.currentThreadId(),
+                getConversationId(),
+                Integer.toHexString(newScopedObject.hashCode()));
+        
         return newScopedObject.getInstance();
     }
 
@@ -116,12 +130,16 @@ class InteractionScope implements Scope, InteractionScopeLifecycleHandler {
     }
     
     @Override
-    public void onTopLevelInteractionOpened() {
+    public void onInteractionOpened() {
         // nothing to do
+        _Probe.errOut("INTERACTION_SCOPE opening");
     }
 
     @Override
-    public void onTopLevelInteractionClosing() {
+    public void onInteractionClosing() {
+        
+        _Probe.errOut("INTERACTION_SCOPE closing");
+        
         try {
             scopedObjects.get().values().forEach(ScopedObject::preDestroy);
         } finally {
diff --git a/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScopeLifecycleHandler.java b/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScopeLifecycleHandler.java
index 82ad852..a09db78 100644
--- a/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScopeLifecycleHandler.java
+++ b/core/interaction/src/main/java/org/apache/isis/core/interaction/scope/InteractionScopeLifecycleHandler.java
@@ -23,7 +23,7 @@ package org.apache.isis.core.interaction.scope;
  */
 public interface InteractionScopeLifecycleHandler {
 
-    void onTopLevelInteractionOpened();
-    void onTopLevelInteractionClosing();
+    void onInteractionOpened();
+    void onInteractionClosing();
     
 }
diff --git a/core/runtimeservices/src/main/java/org/apache/isis/core/runtimeservices/session/InteractionFactoryDefault.java b/core/runtimeservices/src/main/java/org/apache/isis/core/runtimeservices/session/InteractionFactoryDefault.java
index ba6b67f..6fe80cd 100644
--- a/core/runtimeservices/src/main/java/org/apache/isis/core/runtimeservices/session/InteractionFactoryDefault.java
+++ b/core/runtimeservices/src/main/java/org/apache/isis/core/runtimeservices/session/InteractionFactoryDefault.java
@@ -187,7 +187,7 @@ implements
         authenticationStack.get().push(authenticationLayer);
 
         if(isInBaseLayer()) {
-        	postSessionOpened(interactionSession);
+        	postInteractionOpened(interactionSession);
         }
         
         if(log.isDebugEnabled()) {
@@ -312,18 +312,18 @@ implements
     	return authenticationStack.get().size()==1; 
     }
     
-    private void postSessionOpened(IsisInteraction interaction) {
+    private void postInteractionOpened(IsisInteraction interaction) {
         interactionId.set(interaction.getInteractionId());
         interactionScopeAwareBeans.forEach(bean->bean.beforeEnteringTransactionalBoundary(interaction));
         txBoundaryHandler.onOpen(interaction);
         val isSynchronizationActive = TransactionSynchronizationManager.isSynchronizationActive();
         interactionScopeAwareBeans.forEach(bean->bean.afterEnteringTransactionalBoundary(interaction, isSynchronizationActive));
-        interactionScopeLifecycleHandler.onTopLevelInteractionOpened();
+        interactionScopeLifecycleHandler.onInteractionOpened();
     }
     
-    private void preSessionClosed(IsisInteraction interaction) {
+    private void preInteractionClosed(IsisInteraction interaction) {
         completeAndPublishCurrentCommand();
-        interactionScopeLifecycleHandler.onTopLevelInteractionClosing(); // cleanup the isis-session scope
+        interactionScopeLifecycleHandler.onInteractionClosing(); // cleanup the isis-session scope
         val isSynchronizationActive = TransactionSynchronizationManager.isSynchronizationActive();
         interactionScopeAwareBeans.forEach(bean->bean.beforeLeavingTransactionalBoundary(interaction, isSynchronizationActive));
         txBoundaryHandler.onClose(interaction);
@@ -343,7 +343,7 @@ implements
         while(stack.size()>downToStackSize) {
         	if(isInBaseLayer()) {
         		// keep the stack unmodified yet, to allow for callbacks to properly operate
-        		preSessionClosed(stack.peek().getInteraction());
+        		preInteractionClosed(stack.peek().getInteraction());
         	}
         	_Xray.closeAuthenticationLayer(stack);
             stack.pop();
diff --git a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/EntityChangeTrackerDefault.java b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/EntityChangeTrackerDefault.java
index ace47ef..6b4121a 100644
--- a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/EntityChangeTrackerDefault.java
+++ b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/EntityChangeTrackerDefault.java
@@ -20,7 +20,6 @@ package org.apache.isis.core.transaction.changetracking;
 
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.LongAdder;
 import java.util.function.Consumer;
@@ -29,15 +28,10 @@ import javax.inject.Inject;
 import javax.inject.Named;
 import javax.inject.Provider;
 
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.context.annotation.Primary;
 import org.springframework.context.event.EventListener;
-import org.springframework.core.annotation.Order;
 import org.springframework.stereotype.Service;
 
 import org.apache.isis.applib.annotation.EntityChangeKind;
-import org.apache.isis.applib.annotation.InteractionScope;
-import org.apache.isis.applib.annotation.OrderPrecedence;
 import org.apache.isis.applib.events.lifecycle.AbstractLifecycleEvent;
 import org.apache.isis.applib.services.bookmark.Bookmark;
 import org.apache.isis.applib.services.eventbus.EventBusService;
@@ -48,9 +42,7 @@ import org.apache.isis.applib.services.publishing.spi.EntityChanges;
 import org.apache.isis.applib.services.publishing.spi.EntityPropertyChange;
 import org.apache.isis.applib.services.xactn.TransactionId;
 import org.apache.isis.commons.collections.Can;
-import org.apache.isis.commons.internal.base._Lazy;
-import org.apache.isis.commons.internal.collections._Maps;
-import org.apache.isis.commons.internal.collections._Sets;
+import org.apache.isis.commons.internal.debug._Probe;
 import org.apache.isis.commons.internal.exceptions._Exceptions;
 import org.apache.isis.commons.internal.factory._InstanceUtil;
 import org.apache.isis.core.metamodel.facets.object.callbacks.CallbackFacet;
@@ -75,8 +67,6 @@ import org.apache.isis.core.security.authentication.AuthenticationContext;
 import org.apache.isis.core.transaction.changetracking.events.IsisTransactionPlaceholder;
 import org.apache.isis.core.transaction.events.TransactionBeforeCompletionEvent;
 
-import lombok.AccessLevel;
-import lombok.Getter;
 import lombok.NonNull;
 import lombok.val;
 import lombok.extern.log4j.Log4j2;
@@ -86,10 +76,6 @@ import lombok.extern.log4j.Log4j2;
  */
 @Service
 @Named("isis.transaction.EntityChangeTrackerDefault")
-@Order(OrderPrecedence.EARLY)
-@Primary
-@Qualifier("Default")
-@InteractionScope
 @Log4j2
 public class EntityChangeTrackerDefault
 implements
@@ -104,75 +90,59 @@ implements
     @Inject private Provider<InteractionContext> interactionContextProvider;
     @Inject private Provider<AuthenticationContext> authenticationContextProvider;
     
-
-    /**
-     * Contains initial change records having set the pre-values of every property of every object that was enlisted.
-     */
-    private final Set<_PropertyChangeRecord> entityPropertyChangeRecords = _Sets.newLinkedHashSet();
-
-    /**
-     * Contains pre- and post- values of every property of every object that actually changed. A lazy snapshot,
-     * triggered by internal call to {@link #snapshotPropertyChangeRecords()}.
-     */
-    private final _Lazy<Set<_PropertyChangeRecord>> entityPropertyChangeRecordsForPublishing 
-        = _Lazy.threadSafe(this::capturePostValuesAndDrain);
-
-    @Getter(AccessLevel.PACKAGE)
-    private final Map<Bookmark, EntityChangeKind> changeKindByEnlistedAdapter = _Maps.newLinkedHashMap();
+    private ThreadLocal<_TransactionScopedContext> transactionScopedContext 
+        = ThreadLocal.withInitial(()->
+                new _TransactionScopedContext(interactionContextProvider, authenticationContextProvider));
 
     private boolean isEnlisted(final @NonNull ManagedObject adapter) {
         return ManagedObjects.bookmark(adapter)
-        .map(changeKindByEnlistedAdapter::containsKey)
+        .map(bookmark->transactionScopedContext.get()
+                .changeKindByEnlistedAdapter()
+                .containsKey(bookmark))
         .orElse(false);
     }
 
-    private void enlistCreatedInternal(final @NonNull ManagedObject adapter) {
-        if(!isEntityEnabledForChangePublishing(adapter)) {
-            return;
+    private void enlistCreatedInternal(final @NonNull ManagedObject entity) {
+        onEntityChangeRecognized();
+        if(isEntityEnabledForChangePublishing(entity)) {
+            transactionScopedContext.get()
+                .enlistForChangeKindPublishing(entity, EntityChangeKind.CREATE);
+            enlistForPreAndPostValuePublishing(entity, record->record.setPreValue(IsisTransactionPlaceholder.NEW));    
         }
-        enlistForChangeKindPublishing(adapter, EntityChangeKind.CREATE);
-        enlistForPreAndPostValuePublishing(adapter, record->record.setPreValue(IsisTransactionPlaceholder.NEW));
     }
 
-    private void enlistUpdatingInternal(final @NonNull ManagedObject adapter) {
-        if(!isEntityEnabledForChangePublishing(adapter)) {
-            return;
+    private void enlistUpdatingInternal(final @NonNull ManagedObject entity) {
+        onEntityChangeRecognized();
+        if(isEntityEnabledForChangePublishing(entity)) {
+            transactionScopedContext.get()
+                .enlistForChangeKindPublishing(entity, EntityChangeKind.UPDATE);
+            enlistForPreAndPostValuePublishing(entity, _PropertyChangeRecord::updatePreValue);    
         }
-        enlistForChangeKindPublishing(adapter, EntityChangeKind.UPDATE);
-        enlistForPreAndPostValuePublishing(adapter, _PropertyChangeRecord::updatePreValue);
     }
 
-    private void enlistDeletingInternal(final @NonNull ManagedObject adapter) {
-        if(!isEntityEnabledForChangePublishing(adapter)) {
-            return;
-        }
-        final boolean enlisted = enlistForChangeKindPublishing(adapter, EntityChangeKind.DELETE);
-        if(enlisted) {
-            enlistForPreAndPostValuePublishing(adapter, _PropertyChangeRecord::updatePreValue);            
+    private void enlistDeletingInternal(final @NonNull ManagedObject entity) {
+        onEntityChangeRecognized();
+        if(isEntityEnabledForChangePublishing(entity)) {
+            val successfullyEnlisted = transactionScopedContext.get()
+                    .enlistForChangeKindPublishing(entity, EntityChangeKind.DELETE);
+            if(successfullyEnlisted) {
+                enlistForPreAndPostValuePublishing(entity, _PropertyChangeRecord::updatePreValue);            
+            }   
         }
     }
 
-    private Set<_PropertyChangeRecord> snapshotPropertyChangeRecords() {
-        // this code path has side-effects, it locks the result for this transaction,
-        // such that cannot enlist on top of it
-        return entityPropertyChangeRecordsForPublishing.get();
-    }
-
-    private boolean isEntityEnabledForChangePublishing(final @NonNull ManagedObject adapter) {
-
-        if(entityPropertyChangeRecordsForPublishing.isMemoized()) {
+    private void onEntityChangeRecognized() {
+        if(transactionScopedContext.get().isAlreadyPreparedForPublishing()) {
             throw _Exceptions.illegalState("Cannot enlist additional changes for auditing, "
                     + "since changedObjectPropertiesRef was already prepared (memoized) for auditing.");
         }
-
         entityChangeEventCount.increment();
         enableCommandPublishing();
-
-        if(!EntityChangePublishingFacet.isPublishingEnabled(adapter.getSpecification())) {
-            return false; // ignore entities that are not enabled for entity change publishing
-        }
-
-        return true;
+    }
+    
+    boolean isEntityEnabledForChangePublishing(final @NonNull ManagedObject entity) {
+        // ignore entities that are not enabled for entity change publishing
+        return EntityChangePublishingFacet.isPublishingEnabled(entity.getSpecification());
     }
 
     /**
@@ -197,14 +167,20 @@ implements
     }
 
     private void postPublishing() {
+        debug("PURGE");
         log.debug("purging entity change records");
-        entityPropertyChangeRecords.clear();
-        changeKindByEnlistedAdapter.clear();
-        entityPropertyChangeRecordsForPublishing.clear();
+        transactionScopedContext.remove();
         entityChangeEventCount.reset();
         numberEntitiesLoaded.reset();
     }
 
+    private void debug(String label) {
+        _Probe.errOut("!!![%s] %s %d", 
+                Integer.toHexString(EntityChangeTrackerDefault.this.hashCode()),
+                label, 
+                transactionScopedContext.get().entityPropertyChangeRecords().size());
+    }
+
     private void enableCommandPublishing() {
         val alreadySet = persitentChangesEncountered.getAndSet(true);
         if(!alreadySet) {
@@ -226,79 +202,26 @@ implements
 
     // -- HELPER
 
-    /**
-     * @return <code>true</code> if successfully enlisted, <code>false</code> if was already enlisted
-     */
-    private boolean enlistForChangeKindPublishing(
-            final @NonNull ManagedObject entity,
-            final @NonNull EntityChangeKind changeKind) {
-
-        val bookmark = ManagedObjects.bookmarkElseFail(entity);
-        
-        val previousChangeKind = changeKindByEnlistedAdapter.get(bookmark);
-        if(previousChangeKind == null) {
-            changeKindByEnlistedAdapter.put(bookmark, changeKind);
-            return true;
-        }
-        switch (previousChangeKind) {
-        case CREATE:
-            switch (changeKind) {
-            case DELETE:
-                changeKindByEnlistedAdapter.remove(bookmark);
-            case CREATE:
-            case UPDATE:
-                return false;
-            }
-            break;
-        case UPDATE:
-            switch (changeKind) {
-            case DELETE:
-                changeKindByEnlistedAdapter.put(bookmark, changeKind);
-                return true;
-            case CREATE:
-            case UPDATE:
-                return false;
-            }
-            break;
-        case DELETE:
-            return false;
-        }
-        return previousChangeKind == null;
-    }
-
     private void enlistForPreAndPostValuePublishing(
             final ManagedObject entity,
-            final Consumer<_PropertyChangeRecord> fun) {
+            final Consumer<_PropertyChangeRecord> onNewRecord) {
 
         log.debug("enlist entity's property changes for publishing {}", entity);
 
+        val entityPropertyChangeRecords = transactionScopedContext.get()
+                .entityPropertyChangeRecords();
         entity.getSpecification().streamProperties(MixedIn.EXCLUDED)
         .filter(property->!property.isNotPersisted())
         .map(property->_PropertyChangeRecord.of(entity, property))
         .filter(record->!entityPropertyChangeRecords.contains(record)) // already enlisted, so ignore
         .forEach(record->{
-            fun.accept(record);
+            onNewRecord.accept(record);
+            debug("ADD");
             entityPropertyChangeRecords.add(record);
+            debug("ADDED");
         });
     }
 
-    /**
-     * For any enlisted Object Properties collects those, that are meant for publishing,
-     * then clears enlisted objects.
-     */
-    private Set<_PropertyChangeRecord> capturePostValuesAndDrain() {
-
-        val records = entityPropertyChangeRecords.stream()
-                .peek(managedProperty->managedProperty.updatePostValue()) // set post values, which have been left empty up to now
-                .filter(managedProperty->managedProperty.getPreAndPostValue().shouldPublish())
-                .collect(_Sets.toUnmodifiable());
-
-        entityPropertyChangeRecords.clear();
-
-        return records;
-
-    }
-
     // -- METRICS SERVICE
 
     @Override
@@ -308,11 +231,11 @@ implements
 
     @Override
     public int numberEntitiesDirtied() {
-        return changeKindByEnlistedAdapter.size();
+        return transactionScopedContext.get().numberEntitiesDirtied();
     }
-
+    
     int propertyChangeRecordCount() {
-        return snapshotPropertyChangeRecords().size();
+        return transactionScopedContext.get().propertyChangeRecordCount();
     }
 
     // -- ENTITY CHANGE TRACKING
@@ -339,6 +262,9 @@ implements
 
     @Override
     public void enlistUpdating(ManagedObject entity) {
+        
+        _Exceptions.dumpStackTrace();
+        
         _Xray.enlistUpdating(entity, interactionContextProvider, authenticationContextProvider);
         val hasAlreadyBeenEnlisted = isEnlisted(entity);
         // we call this come what may;
@@ -409,10 +335,16 @@ implements
             final String userName,
             final TransactionId txId) {
 
-        return snapshotPropertyChangeRecords().stream()
+        return transactionScopedContext.get()
+                .snapshotPropertyChangeRecords()
+                .stream()
                 .map(propertyChangeRecord->_EntityPropertyChangeFactory
                         .createEntityPropertyChange(timestamp, userName, txId, propertyChangeRecord))
                 .collect(Can.toCan());
     }
 
+    Map<Bookmark, EntityChangeKind> changeKindByEnlistedAdapter() {
+        return transactionScopedContext.get().changeKindByEnlistedAdapter();
+    }
+
 }
diff --git a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_ChangingEntitiesFactory.java b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_ChangingEntitiesFactory.java
index 82737f1..93fa754 100644
--- a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_ChangingEntitiesFactory.java
+++ b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_ChangingEntitiesFactory.java
@@ -42,7 +42,7 @@ final class _ChangingEntitiesFactory {
             final String userName,
             final EntityChangeTrackerDefault entityChangeTracker) {
 
-        if(entityChangeTracker.getChangeKindByEnlistedAdapter().isEmpty()) {
+        if(entityChangeTracker.changeKindByEnlistedAdapter().isEmpty()) {
             return Optional.empty();
         }
         
@@ -50,7 +50,7 @@ final class _ChangingEntitiesFactory {
         // creates further entities which would be enlisted;
         // taking copy of the map avoids ConcurrentModificationException
         val changeKindByEnlistedAdapter = new HashMap<>(
-                entityChangeTracker.getChangeKindByEnlistedAdapter());
+                entityChangeTracker.changeKindByEnlistedAdapter());
 
         val changingEntities = newChangingEntities(
                 completedAt,
diff --git a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_TransactionScopedContext.java b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_TransactionScopedContext.java
new file mode 100644
index 0000000..370c230
--- /dev/null
+++ b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_TransactionScopedContext.java
@@ -0,0 +1,161 @@
+/*
+ *  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 agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.isis.core.transaction.changetracking;
+
+import java.util.Map;
+import java.util.Set;
+
+import javax.inject.Provider;
+
+import org.apache.isis.applib.annotation.EntityChangeKind;
+import org.apache.isis.applib.services.bookmark.Bookmark;
+import org.apache.isis.applib.services.iactn.InteractionContext;
+import org.apache.isis.commons.collections.Can;
+import org.apache.isis.commons.internal.base._Lazy;
+import org.apache.isis.commons.internal.collections._Maps;
+import org.apache.isis.commons.internal.collections._Sets;
+import org.apache.isis.commons.internal.debug._Probe;
+import org.apache.isis.core.metamodel.spec.ManagedObject;
+import org.apache.isis.core.metamodel.spec.ManagedObjects;
+import org.apache.isis.core.security.authentication.AuthenticationContext;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.val;
+import lombok.experimental.Accessors;
+
+@RequiredArgsConstructor
+final class _TransactionScopedContext {
+
+    private final Provider<InteractionContext> interactionContextProvider;
+    private final Provider<AuthenticationContext> authenticationContextProvider;
+    
+    /**
+     * Contains initial change records having set the pre-values of every property of every object that was enlisted.
+     */
+    @Getter(AccessLevel.PACKAGE)
+    @Accessors(fluent = true)
+    private final Set<_PropertyChangeRecord> entityPropertyChangeRecords = _Sets.newLinkedHashSet();
+    
+    /**
+     * Contains pre- and post- values of every property of every object that actually changed. A lazy snapshot,
+     * triggered by internal call to {@link #snapshotPropertyChangeRecords()}.
+     */
+    private final _Lazy<Can<_PropertyChangeRecord>> entityPropertyChangeRecordsForPublishing 
+        = _Lazy.threadSafe(this::capturePostValuesAndDrain);
+    
+    @Getter(AccessLevel.PACKAGE)
+    @Accessors(fluent = true)
+    private final Map<Bookmark, EntityChangeKind> changeKindByEnlistedAdapter = _Maps.newLinkedHashMap();
+    
+    /**
+     * For any enlisted Object Properties collects those, that are meant for publishing,
+     * then clears enlisted objects.
+     * @param interactionContextProvider 
+     * @param authenticationContextProvider 
+     */
+    Can<_PropertyChangeRecord> capturePostValuesAndDrain() {
+        
+        _Xray.capturePostValuesAndDrain(
+                entityPropertyChangeRecords, 
+                interactionContextProvider, 
+                authenticationContextProvider);
+
+        debug("BEFORE CLEAR");
+        
+        val records = entityPropertyChangeRecords.stream()
+        .peek(managedProperty->managedProperty.updatePostValue()) // set post values, which have been left empty up to now
+        .filter(managedProperty->managedProperty.getPreAndPostValue().shouldPublish())
+        .collect(Can.toCan());
+
+        debug("CLEAR");
+        
+        entityPropertyChangeRecords.clear();
+
+        return records;
+
+    }
+    
+    Can<_PropertyChangeRecord> snapshotPropertyChangeRecords() {
+        // this code path has side-effects, it locks the result for this transaction,
+        // such that cannot enlist on top of it
+        return entityPropertyChangeRecordsForPublishing.get();
+    }
+
+    boolean isAlreadyPreparedForPublishing() {
+        return entityPropertyChangeRecordsForPublishing.isMemoized();
+    }
+    
+    int propertyChangeRecordCount() {
+        return snapshotPropertyChangeRecords().size();
+    }
+    
+    int numberEntitiesDirtied() {
+        return changeKindByEnlistedAdapter.size();
+    }
+    
+    /**
+     * @return <code>true</code> if successfully enlisted, <code>false</code> if was already enlisted
+     */
+    boolean enlistForChangeKindPublishing(
+            final @NonNull ManagedObject entity,
+            final @NonNull EntityChangeKind changeKind) {
+
+        val bookmark = ManagedObjects.bookmarkElseFail(entity);
+        
+        val previousChangeKind = changeKindByEnlistedAdapter.get(bookmark);
+        if(previousChangeKind == null) {
+            changeKindByEnlistedAdapter.put(bookmark, changeKind);
+            return true;
+        }
+        switch (previousChangeKind) {
+        case CREATE:
+            switch (changeKind) {
+            case DELETE:
+                changeKindByEnlistedAdapter.remove(bookmark);
+            case CREATE:
+            case UPDATE:
+                return false;
+            }
+            break;
+        case UPDATE:
+            switch (changeKind) {
+            case DELETE:
+                changeKindByEnlistedAdapter.put(bookmark, changeKind);
+                return true;
+            case CREATE:
+            case UPDATE:
+                return false;
+            }
+            break;
+        case DELETE:
+            return false;
+        }
+        return previousChangeKind == null;
+    }
+    
+    private void debug(String label) {
+        _Probe.errOut("!!! %s %d", 
+                label, 
+                entityPropertyChangeRecords().size());
+    }
+    
+}
diff --git a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_Xray.java b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_Xray.java
index 596d74d..f7ff624 100644
--- a/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_Xray.java
+++ b/core/transaction/src/main/java/org/apache/isis/core/transaction/changetracking/_Xray.java
@@ -19,11 +19,14 @@
 package org.apache.isis.core.transaction.changetracking;
 
 import java.awt.Color;
+import java.util.Objects;
+import java.util.Set;
 
 import javax.inject.Provider;
 
 import org.apache.isis.applib.services.iactn.InteractionContext;
 import org.apache.isis.commons.internal.debug.xray.XrayUi;
+import org.apache.isis.core.metamodel.facets.object.publish.entitychange.EntityChangePublishingFacet;
 import org.apache.isis.core.metamodel.spec.ManagedObject;
 import org.apache.isis.core.metamodel.spec.ManagedObjects;
 import org.apache.isis.core.security.authentication.AuthenticationContext;
@@ -33,6 +36,35 @@ import lombok.val;
 
 final class _Xray {
 
+    public static void capturePostValuesAndDrain(
+            final Set<_PropertyChangeRecord> entityPropertyChangeRecords, 
+            final Provider<InteractionContext> iaContextProvider,
+            final Provider<AuthenticationContext> authContextProvider) {
+        
+        if(!XrayUi.isXrayEnabled()) {
+            return;
+        }
+
+        // records enlisted before post values have been set
+        final int propertyChangeRecordCount = entityPropertyChangeRecords.size();
+        val enteringLabel = String.format("capture %d post-values", propertyChangeRecordCount);
+        
+        XrayUtil.createSequenceHandle(iaContextProvider.get(), authContextProvider.get(), "ec-tracker")
+        .ifPresent(handle->{
+            
+            handle.submit(sequenceData->{
+                
+                sequenceData.alias("ec-tracker", "EntityChange-\nTracker-\n(Default)");
+                
+                val callee = handle.getCallees().getFirstOrFail();
+                sequenceData.enter(handle.getCaller(), callee, enteringLabel);
+                //sequenceData.activate(callee);
+            });
+            
+        });
+    }
+    
+    
     public static void publish(
             final EntityChangeTrackerDefault entityChangeTrackerDefault, 
             final Provider<InteractionContext> iaContextProvider,
@@ -42,6 +74,7 @@ final class _Xray {
             return;
         }
         
+        // records enlisted after post values have been set
         final int propertyChangeRecordCount = entityChangeTrackerDefault.propertyChangeRecordCount();
         
         val enteringLabel = String.format("do publish %d entity change records",
@@ -121,13 +154,17 @@ final class _Xray {
             return;
         }
         
+        //XXX using the same logic as in EntityChangeTrackerDefault; keep that in sync!
+        val isPublishingEnabled = EntityChangePublishingFacet.isPublishingEnabled(entity.getSpecification());
+        
         val enteringLabel = String.format("%s %s",
                 what,
                 ManagedObjects.isNullOrUnspecifiedOrEmpty(entity)
                     ? "<empty>"
-                    : String.format("%s:\n%s", 
+                    : String.format("%s@%s ... publishing %s",
                             entity.getSpecification().getLogicalTypeName(),
-                            "" + entity.getPojo()));
+                            Integer.toHexString(Objects.hash(entity.getPojo())),
+                            isPublishingEnabled ? "enabled" : "disabled"));
         
         XrayUtil.createSequenceHandle(iaContextProvider.get(), authContextProvider.get(), "ec-tracker")
         .ifPresent(handle->{
@@ -136,6 +173,11 @@ final class _Xray {
                 
                 sequenceData.alias("ec-tracker", "EntityChange-\nTracker-\n(Default)");
                 
+                if(!isPublishingEnabled) {
+                    sequenceData.setConnectionArrowColor(Color.GRAY);
+                    sequenceData.setConnectionLabelColor(Color.GRAY);
+                }
+                
                 val callee = handle.getCallees().getFirstOrFail();
                 sequenceData.enter(handle.getCaller(), callee, enteringLabel);
                 //sequenceData.activate(callee);
@@ -146,6 +188,7 @@ final class _Xray {
     }
 
 
+
     
 
 }
diff --git a/regressiontests/incubating/src/test/java/org/apache/isis/testdomain/applayer/publishing/jdo/JdoEntityPropertyChangePublishingTest.java b/regressiontests/incubating/src/test/java/org/apache/isis/testdomain/applayer/publishing/jdo/JdoEntityPropertyChangePublishingTest.java
index 18624bd..c424406 100644
--- a/regressiontests/incubating/src/test/java/org/apache/isis/testdomain/applayer/publishing/jdo/JdoEntityPropertyChangePublishingTest.java
+++ b/regressiontests/incubating/src/test/java/org/apache/isis/testdomain/applayer/publishing/jdo/JdoEntityPropertyChangePublishingTest.java
@@ -55,6 +55,7 @@ import lombok.val;
                 "logging.level.org.apache.isis.testdomain.util.rest.KVStoreForTesting=DEBUG",
                 "logging.level.org.apache.isis.persistence.jdo.integration.changetracking.JdoLifecycleListener=DEBUG",
                 "logging.level.org.apache.isis.core.transaction.changetracking.EntityChangeTrackerDefault=DEBUG",
+                "logging.level.org.apache.isis.testdomain.applayer.publishing.EntityPropertyChangeSubscriberForTesting=DEBUG",
         })
 @TestPropertySource({
     IsisPresets.UseLog4j2Test
diff --git a/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/ApplicationLayerTestFactory.java b/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/ApplicationLayerTestFactory.java
index e4728a5..993e481 100644
--- a/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/ApplicationLayerTestFactory.java
+++ b/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/ApplicationLayerTestFactory.java
@@ -27,7 +27,6 @@ import static org.junit.jupiter.api.DynamicTest.dynamicTest;
 
 import java.util.HashSet;
 import java.util.List;
-import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -46,7 +45,6 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Propagation;
 
 import org.apache.isis.applib.annotation.Where;
-import org.apache.isis.applib.services.iactn.Interaction;
 import org.apache.isis.applib.services.repository.RepositoryService;
 import org.apache.isis.applib.services.wrapper.DisabledException;
 import org.apache.isis.applib.services.wrapper.WrapperFactory;
@@ -59,7 +57,6 @@ import org.apache.isis.commons.internal.debug.xray.XrayUi;
 import org.apache.isis.commons.internal.exceptions._Exceptions;
 import org.apache.isis.commons.internal.functions._Functions.CheckedConsumer;
 import org.apache.isis.core.interaction.session.InteractionFactory;
-import org.apache.isis.core.interaction.session.InteractionTracker;
 import org.apache.isis.core.metamodel.interactions.managed.PropertyInteraction;
 import org.apache.isis.core.metamodel.objectmanager.ObjectManager;
 import org.apache.isis.core.metamodel.spec.ManagedObject;
@@ -89,7 +86,6 @@ public class ApplicationLayerTestFactory {
     private final FixtureScripts fixtureScripts;
     private final PreCommitListener preCommitListener;
     private final InteractionFactory interactionFactory;
-    private final InteractionTracker interactionTracker;
     
     @Named("transaction-aware-pmf-proxy")
     private final PersistenceManagerFactory pmf;
@@ -123,30 +119,30 @@ public class ApplicationLayerTestFactory {
 
         val dynamicTests = Can.<DynamicTest>of(
                 
-                interactionTest("Programmatic Execution", 
-                        given, verifier, 
-                        VerificationStage.POST_INTERACTION_WHEN_PROGRAMMATIC, 
-                        this::programmaticExecution),
+//                interactionTest("Programmatic Execution", 
+//                        given, verifier, 
+//                        VerificationStage.POST_INTERACTION_WHEN_PROGRAMMATIC, 
+//                        this::programmaticExecution),
                 interactionTest("Interaction Api Execution", 
                         given, verifier, 
                         VerificationStage.POST_INTERACTION, 
-                        this::interactionApiExecution),
-                interactionTest("Wrapper Sync Execution w/o Rules", 
-                        given, verifier, 
-                        VerificationStage.POST_INTERACTION, 
-                        this::wrapperSyncExecutionNoRules),
-                interactionTest("Wrapper Sync Execution w/ Rules (expected to fail w/ DisabledException)", 
-                        given, verifier, 
-                        VerificationStage.POST_INTERACTION, 
-                        this::wrapperSyncExecutionWithFailure),
-                interactionTest("Wrapper Async Execution w/o Rules", 
-                        given, verifier, 
-                        VerificationStage.POST_INTERACTION, 
-                        this::wrapperAsyncExecutionNoRules),
-                interactionTest("Wrapper Async Execution w/ Rules (expected to fail w/ DisabledException)", 
-                        given, verifier, 
-                        VerificationStage.POST_INTERACTION, 
-                        this::wrapperAsyncExecutionWithFailure)
+                        this::interactionApiExecution)
+//                interactionTest("Wrapper Sync Execution w/o Rules", 
+//                        given, verifier, 
+//                        VerificationStage.POST_INTERACTION, 
+//                        this::wrapperSyncExecutionNoRules),
+//                interactionTest("Wrapper Sync Execution w/ Rules (expected to fail w/ DisabledException)", 
+//                        given, verifier, 
+//                        VerificationStage.POST_INTERACTION, 
+//                        this::wrapperSyncExecutionWithFailure),
+//                interactionTest("Wrapper Async Execution w/o Rules", 
+//                        given, verifier, 
+//                        VerificationStage.POST_INTERACTION, 
+//                        this::wrapperAsyncExecutionNoRules),
+//                interactionTest("Wrapper Async Execution w/ Rules (expected to fail w/ DisabledException)", 
+//                        given, verifier, 
+//                        VerificationStage.POST_INTERACTION, 
+//                        this::wrapperAsyncExecutionWithFailure)
                 );
 
         return XrayUi.isXrayEnabled()
@@ -180,10 +176,7 @@ public class ApplicationLayerTestFactory {
             assert_no_initial_tx_context();
             
             final boolean isSuccesfulRun = interactionFactory.callAnonymous(()->{
-                val currentInteraction = interactionTracker.currentInteraction();
-                xrayEnterInteraction(currentInteraction);
                 val result = interactionTestRunner.run(given, verifier);
-                xrayExitInteraction();
                 return result;
             });
             
@@ -438,22 +431,20 @@ public class ApplicationLayerTestFactory {
     
     private void withBookDoTransactional(CheckedConsumer<JdoBook> transactionalBookConsumer) {
         
-        xrayEnterTansaction(Propagation.REQUIRES_NEW);
-        
         transactionService.runTransactional(Propagation.REQUIRES_NEW, ()->{
             val book = repository.allInstances(JdoBook.class).listIterator().next();
             transactionalBookConsumer.accept(book);
 
         })
         .optionalElseFail();
-        
-        xrayExitTansaction();
     }
     
     // -- XRAY
     
     private void xrayAddTest(String name) {
         
+        _Probe.errOut("TESTING %s", name);
+        
         val threadId = XrayUtil.currentThreadAsMemento();
         
         XrayUi.updateModel(model->{
@@ -465,16 +456,5 @@ public class ApplicationLayerTestFactory {
         
     }
     
-    private void xrayEnterTansaction(Propagation propagation) {
-    }
-    
-    private void xrayExitTansaction() {
-    }
-    
-    private void xrayEnterInteraction(Optional<Interaction> currentInteraction) {
-    }
-    
-    private void xrayExitInteraction() {
-    }
 
 }
diff --git a/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/publishing/EntityPropertyChangeSubscriberForTesting.java b/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/publishing/EntityPropertyChangeSubscriberForTesting.java
index 8ba9862..66f87b2 100644
--- a/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/publishing/EntityPropertyChangeSubscriberForTesting.java
+++ b/regressiontests/stable/src/main/java/org/apache/isis/testdomain/applayer/publishing/EntityPropertyChangeSubscriberForTesting.java
@@ -34,7 +34,8 @@ import org.apache.isis.testdomain.util.kv.KVStoreForTesting;
 import lombok.val;
 import lombok.extern.log4j.Log4j2;
 
-@Service @Log4j2
+@Service 
+@Log4j2
 public class EntityPropertyChangeSubscriberForTesting 
 implements EntityPropertyChangeSubscriber {