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

rmaucher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git

commit 2625dc97f63fa6e73d9167fda3ce91744a854d62
Author: opencode <[email protected]>
AuthorDate: Tue Nov 3 01:17:13 2026 +0100

    Refuse to pool an XA session that carries an unresolved branch
    
    A pooled XA handle's close() recycles the session, but the pool's
    return-time transaction termination unwinds only local work and can
    never resolve an XA branch. Closing a spec-following handle between
    end/prepare and commit therefore re-pooled a session holding a live
    prepared branch and its database locks: the next borrower inherited
    them and the transaction manager's late commit landed on a session
    that does not carry the branch (XAER_NOTA / permanently in-doubt).
    
    The pool now wraps the driver's XA handle per session and books the
    branches started through it until they are committed, rolled back or
    forgotten. A returned session with an unresolved branch is closed and
    replaced rather than pooled - the resource manager drops the branch
    with the session - and late branch calls on a captured XAResource are
    refused with XAER_RMFAIL against the retired session instead of
    whatever the closed driver object happens to raise. Resolved branches
    still pool as before. Regression tests: TestUnresolvedXaBranchOnReturn.
---
 .../apache/tomcat/jdbc/pool/ConnectionPool.java    |  14 +
 .../apache/tomcat/jdbc/pool/PooledConnection.java  |  43 ++-
 .../apache/tomcat/jdbc/pool/ProxyConnection.java   |   6 +-
 .../apache/tomcat/jdbc/pool/XABranchTracker.java   | 302 +++++++++++++++++++++
 .../org/apache/tomcat/jdbc/pool/XADataSource.java  |   8 +-
 .../tomcat/jdbc/pool/util/LocalStrings.properties  |   2 +
 .../jdbc/pool/TestUnresolvedXaBranchOnReturn.java  | 171 ++++++++++++
 7 files changed, 539 insertions(+), 7 deletions(-)

diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java
index 87e5e33957..4c44a6f10d 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java
@@ -1687,6 +1687,20 @@ public class ConnectionPool {
         if (isClosed()) {
             return true;
         }
+        if (con.hasUnresolvedXaBranches()) {
+            // A branch that was started through this session's XA resource
+            // and never committed, rolled back or forgotten stays attached to
+            // the physical connection, and neither the transaction
+            // termination below nor a plain rollback can resolve it: pooling
+            // the session would hand the branch and every database lock held
+            // for it to the next borrower, while the transaction manager's
+            // late commit would land on a session that does not carry it.
+            // Close the session instead - the resource manager drops the
+            // branch with it, and the manager's late call then fails visibly
+            // against a closed session.
+            
log.warn(MessageFactory.get("connectionPool.unresolvedXaBranchReturned", con));
+            return true;
+        }
         // Terminate the transaction before validating. When the pool is
         // configured to commit or roll back on return, that is the explicit
         // choice for the pending work, and a validation that runs first
diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PooledConnection.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PooledConnection.java
index 8a7f27bf48..9161226119 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PooledConnection.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PooledConnection.java
@@ -79,7 +79,8 @@ public class PooledConnection implements 
PooledConnectionMBean {
     private volatile java.sql.Connection connection;
 
     /**
-     * If using a XAConnection underneath.
+     * If using a XAConnection underneath. On an XA session this is the pool's
+     * {@link XABranchTracker} wrapper around the handle the data source 
returned.
      */
     protected volatile javax.sql.XAConnection xaConnection;
     /**
@@ -382,6 +383,14 @@ public class PooledConnection implements 
PooledConnectionMBean {
                 }
                 throw failure;
             }
+            // Once the session is fully established, hand out the pool's
+            // wrapper rather than the driver's handle: it tracks the
+            // transaction branches started through this session, so a return
+            // can tell whether its borrower is leaving a branch behind (see
+            // hasUnresolvedXaBranches()). The connect above holds the driver's
+            // handle directly, and the wrapper below only delegates - a close
+            // through the field still physically closes the session.
+            xaConnection = new XABranchTracker(pooled);
         } else if (poolProperties.getDataSource() instanceof 
javax.sql.DataSource) {
             javax.sql.DataSource ds = (javax.sql.DataSource) 
poolProperties.getDataSource();
             if (usr != null && pwd != null) {
@@ -591,6 +600,14 @@ public class PooledConnection implements 
PooledConnectionMBean {
                 // connection from being closed
                 
log.warn(MessageFactory.get("pooledConnection.interceptorDisconnectedFailed"), 
x);
             }
+            javax.sql.XAConnection retired = xaConnection;
+            if (retired instanceof XABranchTracker) {
+                // Mark the handle retired before the physical close: branch
+                // work on a resource captured from this handle then meets the
+                // pool's clean XA refusal instead of the driver's half-closed
+                // state.
+                ((XABranchTracker) retired).markClosed();
+            }
             try {
                 if (cpdsConnection != null) {
                     // Closing the handle closes the physical connection
@@ -1186,12 +1203,34 @@ public class PooledConnection implements 
PooledConnectionMBean {
     /**
      * Returns the underlying XA connection
      *
-     * @return the underlying XA connection as it was returned from the 
Datasource
+     * @return the XA connection of this session on an XA pool - the pool's
+     *             {@link XABranchTracker} wrapper around the handle that the 
data
+     *             source returned, which delegates every call to it - or null 
when
+     *             the pool is not backed by an XA data source or this session 
has
+     *             been disconnected
      */
     public javax.sql.XAConnection getXAConnection() {
         return this.xaConnection;
     }
 
+    /**
+     * Reports whether this session carries an XA transaction branch that was
+     * started through its XA resource and never resolved. Such a branch is
+     * attached to the physical connection until the database drops it - the
+     * pool's return-time transaction termination unwinds only local work - so
+     * the session must not go back to the idle queue with one on it.
+     *
+     * @return true if at least one branch started on this session was never
+     *             committed, rolled back or forgotten
+     */
+    boolean hasUnresolvedXaBranches() {
+        javax.sql.XAConnection xa = xaConnection;
+        if (xa instanceof XABranchTracker) {
+            return ((XABranchTracker) xa).hasUnresolvedBranches();
+        }
+        return false;
+    }
+
 
     /**
      * Returns the timestamp of when the connection was last connected to the 
database. ie, a successful call to
diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ProxyConnection.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ProxyConnection.java
index c8e7c6d8e7..f5c9859c28 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ProxyConnection.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ProxyConnection.java
@@ -379,8 +379,10 @@ public class ProxyConnection extends JdbcInterceptor {
             // connection behind it also provides - java.sql.Connection
             // first of all - keep resolving to the physical connection;
             // javax.sql.XAConnection is excluded because unwrapping to it
-            // has always yielded the driver's XA connection object where one
-            // exists, and the failure below where none does. This answer
+            // has always yielded the XA connection object the pooled
+            // connection holds - the driver's handle, wrapped by the pool's
+            // branch tracker on an XA session - and the failure below where
+            // none does. This answer
             // runs below the fence like every other handout here: above it,
             // a marking or a seal landing with the caller's re-check left
             // could hand the handle out on a borrow the pool was already
diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XABranchTracker.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XABranchTracker.java
new file mode 100644
index 0000000000..a8066525b8
--- /dev/null
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XABranchTracker.java
@@ -0,0 +1,302 @@
+/*
+ * 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.tomcat.jdbc.pool;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.sql.ConnectionEventListener;
+import javax.sql.StatementEventListener;
+import javax.sql.XAConnection;
+import javax.transaction.xa.XAException;
+import javax.transaction.xa.XAResource;
+import javax.transaction.xa.Xid;
+
+import org.apache.tomcat.jdbc.pool.util.MessageFactory;
+
+/**
+ * The pool's XA connection wrapper. It hands the driver's handle out to the
+ * pool and to borrowers unchanged in every call - it only delegates - and
+ * books, next to it, the transaction branches that were started through this
+ * session's XA resource and never resolved: committed, rolled back or
+ * forgotten.
+ * <p>
+ * The bookkeeping exists because a borrow's {@code close()} recycles the
+ * session instead of closing it, while the return-time transaction
+ * termination unwinds only local work and can never resolve an XA branch. A
+ * session that a borrower abandons mid-branch would otherwise go back into
+ * the idle queue carrying the branch and every database lock held for it,
+ * and the transaction manager's late commit would land on a session that
+ * does not carry it. The pool consults {@link #hasUnresolvedBranches()} when
+ * a connection is returned and retires such a session instead of pooling it.
+ */
+final class XABranchTracker implements XAConnection {
+
+    /**
+     * The identity a branch is booked and resolved under. Xid implementations
+     * compare by value per the XA specification, but nothing forces a
+     * transaction manager's Xid to honour that, and the pool must not mistake
+     * a re-created equal branch for a new one: the key snapshots the content
+     * itself, and with it also survives a caller mutating its arrays.
+     */
+    private static final class XidKey {
+        private final int formatId;
+        private final byte[] globalId;
+        private final byte[] branchId;
+        private final int hash;
+
+        XidKey(Xid xid) {
+            formatId = xid.getFormatId();
+            byte[] gtid = xid.getGlobalTransactionId();
+            globalId = gtid == null ? new byte[0] : gtid.clone();
+            byte[] bqual = xid.getBranchQualifier();
+            branchId = bqual == null ? new byte[0] : bqual.clone();
+            hash = Arrays.hashCode(globalId) * 31 + formatId;
+        }
+
+        @Override
+        public int hashCode() {
+            return hash;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (!(obj instanceof XidKey)) {
+                return false;
+            }
+            XidKey other = (XidKey) obj;
+            return formatId == other.formatId
+                    && Arrays.equals(globalId, other.globalId)
+                    && Arrays.equals(branchId, other.branchId);
+        }
+    }
+
+    private final XAConnection delegate;
+
+    /**
+     * Branches started on this session that the resource manager still
+     * considers attached to it. {@code end()} does not detach a branch -
+     * only commit, rollback or a successful forget does, and neither does a
+     * refusal of any of them: a branch whose resolution failed stays booked,
+     * because whether the resource manager kept it is then unknown and
+     * retiring the session is the only safe answer.
+     */
+    private final Set<XidKey> unresolved = ConcurrentHashMap.newKeySet();
+
+    /**
+     * The one tracking resource this handle owns, so that repeated
+     * {@code getXAResource()} calls name the same object the way the driver's
+     * own handle does, and the bookkeeping cannot depend on which of the
+     * objects a transaction manager happened to keep.
+     */
+    private volatile XAResource trackedResource;
+
+    /**
+     * Set when the pool retires the session behind this handle. Branch
+     * lifecycle calls on a resource captured from the handle are then refused
+     * right here instead of reaching the closed driver object: a discarded
+     * session is terminal, its branches dropped with it, and a driver that
+     * does not fail cleanly on its own closed handle would otherwise surface
+     * the misuse as an unrelated runtime failure at the transaction manager.
+     */
+    private volatile boolean closed;
+
+    XABranchTracker(XAConnection delegate) {
+        this.delegate = delegate;
+    }
+
+    /**
+     * Marks the session behind this handle as retired, from the pool's
+     * disconnect path. After this, no branch work is accepted any more.
+     */
+    void markClosed() {
+        closed = true;
+    }
+
+    /**
+     * Reports whether any branch started through this session is still
+     * unresolved.
+     *
+     * @return true if a branch was started and never committed, rolled back
+     *             or forgotten on this session
+     */
+    boolean hasUnresolvedBranches() {
+        return !unresolved.isEmpty();
+    }
+
+    // javax.sql.PooledConnection / javax.sql.XAConnection
+
+    @Override
+    public Connection getConnection() throws SQLException {
+        return delegate.getConnection();
+    }
+
+    @Override
+    public void close() throws SQLException {
+        delegate.close();
+    }
+
+    @Override
+    public XAResource getXAResource() throws SQLException {
+        XAResource resource = trackedResource;
+        if (resource == null) {
+            synchronized (this) {
+                resource = trackedResource;
+                if (resource == null) {
+                    resource = new TrackedResource(delegate.getXAResource());
+                    trackedResource = resource;
+                }
+            }
+        }
+        return resource;
+    }
+
+    @Override
+    public void addConnectionEventListener(ConnectionEventListener listener) {
+        delegate.addConnectionEventListener(listener);
+    }
+
+    @Override
+    public void removeConnectionEventListener(ConnectionEventListener 
listener) {
+        delegate.removeConnectionEventListener(listener);
+    }
+
+    @Override
+    public void addStatementEventListener(StatementEventListener listener) {
+        delegate.addStatementEventListener(listener);
+    }
+
+    @Override
+    public void removeStatementEventListener(StatementEventListener listener) {
+        delegate.removeStatementEventListener(listener);
+    }
+
+    /**
+     * The tracked XA resource. Every call is dispatched to the driver's
+     * resource exactly as it is; the wrapper only books the branch lifecycle
+     * beside it.
+     */
+    private final class TrackedResource implements XAResource {
+
+        private final XAResource delegate;
+
+        TrackedResource(XAResource delegate) {
+            this.delegate = delegate;
+        }
+
+        /**
+         * Branch work on a retired session cannot succeed: the session is
+         * closed, its unresolved branches were dropped with it, and none of
+         * these calls reaches anything any more. The refusal names the
+         * resource manager unavailable, the code the XA specification gives
+         * for a resource that cannot serve the call, so the transaction
+         * manager sees a clean XA failure instead of whatever the closed
+         * driver object happens to raise.
+         */
+        private void refuseRetiredSession() throws XAException {
+            if (closed) {
+                XAException failure = new XAException(
+                        MessageFactory.get("xaBranchTracker.resourceRetired"));
+                failure.errorCode = XAException.XAER_RMFAIL;
+                throw failure;
+            }
+        }
+
+        @Override
+        public void start(Xid xid, int flags) throws XAException {
+            refuseRetiredSession();
+            delegate.start(xid, flags);
+            // Booked only once the resource manager accepted the branch: a
+            // refused start attached nothing to this session, so it leaves no
+            // debt. Joined and resumed branches land on the same key, so the
+            // set counts branches, not calls.
+            unresolved.add(new XidKey(xid));
+        }
+
+        @Override
+        public void end(Xid xid, int flags) throws XAException {
+            refuseRetiredSession();
+            delegate.end(xid, flags);
+        }
+
+        @Override
+        public int prepare(Xid xid) throws XAException {
+            refuseRetiredSession();
+            int outcome = delegate.prepare(xid);
+            if (outcome == XAResource.XA_RDONLY) {
+                // The resource manager resolved the branch itself: a
+                // read-only branch is complete at prepare and no commit will
+                // ever come for it.
+                unresolved.remove(new XidKey(xid));
+            }
+            return outcome;
+        }
+
+        @Override
+        public void commit(Xid xid, boolean onePhase) throws XAException {
+            refuseRetiredSession();
+            delegate.commit(xid, onePhase);
+            unresolved.remove(new XidKey(xid));
+        }
+
+        @Override
+        public void rollback(Xid xid) throws XAException {
+            refuseRetiredSession();
+            delegate.rollback(xid);
+            unresolved.remove(new XidKey(xid));
+        }
+
+        @Override
+        public void forget(Xid xid) throws XAException {
+            refuseRetiredSession();
+            delegate.forget(xid);
+            unresolved.remove(new XidKey(xid));
+        }
+
+        @Override
+        public Xid[] recover(int flag) throws XAException {
+            return delegate.recover(flag);
+        }
+
+        @Override
+        public boolean setTransactionTimeout(int seconds) throws XAException {
+            return delegate.setTransactionTimeout(seconds);
+        }
+
+        @Override
+        public int getTransactionTimeout() throws XAException {
+            return delegate.getTransactionTimeout();
+        }
+
+        @Override
+        public boolean isSameRM(XAResource xares) throws XAException {
+            // Two tracked resources are of the same resource manager exactly
+            // when the resources underneath them are; an unwrapped resource
+            // is compared against the driver's own object directly.
+            if (xares instanceof TrackedResource) {
+                return delegate.isSameRM(((TrackedResource) xares).delegate);
+            }
+            return delegate.isSameRM(xares);
+        }
+    }
+}
diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XADataSource.java 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XADataSource.java
index 7d20bd88e7..97e3b21e8c 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XADataSource.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/XADataSource.java
@@ -66,9 +66,11 @@ public class XADataSource extends DataSource implements 
javax.sql.XADataSource {
      * returns the session to the pool instead of closing the physical 
connection; every XA branch started through the
      * returned {@link javax.transaction.xa.XAResource} must therefore be 
completed - committed or rolled back - before
      * {@code close()}, and neither the handle nor its {@code XAResource} may 
be used after it. A handle closed while a
-     * branch stands prepared parks that in-doubt branch, and the database 
locks held for it, on a session the pool
-     * hands to another borrower: the pool's return-time rollback unwinds only 
local work and can never resolve a
-     * prepared branch.
+     * branch stands unresolved is not returned to the pool: the pool's 
return-time rollback unwinds only local work
+     * and can never resolve a branch that stands prepared, so the pool closes 
the session instead of handing another
+     * borrower a connection that carries that branch and the database locks 
held for it. The transaction manager's
+     * late commit or rollback of the abandoned branch then fails against the 
closed session rather than landing,
+     * silently, on a session that does not carry it.
      * <p>
      * The connection obtained from the returned handle's
      * {@link javax.sql.PooledConnection#getConnection()} is the driver's raw 
logical session, not a pool-managed
diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties
index f3cb9a5c2f..a2b0ecd3bd 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties
@@ -69,6 +69,7 @@ connectionPool.unableToSendRequestHint=Unable to send the 
request boundary hint
 connectionPool.unableToStartJmx=Unable to start JMX integration for connection 
pool. Instance[{0}] can''t be monitored.
 connectionPool.unableToTestIdleConnection=Unable to test idle connection 
[{0}], closing it.
 connectionPool.unableToTerminateTransaction=Unable to terminate transaction, 
connection will be closed.
+connectionPool.unresolvedXaBranchReturned=Connection [{0}] was returned with 
XA transaction branches that were never resolved. The session is closed and 
replaced instead of being pooled, so no other borrower inherits a branch or the 
database locks held for it, and the late commit or rollback of the abandoned 
branch fails against the closed session. Complete every branch - commit, 
rollback or forget - before closing a pooled XA handle.
 connectionPool.validationQueryFailed=Validation Query Failed, enable 
logValidationErrors for more details.
 
 connectionPool.asyncBorrowConnectionReleased=The pooled connection served to 
this asynchronous borrow was released before it could be set up; no connection 
was borrowed, borrow the pool again.
@@ -212,6 +213,7 @@ statementFinalizer.unableToCreateTrackingProxy=Unable to 
create statement tracki
 
 trapException.uncaughtUnderlyingException=Uncaught underlying exception.
 
+xaBranchTracker.resourceRetired=The pooled XA session behind this XA resource 
has been closed and taken out of service by the pool; the transaction branches 
left on it were dropped with the session and cannot be resolved any more.
 xaDataSource.dataSourceNotXA=The connection pool [{0}] cannot provide XA 
connections: the data source configured through the ''dataSource'' or 
''dataSourceJNDI'' attribute ({1}) does not implement javax.sql.XADataSource.
 xaDataSource.jndiNotResolved=The connection pool [{0}] cannot provide XA 
connections: the name "{1}" set through the ''dataSourceJNDI'' attribute has 
not been resolved into a data source; the name is only looked up when the pool 
is created through the DataSourceFactory. Set the ''dataSource'' attribute to a 
javax.sql.XADataSource provided by the JDBC driver.
 xaDataSource.noDataSource=The connection pool [{0}] cannot provide XA 
connections: neither the ''dataSource'' nor the ''dataSourceJNDI'' attribute is 
set, so only plain JDBC connections from a JDBC driver and URL can be pooled. 
Set the ''dataSource'' or ''dataSourceJNDI'' attribute to a 
javax.sql.XADataSource provided by the JDBC driver.
diff --git 
a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestUnresolvedXaBranchOnReturn.java
 
b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestUnresolvedXaBranchOnReturn.java
new file mode 100644
index 0000000000..9145cd8b7f
--- /dev/null
+++ 
b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestUnresolvedXaBranchOnReturn.java
@@ -0,0 +1,171 @@
+/*
+ *  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.tomcat.jdbc.pool;
+
+import java.sql.Connection;
+import java.sql.Statement;
+
+import javax.sql.XAConnection;
+import javax.transaction.xa.XAException;
+import javax.transaction.xa.XAResource;
+import javax.transaction.xa.Xid;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tomcat.jdbc.JdbcPoolBaseTest;
+
+/**
+ * A returned handle is a pool borrow, not a physical close, and the pool's
+ * return-time transaction termination unwinds only local work: it can never
+ * resolve an XA branch. A handle closed between {@code xa.end} and
+ * {@code xa.commit} therefore used to re-pool the session with the branch -
+ * and every database lock held for it - attached, so the next borrower
+ * inherited them and the transaction manager's late commit landed on a
+ * session that does not carry the branch. The pool must instead take such a
+ * session out of service, so the resource manager drops the branch with the
+ * session and the late commit fails visibly instead of silently misfiring
+ * across borrowers.
+ */
+class TestUnresolvedXaBranchOnReturn extends JdbcPoolBaseTest {
+
+    /** Simple test transaction id. */
+    private static final class TestXid implements Xid {
+        private final byte qualifier;
+        TestXid(int qualifier) {
+            this.qualifier = (byte) qualifier;
+        }
+        @Override
+        public int getFormatId() {
+            return 1;
+        }
+        @Override
+        public byte[] getGlobalTransactionId() {
+            return new byte[] { 42, qualifier };
+        }
+        @Override
+        public byte[] getBranchQualifier() {
+            return new byte[] { qualifier };
+        }
+    }
+
+    private XADataSource newXaPool() {
+        org.h2.jdbcx.JdbcDataSource inner = new org.h2.jdbcx.JdbcDataSource();
+        inner.setUrl(getUrl());
+        inner.setUser("sa");
+        inner.setPassword("");
+        XADataSource ds = new XADataSource();
+        ds.setDriverClassName("org.h2.Driver");
+        ds.getPoolProperties().setJmxEnabled(false);
+        ds.getPoolProperties().setInitialSize(1);
+        ds.getPoolProperties().setMinIdle(0);
+        ds.getPoolProperties().setDataSource(inner);
+        return manage(ds);
+    }
+
+    @Test
+    void aPreparedBranchOutlivingTheHandleKeepsTheSessionOutOfThePool() throws 
Exception {
+        XADataSource ds = newXaPool();
+        String t = uniqueTable();
+        // The table itself is committed work, so the branch's row stays
+        // countable after the branch - and with it the session - is retired.
+        try (Connection setup = ds.getConnection();
+                Statement st = setup.createStatement()) {
+            st.execute("CREATE TABLE " + t + " (v INT)");
+        }
+
+        XAConnection xc = ds.getXAConnection();
+        XAResource xa = xc.getXAResource();
+        Assertions.assertSame(xa, xc.getXAResource(),
+                "repeated calls must name the same XA resource");
+        Connection c = xc.getConnection();
+        c.setAutoCommit(false);
+        Xid xid = new TestXid(11);
+
+        xa.start(xid, XAResource.TMNOFLAGS);
+        try (Statement st = c.createStatement()) {
+            st.execute("INSERT INTO " + t + " VALUES (1)");
+        }
+        setSessionMarker(c, 77);
+        xa.end(xid, XAResource.TMSUCCESS);
+        Assertions.assertEquals(XAResource.XA_OK, xa.prepare(xid));
+        Assertions.assertEquals(0, adminRowCount(t), "work must be invisible 
before commit");
+
+        // The spec-shaped misuse: abandon the handle between end/prepare and
+        // commit. A physical close would have dropped the branch; the pool
+        // must not turn that close into a re-pool.
+        xc.close();
+
+        Assertions.assertEquals(0, poolSessions(),
+                "a session carrying an unresolved prepared branch must not 
stay in the pool");
+
+        // The late commit of an abandoned branch must fail visibly against
+        // the retired session, not quietly succeed on a re-pooled one - and
+        // as a refusal of the resource manager, the code the XA
+        // specification gives for a resource that can no longer serve.
+        XAException refused = Assertions.assertThrows(XAException.class, () -> 
xa.commit(xid, false),
+                "committing a branch whose session the pool retired must 
fail");
+        Assertions.assertEquals(XAException.XAER_RMFAIL, refused.errorCode);
+        Assertions.assertEquals(0, adminRowCount(t),
+                "the abandoned branch must not resurface through another 
borrower");
+
+        try (Connection again = ds.getConnection()) {
+            Assertions.assertNull(sessionMarker(again),
+                    "the next borrower must not inherit the session the branch 
was parked on");
+        }
+    }
+
+    @Test
+    void anOpenBranchOutlivingTheHandleKeepsTheSessionOutOfThePool() throws 
Exception {
+        XADataSource ds = newXaPool();
+        String t = uniqueTable();
+        try (Connection setup = ds.getConnection();
+                Statement st = setup.createStatement()) {
+            st.execute("CREATE TABLE " + t + " (v INT)");
+        }
+
+        XAConnection xc = ds.getXAConnection();
+        XAResource xa = xc.getXAResource();
+        Connection c = xc.getConnection();
+        c.setAutoCommit(false);
+        Xid xid = new TestXid(12);
+
+        // A branch that stands started and never resolved is just as attached
+        // to the session as a prepared one: end() alone would not detach it
+        // either, and the locks its work took are held the same way.
+        xa.start(xid, XAResource.TMNOFLAGS);
+        try (Statement st = c.createStatement()) {
+            st.execute("INSERT INTO " + t + " VALUES (1)");
+        }
+        setSessionMarker(c, 78);
+
+        xc.close();
+
+        Assertions.assertEquals(0, poolSessions(),
+                "a session carrying an unresolved branch must not stay in the 
pool");
+        XAException refused = Assertions.assertThrows(XAException.class, () -> 
xa.rollback(xid),
+                "resolving a branch whose session the pool retired must fail");
+        Assertions.assertEquals(XAException.XAER_RMFAIL, refused.errorCode);
+        Assertions.assertEquals(0, adminRowCount(t),
+                "the abandoned branch's work must not resurface through 
another borrower");
+
+        try (Connection again = ds.getConnection()) {
+            Assertions.assertNull(sessionMarker(again),
+                    "the next borrower must not inherit the session the branch 
was parked on");
+        }
+    }
+}


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

Reply via email to