This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/main by this push:
new ae0256a9cc Fixes #9056. Stop swallowing JTA rollback and resume
failures
ae0256a9cc is described below
commit ae0256a9cc1bda67c8ada0513594b2292aa90aac
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Sep 1 15:40:49 2026 +0200
Fixes #9056. Stop swallowing JTA rollback and resume failures
* Fixes #9056. Stop swallowing JTA rollback and resume failures
TransactionalJtaTransactionPolicy, the base class of all six PROPAGATION_*
policies, logged a warning and carried on when rollback(),
setRollbackOnly() or
resume() failed. A route that handled the original exception could then
continue
as though the transaction had been marked for rollback when it had not, and
work
after a failed resume ran outside the transaction the policy was expected to
restore.
Raise those failures instead. Neither call site lets one replace the
exception
that made the rollback necessary: runWithTransaction and commit attach a
rollback
failure to the in-flight exception as a suppressed exception, and the two
policies that suspend pass their in-flight exception into resumeTransaction
so a
failure in the finally block is attached rather than masking it.
No signatures change. resumeTransaction(Transaction) keeps its shape and
wraps a
failure in a RuntimeCamelException; a resumeTransaction(Transaction,
Throwable)
overload carries the in-flight exception for the policies that need it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Fixes #9056. Use the non-deprecated QuarkusExtensionTest
QuarkusUnitTest is deprecated in favour of QuarkusExtensionTest. Both extend
AbstractQuarkusExtensionTest and setArchiveProducer lives on that shared
base,
so this is a straight swap.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/migration-guide/3.40.0.adoc | 15 +++
.../jta/JtaTransactionFailurePropagationTest.java | 137 +++++++++++++++++++++
.../jta/NotSupportedJtaTransactionPolicy.java | 7 +-
.../jta/RequiresNewJtaTransactionPolicy.java | 7 +-
.../jta/TransactionalJtaTransactionPolicy.java | 56 +++++++--
5 files changed, 212 insertions(+), 10 deletions(-)
diff --git a/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
b/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
index 69cb3e0074..630aa2255e 100644
--- a/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
+++ b/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
@@ -18,3 +18,18 @@ To keep the previous behaviour for a context that relied on
the default, set it
----
quarkus.camel.ldap.dir-contexts."my-context".security-authentication=none
----
+
+== JTA extension changes
+
+=== Rollback and resume failures are no longer swallowed
+
+`TransactionalJtaTransactionPolicy`, the base class of all six `PROPAGATION_*`
policies, logged a warning and carried on when `rollback()`,
`setRollbackOnly()` or `resume()` failed. A route that handled the original
exception could therefore continue as though the transaction had been marked
for rollback when it had not, and work after a failed resume ran outside the
transaction the policy was expected to restore.
+
+These failures are now raised:
+
+* A failure to roll back, or to mark an outer transaction for rollback, is
attached to the exception that made the rollback necessary as a suppressed
exception. That exception still propagates unchanged, so the failure is visible
without hiding its cause.
+* A failure to resume a suspended transaction, in `PROPAGATION_REQUIRES_NEW`
and `PROPAGATION_NOT_SUPPORTED`, is attached to the in-flight failure in the
same way, or raised on its own when the body succeeded.
+
+An application that inspected only the top-level exception sees no difference.
One that handles exceptions and continues may now see a failure it previously
never learned about. Check `Throwable.getSuppressed()` when diagnosing.
+
+No method signatures changed. `resumeTransaction(Transaction)` is unchanged
and now wraps a resume failure in a `RuntimeCamelException`; a new
`resumeTransaction(Transaction, Throwable)` overload is what the policies use
so that a resume failure cannot replace a failure already on its way out.
diff --git
a/extensions/jta/deployment/src/test/java/org/apache/camel/quarkus/component/jta/JtaTransactionFailurePropagationTest.java
b/extensions/jta/deployment/src/test/java/org/apache/camel/quarkus/component/jta/JtaTransactionFailurePropagationTest.java
new file mode 100644
index 0000000000..5d22fa3d00
--- /dev/null
+++
b/extensions/jta/deployment/src/test/java/org/apache/camel/quarkus/component/jta/JtaTransactionFailurePropagationTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.camel.quarkus.component.jta;
+
+import java.util.Arrays;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.transaction.Status;
+import jakarta.transaction.SystemException;
+import jakarta.transaction.Transaction;
+import jakarta.transaction.TransactionManager;
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.spec.JavaArchive;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.when;
+
+/**
+ * A transaction whose rollback, rollback marking or resumption fails must not
let that failure disappear, and must not
+ * let it replace the exception that made the rollback necessary.
+ */
+public class JtaTransactionFailurePropagationTest {
+
+ @RegisterExtension
+ static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+ .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
+ .addClasses(MockTransactionManagerProducer.class,
MockTransaction.class));
+
+ @Inject
+ TransactionManager transactionManager;
+
+ @Inject
+ @Named("PROPAGATION_REQUIRED")
+ RequiredJtaTransactionPolicy requiredPolicy;
+
+ @Inject
+ @Named("PROPAGATION_REQUIRES_NEW")
+ RequiresNewJtaTransactionPolicy requiresNewPolicy;
+
+ @AfterEach
+ public void afterEach() {
+ reset(transactionManager);
+ }
+
+ @Test
+ public void failedRollbackMarkingIsReportedAndKeepsTheOriginalCause()
throws Exception {
+ // Participating in an outer transaction, so the policy marks it
rather than rolling it back
+ when(transactionManager.getStatus()).thenReturn(Status.STATUS_ACTIVE);
+ doThrow(new SystemException("mark
failed")).when(transactionManager).setRollbackOnly();
+
+ Exception routeFailure = new Exception("route failed");
+ Throwable thrown = assertThrows(Throwable.class, () ->
requiredPolicy.run(() -> {
+ throw routeFailure;
+ }));
+
+ // The original failure still surfaces
+ assertEquals(routeFailure, thrown);
+ // and the marking failure rides along rather than vanishing into a
log line
+ assertTrue(Arrays.stream(thrown.getSuppressed())
+ .anyMatch(s -> s.getMessage().contains("Unable to mark the
transaction for rollback")),
+ "expected the failed setRollbackOnly to be attached, got "
+ + Arrays.toString(thrown.getSuppressed()));
+ }
+
+ @Test
+ public void failedRollbackIsReportedAndKeepsTheOriginalCause() throws
Exception {
+
when(transactionManager.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION);
+ doThrow(new SystemException("rollback
failed")).when(transactionManager).rollback();
+
+ Exception routeFailure = new Exception("route failed");
+ Throwable thrown = assertThrows(Throwable.class, () ->
requiredPolicy.run(() -> {
+ throw routeFailure;
+ }));
+
+ assertEquals(routeFailure, thrown);
+ assertTrue(Arrays.stream(thrown.getSuppressed())
+ .anyMatch(s -> s.getMessage().contains("Unable to rollback
transaction")),
+ "expected the failed rollback to be attached, got " +
Arrays.toString(thrown.getSuppressed()));
+ }
+
+ @Test
+ public void failedResumeIsReportedWhenTheBodySucceeded() throws Exception {
+ Transaction suspended = new MockTransaction();
+
when(transactionManager.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION);
+ when(transactionManager.suspend()).thenReturn(suspended);
+ doThrow(new SystemException("resume
failed")).when(transactionManager).resume(suspended);
+
+ // Nothing else is in flight, so the resume failure is the failure
+ Throwable thrown = assertThrows(Throwable.class, () ->
requiresNewPolicy.run(() -> {
+ }));
+
+ assertTrue(thrown.getMessage().contains("Unable to resume
transaction"),
+ "expected the resume failure to surface, got " + thrown);
+ }
+
+ @Test
+ public void failedResumeDoesNotReplaceTheBodyFailure() throws Exception {
+ Transaction suspended = new MockTransaction();
+
when(transactionManager.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION);
+ when(transactionManager.suspend()).thenReturn(suspended);
+ doThrow(new SystemException("resume
failed")).when(transactionManager).resume(suspended);
+
+ Exception routeFailure = new Exception("route failed");
+ Throwable thrown = assertThrows(Throwable.class, () ->
requiresNewPolicy.run(() -> {
+ throw routeFailure;
+ }));
+
+ // The body's failure wins; the resume failure is attached to it
+ assertEquals(routeFailure, thrown);
+ assertTrue(Arrays.stream(thrown.getSuppressed())
+ .anyMatch(s -> s.getMessage().contains("Unable to resume
transaction")),
+ "expected the failed resume to be attached, got " +
Arrays.toString(thrown.getSuppressed()));
+ }
+}
diff --git
a/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/NotSupportedJtaTransactionPolicy.java
b/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/NotSupportedJtaTransactionPolicy.java
index 7dec059d92..02d6a5f910 100644
---
a/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/NotSupportedJtaTransactionPolicy.java
+++
b/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/NotSupportedJtaTransactionPolicy.java
@@ -25,11 +25,16 @@ public final class NotSupportedJtaTransactionPolicy extends
TransactionalJtaTran
@Override
public void run(final Runnable runnable) throws Throwable {
Transaction suspendedTransaction = null;
+ Throwable primary = null;
try {
suspendedTransaction = suspendTransaction();
runnable.run();
+ } catch (Throwable e) {
+ primary = e;
+ throw e;
} finally {
- resumeTransaction(suspendedTransaction);
+ // Passing the failure already on its way out keeps a resume
failure from replacing it
+ resumeTransaction(suspendedTransaction, primary);
}
}
}
diff --git
a/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/RequiresNewJtaTransactionPolicy.java
b/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/RequiresNewJtaTransactionPolicy.java
index 60156dc221..2bc769f0f9 100644
---
a/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/RequiresNewJtaTransactionPolicy.java
+++
b/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/RequiresNewJtaTransactionPolicy.java
@@ -25,11 +25,16 @@ public final class RequiresNewJtaTransactionPolicy extends
TransactionalJtaTrans
@Override
public void run(final Runnable runnable) throws Throwable {
Transaction suspendedTransaction = null;
+ Throwable primary = null;
try {
suspendedTransaction = suspendTransaction();
runWithTransaction(runnable, true);
+ } catch (Throwable e) {
+ primary = e;
+ throw e;
} finally {
- resumeTransaction(suspendedTransaction);
+ // Passing the failure already on its way out keeps a resume
failure from replacing it
+ resumeTransaction(suspendedTransaction, primary);
}
}
}
diff --git
a/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/TransactionalJtaTransactionPolicy.java
b/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/TransactionalJtaTransactionPolicy.java
index 00a2fc2e00..6bd69b9b94 100644
---
a/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/TransactionalJtaTransactionPolicy.java
+++
b/extensions/jta/runtime/src/main/java/org/apache/camel/quarkus/component/jta/TransactionalJtaTransactionPolicy.java
@@ -25,17 +25,14 @@ import jakarta.transaction.SystemException;
import jakarta.transaction.Transaction;
import jakarta.transaction.TransactionManager;
import org.apache.camel.CamelException;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.jta.JtaTransactionPolicy;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
/**
* Helper methods for transaction handling
*/
public abstract class TransactionalJtaTransactionPolicy extends
JtaTransactionPolicy {
- private static final Logger LOG =
LoggerFactory.getLogger(TransactionalJtaTransactionPolicy.class);
-
@Inject
TransactionManager transactionManager;
@@ -46,7 +43,7 @@ public abstract class TransactionalJtaTransactionPolicy
extends JtaTransactionPo
try {
runnable.run();
} catch (Throwable e) {
- rollback(isNew);
+ rollbackSuppressing(e, isNew);
throw e;
}
if (isNew) {
@@ -64,11 +61,18 @@ public abstract class TransactionalJtaTransactionPolicy
extends JtaTransactionPo
} catch (HeuristicMixedException | HeuristicRollbackException |
RollbackException | SystemException e) {
throw new CamelException("Unable to commit transaction", e);
} catch (Exception | Error e) {
- rollback(true);
+ rollbackSuppressing(e, true);
throw e;
}
}
+ /**
+ * Rolls the transaction back, or marks it for rollback when it belongs to
an outer policy.
+ *
+ * A failure here is raised rather than logged and discarded. Callers that
already have an exception on its way
+ * out attach this one to it through {@link
#rollbackSuppressing(Throwable, boolean)}, so the original cause is
+ * never replaced.
+ */
final protected void rollback(boolean isNew) throws Exception {
try {
if (isNew) {
@@ -77,7 +81,20 @@ public abstract class TransactionalJtaTransactionPolicy
extends JtaTransactionPo
transactionManager.setRollbackOnly();
}
} catch (Throwable e) {
- LOG.warn("Could not rollback transaction!", e);
+ throw new CamelException(
+ isNew ? "Unable to rollback transaction" : "Unable to mark
the transaction for rollback", e);
+ }
+ }
+
+ /**
+ * Rolls back while an exception is already on its way out, attaching a
rollback failure to it as a suppressed
+ * exception. Replacing the original would hide why the rollback was
needed in the first place.
+ */
+ private void rollbackSuppressing(Throwable primary, boolean isNew) {
+ try {
+ rollback(isNew);
+ } catch (Throwable rollbackFailure) {
+ primary.addSuppressed(rollbackFailure);
}
}
@@ -85,7 +102,25 @@ public abstract class TransactionalJtaTransactionPolicy
extends JtaTransactionPo
return transactionManager.suspend();
}
+ /**
+ * Resumes the suspended transaction, raising a failure rather than
logging and discarding it, so that work after
+ * this point does not silently continue outside the transaction the
caller expects to be restored.
+ */
final protected void resumeTransaction(final Transaction
suspendedTransaction) {
+ try {
+ resumeTransaction(suspendedTransaction, null);
+ } catch (Exception e) {
+ throw RuntimeCamelException.wrapRuntimeCamelException(e);
+ }
+ }
+
+ /**
+ * Resumes the suspended transaction while {@code primary} may already be
on its way out, which is the case in the
+ * `finally` block of a policy that suspends. A resume failure is attached
to {@code primary} when there is one and
+ * raised on its own otherwise, so it is neither lost nor allowed to
replace the original failure.
+ */
+ final protected void resumeTransaction(final Transaction
suspendedTransaction, final Throwable primary)
+ throws Exception {
if (suspendedTransaction == null) {
return;
}
@@ -93,7 +128,12 @@ public abstract class TransactionalJtaTransactionPolicy
extends JtaTransactionPo
try {
transactionManager.resume(suspendedTransaction);
} catch (Throwable e) {
- LOG.warn("Could not resume transaction!", e);
+ CamelException failure = new CamelException("Unable to resume
transaction", e);
+ if (primary != null) {
+ primary.addSuppressed(failure);
+ } else {
+ throw failure;
+ }
}
}