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 47c6f82278 feature: support action status report and TccHook rollback 
interceptors in saga annotation mode (#8188)
47c6f82278 is described below

commit 47c6f82278224d279864410e42baeb9c583bc319
Author: ChuMing <[email protected]>
AuthorDate: Fri Aug 7 08:56:31 2026 +0800

    feature: support action status report and TccHook rollback interceptors in 
saga annotation mode (#8188)
---
 changes/en-us/2.x.md                               |   2 +
 changes/zh-cn/2.x.md                               |   2 +
 .../org/apache/seata/common/ConfigurationKeys.java |   6 +
 .../java/org/apache/seata/common/Constants.java    |  25 ++
 .../org/apache/seata/common/DefaultValues.java     |   5 +
 .../api/interceptor/ActionInterceptorHandler.java  |  53 ++-
 .../seata/rm/tcc/api/BusinessActionContext.java    |  25 ++
 .../ActionInterceptorHandlerReportTest.java        | 174 ++++++++
 .../rm/tcc/api/BusinessActionContextTest.java      |  82 ++++
 .../saga/rm/SagaAnnotationResourceManager.java     |  87 +++-
 .../saga/rm/SagaAnnotationResourceManagerTest.java | 471 +++++++++++++++++++++
 .../properties/client/RmProperties.java            |  10 +
 .../additional-spring-configuration-metadata.json  |   7 +
 13 files changed, 946 insertions(+), 3 deletions(-)

diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index 19c39363b2..620e6ec6a0 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -21,6 +21,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
+- [[#8188](https://github.com/apache/incubator-seata/pull/8188)] support 
action status report and TccHook rollback interceptors in Saga annotation mode 
for anti-suspension and empty rollback
 
 ### bugfix:
 
@@ -66,6 +67,7 @@ Thanks to these contributors for their code commits. Please 
report an unintended
 - [Zhengcy05](https://github.com/Zhengcy05)
 - [neu-hsc](https://github.com/neu-hsc)
 - [pyshiweijia](https://github.com/pyshiweijia)
+- [biedongbin](https://github.com/biedongbin)
 
 
 
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index 6192449bb6..67ea4df4e1 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -21,6 +21,7 @@
 ### feature:
 
 - [[#8140](https://github.com/apache/incubator-seata/pull/8140)] 支持在 
BusinessActionContext 变更后自动标记 updated
+- [[#8188](https://github.com/apache/incubator-seata/pull/8188)] Saga 注解模式支持 
action status 上报和 TccHook 回滚切面,增强防悬挂和空回滚能力
 
 ### bugfix:
 
@@ -66,6 +67,7 @@
 - [Zhengcy05](https://github.com/Zhengcy05)
 - [neu-hsc](https://github.com/neu-hsc)
 - [pyshiweijia](https://github.com/pyshiweijia)
+- [biedongbin](https://github.com/biedongbin)
 
 
 
diff --git 
a/common/src/main/java/org/apache/seata/common/ConfigurationKeys.java 
b/common/src/main/java/org/apache/seata/common/ConfigurationKeys.java
index e31bc8e72e..ba2ba379c3 100644
--- a/common/src/main/java/org/apache/seata/common/ConfigurationKeys.java
+++ b/common/src/main/java/org/apache/seata/common/ConfigurationKeys.java
@@ -229,6 +229,12 @@ public interface ConfigurationKeys {
      */
     String CLIENT_SAGA_COMPENSATE_PERSIST_MODE_UPDATE = CLIENT_RM_PREFIX + 
"sagaCompensatePersistModeUpdate";
 
+    /**
+     * The constant CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE.
+     * Enable action status report for SAGA/TCC annotation mode to handle 
empty compensation and suspension issues.
+     */
+    String CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE = CLIENT_RM_PREFIX + 
"sagaActionStatusReportEnable";
+
     /**
      * The constant CLIENT_REPORT_RETRY_COUNT.
      */
diff --git a/common/src/main/java/org/apache/seata/common/Constants.java 
b/common/src/main/java/org/apache/seata/common/Constants.java
index c10416c1cb..e437b13ce4 100644
--- a/common/src/main/java/org/apache/seata/common/Constants.java
+++ b/common/src/main/java/org/apache/seata/common/Constants.java
@@ -260,4 +260,29 @@ public interface Constants {
      * CW stands for Cluster Watch
      */
     String WATCH_EVENT_PREFIX = "CW:";
+
+    /**
+     * Framework-reserved action status key (sys:: prefix avoids collisions 
with business keys)
+     */
+    String ACTION_STATUS = "sys::actionStatus";
+
+    /**
+     * Action status: phase one not started (branch registered, business never 
executed)
+     */
+    String ACTION_STATUS_NONE = "none";
+
+    /**
+     * Action status: phase one is in progress (anti-suspension guard)
+     */
+    String ACTION_STATUS_RUNNING = "running";
+
+    /**
+     * Action status: prepare method executed successfully
+     */
+    String ACTION_STATUS_SUCCESS = "success";
+
+    /**
+     * Action status: prepare method execution failed
+     */
+    String ACTION_STATUS_FAILED = "failed";
 }
diff --git a/common/src/main/java/org/apache/seata/common/DefaultValues.java 
b/common/src/main/java/org/apache/seata/common/DefaultValues.java
index 9c1c0cd4c9..f8ae74c09b 100644
--- a/common/src/main/java/org/apache/seata/common/DefaultValues.java
+++ b/common/src/main/java/org/apache/seata/common/DefaultValues.java
@@ -77,6 +77,11 @@ public interface DefaultValues {
      */
     boolean DEFAULT_CLIENT_SAGA_BRANCH_REGISTER_ENABLE = false;
 
+    /**
+     * The constant DEFAULT_CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE.
+     */
+    boolean DEFAULT_CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE = false;
+
     /**
      * The default session store dir
      */
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 35234139a7..83b92780be 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
@@ -16,14 +16,18 @@
  */
 package org.apache.seata.integration.tx.api.interceptor;
 
+import org.apache.seata.common.ConfigurationKeys;
 import org.apache.seata.common.Constants;
+import org.apache.seata.common.DefaultValues;
 import org.apache.seata.common.exception.FrameworkException;
 import org.apache.seata.common.exception.SkipCallbackWrapperException;
 import org.apache.seata.common.executor.Callback;
 import org.apache.seata.common.json.JsonUtil;
 import org.apache.seata.common.util.CollectionUtils;
 import org.apache.seata.common.util.NetUtil;
+import org.apache.seata.config.ConfigurationFactory;
 import org.apache.seata.core.context.RootContext;
+import org.apache.seata.core.model.BranchType;
 import org.apache.seata.integration.tx.api.fence.DefaultCommonFenceHandler;
 import org.apache.seata.integration.tx.api.fence.hook.TccHook;
 import org.apache.seata.integration.tx.api.fence.hook.TccHookManager;
@@ -53,6 +57,22 @@ public class ActionInterceptorHandler {
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(ActionInterceptorHandler.class);
 
+    private static final boolean ACTION_STATUS_REPORT_ENABLED = 
ConfigurationFactory.getInstance()
+            .getBoolean(
+                    ConfigurationKeys.CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE,
+                    
DefaultValues.DEFAULT_CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE);
+
+    /**
+     * Whether action status report is enabled. Restricted to {@link 
BranchType#SAGA_ANNOTATION} so that enabling
+     * this option does not affect normal TCC behavior. Overridable for 
testing.
+     *
+     * @param branchType the branch type of the current action
+     * @return true if action status should be reported
+     */
+    protected boolean isActionStatusReportEnabled(BranchType branchType) {
+        return branchType == BranchType.SAGA_ANNOTATION && 
ACTION_STATUS_REPORT_ENABLED;
+    }
+
     /**
      * Handler the Tx Aspect
      *
@@ -96,10 +116,19 @@ public class ActionInterceptorHandler {
 
         // save the previous action context
         BusinessActionContext previousActionContext = 
BusinessActionContextUtil.getContext();
+        final boolean reportStatus = 
isActionStatusReportEnabled(businessActionParam.getBranchType());
         try {
             // share actionContext implicitly
             BusinessActionContextUtil.setContext(actionContext);
-            doBeforeTccPrepare(xid, branchId, actionName, actionContext);
+            try {
+                doBeforeTccPrepare(xid, branchId, actionName, actionContext);
+            } catch (Throwable t) {
+                // before-prepare hook failed: phase one business never 
executed, mark as none (empty rollback).
+                if (reportStatus) {
+                    
actionContext.setActionStatus(Constants.ACTION_STATUS_NONE);
+                }
+                throw t;
+            }
             if (businessActionParam.getUseCommonFence()) {
                 try {
                     // Use common Fence, and return the business result
@@ -113,8 +142,28 @@ public class ActionInterceptorHandler {
                     throw originException;
                 }
             } else {
+                // Mark action status: running and report immediately so TC 
can observe it if the business
+                // callback hangs (anti-suspension). The final success/failed 
status is reported in finally.
+                if (reportStatus) {
+                    
actionContext.setActionStatus(Constants.ACTION_STATUS_RUNNING);
+                    BusinessActionContextUtil.reportContext(actionContext);
+                }
                 // Execute business, and return the business result
-                return targetCallback.execute();
+                try {
+                    Object result = targetCallback.execute();
+                    // Mark action status: success (only for non-CommonFence 
mode)
+                    if (reportStatus) {
+                        
actionContext.setActionStatus(Constants.ACTION_STATUS_SUCCESS);
+                    }
+                    return result;
+                } catch (Throwable t) {
+                    // Mark action status: failed (only for non-CommonFence 
mode).
+                    // Note: failed only indicates the callback threw; it does 
not prove no business side effects.
+                    if (reportStatus) {
+                        
actionContext.setActionStatus(Constants.ACTION_STATUS_FAILED);
+                    }
+                    throw t;
+                }
             }
         } finally {
             try {
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 ada14bbb0e..64da89412b 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
@@ -16,6 +16,7 @@
  */
 package org.apache.seata.rm.tcc.api;
 
+import org.apache.seata.common.Constants;
 import org.apache.seata.core.model.BranchType;
 import org.apache.seata.integration.tx.api.interceptor.ActionContextUtil;
 
@@ -263,6 +264,30 @@ public class BusinessActionContext implements Serializable 
{
         this.branchType = branchType;
     }
 
+    /**
+     * Gets action status.
+     *
+     * @return the action status
+     */
+    public String getActionStatus() {
+        if (actionContext == null) {
+            return null;
+        }
+        Object status = actionContext.get(Constants.ACTION_STATUS);
+        return status != null ? status.toString() : null;
+    }
+
+    /**
+     * Sets action status.
+     *
+     * @param status the action status
+     */
+    public void setActionStatus(String status) {
+        if (actionContext != null && status != null) {
+            actionContext.put(Constants.ACTION_STATUS, status);
+        }
+    }
+
     private void markUpdatedOnActionContextMutation() {
         setUpdated(true);
     }
diff --git 
a/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerReportTest.java
 
b/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerReportTest.java
new file mode 100644
index 0000000000..0c0dc1ccab
--- /dev/null
+++ 
b/integration-tx-api/src/test/java/org/apache/seata/integration/tx/api/interceptor/ActionInterceptorHandlerReportTest.java
@@ -0,0 +1,174 @@
+/*
+ * 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.seata.integration.tx.api.interceptor;
+
+import org.apache.seata.common.Constants;
+import org.apache.seata.core.model.BranchType;
+import org.apache.seata.integration.tx.api.fence.hook.TccHook;
+import org.apache.seata.integration.tx.api.fence.hook.TccHookManager;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+
+/**
+ * Tests for the action status state machine in ActionInterceptorHandler.
+ *
+ * Covers the none/running/success/failed status transitions and the 
SAGA_ANNOTATION-only gating,
+ * replacing the previous sun.misc.Unsafe-based static field manipulation with 
an overridable method.
+ */
+public class ActionInterceptorHandlerReportTest {
+
+    private MockedStatic<BusinessActionContextUtil> mockedContextUtil;
+
+    @BeforeEach
+    void setUp() {
+        TccHookManager.clear();
+        mockedContextUtil = 
Mockito.mockStatic(BusinessActionContextUtil.class);
+        mockedContextUtil
+                .when(() -> BusinessActionContextUtil.reportContext(any()))
+                .thenReturn(true);
+    }
+
+    @AfterEach
+    void tearDown() {
+        mockedContextUtil.close();
+        TccHookManager.clear();
+    }
+
+    private BusinessActionContext createActionContext(String xid, long 
branchId) {
+        BusinessActionContext context = new BusinessActionContext();
+        context.setXid(xid);
+        context.setBranchId(branchId);
+        context.setBranchType(BranchType.SAGA_ANNOTATION);
+        Map<String, Object> actionContext = new HashMap<>();
+        context.setActionContext(actionContext);
+        return context;
+    }
+
+    private TwoPhaseBusinessActionParam createParam(String actionName) {
+        TwoPhaseBusinessActionParam param = new TwoPhaseBusinessActionParam();
+        param.setActionName(actionName);
+        param.setDelayReport(Boolean.TRUE);
+        param.setBranchType(BranchType.SAGA_ANNOTATION);
+        param.setUseCommonFence(false);
+        return param;
+    }
+
+    private ActionInterceptorHandler spyHandlerWithReportEnabled() {
+        ActionInterceptorHandler handler = Mockito.spy(new 
ActionInterceptorHandler());
+        
Mockito.doReturn(true).when(handler).isActionStatusReportEnabled(any());
+        Mockito.doReturn("branch1")
+                .when(handler)
+                .doTxActionLogStore(
+                        any(Method.class),
+                        any(),
+                        any(TwoPhaseBusinessActionParam.class),
+                        any(BusinessActionContext.class));
+        return handler;
+    }
+
+    @Test
+    void testProceedReportsRunningThenSuccess() throws Throwable {
+        ActionInterceptorHandler handler = spyHandlerWithReportEnabled();
+        Method method = TestTarget.class.getDeclaredMethod("execute", 
BusinessActionContext.class);
+        BusinessActionContext context = createActionContext("xid1", 1L);
+
+        handler.proceed(method, new Object[] {context}, "xid1", 
createParam("testAction"), () -> "ok");
+
+        assertEquals(Constants.ACTION_STATUS_SUCCESS, 
context.getActionStatus());
+        // running reported immediately before execute, plus the final report 
in finally
+        mockedContextUtil.verify(() -> 
BusinessActionContextUtil.reportContext(any()), Mockito.atLeast(2));
+    }
+
+    @Test
+    void testProceedReportsFailedWhenCallbackThrows() throws Throwable {
+        ActionInterceptorHandler handler = spyHandlerWithReportEnabled();
+        Method method = TestTarget.class.getDeclaredMethod("execute", 
BusinessActionContext.class);
+        BusinessActionContext context = createActionContext("xid2", 2L);
+
+        try {
+            handler.proceed(method, new Object[] {context}, "xid2", 
createParam("testAction"), () -> {
+                throw new RuntimeException("business error");
+            });
+        } catch (RuntimeException e) {
+            // expected
+        }
+
+        assertEquals(Constants.ACTION_STATUS_FAILED, 
context.getActionStatus());
+    }
+
+    @Test
+    void testProceedReportsNoneWhenBeforePrepareHookThrows() throws Throwable {
+        ActionInterceptorHandler handler = spyHandlerWithReportEnabled();
+        TccHook throwingHook = Mockito.mock(TccHook.class);
+        Mockito.doThrow(new RuntimeException("prepare hook error"))
+                .when(throwingHook)
+                .beforeTccPrepare(any(), any(), any(), any());
+        TccHookManager.registerHook(throwingHook);
+
+        Method method = TestTarget.class.getDeclaredMethod("execute", 
BusinessActionContext.class);
+        BusinessActionContext context = createActionContext("xid3", 3L);
+
+        try {
+            handler.proceed(method, new Object[] {context}, "xid3", 
createParam("testAction"), () -> "ok");
+        } catch (RuntimeException e) {
+            // expected: before-prepare hook failure rethrown
+        }
+
+        assertEquals(Constants.ACTION_STATUS_NONE, context.getActionStatus());
+    }
+
+    @Test
+    void testProceedDoesNotReportWhenBranchTypeIsTcc() throws Throwable {
+        ActionInterceptorHandler handler = Mockito.spy(new 
ActionInterceptorHandler());
+        
Mockito.doReturn(false).when(handler).isActionStatusReportEnabled(any());
+        Mockito.doReturn("branch4")
+                .when(handler)
+                .doTxActionLogStore(
+                        any(Method.class),
+                        any(),
+                        any(TwoPhaseBusinessActionParam.class),
+                        any(BusinessActionContext.class));
+
+        Method method = TestTarget.class.getDeclaredMethod("execute", 
BusinessActionContext.class);
+        BusinessActionContext context = createActionContext("xid4", 4L);
+        TwoPhaseBusinessActionParam param = createParam("testAction");
+        param.setBranchType(BranchType.TCC);
+
+        handler.proceed(method, new Object[] {context}, "xid4", param, () -> 
"ok");
+
+        assertNull(context.getActionStatus());
+    }
+
+    public static class TestTarget {
+        public Object execute(BusinessActionContext context) {
+            return "result";
+        }
+    }
+}
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 c2ea6deef0..649e48f354 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
@@ -16,6 +16,7 @@
  */
 package org.apache.seata.rm.tcc.api;
 
+import org.apache.seata.common.Constants;
 import org.apache.seata.core.model.BranchType;
 import org.junit.jupiter.api.Test;
 
@@ -25,6 +26,7 @@ import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
 
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -32,6 +34,86 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class BusinessActionContextTest {
 
+    @Test
+    public void testGetActionStatusWhenContextIsNull() {
+        BusinessActionContext context = new BusinessActionContext();
+        assertNull(context.getActionStatus(), "Action status should be null 
when actionContext is null");
+    }
+
+    @Test
+    public void testGetActionStatusWhenNotSet() {
+        BusinessActionContext context = new BusinessActionContext();
+        Map<String, Object> actionContext = new HashMap<>();
+        context.setActionContext(actionContext);
+
+        assertNull(context.getActionStatus(), "Action status should be null 
when not set");
+    }
+
+    @Test
+    public void testSetAndGetActionStatusSuccess() {
+        BusinessActionContext context = new BusinessActionContext();
+        Map<String, Object> actionContext = new HashMap<>();
+        context.setActionContext(actionContext);
+
+        context.setActionStatus(Constants.ACTION_STATUS_SUCCESS);
+
+        assertEquals(Constants.ACTION_STATUS_SUCCESS, 
context.getActionStatus(), "Action status should be 'success'");
+    }
+
+    @Test
+    public void testSetAndGetActionStatusFailed() {
+        BusinessActionContext context = new BusinessActionContext();
+        Map<String, Object> actionContext = new HashMap<>();
+        context.setActionContext(actionContext);
+
+        context.setActionStatus(Constants.ACTION_STATUS_FAILED);
+
+        assertEquals(Constants.ACTION_STATUS_FAILED, 
context.getActionStatus(), "Action status should be 'failed'");
+    }
+
+    @Test
+    public void testSetActionStatusWithNullStatus() {
+        BusinessActionContext context = new BusinessActionContext();
+        Map<String, Object> actionContext = new HashMap<>();
+        context.setActionContext(actionContext);
+
+        context.setActionStatus(Constants.ACTION_STATUS_SUCCESS);
+        context.setActionStatus(null);
+
+        assertEquals(
+                Constants.ACTION_STATUS_SUCCESS,
+                context.getActionStatus(),
+                "Action status should not change when setting null");
+    }
+
+    @Test
+    public void testSetActionStatusWithNullActionContext() {
+        BusinessActionContext context = new BusinessActionContext();
+
+        // Should not throw exception
+        assertDoesNotThrow(
+                () -> context.setActionStatus(Constants.ACTION_STATUS_SUCCESS),
+                "Setting action status with null actionContext should not 
throw exception");
+
+        assertNull(context.getActionStatus(), "Action status should still be 
null");
+    }
+
+    @Test
+    public void testActionStatusOverwrite() {
+        BusinessActionContext context = new BusinessActionContext();
+        Map<String, Object> actionContext = new HashMap<>();
+        context.setActionContext(actionContext);
+
+        context.setActionStatus(Constants.ACTION_STATUS_SUCCESS);
+        assertEquals(Constants.ACTION_STATUS_SUCCESS, 
context.getActionStatus());
+
+        context.setActionStatus(Constants.ACTION_STATUS_FAILED);
+        assertEquals(
+                Constants.ACTION_STATUS_FAILED,
+                context.getActionStatus(),
+                "Action status should be overwritten to 'failed'");
+    }
+
     @Test
     public void testBranchIdAccessors() {
         BusinessActionContext context = new BusinessActionContext();
diff --git 
a/saga/seata-saga-annotation/src/main/java/org/apache/seata/saga/rm/SagaAnnotationResourceManager.java
 
b/saga/seata-saga-annotation/src/main/java/org/apache/seata/saga/rm/SagaAnnotationResourceManager.java
index 471b3640ce..0111f29903 100644
--- 
a/saga/seata-saga-annotation/src/main/java/org/apache/seata/saga/rm/SagaAnnotationResourceManager.java
+++ 
b/saga/seata-saga-annotation/src/main/java/org/apache/seata/saga/rm/SagaAnnotationResourceManager.java
@@ -16,6 +16,7 @@
  */
 package org.apache.seata.saga.rm;
 
+import org.apache.seata.common.Constants;
 import org.apache.seata.common.exception.ExceptionUtil;
 import org.apache.seata.common.exception.RepeatRegistrationException;
 import org.apache.seata.common.exception.ShouldNeverHappenException;
@@ -23,12 +24,15 @@ import org.apache.seata.core.exception.TransactionException;
 import org.apache.seata.core.model.BranchStatus;
 import org.apache.seata.core.model.BranchType;
 import org.apache.seata.core.model.Resource;
+import org.apache.seata.integration.tx.api.fence.hook.TccHook;
+import org.apache.seata.integration.tx.api.fence.hook.TccHookManager;
 import org.apache.seata.integration.tx.api.remoting.TwoPhaseResult;
 import org.apache.seata.rm.AbstractResourceManager;
 import org.apache.seata.rm.tcc.api.BusinessActionContext;
 import org.apache.seata.rm.tcc.api.BusinessActionContextUtil;
 
 import java.lang.reflect.Method;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -100,12 +104,45 @@ public class SagaAnnotationResourceManager extends 
AbstractResourceManager {
                     String.format("SagaAnnotation resource is not available, 
resourceId: %s", resourceId));
         }
 
+        BusinessActionContext businessActionContext = null;
         try {
-            BusinessActionContext businessActionContext =
+            businessActionContext =
                     BusinessActionContextUtil.getBusinessActionContext(xid, 
branchId, resourceId, applicationData);
+            businessActionContext.setBranchType(branchType);
             Object[] args = this.getTwoPhaseRollbackArgs(resource, 
businessActionContext);
             BusinessActionContextUtil.setContext(businessActionContext);
 
+            // Anti-suspension & empty-rollback: decide whether to compensate 
based on the phase-one action status
+            // reported by ActionInterceptorHandler. When action status report 
is disabled, getActionStatus() returns
+            // null and compensation runs unconditionally (legacy behavior).
+            String actionStatus = businessActionContext.getActionStatus();
+            if (Constants.ACTION_STATUS_RUNNING.equals(actionStatus)) {
+                // phase one is still in progress (suspension); retry rollback 
later
+                LOGGER.info(
+                        "SagaAnnotation rollback retry for phase one still 
running, xid: {}, branchId: {}, resourceId: {}",
+                        xid,
+                        branchId,
+                        resourceId);
+                return BranchStatus.PhaseTwo_RollbackFailed_Retryable;
+            }
+            if (Constants.ACTION_STATUS_NONE.equals(actionStatus)
+                    || Constants.ACTION_STATUS_FAILED.equals(actionStatus)) {
+                // empty rollback: phase one never completed successfully, 
skip compensation
+                LOGGER.info(
+                        "SagaAnnotation empty rollback, actionStatus: {}, xid: 
{}, branchId: {}, resourceId: {}",
+                        actionStatus,
+                        xid,
+                        branchId,
+                        resourceId);
+                return BranchStatus.PhaseTwo_Rollbacked;
+            }
+
+            if (!doBeforeSagaAnnotationRollback(xid, branchId, 
resource.getActionName(), businessActionContext)) {
+                // before-rollback hook failed (e.g. tenant/datasource context 
switch failed);
+                // retry to avoid compensating against the wrong context
+                return BranchStatus.PhaseTwo_RollbackFailed_Retryable;
+            }
+
             boolean result;
             Object ret = compensationMethod.invoke(targetBean, args);
             if (ret != null) {
@@ -131,6 +168,7 @@ public class SagaAnnotationResourceManager extends 
AbstractResourceManager {
             LOGGER.error(msg, ExceptionUtil.unwrap(t));
             return BranchStatus.PhaseTwo_RollbackFailed_Retryable;
         } finally {
+            doAfterSagaAnnotationRollback(xid, branchId, 
resource.getActionName(), businessActionContext);
             BusinessActionContextUtil.clear();
         }
     }
@@ -164,4 +202,51 @@ public class SagaAnnotationResourceManager extends 
AbstractResourceManager {
         }
         return args;
     }
+
+    /**
+     * to do some business operations before saga annotation rollback
+     * @param xid          the xid
+     * @param branchId     the branchId
+     * @param actionName   the actionName
+     * @param context      the business action context
+     */
+    private boolean doBeforeSagaAnnotationRollback(
+            String xid, long branchId, String actionName, 
BusinessActionContext context) {
+        List<TccHook> hooks = TccHookManager.getHooks();
+        if (hooks.isEmpty()) {
+            return true;
+        }
+        boolean allSuccess = true;
+        for (TccHook hook : hooks) {
+            try {
+                hook.beforeTccRollback(xid, branchId, actionName, context);
+            } catch (Exception e) {
+                allSuccess = false;
+                LOGGER.error("Failed execute beforeTccRollback in hook {}", 
e.getMessage(), e);
+            }
+        }
+        return allSuccess;
+    }
+
+    /**
+     * to do some business operations after saga annotation rollback
+     * @param xid          the xid
+     * @param branchId     the branchId
+     * @param actionName   the actionName
+     * @param context      the business action context
+     */
+    private void doAfterSagaAnnotationRollback(
+            String xid, long branchId, String actionName, 
BusinessActionContext context) {
+        List<TccHook> hooks = TccHookManager.getHooks();
+        if (hooks.isEmpty()) {
+            return;
+        }
+        for (TccHook hook : hooks) {
+            try {
+                hook.afterTccRollback(xid, branchId, actionName, context);
+            } catch (Exception e) {
+                LOGGER.error("Failed execute afterTccRollback in hook {}", 
e.getMessage(), e);
+            }
+        }
+    }
 }
diff --git 
a/saga/seata-saga-annotation/src/test/java/org/apache/seata/saga/rm/SagaAnnotationResourceManagerTest.java
 
b/saga/seata-saga-annotation/src/test/java/org/apache/seata/saga/rm/SagaAnnotationResourceManagerTest.java
new file mode 100644
index 0000000000..5dd21d7f29
--- /dev/null
+++ 
b/saga/seata-saga-annotation/src/test/java/org/apache/seata/saga/rm/SagaAnnotationResourceManagerTest.java
@@ -0,0 +1,471 @@
+/*
+ * 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.seata.saga.rm;
+
+import org.apache.seata.common.Constants;
+import org.apache.seata.common.json.JsonUtil;
+import org.apache.seata.core.model.BranchStatus;
+import org.apache.seata.core.model.BranchType;
+import org.apache.seata.integration.tx.api.fence.hook.TccHook;
+import org.apache.seata.integration.tx.api.fence.hook.TccHookManager;
+import org.apache.seata.integration.tx.api.remoting.TwoPhaseResult;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for SagaAnnotationResourceManager.
+ *
+ * Focus areas:
+ * 1. branchRollback with TccHook before/after callbacks
+ * 2. before-rollback hook failure blocks compensation and returns retryable
+ * 3. Action status driven rollback decision (none/running/success/failed/null)
+ * 4. Different compensation return types (boolean, TwoPhaseResult, null)
+ */
+public class SagaAnnotationResourceManagerTest {
+
+    static {
+        System.setProperty("config.type", "file");
+        System.setProperty("config.file.name", "file.conf");
+    }
+
+    private SagaAnnotationResourceManager resourceManager;
+
+    @BeforeEach
+    void setUp() {
+        TccHookManager.clear();
+        resourceManager = new SagaAnnotationResourceManager();
+    }
+
+    @AfterEach
+    void tearDown() {
+        TccHookManager.clear();
+        BusinessActionContextUtil.clear();
+    }
+
+    // ---- Helper classes ----
+
+    public static class TestCompensationTarget {
+        int compensateCount = 0;
+
+        public boolean compensate(BusinessActionContext context) {
+            compensateCount++;
+            return true;
+        }
+
+        public boolean compensateFail(BusinessActionContext context) {
+            return false;
+        }
+
+        public Boolean compensateReturnNull(BusinessActionContext context) {
+            return null;
+        }
+
+        public TwoPhaseResult 
compensateWithResultSuccess(BusinessActionContext context) {
+            return new TwoPhaseResult(true, "ok");
+        }
+
+        public TwoPhaseResult compensateWithResultFail(BusinessActionContext 
context) {
+            return new TwoPhaseResult(false, "fail");
+        }
+
+        public boolean compensateThrow(BusinessActionContext context) {
+            throw new RuntimeException("compensation error");
+        }
+    }
+
+    public static class TrackingTccHook implements TccHook {
+        boolean beforeRollbackCalled = false;
+        boolean afterRollbackCalled = false;
+        boolean shouldThrowInBefore = false;
+        boolean shouldThrowInAfter = false;
+        BranchType capturedBranchType = null;
+
+        @Override
+        public void beforeTccPrepare(String xid, Long branchId, String 
actionName, BusinessActionContext context) {}
+
+        @Override
+        public void afterTccPrepare(String xid, Long branchId, String 
actionName, BusinessActionContext context) {}
+
+        @Override
+        public void beforeTccCommit(String xid, Long branchId, String 
actionName, BusinessActionContext context) {}
+
+        @Override
+        public void afterTccCommit(String xid, Long branchId, String 
actionName, BusinessActionContext context) {}
+
+        @Override
+        public void beforeTccRollback(String xid, Long branchId, String 
actionName, BusinessActionContext context) {
+            beforeRollbackCalled = true;
+            capturedBranchType = context.getBranchType();
+            if (shouldThrowInBefore) {
+                throw new RuntimeException("hook error in beforeTccRollback");
+            }
+        }
+
+        @Override
+        public void afterTccRollback(String xid, Long branchId, String 
actionName, BusinessActionContext context) {
+            afterRollbackCalled = true;
+            if (shouldThrowInAfter) {
+                throw new RuntimeException("hook error in afterTccRollback");
+            }
+        }
+    }
+
+    private SagaAnnotationResource createResource(String actionName, String 
methodName) throws NoSuchMethodException {
+        return createResource(actionName, methodName, new 
TestCompensationTarget());
+    }
+
+    private SagaAnnotationResource createResource(String actionName, String 
methodName, TestCompensationTarget target)
+            throws NoSuchMethodException {
+        SagaAnnotationResource resource = new SagaAnnotationResource();
+        resource.setActionName(actionName);
+        resource.setTargetBean(target);
+        resource.setCompensationMethod(
+                TestCompensationTarget.class.getDeclaredMethod(methodName, 
BusinessActionContext.class));
+        resource.setCompensationArgsClasses(new Class<?>[] 
{BusinessActionContext.class});
+        resource.setPhaseTwoCompensationKeys(new String[] {"unused"});
+        return resource;
+    }
+
+    private String buildApplicationData(String actionStatus) {
+        Map<String, Object> inner = new HashMap<>();
+        if (actionStatus != null) {
+            inner.put(Constants.ACTION_STATUS, actionStatus);
+        }
+        Map<String, Object> outer = new HashMap<>();
+        outer.put(Constants.TX_ACTION_CONTEXT, inner);
+        return JsonUtil.toJSONString(outer);
+    }
+
+    // ---- Tests for hook invocation in branchRollback (legacy: null action 
status) ----
+
+    @Test
+    void testBranchRollbackWithHooksInvoked() throws Exception {
+        TrackingTccHook hook = new TrackingTccHook();
+        TccHookManager.registerHook(hook);
+
+        SagaAnnotationResource resource = createResource("testAction", 
"compensate");
+        resourceManager.getManagedResources().put("testAction", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 1L, "testAction", null);
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+        assertTrue(hook.beforeRollbackCalled, "beforeTccRollback should be 
called");
+        assertTrue(hook.afterRollbackCalled, "afterTccRollback should be 
called");
+        assertEquals(BranchType.SAGA_ANNOTATION, hook.capturedBranchType, 
"branchType should be set on context");
+    }
+
+    @Test
+    void testBranchRollbackHookExceptionInBeforeReturnsRetryable() throws 
Exception {
+        TrackingTccHook hook = new TrackingTccHook();
+        hook.shouldThrowInBefore = true;
+        TccHookManager.registerHook(hook);
+
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("testAction2", 
"compensate", target);
+        resourceManager.getManagedResources().put("testAction2", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 2L, "testAction2", null);
+
+        assertEquals(
+                BranchStatus.PhaseTwo_RollbackFailed_Retryable,
+                status,
+                "before-hook failure should block compensation and return 
retryable");
+        assertEquals(0, target.compensateCount, "compensation should not 
execute when before-hook fails");
+        assertTrue(hook.afterRollbackCalled, "afterTccRollback should still be 
called in finally");
+    }
+
+    @Test
+    void testBranchRollbackHookExceptionInAfterDoesNotBreakRollback() throws 
Exception {
+        TrackingTccHook hook = new TrackingTccHook();
+        hook.shouldThrowInAfter = true;
+        TccHookManager.registerHook(hook);
+
+        SagaAnnotationResource resource = createResource("testAction3", 
"compensate");
+        resourceManager.getManagedResources().put("testAction3", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 3L, "testAction3", null);
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+        assertTrue(hook.beforeRollbackCalled);
+        assertTrue(hook.afterRollbackCalled);
+    }
+
+    @Test
+    void testBranchRollbackWithoutHooks() throws Exception {
+        SagaAnnotationResource resource = createResource("testAction4", 
"compensate");
+        resourceManager.getManagedResources().put("testAction4", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 4L, "testAction4", null);
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+    }
+
+    // ---- Tests for different compensation return types ----
+
+    @Test
+    void testBranchRollbackCompensationReturnsFalse() throws Exception {
+        SagaAnnotationResource resource = createResource("testAction5", 
"compensateFail");
+        resourceManager.getManagedResources().put("testAction5", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 5L, "testAction5", null);
+
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, status);
+    }
+
+    @Test
+    void testBranchRollbackCompensationReturnsNull() throws Exception {
+        SagaAnnotationResource resource = createResource("testAction6", 
"compensateReturnNull");
+        resourceManager.getManagedResources().put("testAction6", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 6L, "testAction6", null);
+
+        // null return is treated as success
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+    }
+
+    @Test
+    void testBranchRollbackCompensationReturnsTwoPhaseResultSuccess() throws 
Exception {
+        SagaAnnotationResource resource = createResource("testAction7", 
"compensateWithResultSuccess");
+        resourceManager.getManagedResources().put("testAction7", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 7L, "testAction7", null);
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+    }
+
+    @Test
+    void testBranchRollbackCompensationReturnsTwoPhaseResultFail() throws 
Exception {
+        SagaAnnotationResource resource = createResource("testAction8", 
"compensateWithResultFail");
+        resourceManager.getManagedResources().put("testAction8", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 8L, "testAction8", null);
+
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, status);
+    }
+
+    @Test
+    void testBranchRollbackCompensationThrowsException() throws Exception {
+        SagaAnnotationResource resource = createResource("testAction9", 
"compensateThrow");
+        resourceManager.getManagedResources().put("testAction9", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 9L, "testAction9", null);
+
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, status);
+    }
+
+    // ---- Tests for hook invocation with compensation failure ----
+
+    @Test
+    void testBranchRollbackWithHooksWhenCompensationFails() throws Exception {
+        TrackingTccHook hook = new TrackingTccHook();
+        TccHookManager.registerHook(hook);
+
+        SagaAnnotationResource resource = createResource("testAction10", 
"compensateFail");
+        resourceManager.getManagedResources().put("testAction10", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 10L, "testAction10", null);
+
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, status);
+        assertTrue(hook.beforeRollbackCalled, "beforeTccRollback should be 
called even when compensation fails");
+        assertTrue(hook.afterRollbackCalled, "afterTccRollback should be 
called in finally block");
+    }
+
+    @Test
+    void testBranchRollbackWithHooksWhenCompensationThrows() throws Exception {
+        TrackingTccHook hook = new TrackingTccHook();
+        TccHookManager.registerHook(hook);
+
+        SagaAnnotationResource resource = createResource("testAction11", 
"compensateThrow");
+        resourceManager.getManagedResources().put("testAction11", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 11L, "testAction11", null);
+
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, status);
+        assertTrue(hook.beforeRollbackCalled);
+        assertTrue(hook.afterRollbackCalled, "afterTccRollback should be 
called in finally block even on exception");
+    }
+
+    // ---- Tests for multiple hooks ----
+
+    @Test
+    void testBranchRollbackWithMultipleHooks() throws Exception {
+        TrackingTccHook hook1 = new TrackingTccHook();
+        TrackingTccHook hook2 = new TrackingTccHook();
+        TccHookManager.registerHook(hook1);
+        TccHookManager.registerHook(hook2);
+
+        SagaAnnotationResource resource = createResource("testAction12", 
"compensate");
+        resourceManager.getManagedResources().put("testAction12", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 12L, "testAction12", null);
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+        assertTrue(hook1.beforeRollbackCalled);
+        assertTrue(hook1.afterRollbackCalled);
+        assertTrue(hook2.beforeRollbackCalled);
+        assertTrue(hook2.afterRollbackCalled);
+    }
+
+    @Test
+    void testBranchRollbackFirstHookThrowsSecondStillCalled() throws Exception 
{
+        TrackingTccHook hook1 = new TrackingTccHook();
+        hook1.shouldThrowInBefore = true;
+        TrackingTccHook hook2 = new TrackingTccHook();
+        TccHookManager.registerHook(hook1);
+        TccHookManager.registerHook(hook2);
+
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("testAction13", 
"compensate", target);
+        resourceManager.getManagedResources().put("testAction13", resource);
+
+        BranchStatus status =
+                resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, 
"xid123", 13L, "testAction13", null);
+
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, status);
+        assertTrue(hook1.beforeRollbackCalled);
+        assertTrue(
+                hook2.beforeRollbackCalled,
+                "second hook beforeTccRollback should still be called (hook 
exceptions are caught per hook)");
+        assertTrue(hook2.afterRollbackCalled, "afterTccRollback should still 
call all hooks");
+        assertEquals(0, target.compensateCount, "compensation should be 
skipped when a before-hook fails");
+    }
+
+    // ---- Tests for action status driven rollback decision ----
+
+    @Test
+    void testBranchRollbackEmptyRollbackWhenActionStatusNone() throws 
Exception {
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("actNone", 
"compensate", target);
+        resourceManager.getManagedResources().put("actNone", resource);
+
+        BranchStatus status = resourceManager.branchRollback(
+                BranchType.SAGA_ANNOTATION, "xid", 1L, "actNone", 
buildApplicationData(Constants.ACTION_STATUS_NONE));
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status, "none status -> 
empty rollback");
+        assertEquals(0, target.compensateCount, "compensation should be 
skipped on empty rollback");
+    }
+
+    @Test
+    void testBranchRollbackRetryWhenActionStatusRunning() throws Exception {
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("actRunning", 
"compensate", target);
+        resourceManager.getManagedResources().put("actRunning", resource);
+
+        BranchStatus status = resourceManager.branchRollback(
+                BranchType.SAGA_ANNOTATION,
+                "xid",
+                1L,
+                "actRunning",
+                buildApplicationData(Constants.ACTION_STATUS_RUNNING));
+
+        assertEquals(
+                BranchStatus.PhaseTwo_RollbackFailed_Retryable, status, 
"running status -> retry (anti-suspension)");
+        assertEquals(0, target.compensateCount, "compensation should be 
skipped while phase one is running");
+    }
+
+    @Test
+    void testBranchRollbackEmptyRollbackWhenActionStatusFailed() throws 
Exception {
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("actFailed", 
"compensate", target);
+        resourceManager.getManagedResources().put("actFailed", resource);
+
+        BranchStatus status = resourceManager.branchRollback(
+                BranchType.SAGA_ANNOTATION,
+                "xid",
+                1L,
+                "actFailed",
+                buildApplicationData(Constants.ACTION_STATUS_FAILED));
+
+        assertEquals(
+                BranchStatus.PhaseTwo_Rollbacked, status, "failed status -> 
empty rollback (business self-handled)");
+        assertEquals(0, target.compensateCount);
+    }
+
+    @Test
+    void testBranchRollbackCompensateWhenActionStatusSuccess() throws 
Exception {
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("actSuccess", 
"compensate", target);
+        resourceManager.getManagedResources().put("actSuccess", resource);
+
+        BranchStatus status = resourceManager.branchRollback(
+                BranchType.SAGA_ANNOTATION,
+                "xid",
+                1L,
+                "actSuccess",
+                buildApplicationData(Constants.ACTION_STATUS_SUCCESS));
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status, "success status 
-> execute compensation");
+        assertEquals(1, target.compensateCount);
+    }
+
+    @Test
+    void testBranchRollbackLegacyWhenActionStatusNull() throws Exception {
+        // action status report disabled -> getActionStatus returns null -> 
compensate unconditionally (legacy)
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("actNull", 
"compensate", target);
+        resourceManager.getManagedResources().put("actNull", resource);
+
+        BranchStatus status = 
resourceManager.branchRollback(BranchType.SAGA_ANNOTATION, "xid", 1L, 
"actNull", null);
+
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, status);
+        assertEquals(1, target.compensateCount);
+    }
+
+    // ---- End-to-end retry flow: null -> retry -> reported -> retry with 
updated data ----
+
+    @Test
+    void testBranchRollbackRetryFlowFromNullToReportedStatus() throws 
Exception {
+        // Simulates the TC-driven retry sequence:
+        // 1. initial rollback with no status reported yet (null) -> legacy 
compensation would run, but here we
+        //    emulate the "phase one still running" window by reporting 
running on retry.
+        TestCompensationTarget target = new TestCompensationTarget();
+        SagaAnnotationResource resource = createResource("actE2e", 
"compensate", target);
+        resourceManager.getManagedResources().put("actE2e", resource);
+
+        // 1st retry: phase one still running -> retryable, no compensation
+        BranchStatus first = resourceManager.branchRollback(
+                BranchType.SAGA_ANNOTATION, "xid", 1L, "actE2e", 
buildApplicationData(Constants.ACTION_STATUS_RUNNING));
+        assertEquals(BranchStatus.PhaseTwo_RollbackFailed_Retryable, first);
+        assertEquals(0, target.compensateCount);
+
+        // 2nd retry: phase one finally reported success -> compensation 
executes
+        BranchStatus second = resourceManager.branchRollback(
+                BranchType.SAGA_ANNOTATION, "xid", 1L, "actE2e", 
buildApplicationData(Constants.ACTION_STATUS_SUCCESS));
+        assertEquals(BranchStatus.PhaseTwo_Rollbacked, second);
+        assertEquals(1, target.compensateCount);
+    }
+}
diff --git 
a/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/java/org/apache/seata/spring/boot/autoconfigure/properties/client/RmProperties.java
 
b/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/java/org/apache/seata/spring/boot/autoconfigure/properties/client/RmProperties.java
index 483fe1c5df..d1005148d3 100644
--- 
a/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/java/org/apache/seata/spring/boot/autoconfigure/properties/client/RmProperties.java
+++ 
b/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/java/org/apache/seata/spring/boot/autoconfigure/properties/client/RmProperties.java
@@ -24,6 +24,7 @@ import static 
org.apache.seata.common.DefaultValues.DEFAULT_APPLICATION_DATA_SIZ
 import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_ASYNC_COMMIT_BUFFER_LIMIT;
 import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_REPORT_RETRY_COUNT;
 import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_REPORT_SUCCESS_ENABLE;
+import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE;
 import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_SAGA_BRANCH_REGISTER_ENABLE;
 import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_SAGA_COMPENSATE_PERSIST_MODE_UPDATE;
 import static 
org.apache.seata.common.DefaultValues.DEFAULT_CLIENT_SAGA_RETRY_PERSIST_MODE_UPDATE;
@@ -47,6 +48,7 @@ public class RmProperties {
     private String sagaJsonParser = DEFAULT_SAGA_JSON_PARSER;
     private boolean sagaRetryPersistModeUpdate = 
DEFAULT_CLIENT_SAGA_RETRY_PERSIST_MODE_UPDATE;
     private boolean sagaCompensatePersistModeUpdate = 
DEFAULT_CLIENT_SAGA_COMPENSATE_PERSIST_MODE_UPDATE;
+    private boolean sagaActionStatusReportEnable = 
DEFAULT_CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE;
     private int tccActionInterceptorOrder = TCC_ACTION_INTERCEPTOR_ORDER;
     private int branchExecutionTimeoutXA = DEFAULT_XA_BRANCH_EXECUTION_TIMEOUT;
     private int connectionTwoPhaseHoldTimeoutXA = 
DEFAULT_XA_CONNECTION_TWO_PHASE_HOLD_TIMEOUT;
@@ -131,6 +133,14 @@ public class RmProperties {
         this.sagaCompensatePersistModeUpdate = sagaCompensatePersistModeUpdate;
     }
 
+    public boolean isSagaActionStatusReportEnable() {
+        return sagaActionStatusReportEnable;
+    }
+
+    public void setSagaActionStatusReportEnable(boolean 
sagaActionStatusReportEnable) {
+        this.sagaActionStatusReportEnable = sagaActionStatusReportEnable;
+    }
+
     public int getTccActionInterceptorOrder() {
         return tccActionInterceptorOrder;
     }
diff --git 
a/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json
 
b/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index 551f35ab8d..911e5c33b3 100644
--- 
a/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ 
b/spring/seata-spring-autoconfigure/seata-spring-autoconfigure-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -90,6 +90,13 @@
       "sourceType": 
"org.apache.seata.spring.boot.autoconfigure.properties.client.RmProperties",
       "defaultValue": false
     },
+    {
+      "name": "seata.client.rm.saga-action-status-report-enable",
+      "type": "java.lang.Boolean",
+      "description": "Whether enable saga annotation action status report for 
anti-suspension and empty rollback. Only takes effect for 
BranchType.SAGA_ANNOTATION.",
+      "sourceType": 
"org.apache.seata.spring.boot.autoconfigure.properties.client.RmProperties",
+      "defaultValue": false
+    },
     {
       "name": "seata.client.rm.tcc-action-interceptor-order",
       "type": "java.lang.Integer",


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

Reply via email to