This is an automated email from the ASF dual-hosted git repository.
thunguo pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-seata-go.git
The following commit(s) were added to refs/heads/master by this push:
new 9992f1b7 feat: XA branch enrollment for autoCommit statements" (#1137)
9992f1b7 is described below
commit 9992f1b7ae8fafb8fd6e8850e6977c0545c28c97
Author: Larry <[email protected]>
AuthorDate: Thu Aug 6 17:42:21 2026 +0800
feat: XA branch enrollment for autoCommit statements" (#1137)
* feat: support XA branch reuse in autoCommit mode
Fix #904: busy buffer error when a SELECT ... FOR UPDATE is followed by
an UPDATE in XA mode with autoCommit=true.
When autoCommit=true, allow multiple SQL statements to reuse the same XA
branch instead of opening a new branch for every statement.
- Add IsAutoCommitXABranch to distinguish autoCommit-originated XA branches
- Introduce a DBResource-scoped XA branch registry for lifecycle and reuse
- Track a rollback flag to avoid duplicate XA branch rollback
- Skip undo log generation and AT-specific hooks for XA mode in
BuildExecutor
- Handle nil target in Tx.Rollback for non-XA modes
- Add XA mode test cases for BuildExecutor
* refactor: make each autoCommit statement a complete XA branch
Address review comments on the autoCommit XA path. The previous branch-reuse
machinery left the XA branch never actually started in autoCommit mode
(XA START was skipped in BeginTx and never issued later), so no data was
enrolled and phase-2 commit ran against an unprepared branch.
Instead, drive a full per-statement XA lifecycle (START -> exec -> END ->
PREPARE -> report) for each autoCommit statement, matching the connection
pool's ResetSession semantics where every checkout is a fresh branch.
- BeginTx: always XA START and set xaActive, for both explicit and
autoCommit transactions.
- createNewTxOnExecIfNeed: drop the GetXABranch/RegisterXABranch reuse and
cross-connection delegation; keep ErrSkip handling and the
xaActive-guarded
defer rollback.
- createOnceTxContext: restore GlobalLockRequire instead of the reuse flag.
- start/Commit/Rollback: remove the autoCommit branch-reuse special cases.
- Delete the unused xaRegistry and the DBResource.xaConnsByXID map plus its
Register/Get/UnregisterXABranch helpers (potential leak on abnormal exit).
- Remove TransactionContext.IsAutoCommitXABranch.
* fix: report phase-1 failure to TC when an autoCommit XA statement fails
On a real SQL error (non-ErrSkip), roll back through XATx.Rollback when a
branch was started, so the already-registered branch reports phase-1 failure
to the TC, instead of only doing a local XA END(FAIL) + ROLLBACK on the raw
connection. Fall back to the connection rollback for non-autoCommit paths.
Applied to both the exec error path and the defer safety net.
* fix: harden XA autoCommit executor routing, ErrSkip cleanup, and drop
dead undo-hook code
- executor: route XA mode to a pass-through executor before the parser
fallback so unparseable SQL is never sent to an AT executor
- conn_xa: on driver.ErrSkip after an XA branch is opened, roll the branch
back and return a real error instead of letting database/sql fall back to
non-XA execution (prevents branch leak and writes escaping the global tx)
- undo_log_hook: remove the unreachable XA skip; the hook is never
registered
(SQLTypeUnknown is dropped by RegisterHook), so the code was dead
* test: stabilize mysql trigger tests via injection seams
The trigger tests patched getColumnMetas/getIndexes with gomonkey
ApplyPrivateMethod, which rewrites machine code and silently fails to
apply once the compiler inlines those methods on a default build,
making the tests flaky (pass under -race / -gcflags=all=-l, fail
otherwise).
Switch to the getColumnMetasFn / getIndexesFn injection seams already
present on mysqlTrigger: the stubs now set those function fields
directly instead of monkey-patching, and the gomonkey dependency is
dropped from the test. Verified with default go test, -race and
-shuffle=on.
* fix: preallocate NamedValue slice with zero length in ExecWithValue
The pass-through branch built the converted args with
make([]driver.NamedValue, len(execCtx.Values)) and then appended,
producing a slice of double length whose leading half was empty
NamedValue{} elements with zero Ordinals. Any driver consuming those
args saw phantom leading parameters.
Use make(..., 0, len(...)) so the appended values are the only
elements, with 0-based Ordinals. The executor test now asserts the
exact converted []driver.NamedValue (length and every element),
covers the empty-values case, and the e.ex != nil delegation path.
* fix: harden XA autoCommit branch lifecycle
Three related fixes found while reviewing the autoCommit branch-reuse
path, all on the XA connection's per-statement branch lifecycle:
* Reuse: XAConn.Commit's phase-1 success path never cleared xaActive
(only the rollback/cleanup path did), and ResetSession lives on the
embedded *Conn so it could not reach the flag either. A second
autoCommit statement on the same pooled connection then tripped
BeginTx's "xa branch is active" guard and failed. Clear xaActive
after phase-1 (keeping prepareTime and the held xaBranchXid for
phase-2) and override XAConn.ResetSession as a backstop.
* Deferred query commit: committing the branch (XA END + XA PREPARE)
inline while a query's rows are still open issues a command on top of
an unread result set, which go-sql-driver rejects as a busy buffer /
commands out of sync and database/sql surfaces as "bad connection"
(issue #904, e.g. SELECT ... FOR UPDATE). Defer the branch commit
until the rows are closed via RowsCommitOnClose, mirroring AT mode.
* PREPARED-state rollback: IsAlreadyEnded only matched the IDLE-state
XAER_RMFAIL message, so when a branch was already prepared (XA END +
XA PREPARE ran but the phase-1 report to the TC failed) Rollback
bailed out before XA ROLLBACK, leaving the branch holding locks.
Match the PREPARED-state message too so XA ROLLBACK still runs.
Adds regression tests for each: same-XAConn reuse across autoCommit
statements, deferred commit until rows close, and PREPARED-branch
rollback still issuing XA ROLLBACK.
* test: cover the #904 SELECT FOR UPDATE + UPDATE sequence
Add TestXAConn_AutoCommit_SelectForUpdateThenUpdate, an end-to-end
regression for the exact #904 scenario: under an autoCommit global
transaction a "SELECT ... FOR UPDATE" is followed by an "UPDATE" on the
same pooled connection. It drives query -> drain/close -> commit branch
1 -> ResetSession -> UPDATE as branch 2, and asserts each statement
forms its own complete XA branch (two XA END + XA PREPARE + phase-1
reports) with no busy-buffer error. Verified it fails without the
xaActive reset fix and passes with it.
Also document the ErrSkip tradeoff at its source: because the XA branch
is opened before the statement runs, a statement that needs the
Prepare+Exec fallback (answers driver.ErrSkip) hard-errors under XA
autoCommit instead of retrying. Note lazy branch-open as the follow-up
that would remove the tradeoff.
* docs: changelog for XA autoCommit branch enrollment and #904 fix
Correct the feature note to describe the implemented behavior (each
autoCommit statement is registered as its own complete XA branch; N
statements create N branches at the TC) and add the #904 bugfix entry
for the deferred branch commit that avoids the busy-buffer error. Mirror
in dev_zh.md.
* fix: run in-branch Prepare+Exec fallback for XA autoCommit ErrSkip
Under an XA autoCommit global transaction the branch is opened (XA START)
before the statement runs, so a driver.ErrSkip retry could not be handed
back to database/sql without leaking the branch and letting the write
escape the global transaction. driver.ErrSkip is the common case: with the
default go-sql-driver DSN (interpolateParams=false) the direct
Execer/Queryer answers ErrSkip for any statement carrying bind parameters.
Instead of hard-erroring, createNewTxOnExecIfNeed now runs the Prepare+Exec
fallback itself on the same physical connection (still holding XA START)
via execPreparedInBranch / queryPreparedInBranch, so the retried statement
stays inside the branch and the normal XA END + XA PREPARE commit applies
unchanged. The query variant wraps the driver rows in rowsWithStmt so the
prepared statement outlives the result set (composing with
RowsCommitOnClose). Mirrors AT mode's existing prepared-statement fallback.
* test: cover query-path ErrSkip fallback and in-branch fallback error
rollback
Add two XA autoCommit cases around the in-branch ErrSkip fallback:
- ParameterizedSelectForUpdateErrSkipDefersBranchCommit: the real #904
shape,
a parameterized `SELECT ... FOR UPDATE WHERE id = ?` whose direct Queryer
answers driver.ErrSkip, exercises queryPreparedInBranch AND the
busy-buffer
guard together - the fallback rows are still wrapped in RowsCommitOnClose
so
XA END + XA PREPARE are deferred until the rows close, then fire exactly
once.
- InBranchFallbackErrorRollsBackBranch: when the in-branch Prepare+Exec
fallback
fails with a real (non-ErrSkip) error, the branch rolls back, reports
phase-1
FAILED to the TC, and surfaces the concrete error (never raw ErrSkip).
Adds test seams simulateQueryContextError and simulatePreparedExecError,
and a
query field on fakePreparedStmt so the prepared exec can be targeted by
query.
* fix: guard nil xaBranchXid in XAConn keep/release to prevent sweep panic
The two-phase timeout checker force-closes committed XA connections after
the hold time elapses. CloseForce -> cleanXABranchContext nils xaBranchXid
once the branch is no longer kept, then calls releaseIfNecessary, which
dereferenced the now-nil *XABranchXid via String() and panicked (SIGSEGV).
Add nil-guards to keepIfNecessary and releaseIfNecessary so the background
sweep of a committed connection no longer crashes. Surfaced by the #904
GORM SELECT ... FOR UPDATE + UPDATE end-to-end run.
---------
Co-authored-by: Ethan <[email protected]>
---
changes/dev.md | 2 +
changes/dev_zh.md | 2 +
pkg/datasource/sql/conn_xa.go | 270 +++++++++--
pkg/datasource/sql/conn_xa_test.go | 524 ++++++++++++++++++++-
.../sql/datasource/mysql/trigger_test.go | 97 ++--
pkg/datasource/sql/db.go | 2 +-
pkg/datasource/sql/exec/executor.go | 24 +-
pkg/datasource/sql/exec/executor_test.go | 110 ++++-
pkg/datasource/sql/tx.go | 11 +
pkg/datasource/sql/types/types.go | 2 +-
pkg/datasource/sql/xa/mysql_xa_connection.go | 21 +-
11 files changed, 945 insertions(+), 120 deletions(-)
diff --git a/changes/dev.md b/changes/dev.md
index e837c1a0..bf2d9ab0 100755
--- a/changes/dev.md
+++ b/changes/dev.md
@@ -26,11 +26,13 @@
### feature:
- [[#123](https://github.com/apache/incubator-seata-go/pull/123)] add two
phase and dubbo
+ - support XA branch enrollment for autoCommit statements in a global
transaction: each autoCommit statement is registered and prepared as its own
complete XA branch (note: N autoCommit statements create N branches at the TC);
parameterized statements (which the default go-sql-driver DSN answers with
`driver.ErrSkip`) are executed via an in-branch Prepare+Exec fallback so they
stay inside the branch
- support PostgreSQL XA via pgx driver
- [[#1130](https://github.com/apache/incubator-seata-go/issues/1130)]
support MySQL multi-value INSERT in AT mode for composite and mixed primary keys
### bugfix:
+ - [[#904](https://github.com/apache/incubator-seata-go/issues/904)] fix
"busy buffer" / "driver: bad connection" when a `SELECT ... FOR UPDATE` is
followed by another statement under XA autoCommit, by deferring the branch
commit (XA END + XA PREPARE) until the query rows are closed
- [[#130](https://github.com/apache/incubator-seata-go/pull/130)] getty
session auto close bug
- [[#991](https://github.com/apache/incubator-seata-go/issues/991)] fix
connection leaks and prevent nil pointer panic in async worker
- [[#887](https://github.com/apache/incubator-seata-go/issues/887)] make
DayValue serialization timezone-stable
diff --git a/changes/dev_zh.md b/changes/dev_zh.md
index 53615803..23ff3746 100644
--- a/changes/dev_zh.md
+++ b/changes/dev_zh.md
@@ -27,11 +27,13 @@ Seata-go 是一款开源的分布式事务解决方案,提供高性能和简
### feature:
- [[#123](https://github.com/apache/incubator-seata-go/pull/123)]
添加二阶段事务接口,以及dubbo集成
+- 支持全局事务中 autoCommit 语句的 XA 分支注册:每条 autoCommit 语句都作为一个完整的 XA 分支单独注册并
prepare(注意:N 条 autoCommit 语句会在 TC 侧产生 N 个分支);带参数的语句(默认 go-sql-driver DSN 会返回
`driver.ErrSkip`)通过分支内 Prepare+Exec 回退执行,从而保证仍在分支内完成
- 支持基于 pgx 驱动的 PostgreSQL XA
- [[#1130](https://github.com/apache/incubator-seata-go/issues/1130)] 支持 AT
模式下 MySQL 多值 INSERT 的复合主键与混合主键场景
### bugfix:
+- [[#904](https://github.com/apache/incubator-seata-go/issues/904)] 修复 XA
autoCommit 下 `SELECT ... FOR UPDATE` 后紧接其他语句导致的 "busy buffer" / "driver: bad
connection":将分支提交(XA END + XA PREPARE)延迟到查询结果集关闭之后再执行
- [[#130](https://github.com/apache/incubator-seata-go/pull/130)] 修复getty
session自动关闭的bug
### optimize:
diff --git a/pkg/datasource/sql/conn_xa.go b/pkg/datasource/sql/conn_xa.go
index 857e885b..a7c73cf6 100644
--- a/pkg/datasource/sql/conn_xa.go
+++ b/pkg/datasource/sql/conn_xa.go
@@ -90,7 +90,7 @@ func (c *XAConn) QueryContext(ctx context.Context, query
string, args []driver.N
}()
}
- ret, err := c.createNewTxOnExecIfNeed(ctx, func() (types.ExecResult,
error) {
+ ret, err := c.createNewTxOnExecIfNeed(ctx, true, query, args, func()
(types.ExecResult, error) {
ret, err := c.Conn.QueryContext(ctx, query, args)
if err != nil {
return nil, err
@@ -110,7 +110,7 @@ func (c *XAConn) ExecContext(ctx context.Context, query
string, args []driver.Na
}()
}
- ret, err := c.createNewTxOnExecIfNeed(ctx, func() (types.ExecResult,
error) {
+ ret, err := c.createNewTxOnExecIfNeed(ctx, false, query, args, func()
(types.ExecResult, error) {
ret, err := c.Conn.ExecContext(ctx, query, args)
if err != nil {
return nil, err
@@ -156,33 +156,31 @@ func (c *XAConn) BeginTx(ctx context.Context, opts
driver.TxOptions) (driver.Tx,
return nil, err
}
- if !c.autoCommit {
- if c.xaActive {
- return nil, errors.New("should NEVER happen:
setAutoCommit from true to false while xa branch is active")
- }
+ if c.xaActive {
+ return nil, errors.New("should NEVER happen: setAutoCommit from
true to false while xa branch is active")
+ }
- baseTx, ok := tx.(*Tx)
- if !ok {
- return nil, fmt.Errorf("start xa %s transaction failure
for the tx is a wrong type", c.txCtx.XID)
- }
+ baseTx, ok := tx.(*Tx)
+ if !ok {
+ return nil, fmt.Errorf("start xa %s transaction failure for the
tx is a wrong type", c.txCtx.XID)
+ }
- baseTx.xaConn = c
+ baseTx.xaConn = c
- c.branchRegisterTime = time.Now()
- if err := baseTx.register(c.txCtx); err != nil {
- c.cleanXABranchContext()
- return nil, fmt.Errorf("failed to register xa branch
%s, err:%w", c.txCtx.XID, err)
- }
+ c.branchRegisterTime = time.Now()
+ if err := baseTx.register(c.txCtx); err != nil {
+ c.cleanXABranchContext()
+ return nil, fmt.Errorf("failed to register xa branch %s,
err:%w", c.txCtx.XID, err)
+ }
- c.xaBranchXid = XaIdBuild(c.txCtx.XID, c.txCtx.BranchID)
- c.keepIfNecessary()
+ c.xaBranchXid = XaIdBuild(c.txCtx.XID, c.txCtx.BranchID)
+ c.keepIfNecessary()
- if err = c.start(ctx); err != nil {
- c.cleanXABranchContext()
- return nil, fmt.Errorf("failed to start xa branch
xid:%s err:%w", c.txCtx.XID, err)
- }
- c.xaActive = true
+ if err = c.start(ctx); err != nil {
+ c.cleanXABranchContext()
+ return nil, fmt.Errorf("failed to start xa branch xid:%s
err:%w", c.txCtx.XID, err)
}
+ c.xaActive = true
return &XATx{tx: tx.(*Tx)}, nil
}
@@ -202,31 +200,40 @@ func (c *XAConn) createOnceTxContext(ctx context.Context)
bool {
return onceTx
}
-func (c *XAConn) createNewTxOnExecIfNeed(ctx context.Context, f func()
(types.ExecResult, error)) (types.ExecResult, error) {
+func (c *XAConn) createNewTxOnExecIfNeed(ctx context.Context, isQuery bool,
query string, args []driver.NamedValue, f func() (types.ExecResult, error))
(types.ExecResult, error) {
var (
- tx driver.Tx
- err error
+ tx driver.Tx
+ err error
+ xaRollbacked bool // Track if XA rollback was already done to
avoid duplicate rollback
)
defer func() {
recoverErr := recover()
- if recoverErr != nil {
- log.Errorf("conn xa rollback recoverErr:%v", recoverErr)
- if tx != nil {
- if rollbackErr := tx.Rollback(); rollbackErr !=
nil {
- log.Errorf("conn xa rollback error:%v",
rollbackErr)
- }
- return
- }
- if c.tx != nil {
- if rollbackErr := c.Rollback(ctx); rollbackErr
!= nil {
- log.Errorf("conn xa rollback error:%v",
rollbackErr)
+ // Check if error is ErrSkip - don't rollback for this special
error
+ isErrSkip := err != nil && errors.Is(err, driver.ErrSkip)
+
+ if (err != nil && !isErrSkip) || recoverErr != nil {
+ // Prefer XATx.Rollback so a registered branch reports
phase-1 failure to
+ // the TC; fall back to the raw connection rollback for
non-autoCommit paths.
+ if !xaRollbacked {
+ if tx != nil {
+ if rollbackErr := tx.Rollback();
rollbackErr != nil {
+ log.Errorf("defer rollback xa
branch error:%v", rollbackErr)
+ }
+ xaRollbacked = true
+ } else if c.xaActive {
+ if rollbackErr := c.Rollback(ctx);
rollbackErr != nil {
+ log.Errorf("defer rollback xa
branch error:%v", rollbackErr)
+ }
+ xaRollbacked = true
}
}
}
}()
currentAutoCommit := c.autoCommit
+
+ // For global transactions in autoCommit mode, each statement is a
complete XA branch
if c.txCtx.TransactionMode != types.Local && tm.IsGlobalTx(ctx) &&
c.autoCommit {
tx, err = c.BeginTx(ctx, driver.TxOptions{Isolation:
driver.IsolationLevel(gosql.LevelDefault)})
if err != nil {
@@ -236,23 +243,77 @@ func (c *XAConn) createNewTxOnExecIfNeed(ctx
context.Context, f func() (types.Ex
// execute SQL
ret, err := f()
+ if err != nil && errors.Is(err, driver.ErrSkip) {
+ // driver.ErrSkip is not a real failure: with the default
go-sql-driver DSN
+ // (interpolateParams=false) the direct Execer/Queryer answers
ErrSkip for any
+ // statement carrying bind parameters, asking database/sql to
retry it through
+ // the Prepare+Exec fallback path.
+ if tx == nil {
+ // No XA branch opened for this statement - safe to
hand the retry back to
+ // database/sql and let it run its own Prepare+Exec
fallback.
+ return nil, err
+ }
+ // We already opened an XA branch (XA START) for this
statement. We cannot hand
+ // the retry back to database/sql: it would run on a
*different* pooled
+ // connection (autoCommit is now false and txCtx has been
reset), leaving this
+ // branch registered-but-never-prepared (a leak) and letting
the retried write
+ // escape the global transaction. Instead run the Prepare+Exec
fallback
+ // OURSELVES on this same physical connection, which still
holds XA START open,
+ // so the retried statement stays inside the branch and the
normal XA END +
+ // XA PREPARE commit below applies unchanged. These helpers
never return ErrSkip.
+ if isQuery {
+ ret, err = c.queryPreparedInBranch(ctx, query, args)
+ } else {
+ ret, err = c.execPreparedInBranch(ctx, query, args)
+ }
+ }
if err != nil {
+ // On real error, rollback the entire branch. Prefer
XATx.Rollback so the
+ // already-registered branch reports phase-1 failure to the TC;
fall back to
+ // the raw connection rollback for non-autoCommit paths.
if tx != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Errorf("failed to rollback xa branch of
:%s, err:%v", c.txCtx.XID, rollbackErr)
}
- } else {
+ } else if c.xaActive {
if rollbackErr := c.Rollback(ctx); rollbackErr != nil {
log.Errorf("failed to rollback xa branch of
:%s, err:%v", c.txCtx.XID, rollbackErr)
}
}
+ xaRollbacked = true // Mark that rollback was handled
return nil, err
}
+ // For autoCommit mode with global transaction, commit the branch now:
+ // XA END + XA PREPARE + report phase-1 success to TC.
if tx != nil && currentAutoCommit {
- // Commit through XATx so phase-one reporting stays coupled to
driver.Tx lifecycle.
+ // A query statement returns an open result set that still
occupies this
+ // connection's read buffer. Running XA END + XA PREPARE here -
before the
+ // caller has drained/closed the rows - issues a new command on
top of that
+ // unread result set, which go-sql-driver rejects as a "busy
buffer" /
+ // "commands out of sync" error and database/sql surfaces as
+ // "driver: bad connection", forcing the whole transaction to
roll back
+ // (issue #904, e.g. SELECT ... FOR UPDATE followed by UPDATE).
Defer the
+ // branch commit until the caller closes the rows, mirroring AT
mode's
+ // RowsCommitOnClose handling. xaDeferredCommitTx keeps the
inline path's
+ // rollback-on-commit-failure semantics so a failed deferred
commit never
+ // leaves a prepared branch holding locks. (GetRows must only
be called on
+ // a query result - it panics on a write result - hence the
isQuery gate.)
+ if isQuery {
+ if dr := ret.GetRows(); dr != nil {
+ return
types.NewResult(types.WithRows(&RowsCommitOnClose{
+ rows: dr,
+ tx: xaDeferredCommitTx{tx: tx},
+ })), nil
+ }
+ }
if err = tx.Commit(); err != nil {
- log.Errorf("xa connection proxy commit failure xid:%s,
err:%v", c.txCtx.XID, err)
+ log.Errorf("xa transaction commit failure xid:%s,
err:%v", c.txCtx.XID, err)
+ // XA End & Rollback
+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
+ log.Errorf("xa transaction rollback failure
xid:%s, err:%v", c.txCtx.XID, rollbackErr)
+ }
+ xaRollbacked = true
return nil, err
}
}
@@ -260,7 +321,104 @@ func (c *XAConn) createNewTxOnExecIfNeed(ctx
context.Context, f func() (types.Ex
return ret, nil
}
+// execPreparedInBranch runs an ExecContext statement through the driver's
+// Prepare+Exec path on the XAConn's own physical connection, which is still
inside
+// the open XA branch (XA START has been issued and not yet ended). It exists
so a
+// statement that answers driver.ErrSkip on the direct Execer path - the
default
+// go-sql-driver behavior for parameterized statements - can still be executed
+// without handing the retry back to database/sql, which would run it on a
different
+// connection outside the branch. Unlike the direct path this never returns
+// driver.ErrSkip: it either produces a concrete result or a concrete error.
+func (c *XAConn) execPreparedInBranch(ctx context.Context, query string, args
[]driver.NamedValue) (types.ExecResult, error) {
+ preparer, ok := c.Conn.targetConn.(driver.ConnPrepareContext)
+ if !ok {
+ return nil, fmt.Errorf("xa branch %s: driver connection does
not support PrepareContext, cannot recover from ErrSkip", c.txCtx.XID)
+ }
+ stmt, err := preparer.PrepareContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer stmt.Close()
+
+ execer, ok := stmt.(driver.StmtExecContext)
+ if !ok {
+ return nil, fmt.Errorf("xa branch %s: prepared statement does
not support ExecContext, cannot recover from ErrSkip", c.txCtx.XID)
+ }
+ res, err := execer.ExecContext(ctx, args)
+ if err != nil {
+ return nil, err
+ }
+ return types.NewResult(types.WithResult(res)), nil
+}
+
+// queryPreparedInBranch is the QueryContext counterpart of
execPreparedInBranch.
+// The prepared statement must outlive the result set, so it is wrapped in
+// rowsWithStmt, which closes the statement when the rows are closed (this
composes
+// with RowsCommitOnClose: draining the rows closes both the driver rows and
the
+// statement and then runs the deferred XA END + XA PREPARE).
+func (c *XAConn) queryPreparedInBranch(ctx context.Context, query string, args
[]driver.NamedValue) (types.ExecResult, error) {
+ preparer, ok := c.Conn.targetConn.(driver.ConnPrepareContext)
+ if !ok {
+ return nil, fmt.Errorf("xa branch %s: driver connection does
not support PrepareContext, cannot recover from ErrSkip", c.txCtx.XID)
+ }
+ stmt, err := preparer.PrepareContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ queryer, ok := stmt.(driver.StmtQueryContext)
+ if !ok {
+ _ = stmt.Close()
+ return nil, fmt.Errorf("xa branch %s: prepared statement does
not support QueryContext, cannot recover from ErrSkip", c.txCtx.XID)
+ }
+ rows, err := queryer.QueryContext(ctx, args)
+ if err != nil {
+ _ = stmt.Close()
+ return nil, err
+ }
+ return types.NewResult(types.WithRows(&rowsWithStmt{Rows: rows, stmt:
stmt})), nil
+}
+
+// xaDeferredCommitTx wraps an XA branch tx whose commit is deferred until the
+// query's rows are closed (see createNewTxOnExecIfNeed / RowsCommitOnClose).
+// It mirrors the inline exec path: if the deferred XA END + XA PREPARE (or the
+// phase-1 report to the TC) fails, the branch is rolled back so it does not
stay
+// prepared and hold locks.
+type xaDeferredCommitTx struct {
+ tx driver.Tx
+}
+
+func (t xaDeferredCommitTx) Commit() error {
+ if err := t.tx.Commit(); err != nil {
+ log.Errorf("deferred xa branch commit failed, rolling back
branch: %v", err)
+ if rollbackErr := t.tx.Rollback(); rollbackErr != nil {
+ log.Errorf("deferred xa branch rollback failed: %v",
rollbackErr)
+ }
+ return err
+ }
+ return nil
+}
+
+func (t xaDeferredCommitTx) Rollback() error {
+ return t.tx.Rollback()
+}
+
+// ResetSession is called by database/sql before reusing a pooled connection.
+// XAConn.xaActive lives on the XA wrapper, so the embedded *Conn.ResetSession
+// cannot clear it; without this override a connection whose previous
autoCommit
+// branch already completed phase-1 would keep xaActive=true and the next
+// statement's BeginTx would fail the "xa branch is active" guard. Clearing it
+// here (in addition to XAConn.Commit) is a defensive backstop for any path
that
+// leaves a stale flag. xaBranchXid is intentionally left untouched so a held
+// branch remains available for phase-2.
+func (c *XAConn) ResetSession(ctx context.Context) error {
+ c.xaActive = false
+ return c.Conn.ResetSession(ctx)
+}
+
func (c *XAConn) keepIfNecessary() {
+ if c.xaBranchXid == nil {
+ return
+ }
if c.ShouldBeHeld() {
if err := c.res.Hold(c.xaBranchXid.String(), c); err == nil {
c.isConnKept = true
@@ -269,6 +427,14 @@ func (c *XAConn) keepIfNecessary() {
}
func (c *XAConn) releaseIfNecessary() {
+ // cleanXABranchContext nils xaBranchXid once a branch is no longer
kept, and
+ // the two-phase timeout checker force-closes committed connections
after the
+ // hold time elapses. Guard against the nil branch xid so that sweep
(which
+ // calls CloseForce -> cleanXABranchContext -> releaseIfNecessary) does
not
+ // dereference a nil *XABranchXid via String().
+ if c.xaBranchXid == nil {
+ return
+ }
if c.ShouldBeHeld() && c.xaBranchXid.String() != "" {
if c.isConnKept {
c.res.Release(c.xaBranchXid.String())
@@ -294,7 +460,7 @@ func (c *XAConn) start(ctx context.Context) error {
c.XaRollback(ctx, c.xaBranchXid)
return err
}
- return err
+ return nil
}
func (c *XAConn) end(ctx context.Context, flags int) error {
@@ -341,10 +507,9 @@ func (c *XAConn) Rollback(ctx context.Context) error {
// First end the XA branch with TMFail
if err := c.xaResource.End(ctx, c.xaBranchXid.String(),
xa.TMFail); err != nil {
// Handle XAER_RMFAIL exception - check if it's already
ended
- //expected error: Error 1399 (XAE07): XAER_RMFAIL: The
command cannot be executed when global transaction is in the IDLE state
if c.xaErrorClassifier.IsAlreadyEnded(err) {
- // If already ended, continue with rollback
log.Infof("XA branch already ended, continuing
with rollback for xid: %s", c.txCtx.XID)
+ // Already ended, continue with rollback
} else {
return c.rollbackErrorHandle()
}
@@ -358,6 +523,7 @@ func (c *XAConn) Rollback(ctx context.Context) error {
c.rollBacked = true
}
c.cleanXABranchContext()
+
return nil
}
@@ -375,6 +541,7 @@ func (c *XAConn) Commit(ctx context.Context) error {
}
now := time.Now()
+
if c.end(ctx, xa.TMSuccess) != nil {
return c.commitErrorHandle(ctx)
}
@@ -388,6 +555,16 @@ func (c *XAConn) Commit(ctx context.Context) error {
}
c.prepareTime = time.Now()
+
+ // Phase-1 is done: this session no longer has an in-flight XA branch.
Clear
+ // only the session-active flag so a subsequent autoCommit statement on
the
+ // same (possibly pooled/reused) connection can open a fresh branch
instead of
+ // tripping the "xa branch is active" guard in BeginTx. The branch
itself is
+ // still prepared and, when held, retrievable for phase-2 via
xaBranchXid, so
+ // we must NOT call cleanXABranchContext here (that would reset
prepareTime and
+ // drop xaBranchXid). Phase-2 XaCommit/XaRollback do not depend on
xaActive.
+ c.xaActive = false
+
return nil
}
@@ -418,10 +595,11 @@ func (c *XAConn) Close() error {
return nil
}
c.cleanXABranchContext()
- if err := c.Conn.Close(); err != nil {
- return err
+ // Check if Conn is nil before calling Close
+ if c.Conn == nil {
+ return nil
}
- return nil
+ return c.Conn.Close()
}
func (c *XAConn) CloseForce() error {
diff --git a/pkg/datasource/sql/conn_xa_test.go
b/pkg/datasource/sql/conn_xa_test.go
index 46fc412c..4174b5a0 100644
--- a/pkg/datasource/sql/conn_xa_test.go
+++ b/pkg/datasource/sql/conn_xa_test.go
@@ -21,6 +21,7 @@ import (
"context"
"database/sql"
"database/sql/driver"
+ "errors"
"io"
"strings"
"sync/atomic"
@@ -53,8 +54,7 @@ func (m *mysqlMockRows) Columns() []string {
}
func (m *mysqlMockRows) Close() error {
- //TODO implement me
- panic("implement me")
+ return nil
}
func (m *mysqlMockRows) Next(dest []driver.Value) error {
@@ -104,9 +104,60 @@ func (mi *mockSQLInterceptor) After(ctx context.Context,
execCtx *types.ExecCont
}
// simulateExecContextError allows tests to inject driver errors for certain
SQL strings.
-// When set, baseMockConn will call this hook for each ExecContext.
+// When set, baseMockConn will call this hook for each direct ExecContext.
var simulateExecContextError func(query string) error
+// simulateQueryContextError injects driver errors for certain SQL strings on
the
+// direct QueryContext path (e.g. returning driver.ErrSkip for a parameterized
+// SELECT, as the default go-sql-driver DSN does). When set, baseMockConn
calls it
+// for each direct QueryContext.
+var simulateQueryContextError func(query string) error
+
+// simulatePreparedExecError injects an error from the prepared statement's
+// ExecContext, keyed by the query it was prepared with. It lets tests drive
the
+// case where the in-branch Prepare+Exec fallback itself fails with a real (non
+// ErrSkip) error, so the branch must roll back and report phase-1 failure.
+var simulatePreparedExecError func(query string) error
+
+// fakePreparedStmt models a driver prepared statement. It is what the driver
+// returns from PrepareContext, and its ExecContext/QueryContext succeed by
default -
+// mirroring the real go-sql-driver, where the direct Execer answers
driver.ErrSkip
+// for parameterized statements but the Prepare+Exec path executes fine. This
lets
+// tests exercise XAConn's in-branch ErrSkip fallback (execPreparedInBranch /
+// queryPreparedInBranch). simulatePreparedExecError can force the prepared
exec to
+// fail for the fallback-error rollback path.
+type fakePreparedStmt struct {
+ query string
+}
+
+func (s *fakePreparedStmt) Close() error { return nil }
+func (s *fakePreparedStmt) NumInput() int { return -1 }
+
+func (s *fakePreparedStmt) Exec(args []driver.Value) (driver.Result, error) {
+ return &driver.ResultNoRows, nil
+}
+
+func (s *fakePreparedStmt) Query(args []driver.Value) (driver.Rows, error) {
+ rows := &mysqlMockRows{}
+ rows.data = [][]interface{}{{"8.0.29"}}
+ return rows, nil
+}
+
+func (s *fakePreparedStmt) ExecContext(ctx context.Context, args
[]driver.NamedValue) (driver.Result, error) {
+ if simulatePreparedExecError != nil {
+ if err := simulatePreparedExecError(s.query); err != nil {
+ return nil, err
+ }
+ }
+ return &driver.ResultNoRows, nil
+}
+
+func (s *fakePreparedStmt) QueryContext(ctx context.Context, args
[]driver.NamedValue) (driver.Rows, error) {
+ rows := &mysqlMockRows{}
+ rows.data = [][]interface{}{{"8.0.29"}}
+ return rows, nil
+}
+
func baseMockConn(mockConn *mock.MockTestDriverConn) {
branchStatusCache = gcache.New(1024).LRU().Expiration(time.Minute *
10).Build()
@@ -124,8 +175,22 @@ func baseMockConn(mockConn *mock.MockTestDriverConn) {
mockConn.EXPECT().ResetSession(gomock.Any()).AnyTimes().Return(nil)
mockConn.EXPECT().Close().AnyTimes().Return(nil)
+ // The Prepare+Exec fallback path (used when the direct ExecContext
answers
+ // driver.ErrSkip) prepares on the same physical connection and runs the
+ // statement through the prepared stmt, which succeeds by default. The
prepared
+ // stmt keeps the query so simulatePreparedExecError can target it.
+ mockConn.EXPECT().PrepareContext(gomock.Any(),
gomock.Any()).AnyTimes().DoAndReturn(
+ func(ctx context.Context, query string) (driver.Stmt, error) {
+ return &fakePreparedStmt{query: query}, nil
+ })
+
mockConn.EXPECT().QueryContext(gomock.Any(), gomock.Any(),
gomock.Any()).AnyTimes().DoAndReturn(
func(ctx context.Context, query string, args
[]driver.NamedValue) (driver.Rows, error) {
+ if simulateQueryContextError != nil {
+ if err := simulateQueryContextError(query); err
!= nil {
+ return nil, err
+ }
+ }
rows := &mysqlMockRows{}
rows.data = [][]interface{}{
{"8.0.29"},
@@ -376,6 +441,14 @@ func TestXAConn_Rollback_XAER_RMFAIL(t *testing.T) {
},
want: true,
},
+ {
+ name: "matching XAER_RMFAIL error with PREPARED state",
+ err: &mysql.MySQLError{
+ Number: 1399,
+ Message: "Error 1399 (XAE07): XAER_RMFAIL: The
command cannot be executed when global transaction is in the PREPARED state",
+ },
+ want: true,
+ },
{
name: "matching XAER_RMFAIL error with already ended",
err: &mysql.MySQLError{
@@ -447,6 +520,51 @@ func TestXAConn_Rollback_HandleXAERRMFAILAlreadyEnded(t
*testing.T) {
}
}
+// Reproduces the review scenario where the branch is already PREPARED when
Rollback runs:
+// during autoCommit Commit the DB executed XA END + XA PREPARE, but the
phase-1 report to
+// the TC failed, so the branch is left in the PREPARED state. The follow-up
rollback issues
+// XA END(TMFAIL), which MySQL rejects with XAER_RMFAIL "...PREPARED state".
Before the fix
+// IsAlreadyEnded only recognized the IDLE-state message, so Rollback bailed
out via
+// rollbackErrorHandle() BEFORE running XA ROLLBACK, leaving the branch
holding locks forever.
+// This asserts XA ROLLBACK is still issued so the prepared branch releases
its locks.
+func TestXAConn_Rollback_PreparedBranchStillRollsBack(t *testing.T) {
+ ctrl, db, _, _ := initXAConnTestResource(t)
+ defer func() {
+ simulateExecContextError = nil
+ db.Close()
+ ctrl.Finish()
+ CleanTxHooks()
+ }()
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.New().String())
+
+ var rollbackSeen int32
+ // Inject: XA END returns XAER_RMFAIL with the PREPARED-state message;
user SQL fails to
+ // trigger the rollback path; record whether XA ROLLBACK is
subsequently issued.
+ simulateExecContextError = func(query string) error {
+ upper := strings.ToUpper(strings.TrimSpace(query))
+ switch {
+ case strings.HasPrefix(upper, "XA END"):
+ return &mysql.MySQLError{
+ Number: types.ErrCodeXAER_RMFAIL_IDLE,
+ Message: "Error 1399 (XAE07): XAER_RMFAIL: The
command cannot be executed when global transaction is in the PREPARED state",
+ }
+ case strings.HasPrefix(upper, "XA ROLLBACK"):
+ atomic.StoreInt32(&rollbackSeen, 1)
+ return nil
+ case !strings.HasPrefix(upper, "XA "):
+ return io.EOF
+ }
+ return nil
+ }
+
+ _, err := db.ExecContext(ctx, "UPDATE user SET age = 1 WHERE id = 1")
+ assert.Error(t, err, "expected error to trigger rollback path")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&rollbackSeen),
+ "XA ROLLBACK must run so a PREPARED branch releases its locks")
+}
+
func TestXAConn_ExecContext_AutoCommitReportsPhaseOneDone(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -479,6 +597,406 @@ func
TestXAConn_ExecContext_AutoCommitReportsPhaseOneDone(t *testing.T) {
assert.Equal(t, int32(1), atomic.LoadInt32(&commitCnt))
}
+// Regression for the autoCommit branch-reuse bug: after a statement's XA
branch
+// completes phase-1 (XA END + XA PREPARE + report), the session must no
longer be
+// marked as having an active branch, otherwise the next autoCommit statement
on the
+// SAME physical connection (which database/sql reuses via the pool, calling
+// ResetSession in between) trips BeginTx's "should NEVER happen:
setAutoCommit from
+// true to false while xa branch is active" guard. Before the fix,
XAConn.Commit's
+// success path never cleared xaActive (only the rollback/cleanup path did) and
+// ResetSession - living on the embedded *Conn - could not reach it, so the
second
+// statement always failed.
+func TestXAConn_ExecContext_ReuseAfterAutoCommitBranch(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ CleanTxHooks()
+ defer CleanTxHooks()
+
+ // XAConn.Commit -> checkTimeout compares branchRegisterTime against
xaConnTimeout,
+ // which is 0 unless InitXA runs. Give the branch a real budget so
phase-1 prepares.
+ prevTimeout := xaConnTimeout
+ xaConnTimeout = time.Minute
+ defer func() { xaConnTimeout = prevTimeout }()
+
+ xaConn, mockMgr := newMockXAConn(t, ctrl, 123)
+ mockMgr.EXPECT().BranchReport(gomock.Any(),
gomock.Any()).AnyTimes().Return(nil)
+
+ var commitCnt int32
+ RegisterTxHook(&mockTxHook{
+ beforeCommit: func(tx *Tx) error {
+ atomic.AddInt32(&commitCnt, 1)
+ return nil
+ },
+ })
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.NewString())
+
+ // First autoCommit statement: opens and completes a full XA branch.
+ _, err := xaConn.ExecContext(ctx, "SELECT 1", nil)
+ assert.NoError(t, err)
+ // The Commit success path must clear the session-active flag on its
own, so the
+ // fix holds even for paths where database/sql does not call
ResetSession.
+ assert.False(t, xaConn.xaActive, "xaActive must be cleared after
phase-1 completes")
+
+ // Simulate database/sql returning the connection to the pool and
reusing it:
+ // ResetSession restores autoCommit=true (and, via the XAConn override,
clears the
+ // XA session flag as a backstop).
+ assert.NoError(t, xaConn.ResetSession(ctx))
+ assert.True(t, xaConn.autoCommit, "ResetSession must restore autoCommit
for pooled reuse")
+ assert.False(t, xaConn.xaActive, "ResetSession must leave no active XA
branch")
+
+ // Second autoCommit statement on the SAME XAConn must open a fresh
branch instead
+ // of failing the "xa branch is active" guard.
+ _, err = xaConn.ExecContext(ctx, "SELECT 2", nil)
+ assert.NoError(t, err, "second autoCommit statement on a reused XAConn
must succeed")
+
+ assert.Equal(t, int32(2), atomic.LoadInt32(&commitCnt))
+}
+
+// Reproduces the #904 "busy buffer" scenario on the query path: a SELECT ...
FOR
+// UPDATE opens a result set that still occupies the connection's read buffer.
If the
+// autoCommit branch were committed inline (XA END + XA PREPARE) while those
rows are
+// open, go-sql-driver would reject the new command with a "busy buffer" /
+// "commands out of sync" error surfacing as "driver: bad connection". This
asserts the
+// branch commit is deferred: XA END / XA PREPARE / the phase-1 report only
run once the
+// caller closes the rows, so the busy-buffer collision never happens.
+func TestXAConn_QueryContext_DefersBranchCommitUntilRowsClose(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ CleanTxHooks()
+ defer func() {
+ simulateExecContextError = nil
+ CleanTxHooks()
+ }()
+
+ // checkTimeout compares against xaConnTimeout, which is only set by
InitXA in a
+ // running server. Give the branch a real budget so the deferred commit
prepares
+ // instead of aborting as timed-out.
+ prevTimeout := xaConnTimeout
+ xaConnTimeout = time.Minute
+ defer func() { xaConnTimeout = prevTimeout }()
+
+ xaConn, mockMgr := newMockXAConn(t, ctrl, 123)
+
+ var reported int32
+ mockMgr.EXPECT().BranchReport(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(ctx context.Context, param rm.BranchReportParam) error {
+ assert.EqualValues(t, branch.BranchStatusPhaseoneDone,
param.Status)
+ atomic.StoreInt32(&reported, 1)
+ return nil
+ },
+ ).Times(1)
+
+ // Record when the branch-commit statements run on the physical
connection.
+ var endSeen, prepareSeen int32
+ simulateExecContextError = func(query string) error {
+ upper := strings.ToUpper(strings.TrimSpace(query))
+ switch {
+ case strings.HasPrefix(upper, "XA END"):
+ atomic.StoreInt32(&endSeen, 1)
+ case strings.HasPrefix(upper, "XA PREPARE"):
+ atomic.StoreInt32(&prepareSeen, 1)
+ }
+ return nil
+ }
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.NewString())
+
+ rows, err := xaConn.QueryContext(ctx, "SELECT * FROM user WHERE id = 1
FOR UPDATE", nil)
+ assert.NoError(t, err)
+
+ // While the result set is still open, the branch must NOT have been
committed -
+ // issuing XA END / XA PREPARE here is exactly the #904 busy-buffer
trigger.
+ assert.Equal(t, int32(0), atomic.LoadInt32(&endSeen), "XA END must be
deferred until rows close")
+ assert.Equal(t, int32(0), atomic.LoadInt32(&prepareSeen), "XA PREPARE
must be deferred until rows close")
+ assert.Equal(t, int32(0), atomic.LoadInt32(&reported), "phase-1 report
must be deferred until rows close")
+
+ // The returned rows must be the deferred-commit wrapper.
+ _, ok := rows.(*RowsCommitOnClose)
+ assert.True(t, ok, "XA query rows must be wrapped in RowsCommitOnClose
to defer the branch commit")
+
+ // Closing the rows drains the connection first, then runs XA END + XA
PREPARE + report.
+ assert.NoError(t, rows.Close())
+
+ assert.Equal(t, int32(1), atomic.LoadInt32(&endSeen), "XA END must run
once rows are closed")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&prepareSeen), "XA PREPARE
must run once rows are closed")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&reported), "phase-1 report
must run once rows are closed")
+}
+
+// End-to-end regression for the exact #904 sequence: under an autoCommit
global
+// transaction, a "SELECT ... FOR UPDATE" is immediately followed by an
"UPDATE" on the
+// SAME physical connection. The SELECT's open result set occupies the
connection's read
+// buffer; the busy-buffer error struck because the first branch used to be
committed
+// inline (XA END + XA PREPARE) while those rows were still open, then the
second
+// statement could not open its own branch. This drives the full flow - query,
drain,
+// commit branch 1, pool reuse (ResetSession), then the UPDATE as branch 2 -
and asserts
+// each statement forms its own complete branch (two XA END + XA PREPARE +
phase-1
+// reports) with no error, so the busy-buffer collision cannot recur.
+func TestXAConn_AutoCommit_SelectForUpdateThenUpdate(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ CleanTxHooks()
+ defer func() {
+ simulateExecContextError = nil
+ CleanTxHooks()
+ }()
+
+ prevTimeout := xaConnTimeout
+ xaConnTimeout = time.Minute
+ defer func() { xaConnTimeout = prevTimeout }()
+
+ xaConn, mockMgr := newMockXAConn(t, ctrl, 123)
+
+ var reportCnt int32
+ mockMgr.EXPECT().BranchReport(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(ctx context.Context, param rm.BranchReportParam) error {
+ assert.EqualValues(t, branch.BranchStatusPhaseoneDone,
param.Status)
+ atomic.AddInt32(&reportCnt, 1)
+ return nil
+ },
+ ).AnyTimes()
+
+ var endCnt, prepareCnt int32
+ simulateExecContextError = func(query string) error {
+ upper := strings.ToUpper(strings.TrimSpace(query))
+ switch {
+ case strings.HasPrefix(upper, "XA END"):
+ atomic.AddInt32(&endCnt, 1)
+ case strings.HasPrefix(upper, "XA PREPARE"):
+ atomic.AddInt32(&prepareCnt, 1)
+ }
+ return nil
+ }
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.NewString())
+
+ // Statement 1: SELECT ... FOR UPDATE. The branch commit is deferred
while the rows
+ // are open, so no XA END / XA PREPARE fires yet - that would be the
busy-buffer bug.
+ rows, err := xaConn.QueryContext(ctx, "SELECT * FROM user WHERE id = 1
FOR UPDATE", nil)
+ assert.NoError(t, err)
+ assert.Equal(t, int32(0), atomic.LoadInt32(&endCnt), "branch 1 must not
commit while its rows are open")
+
+ // Draining/closing the rows completes branch 1 (XA END + XA PREPARE +
report).
+ assert.NoError(t, rows.Close())
+ assert.Equal(t, int32(1), atomic.LoadInt32(&endCnt), "branch 1 commits
once its rows close")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&prepareCnt))
+ assert.False(t, xaConn.xaActive, "branch 1 must leave no active branch
on the session")
+
+ // database/sql returns the connection to the pool and resets it before
reuse.
+ assert.NoError(t, xaConn.ResetSession(ctx))
+
+ // Statement 2: the follow-up UPDATE on the SAME connection must form
its own branch.
+ _, err = xaConn.ExecContext(ctx, "UPDATE user SET age = age + 1 WHERE
id = 1", nil)
+ assert.NoError(t, err, "UPDATE after SELECT ... FOR UPDATE must succeed
(no busy buffer)")
+
+ assert.Equal(t, int32(2), atomic.LoadInt32(&endCnt), "each statement
forms one complete XA branch")
+ assert.Equal(t, int32(2), atomic.LoadInt32(&prepareCnt))
+ assert.Equal(t, int32(2), atomic.LoadInt32(&reportCnt), "each branch
reports phase-1 done to the TC")
+}
+
+// ErrSkip in-branch fallback under XA autoCommit.
+//
+// go-sql-driver returns driver.ErrSkip from Exec/Query whenever a statement
carries
+// bind arguments and the DSN does NOT set interpolateParams=true (the
default). See
+// go-sql-driver/[email protected] connection.go: `if len(args) != 0 { if
!cfg.InterpolateParams
+// { return nil, driver.ErrSkip } }`. database/sql normally answers ErrSkip by
retrying the
+// statement through the Prepare+Exec path.
+//
+// Under XA autoCommit + a global transaction, createNewTxOnExecIfNeed opens
the XA branch
+// (XA START) BEFORE running the statement, so it cannot hand the retry back
to database/sql
+// (that retry would run on another connection, outside the branch). Instead
XAConn runs the
+// Prepare+Exec fallback ITSELF on the same physical connection - which still
holds XA START
+// open - so a perfectly ordinary parameterized statement (`UPDATE ... WHERE
id = ?` with the
+// default MySQL DSN) completes inside the branch and the branch commits
normally.
+//
+// The mock models the driver faithfully: the direct ExecContext answers
ErrSkip for the
+// business UPDATE, while PrepareContext + the prepared stmt's ExecContext
succeed.
+func TestXAConn_AutoCommit_ParameterizedStmtErrSkipFallsBackInBranch(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ CleanTxHooks()
+ defer func() {
+ simulateExecContextError = nil
+ CleanTxHooks()
+ }()
+
+ prevTimeout := xaConnTimeout
+ xaConnTimeout = time.Minute
+ defer func() { xaConnTimeout = prevTimeout }()
+
+ xaConn, mockMgr := newMockXAConn(t, ctrl, 123)
+ // The branch prepares and reports phase-1 success once the in-branch
fallback succeeds.
+ var reportCnt int32
+ mockMgr.EXPECT().BranchReport(gomock.Any(),
gomock.Any()).AnyTimes().DoAndReturn(
+ func(_ context.Context, _ interface{}) error {
+ atomic.AddInt32(&reportCnt, 1)
+ return nil
+ })
+
+ // Model go-sql-driver's default behavior: a parameterized business
statement answers
+ // ErrSkip on the direct Execer path; the XA control statements (XA
START/END/PREPARE)
+ // succeed. PrepareContext + prepared ExecContext (wired in
baseMockConn) succeed.
+ simulateExecContextError = func(query string) error {
+ if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"UPDATE") {
+ return driver.ErrSkip
+ }
+ return nil
+ }
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.NewString())
+
+ _, err := xaConn.ExecContext(ctx, "UPDATE user SET age = age + 1 WHERE
id = ?",
+ []driver.NamedValue{{Ordinal: 1, Value: int64(1)}})
+
+ // The fix: the parameterized UPDATE completes inside the branch via
the in-branch
+ // Prepare+Exec fallback, and the branch commits (phase-1 reported to
the TC).
+ assert.NoError(t, err, "parameterized UPDATE should complete via the
in-branch Prepare+Exec fallback")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&reportCnt),
+ "the branch prepares and reports phase-1 success after the
in-branch fallback")
+}
+
+// The real #904 scenario is a PARAMETERIZED `SELECT ... FOR UPDATE WHERE id =
?`
+// (the samples all bind parameters). Under the default MySQL DSN the direct
Queryer
+// answers driver.ErrSkip for it, so this exercises the query-path in-branch
fallback
+// (queryPreparedInBranch) AND the #904 busy-buffer guard together: the
fallback rows
+// must still be wrapped in RowsCommitOnClose so the branch commit (XA END + XA
+// PREPARE) is deferred until the caller drains/closes the rows, never issued
on top
+// of the still-open result set. Closing the rows also closes the prepared stmt
+// (rowsWithStmt) and then runs XA END + XA PREPARE + the phase-1 report
exactly once.
+func
TestXAConn_AutoCommit_ParameterizedSelectForUpdateErrSkipDefersBranchCommit(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ CleanTxHooks()
+ defer func() {
+ simulateExecContextError = nil
+ simulateQueryContextError = nil
+ CleanTxHooks()
+ }()
+
+ prevTimeout := xaConnTimeout
+ xaConnTimeout = time.Minute
+ defer func() { xaConnTimeout = prevTimeout }()
+
+ xaConn, mockMgr := newMockXAConn(t, ctrl, 123)
+
+ var reportCnt int32
+ mockMgr.EXPECT().BranchReport(gomock.Any(),
gomock.Any()).AnyTimes().DoAndReturn(
+ func(_ context.Context, param rm.BranchReportParam) error {
+ assert.EqualValues(t, branch.BranchStatusPhaseoneDone,
param.Status)
+ atomic.AddInt32(&reportCnt, 1)
+ return nil
+ })
+
+ // The XA control statements run on the direct Execer path and succeed;
count when
+ // the deferred branch commit fires.
+ var endCnt, prepareCnt int32
+ simulateExecContextError = func(query string) error {
+ upper := strings.ToUpper(strings.TrimSpace(query))
+ switch {
+ case strings.HasPrefix(upper, "XA END"):
+ atomic.AddInt32(&endCnt, 1)
+ case strings.HasPrefix(upper, "XA PREPARE"):
+ atomic.AddInt32(&prepareCnt, 1)
+ }
+ return nil
+ }
+ // Model the default go-sql-driver DSN: the direct Queryer answers
ErrSkip for the
+ // parameterized business SELECT, forcing the in-branch prepared-query
fallback.
+ simulateQueryContextError = func(query string) error {
+ if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"SELECT") {
+ return driver.ErrSkip
+ }
+ return nil
+ }
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.NewString())
+
+ rows, err := xaConn.QueryContext(ctx, "SELECT * FROM user WHERE id = ?
FOR UPDATE",
+ []driver.NamedValue{{Ordinal: 1, Value: int64(1)}})
+ assert.NoError(t, err, "parameterized SELECT ... FOR UPDATE must
complete via the in-branch prepared-query fallback")
+
+ // Even though we fell back to queryPreparedInBranch, the branch commit
must still be
+ // deferred while the rows are open - issuing XA END / XA PREPARE now
is the #904 bug.
+ _, ok := rows.(*RowsCommitOnClose)
+ assert.True(t, ok, "the in-branch query fallback must still wrap rows
in RowsCommitOnClose to defer the branch commit")
+ assert.Equal(t, int32(0), atomic.LoadInt32(&endCnt), "XA END must be
deferred until the fallback rows close")
+ assert.Equal(t, int32(0), atomic.LoadInt32(&prepareCnt), "XA PREPARE
must be deferred until the fallback rows close")
+ assert.Equal(t, int32(0), atomic.LoadInt32(&reportCnt), "phase-1 report
must be deferred until the fallback rows close")
+
+ // Closing the rows closes both the driver rows and the prepared stmt
(rowsWithStmt),
+ // then runs the deferred XA END + XA PREPARE + phase-1 report exactly
once.
+ assert.NoError(t, rows.Close())
+ assert.Equal(t, int32(1), atomic.LoadInt32(&endCnt), "XA END runs once
the fallback rows close")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&prepareCnt), "XA PREPARE
runs once the fallback rows close")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&reportCnt), "the branch
reports phase-1 done once the fallback rows close")
+}
+
+// When the in-branch Prepare+Exec fallback itself fails with a real
(non-ErrSkip)
+// error, the branch must not leak: createNewTxOnExecIfNeed rolls it back and
reports
+// phase-1 FAILED to the TC, and surfaces the concrete error (never
driver.ErrSkip) to
+// the caller. This guards the post-fallback error path added with the fix.
+func TestXAConn_AutoCommit_InBranchFallbackErrorRollsBackBranch(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ CleanTxHooks()
+ defer func() {
+ simulateExecContextError = nil
+ simulatePreparedExecError = nil
+ CleanTxHooks()
+ }()
+
+ prevTimeout := xaConnTimeout
+ xaConnTimeout = time.Minute
+ defer func() { xaConnTimeout = prevTimeout }()
+
+ xaConn, mockMgr := newMockXAConn(t, ctrl, 123)
+
+ var failedReportCnt int32
+ mockMgr.EXPECT().BranchReport(gomock.Any(),
gomock.Any()).AnyTimes().DoAndReturn(
+ func(_ context.Context, param rm.BranchReportParam) error {
+ if param.Status == branch.BranchStatusPhaseoneFailed {
+ atomic.AddInt32(&failedReportCnt, 1)
+ }
+ return nil
+ })
+
+ // Direct Execer answers ErrSkip for the business UPDATE (default DSN
behavior); the
+ // XA control statements succeed.
+ simulateExecContextError = func(query string) error {
+ if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"UPDATE") {
+ return driver.ErrSkip
+ }
+ return nil
+ }
+ // The in-branch prepared exec then fails with a real error (e.g. a
constraint
+ // violation) - this is NOT ErrSkip, so it must abort and roll back the
branch.
+ prepErr := errors.New("Error 1062: Duplicate entry for key 'PRIMARY'")
+ simulatePreparedExecError = func(query string) error {
+ if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"UPDATE") {
+ return prepErr
+ }
+ return nil
+ }
+
+ ctx := tm.InitSeataContext(context.Background())
+ tm.SetXID(ctx, uuid.NewString())
+
+ _, err := xaConn.ExecContext(ctx, "UPDATE user SET age = age + 1 WHERE
id = ?",
+ []driver.NamedValue{{Ordinal: 1, Value: int64(1)}})
+
+ assert.Error(t, err, "a real error from the in-branch fallback must
surface")
+ assert.False(t, errors.Is(err, driver.ErrSkip), "the caller must never
see raw driver.ErrSkip - the fix converts it into a concrete result or error")
+ assert.ErrorIs(t, err, prepErr, "the concrete fallback error must be
surfaced to the caller")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&failedReportCnt),
+ "the failed branch must report phase-1 FAILED to the TC so it
does not leak")
+ assert.False(t, xaConn.xaActive, "the rolled-back branch must leave no
active branch on the session")
+}
+
func TestXAConn_BeginTx_DoesNotStartPhysicalTx(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
diff --git a/pkg/datasource/sql/datasource/mysql/trigger_test.go
b/pkg/datasource/sql/datasource/mysql/trigger_test.go
index 49737522..ab9037c6 100644
--- a/pkg/datasource/sql/datasource/mysql/trigger_test.go
+++ b/pkg/datasource/sql/datasource/mysql/trigger_test.go
@@ -24,7 +24,6 @@ import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
- "github.com/agiledragon/gomonkey/v2"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
@@ -62,20 +61,23 @@ func initMockColumnMeta() []types.ColumnMeta {
}
}
-func initGetIndexesStub(m *mysqlTrigger, indexMeta []types.IndexMeta)
*gomonkey.Patches {
- getIndexesStub := gomonkey.ApplyPrivateMethod(m, "getIndexes",
- func(_ *mysqlTrigger, ctx context.Context, dbName string,
tableName string, conn *sql.Conn) ([]types.IndexMeta, error) {
- return indexMeta, nil
- })
- return getIndexesStub
+// initGetIndexesStub injects a getIndexes implementation via the
mysqlTrigger's
+// built-in getIndexesFn seam. This replaces gomonkey.ApplyPrivateMethod, which
+// rewrites the method's machine code and silently no-ops once the compiler
inlines
+// getIndexes (default builds without -race/-gcflags=all=-l), making the
LoadOne/
+// LoadAll tests order-dependent.
+func initGetIndexesStub(m *mysqlTrigger, indexMeta []types.IndexMeta) {
+ m.getIndexesFn = func(ctx context.Context, dbName string, tableName
string, conn *sql.Conn) ([]types.IndexMeta, error) {
+ return indexMeta, nil
+ }
}
-func initGetColumnMetasStub(m *mysqlTrigger, columnMeta []types.ColumnMeta)
*gomonkey.Patches {
- getColumnMetasStub := gomonkey.ApplyPrivateMethod(m, "getColumnMetas",
- func(_ *mysqlTrigger, ctx context.Context, dbName string, table
string, conn *sql.Conn) ([]types.ColumnMeta, error) {
- return columnMeta, nil
- })
- return getColumnMetasStub
+// initGetColumnMetasStub injects a getColumnMetas implementation via the
+// mysqlTrigger's built-in getColumnMetasFn seam (see initGetIndexesStub).
+func initGetColumnMetasStub(m *mysqlTrigger, columnMeta []types.ColumnMeta) {
+ m.getColumnMetasFn = func(ctx context.Context, dbName string, table
string, conn *sql.Conn) ([]types.ColumnMeta, error) {
+ return columnMeta, nil
+ }
}
func Test_mysqlTrigger_LoadOne(t *testing.T) {
@@ -105,11 +107,8 @@ func Test_mysqlTrigger_LoadOne(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
m := &mysqlTrigger{}
- getColumnMetasStub := initGetColumnMetasStub(m,
tt.columnMeta)
- defer getColumnMetasStub.Reset()
-
- getIndexesStub := initGetIndexesStub(m, tt.indexMeta)
- defer getIndexesStub.Reset()
+ initGetColumnMetasStub(m, tt.columnMeta)
+ initGetIndexesStub(m, tt.indexMeta)
got, err := m.LoadOne(tt.args.ctx, tt.args.dbName,
tt.args.tableName, tt.args.conn)
if err != nil {
@@ -175,11 +174,8 @@ func Test_mysqlTrigger_LoadAll(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
m := &mysqlTrigger{}
- getColumnMetasStub := initGetColumnMetasStub(m,
tt.columnMeta)
- defer getColumnMetasStub.Reset()
-
- getIndexesStub := initGetIndexesStub(m, tt.indexMeta)
- defer getIndexesStub.Reset()
+ initGetColumnMetasStub(m, tt.columnMeta)
+ initGetIndexesStub(m, tt.indexMeta)
got, err := m.LoadAll(tt.args.ctx, tt.args.dbName,
tt.args.conn, tt.args.tables...)
if err != nil {
@@ -234,25 +230,19 @@ func Test_mysqlTrigger_LoadOne_ErrorCases(t *testing.T) {
m := &mysqlTrigger{}
if tt.columnMetaErr != nil {
- getColumnMetasStub :=
gomonkey.ApplyPrivateMethod(m, "getColumnMetas",
- func(_ *mysqlTrigger, ctx
context.Context, dbName string, table string, conn *sql.Conn)
([]types.ColumnMeta, error) {
- return nil, tt.columnMetaErr
- })
- defer getColumnMetasStub.Reset()
+ m.getColumnMetasFn = func(ctx context.Context,
dbName string, table string, conn *sql.Conn) ([]types.ColumnMeta, error) {
+ return nil, tt.columnMetaErr
+ }
} else {
- getColumnMetasStub := initGetColumnMetasStub(m,
tt.columnMeta)
- defer getColumnMetasStub.Reset()
+ initGetColumnMetasStub(m, tt.columnMeta)
}
if tt.indexMetaErr != nil {
- getIndexesStub :=
gomonkey.ApplyPrivateMethod(m, "getIndexes",
- func(_ *mysqlTrigger, ctx
context.Context, dbName string, tableName string, conn *sql.Conn)
([]types.IndexMeta, error) {
- return nil, tt.indexMetaErr
- })
- defer getIndexesStub.Reset()
+ m.getIndexesFn = func(ctx context.Context,
dbName string, tableName string, conn *sql.Conn) ([]types.IndexMeta, error) {
+ return nil, tt.indexMetaErr
+ }
} else {
- getIndexesStub := initGetIndexesStub(m,
tt.indexMeta)
- defer getIndexesStub.Reset()
+ initGetIndexesStub(m, tt.indexMeta)
}
_, err := m.LoadOne(context.Background(), "testdb",
"testtable", nil)
@@ -300,11 +290,8 @@ func Test_mysqlTrigger_LoadOne_ComplexIndexes(t
*testing.T) {
},
}
- getColumnMetasStub := initGetColumnMetasStub(m, columnMeta)
- defer getColumnMetasStub.Reset()
-
- getIndexesStub := initGetIndexesStub(m, indexMeta)
- defer getIndexesStub.Reset()
+ initGetColumnMetasStub(m, columnMeta)
+ initGetIndexesStub(m, indexMeta)
tableMeta, err := m.LoadOne(context.Background(), "testdb",
"testtable", nil)
@@ -604,18 +591,15 @@ func Test_mysqlTrigger_LoadAll_ErrorHandling(t
*testing.T) {
indexMeta := initMockIndexMeta()
callCount := 0
- getColumnMetasStub := gomonkey.ApplyPrivateMethod(m, "getColumnMetas",
- func(_ *mysqlTrigger, ctx context.Context, dbName string, table
string, conn *sql.Conn) ([]types.ColumnMeta, error) {
- callCount++
- if callCount == 2 {
- return nil, errors.New("column error")
- }
- return columnMeta, nil
- })
- defer getColumnMetasStub.Reset()
+ m.getColumnMetasFn = func(ctx context.Context, dbName string, table
string, conn *sql.Conn) ([]types.ColumnMeta, error) {
+ callCount++
+ if callCount == 2 {
+ return nil, errors.New("column error")
+ }
+ return columnMeta, nil
+ }
- getIndexesStub := initGetIndexesStub(m, indexMeta)
- defer getIndexesStub.Reset()
+ initGetIndexesStub(m, indexMeta)
// LoadAll should continue even if one table fails
result, err := m.LoadAll(context.Background(), "testdb", nil, "table1",
"table2", "table3")
@@ -657,11 +641,8 @@ func
Test_mysqlTrigger_LoadOne_MultipleIndexesOnSameColumn(t *testing.T) {
},
}
- getColumnMetasStub := initGetColumnMetasStub(m, columnMeta)
- defer getColumnMetasStub.Reset()
-
- getIndexesStub := initGetIndexesStub(m, indexMeta)
- defer getIndexesStub.Reset()
+ initGetColumnMetasStub(m, columnMeta)
+ initGetIndexesStub(m, indexMeta)
tableMeta, err := m.LoadOne(context.Background(), "testdb",
"testtable", nil)
diff --git a/pkg/datasource/sql/db.go b/pkg/datasource/sql/db.go
index cc019283..10fdb156 100644
--- a/pkg/datasource/sql/db.go
+++ b/pkg/datasource/sql/db.go
@@ -117,7 +117,7 @@ type DBResource struct {
// for xa
metaCache datasource.TableMetaCache
shouldBeHeld bool
- keeper sync.Map
+ keeper sync.Map // xaBranchID -> *XAConn
}
func (db *DBResource) GetResourceGroupId() string {
diff --git a/pkg/datasource/sql/exec/executor.go
b/pkg/datasource/sql/exec/executor.go
index 5a0b9868..57f7b25c 100644
--- a/pkg/datasource/sql/exec/executor.go
+++ b/pkg/datasource/sql/exec/executor.go
@@ -55,6 +55,14 @@ type (
func BuildExecutor(dbType types.DBType, transactionMode types.TransactionMode,
query string) (SQLExecutor, error) {
parseContext, err := parser.DoParser(query)
if err != nil {
+ // XA mode uses a pass-through executor and never needs the
parsed statement:
+ // two-phase commit is managed by the TC and no undo log is
generated, so SQL
+ // the shared parser cannot understand must still execute as-is
instead of
+ // erroring or falling back to an AT executor. This check must
come before the
+ // PostgreSQL fallback so XA mode is never routed to an AT
executor.
+ if transactionMode == types.XAMode {
+ return newXAExecutor(commonHook), nil
+ }
if dbType == types.DBTypePostgreSQL {
// PostgreSQL local execution must remain pass-through
even when the
// shared parser cannot understand PostgreSQL-only
syntax yet.
@@ -67,9 +75,23 @@ func BuildExecutor(dbType types.DBType, transactionMode
types.TransactionMode, q
hooks = append(hooks, commonHook...)
hooks = append(hooks, hookSolts[parseContext.SQLType]...)
+ // For XA mode, use a plain executor without AT-specific hooks: XA
transactions
+ // don't need undo logs, they rely on the two-phase commit protocol
managed by TC.
+ if transactionMode == types.XAMode {
+ return newXAExecutor(hooks), nil
+ }
+
return newATExecutor(dbType, hooks)
}
+// newXAExecutor builds a pass-through executor for XA mode. It applies the
given
+// hooks but never wraps an AT executor, so no undo log is generated.
+func newXAExecutor(hooks []SQLHook) SQLExecutor {
+ e := &BaseExecutor{}
+ e.Interceptors(hooks)
+ return e
+}
+
func newATExecutor(dbType types.DBType, hooks []SQLHook) (SQLExecutor, error) {
builder, ok := atExecutors[dbType]
if !ok || builder == nil {
@@ -127,7 +149,7 @@ func (e *BaseExecutor) ExecWithValue(ctx context.Context,
execCtx *types.ExecCon
return e.ex.ExecWithValue(ctx, execCtx, f)
}
- nvargs := make([]driver.NamedValue, len(execCtx.Values))
+ nvargs := make([]driver.NamedValue, 0, len(execCtx.Values))
for i, value := range execCtx.Values {
nvargs = append(nvargs, driver.NamedValue{
Value: value,
diff --git a/pkg/datasource/sql/exec/executor_test.go
b/pkg/datasource/sql/exec/executor_test.go
index 5870785e..0ac9846c 100644
--- a/pkg/datasource/sql/exec/executor_test.go
+++ b/pkg/datasource/sql/exec/executor_test.go
@@ -150,6 +150,34 @@ func TestBuildExecutor(t *testing.T) {
query: "INVALID SQL QUERY",
wantErr: true,
},
+ {
+ name: "XA mode INSERT statement",
+ dbType: types.DBTypeMySQL,
+ transactionMode: types.XAMode,
+ query: "INSERT INTO users (name, age) VALUES
('Alice', 30)",
+ wantErr: false,
+ },
+ {
+ name: "XA mode UPDATE statement",
+ dbType: types.DBTypeMySQL,
+ transactionMode: types.XAMode,
+ query: "UPDATE users SET age = 31 WHERE name
= 'Alice'",
+ wantErr: false,
+ },
+ {
+ name: "XA mode DELETE statement",
+ dbType: types.DBTypeMySQL,
+ transactionMode: types.XAMode,
+ query: "DELETE FROM users WHERE name =
'Alice'",
+ wantErr: false,
+ },
+ {
+ name: "XA mode SELECT statement",
+ dbType: types.DBTypeMySQL,
+ transactionMode: types.XAMode,
+ query: "SELECT * FROM users WHERE name =
'Alice'",
+ wantErr: false,
+ },
}
for _, tt := range tests {
@@ -165,8 +193,14 @@ func TestBuildExecutor(t *testing.T) {
assert.NoError(t, err, "should not return error
for valid query")
assert.NotNil(t, executor, "executor should not
be nil")
// Verify that the mock executor received the
interceptors
- assert.True(t, mockExecutor.interceptorsCalled,
"Interceptors should be called")
- assert.NotEmpty(t, mockExecutor.hooks, "hooks
should be set")
+ if tt.transactionMode == types.XAMode {
+ assert.IsType(t, &BaseExecutor{},
executor, "XA mode should return BaseExecutor")
+ baseExec := executor.(*BaseExecutor)
+ assert.NotNil(t, baseExec.hooks,
"BaseExecutor should have hooks set")
+ } else {
+ assert.True(t,
mockExecutor.interceptorsCalled, "Interceptors should be called")
+ assert.NotEmpty(t, mockExecutor.hooks,
"hooks should be set")
+ }
}
})
}
@@ -324,23 +358,44 @@ func TestBaseExecutor_ExecWithValue(t *testing.T) {
tests := []struct {
name string
setupHooks []SQLHook
+ innerExecutor SQLExecutor
callback CallbackWithNamedValue
execCtx *types.ExecContext
wantErr bool
wantBeforeCount int
wantAfterCount int
+ // wantArgs is the exact []driver.NamedValue the callback must
receive on the
+ // e.ex == nil pass-through path (values converted from
execCtx.Values). Length
+ // and every element are asserted, so an off-by-N conversion
bug cannot pass.
+ wantArgs []driver.NamedValue
}{
{
- name: "execute with values - converts to NamedValues",
+ name: "e.ex == nil - values are converted to
NamedValues without extra empty slots",
callback: func(ctx context.Context, query string, args
[]driver.NamedValue) (types.ExecResult, error) {
- // Verify that values were converted to
NamedValues
- assert.NotEmpty(t, args, "args should not be
empty")
return newMockExecResult(1, 1), nil
},
execCtx: &types.ExecContext{
Query: "UPDATE users SET age = ? WHERE name =
?",
Values: []driver.Value{31, "Alice"},
},
+ wantArgs: []driver.NamedValue{
+ {Ordinal: 0, Value: 31},
+ {Ordinal: 1, Value: "Alice"},
+ },
+ wantErr: false,
+ wantBeforeCount: 0,
+ wantAfterCount: 0,
+ },
+ {
+ name: "e.ex == nil - empty values produce empty args",
+ callback: func(ctx context.Context, query string, args
[]driver.NamedValue) (types.ExecResult, error) {
+ return newMockExecResult(1, 1), nil
+ },
+ execCtx: &types.ExecContext{
+ Query: "DELETE FROM users",
+ Values: []driver.Value{},
+ },
+ wantArgs: []driver.NamedValue{},
wantErr: false,
wantBeforeCount: 0,
wantAfterCount: 0,
@@ -357,6 +412,10 @@ func TestBaseExecutor_ExecWithValue(t *testing.T) {
Query: "UPDATE users SET age = ? WHERE name =
?",
Values: []driver.Value{31, "Alice"},
},
+ wantArgs: []driver.NamedValue{
+ {Ordinal: 0, Value: 31},
+ {Ordinal: 1, Value: "Alice"},
+ },
wantErr: false,
wantBeforeCount: 1,
wantAfterCount: 1,
@@ -373,19 +432,58 @@ func TestBaseExecutor_ExecWithValue(t *testing.T) {
Query: "UPDATE users SET age = ? WHERE name =
?",
Values: []driver.Value{31, "Alice"},
},
+ wantArgs: []driver.NamedValue{
+ {Ordinal: 0, Value: 31},
+ {Ordinal: 1, Value: "Alice"},
+ },
wantErr: true,
wantBeforeCount: 1,
wantAfterCount: 1,
},
+ {
+ name: "e.ex != nil - delegates to the inner executor",
+ setupHooks: []SQLHook{
+ &mockSQLHook{sqlType: types.SQLTypeUpdate},
+ },
+ innerExecutor: &mockSQLExecutor{
+ execWithValueFunc: func(ctx context.Context,
execCtx *types.ExecContext, f CallbackWithNamedValue) (types.ExecResult, error)
{
+ return newMockExecResult(2, 2), nil
+ },
+ },
+ callback: func(ctx context.Context, query string, args
[]driver.NamedValue) (types.ExecResult, error) {
+ // The inner executor short-circuits the
pass-through conversion, so the
+ // base executor must never invoke this
callback itself.
+ panic("callback should not be called when inner
executor is set")
+ },
+ execCtx: &types.ExecContext{
+ Query: "UPDATE users SET age = ? WHERE name =
?",
+ Values: []driver.Value{31, "Alice"},
+ },
+ wantErr: false,
+ wantBeforeCount: 1,
+ wantAfterCount: 1,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ // Wrap the callback to assert the exact converted args
on the pass-through path.
+ callback := tt.callback
+ if tt.wantArgs != nil {
+ inner := tt.callback
+ callback = func(ctx context.Context, query
string, args []driver.NamedValue) (types.ExecResult, error) {
+ assert.Equal(t, len(tt.wantArgs),
len(args), "converted args length should match values length")
+ assert.Equal(t, tt.wantArgs, args,
"converted args should match expected NamedValues")
+ return inner(ctx, query, args)
+ }
+ }
+
executor := &BaseExecutor{
hooks: tt.setupHooks,
+ ex: tt.innerExecutor,
}
- result, err :=
executor.ExecWithValue(context.Background(), tt.execCtx, tt.callback)
+ result, err :=
executor.ExecWithValue(context.Background(), tt.execCtx, callback)
if tt.wantErr {
assert.Error(t, err, "should return error")
diff --git a/pkg/datasource/sql/tx.go b/pkg/datasource/sql/tx.go
index c3c74c26..46351ab6 100644
--- a/pkg/datasource/sql/tx.go
+++ b/pkg/datasource/sql/tx.go
@@ -149,6 +149,17 @@ func (tx *Tx) Rollback() error {
}
}
+ // In XA mode, target might be nil (set with withOriginTx(nil))
+ // Only allow nil target when explicitly in XA mode; otherwise,
+ // treat it as an error to avoid masking unexpected
driver/initialization bugs
+ if tx.target == nil {
+ if tx.tranCtx != nil && tx.tranCtx.TransactionMode ==
types.XAMode {
+ // XA transactions are managed separately
+ return nil
+ }
+ return fmt.Errorf("sql.Tx Rollback: underlying transaction is
nil in non-XA mode")
+ }
+
return tx.target.Rollback()
}
diff --git a/pkg/datasource/sql/types/types.go
b/pkg/datasource/sql/types/types.go
index 74598ce1..0893139a 100644
--- a/pkg/datasource/sql/types/types.go
+++ b/pkg/datasource/sql/types/types.go
@@ -147,7 +147,7 @@ type TransactionContext struct {
BranchID uint64
// XID global transaction id
XID string
- // GlobalLockRequire
+ // GlobalLockRequire indicates whether global lock is required (used in
AT mode)
GlobalLockRequire bool
// RoundImages when run in AT mode, record before and after Row image
RoundImages *RoundRecordImage
diff --git a/pkg/datasource/sql/xa/mysql_xa_connection.go
b/pkg/datasource/sql/xa/mysql_xa_connection.go
index 601a27dd..181874eb 100644
--- a/pkg/datasource/sql/xa/mysql_xa_connection.go
+++ b/pkg/datasource/sql/xa/mysql_xa_connection.go
@@ -50,9 +50,20 @@ func (f *mysqlXAResourceFactory) CreateErrorClassifier()
XAErrorClassifier {
// MysqlXAErrorClassifier classifies MySQL-specific XA errors.
type MysqlXAErrorClassifier struct{}
-// IsAlreadyEnded checks if the XAER_RMFAIL error indicates the XA branch is
already ended.
-// Expected error: Error 1399 (XAE07): XAER_RMFAIL: The command cannot be
executed
-// when global transaction is in the IDLE state
+// IsAlreadyEnded reports whether the XAER_RMFAIL error means the XA branch has
+// already left the ACTIVE state, so a subsequent XA END(TMFAIL) is a no-op
and the
+// caller should proceed straight to XA ROLLBACK.
+//
+// Two states qualify, both raised as Error 1399 (XAE07) XAER_RMFAIL:
+// - IDLE: "...cannot be executed when global transaction is in the IDLE
state"
+// (branch already ended once, e.g. Commit ran XA END then failed later).
+// - PREPARED: "...cannot be executed when global transaction is in the
PREPARED state"
+// (branch already ended AND prepared, e.g. Commit did XA END + XA PREPARE
at the DB
+// but the phase-1 report to the TC failed, and Rollback now needs to
release locks).
+//
+// Treating the PREPARED case as "already ended" is required so that Rollback
does not
+// bail out before XA ROLLBACK - otherwise a prepared branch would keep
holding locks.
+// XA ROLLBACK is a legal transition out of the PREPARED state, so it still
releases them.
func (c *MysqlXAErrorClassifier) IsAlreadyEnded(err error) bool {
if err == nil {
return false
@@ -60,7 +71,9 @@ func (c *MysqlXAErrorClassifier) IsAlreadyEnded(err error)
bool {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) {
if mysqlErr.Number == types.ErrCodeXAER_RMFAIL_IDLE {
- return strings.Contains(mysqlErr.Message, "IDLE state")
|| strings.Contains(mysqlErr.Message, "already ended")
+ return strings.Contains(mysqlErr.Message, "IDLE state")
||
+ strings.Contains(mysqlErr.Message, "PREPARED
state") ||
+ strings.Contains(mysqlErr.Message, "already
ended")
}
}
return false
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]