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

funky-eyes pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/incubator-seata.git


The following commit(s) were added to refs/heads/2.x by this push:
     new bba87e7cbe feature: automatic update after BusinessActionContext 
modify (#8140)
bba87e7cbe is described below

commit bba87e7cbe69779ebb07cfbab0a354d645f8b7b0
Author: Zhengcy05 <[email protected]>
AuthorDate: Fri Jun 19 13:22:54 2026 +0800

    feature: automatic update after BusinessActionContext modify (#8140)
---
 changes/en-us/2.x.md                               |   2 +
 changes/zh-cn/2.x.md                               |   2 +
 .../api/interceptor/ActionInterceptorHandler.java  |  14 +-
 .../seata/rm/tcc/api/BusinessActionContext.java    | 203 +++++++++++++++++++++
 .../interceptor/ActionInterceptorHandlerTest.java  |  79 ++++++++
 .../rm/tcc/api/BusinessActionContextTest.java      | 114 ++++++++++++
 6 files changed, 410 insertions(+), 4 deletions(-)

diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index de80e8f743..280ba232df 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -20,6 +20,7 @@ Add changes here for all PR submitted to the 2.x branch.
 
 ### feature:
 
+- [[#8140](https://github.com/apache/incubator-seata/pull/8140)] support 
automatic updated marking after BusinessActionContext modifications
 
 ### bugfix:
 
@@ -44,6 +45,7 @@ Thanks to these contributors for their code commits. Please 
report an unintended
 <!-- Please make sure your Github ID is in the list below -->
 
 - [slievrly](https://github.com/slievrly)
+- [Zhengcy05](https://github.com/Zhengcy05)
 
 
 Also, we receive many valuable issues, questions and advices from our 
community. Thanks for you all.
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index ae2a3808f0..37bbf1cacb 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -20,6 +20,7 @@
 
 ### feature:
 
+- [[#8140](https://github.com/apache/incubator-seata/pull/8140)] 支持在 
BusinessActionContext 变更后自动标记 updated
 
 ### bugfix:
 
@@ -44,6 +45,7 @@
 <!-- 请确保您的 GitHub ID 在以下列表中 -->
 
 - [slievrly](https://github.com/slievrly)
+- [Zhengcy05](https://github.com/Zhengcy05)
 
 
 同时,我们收到了社区反馈的很多有价值的issue和建议,非常感谢大家。
diff --git 
a/integration-tx-api/src/main/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandler.java
 
b/integration-tx-api/src/main/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandler.java
index d47047b71c..35234139a7 100644
--- 
a/integration-tx-api/src/main/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandler.java
+++ 
b/integration-tx-api/src/main/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandler.java
@@ -91,6 +91,9 @@ public class ActionInterceptorHandler {
         // MDC put branchId
         MDC.put(RootContext.MDC_KEY_BRANCH_ID, branchId);
 
+        // enable mutation tracking only after framework initialization is 
complete
+        actionContext.enableActionContextTracking();
+
         // save the previous action context
         BusinessActionContext previousActionContext = 
BusinessActionContextUtil.getContext();
         try {
@@ -232,10 +235,13 @@ public class ActionInterceptorHandler {
 
         Map<String, Object> originContext = actionContext.getActionContext();
         if (CollectionUtils.isNotEmpty(originContext)) {
-            // Merge context and origin context if it exists.
-            // @since: above 1.4.2
-            originContext.putAll(context);
-            context = originContext;
+            // Keep framework-side merge outside the tracked map to avoid 
false updated flags.
+            // Merge framework context into a fresh map to avoid treating 
framework-side
+            // initialization as a business mutation when tracking is already 
enabled.
+            Map<String, Object> mergedContext = new HashMap<>(originContext);
+            mergedContext.putAll(context);
+            actionContext.setActionContext(mergedContext);
+            context = mergedContext;
         } else {
             actionContext.setActionContext(context);
         }
diff --git 
a/integration-tx-api/src/main/java/org/apache/seata/rm/tcc/api/BusinessActionContext.java
 
b/integration-tx-api/src/main/java/org/apache/seata/rm/tcc/api/BusinessActionContext.java
index a34b41e9ce..ada14bbb0e 100644
--- 
a/integration-tx-api/src/main/java/org/apache/seata/rm/tcc/api/BusinessActionContext.java
+++ 
b/integration-tx-api/src/main/java/org/apache/seata/rm/tcc/api/BusinessActionContext.java
@@ -22,7 +22,14 @@ import 
org.apache.seata.integration.tx.api.interceptor.ActionContextUtil;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import java.io.Serializable;
+import java.util.AbstractMap;
+import java.util.AbstractSet;
+import java.util.HashMap;
+import java.util.Iterator;
 import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.Set;
 
 /**
  * The type Business action context.
@@ -62,6 +69,11 @@ public class BusinessActionContext implements Serializable {
      */
     private Map<String, Object> actionContext;
 
+    /**
+     * whether the action context should stay tracked on mutation
+     */
+    private transient boolean actionContextTrackingEnabled;
+
     /**
      * Instantiates a new Business action context.
      */
@@ -147,9 +159,27 @@ public class BusinessActionContext implements Serializable 
{
      * @param actionContext the action context
      */
     public void setActionContext(Map<String, Object> actionContext) {
+        if (actionContextTrackingEnabled) {
+            this.actionContext = actionContext == null
+                    ? new TrackedActionContextMap(this)
+                    : new TrackedActionContextMap(this, actionContext);
+            return;
+        }
         this.actionContext = actionContext;
     }
 
+    /**
+     * Enable automatic updated tracking for action context mutations.
+     */
+    public void enableActionContextTracking() {
+        actionContextTrackingEnabled = true;
+        if (actionContext == null) {
+            actionContext = new TrackedActionContextMap(this);
+        } else if (!(actionContext instanceof TrackedActionContextMap)) {
+            actionContext = new TrackedActionContextMap(this, actionContext);
+        }
+    }
+
     /**
      * Gets xid.
      *
@@ -233,6 +263,10 @@ public class BusinessActionContext implements Serializable 
{
         this.branchType = branchType;
     }
 
+    private void markUpdatedOnActionContextMutation() {
+        setUpdated(true);
+    }
+
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
@@ -253,4 +287,173 @@ public class BusinessActionContext implements 
Serializable {
                 .append("]");
         return sb.toString();
     }
+
+    /**
+     * The tracked action context map.
+     */
+    private static final class TrackedActionContextMap extends 
AbstractMap<String, Object> implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private final BusinessActionContext owner;
+
+        private final Map<String, Object> delegate;
+
+        private TrackedActionContextMap(BusinessActionContext owner) {
+            this.owner = owner;
+            this.delegate = new HashMap<>(8);
+        }
+
+        private TrackedActionContextMap(BusinessActionContext owner, 
Map<String, Object> source) {
+            this.owner = owner;
+            this.delegate = new HashMap<>(source);
+        }
+
+        @Override
+        public Object put(String key, Object value) {
+            boolean hadKey = delegate.containsKey(key);
+            Object previousValue = delegate.put(key, value);
+            if (!hadKey || !Objects.equals(previousValue, value)) {
+                owner.markUpdatedOnActionContextMutation();
+            }
+            return previousValue;
+        }
+
+        @Override
+        public void putAll(Map<? extends String, ? extends Object> m) {
+            Objects.requireNonNull(m, "m");
+            if (m.isEmpty()) {
+                return;
+            }
+            for (Map.Entry<? extends String, ? extends Object> entry : 
m.entrySet()) {
+                put(entry.getKey(), entry.getValue());
+            }
+        }
+
+        @Override
+        public Object remove(Object key) {
+            boolean hadKey = delegate.containsKey(key);
+            Object previousValue = delegate.remove(key);
+            if (hadKey) {
+                owner.markUpdatedOnActionContextMutation();
+            }
+            return previousValue;
+        }
+
+        @Override
+        public void clear() {
+            if (!delegate.isEmpty()) {
+                delegate.clear();
+                owner.markUpdatedOnActionContextMutation();
+            }
+        }
+
+        @Override
+        public Set<Entry<String, Object>> entrySet() {
+            return new AbstractSet<Entry<String, Object>>() {
+                @Override
+                public Iterator<Entry<String, Object>> iterator() {
+                    Iterator<Entry<String, Object>> iterator =
+                            delegate.entrySet().iterator();
+                    return new Iterator<Entry<String, Object>>() {
+                        @Override
+                        public boolean hasNext() {
+                            return iterator.hasNext();
+                        }
+
+                        @Override
+                        public Entry<String, Object> next() {
+                            Entry<String, Object> current = iterator.next();
+                            return new TrackingEntry(current);
+                        }
+
+                        @Override
+                        public void remove() {
+                            iterator.remove();
+                            owner.markUpdatedOnActionContextMutation();
+                        }
+                    };
+                }
+
+                // The following methods delegate directly to the underlying 
Map.
+                @Override
+                public int size() {
+                    return delegate.size();
+                }
+
+                @Override
+                public boolean remove(Object o) {
+                    if (!(o instanceof Entry)) {
+                        return false;
+                    }
+                    Entry<?, ?> entry = (Entry<?, ?>) o;
+                    if (!delegate.containsKey(entry.getKey())) {
+                        return false;
+                    }
+                    if (!Objects.equals(delegate.get(entry.getKey()), 
entry.getValue())) {
+                        return false;
+                    }
+                    TrackedActionContextMap.this.remove(entry.getKey());
+                    return true;
+                }
+            };
+        }
+
+        @Override
+        public int size() {
+            return delegate.size();
+        }
+
+        @Override
+        public boolean containsKey(Object key) {
+            return delegate.containsKey(key);
+        }
+
+        @Override
+        public boolean containsValue(Object value) {
+            return delegate.containsValue(value);
+        }
+
+        @Override
+        public Object get(Object key) {
+            return delegate.get(key);
+        }
+
+        private final class TrackingEntry implements Entry<String, Object> {
+            private final Entry<String, Object> delegateEntry;
+
+            private TrackingEntry(Entry<String, Object> delegateEntry) {
+                this.delegateEntry = delegateEntry;
+            }
+
+            @Override
+            public String getKey() {
+                return delegateEntry.getKey();
+            }
+
+            @Override
+            public Object getValue() {
+                return delegateEntry.getValue();
+            }
+
+            @Override
+            public Object setValue(Object value) {
+                Object previousValue = delegateEntry.setValue(value);
+                if (!Objects.equals(previousValue, value)) {
+                    owner.markUpdatedOnActionContextMutation();
+                }
+                return previousValue;
+            }
+
+            @Override
+            public boolean equals(Object o) {
+                return delegateEntry.equals(o);
+            }
+
+            @Override
+            public int hashCode() {
+                return delegateEntry.hashCode();
+            }
+        }
+    }
 }
diff --git 
a/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerTest.java
 
b/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerTest.java
index 55b796bd28..101165ddf1 100644
--- 
a/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerTest.java
+++ 
b/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerTest.java
@@ -16,14 +16,32 @@
  */
 package org.apache.seata.integration.tx.api.interceptor;
 
+import org.apache.seata.common.executor.Callback;
+import org.apache.seata.core.model.BranchStatus;
+import org.apache.seata.core.model.BranchType;
+import org.apache.seata.rm.DefaultResourceManager;
 import org.apache.seata.rm.tcc.api.BusinessActionContext;
+import org.apache.seata.rm.tcc.api.BusinessActionContextUtil;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
 
 import java.lang.reflect.Method;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /**
  * The type Action interceptor handler test.
@@ -36,6 +54,11 @@ public class ActionInterceptorHandlerTest {
      */
     protected ActionInterceptorHandler actionInterceptorHandler = new 
ActionInterceptorHandler();
 
+    @AfterEach
+    public void tearDown() {
+        BusinessActionContextUtil.clear();
+    }
+
     /**
      * Test business action context.
      *
@@ -57,4 +80,60 @@ public class ActionInterceptorHandlerTest {
         Assertions.assertEquals("b", paramContext.get("b"));
         Assertions.assertEquals("[email protected]", paramContext.get("email"));
     }
+
+    @Test
+    public void testProceedTracksActionContextMutation() throws Throwable {
+        Method prepareMethod = TestAction.class.getDeclaredMethod(
+                "prepare", BusinessActionContext.class, int.class, List.class, 
TestParam.class);
+        List<Object> list = new ArrayList<>();
+        list.add("b");
+        TestParam tccParam = new TestParam(1, "[email protected]");
+
+        TwoPhaseBusinessActionParam businessActionParam = 
mock(TwoPhaseBusinessActionParam.class);
+        
org.mockito.Mockito.doReturn("prepare").when(businessActionParam).getActionName();
+        
org.mockito.Mockito.doReturn(BranchType.TCC).when(businessActionParam).getBranchType();
+        
org.mockito.Mockito.doReturn(false).when(businessActionParam).getDelayReport();
+        
org.mockito.Mockito.doReturn(false).when(businessActionParam).getUseCommonFence();
+        org.mockito.Mockito.doReturn(Collections.emptyMap())
+                .when(businessActionParam)
+                .getBusinessActionContext();
+
+        DefaultResourceManager resourceManager = 
mock(DefaultResourceManager.class);
+        AtomicReference<BusinessActionContext> observedContext = new 
AtomicReference<>();
+        ArgumentCaptor<String> applicationDataCaptor = 
ArgumentCaptor.forClass(String.class);
+
+        try (MockedStatic<DefaultResourceManager> mocked = 
mockStatic(DefaultResourceManager.class)) {
+            
mocked.when(DefaultResourceManager::get).thenReturn(resourceManager);
+            when(resourceManager.branchRegister(
+                            eq(BranchType.TCC), eq("prepare"), isNull(), 
eq("test-xid"), anyString(), isNull()))
+                    .thenReturn(1L);
+
+            Callback<Object> callback = () -> {
+                BusinessActionContext currentContext = 
BusinessActionContextUtil.getContext();
+                Assertions.assertNotNull(currentContext);
+                Assertions.assertNull(currentContext.getUpdated());
+                currentContext.getActionContext().put("biz", "value");
+                Assertions.assertTrue(currentContext.getUpdated());
+                observedContext.set(currentContext);
+                return null;
+            };
+
+            Object result = actionInterceptorHandler.proceed(
+                    prepareMethod, new Object[] {null, 10, list, tccParam}, 
"test-xid", businessActionParam, callback);
+
+            Assertions.assertNull(result);
+        }
+
+        Assertions.assertNotNull(observedContext.get());
+        Assertions.assertNull(observedContext.get().getUpdated());
+        verify(resourceManager)
+                .branchReport(
+                        eq(BranchType.TCC),
+                        eq("test-xid"),
+                        eq(1L),
+                        eq(BranchStatus.Registered),
+                        applicationDataCaptor.capture());
+        
Assertions.assertTrue(applicationDataCaptor.getValue().contains("biz"));
+        
Assertions.assertTrue(applicationDataCaptor.getValue().contains("value"));
+    }
 }
diff --git 
a/integration-tx-api/src/test/java/org/apache/seata/rm/tcc/api/BusinessActionContextTest.java
 
b/integration-tx-api/src/test/java/org/apache/seata/rm/tcc/api/BusinessActionContextTest.java
index 7242540557..c2ea6deef0 100644
--- 
a/integration-tx-api/src/test/java/org/apache/seata/rm/tcc/api/BusinessActionContextTest.java
+++ 
b/integration-tx-api/src/test/java/org/apache/seata/rm/tcc/api/BusinessActionContextTest.java
@@ -19,7 +19,10 @@ package org.apache.seata.rm.tcc.api;
 import org.apache.seata.core.model.BranchType;
 import org.junit.jupiter.api.Test;
 
+import java.util.AbstractMap;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -72,6 +75,117 @@ public class BusinessActionContextTest {
                 context.getActionContext("payload", Payload.class).getName());
     }
 
+    @Test
+    public void testTrackedActionContextMarksUpdatedOnMutationOperations() {
+        BusinessActionContext context = new BusinessActionContext("xid", "4", 
new HashMap<>());
+        context.enableActionContextTracking();
+        context.setActionContext(new HashMap<>());
+
+        context.getActionContext().put("name", "seata");
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        context.getActionContext().putAll(Collections.singletonMap("status", 
"prepared"));
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        context.getActionContext().remove("name");
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        context.getActionContext().put("name", "seata");
+        context.getActionContext().replace("name", "seata-updated");
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        context.getActionContext().computeIfAbsent("branch", key -> 
"branch-1");
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        context.getActionContext().merge("branch", "branch-2", (oldValue, 
newValue) -> newValue);
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        context.getActionContext().replaceAll((key, value) -> value);
+        assertNull(context.getUpdated());
+
+        context.getActionContext().clear();
+        assertTrue(context.getUpdated());
+    }
+
+    @Test
+    public void testTrackedActionContextMapViewOperations() {
+        BusinessActionContext context = new BusinessActionContext();
+        context.enableActionContextTracking();
+        context.enableActionContextTracking();
+
+        assertEquals(0, context.getActionContext().size());
+        assertFalse(context.getActionContext().containsKey("missing"));
+        assertFalse(context.getActionContext().containsValue("missing"));
+
+        context.getActionContext().putAll(Collections.emptyMap());
+        assertNull(context.getUpdated());
+
+        context.getActionContext().put("name", "seata");
+        context.setUpdated(null);
+        assertEquals("seata", context.getActionContext().put("name", "seata"));
+        assertNull(context.getUpdated());
+
+        assertEquals("seata", context.getActionContext().get("name"));
+        context.setUpdated(null);
+        assertNull(context.getActionContext().remove("missing"));
+        assertNull(context.getUpdated());
+
+        context.setActionContext(Collections.singletonMap("entry", "old"));
+        Map.Entry<String, Object> entry =
+                context.getActionContext().entrySet().iterator().next();
+        assertEquals("entry", entry.getKey());
+        assertEquals("old", entry.getValue());
+
+        assertEquals("old", entry.setValue("new"));
+        assertTrue(context.getUpdated());
+
+        context.setUpdated(null);
+        assertEquals("new", entry.setValue("new"));
+        assertNull(context.getUpdated());
+        assertEquals(1, context.getActionContext().entrySet().size());
+        assertEquals(entry, 
context.getActionContext().entrySet().iterator().next());
+        assertEquals(
+                entry.hashCode(),
+                
context.getActionContext().entrySet().iterator().next().hashCode());
+
+        context.setUpdated(null);
+        assertFalse(context.getActionContext().entrySet().remove("not-entry"));
+        assertNull(context.getUpdated());
+
+        assertFalse(context.getActionContext().entrySet().remove(new 
AbstractMap.SimpleEntry<>("missing", "new")));
+        assertNull(context.getUpdated());
+
+        assertFalse(context.getActionContext().entrySet().remove(new 
AbstractMap.SimpleEntry<>("entry", "wrong")));
+        assertNull(context.getUpdated());
+
+        assertTrue(context.getActionContext().entrySet().remove(new 
AbstractMap.SimpleEntry<>("entry", "new")));
+        assertTrue(context.getUpdated());
+
+        context.setActionContext(Collections.singletonMap("remove", "value"));
+        context.setUpdated(null);
+        Iterator<Map.Entry<String, Object>> iterator =
+                context.getActionContext().entrySet().iterator();
+        assertTrue(iterator.hasNext());
+        iterator.next();
+        iterator.remove();
+        assertTrue(context.getUpdated());
+        assertEquals(0, context.getActionContext().size());
+        context.setUpdated(null);
+        context.getActionContext().clear();
+        assertNull(context.getUpdated());
+
+        context.setActionContext(null);
+        assertEquals(0, context.getActionContext().size());
+        context.getActionContext().put("created", "value");
+        assertTrue(context.getUpdated());
+    }
+
     @Test
     public void testToStringIncludesCoreFields() {
         BusinessActionContext context = new BusinessActionContext("xid", "3", 
new HashMap<>());


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to