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]