Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,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.
*/
Expand Down
15 changes: 15 additions & 0 deletions common/src/main/java/org/apache/seata/common/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,19 @@ public interface Constants {
* CW stands for Cluster Watch
*/
String WATCH_EVENT_PREFIX = "CW:";

/**
* Action status key for action context
*/
String ACTION_STATUS = "actionStatus";

/**
* Action status: prepare method executed successfully
*/
String ACTION_STATUS_SUCCESS = "success";

/**
* Action status: prepare method execution failed
*/
String ACTION_STATUS_FAILED = "failed";
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
*/
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.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.integration.tx.api.fence.DefaultCommonFenceHandler;
import org.apache.seata.integration.tx.api.fence.hook.TccHook;
Expand Down Expand Up @@ -53,6 +55,9 @@ public class ActionInterceptorHandler {

private static final Logger LOGGER = LoggerFactory.getLogger(ActionInterceptorHandler.class);

private static final boolean ENABLE_ACTION_STATUS_REPORT = ConfigurationFactory.getInstance()
.getBoolean(ConfigurationKeys.CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE, false);
Comment on lines +58 to +59

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ENABLE_ACTION_STATUS_REPORT is read once at class-load time. This prevents config hot-reload from taking effect and can also capture the wrong value if ConfigurationFactory isn’t initialized yet when this class is first loaded. Prefer reading the flag from ConfigurationFactory.getInstance() at execution time (or caching via a config listener) instead of a static final snapshot.

Suggested change
private static final boolean ENABLE_ACTION_STATUS_REPORT = ConfigurationFactory.getInstance()
.getBoolean(ConfigurationKeys.CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE, false);
private static boolean isActionStatusReportEnabled() {
return ConfigurationFactory.getInstance()
.getBoolean(ConfigurationKeys.CLIENT_SAGA_ACTION_STATUS_REPORT_ENABLE, false);
}

Copilot uses AI. Check for mistakes.

/**
* Handler the Tx Aspect
*
Expand Down Expand Up @@ -111,7 +116,20 @@ public Object proceed(
}
} else {
// Execute business, and return the business result
return targetCallback.execute();
try {
Object result = targetCallback.execute();
// Report action status: success (only for non-CommonFence mode)
if (ENABLE_ACTION_STATUS_REPORT) {
reportActionStatus(actionContext, Constants.ACTION_STATUS_SUCCESS);
}
return result;
} catch (Throwable t) {
// Report action status: failed (only for non-CommonFence mode)
if (ENABLE_ACTION_STATUS_REPORT) {
reportActionStatus(actionContext, Constants.ACTION_STATUS_FAILED);
}
throw t;
}
}
} finally {
try {
Expand Down Expand Up @@ -325,4 +343,32 @@ protected Map<String, Object> fetchActionRequestContext(Method method, Object[]
}
return context;
}

/**
* Report action status to TC
*
* @param actionContext the action context
* @param status the action status (success/failed)
*/
protected void reportActionStatus(BusinessActionContext actionContext, String status) {
try {
actionContext.setActionStatus(status);
actionContext.setUpdated(true);
BusinessActionContextUtil.reportContext(actionContext);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(
"Report action status: xid={}, branchId={}, status={}",
actionContext.getXid(),
actionContext.getBranchId(),
status);
}
} catch (Exception e) {
LOGGER.warn(
"Report action status failed: xid={}, branchId={}, status={}, error={}",
actionContext.getXid(),
actionContext.getBranchId(),
status,
e.getMessage());
Comment on lines +366 to +371

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reportActionStatus(...) swallows exceptions from BusinessActionContextUtil.reportContext(...), but it leaves actionContext.updated=true. The finally block later calls BusinessActionContextUtil.reportContext(actionContext) again and may throw, so the “swallow” here won’t actually prevent the prepare from failing. Either make status reporting best-effort by clearing/resetting updated when catching, or don’t catch and let failures propagate (and log the exception/stacktrace accordingly).

Suggested change
LOGGER.warn(
"Report action status failed: xid={}, branchId={}, status={}, error={}",
actionContext.getXid(),
actionContext.getBranchId(),
status,
e.getMessage());
// Make status reporting best-effort: roll back the updated flag on failure
actionContext.setUpdated(false);
LOGGER.warn(
"Report action status failed: xid={}, branchId={}, status={}",
actionContext.getXid(),
actionContext.getBranchId(),
status,
e);

Copilot uses AI. Check for mistakes.

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning log drops the exception stack trace, which makes diagnosing production/reporting failures harder. Pass e as the last argument to the logger (and optionally remove error={}/e.getMessage()), so stack traces are captured in logs.

Suggested change
e.getMessage());
e.getMessage(),
e);

Copilot uses AI. Check for mistakes.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -233,6 +234,30 @@ public void setBranchType(BranchType branchType) {
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);
}
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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.rm.tcc.api;

import org.apache.seata.common.Constants;
import org.junit.jupiter.api.Test;

import java.util.HashMap;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;

/**
* Test cases for BusinessActionContext action status methods.
*/
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'");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@
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;

Expand Down Expand Up @@ -100,12 +103,15 @@ public BranchStatus branchRollback(
String.format("SagaAnnotation resource is not available, resourceId: %s", resourceId));
}

BusinessActionContext businessActionContext = null;
try {
BusinessActionContext businessActionContext =
businessActionContext =
BusinessActionContextUtil.getBusinessActionContext(xid, branchId, resourceId, applicationData);
Object[] args = this.getTwoPhaseRollbackArgs(resource, businessActionContext);
BusinessActionContextUtil.setContext(businessActionContext);
Comment on lines +106 to 111

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doAfterSagaAnnotationRollback(...) is invoked in finally even when BusinessActionContextUtil.getBusinessActionContext(...) fails and businessActionContext remains null, so hooks may receive a null context. Even though per-hook exceptions are caught, calling hooks with a null context changes callback semantics and can break hook implementations relying on a non-null context. Guard the doAfterSagaAnnotationRollback(...) call (and any other hook invocation) with if (businessActionContext != null) so hooks are only called when a valid context exists.

Copilot uses AI. Check for mistakes.

doBeforeSagaAnnotationRollback(xid, branchId, resource.getActionName(), businessActionContext);

boolean result;
Object ret = compensationMethod.invoke(targetBean, args);
if (ret != null) {
Expand All @@ -131,6 +137,7 @@ public BranchStatus branchRollback(
LOGGER.error(msg, ExceptionUtil.unwrap(t));
return BranchStatus.PhaseTwo_RollbackFailed_Retryable;
} finally {
doAfterSagaAnnotationRollback(xid, branchId, resource.getActionName(), businessActionContext);

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doAfterSagaAnnotationRollback(...) is invoked in the finally block even when businessActionContext was never created (e.g., if getBusinessActionContext(...) throws). In that case hooks will receive a null context, which can cause NPEs inside hook implementations. Guard the hook invocation (or build a minimal context) when businessActionContext == null.

Suggested change
doAfterSagaAnnotationRollback(xid, branchId, resource.getActionName(), businessActionContext);
if (businessActionContext != null) {
doAfterSagaAnnotationRollback(xid, branchId, resource.getActionName(), businessActionContext);
}

Copilot uses AI. Check for mistakes.
BusinessActionContextUtil.clear();
}
}
Expand Down Expand Up @@ -164,4 +171,48 @@ protected Object[] getTwoPhaseMethodParams(
}
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 void doBeforeSagaAnnotationRollback(
String xid, long branchId, String actionName, BusinessActionContext context) {
List<TccHook> hooks = TccHookManager.getHooks();
if (hooks.isEmpty()) {
return;
}
for (TccHook hook : hooks) {
try {
hook.beforeTccRollback(xid, branchId, actionName, context);
} catch (Exception e) {
LOGGER.error("Failed execute beforeTccRollback in hook {}", e.getMessage(), e);
}
Comment on lines +190 to +193

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message "Failed execute beforeTccRollback in hook {}" is grammatically incorrect and the {} placeholder is currently used for the exception message, which loses useful context about which hook failed. Consider changing the message to “Failed to execute …” and log hook identity (e.g., hook class/name) in the placeholder; keep the exception as the throwable argument for stack trace.

Copilot uses AI. Check for mistakes.
}
}

/**
* 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);
}
}
}
}
Loading
Loading