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:38 UTC

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

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 {