From 21ec132f98f286798d5f794e1406b10fbf26062d Mon Sep 17 00:00:00 2001 From: Torsten Liermann Date: Thu, 30 Apr 2026 14:59:32 +0200 Subject: [PATCH] JCR-5239: Skip direct JDBC tx-control on managed XA connection during active JTA transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ConnectionHelper runs inside a Jakarta/Java EE container (WildFly, JBoss, ...) the DataSource is typically a JCA-managed connection pool. While the application thread holds an active container-managed JTA transaction, the wrapped Connection refuses explicit setAutoCommit(false), commit() and rollback() calls (e.g. IronJacamar IJ031017 / IJ031018 / IJ031019) because the transaction manager owns the connection's transactional lifecycle. This change resolves a TransactionSynchronizationRegistry once via JNDI (java:comp/TransactionSynchronizationRegistry) and uses its getTransactionStatus() to detect a managed transaction on the current thread. When such a transaction is active, the three direct JDBC transaction-control calls in startBatch(), endBatch() and getConnection() are skipped — the TM commits/rolls back the underlying XAResource as part of the global transaction. When no managed transaction is in progress, the historical behaviour is preserved (auto-commit toggling and explicit commit/rollback as before). The TSR is referenced through reflection so the same code compiles and runs against javax.transaction (Java EE 8) and jakarta.transaction (Jakarta EE 9+) container stacks. Signed-off-by: Torsten Liermann --- .../core/util/db/ConnectionHelper.java | 289 ++++++++++++- .../db/ConnectionHelperJtaContextTest.java | 174 ++++++++ .../core/util/db/ConnectionHelperJtaIT.java | 406 ++++++++++++++++++ 3 files changed, 861 insertions(+), 8 deletions(-) create mode 100644 jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaContextTest.java create mode 100644 jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaIT.java diff --git a/jackrabbit-data/src/main/java/org/apache/jackrabbit/core/util/db/ConnectionHelper.java b/jackrabbit-data/src/main/java/org/apache/jackrabbit/core/util/db/ConnectionHelper.java index 4610e81d7cd..40029099613 100644 --- a/jackrabbit-data/src/main/java/org/apache/jackrabbit/core/util/db/ConnectionHelper.java +++ b/jackrabbit-data/src/main/java/org/apache/jackrabbit/core/util/db/ConnectionHelper.java @@ -16,6 +16,8 @@ */ package org.apache.jackrabbit.core.util.db; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; @@ -26,6 +28,8 @@ import java.util.HashMap; import java.util.Map; +import javax.naming.InitialContext; +import javax.naming.NamingException; import javax.sql.DataSource; import org.apache.jackrabbit.data.core.TransactionContext; @@ -83,10 +87,241 @@ public class ConnectionHelper { private Map batchConnectionMap = Collections.synchronizedMap(new HashMap()); /** - * The default fetchSize is '0'. This means the fetchSize Hint will be ignored + * The default fetchSize is '0'. This means the fetchSize Hint will be ignored */ private int fetchSize = 0; + // ------------------------------------------------------------------------ + // JTA-aware autoCommit / commit / rollback handling. + // + // When this helper runs inside a Jakarta/Java EE container (WildFly, + // JBoss, ...) the DataSource handed in here is typically a JCA-managed + // connection pool. As soon as the application thread holds an active + // container-managed JTA transaction, the wrapped Connection refuses + // explicit setAutoCommit(false), commit() and rollback() calls (e.g. + // IronJacamar IJ031017 / IJ031018 / IJ031019) -- the transaction + // manager owns the lifecycle. + // + // The {@link JtaContext} below resolves the ambient container + // TransactionSynchronizationRegistry on first use (lazy JNDI lookup so + // ConnectionHelper instances created before the namespace is fully + // bound still work) and offers two operations: + // - hasManagedTransaction(): true when a transaction is currently + // associated with this thread; STATUS_NO_TRANSACTION is the only + // status considered safe for direct JDBC tx-control. STATUS_UNKNOWN + // means the TM cannot determine the status, so the helper treats + // it as "associated" (fail closed). Reflection or invocation + // failures with a TSR present are also treated as "associated". + // - markRollbackOnly(): mirrors a caller's local rollback intent + // onto the global JTA transaction so the TM aborts the global + // branch when the caller could not. + // + // The TSR is referenced through reflection so the class compiles and + // runs unchanged against javax.transaction (Java EE 8) and + // jakarta.transaction (Jakarta EE 9+) container stacks. + // ------------------------------------------------------------------------ + + private volatile JtaContext jtaContext; + + /** + * Returns the {@link JtaContext} for this helper, performing the + * (idempotent) lazy JNDI lookup on first call. The method is + * {@code final} and package-private; tests in this package can + * inject a context up front via {@link #setJtaContext(JtaContext)}. + */ + final JtaContext getJtaContext() { + JtaContext ctx = jtaContext; + if (ctx == null) { + ctx = JtaContext.discover(); + jtaContext = ctx; + } + return ctx; + } + + /** + * Test hook: replace the discovered JtaContext with a stub. Visible + * to tests in this package (the method is final and package-private, + * not a subclass extension point). Must be called before the first + * batch operation; subsequent calls to {@link #getJtaContext()} will + * return the supplied instance. + */ + final void setJtaContext(JtaContext ctx) { + this.jtaContext = ctx; + } + + /** + * Encapsulates the JTA TransactionSynchronizationRegistry access + * required by JCR-5239. Package-private. Each {@link ConnectionHelper} + * holds its own resolved {@code JtaContext} (the TSR object provided + * by the container is itself thread-aware, so there is no shared + * caller state inside this class beyond the bound reflective methods). + * The {@link #NONE} sentinel for "no TSR available" is reused. + * + *

Two factory methods: + *

    + *
  • {@link #discover()} performs the JNDI lookup against + * {@code java:comp/TransactionSynchronizationRegistry} and the + * reflective method binding. Always returns a non-null instance; + * when no TSR is available the returned instance reports + * {@code hasManagedTransaction() == false} on every call.
  • + *
  • {@link #forTesting(Object)} accepts an externally supplied + * TSR-like object so unit tests can drive the decision logic + * without bringing up a JNDI context.
  • + *
+ */ + static final class JtaContext { + + private static final int STATUS_NO_TRANSACTION_VALUE = 6; + + private static final JtaContext NONE = new JtaContext(null, null, null); + + private final Object tsr; + private final Method getTransactionStatusMethod; + private final Method setRollbackOnlyMethod; + + private JtaContext(Object tsr, Method getStatus, Method setRollbackOnly) { + this.tsr = tsr; + this.getTransactionStatusMethod = getStatus; + this.setRollbackOnlyMethod = setRollbackOnly; + } + + static JtaContext discover() { + Object tsr = lookupTsr(); + if (tsr == null) { + return NONE; + } + Method getStatus = resolveMethod(tsr, "getTransactionStatus"); + Method setRollback = resolveMethod(tsr, "setRollbackOnly"); + return new JtaContext(tsr, getStatus, setRollback); + } + + static JtaContext forTesting(Object tsr) { + if (tsr == null) { + return NONE; + } + return new JtaContext( + tsr, + resolveMethod(tsr, "getTransactionStatus"), + resolveMethod(tsr, "setRollbackOnly")); + } + + private static Object lookupTsr() { + InitialContext ctx = null; + try { + ctx = new InitialContext(); + return ctx.lookup("java:comp/TransactionSynchronizationRegistry"); + } catch (NamingException e) { + LoggerFactory.getLogger(ConnectionHelper.class).debug( + "No TransactionSynchronizationRegistry bound at " + + "java:comp/TransactionSynchronizationRegistry, " + + "JTA-aware tx handling disabled: {}", + e.toString()); + return null; + } finally { + if (ctx != null) { + try { + ctx.close(); + } catch (NamingException ignored) { + // close failure is not actionable + } + } + } + } + + private static Method resolveMethod(Object tsr, String name) { + // Prefer the public TSR interface types so we do not depend on a + // package-private container implementation class. Try Jakarta + // first, then Java EE 8. + for (String typeName : new String[] { + "jakarta.transaction.TransactionSynchronizationRegistry", + "javax.transaction.TransactionSynchronizationRegistry" }) { + try { + Class type = Class.forName(typeName); + if (type.isInstance(tsr)) { + return type.getMethod(name); + } + } catch (ClassNotFoundException e) { + // expected on the other platform variant + } catch (NoSuchMethodException e) { + // unexpected on a public TSR interface, fall through + } + } + try { + return tsr.getClass().getMethod(name); + } catch (NoSuchMethodException e) { + LoggerFactory.getLogger(ConnectionHelper.class).warn( + "TransactionSynchronizationRegistry instance {} does not expose method {}(); " + + "JTA-aware handling for this method will be unavailable. " + + "hasManagedTransaction() will fall back to fail-closed (treat as managed); " + + "markRollbackOnly() will report failure to the caller.", + tsr.getClass().getName(), name); + return null; + } + } + + /** + * @return {@code true} if a managed JTA transaction is currently + * associated with this thread. Fail-closed: when a TSR is + * present but the status cannot be determined (reflection + * binding missing, invocation failure, non-integer return, + * etc.) this method returns {@code true} so callers do not + * run direct JDBC tx-control on a possibly enrolled + * connection. + */ + boolean hasManagedTransaction() { + if (tsr == null) { + return false; + } + if (getTransactionStatusMethod == null) { + // TSR was discovered but status cannot be read -- assume a + // transaction may be associated and skip direct JDBC calls. + return true; + } + Object status; + try { + status = getTransactionStatusMethod.invoke(tsr); + } catch (IllegalAccessException | InvocationTargetException e) { + LoggerFactory.getLogger(ConnectionHelper.class).debug( + "Failed to read TransactionSynchronizationRegistry status; " + + "treating thread as transaction-associated.", e); + return true; + } + if (!(status instanceof Integer)) { + return true; + } + return ((Integer) status).intValue() != STATUS_NO_TRANSACTION_VALUE; + } + + /** + * Mark the currently associated JTA transaction as rollback-only. + * + * @return {@code true} if the rollback-only marker was applied to + * the global JTA transaction; {@code false} when no TSR + * was discovered, when the {@code setRollbackOnly()} + * method could not be bound, or when the reflective + * invocation failed. Callers in batch-rollback paths + * (e.g. {@link ConnectionHelper#endBatch(boolean)} with + * {@code commit==false}) must surface a {@code false} + * return as a failure to the caller, otherwise a + * requested rollback can disappear silently while the + * global transaction still commits. + */ + boolean markRollbackOnly() { + if (tsr == null || setRollbackOnlyMethod == null) { + return false; + } + try { + setRollbackOnlyMethod.invoke(tsr); + return true; + } catch (IllegalAccessException | InvocationTargetException e) { + LoggerFactory.getLogger(ConnectionHelper.class).warn( + "Failed to mark managed JTA transaction as rollback-only " + + "via TransactionSynchronizationRegistry.setRollbackOnly()", e); + return false; + } + } + } + /** * @param dataSrc the {@link DataSource} on which this instance acts * @param block whether the helper should transparently block on DB connection loss (otherwise it retries @@ -237,7 +472,14 @@ public final void startBatch() throws SQLException { Connection batchConnection = null; try { batchConnection = getConnection(false); - batchConnection.setAutoCommit(false); + // JTA-aware tx handling: inside a container-managed JTA + // transaction the connection's auto-commit state is controlled + // by the transaction manager; an explicit setAutoCommit(false) + // here would be rejected by the JCA pool (e.g. IronJacamar + // IJ031017). + if (!getJtaContext().hasManagedTransaction()) { + batchConnection.setAutoCommit(false); + } setTransactionAwareBatchConnection(batchConnection); } catch (SQLException e) { removeTransactionAwareBatchConnection(); @@ -260,12 +502,37 @@ public final void endBatch(boolean commit) throws SQLException { if (!inBatchMode()) { throw new SQLException("not in batch mode"); } - Connection batchConnection = getTransactionAwareBatchConnection(); + Connection batchConnection = getTransactionAwareBatchConnection(); try { - if (commit) { - batchConnection.commit(); + // JTA-aware tx handling: inside a container-managed JTA + // transaction the transaction manager owns commit/rollback on + // the underlying XAResource -- issuing them directly on the + // wrapped Connection is rejected by JCA pools (IJ031018 / + // IJ031019). For commit==true the global TM commits the branch + // when the outer JTA transaction commits, so the local commit() + // would be redundant either way. For commit==false the caller + // requested a rollback that we cannot perform locally, so the + // closest semantic equivalent is to mark the global JTA + // transaction rollback-only via the TSR; the TM will then + // abort the global transaction (and this branch with it). If + // setRollbackOnly() is unavailable or fails, surface that as + // a SQLException so the caller's rollback intent is not lost + // silently. Outside a managed tx the historical behaviour + // applies. + JtaContext ctx = getJtaContext(); + if (ctx.hasManagedTransaction()) { + if (!commit && !ctx.markRollbackOnly()) { + throw new SQLException( + "Unable to roll back batch inside managed JTA transaction: " + + "TransactionSynchronizationRegistry.setRollbackOnly() " + + "is unavailable or failed"); + } } else { - batchConnection.rollback(); + if (commit) { + batchConnection.commit(); + } else { + batchConnection.rollback(); + } } } finally { removeTransactionAwareBatchConnection(); @@ -446,8 +713,14 @@ protected final Connection getConnection(boolean inBatchMode) throws SQLExceptio return getTransactionAwareBatchConnection(); } else { Connection con = dataSource.getConnection(); - // JCR-1013: Setter may fail unnecessarily on a managed connection - if (!con.getAutoCommit()) { + // JCR-1013 + JTA-aware skip: the original mitigation flips + // auto-commit back to true when the wrapped Connection reports + // it off, but on a JCA-managed connection enrolled in an + // active JTA transaction the setAutoCommit call is rejected + // (IJ031017). Skip the flip while a managed tx is in progress. + // The JTA check runs first so managed-JTA paths do not even + // read the auto-commit state. + if (!getJtaContext().hasManagedTransaction() && !con.getAutoCommit()) { con.setAutoCommit(true); } return con; diff --git a/jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaContextTest.java b/jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaContextTest.java new file mode 100644 index 00000000000..9039e8a63d9 --- /dev/null +++ b/jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaContextTest.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.core.util.db; + +import junit.framework.TestCase; + +import org.apache.jackrabbit.core.util.db.ConnectionHelper.JtaContext; + +/** + * Verifies the decision logic in {@link ConnectionHelper.JtaContext}: + * which JTA status values cause direct JDBC tx-control to be skipped, + * and how reflection / invocation failures fall back. The tests use a + * {@link FakeTransactionSynchronizationRegistry} as a stand-in for the + * container-supplied TSR so no JNDI context is needed. + * + *

JTA-Status values follow the Status interface contract: + *

    + *
  • {@code STATUS_ACTIVE = 0}
  • + *
  • {@code STATUS_MARKED_ROLLBACK = 1}
  • + *
  • {@code STATUS_PREPARED = 2}
  • + *
  • {@code STATUS_COMMITTED = 3}
  • + *
  • {@code STATUS_ROLLEDBACK = 4}
  • + *
  • {@code STATUS_UNKNOWN = 5}
  • + *
  • {@code STATUS_NO_TRANSACTION = 6}
  • + *
+ */ +public class ConnectionHelperJtaContextTest extends TestCase { + + public void testNoRegistryMeansNoManagedTransaction() { + JtaContext ctx = JtaContext.forTesting(null); + assertFalse(ctx.hasManagedTransaction()); + // markRollbackOnly() must be safe to call without a registry. + ctx.markRollbackOnly(); + } + + public void testStatusNoTransactionMeansNoManagedTransaction() { + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(6); + JtaContext ctx = JtaContext.forTesting(tsr); + assertFalse(ctx.hasManagedTransaction()); + } + + public void testStatusActiveMeansManagedTransaction() { + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(0); + JtaContext ctx = JtaContext.forTesting(tsr); + assertTrue(ctx.hasManagedTransaction()); + } + + public void testStatusMarkedRollbackMeansManagedTransaction() { + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(1); + JtaContext ctx = JtaContext.forTesting(tsr); + assertTrue(ctx.hasManagedTransaction()); + } + + public void testStatusUnknownMeansManagedTransaction() { + // STATUS_UNKNOWN means a transaction is associated but its status + // cannot be determined; treat as managed (fail closed). + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(5); + JtaContext ctx = JtaContext.forTesting(tsr); + assertTrue(ctx.hasManagedTransaction()); + } + + public void testStatusInvocationFailureMeansManagedTransaction() { + // Reflective invocation failures on a present TSR must be treated + // as managed (fail closed), so callers do not run direct JDBC + // tx-control on a possibly enrolled connection. + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(0); + tsr.failOnGetStatus = true; + JtaContext ctx = JtaContext.forTesting(tsr); + assertTrue(ctx.hasManagedTransaction()); + } + + public void testMarkRollbackOnlyDelegatesToRegistry() { + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(0); + JtaContext ctx = JtaContext.forTesting(tsr); + assertEquals(0, tsr.rollbackOnlyCalls); + assertTrue(ctx.markRollbackOnly()); + assertEquals(1, tsr.rollbackOnlyCalls); + } + + public void testMarkRollbackOnlyReportsInvocationFailure() { + // setRollbackOnly() failures must be visible to the caller so the + // batch-rollback path can surface them as SQLException; otherwise + // a requested rollback could vanish while the global transaction + // still commits. + FakeTransactionSynchronizationRegistry tsr = new FakeTransactionSynchronizationRegistry(0); + tsr.failOnSetRollbackOnly = true; + JtaContext ctx = JtaContext.forTesting(tsr); + assertFalse(ctx.markRollbackOnly()); + } + + public void testMarkRollbackOnlyReportsAbsenceOfRegistry() { + JtaContext ctx = JtaContext.forTesting(null); + assertFalse(ctx.markRollbackOnly()); + } + + public void testMissingGetTransactionStatusMethodMeansManagedTransaction() { + // TSR present but does not expose getTransactionStatus(); the + // resolveMethod() reflective lookup returns null and the helper + // must fall back to fail-closed (treat thread as transaction- + // associated) so callers do not run direct JDBC tx-control on + // a possibly enrolled connection. + JtaContext ctx = JtaContext.forTesting(new TsrWithoutGetTransactionStatus()); + assertTrue(ctx.hasManagedTransaction()); + } + + public void testMarkRollbackOnlyReportsMissingMethod() { + // TSR present but does not expose setRollbackOnly(); the helper + // must report failure to the caller (so endBatch(false) can + // surface it as SQLException) rather than silently no-op. + JtaContext ctx = JtaContext.forTesting(new TsrWithoutSetRollbackOnly()); + assertFalse(ctx.markRollbackOnly()); + } + + /** TSR stand-in lacking getTransactionStatus(). */ + public static final class TsrWithoutGetTransactionStatus { + public void setRollbackOnly() { + // no-op + } + } + + /** TSR stand-in lacking setRollbackOnly(). */ + public static final class TsrWithoutSetRollbackOnly { + public int getTransactionStatus() { + return 0; // STATUS_ACTIVE + } + } + + /** + * Test stand-in that exposes the two TSR-public methods exercised by + * {@link JtaContext}. The duck-typed reflective lookup in + * {@code JtaContext} resolves these by name on the concrete class + * because the real TSR interface types may not be on the test + * classpath. + */ + public static final class FakeTransactionSynchronizationRegistry { + + private final int status; + boolean failOnGetStatus; + boolean failOnSetRollbackOnly; + int rollbackOnlyCalls; + + FakeTransactionSynchronizationRegistry(int status) { + this.status = status; + } + + public int getTransactionStatus() { + if (failOnGetStatus) { + throw new IllegalStateException("forced failure for test"); + } + return status; + } + + public void setRollbackOnly() { + if (failOnSetRollbackOnly) { + throw new IllegalStateException("forced failure for test"); + } + rollbackOnlyCalls++; + } + } +} diff --git a/jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaIT.java b/jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaIT.java new file mode 100644 index 00000000000..f66a2546109 --- /dev/null +++ b/jackrabbit-data/src/test/java/org/apache/jackrabbit/core/util/db/ConnectionHelperJtaIT.java @@ -0,0 +1,406 @@ +/* + * 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.jackrabbit.core.util.db; + +import java.io.PrintWriter; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; + +import javax.sql.DataSource; + +import junit.framework.TestCase; + +import org.apache.jackrabbit.core.util.db.ConnectionHelper.JtaContext; + +/** + * Verifies that {@link ConnectionHelper}'s batch-control methods skip the + * direct JDBC tx-control calls when a managed JTA transaction is active, + * call them as before when no managed transaction is in progress, and + * surface a TSR-side rollback-only failure to the caller as a + * {@link SQLException}. + * + *

The tests use a {@link CountingConnection} that records whether + * {@code setAutoCommit}, {@code commit} and {@code rollback} were called, + * and a hand-rolled {@link DataSource} that hands out a single connection + * instance per test. The JTA decision is driven by injecting a + * {@link JtaContext} via {@link ConnectionHelper}'s package-private + * {@code setJtaContext()} hook, so no JNDI context is needed. + */ +public class ConnectionHelperJtaIT extends TestCase { + + public void testStartBatchSkipsSetAutoCommitUnderManagedTx() throws SQLException { + CountingConnection con = new CountingConnection(); + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(new FakeTsr(0))); // STATUS_ACTIVE + + helper.startBatch(); + try { + assertEquals("setAutoCommit must NOT be called inside a managed JTA tx", + 0, con.setAutoCommitCalls.get()); + } finally { + // endBatch with commit==true is a no-op on the JDBC side under + // managed JTA -- it must not throw. + helper.endBatch(true); + } + assertEquals("commit must NOT be called inside a managed JTA tx", + 0, con.commitCalls.get()); + assertEquals("rollback must NOT be called inside a managed JTA tx", + 0, con.rollbackCalls.get()); + } + + public void testStartBatchCallsSetAutoCommitOutsideManagedTx() throws SQLException { + CountingConnection con = new CountingConnection(); + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(null)); // no TSR + + helper.startBatch(); + try { + assertEquals(1, con.setAutoCommitCalls.get()); + assertFalse("setAutoCommit(false) expected", con.lastAutoCommitArg); + } finally { + helper.endBatch(true); + } + assertEquals("commit() expected when no managed tx and commit=true", + 1, con.commitCalls.get()); + } + + public void testEndBatchRollbackOutsideManagedTxCallsConnectionRollback() throws SQLException { + CountingConnection con = new CountingConnection(); + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(null)); + + helper.startBatch(); + helper.endBatch(false); + + assertEquals(1, con.rollbackCalls.get()); + assertEquals(0, con.commitCalls.get()); + } + + public void testEndBatchRollbackUnderManagedTxMarksTransactionRollbackOnly() throws SQLException { + FakeTsr tsr = new FakeTsr(0); // STATUS_ACTIVE + CountingConnection con = new CountingConnection(); + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(tsr)); + + helper.startBatch(); + helper.endBatch(false); + + assertEquals("Connection.rollback must NOT be called under managed JTA", + 0, con.rollbackCalls.get()); + assertEquals("setRollbackOnly() must be called when caller requests rollback", + 1, tsr.rollbackOnlyCalls); + } + + public void testEndBatchRollbackUnderManagedTxThrowsWhenSetRollbackOnlyFails() throws SQLException { + FakeTsr tsr = new FakeTsr(0); + tsr.failOnSetRollbackOnly = true; + CountingConnection con = new CountingConnection(); + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(tsr)); + + helper.startBatch(); + try { + helper.endBatch(false); + fail("endBatch(false) must throw SQLException when setRollbackOnly() fails"); + } catch (SQLException expected) { + // expected -- caller's rollback intent must not be lost + } + assertEquals("Connection.rollback must NOT be called under managed JTA", + 0, con.rollbackCalls.get()); + } + + public void testGetConnectionSkipsSetAutoCommitUnderManagedTx() throws SQLException { + CountingConnection con = new CountingConnection(); + con.autoCommitState = false; // simulate the JCA-managed pool's reported state + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(new FakeTsr(0))); + + Connection out = helper.getConnection(false); + + assertSame(con, out); + assertEquals("setAutoCommit must NOT be called inside a managed JTA tx", + 0, con.setAutoCommitCalls.get()); + } + + public void testGetConnectionRestoresAutoCommitOutsideManagedTx() throws SQLException { + CountingConnection con = new CountingConnection(); + con.autoCommitState = false; + ConnectionHelper helper = new ConnectionHelper(new SingletonDataSource(con), false); + helper.setJtaContext(JtaContext.forTesting(null)); + + helper.getConnection(false); + + assertEquals(1, con.setAutoCommitCalls.get()); + assertTrue("setAutoCommit(true) expected (JCR-1013 mitigation)", + con.lastAutoCommitArg); + } + + /** + * Hand-rolled stand-in for the container TSR. The reflective lookup + * in {@link JtaContext} does not require a real + * {@code TransactionSynchronizationRegistry} -- it duck-types on + * method names. + */ + public static final class FakeTsr { + + private final int status; + boolean failOnSetRollbackOnly; + int rollbackOnlyCalls; + + FakeTsr(int status) { + this.status = status; + } + + public int getTransactionStatus() { + return status; + } + + public void setRollbackOnly() { + if (failOnSetRollbackOnly) { + throw new IllegalStateException("forced failure for test"); + } + rollbackOnlyCalls++; + } + } + + /** + * Minimal {@link DataSource} that hands out the same {@link Connection} + * for every {@code getConnection()} call. Other methods either return + * sensible defaults or throw {@link SQLFeatureNotSupportedException}. + */ + private static final class SingletonDataSource implements DataSource { + + private final Connection connection; + + SingletonDataSource(Connection connection) { + this.connection = connection; + } + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public Connection getConnection(String username, String password) { + return connection; + } + + @Override + public PrintWriter getLogWriter() { + return null; + } + + @Override + public void setLogWriter(PrintWriter out) { + } + + @Override + public void setLoginTimeout(int seconds) { + } + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException(); + } + + @Override + public T unwrap(Class iface) { + return null; + } + + @Override + public boolean isWrapperFor(Class iface) { + return false; + } + } + + /** + * {@link Connection} stand-in that counts the three tx-control method + * calls we care about. Other methods are routed to a JDK proxy that + * returns sensible defaults / no-ops. + */ + private static final class CountingConnection implements Connection { + + private final AtomicInteger setAutoCommitCalls = new AtomicInteger(); + private final AtomicInteger commitCalls = new AtomicInteger(); + private final AtomicInteger rollbackCalls = new AtomicInteger(); + private boolean autoCommitState = true; + private boolean lastAutoCommitArg; + private final Connection delegate; + + CountingConnection() { + this.delegate = (Connection) Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + new NoopHandler()); + } + + @Override + public void setAutoCommit(boolean autoCommit) { + setAutoCommitCalls.incrementAndGet(); + lastAutoCommitArg = autoCommit; + autoCommitState = autoCommit; + } + + @Override + public boolean getAutoCommit() { + return autoCommitState; + } + + @Override + public void commit() { + commitCalls.incrementAndGet(); + } + + @Override + public void rollback() { + rollbackCalls.incrementAndGet(); + } + + @Override + public void close() { + } + + @Override + public boolean isClosed() { + return false; + } + + // Delegate every other method to a no-op proxy so this class + // stays compatible with future Connection method additions. + @Override + public java.sql.Statement createStatement() throws SQLException { return delegate.createStatement(); } + @Override + public java.sql.PreparedStatement prepareStatement(String sql) throws SQLException { return delegate.prepareStatement(sql); } + @Override + public java.sql.CallableStatement prepareCall(String sql) throws SQLException { return delegate.prepareCall(sql); } + @Override + public String nativeSQL(String sql) throws SQLException { return delegate.nativeSQL(sql); } + @Override + public java.sql.DatabaseMetaData getMetaData() throws SQLException { return delegate.getMetaData(); } + @Override + public void setReadOnly(boolean readOnly) throws SQLException { delegate.setReadOnly(readOnly); } + @Override + public boolean isReadOnly() throws SQLException { return delegate.isReadOnly(); } + @Override + public void setCatalog(String catalog) throws SQLException { delegate.setCatalog(catalog); } + @Override + public String getCatalog() throws SQLException { return delegate.getCatalog(); } + @Override + public void setTransactionIsolation(int level) throws SQLException { delegate.setTransactionIsolation(level); } + @Override + public int getTransactionIsolation() throws SQLException { return delegate.getTransactionIsolation(); } + @Override + public java.sql.SQLWarning getWarnings() throws SQLException { return null; } + @Override + public void clearWarnings() {} + @Override + public java.sql.Statement createStatement(int rsType, int rsConcurrency) throws SQLException { return delegate.createStatement(rsType, rsConcurrency); } + @Override + public java.sql.PreparedStatement prepareStatement(String sql, int rsType, int rsConcurrency) throws SQLException { return delegate.prepareStatement(sql, rsType, rsConcurrency); } + @Override + public java.sql.CallableStatement prepareCall(String sql, int rsType, int rsConcurrency) throws SQLException { return delegate.prepareCall(sql, rsType, rsConcurrency); } + @Override + public java.util.Map> getTypeMap() throws SQLException { return null; } + @Override + public void setTypeMap(java.util.Map> map) {} + @Override + public void setHoldability(int holdability) {} + @Override + public int getHoldability() { return 0; } + @Override + public java.sql.Savepoint setSavepoint() throws SQLException { return delegate.setSavepoint(); } + @Override + public java.sql.Savepoint setSavepoint(String name) throws SQLException { return delegate.setSavepoint(name); } + @Override + public void rollback(java.sql.Savepoint savepoint) throws SQLException { rollbackCalls.incrementAndGet(); } + @Override + public void releaseSavepoint(java.sql.Savepoint savepoint) {} + @Override + public java.sql.Statement createStatement(int a, int b, int c) throws SQLException { return delegate.createStatement(a, b, c); } + @Override + public java.sql.PreparedStatement prepareStatement(String s, int a, int b, int c) throws SQLException { return delegate.prepareStatement(s, a, b, c); } + @Override + public java.sql.CallableStatement prepareCall(String s, int a, int b, int c) throws SQLException { return delegate.prepareCall(s, a, b, c); } + @Override + public java.sql.PreparedStatement prepareStatement(String s, int a) throws SQLException { return delegate.prepareStatement(s, a); } + @Override + public java.sql.PreparedStatement prepareStatement(String s, int[] a) throws SQLException { return delegate.prepareStatement(s, a); } + @Override + public java.sql.PreparedStatement prepareStatement(String s, String[] a) throws SQLException { return delegate.prepareStatement(s, a); } + @Override + public java.sql.Clob createClob() { return null; } + @Override + public java.sql.Blob createBlob() { return null; } + @Override + public java.sql.NClob createNClob() { return null; } + @Override + public java.sql.SQLXML createSQLXML() { return null; } + @Override + public boolean isValid(int timeout) { return true; } + @Override + public void setClientInfo(String name, String value) {} + @Override + public void setClientInfo(java.util.Properties properties) {} + @Override + public String getClientInfo(String name) { return null; } + @Override + public java.util.Properties getClientInfo() { return new java.util.Properties(); } + @Override + public java.sql.Array createArrayOf(String typeName, Object[] elements) { return null; } + @Override + public java.sql.Struct createStruct(String typeName, Object[] attributes) { return null; } + @Override + public void setSchema(String schema) {} + @Override + public String getSchema() { return null; } + @Override + public void abort(java.util.concurrent.Executor executor) {} + @Override + public void setNetworkTimeout(java.util.concurrent.Executor executor, int milliseconds) {} + @Override + public int getNetworkTimeout() { return 0; } + @Override + public T unwrap(Class iface) { return null; } + @Override + public boolean isWrapperFor(Class iface) { return false; } + + private static final class NoopHandler implements InvocationHandler { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + Class rt = method.getReturnType(); + if (rt == boolean.class) return Boolean.FALSE; + if (rt == int.class || rt == long.class || rt == short.class || rt == byte.class) return 0; + if (rt == double.class || rt == float.class) return 0.0; + if (rt == char.class) return '\0'; + return null; + } + } + } +}