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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-11569-84e43a95b469b2021b0590e0bbac703bcc9cdcf3
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 7a58d1005b864ce787ded4f0d64c6aab1135393b
Author: Daniel <[email protected]>
AuthorDate: Mon Sep 7 05:57:12 2026 +0000

    [Fix][Connector-V2] Propagate JDBC XA commit failures (#11569)
    
    Co-authored-by: DanielLeens <[email protected]>
    Co-authored-by: davidzollo <[email protected]>
    Co-authored-by: davidzollo <[email protected]>
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 docs/en/connectors/sink/Jdbc.md                    |  10 +
 .../introduction/concepts/incompatible-changes.md  |   6 +
 docs/zh/connectors/sink/Jdbc.md                    |   8 +
 .../introduction/concepts/incompatible-changes.md  |   6 +
 .../seatunnel/jdbc/internal/xa/XaFacade.java       |   7 +-
 .../jdbc/internal/xa/XaFacadeImplAutoLoad.java     |  14 +-
 .../seatunnel/jdbc/internal/xa/XaGroupOps.java     |  13 +-
 .../seatunnel/jdbc/internal/xa/XaGroupOpsImpl.java |   9 +-
 .../jdbc/sink/JdbcSinkAggregatedCommitter.java     | 278 ++++++++++++-
 .../jdbc/internal/xa/XaFacadeImplAutoLoadTest.java | 145 +++++++
 .../jdbc/internal/xa/XaGroupOpsImplTest.java       | 122 ++++++
 .../jdbc/sink/JdbcSinkAggregatedCommitterTest.java | 440 +++++++++++++++++++++
 .../jdbc/internal/xa/XaGroupOpsImplIT.java         |  75 +++-
 13 files changed, 1097 insertions(+), 36 deletions(-)

diff --git a/docs/en/connectors/sink/Jdbc.md b/docs/en/connectors/sink/Jdbc.md
index d3181e8a41..00172e891a 100644
--- a/docs/en/connectors/sink/Jdbc.md
+++ b/docs/en/connectors/sink/Jdbc.md
@@ -336,6 +336,16 @@ please refer to appendix for other data sources
 
 The number of retries for transaction commit failures
 
+In exactly-once XA mode, this retry budget is consumed within a single 
aggregated-commit or
+restore invocation. During restore, SeaTunnel replays the still-prepared 
suffix starting from the
+first checkpoint XID that remains in the XA recovery scan. Missing XIDs before 
that boundary are
+treated as already resolved only after the still-prepared suffix commits 
successfully. If none of
+the checkpoint XIDs remain in the recovery scan, SeaTunnel treats the whole 
batch as already
+resolved and does not replay it. SeaTunnel waits one second between 
synchronous retry rounds so
+transient resource-manager outages can consume the retry budget over time 
instead of burning every
+attempt immediately. If a gap appears after the first recovered checkpoint XID,
+recovery still fails closed instead of inferring a successful commit.
+
 ### transaction_timeout_sec [int]
 
 The timeout after the transaction is opened, the default is -1 (never 
timeout). Note that setting the timeout may affect
diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 50561c2ebb..79ce8ea56c 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -19,6 +19,12 @@ You need to check this document before you upgrade to 
related version.
 
 ### JDBC Connector
 
+- **Breaking Change: JDBC XA restore now uses recovery-order evidence and 
fail-closed gaps**
+  - **Affected component**: `seatunnel-connectors-v2/connector-jdbc` sink 
exactly-once XA path
+  - **Description**: SeaTunnel now consumes `max_commit_attempts` within a 
single aggregated-commit or restore invocation, and restore replays only the 
still-prepared suffix starting from the first checkpoint XID that remains in 
the XA recovery scan. Missing XIDs before that boundary are treated as already 
resolved only after the suffix commits successfully. If none of the checkpoint 
XIDs remain in the recovery scan, SeaTunnel treats the whole batch as already 
resolved and skips replay.  [...]
+  - **Impact**: Jobs that previously relied on restore inferring success from 
a missing XA branch may now fail during recovery when the XA recovery scan 
still contains later checkpoint XIDs but shows a gap after them. Operators may 
also observe that `max_commit_attempts` is exhausted within one restore/commit 
invocation rather than across repeated task restarts.
+  - **Migration Guide**: Before upgrading, inspect the resource manager for 
dangling prepared XA transactions (for example `XA RECOVER` on MySQL or 
`pg_prepared_xacts` on PostgreSQL). If recovery fails closed because a later 
checkpoint XID still exists but a following one is missing, investigate whether 
the missing XID was rolled back, expired, or cleaned up externally before 
retrying the job. XA recovery cannot distinguish a SeaTunnel-committed XID from 
one rolled back or removed by an  [...]
+
 - **Breaking Change: Mapping of timezone-aware timestamp columns to 
`TIMESTAMP_TZ` type**
   - **Affected component**: `seatunnel-connectors-v2/connector-jdbc`, 
`seatunnel-connectors-v2/connector-iceberg`, 
`seatunnel-connectors-v2/connector-cdc-base`, 
`seatunnel-connectors-v2/connector-cdc-tidb`, 
`seatunnel-connectors-v2/connector-starrocks`, 
`seatunnel-connectors-v2/connector-hudi`, 
`seatunnel-connectors-v2/connector-snowflake` (via JDBC dialect)
   - **Description**: Previously, JDBC sources mapped both timezone-naive 
(e.g., MySQL `DATETIME`) and timezone-aware (e.g., MySQL `TIMESTAMP`) timestamp 
columns to SeaTunnel's internal `TIMESTAMP` type. Now, timezone-aware columns 
like MySQL `TIMESTAMP`, PostgreSQL `timestamptz`, Oracle `TIMESTAMP WITH LOCAL 
TIME ZONE`, SQL Server `datetimeoffset`, Snowflake `TIMESTAMP_LTZ/TZ`, and 
others are explicitly mapped to `TIMESTAMP_TZ`. This ensures that timezone 
semantics are accurately preserv [...]
diff --git a/docs/zh/connectors/sink/Jdbc.md b/docs/zh/connectors/sink/Jdbc.md
index 1db79f01f0..f5ac84c757 100644
--- a/docs/zh/connectors/sink/Jdbc.md
+++ b/docs/zh/connectors/sink/Jdbc.md
@@ -335,6 +335,14 @@ JDBC `executeBatch` 失败后的重试次数。Exactly-once 模式要求设置
 
 事务提交失败的最大重试次数
 
+在 exactly-once 的 XA 模式下,这个重试预算会在单次 aggregated-commit 或 restore 调用内消耗完。
+恢复时,SeaTunnel 会从 XA recovery scan 中第一个仍然处于 prepared 状态的 checkpoint XID 开始,
+严格回放其后的事务后缀。位于该边界之前、且在 recovery scan 中缺失的 XID,只有在后缀严格提交
+成功之后才会被视为已经解析完成。如果 recovery scan 中一个 checkpoint XID 都不存在,SeaTunnel
+会把整个批次视为已经完成,不再重复回放。SeaTunnel 会在同步重试轮次之间固定等待 1 秒,让瞬时
+的资源管理器不可用可以真正消耗这份重试预算,而不是在瞬间烧完所有尝试次数。只有在第一个 recovered checkpoint XID 之后又出现缺
+口时,恢复才会直接 fail-closed,而不是把结果推断成已经成功提交。
+
 ### transaction_timeout_sec [int]
 
 在事务开启后的超时时间,默认值为-1(即永不超时)。请注意,设置超时时间可能会影响到精确一次(exactly-once)的语义
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index 887647be93..9e16201b6f 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -13,6 +13,12 @@
 
 ### JDBC Connector
 
+- **破坏性变更:JDBC XA restore 改为基于 recovery 顺序证据并对缺口 fail-closed**
+  - **影响范围**:`seatunnel-connectors-v2/connector-jdbc` sink 的 exactly-once XA 路径
+  - **变更说明**:SeaTunnel 现在会在单次 aggregated-commit 或 restore 调用内消耗完 
`max_commit_attempts`。恢复时,只会从 XA recovery scan 中第一个仍然存在的 checkpoint XID 
开始,严格回放其后的 prepared 事务后缀。位于该边界之前、且在 recovery scan 中缺失的 
XID,只有在后缀严格提交成功之后才会被视为已经完成;如果 recovery scan 中一个 checkpoint XID 都不存在,SeaTunnel 
会把整个批次视为已经完成并跳过回放;只有在第一个 recovered checkpoint XID 之后又出现缺失 XID 时,restore 才会直接 
fail-closed,而不是仅凭 `XAER_NOTA` 这类“事务不存在”结果去推断已经提交成功。
+  - **影响**:以前依赖“XA 分支缺失即视为成功”的任务,在升级后如果 recovery scan 里仍然能看到后续 checkpoint 
XID、但中间出现缺口,可能会在恢复阶段收到明确的 XA restore 错误。另外,`max_commit_attempts` 现在会在一次 
restore/commit 调用内耗尽,而不是分散到多次任务重启中。
+  - **迁移指南**:升级前请先检查资源管理器中是否还残留 prepared XA 事务,例如 MySQL 可使用 `XA 
RECOVER`,PostgreSQL 可检查 `pg_prepared_xacts`。如果升级后 restore 因为“后面仍有 checkpoint 
XID,但中间出现缺失 XID”而进入 fail-closed,请重点确认缺失 XID 是否被回滚、超时过期,或被外部清理,再决定后续恢复操作。XA 
recovery 无法区分 SeaTunnel 已提交的 XID 与被外部清理者回滚或删除的 XID。因此,位于 recovered 后缀之前的缺失 
XID,或全部 XID 缺失的批次,会被推断为已经完成;在相关作业可能恢复时,不要对 SeaTunnel 所属的 prepared XA 
分支执行外部清理,任何清理操作都应与作业恢复流程协调。
+
 - **破坏性变更:带时区的时间戳列映射为 `TIMESTAMP_TZ` 类型**
   - 
**影响范围**:`seatunnel-connectors-v2/connector-jdbc`、`seatunnel-connectors-v2/connector-iceberg`、`seatunnel-connectors-v2/connector-cdc-base`、`seatunnel-connectors-v2/connector-cdc-tidb`、`seatunnel-connectors-v2/connector-starrocks`、`seatunnel-connectors-v2/connector-hudi`、`seatunnel-connectors-v2/connector-snowflake`(通过
 JDBC 方言)
   - **变更说明**:以前,JDBC Source 将无时区(如 MySQL `DATETIME`)和带时区(如 MySQL 
`TIMESTAMP`)的时间戳列都映射为 SeaTunnel 内部的 `TIMESTAMP` 类型。现在,带时区的列(如 MySQL 
`TIMESTAMP`、PostgreSQL `timestamptz`、Oracle `TIMESTAMP WITH LOCAL TIME 
ZONE`、SQL Server `datetimeoffset`、Snowflake `TIMESTAMP_LTZ/TZ` 等)被显式映射为 
`TIMESTAMP_TZ`。这确保了在写入 Iceberg 等格式时,时区语义得到准确保留(在 Iceberg 中 `TIMESTAMP` 存为无时区的 
`timestamp`,`TIMESTAMP_TZ` 存为带时区的 `timestamptz`)。
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacade.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacade.java
index e47ca7d4a9..acd709db8f 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacade.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacade.java
@@ -97,11 +97,12 @@ public interface XaFacade extends JdbcConnectionProvider, 
Serializable, AutoClos
     }
 
     /**
-     * Indicates a transient or unknown failure from the resource manager (see 
{@link
-     * XAException#XA_RBTRANSIENT XA_RBTRANSIENT}, {@link 
XAException#XAER_RMFAIL XAER_RMFAIL}).
+     * Indicates that an XA operation had no effect and can be retried, or 
that its outcome is
+     * unknown because the resource manager is unavailable (see {@link 
XAException#XA_RETRY
+     * XA_RETRY}, {@link XAException#XAER_RMFAIL XAER_RMFAIL}).
      */
     class TransientXaException extends RuntimeException {
-        TransientXaException(XAException cause) {
+        public TransientXaException(XAException cause) {
             super(cause);
         }
     }
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoad.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoad.java
index 5dedc0e475..2842613b30 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoad.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoad.java
@@ -56,6 +56,7 @@ import static javax.transaction.xa.XAException.XA_HEURRB;
 import static javax.transaction.xa.XAException.XA_RBBASE;
 import static javax.transaction.xa.XAException.XA_RBTIMEOUT;
 import static javax.transaction.xa.XAException.XA_RBTRANSIENT;
+import static javax.transaction.xa.XAException.XA_RETRY;
 import static javax.transaction.xa.XAResource.TMENDRSCAN;
 import static javax.transaction.xa.XAResource.TMNOFLAGS;
 import static javax.transaction.xa.XAResource.TMSTARTRSCAN;
@@ -72,7 +73,7 @@ public class XaFacadeImplAutoLoad implements XaFacade {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(XaFacadeImplAutoLoad.class);
     private static final Set<Integer> TRANSIENT_ERR_CODES =
-            new HashSet<>(Arrays.asList(XA_RBTRANSIENT, XAER_RMFAIL));
+            new HashSet<>(Arrays.asList(XA_RETRY, XAER_RMFAIL));
     private static final Set<Integer> HEUR_ERR_CODES =
             new HashSet<>(Arrays.asList(XA_HEURRB, XA_HEURCOM, XA_HEURHAZ, 
XA_HEURMIX));
     private static final int MAX_RECOVER_CALLS = 100;
@@ -297,19 +298,22 @@ public class XaFacadeImplAutoLoad implements XaFacade {
         }
     }
 
+    /**
+     * Preserves retryable XA failures as {@link TransientXaException}; all 
other failures use the
+     * connector error contract.
+     */
     private static RuntimeException wrapException(String action, Optional<Xid> 
xid, Exception ex) {
         if (ex instanceof XAException) {
             XAException xa = (XAException) ex;
             if (TRANSIENT_ERR_CODES.contains(xa.errorCode)) {
-                throw new JdbcConnectorException(
-                        JdbcConnectorErrorCode.XA_OPERATION_FAILED, new 
TransientXaException(xa));
+                return new TransientXaException(xa);
             } else {
-                throw new JdbcConnectorException(
+                return new JdbcConnectorException(
                         JdbcConnectorErrorCode.XA_OPERATION_FAILED,
                         formatErrorMessage(action, xid, of(xa.errorCode), 
xa.getMessage()));
             }
         } else {
-            throw new JdbcConnectorException(
+            return new JdbcConnectorException(
                     JdbcConnectorErrorCode.XA_OPERATION_FAILED,
                     formatErrorMessage(action, xid, empty(), ex.getMessage()),
                     ex);
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOps.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOps.java
index 655d9fe989..e254e27764 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOps.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOps.java
@@ -29,8 +29,17 @@ import java.util.List;
 
 public interface XaGroupOps extends Serializable {
 
-    // Commit a batch of transactions
-    public GroupXaOperationResult<XidInfo> commit(
+    /**
+     * Commits a batch of prepared transactions.
+     *
+     * @param xids prepared transactions to commit
+     * @param allowOutOfOrderCommits whether transactions after a failed 
transaction may be
+     *     committed
+     * @param maxCommitAttempts maximum number of transient commit attempts
+     * @return commit result containing transactions that need another 
attempt; implementations must
+     *     eventually return an empty retry list or throw once the retry 
budget is exhausted
+     */
+    GroupXaOperationResult<XidInfo> commit(
             List<XidInfo> xids, boolean allowOutOfOrderCommits, int 
maxCommitAttempts);
 
     void rollback(List<XidInfo> xids);
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImpl.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImpl.java
index 88a48a65e0..77acbaf4f8 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImpl.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImpl.java
@@ -67,12 +67,9 @@ public class XaGroupOpsImpl implements XaGroupOps {
             }
         }
         result.getForRetry().addAll(xids);
-        // TODO At present, it is impossible to distinguish whether
-        // the repeated Commit failure caused by restore (exception should not 
be thrown) or
-        // the failure of normal process Commit (exception should be thrown).
-        // So currently the exception is not thrown.
-
-        // result.throwIfAnyFailed("commit");
+        // A permanent or unknown commit failure must fail the checkpoint 
instead of being reported
+        // as a successful commit.
+        result.throwIfAnyFailed("commit");
         throwIfAnyReachedMaxAttempts(result, maxCommitAttempts);
         result.getTransientFailure()
                 .ifPresent(
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitter.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitter.java
index c4abb10cb8..6cbefba761 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitter.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitter.java
@@ -30,15 +30,25 @@ import 
org.apache.seatunnel.connectors.seatunnel.jdbc.state.XidInfo;
 
 import lombok.extern.slf4j.Slf4j;
 
+import javax.transaction.xa.Xid;
+
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
-import java.util.stream.Collectors;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 @Slf4j
 public class JdbcSinkAggregatedCommitter
         implements SinkAggregatedCommitter<XidInfo, JdbcAggregatedCommitInfo> {
 
+    private static final long XA_RETRY_BACKOFF_MILLIS = 
TimeUnit.SECONDS.toMillis(1);
+
     private XaFacade xaFacade;
     private XaGroupOps xaGroupOps;
     private final JdbcSinkConfig jdbcSinkConfig;
@@ -70,22 +80,79 @@ public class JdbcSinkAggregatedCommitter
     @Override
     public List<JdbcAggregatedCommitInfo> commit(
             List<JdbcAggregatedCommitInfo> aggregatedCommitInfos) throws 
IOException {
+        return commitPreparedTransactions(aggregatedCommitInfos);
+    }
+
+    /**
+     * Reconciles checkpoint XIDs with the resource manager using commit-order 
evidence. Checkpoint
+     * XIDs from the first still-prepared transaction onward must all be 
present in the recovery
+     * scan and are replayed strictly. An all-absent batch is treated as 
already resolved, while an
+     * absent prefix before a still-prepared suffix is treated as already 
resolved only after that
+     * suffix commits successfully. The scan is refreshed for each batch 
because an external
+     * resource-manager actor, such as a DBA or RM cleanup process, can 
resolve an in-doubt
+     * transaction between restored batches. This is not a second Zeta 
committer; the refresh
+     * narrows, but cannot eliminate, the gap before the following XA commit 
call.
+     */
+    @Override
+    public List<JdbcAggregatedCommitInfo> restoreCommit(
+            List<JdbcAggregatedCommitInfo> aggregatedCommitInfos) throws 
IOException {
+        tryOpen();
+        for (JdbcAggregatedCommitInfo aggregatedCommitInfo : 
aggregatedCommitInfos) {
+            // Refresh RM evidence for every batch because a DBA or RM cleanup 
process can resolve
+            // an in-doubt transaction while earlier restored batches are 
being replayed.
+            replayRecoveredCheckpoint(
+                    aggregatedCommitInfo.getXidInfoList(), 
recoverCheckpointTransactions());
+        }
+        return Collections.emptyList();
+    }
+
+    /**
+     * Commits prepared transactions and completes bounded transient retries 
in the same invocation.
+     *
+     * @param aggregatedCommitInfos prepared transactions grouped for commit
+     * @return an empty list after all prepared transactions have been 
committed
+     * @throws IOException when the committer cannot open its XA resource
+     */
+    private List<JdbcAggregatedCommitInfo> commitPreparedTransactions(
+            List<JdbcAggregatedCommitInfo> aggregatedCommitInfos) throws 
IOException {
         tryOpen();
-        return aggregatedCommitInfos.stream()
-                .map(
-                        aggregatedCommitInfo -> {
-                            log.info("commit xid: " + 
aggregatedCommitInfo.getXidInfoList());
-                            GroupXaOperationResult<XidInfo> result =
-                                    xaGroupOps.commit(
-                                            new 
ArrayList<>(aggregatedCommitInfo.getXidInfoList()),
-                                            false,
-                                            jdbcSinkConfig
-                                                    .getJdbcConnectionConfig()
-                                                    .getMaxCommitAttempts());
-                            return new 
JdbcAggregatedCommitInfo(result.getForRetry());
-                        })
-                .filter(ainfo -> !ainfo.getXidInfoList().isEmpty())
-                .collect(Collectors.toList());
+        for (JdbcAggregatedCommitInfo aggregatedCommitInfo : 
aggregatedCommitInfos) {
+            log.info("commit xid: " + aggregatedCommitInfo.getXidInfoList());
+            commitXidInfos(aggregatedCommitInfo.getXidInfoList());
+        }
+        return Collections.emptyList();
+    }
+
+    /**
+     * Commits one XID list and exhausts bounded transient retries while the 
retry-attempt state is
+     * still available in memory.
+     *
+     * @param xidInfos prepared transactions to commit
+     */
+    private void commitXidInfos(List<XidInfo> xidInfos) {
+        List<XidInfo> pending = new ArrayList<>(xidInfos);
+        int maxCommitAttempts = 
jdbcSinkConfig.getJdbcConnectionConfig().getMaxCommitAttempts();
+        int remainingRounds = Math.max(1, maxCommitAttempts);
+        while (!pending.isEmpty()) {
+            if (remainingRounds-- == 0) {
+                throw new JdbcConnectorException(
+                        CommonErrorCodeDeprecated.WRITER_OPERATION_FAILED,
+                        String.format(
+                                "XA commit retry loop did not terminate within 
%d rounds for transactions: %s",
+                                Math.max(1, maxCommitAttempts), pending));
+            }
+            GroupXaOperationResult<XidInfo> result =
+                    xaGroupOps.commit(pending, false, maxCommitAttempts);
+            // Zeta does not persist the returned committables before 
restarting a failed
+            // checkpoint, so complete bounded retries in this invocation.
+            pending = new ArrayList<>(result.getForRetry());
+            if (!pending.isEmpty() && remainingRounds > 0) {
+                backoffBeforeRetry(
+                        "commit",
+                        Math.max(1, maxCommitAttempts) - remainingRounds + 1,
+                        Math.max(1, maxCommitAttempts));
+            }
+        }
     }
 
     @Override
@@ -114,4 +181,183 @@ public class JdbcSinkAggregatedCommitter
                     e);
         }
     }
+
+    /**
+     * Normalizes driver-specific {@link Xid} implementations into canonical 
values for recovery
+     * reconciliation.
+     *
+     * @param recoveredXids transactions returned by the resource manager
+     * @return canonical values for transactions returned by the resource 
manager
+     */
+    private Set<XidKey> normalizeXids(Collection<Xid> recoveredXids) {
+        Set<XidKey> normalized = new HashSet<>();
+        for (Xid xid : recoveredXids) {
+            normalized.add(XidKey.from(xid));
+        }
+        return normalized;
+    }
+
+    /**
+     * Replays one restored checkpoint using the recovery scan as evidence for 
which transactions
+     * can still be committed safely.
+     *
+     * @param checkpointXids checkpoint-owned prepared transactions in 
original commit order
+     * @param recoveredXids canonical values returned by the resource manager 
recovery scan
+     */
+    private void replayRecoveredCheckpoint(
+            List<XidInfo> checkpointXids, Set<XidKey> recoveredXids) {
+        if (checkpointXids.isEmpty()) {
+            return;
+        }
+        int firstRecoveredIndex = findFirstRecoveredIndex(checkpointXids, 
recoveredXids);
+        if (firstRecoveredIndex < 0) {
+            log.warn(
+                    "Skipping checkpoint batch because none of its 
transactions remain in the XA recovery scan; treating it as already resolved: 
{}",
+                    checkpointXids);
+            return;
+        }
+        List<XidInfo> stillPrepared = new ArrayList<>();
+        for (int i = firstRecoveredIndex; i < checkpointXids.size(); i++) {
+            XidInfo xidInfo = checkpointXids.get(i);
+            if (!containsEquivalentXid(recoveredXids, xidInfo.getXid())) {
+                throw new JdbcConnectorException(
+                        CommonErrorCodeDeprecated.WRITER_OPERATION_FAILED,
+                        String.format(
+                                "checkpoint transaction %s is absent from the 
XA recovery scan after still-prepared transactions in the same commit batch: 
%s",
+                                xidInfo.getXid(), checkpointXids));
+            }
+            stillPrepared.add(xidInfo);
+        }
+        commitXidInfos(stillPrepared);
+        if (firstRecoveredIndex > 0) {
+            List<XidInfo> alreadyResolved = checkpointXids.subList(0, 
firstRecoveredIndex);
+            log.warn(
+                    "Skipping {} checkpoint transactions that are absent from 
the XA recovery scan but precede the first still-prepared transaction: {}",
+                    alreadyResolved.size(),
+                    alreadyResolved);
+        }
+    }
+
+    /**
+     * Retries the recovery scan with the same bounded budget as XA commit so 
transient RM outages
+     * do not fail restore immediately.
+     *
+     * @return canonical values returned by the recovery scan
+     */
+    private Set<XidKey> recoverCheckpointTransactions() {
+        int maxCommitAttempts = 
jdbcSinkConfig.getJdbcConnectionConfig().getMaxCommitAttempts();
+        XaFacade.TransientXaException lastTransientFailure = null;
+        for (int attempt = 1; attempt <= Math.max(1, maxCommitAttempts); 
attempt++) {
+            try {
+                return normalizeXids(xaFacade.recover());
+            } catch (XaFacade.TransientXaException e) {
+                lastTransientFailure = e;
+                log.warn(
+                        "Transient XA recovery-scan failure on attempt {}/{}",
+                        attempt,
+                        Math.max(1, maxCommitAttempts),
+                        e);
+                if (attempt < Math.max(1, maxCommitAttempts)) {
+                    backoffBeforeRetry(
+                            "recovery scan", attempt + 1, Math.max(1, 
maxCommitAttempts));
+                }
+            }
+        }
+        throw new JdbcConnectorException(
+                CommonErrorCodeDeprecated.WRITER_OPERATION_FAILED,
+                String.format(
+                        "unable to complete the XA recovery scan within %d 
attempts",
+                        Math.max(1, maxCommitAttempts)),
+                lastTransientFailure);
+    }
+
+    /**
+     * Finds the first checkpoint transaction that is still prepared in the 
resource manager.
+     *
+     * @param checkpointXids checkpoint-owned prepared transactions in 
original commit order
+     * @param recoveredXids canonical values returned by the recovery scan
+     * @return the first still-prepared checkpoint transaction index, or 
{@code -1} if none remain
+     */
+    private int findFirstRecoveredIndex(List<XidInfo> checkpointXids, 
Set<XidKey> recoveredXids) {
+        for (int i = 0; i < checkpointXids.size(); i++) {
+            if (containsEquivalentXid(recoveredXids, 
checkpointXids.get(i).getXid())) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Checks whether the recovery scan contains the checkpoint transaction by 
canonical XID value.
+     *
+     * @param recoveredXids canonical recovery-scan values
+     * @param checkpointXid transaction restored from checkpoint state
+     * @return whether the recovery scan contains the checkpoint transaction
+     */
+    private boolean containsEquivalentXid(Set<XidKey> recoveredXids, Xid 
checkpointXid) {
+        return recoveredXids.contains(XidKey.from(checkpointXid));
+    }
+
+    /**
+     * Adds a bounded pause between synchronous retry rounds so transient RM 
outages do not consume
+     * the whole retry budget immediately.
+     *
+     * @param operation operation being retried
+     * @param nextAttempt 1-based retry attempt number that will run after the 
pause
+     * @param maxAttempts bounded retry budget
+     */
+    private void backoffBeforeRetry(String operation, int nextAttempt, int 
maxAttempts) {
+        try {
+            Thread.sleep(XA_RETRY_BACKOFF_MILLIS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new JdbcConnectorException(
+                    CommonErrorCodeDeprecated.WRITER_OPERATION_FAILED,
+                    String.format(
+                            "Interrupted while waiting to retry XA %s on 
attempt %d/%d",
+                            operation, nextAttempt, maxAttempts),
+                    e);
+        }
+    }
+
+    /** Canonical XID value used to compare driver-specific {@link Xid} 
implementations. */
+    private static final class XidKey {
+        private final int formatId;
+        private final byte[] globalTransactionId;
+        private final byte[] branchQualifier;
+
+        private XidKey(int formatId, byte[] globalTransactionId, byte[] 
branchQualifier) {
+            this.formatId = formatId;
+            this.globalTransactionId =
+                    Arrays.copyOf(globalTransactionId, 
globalTransactionId.length);
+            this.branchQualifier = Arrays.copyOf(branchQualifier, 
branchQualifier.length);
+        }
+
+        private static XidKey from(Xid xid) {
+            return new XidKey(
+                    xid.getFormatId(), xid.getGlobalTransactionId(), 
xid.getBranchQualifier());
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (!(o instanceof XidKey)) {
+                return false;
+            }
+            XidKey xidKey = (XidKey) o;
+            return formatId == xidKey.formatId
+                    && Arrays.equals(globalTransactionId, 
xidKey.globalTransactionId)
+                    && Arrays.equals(branchQualifier, xidKey.branchQualifier);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = Objects.hash(formatId);
+            result = 31 * result + Arrays.hashCode(globalTransactionId);
+            result = 31 * result + Arrays.hashCode(branchQualifier);
+            return result;
+        }
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoadTest.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoadTest.java
new file mode 100644
index 0000000000..c6322d3f2a
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaFacadeImplAutoLoadTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.jdbc.internal.xa;
+
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.exception.JdbcConnectorException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.transaction.xa.XAException;
+import javax.transaction.xa.XAResource;
+import javax.transaction.xa.Xid;
+
+import java.lang.reflect.Field;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests XA error classification and explicit recovery outcomes without 
requiring a database
+ * resource manager.
+ */
+class XaFacadeImplAutoLoadTest {
+
+    /**
+     * Verifies that resource-manager availability errors remain retryable 
instead of being wrapped
+     * as permanent connector failures.
+     */
+    @Test
+    void testCommitPreservesTransientXaException() throws Exception {
+        XAResource xaResource = mock(XAResource.class);
+        XAException transientFailure = new 
XAException(XAException.XAER_RMFAIL);
+        doThrow(transientFailure).when(xaResource).commit(any(), eq(false));
+        XaFacadeImplAutoLoad xaFacade = createOpenFacade(xaResource);
+
+        XaFacade.TransientXaException exception =
+                Assertions.assertThrows(
+                        XaFacade.TransientXaException.class,
+                        () -> xaFacade.commit(createXid(), false));
+
+        Assertions.assertSame(transientFailure, exception.getCause());
+    }
+
+    /**
+     * Verifies that XA_RETRY remains retryable because the resource manager 
reports that the
+     * operation had no effect.
+     */
+    @Test
+    void testCommitRetriesWhenResourceManagerRequestsRetry() throws Exception {
+        XAResource xaResource = mock(XAResource.class);
+        XAException retry = new XAException(XAException.XA_RETRY);
+        doThrow(retry).when(xaResource).commit(any(), eq(false));
+        XaFacadeImplAutoLoad xaFacade = createOpenFacade(xaResource);
+
+        XaFacade.TransientXaException exception =
+                Assertions.assertThrows(
+                        XaFacade.TransientXaException.class,
+                        () -> xaFacade.commit(createXid(), false));
+
+        Assertions.assertSame(retry, exception.getCause());
+    }
+
+    /**
+     * Verifies that an explicit heuristic-commit result is accepted as 
durable completion evidence.
+     */
+    @Test
+    void testCommitAcceptsHeuristicCommitResult() throws Exception {
+        XAResource xaResource = mock(XAResource.class);
+        XAException heuristicallyCommitted = new 
XAException(XAException.XA_HEURCOM);
+        doThrow(heuristicallyCommitted).when(xaResource).commit(any(), 
eq(false));
+        XaFacadeImplAutoLoad xaFacade = createOpenFacade(xaResource);
+
+        Assertions.assertDoesNotThrow(() -> xaFacade.commit(createXid(), 
false));
+
+        verify(xaResource).forget(any());
+    }
+
+    /**
+     * Verifies that a transaction reported as rolled back is a permanent 
failure and is never
+     * retried as though its commit outcome were unknown.
+     */
+    @Test
+    void testCommitDoesNotRetryRolledBackTransaction() throws Exception {
+        XAResource xaResource = mock(XAResource.class);
+        XAException rolledBack = new XAException(XAException.XA_RBTRANSIENT);
+        doThrow(rolledBack).when(xaResource).commit(any(), eq(false));
+        XaFacadeImplAutoLoad xaFacade = createOpenFacade(xaResource);
+
+        Assertions.assertThrows(
+                JdbcConnectorException.class, () -> 
xaFacade.commit(createXid(), false));
+    }
+
+    /**
+     * Verifies that XAER_NOTA is ignored only when recovery is repeating a 
commit whose response
+     * may have been lost.
+     */
+    @Test
+    void testCommitIgnoresUnknownTransactionOnlyWhenRequested() throws 
Exception {
+        XAResource xaResource = mock(XAResource.class);
+        XAException unknownTransaction = new 
XAException(XAException.XAER_NOTA);
+        doThrow(unknownTransaction).when(xaResource).commit(any(), eq(false));
+        XaFacadeImplAutoLoad xaFacade = createOpenFacade(xaResource);
+        Xid xid = createXid();
+
+        Assertions.assertDoesNotThrow(() -> xaFacade.commit(xid, true));
+        Assertions.assertThrows(JdbcConnectorException.class, () -> 
xaFacade.commit(xid, false));
+    }
+
+    /**
+     * Creates a facade with an injected XA resource so error handling can be 
tested without a
+     * database.
+     */
+    private XaFacadeImplAutoLoad createOpenFacade(XAResource xaResource) 
throws Exception {
+        XaFacadeImplAutoLoad xaFacade = new XaFacadeImplAutoLoad(null);
+        Field field = 
XaFacadeImplAutoLoad.class.getDeclaredField("xaResource");
+        field.setAccessible(true);
+        field.set(xaFacade, xaResource);
+        return xaFacade;
+    }
+
+    /**
+     * Creates a stable transaction identifier whose value is shared across 
the commit test cases.
+     */
+    private Xid createXid() {
+        return new XidImpl(1, new byte[] {1}, new byte[] {1});
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplTest.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplTest.java
new file mode 100644
index 0000000000..64a0ee4a8e
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.jdbc.internal.xa;
+
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.exception.JdbcConnectorException;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.state.XidInfo;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.transaction.xa.XAException;
+import javax.transaction.xa.Xid;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests grouped XA commit failure propagation and retry accounting.
+ *
+ * <p>Each case verifies strict resource-manager commit handling for prepared 
transaction batches.
+ */
+class XaGroupOpsImplTest {
+
+    /**
+     * Verifies that a permanent commit failure propagates instead of being 
reported as a successful
+     * checkpoint.
+     */
+    @Test
+    void testCommitPropagatesPermanentFailure() {
+        XaFacade xaFacade = mock(XaFacade.class);
+        Xid xid = createXid();
+        RuntimeException commitFailure = new RuntimeException("permanent 
commit failure");
+        doThrow(commitFailure).when(xaFacade).commit(xid, false);
+        XaGroupOps xaGroupOps = new XaGroupOpsImpl(xaFacade);
+
+        JdbcConnectorException exception =
+                Assertions.assertThrows(
+                        JdbcConnectorException.class,
+                        () ->
+                                xaGroupOps.commit(
+                                        new ArrayList<>(
+                                                Collections.singletonList(new 
XidInfo(xid, 0))),
+                                        false,
+                                        3));
+
+        Assertions.assertSame(commitFailure, exception.getCause());
+    }
+
+    /**
+     * Verifies that a transient failure is returned for retry with an 
incremented attempt count.
+     */
+    @Test
+    void testCommitReturnsTransientFailureForRetry() {
+        XaFacade xaFacade = mock(XaFacade.class);
+        Xid xid = createXid();
+        XAException transientCause = new XAException(XAException.XAER_RMFAIL);
+        doThrow(new XaFacade.TransientXaException(transientCause))
+                .when(xaFacade)
+                .commit(xid, false);
+        XaGroupOps xaGroupOps = new XaGroupOpsImpl(xaFacade);
+
+        GroupXaOperationResult<XidInfo> result =
+                xaGroupOps.commit(
+                        new ArrayList<>(Collections.singletonList(new 
XidInfo(xid, 0))), false, 3);
+
+        Assertions.assertEquals(1, result.getForRetry().size());
+        Assertions.assertEquals(1, result.getForRetry().get(0).getAttempts());
+        Assertions.assertSame(xid, result.getForRetry().get(0).getXid());
+    }
+
+    /**
+     * Verifies that retry exhaustion fails the commit instead of silently 
discarding the
+     * transaction.
+     */
+    @Test
+    void testCommitFailsWhenTransientRetryLimitIsReached() {
+        XaFacade xaFacade = mock(XaFacade.class);
+        Xid xid = createXid();
+        XAException transientCause = new XAException(XAException.XAER_RMFAIL);
+        doThrow(new XaFacade.TransientXaException(transientCause))
+                .when(xaFacade)
+                .commit(xid, false);
+        XaGroupOps xaGroupOps = new XaGroupOpsImpl(xaFacade);
+
+        JdbcConnectorException exception =
+                Assertions.assertThrows(
+                        JdbcConnectorException.class,
+                        () ->
+                                xaGroupOps.commit(
+                                        new ArrayList<>(
+                                                Collections.singletonList(new 
XidInfo(xid, 2))),
+                                        false,
+                                        3));
+
+        Assertions.assertTrue(exception.getMessage().contains("reached max 
number"));
+    }
+
+    /**
+     * Creates a stable transaction identifier whose value is shared across 
grouped commit tests.
+     */
+    private Xid createXid() {
+        return new XidImpl(1, new byte[] {1}, new byte[] {1});
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitterTest.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitterTest.java
new file mode 100644
index 0000000000..50310ddf33
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkAggregatedCommitterTest.java
@@ -0,0 +1,440 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.jdbc.sink;
+
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcConnectionConfig;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcSinkConfig;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.exception.JdbcConnectorException;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.internal.xa.GroupXaOperationResult;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.internal.xa.XaFacade;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.internal.xa.XaGroupOps;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.state.JdbcAggregatedCommitInfo;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.state.XidInfo;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.transaction.xa.XAException;
+import javax.transaction.xa.Xid;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests normal and recovery commit semantics, including RM reconciliation, 
for the JDBC aggregated
+ * committer.
+ */
+class JdbcSinkAggregatedCommitterTest {
+
+    /**
+     * Verifies that recovery matches driver-specific XIDs by value and never 
commits an unrelated
+     * recovered transaction.
+     */
+    @Test
+    void testRestoreCommitReconcilesRecoveredTransactionsByValue() throws 
Exception {
+        JdbcConnectionConfig connectionConfig =
+                JdbcConnectionConfig.builder().maxCommitAttempts(3).build();
+        JdbcSinkConfig sinkConfig =
+                
JdbcSinkConfig.builder().jdbcConnectionConfig(connectionConfig).build();
+        JdbcSinkAggregatedCommitter committer = new 
JdbcSinkAggregatedCommitter(sinkConfig);
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        when(xaFacade.isOpen()).thenReturn(true);
+        Xid checkpointXid = createXid(1, new byte[] {1}, new byte[] {2});
+        Xid recoveredXid = createXid(1, new byte[] {1}, new byte[] {2});
+        Xid unrelatedXid = createXid(2, new byte[] {3}, new byte[] {4});
+        when(xaFacade.recover()).thenReturn(Arrays.asList(recoveredXid, 
unrelatedXid));
+        when(xaGroupOps.commit(anyList(), eq(false), eq(3)))
+                .thenReturn(new GroupXaOperationResult<>());
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Collections.singletonList(new XidInfo(checkpointXid, 
0)));
+
+        committer.restoreCommit(Collections.singletonList(commitInfo));
+
+        verify(xaFacade).recover();
+        verify(xaGroupOps)
+                .commit(
+                        argThat(xids -> xids.size() == 1 && 
xids.get(0).getXid() == checkpointXid),
+                        eq(false),
+                        eq(3));
+    }
+
+    /**
+     * Verifies that each restored batch uses a fresh recovery scan instead of 
reusing stale
+     * resource-manager evidence from an earlier batch.
+     */
+    @Test
+    void testRestoreCommitRefreshesRecoveryScanForEachBatch() throws Exception 
{
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        Xid firstCheckpointXid = createXid(1, new byte[] {1}, new byte[] {1});
+        Xid secondCheckpointXid = createXid(2, new byte[] {2}, new byte[] {2});
+        Xid firstRecoveredXid = createXid(1, new byte[] {1}, new byte[] {1});
+        Xid secondRecoveredXid = createXid(2, new byte[] {2}, new byte[] {2});
+        when(xaFacade.isOpen()).thenReturn(true);
+        when(xaFacade.recover())
+                .thenReturn(Arrays.asList(firstRecoveredXid, 
secondRecoveredXid))
+                .thenReturn(Collections.emptyList());
+        when(xaGroupOps.commit(anyList(), eq(false), eq(3)))
+                .thenReturn(new GroupXaOperationResult<>());
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo firstCommitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Collections.singletonList(new 
XidInfo(firstCheckpointXid, 0)));
+        JdbcAggregatedCommitInfo secondCommitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Collections.singletonList(new 
XidInfo(secondCheckpointXid, 0)));
+
+        committer.restoreCommit(Arrays.asList(firstCommitInfo, 
secondCommitInfo));
+
+        verify(xaFacade, times(2)).recover();
+        verify(xaGroupOps)
+                .commit(
+                        argThat(
+                                xids ->
+                                        xids.size() == 1
+                                                && xids.get(0).getXid() == 
firstCheckpointXid),
+                        eq(false),
+                        eq(3));
+        verify(xaGroupOps, never())
+                .commit(
+                        argThat(
+                                xids ->
+                                        xids.stream()
+                                                .anyMatch(
+                                                        xidInfo ->
+                                                                
xidInfo.getXid()
+                                                                        == 
secondCheckpointXid)),
+                        eq(false),
+                        eq(3));
+    }
+
+    /**
+     * Verifies that a missing prefix is skipped only after the still-prepared 
suffix commits
+     * strictly.
+     */
+    @Test
+    void testRestoreCommitSkipsMissingPrefixAfterRecoveredSuffixCommits() 
throws Exception {
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        Xid alreadyResolvedXid = createXid(1, new byte[] {1}, new byte[] {1});
+        Xid stillPreparedXid = createXid(2, new byte[] {2}, new byte[] {2});
+        Xid recoveredStillPreparedXid = createXid(2, new byte[] {2}, new 
byte[] {2});
+        when(xaFacade.isOpen()).thenReturn(true);
+        
when(xaFacade.recover()).thenReturn(Collections.singletonList(recoveredStillPreparedXid));
+        when(xaGroupOps.commit(anyList(), eq(false), eq(3)))
+                .thenReturn(new GroupXaOperationResult<>());
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Arrays.asList(
+                                new XidInfo(alreadyResolvedXid, 0),
+                                new XidInfo(stillPreparedXid, 0)));
+
+        Assertions.assertDoesNotThrow(
+                () -> 
committer.restoreCommit(Collections.singletonList(commitInfo)));
+
+        verify(xaGroupOps)
+                .commit(
+                        argThat(
+                                xids ->
+                                        xids.size() == 1
+                                                && xids.get(0).getXid() == 
stillPreparedXid),
+                        eq(false),
+                        eq(3));
+        verify(xaGroupOps, never())
+                .commit(
+                        argThat(
+                                xids ->
+                                        xids.stream()
+                                                .anyMatch(
+                                                        xidInfo ->
+                                                                
xidInfo.getXid()
+                                                                        == 
alreadyResolvedXid)),
+                        eq(false),
+                        eq(3));
+    }
+
+    /**
+     * Verifies that restore treats an all-absent checkpoint batch as already 
resolved instead of
+     * replaying or failing it again.
+     */
+    @Test
+    void 
testRestoreCommitSkipsAlreadyResolvedBatchWhenRecoveryScanHasNoCheckpointXid()
+            throws Exception {
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        when(xaFacade.isOpen()).thenReturn(true);
+        when(xaFacade.recover()).thenReturn(Collections.emptyList());
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Arrays.asList(
+                                new XidInfo(createXid(1, new byte[] {1}, new 
byte[] {2}), 0),
+                                new XidInfo(createXid(2, new byte[] {3}, new 
byte[] {4}), 0)));
+
+        Assertions.assertDoesNotThrow(
+                () -> 
committer.restoreCommit(Collections.singletonList(commitInfo)));
+
+        verify(xaGroupOps, never()).commit(anyList(), eq(false), eq(3));
+    }
+
+    /**
+     * Verifies that restore reaches the still-prepared XID after an earlier 
XID was already
+     * resolved by a previous failed attempt.
+     */
+    @Test
+    void testRestoreCommitReportsRecoveredFailureBeforeAlreadyResolvedReplay() 
throws Exception {
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        RuntimeException stillPreparedFailure = new RuntimeException("still 
prepared failed");
+        Xid alreadyResolvedXid = createXid(1, new byte[] {1}, new byte[] {1});
+        Xid stillPreparedXid = createXid(2, new byte[] {2}, new byte[] {2});
+        Xid recoveredStillPreparedXid = createXid(2, new byte[] {2}, new 
byte[] {2});
+        when(xaFacade.isOpen()).thenReturn(true);
+        
when(xaFacade.recover()).thenReturn(Collections.singletonList(recoveredStillPreparedXid));
+        when(xaGroupOps.commit(
+                        argThat(
+                                xids ->
+                                        xids.size() == 1
+                                                && xids.get(0).getXid() == 
stillPreparedXid),
+                        eq(false),
+                        eq(3)))
+                .thenThrow(stillPreparedFailure);
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Arrays.asList(
+                                new XidInfo(alreadyResolvedXid, 0),
+                                new XidInfo(stillPreparedXid, 0)));
+
+        RuntimeException exception =
+                Assertions.assertThrows(
+                        RuntimeException.class,
+                        () -> 
committer.restoreCommit(Collections.singletonList(commitInfo)));
+
+        Assertions.assertSame(stillPreparedFailure, exception);
+        verify(xaGroupOps)
+                .commit(
+                        argThat(
+                                xids ->
+                                        xids.size() == 1
+                                                && xids.get(0).getXid() == 
stillPreparedXid),
+                        eq(false),
+                        eq(3));
+        verify(xaGroupOps, never())
+                .commit(
+                        argThat(
+                                xids ->
+                                        xids.stream()
+                                                .anyMatch(
+                                                        xidInfo ->
+                                                                
xidInfo.getXid()
+                                                                        == 
alreadyResolvedXid)),
+                        eq(false),
+                        eq(3));
+    }
+
+    /**
+     * Verifies that a missing XID after the first still-prepared transaction 
fails closed because
+     * the batch order cannot explain it as an earlier successful commit.
+     */
+    @Test
+    void testRestoreCommitFailsClosedOnMissingGapAfterRecoveredTransaction() 
throws Exception {
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        Xid stillPreparedXid = createXid(1, new byte[] {1}, new byte[] {1});
+        Xid missingTailXid = createXid(2, new byte[] {2}, new byte[] {2});
+        Xid recoveredStillPreparedXid = createXid(1, new byte[] {1}, new 
byte[] {1});
+        when(xaFacade.isOpen()).thenReturn(true);
+        
when(xaFacade.recover()).thenReturn(Collections.singletonList(recoveredStillPreparedXid));
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Arrays.asList(
+                                new XidInfo(stillPreparedXid, 0), new 
XidInfo(missingTailXid, 0)));
+
+        JdbcConnectorException exception =
+                Assertions.assertThrows(
+                        JdbcConnectorException.class,
+                        () -> 
committer.restoreCommit(Collections.singletonList(commitInfo)));
+
+        Assertions.assertTrue(
+                exception
+                        .getMessage()
+                        .contains(
+                                "is absent from the XA recovery scan after 
still-prepared transactions"));
+        verify(xaGroupOps, never()).commit(anyList(), eq(false), eq(3));
+    }
+
+    /**
+     * Verifies that restore retries a transient recovery-scan failure before 
replaying the
+     * checkpoint transactions.
+     */
+    @Test
+    void testRestoreCommitRetriesTransientRecoveryScanFailure() throws 
Exception {
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        XAException transientCause = new XAException(XAException.XAER_RMFAIL);
+        Xid checkpointXid = createXid(1, new byte[] {1}, new byte[] {1});
+        Xid recoveredXid = createXid(1, new byte[] {1}, new byte[] {1});
+        when(xaFacade.isOpen()).thenReturn(true);
+        when(xaFacade.recover())
+                .thenThrow(new XaFacade.TransientXaException(transientCause))
+                .thenReturn(Collections.singletonList(recoveredXid));
+        when(xaGroupOps.commit(anyList(), eq(false), eq(3)))
+                .thenReturn(new GroupXaOperationResult<>());
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Collections.singletonList(new XidInfo(checkpointXid, 
0)));
+
+        Assertions.assertDoesNotThrow(
+                () -> 
committer.restoreCommit(Collections.singletonList(commitInfo)));
+
+        verify(xaFacade, times(2)).recover();
+        verify(xaGroupOps).commit(anyList(), eq(false), eq(3));
+    }
+
+    /**
+     * Verifies that Zeta retries are completed while the incremented attempt 
state is available.
+     */
+    @Test
+    void testCommitCompletesBoundedRetriesWithinInvocation() throws Exception {
+        JdbcConnectionConfig connectionConfig =
+                JdbcConnectionConfig.builder().maxCommitAttempts(3).build();
+        JdbcSinkConfig sinkConfig =
+                
JdbcSinkConfig.builder().jdbcConnectionConfig(connectionConfig).build();
+        JdbcSinkAggregatedCommitter committer = new 
JdbcSinkAggregatedCommitter(sinkConfig);
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        when(xaFacade.isOpen()).thenReturn(true);
+        when(xaGroupOps.commit(anyList(), eq(false), eq(3)))
+                .thenAnswer(
+                        invocation -> {
+                            XidInfo current = 
invocation.<List<XidInfo>>getArgument(0).get(0);
+                            GroupXaOperationResult<XidInfo> result = new 
GroupXaOperationResult<>();
+                            if (current.getAttempts() < 2) {
+                                
result.getForRetry().add(current.withAttemptsIncremented());
+                            }
+                            return result;
+                        });
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Collections.singletonList(new XidInfo(mock(Xid.class), 
0)));
+
+        committer.commit(Collections.singletonList(commitInfo));
+
+        verify(xaGroupOps, times(3)).commit(anyList(), eq(false), eq(3));
+    }
+
+    /**
+     * Verifies that the committer fails fast if an alternative XaGroupOps 
implementation never
+     * drains or escalates the retry list.
+     */
+    @Test
+    void testCommitFailsWhenRetryRoundsNeverDrain() throws Exception {
+        JdbcSinkAggregatedCommitter committer = createCommitter();
+        XaFacade xaFacade = mock(XaFacade.class);
+        XaGroupOps xaGroupOps = mock(XaGroupOps.class);
+        Xid xid = mock(Xid.class);
+        when(xaFacade.isOpen()).thenReturn(true);
+        when(xaGroupOps.commit(anyList(), eq(false), eq(3)))
+                .thenAnswer(
+                        invocation -> {
+                            GroupXaOperationResult<XidInfo> result = new 
GroupXaOperationResult<>();
+                            
result.getForRetry().addAll(invocation.getArgument(0));
+                            return result;
+                        });
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", xaGroupOps);
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(Collections.singletonList(new 
XidInfo(xid, 0)));
+
+        JdbcConnectorException exception =
+                Assertions.assertThrows(
+                        JdbcConnectorException.class,
+                        () -> 
committer.commit(Collections.singletonList(commitInfo)));
+
+        Assertions.assertTrue(exception.getMessage().contains("did not 
terminate"));
+        verify(xaGroupOps, times(3)).commit(anyList(), eq(false), eq(3));
+    }
+
+    /**
+     * Creates a committer with the same explicit retry limit used by every 
recovery test case.
+     *
+     * @return an uninitialized committer whose collaborators can be injected 
by the test
+     */
+    private JdbcSinkAggregatedCommitter createCommitter() {
+        JdbcConnectionConfig connectionConfig =
+                JdbcConnectionConfig.builder().maxCommitAttempts(3).build();
+        JdbcSinkConfig sinkConfig =
+                
JdbcSinkConfig.builder().jdbcConnectionConfig(connectionConfig).build();
+        return new JdbcSinkAggregatedCommitter(sinkConfig);
+    }
+
+    /**
+     * Creates a driver-neutral XID value whose canonical components can be 
compared during
+     * recovery.
+     */
+    private Xid createXid(int formatId, byte[] globalTransactionId, byte[] 
branchQualifier) {
+        Xid xid = mock(Xid.class);
+        when(xid.getFormatId()).thenReturn(formatId);
+        when(xid.getGlobalTransactionId()).thenReturn(globalTransactionId);
+        when(xid.getBranchQualifier()).thenReturn(branchQualifier);
+        return xid;
+    }
+
+    /**
+     * Injects a test dependency through the existing private field without 
changing production
+     * constructors.
+     */
+    private void setPrivateField(Object target, String fieldName, Object 
value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplIT.java
index 28e34b656c..d833e024d3 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplIT.java
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/xa/XaGroupOpsImplIT.java
@@ -22,15 +22,20 @@ import org.apache.seatunnel.api.common.JobContext;
 import org.apache.seatunnel.api.sink.DefaultSinkWriterContext;
 import org.apache.seatunnel.api.sink.SinkWriter;
 import 
org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcConnectionConfig;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcSinkConfig;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.exception.JdbcConnectorException;
 import 
org.apache.seatunnel.connectors.seatunnel.jdbc.internal.connection.DataSourceUtils;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.sink.JdbcSinkAggregatedCommitter;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.state.JdbcAggregatedCommitInfo;
+import org.apache.seatunnel.connectors.seatunnel.jdbc.state.XidInfo;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.testcontainers.containers.MySQLContainer;
 import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.images.PullPolicy;
 import org.testcontainers.lifecycle.Startables;
 import org.testcontainers.utility.DockerImageName;
 import org.testcontainers.utility.DockerLoggerFactory;
@@ -42,13 +47,13 @@ import javax.transaction.xa.XAException;
 import javax.transaction.xa.XAResource;
 import javax.transaction.xa.Xid;
 
+import java.lang.reflect.Field;
+import java.util.Collections;
 import java.util.stream.Stream;
 
 import static javax.transaction.xa.XAResource.TMSTARTRSCAN;
 
 @Slf4j
-@Disabled(
-        "Temporary fast fix, reason: JdbcDatabaseContainer: 
ClassNotFoundException: com.mysql.jdbc.Driver")
 class XaGroupOpsImplIT {
 
     private static final String MYSQL_DOCKER_IMAGE = "mysql:8.0.43";
@@ -66,6 +71,14 @@ class XaGroupOpsImplIT {
         mc =
                 new MySQLContainer<>(DockerImageName.parse(MYSQL_DOCKER_IMAGE))
                         .withUsername("root")
+                        // AbstractJdbcIT#tearDown removes the database image 
from the Docker
+                        // daemon after every JDBC IT (JdbcMysqlIT uses this 
same image and runs
+                        // in the same surefire JVM right before this class). 
Testcontainers
+                        // caches "image is present" per JVM, so the default 
pull policy skips
+                        // the pull and container creation fails with
+                        // "No such image: mysql:8.0.43". Always pull so the 
image is re-fetched
+                        // whenever a previous test removed it.
+                        .withImagePullPolicy(PullPolicy.alwaysPull())
                         .withLogConsumer(
                                 new Slf4jLogConsumer(
                                         
DockerLoggerFactory.getLogger(MYSQL_DOCKER_IMAGE)));
@@ -114,6 +127,33 @@ class XaGroupOpsImplIT {
         Assertions.assertTrue(checkPreparedXid(xid2));
     }
 
+    /**
+     * Verifies that a real MySQL XA commit failure bubbles up through the 
aggregated committer
+     * instead of being silently swallowed when the resource manager becomes 
unavailable.
+     */
+    @Test
+    void testCommitFailurePropagatesThroughAggregatedCommitter() throws 
Exception {
+        JobContext jobContext = new JobContext();
+        SinkWriter.Context writerContext = new DefaultSinkWriterContext(1, 1);
+        Xid preparedXid =
+                xidGenerator.generateXid(jobContext, writerContext, 
System.currentTimeMillis());
+
+        xaFacade.start(preparedXid);
+        xaFacade.endAndPrepare(preparedXid);
+        Assertions.assertTrue(checkPreparedXid(preparedXid));
+
+        JdbcSinkAggregatedCommitter committer = 
createCommitterWithCurrentXaFacade();
+
+        mc.stop();
+
+        JdbcAggregatedCommitInfo commitInfo =
+                new JdbcAggregatedCommitInfo(
+                        Collections.singletonList(new XidInfo(preparedXid, 
0)));
+        Assertions.assertThrows(
+                JdbcConnectorException.class,
+                () -> committer.commit(Collections.singletonList(commitInfo)));
+    }
+
     private boolean checkPreparedXid(Xid xidCrr) throws XAException {
         Xid[] recover = xaResource.recover(TMSTARTRSCAN);
         for (Xid value : recover) {
@@ -129,8 +169,35 @@ class XaGroupOpsImplIT {
         return false;
     }
 
+    /**
+     * Reuses the prepared, already-open XA facade so the test can verify 
failure propagation on the
+     * exact commit path that handles checkpoint-owned prepared transactions.
+     */
+    private JdbcSinkAggregatedCommitter createCommitterWithCurrentXaFacade() 
throws Exception {
+        JdbcSinkConfig sinkConfig =
+                
JdbcSinkConfig.builder().jdbcConnectionConfig(jdbcConnectionConfig).build();
+        JdbcSinkAggregatedCommitter committer = new 
JdbcSinkAggregatedCommitter(sinkConfig);
+        setPrivateField(committer, "xaFacade", xaFacade);
+        setPrivateField(committer, "xaGroupOps", new XaGroupOpsImpl(xaFacade));
+        return committer;
+    }
+
+    /** Injects an integration-test collaborator without widening the 
production constructor API. */
+    private void setPrivateField(Object target, String fieldName, Object 
value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
     @AfterEach
-    public void closePostgreSqlContainer() {
+    public void closeResources() {
+        if (xaFacade != null && xaFacade.isOpen()) {
+            try {
+                xaFacade.close();
+            } catch (Exception e) {
+                log.warn("Unable to close XA facade after test", e);
+            }
+        }
         if (mc != null) {
             mc.stop();
         }

Reply via email to