transactionStates = propagatedContext.findAll(OracleSessionlessTransactionState.class).toList();
- for (OracleSessionlessTransactionState existingTransactionState : transactionStates) {
- mutablePropagatedContext.remove(existingTransactionState);
- }
- }
- mutablePropagatedContext.add(transactionState);
- }
}
diff --git a/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java b/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java
index aff555cb896..2b2bad70b83 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java
@@ -158,7 +158,30 @@ enum Propagation {
* nested transactions as well.
*/
NESTED,
+ /**
+ * Start an Oracle sessionless transaction and suspend it instead of committing when the
+ * transactional boundary completes.
+ * This propagation mode is intended for top-level Oracle sessionless transaction workflow
+ * boundaries, such as an HTTP request that starts work and returns a suspended transaction
+ * identifier to the caller. When an existing transaction is already active, Micronaut's
+ * generic transaction orchestration treats this as participation in the existing transaction;
+ * it does not create a nested sessionless transaction or suspend the existing transaction.
+ *
NOTE: This mode requires a transaction manager that supports Oracle sessionless
+ * transactions and an active Oracle sessionless transaction propagation context.
+ */
SUSPEND,
+ /**
+ * Resume an Oracle sessionless transaction from the current propagation context and complete
+ * it when the transactional boundary completes.
+ *
This propagation mode is intended for top-level Oracle sessionless transaction workflow
+ * boundaries, such as a later HTTP request that supplies a previously suspended transaction
+ * identifier. When an existing transaction is already active, Micronaut's generic transaction
+ * orchestration treats this as participation in the existing transaction; it does not resume
+ * the sessionless transaction from the propagation context.
+ *
NOTE: This mode requires a transaction manager that supports Oracle sessionless
+ * transactions and a transaction identifier in the active Oracle sessionless transaction
+ * propagation context.
+ */
REQUIRES_SUSPENDED
}
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
index 8d538b7b92c..6c84f883a63 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
@@ -9,6 +9,7 @@
import java.util.List;
+import static java.util.Objects.requireNonNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -51,11 +52,11 @@ void testTransactionResumed() {
@Test
void testCurrentTransactionIdExportsSuspendedTransactionId() {
- SuspendedSeat suspendedSeat = transactionPropagationOperations.withPropagation(() -> {
+ SuspendedSeat suspendedSeat = requireNonNull(transactionPropagationOperations.withPropagation(() -> {
Long seatId = bookingService.holdSeat(new Seat("JU502", "3a", "msid"));
String transactionId = transactionPropagationOperations.currentTransactionId().orElseThrow();
return new SuspendedSeat(seatId, transactionId);
- });
+ }));
transactionPropagationOperations.withPropagation(suspendedSeat.transactionId(), () -> {
bookingService.ticketSeat(suspendedSeat.seatId());
@@ -67,11 +68,11 @@ void testCurrentTransactionIdExportsSuspendedTransactionId() {
@Test
void testSetTransactionIdImportsIntoActivePropagationState() {
- SuspendedSeat suspendedSeat = transactionPropagationOperations.withPropagation(() -> {
+ SuspendedSeat suspendedSeat = requireNonNull(transactionPropagationOperations.withPropagation(() -> {
Long seatId = bookingService.holdSeat(new Seat("JU503", "4b", "msid"));
String transactionId = transactionPropagationOperations.currentTransactionId().orElseThrow();
return new SuspendedSeat(seatId, transactionId);
- });
+ }));
transactionPropagationOperations.withPropagation(() -> {
assertTrue(transactionPropagationOperations.currentTransactionId().isEmpty());
From 8fb4fdde706b6bb809fe33f7734a605a4fd3cb2d Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Mon, 25 May 2026 16:18:45 +0200
Subject: [PATCH 14/32] Oracle sessionless transaction impl - wip
---
...lessTransactionHttpServerFilterSpec.groovy | 122 -----------
...lessTransactionHttpServerFilterSpec.groovy | 189 +++++++++++++++++
...leSessionlessTransactionIdCodecSpec.groovy | 47 +++++
...leSessionlessTransactionManagerSpec.groovy | 198 ++++++++++++++++++
4 files changed, 434 insertions(+), 122 deletions(-)
delete mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/OracleSessionlessTransactionHttpServerFilterSpec.groovy
create mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy
create mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodecSpec.groovy
create mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/OracleSessionlessTransactionHttpServerFilterSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/OracleSessionlessTransactionHttpServerFilterSpec.groovy
deleted file mode 100644
index 5b976ca70b7..00000000000
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/OracleSessionlessTransactionHttpServerFilterSpec.groovy
+++ /dev/null
@@ -1,122 +0,0 @@
-package io.micronaut.transaction.jdbc
-
-import io.micronaut.core.propagation.MutablePropagatedContext
-import io.micronaut.core.propagation.PropagatedContext
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
-import io.micronaut.http.exceptions.HttpStatusException
-import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionContext
-import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionHttpConfiguration
-import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionHttpServerFilter
-import spock.lang.Specification
-
-class OracleSessionlessTransactionHttpServerFilterSpec extends Specification {
-
- def "reads transaction id from the configured request header"() {
- given:
- def configuration = new OracleSessionlessTransactionHttpConfiguration()
- def filter = new OracleSessionlessTransactionHttpServerFilter(configuration)
- def gtrid = [1, 2, 3, 4] as byte[]
- def value = new OracleSessionlessTransactionContext(gtrid).encode()
- def context = MutablePropagatedContext.of(PropagatedContext.empty())
- def request = HttpRequest.GET("/")
- .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, value)
-
- when:
- filter.readTransactionId(request, context)
-
- then:
- def element = OracleSessionlessTransactionContext.find(context.context).orElseThrow()
- Arrays.equals(gtrid, element.gtrid())
- }
-
- def "request header replaces stale transaction id context"() {
- given:
- def configuration = new OracleSessionlessTransactionHttpConfiguration()
- def filter = new OracleSessionlessTransactionHttpServerFilter(configuration)
- def stale = new OracleSessionlessTransactionContext([9, 9, 9] as byte[])
- def incoming = new OracleSessionlessTransactionContext([1, 2, 3] as byte[])
- def context = MutablePropagatedContext.of(PropagatedContext.empty())
- context.add(stale)
- def request = HttpRequest.GET("/")
- .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, incoming.encode())
-
- when:
- filter.readTransactionId(request, context)
-
- then:
- def elements = context.context.findAll(OracleSessionlessTransactionContext).toList()
- elements.size() == 1
- Arrays.equals(incoming.gtrid(), elements[0].gtrid())
- }
-
- def "writes transaction id to the configured response header"() {
- given:
- def configuration = new OracleSessionlessTransactionHttpConfiguration()
- def filter = new OracleSessionlessTransactionHttpServerFilter(configuration)
- def gtrid = [10, 20, 30] as byte[]
- def element = new OracleSessionlessTransactionContext(gtrid)
- def context = MutablePropagatedContext.of(PropagatedContext.empty())
- def response = HttpResponse.ok()
- context.add(element)
-
- when:
- filter.writeTransactionId(response, context)
-
- then:
- response.headers.get(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME) == element.encode()
- }
-
- def "does not write a response header when no transaction id remains in context"() {
- given:
- def configuration = new OracleSessionlessTransactionHttpConfiguration()
- def filter = new OracleSessionlessTransactionHttpServerFilter(configuration)
- def element = new OracleSessionlessTransactionContext([10, 20, 30] as byte[])
- def context = MutablePropagatedContext.of(PropagatedContext.empty())
- def response = HttpResponse.ok()
- context.add(element)
- context.remove(element)
-
- when:
- filter.writeTransactionId(response, context)
-
- then:
- !response.headers.contains(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME)
- }
-
- def "uses a custom configured header name"() {
- given:
- def configuration = new OracleSessionlessTransactionHttpConfiguration()
- configuration.headerName = "X-Oracle-Sessionless-Tx"
- def filter = new OracleSessionlessTransactionHttpServerFilter(configuration)
- def element = new OracleSessionlessTransactionContext([1, 1, 2, 3, 5] as byte[])
- def context = MutablePropagatedContext.of(PropagatedContext.empty())
- def request = HttpRequest.GET("/").header("X-Oracle-Sessionless-Tx", element.encode())
- def response = HttpResponse.ok()
-
- when:
- filter.readTransactionId(request, context)
- filter.writeTransactionId(response, context)
-
- then:
- response.headers.get("X-Oracle-Sessionless-Tx") == element.encode()
- !response.headers.contains(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME)
- }
-
- def "rejects malformed transaction id header values"() {
- given:
- def configuration = new OracleSessionlessTransactionHttpConfiguration()
- def filter = new OracleSessionlessTransactionHttpServerFilter(configuration)
- def context = MutablePropagatedContext.of(PropagatedContext.empty())
- def request = HttpRequest.GET("/")
- .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, "*")
-
- when:
- filter.readTransactionId(request, context)
-
- then:
- def e = thrown(HttpStatusException)
- e.status == HttpStatus.BAD_REQUEST
- }
-}
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy
new file mode 100644
index 00000000000..7609b1130e6
--- /dev/null
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy
@@ -0,0 +1,189 @@
+package io.micronaut.transaction.jdbc.oracle
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.core.propagation.MutablePropagatedContext
+import io.micronaut.core.propagation.PropagatedContext
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.exceptions.HttpStatusException
+import spock.lang.Specification
+
+class OracleSessionlessTransactionHttpServerFilterSpec extends Specification {
+
+ def "reads transaction id from the configured request header"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, codec)
+ def gtrid = [1, 2, 3, 4] as byte[]
+ def value = codec.encode(gtrid)
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def request = HttpRequest.GET("/")
+ .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, value)
+
+ when:
+ filter.readTransactionId(request, context)
+
+ then:
+ def state = OracleSessionlessTransactionState.find(context.context).orElseThrow()
+ Arrays.equals(gtrid, state.gtrid.orElseThrow())
+ }
+
+ def "request filter rejects existing transaction state"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, codec)
+ def stale = new OracleSessionlessTransactionState()
+ stale.setGtrid([9, 9, 9] as byte[])
+ def incoming = [1, 2, 3] as byte[]
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ context.add(stale)
+ def request = HttpRequest.GET("/")
+ .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, codec.encode(incoming))
+
+ when:
+ filter.readTransactionId(request, context)
+
+ then:
+ def e = thrown(HttpStatusException)
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ def states = context.context.findAll(OracleSessionlessTransactionState).toList()
+ states.size() == 1
+ Arrays.equals([9, 9, 9] as byte[], states[0].gtrid.orElseThrow())
+ }
+
+ def "request without header installs empty transaction state"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, new DefaultOracleSessionlessTransactionIdCodec())
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def request = HttpRequest.GET("/")
+
+ when:
+ filter.readTransactionId(request, context)
+
+ then:
+ def state = OracleSessionlessTransactionState.find(context.context).orElseThrow()
+ state.gtrid.isEmpty()
+ }
+
+ def "writes transaction id to the configured response header"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, codec)
+ def gtrid = [10, 20, 30] as byte[]
+ def encodedGtrid = codec.encode(gtrid)
+ def state = new OracleSessionlessTransactionState()
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def response = HttpResponse.ok()
+ state.setGtrid(gtrid)
+ context.add(state)
+
+ when:
+ filter.writeTransactionId(response, context)
+
+ then:
+ response.headers.get(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME) == encodedGtrid
+ }
+
+ def "does not write a response header when no transaction id remains in context"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, new DefaultOracleSessionlessTransactionIdCodec())
+ def state = new OracleSessionlessTransactionState()
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def response = HttpResponse.ok()
+ context.add(state)
+
+ when:
+ filter.writeTransactionId(response, context)
+
+ then:
+ !response.headers.contains(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME)
+ }
+
+ def "uses a custom configured header name"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ configuration.headerName = "X-Oracle-Sessionless-Tx"
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, codec)
+ def encodedGtrid = codec.encode([1, 1, 2, 3, 5] as byte[])
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def request = HttpRequest.GET("/").header("X-Oracle-Sessionless-Tx", encodedGtrid)
+ def response = HttpResponse.ok()
+
+ when:
+ filter.readTransactionId(request, context)
+ filter.writeTransactionId(response, context)
+
+ then:
+ response.headers.get("X-Oracle-Sessionless-Tx") == encodedGtrid
+ !response.headers.contains(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME)
+ }
+
+ def "rejects malformed transaction id header values"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, new DefaultOracleSessionlessTransactionIdCodec())
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def request = HttpRequest.GET("/")
+ .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, "*")
+
+ when:
+ filter.readTransactionId(request, context)
+
+ then:
+ def e = thrown(HttpStatusException)
+ e.status == HttpStatus.BAD_REQUEST
+ }
+
+ def "uses the configured transaction id codec"() {
+ given:
+ def configuration = new OracleSessionlessTransactionHttpConfiguration()
+ def codec = new ReversingTransactionIdCodec()
+ def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, codec)
+ def context = MutablePropagatedContext.of(PropagatedContext.empty())
+ def request = HttpRequest.GET("/")
+ .header(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME, "4,3,2,1")
+ def response = HttpResponse.ok()
+
+ when:
+ filter.readTransactionId(request, context)
+ filter.writeTransactionId(response, context)
+
+ then:
+ def state = OracleSessionlessTransactionState.find(context.context).orElseThrow()
+ Arrays.equals([1, 2, 3, 4] as byte[], state.gtrid.orElseThrow())
+ response.headers.get(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME) == "4,3,2,1"
+ }
+
+ def "http filter is enabled by the propagation enabled property"() {
+ when:
+ def context = ApplicationContext.run([
+ "micronaut.data.oracle.sessionless.http.propagation-enabled": true
+ ])
+
+ then:
+ context.containsBean(OracleSessionlessTransactionHttpServerFilter)
+
+ cleanup:
+ context.close()
+ }
+
+ private static final class ReversingTransactionIdCodec implements OracleSessionlessTransactionIdCodec {
+
+ @Override
+ String encode(byte[] gtrid) {
+ gtrid.reverse().join(",")
+ }
+
+ @Override
+ byte[] decode(String encodedTransactionId) {
+ encodedTransactionId.split(",")*.toInteger().reverse() as byte[]
+ }
+ }
+}
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodecSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodecSpec.groovy
new file mode 100644
index 00000000000..9eafc1f9483
--- /dev/null
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodecSpec.groovy
@@ -0,0 +1,47 @@
+package io.micronaut.transaction.jdbc.oracle
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.context.annotation.Requires
+import jakarta.inject.Singleton
+import spock.lang.Specification
+
+class OracleSessionlessTransactionIdCodecSpec extends Specification {
+
+ def "default codec is used when no custom codec bean exists"() {
+ when:
+ def context = ApplicationContext.run()
+
+ then:
+ context.getBean(OracleSessionlessTransactionIdCodec) instanceof DefaultOracleSessionlessTransactionIdCodec
+
+ cleanup:
+ context.close()
+ }
+
+ def "custom codec replaces the default codec bean"() {
+ when:
+ def context = ApplicationContext.run("spec.name": "OracleSessionlessTransactionIdCodecSpec")
+
+ then:
+ context.getBeansOfType(OracleSessionlessTransactionIdCodec).size() == 1
+ context.getBean(OracleSessionlessTransactionIdCodec) instanceof CustomTransactionIdCodec
+
+ cleanup:
+ context.close()
+ }
+
+ @Singleton
+ @Requires(property = "spec.name", value = "OracleSessionlessTransactionIdCodecSpec")
+ static final class CustomTransactionIdCodec implements OracleSessionlessTransactionIdCodec {
+
+ @Override
+ String encode(byte[] gtrid) {
+ "custom"
+ }
+
+ @Override
+ byte[] decode(String encodedTransactionId) {
+ [1] as byte[]
+ }
+ }
+}
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
new file mode 100644
index 00000000000..2c131fea2b2
--- /dev/null
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
@@ -0,0 +1,198 @@
+package io.micronaut.transaction.jdbc.oracle
+
+import io.micronaut.core.propagation.PropagatedContext
+import io.micronaut.data.connection.ConnectionDefinition
+import io.micronaut.data.connection.ConnectionOperations
+import io.micronaut.data.connection.SynchronousConnectionManager
+import io.micronaut.data.connection.support.DefaultConnectionStatus
+import io.micronaut.transaction.TransactionDefinition
+import io.micronaut.transaction.exceptions.CannotCreateTransactionException
+import io.micronaut.transaction.exceptions.TransactionSystemException
+import io.micronaut.transaction.impl.DefaultTransactionStatus
+import io.micronaut.transaction.support.TransactionExecutionListener
+import oracle.jdbc.OracleConnection
+import spock.lang.Specification
+
+import javax.sql.DataSource
+import java.sql.Connection
+import java.sql.SQLException
+import java.time.Duration
+
+class OracleSessionlessTransactionManagerSpec extends Specification {
+
+ def "begin delegates JDBC setup and starts sessionless transaction with listener support"() {
+ given:
+ def listener = Mock(TransactionExecutionListener)
+ def manager = newTransactionManager([listener])
+ def connection = Mock(Connection)
+ def oracle = Mock(OracleConnection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND, Duration.ofSeconds(5))
+ def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
+ def status = DefaultTransactionStatus.newTx(connectionStatus, definition, manager)
+ def gtrid = [1, 2, 3] as byte[]
+ def state = new OracleSessionlessTransactionState()
+
+ when:
+ PropagatedContext.empty().plus(state).propagate({ manager.doBegin(status) })
+
+ then:
+ 1 * listener.beforeBegin(connectionStatus, definition)
+ 1 * connection.getAutoCommit() >> true
+ 1 * connection.setAutoCommit(false)
+ 1 * listener.afterBegin(connectionStatus, definition)
+ 1 * connection.unwrap(OracleConnection) >> oracle
+ 1 * oracle.startTransaction(5) >> gtrid
+ 0 * oracle.getTransactionId()
+ Arrays.equals(gtrid, state.gtrid.orElseThrow())
+ }
+
+ def "start transaction failure does not fall back to current transaction id"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def oracle = Mock(OracleConnection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+ def state = new OracleSessionlessTransactionState()
+
+ when:
+ PropagatedContext.empty().plus(state).propagate({ manager.doBegin(status) })
+
+ then:
+ 1 * connection.getAutoCommit() >> false
+ 1 * connection.unwrap(OracleConnection) >> oracle
+ 1 * oracle.startTransaction() >> { throw new SQLException("start failed") }
+ 0 * oracle.getTransactionId()
+ thrown(CannotCreateTransactionException)
+ }
+
+ def "sessionless begin requires an Oracle connection"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+ def state = new OracleSessionlessTransactionState()
+
+ when:
+ PropagatedContext.empty().plus(state).propagate({ manager.doBegin(status) })
+
+ then:
+ 1 * connection.getAutoCommit() >> false
+ 1 * connection.unwrap(OracleConnection) >> { throw new SQLException("not oracle") }
+ thrown(CannotCreateTransactionException)
+ }
+
+ def "sessionless begin requires active propagation state"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+
+ when:
+ manager.doBegin(status)
+
+ then:
+ thrown(CannotCreateTransactionException)
+ 0 * connection._
+ }
+
+ def "sessionless begin rejects a second suspended transaction id"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+ def state = new OracleSessionlessTransactionState()
+ state.setGtrid([9, 9, 9] as byte[])
+
+ when:
+ PropagatedContext.empty().plus(state).propagate({ manager.doBegin(status) })
+
+ then:
+ thrown(CannotCreateTransactionException)
+ 0 * connection._
+ }
+
+ def "suspend commit falls back to non-immediate suspend"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def oracle = Mock(OracleConnection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+
+ when:
+ manager.doCommit(status)
+
+ then:
+ 1 * connection.unwrap(OracleConnection) >> oracle
+ 1 * oracle.suspendTransactionImmediately() >> { throw new SQLException("immediate failed") }
+ 1 * oracle.suspendTransaction()
+ 0 * connection.commit()
+ }
+
+ def "resumed transaction context is cleared when commit fails"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def definition = definition(TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ def status = txStatus(connection, definition, manager)
+ def state = new OracleSessionlessTransactionState()
+ state.setGtrid([4, 5, 6] as byte[])
+
+ when:
+ TransactionSystemException thrownException = null
+ Boolean presentAfterCommit = null
+ PropagatedContext.empty().plus(state).propagate({
+ try {
+ manager.doCommit(status)
+ } catch (TransactionSystemException e) {
+ thrownException = e
+ presentAfterCommit = state.gtrid.isPresent()
+ }
+ })
+
+ then:
+ 1 * connection.commit() >> { throw new SQLException("commit failed") }
+ thrownException != null
+ !presentAfterCommit
+ }
+
+ private OracleSessionlessTransactionManager newTransactionManager(List> listeners = Collections.emptyList()) {
+ new OracleSessionlessTransactionManager(
+ Mock(DataSource),
+ Mock(ConnectionOperations),
+ Mock(SynchronousConnectionManager),
+ listeners
+ )
+ }
+
+ private static DefaultTransactionStatus txStatus(Connection connection,
+ TransactionDefinition definition,
+ OracleSessionlessTransactionManager manager) {
+ def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
+ DefaultTransactionStatus.newTx(connectionStatus, definition, manager)
+ }
+
+ private static TransactionDefinition definition(TransactionDefinition.Propagation propagation,
+ Duration timeout = null) {
+ new TransactionDefinition() {
+ @Override
+ String getName() {
+ "test"
+ }
+
+ @Override
+ TransactionDefinition.Propagation getPropagationBehavior() {
+ propagation
+ }
+
+ @Override
+ Optional getTimeout() {
+ Optional.ofNullable(timeout)
+ }
+ }
+ }
+}
From 2dbbb8c7490aa55230b921633c825e2bd5133a7e Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Mon, 25 May 2026 16:25:40 +0200
Subject: [PATCH 15/32] Oracle sessionless transaction impl - wip
---
...ransactionPropagationOperationsSpec.groovy | 99 +++++++++++++++++++
1 file changed, 99 insertions(+)
create mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperationsSpec.groovy
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperationsSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperationsSpec.groovy
new file mode 100644
index 00000000000..134f3504437
--- /dev/null
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperationsSpec.groovy
@@ -0,0 +1,99 @@
+package io.micronaut.transaction.jdbc.oracle
+
+import io.micronaut.transaction.exceptions.TransactionUsageException
+import spock.lang.Specification
+
+class OracleSessionlessTransactionPropagationOperationsSpec extends Specification {
+
+ def "with propagation exposes a mutable sessionless transaction id state"() {
+ given:
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def operations = new DefaultOracleSessionlessTransactionPropagationOperations(codec)
+ def transactionId = codec.encode([1, 2, 3] as byte[])
+
+ when:
+ def captured = operations.withPropagation({
+ assert operations.currentTransactionId().isEmpty()
+ operations.setTransactionId(transactionId)
+ def current = operations.currentTransactionId().orElseThrow()
+ operations.clearTransactionId()
+ assert operations.currentTransactionId().isEmpty()
+ current
+ })
+
+ then:
+ captured == transactionId
+ operations.currentTransactionId().isEmpty()
+ }
+
+ def "with propagation can import an encoded sessionless transaction id"() {
+ given:
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def operations = new DefaultOracleSessionlessTransactionPropagationOperations(codec)
+ def transactionId = codec.encode([4, 5, 6] as byte[])
+
+ expect:
+ operations.withPropagation(transactionId, {
+ operations.currentTransactionId().orElseThrow()
+ }) == transactionId
+ }
+
+ def "nested propagation scopes restore the previous sessionless transaction state"() {
+ given:
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def operations = new DefaultOracleSessionlessTransactionPropagationOperations(codec)
+ def outerTransactionId = codec.encode([1, 1, 2] as byte[])
+ def innerTransactionId = codec.encode([3, 5, 8] as byte[])
+
+ when:
+ def seenInner = operations.withPropagation(outerTransactionId, {
+ assert operations.currentTransactionId().orElseThrow() == outerTransactionId
+ def nested = operations.withPropagation(innerTransactionId, {
+ operations.currentTransactionId().orElseThrow()
+ })
+ assert operations.currentTransactionId().orElseThrow() == outerTransactionId
+ nested
+ })
+
+ then:
+ seenInner == innerTransactionId
+ }
+
+ def "set transaction id requires an active propagation scope"() {
+ given:
+ def codec = new DefaultOracleSessionlessTransactionIdCodec()
+ def operations = new DefaultOracleSessionlessTransactionPropagationOperations(codec)
+ def transactionId = codec.encode([9] as byte[])
+
+ when:
+ operations.setTransactionId(transactionId)
+
+ then:
+ thrown(TransactionUsageException)
+ }
+
+ def "propagation operations use the configured transaction id codec"() {
+ given:
+ def operations = new DefaultOracleSessionlessTransactionPropagationOperations(new HexTransactionIdCodec())
+
+ expect:
+ operations.withPropagation("01020a", {
+ operations.currentTransactionId().orElseThrow()
+ }) == "01020a"
+ }
+
+ private static final class HexTransactionIdCodec implements OracleSessionlessTransactionIdCodec {
+
+ @Override
+ String encode(byte[] gtrid) {
+ gtrid.collect { String.format("%02x", it & 0xff) }.join()
+ }
+
+ @Override
+ byte[] decode(String encodedTransactionId) {
+ (0..
Date: Tue, 26 May 2026 09:34:29 +0200
Subject: [PATCH 16/32] Oracle sessionless transaction impl - wip
---
.../OracleSessionlessTransactionHttpServerFilter.java | 11 ++++++-----
...eSessionlessTransactionHttpServerFilterSpec.groovy | 8 ++++++--
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilter.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilter.java
index ba419061a5f..f4894635379 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilter.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilter.java
@@ -71,11 +71,12 @@ void readTransactionId(HttpRequest> request, MutablePropagatedContext mutableP
void writeTransactionId(MutableHttpResponse> response, MutablePropagatedContext mutablePropagatedContext) {
PropagatedContext propagatedContext = mutablePropagatedContext.getContext();
if (propagatedContext != null) {
- propagatedContext.findAll(OracleSessionlessTransactionState.class)
- .findFirst()
- .flatMap(OracleSessionlessTransactionState::getGtrid)
- .map(transactionIdCodec::encode)
- .ifPresent(transactionId -> response.getHeaders().set(configuration.getHeaderName(), transactionId));
+ OracleSessionlessTransactionState.find(propagatedContext).ifPresent(transactionState -> {
+ transactionState.getGtrid()
+ .map(transactionIdCodec::encode)
+ .ifPresent(transactionId -> response.getHeaders().set(configuration.getHeaderName(), transactionId));
+ mutablePropagatedContext.remove(transactionState);
+ });
}
}
}
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy
index 7609b1130e6..b1a61679e6d 100644
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy
@@ -87,9 +87,10 @@ class OracleSessionlessTransactionHttpServerFilterSpec extends Specification {
then:
response.headers.get(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME) == encodedGtrid
+ OracleSessionlessTransactionState.find(context.context).isEmpty()
}
- def "does not write a response header when no transaction id remains in context"() {
+ def "does not write a response header and removes state when no transaction id remains in context"() {
given:
def configuration = new OracleSessionlessTransactionHttpConfiguration()
def filter = new OracleSessionlessTransactionHttpServerFilter(configuration, new DefaultOracleSessionlessTransactionIdCodec())
@@ -103,6 +104,7 @@ class OracleSessionlessTransactionHttpServerFilterSpec extends Specification {
then:
!response.headers.contains(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME)
+ OracleSessionlessTransactionState.find(context.context).isEmpty()
}
def "uses a custom configured header name"() {
@@ -123,6 +125,7 @@ class OracleSessionlessTransactionHttpServerFilterSpec extends Specification {
then:
response.headers.get("X-Oracle-Sessionless-Tx") == encodedGtrid
!response.headers.contains(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME)
+ OracleSessionlessTransactionState.find(context.context).isEmpty()
}
def "rejects malformed transaction id header values"() {
@@ -153,12 +156,13 @@ class OracleSessionlessTransactionHttpServerFilterSpec extends Specification {
when:
filter.readTransactionId(request, context)
+ def state = OracleSessionlessTransactionState.find(context.context).orElseThrow()
filter.writeTransactionId(response, context)
then:
- def state = OracleSessionlessTransactionState.find(context.context).orElseThrow()
Arrays.equals([1, 2, 3, 4] as byte[], state.gtrid.orElseThrow())
response.headers.get(OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME) == "4,3,2,1"
+ OracleSessionlessTransactionState.find(context.context).isEmpty()
}
def "http filter is enabled by the propagation enabled property"() {
From 4ecb82f01527cb23ada00ea03ec85cc390829741 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Tue, 26 May 2026 10:27:29 +0200
Subject: [PATCH 17/32] Oracle sessionless transaction impl - wip
---
.../jdbc/DataSourceTransactionManager.java | 36 +++++++
.../OracleSessionlessTransactionManager.java | 5 +
.../DataSourceTransactionManagerSpec.groovy | 100 ++++++++++++++++++
3 files changed, 141 insertions(+)
create mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
index a2c61038bec..9e0829405e6 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
@@ -23,6 +23,7 @@
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import io.micronaut.core.annotation.TypeHint;
+import io.micronaut.data.connection.ConnectionDefinition;
import io.micronaut.data.connection.ConnectionOperations;
import io.micronaut.data.connection.ConnectionSynchronization;
import io.micronaut.data.connection.SynchronousConnectionManager;
@@ -30,6 +31,7 @@
import io.micronaut.data.connection.support.JdbcConnectionUtils;
import io.micronaut.transaction.TransactionDefinition;
import io.micronaut.transaction.exceptions.CannotCreateTransactionException;
+import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
import io.micronaut.transaction.exceptions.TransactionSystemException;
import io.micronaut.transaction.impl.DefaultTransactionStatus;
import io.micronaut.transaction.support.AbstractDefaultTransactionOperations;
@@ -144,9 +146,23 @@ public boolean isEnforceReadOnly() {
return this.enforceReadOnly;
}
+ @Override
+ protected ConnectionDefinition getConnectionDefinition(TransactionDefinition transactionDefinition) {
+ validateOracleSessionlessPropagation(transactionDefinition);
+ return super.getConnectionDefinition(transactionDefinition);
+ }
+
+ @Override
+ protected DefaultTransactionStatus createExistingTransactionStatus(TransactionDefinition definition,
+ DefaultTransactionStatus existingTransaction) {
+ validateOracleSessionlessPropagation(definition);
+ return super.createExistingTransactionStatus(definition, existingTransaction);
+ }
+
@Override
protected void doBegin(DefaultTransactionStatus status) {
TransactionDefinition definition = status.getTransactionDefinition();
+ validateOracleSessionlessPropagation(definition);
Connection connection = status.getConnection();
List onComplete = new ArrayList<>(5);
@@ -179,6 +195,26 @@ public void executionComplete() {
}
}
+ /**
+ * @return Whether this manager supports Oracle sessionless transaction propagation modes.
+ */
+ protected boolean supportsOracleSessionlessTransactions() {
+ return false;
+ }
+
+ private void validateOracleSessionlessPropagation(TransactionDefinition definition) {
+ TransactionDefinition.Propagation propagation = definition.getPropagationBehavior();
+ if (propagation != TransactionDefinition.Propagation.SUSPEND
+ && propagation != TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ return;
+ }
+ if (!supportsOracleSessionlessTransactions()) {
+ throw new TransactionSuspensionNotSupportedException(
+ "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ );
+ }
+ }
+
@Override
protected void doCommit(DefaultTransactionStatus status) {
Connection connection = status.getConnection();
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
index 6dad36a2421..1d879f32ab3 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
@@ -61,6 +61,11 @@ public OracleSessionlessTransactionManager(@NonNull DataSource dataSource,
this(dataSource, connectionOperations, synchronousConnectionManager, Collections.emptyList());
}
+ @Override
+ protected boolean supportsOracleSessionlessTransactions() {
+ return true;
+ }
+
@Override
protected void doBegin(DefaultTransactionStatus status) {
TransactionDefinition definition = status.getTransactionDefinition();
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
new file mode 100644
index 00000000000..9705fc5ef39
--- /dev/null
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
@@ -0,0 +1,100 @@
+package io.micronaut.transaction.jdbc
+
+import io.micronaut.data.connection.ConnectionDefinition
+import io.micronaut.data.connection.ConnectionOperations
+import io.micronaut.data.connection.SynchronousConnectionManager
+import io.micronaut.data.connection.support.DefaultConnectionStatus
+import io.micronaut.transaction.TransactionDefinition
+import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException
+import io.micronaut.transaction.impl.DefaultTransactionStatus
+import spock.lang.Specification
+
+import javax.sql.DataSource
+import java.sql.Connection
+
+class DataSourceTransactionManagerSpec extends Specification {
+
+ def "plain JDBC manager rejects Oracle sessionless propagation before JDBC begin"(TransactionDefinition.Propagation propagation) {
+ given:
+ def txManager = newTxManager()
+ def connection = Mock(Connection)
+ def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
+ def txStatus = DefaultTransactionStatus.newTx(connectionStatus, definition(propagation), txManager)
+
+ when:
+ txManager.doBegin(txStatus)
+
+ then:
+ def e = thrown(TransactionSuspensionNotSupportedException)
+ e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ 0 * connection._
+
+ where:
+ propagation << [
+ TransactionDefinition.Propagation.SUSPEND,
+ TransactionDefinition.Propagation.REQUIRES_SUSPENDED
+ ]
+ }
+
+ def "plain JDBC manager rejects Oracle sessionless propagation before joining an existing transaction"(TransactionDefinition.Propagation propagation) {
+ given:
+ def txManager = newTxManager()
+ def connection = Mock(Connection)
+ def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
+ def existingTransaction = DefaultTransactionStatus.newTx(connectionStatus, definition(TransactionDefinition.Propagation.REQUIRED), txManager)
+
+ when:
+ txManager.createExistingTransactionStatus(definition(propagation), existingTransaction)
+
+ then:
+ def e = thrown(TransactionSuspensionNotSupportedException)
+ e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ 0 * connection._
+
+ where:
+ propagation << [
+ TransactionDefinition.Propagation.SUSPEND,
+ TransactionDefinition.Propagation.REQUIRES_SUSPENDED
+ ]
+ }
+
+ def "plain JDBC manager rejects Oracle sessionless propagation before resolving a connection definition"(TransactionDefinition.Propagation propagation) {
+ given:
+ def txManager = newTxManager()
+
+ when:
+ txManager.getConnectionDefinition(definition(propagation))
+
+ then:
+ def e = thrown(TransactionSuspensionNotSupportedException)
+ e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+
+ where:
+ propagation << [
+ TransactionDefinition.Propagation.SUSPEND,
+ TransactionDefinition.Propagation.REQUIRES_SUSPENDED
+ ]
+ }
+
+ private DataSourceTransactionManager newTxManager() {
+ new DataSourceTransactionManager(
+ Mock(DataSource),
+ Mock(ConnectionOperations),
+ Mock(SynchronousConnectionManager)
+ )
+ }
+
+ private static TransactionDefinition definition(TransactionDefinition.Propagation propagation) {
+ new TransactionDefinition() {
+ @Override
+ String getName() {
+ "test"
+ }
+
+ @Override
+ TransactionDefinition.Propagation getPropagationBehavior() {
+ propagation
+ }
+ }
+ }
+}
From 95f41197082f5888ec9dcfbec53e8bf9ee6b2857 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Tue, 26 May 2026 11:22:00 +0200
Subject: [PATCH 18/32] Oracle sessionless transaction impl - wip
---
.../build.gradle | 9 ++++++++-
.../src/main/resources/application.yml | 6 ++----
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle b/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle
index f2ba1559da4..8d09e1158fc 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle
+++ b/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle
@@ -1,3 +1,5 @@
+import io.micronaut.testresources.buildtools.KnownModules
+
plugins {
id "io.micronaut.build.internal.data-native-example"
}
@@ -11,7 +13,10 @@ micronaut {
runtime "netty"
testRuntime "junit5"
testResources {
- enabled = false
+ inferClasspath = false
+ additionalModules.add(KnownModules.JDBC_ORACLE_FREE)
+ clientTimeout = 300
+ version = libs.versions.micronaut.testresources.get()
}
}
@@ -27,4 +32,6 @@ dependencies {
runtimeOnly mnSql.micronaut.jdbc.tomcat
runtimeOnly mnSql.ojdbc11
runtimeOnly mnLogging.logback.classic
+
+ testResourcesService mnSerde.micronaut.serde.jackson
}
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml
index cc99f087e76..01ec851f3ad 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml
+++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml
@@ -9,8 +9,6 @@ micronaut:
datasources:
default:
- driverClassName: oracle.jdbc.OracleDriver
+ db-type: oracle
dialect: ORACLE
- url: jdbc:oracle:thin:@localhost:1521/freepdb1
- username: test
- password: test
+ schema-generate: CREATE_DROP
From 907e76b801bf5b2e2bc6014a7a0e560139275751 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Tue, 26 May 2026 11:23:33 +0200
Subject: [PATCH 19/32] Oracle sessionless transaction impl - Test Build
---
.github/workflows/gradle.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 268cd6a9a70..c9577954fe6 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -7,7 +7,7 @@ name: Java CI
on:
push:
branches:
- - master
+ - sessionless-transaction
- '[0-9]+.[0-9]+.x'
pull_request:
branches:
From 59bdc91eb92d98efe12a1b5b12e4a2b2b9e9a515 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Tue, 26 May 2026 18:31:11 +0200
Subject: [PATCH 20/32] Oracle sessionless transaction impl - Rolled back temp
change
---
.github/workflows/gradle.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index c9577954fe6..268cd6a9a70 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -7,7 +7,7 @@ name: Java CI
on:
push:
branches:
- - sessionless-transaction
+ - master
- '[0-9]+.[0-9]+.x'
pull_request:
branches:
From a8e5f10c412b012804ccfb813044f893f65aae8d Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Tue, 26 May 2026 18:45:33 +0200
Subject: [PATCH 21/32] Oracle sessionless transaction impl - added docs
---
.../java/example/BookingControllerTest.java | 11 +-
.../test/java/example/BookingServiceTest.java | 3 +-
.../jdbc/oracleSessionlessTransactions.adoc | 107 ++++++++++++++++++
src/main/docs/guide/toc.yml | 1 +
4 files changed, 118 insertions(+), 4 deletions(-)
create mode 100644 src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java
index 72d10d4bcff..1872e6a90c3 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java
@@ -1,6 +1,5 @@
package example;
-import io.micronaut.context.annotation.Property;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
@@ -19,7 +18,6 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
-@Property(name = "micronaut.http.client.read-timeout", value = "600")
@MicronautTest(transactional = false)
public class BookingControllerTest {
@@ -39,20 +37,27 @@ void cleanUp() {
@Test
void testTransactionSuspendedAndResumedOverHttp() {
+ // tag::http-propagation-suspend[]
HttpResponse holdResponse = client.toBlocking()
.exchange(HttpRequest.POST("/bookings/hold/JU501/2c/msid", ""), String.class);
-
+ // end::http-propagation-suspend[]
assertEquals(HttpStatus.OK, holdResponse.getStatus());
+ // tag::http-propagation-suspend[]
String transactionId = holdResponse.getHeaders().get(SESSIONLESS_TRANSACTION_HEADER);
+ // end::http-propagation-suspend[]
assertNotNull(transactionId);
+ // tag::http-propagation-suspend[]
Long seatId = Long.valueOf(holdResponse.getBody().orElseThrow());
+ // end::http-propagation-suspend[]
List seats = seatRepository.findAll();
assertTrue(CollectionUtils.isEmpty(seats));
+ // tag::http-propagation-resume[]
HttpRequest ticketRequest = HttpRequest.POST("/bookings/ticket/" + seatId, "")
.header(SESSIONLESS_TRANSACTION_HEADER, transactionId);
HttpResponse ticketResponse = client.toBlocking().exchange(ticketRequest, Void.class);
+ // end::http-propagation-resume[]
assertEquals(HttpStatus.NO_CONTENT, ticketResponse.getStatus());
assertFalse(ticketResponse.getHeaders().contains(SESSIONLESS_TRANSACTION_HEADER));
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
index 6c84f883a63..95082a08d1b 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
@@ -52,6 +52,7 @@ void testTransactionResumed() {
@Test
void testCurrentTransactionIdExportsSuspendedTransactionId() {
+ // tag::propagation[]
SuspendedSeat suspendedSeat = requireNonNull(transactionPropagationOperations.withPropagation(() -> {
Long seatId = bookingService.holdSeat(new Seat("JU502", "3a", "msid"));
String transactionId = transactionPropagationOperations.currentTransactionId().orElseThrow();
@@ -62,7 +63,7 @@ void testCurrentTransactionIdExportsSuspendedTransactionId() {
bookingService.ticketSeat(suspendedSeat.seatId());
return null;
});
-
+ // end::propagation[]
assertTicketedSeat();
}
diff --git a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc b/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
new file mode 100644
index 00000000000..e5c2746c863
--- /dev/null
+++ b/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
@@ -0,0 +1,107 @@
+Oracle sessionless transactions allow a JDBC transaction to be suspended and later resumed, potentially from a different request.
+Micronaut Data supports this for Oracle JDBC by adding two transaction propagation modes:
+
+* api:transaction.TransactionDefinition.Propagation[] `SUSPEND`
+* api:transaction.TransactionDefinition.Propagation[] `REQUIRES_SUSPENDED`
+
+The feature is intended for top-level workflow boundaries.
+Do not use `SUSPEND` or `REQUIRES_SUSPENDED` inside another active transaction.
+If a parent transaction is already active, Micronaut's generic transaction orchestration treats the inner method as participating in that existing transaction, so the Oracle sessionless begin or resume lifecycle is not started.
+
+Oracle sessionless transaction support is available only when Oracle JDBC classes are present.
+For `SUSPEND` and `REQUIRES_SUSPENDED`, the underlying JDBC connection must unwrap to an `oracle.jdbc.OracleConnection`.
+
+The following service starts a sessionless transaction, suspends it, and later resumes it:
+
+snippet::example.BookingService[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
+
+For a method declared with `SUSPEND`, the Oracle transaction manager:
+
+* requires an active Oracle sessionless propagation state
+* begins the JDBC transaction normally
+* starts an Oracle sessionless transaction with `OracleConnection.startTransaction(...)`
+* stores the returned Oracle global transaction identifier, or GTRID, in the propagation state
+* suspends the Oracle transaction instead of committing it when the method completes
+
+If the method declares a transaction timeout, that timeout is passed to Oracle when the sessionless transaction is started.
+If no timeout is declared, the Oracle driver and database defaults apply.
+
+For a method declared with `REQUIRES_SUSPENDED`, the Oracle transaction manager:
+
+* reads the GTRID from the active propagation state
+* begins the JDBC transaction normally
+* resumes the Oracle sessionless transaction with `OracleConnection.resumeTransaction(...)`
+* commits or rolls back the JDBC transaction normally
+* clears the GTRID from the propagation state after completion
+
+Because `SUSPEND` leaves work uncommitted, data written in the suspended transaction is not visible outside that transaction until a later `REQUIRES_SUSPENDED` transaction completes it.
+
+== HTTP Propagation
+
+HTTP propagation is provided by an HTTP server filter and is opt-in:
+
+[configuration]
+----
+micronaut:
+ data:
+ oracle:
+ sessionless:
+ http:
+ propagation-enabled: true
+----
+
+By default, the filter uses the `Oracle-Sessionless-Transaction-Id` header.
+You can change the header name:
+
+[configuration]
+----
+micronaut:
+ data:
+ oracle:
+ sessionless:
+ http:
+ propagation-enabled: true
+ header-name: X-Oracle-Sessionless-Transaction-Id
+----
+
+On each HTTP request, the filter creates the request-scoped Oracle sessionless propagation state.
+If the configured header is present, the filter decodes the header and stores the GTRID in that state so a `REQUIRES_SUSPENDED` method can resume it.
+If the request contains a malformed transaction id, the filter fails the request with a bad request response.
+If another component has already created Oracle sessionless transaction state before the filter runs, the filter fails fast because the HTTP filter owns the request-level state.
+
+On the response, if the request still has a suspended transaction id, the filter writes it to the configured header.
+After a resumed transaction commits or rolls back, the transaction manager clears the id, so the response does not contain the header.
+
+The following controller does not need to handle the header directly:
+
+snippet::example.BookingController[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
+
+The HTTP client sends one request that starts and suspends the transaction:
+
+snippet::example.BookingControllerTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-suspend", indent="0"]
+
+Then sends the returned header back on a later request that resumes and completes it:
+
+snippet::example.BookingControllerTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-resume", indent="0"]
+
+The header value is encoded through api:transaction.jdbc.oracle.OracleSessionlessTransactionIdCodec[].
+The default codec uses URL-safe Base64 without padding.
+Applications can provide their own `OracleSessionlessTransactionIdCodec` bean to sign, encrypt, or otherwise protect the value before it is exposed outside the process.
+
+IMPORTANT: The encoded transaction id is a capability to resume the suspended Oracle transaction.
+Only enable HTTP propagation for trusted workflows, protect the request with normal authentication and transport security, and use short transaction timeouts.
+
+== Programmatic Propagation
+
+For non-HTTP workflows, inject api:transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations[] and create an explicit propagation scope.
+This is useful for tests, messaging, scheduled work, or any transport that needs to export and import the encoded transaction id itself.
+
+snippet::example.BookingServiceTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="propagation", indent="0"]
+
+The first `withPropagation` call creates an empty propagation state.
+After the `SUSPEND` method returns, `currentTransactionId()` exposes the encoded transaction id from that state.
+The second `withPropagation(transactionId, ...)` call creates a state that already contains the id, so the `REQUIRES_SUSPENDED` method can resume and complete the transaction.
+
+The programmatic propagation scope is lexical.
+Code invoked in the same scope sees the same transaction id.
+A nested `withPropagation(...)` call creates a scoped override and restores the previous state after the supplier returns.
diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml
index c3a1d90d938..e9cb843b918 100644
--- a/src/main/docs/guide/toc.yml
+++ b/src/main/docs/guide/toc.yml
@@ -69,6 +69,7 @@ dbc:
title: JDBC
jdbcQuickStart: Quick Start
jdbcConfiguration: Configuration
+ oracleSessionlessTransactions: Oracle Sessionless Transactions
r2dbc:
title: R2DBC
r2dbcQuickStart: Quick Start
From 1f8b7827ecf853a5cf7d5e2b94c22d69f6b8b398 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 11:47:31 +0200
Subject: [PATCH 22/32] Oracle sessionless transaction impl - modified docs
---
.../test/java/example/BookingServiceTest.java | 9 +++-
.../jdbc/oracleSessionlessTransactions.adoc | 49 ++++++++++---------
2 files changed, 33 insertions(+), 25 deletions(-)
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
index 95082a08d1b..c2275630b82 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
@@ -12,6 +12,7 @@
import static java.util.Objects.requireNonNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest(transactional = false)
@@ -53,12 +54,16 @@ void testTransactionResumed() {
@Test
void testCurrentTransactionIdExportsSuspendedTransactionId() {
// tag::propagation[]
- SuspendedSeat suspendedSeat = requireNonNull(transactionPropagationOperations.withPropagation(() -> {
+ SuspendedSeat suspendedSeat = transactionPropagationOperations.withPropagation(() -> {
Long seatId = bookingService.holdSeat(new Seat("JU502", "3a", "msid"));
String transactionId = transactionPropagationOperations.currentTransactionId().orElseThrow();
return new SuspendedSeat(seatId, transactionId);
- }));
+ });
+ // end::propagation[]
+ assertNotNull(suspendedSeat);
+
+ // tag::propagation[]
transactionPropagationOperations.withPropagation(suspendedSeat.transactionId(), () -> {
bookingService.ticketSeat(suspendedSeat.seatId());
return null;
diff --git a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc b/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
index e5c2746c863..95c68c45235 100644
--- a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
+++ b/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
@@ -1,12 +1,12 @@
-Oracle sessionless transactions allow a JDBC transaction to be suspended and later resumed, potentially from a different request.
-Micronaut Data supports this for Oracle JDBC by adding two transaction propagation modes:
+Oracle sessionless transactions allow a JDBC transaction to be suspended and later resumed, potentially from a different
+request. Micronaut Data supports this for Oracle JDBC by adding two transaction propagation modes:
-* api:transaction.TransactionDefinition.Propagation[] `SUSPEND`
-* api:transaction.TransactionDefinition.Propagation[] `REQUIRES_SUSPENDED`
+* api:transaction.TransactionDefinition.Propagation#SUSPEND[]
+* api:transaction.TransactionDefinition.Propagation#REQUIRES_SUSPENDED[]
-The feature is intended for top-level workflow boundaries.
-Do not use `SUSPEND` or `REQUIRES_SUSPENDED` inside another active transaction.
-If a parent transaction is already active, Micronaut's generic transaction orchestration treats the inner method as participating in that existing transaction, so the Oracle sessionless begin or resume lifecycle is not started.
+The feature is intended for top-level workflow boundaries. Do not use `SUSPEND` or `REQUIRES_SUSPENDED` inside another
+active transaction. If a parent transaction is already active, Micronaut's generic transaction orchestration treats
+the inner method as participating in that existing transaction, so the Oracle sessionless begin or resume lifecycle is not started.
Oracle sessionless transaction support is available only when Oracle JDBC classes are present.
For `SUSPEND` and `REQUIRES_SUSPENDED`, the underlying JDBC connection must unwrap to an `oracle.jdbc.OracleConnection`.
@@ -34,7 +34,8 @@ For a method declared with `REQUIRES_SUSPENDED`, the Oracle transaction manager:
* commits or rolls back the JDBC transaction normally
* clears the GTRID from the propagation state after completion
-Because `SUSPEND` leaves work uncommitted, data written in the suspended transaction is not visible outside that transaction until a later `REQUIRES_SUSPENDED` transaction completes it.
+Because `SUSPEND` leaves work uncommitted, data written in the suspended transaction is not visible outside that transaction
+until a later `REQUIRES_SUSPENDED` transaction completes it.
== HTTP Propagation
@@ -64,10 +65,11 @@ micronaut:
header-name: X-Oracle-Sessionless-Transaction-Id
----
-On each HTTP request, the filter creates the request-scoped Oracle sessionless propagation state.
-If the configured header is present, the filter decodes the header and stores the GTRID in that state so a `REQUIRES_SUSPENDED` method can resume it.
-If the request contains a malformed transaction id, the filter fails the request with a bad request response.
-If another component has already created Oracle sessionless transaction state before the filter runs, the filter fails fast because the HTTP filter owns the request-level state.
+On each HTTP request, the filter creates the request-scoped Oracle sessionless propagation state. If the configured header
+is present, the filter decodes the header and stores the GTRID in that state so a `REQUIRES_SUSPENDED` method can resume it.
+If the request contains a malformed transaction id, the filter fails the request with a bad request response. If another
+component has already created Oracle sessionless transaction state before the filter runs, the filter fails fast because
+the HTTP filter owns the request-level state.
On the response, if the request still has a suspended transaction id, the filter writes it to the configured header.
After a resumed transaction commits or rolls back, the transaction manager clears the id, so the response does not contain the header.
@@ -84,24 +86,25 @@ Then sends the returned header back on a later request that resumes and complete
snippet::example.BookingControllerTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-resume", indent="0"]
-The header value is encoded through api:transaction.jdbc.oracle.OracleSessionlessTransactionIdCodec[].
-The default codec uses URL-safe Base64 without padding.
-Applications can provide their own `OracleSessionlessTransactionIdCodec` bean to sign, encrypt, or otherwise protect the value before it is exposed outside the process.
+The header value is encoded through api:transaction.jdbc.oracle.OracleSessionlessTransactionIdCodec[]. The default codec
+uses URL-safe Base64 without padding. Applications can provide their own `OracleSessionlessTransactionIdCodec` bean to sign,
+encrypt, or otherwise protect the value before it is exposed outside the process.
IMPORTANT: The encoded transaction id is a capability to resume the suspended Oracle transaction.
-Only enable HTTP propagation for trusted workflows, protect the request with normal authentication and transport security, and use short transaction timeouts.
+Only enable HTTP propagation for trusted workflows, protect the request with normal authentication and transport security,
+and use short transaction timeouts.
== Programmatic Propagation
-For non-HTTP workflows, inject api:transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations[] and create an explicit propagation scope.
-This is useful for tests, messaging, scheduled work, or any transport that needs to export and import the encoded transaction id itself.
+For non-HTTP workflows, inject api:transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations[] and create
+an explicit propagation scope. This is useful for tests, messaging, scheduled work, or any transport that needs to export
+and import the encoded transaction id itself.
snippet::example.BookingServiceTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="propagation", indent="0"]
-The first `withPropagation` call creates an empty propagation state.
-After the `SUSPEND` method returns, `currentTransactionId()` exposes the encoded transaction id from that state.
-The second `withPropagation(transactionId, ...)` call creates a state that already contains the id, so the `REQUIRES_SUSPENDED` method can resume and complete the transaction.
+The first `withPropagation` call creates an empty propagation state. After the `SUSPEND` method returns,
+`currentTransactionId()` exposes the encoded transaction id from that state. The second `withPropagation(transactionId, ...)`
+call creates a state that already contains the id, so the `REQUIRES_SUSPENDED` method can resume and complete the transaction.
-The programmatic propagation scope is lexical.
-Code invoked in the same scope sees the same transaction id.
+The programmatic propagation scope is lexical. Code invoked in the same scope sees the same transaction id.
A nested `withPropagation(...)` call creates a scoped override and restores the previous state after the supplier returns.
From 656e9c84027d5fb6a5fa2aa98b13b56f8f302830 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 12:48:14 +0200
Subject: [PATCH 23/32] Oracle sessionless transaction impl - added more tests
---
...ssionlessTransactionPropagationSpec.groovy | 305 ++++++++++++++++++
1 file changed, 305 insertions(+)
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy
new file mode 100644
index 00000000000..e45d640df83
--- /dev/null
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy
@@ -0,0 +1,305 @@
+/*
+ * Copyright 2017-2020 original authors
+ *
+ * Licensed 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
+ *
+ * https://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 io.micronaut.data.jdbc.oraclexe.sessionless
+
+import io.micronaut.context.annotation.Property
+import io.micronaut.context.annotation.Requires
+import io.micronaut.data.annotation.GeneratedValue
+import io.micronaut.data.annotation.Id
+import io.micronaut.data.annotation.MappedEntity
+import io.micronaut.data.annotation.Query
+import io.micronaut.data.jdbc.annotation.JdbcRepository
+import io.micronaut.data.jdbc.oraclexe.OracleTestPropertyProvider
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.repository.CrudRepository
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.annotation.Controller
+import io.micronaut.http.annotation.Post
+import io.micronaut.http.annotation.Status
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.annotation.Client
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import io.micronaut.scheduling.TaskExecutors
+import io.micronaut.scheduling.annotation.ExecuteOn
+import io.micronaut.test.extensions.spock.annotation.MicronautTest
+import io.micronaut.transaction.TransactionDefinition
+import io.micronaut.transaction.annotation.Transactional
+import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionHttpConfiguration
+import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations
+import jakarta.inject.Inject
+import jakarta.inject.Singleton
+import spock.lang.Specification
+
+@Property(name = "spec.name", value = OracleSessionlessTransactionPropagationSpec.SPEC_NAME)
+@Property(name = "micronaut.data.oracle.sessionless.http.propagation-enabled", value = "true")
+@Property(name = "micronaut.http.client.read-timeout", value = "600s")
+@MicronautTest(transactional = false)
+class OracleSessionlessTransactionPropagationSpec extends Specification implements OracleTestPropertyProvider {
+
+ static final String SPEC_NAME = "OracleSessionlessTransactionPropagationSpec"
+ private static final String SESSIONLESS_TRANSACTION_HEADER = OracleSessionlessTransactionHttpConfiguration.DEFAULT_HEADER_NAME
+
+ @Inject
+ ExpenseReportService expenseReportService
+
+ @Inject
+ ExpenseReportRepository expenseReportRepository
+
+ @Inject
+ OracleSessionlessTransactionPropagationOperations transactionPropagationOperations
+
+ @Inject
+ @Client("/")
+ HttpClient client
+
+ @Override
+ List packages() {
+ [getClass().package.name]
+ }
+
+ void setup() {
+ expenseReportRepository.deleteAll()
+ }
+
+ void "http propagation commits a suspended expense report approval"() {
+ when:
+ SubmittedExpenseReport report = submitViaHttp("employee-http-1", "travel", 125.75)
+
+ then:
+ expenseReportRepository.findById(report.id()).isEmpty()
+
+ when:
+ HttpRequest approveRequest = HttpRequest.POST("/expense-reports/approve/" + report.id(), "")
+ .header(SESSIONLESS_TRANSACTION_HEADER, report.transactionId())
+ HttpResponse approveResponse = client.toBlocking().exchange(approveRequest, Void)
+
+ then:
+ approveResponse.status == HttpStatus.NO_CONTENT
+ !approveResponse.headers.contains(SESSIONLESS_TRANSACTION_HEADER)
+ expenseReportRepository.findById(report.id()).orElseThrow().status == "APPROVED"
+ }
+
+ void "http propagation rolls back a resumed expense report approval"() {
+ when:
+ SubmittedExpenseReport report = submitViaHttp("employee-http-2", "meals", 43.20)
+
+ then:
+ expenseReportRepository.findById(report.id()).isEmpty()
+
+ when:
+ HttpRequest rejectRequest = HttpRequest.POST("/expense-reports/reject/" + report.id(), "")
+ .header(SESSIONLESS_TRANSACTION_HEADER, report.transactionId())
+ client.toBlocking().exchange(rejectRequest, Void)
+
+ then:
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.INTERNAL_SERVER_ERROR
+ !e.response.headers.contains(SESSIONLESS_TRANSACTION_HEADER)
+ expenseReportRepository.findById(report.id()).isEmpty()
+ }
+
+ void "programmatic propagation commits a suspended expense report approval"() {
+ when:
+ SubmittedExpenseReport report = suspendReport("employee-prog-1", "lodging", 318.40)
+
+ then:
+ expenseReportRepository.findById(report.id()).isEmpty()
+
+ when:
+ transactionPropagationOperations.withPropagation(report.transactionId(), {
+ expenseReportService.approveReport(report.id())
+ null
+ })
+
+ then:
+ expenseReportRepository.findById(report.id()).orElseThrow().status == "APPROVED"
+ }
+
+ void "programmatic propagation rolls back a resumed expense report approval"() {
+ given:
+ SubmittedExpenseReport report = suspendReport("employee-prog-2", "software", 799.99)
+ assert expenseReportRepository.findById(report.id()).isEmpty()
+
+ when:
+ transactionPropagationOperations.withPropagation(report.transactionId(), {
+ expenseReportService.rejectReport(report.id())
+ null
+ })
+
+ then:
+ ExpenseRejectedException e = thrown()
+ e.message == "Expense report failed policy check"
+ expenseReportRepository.findById(report.id()).isEmpty()
+ }
+
+ void "nested programmatic propagation restores the outer expense report approval"() {
+ when:
+ List reportIds = Objects.requireNonNull(transactionPropagationOperations.withPropagation({
+ Long outerReportId = expenseReportService.submitReport("employee-nested-outer", "conference", 640.00)
+ String outerTransactionId = transactionPropagationOperations.currentTransactionId().orElseThrow()
+ assert expenseReportRepository.findById(outerReportId).isEmpty()
+
+ SubmittedExpenseReport innerReport = Objects.requireNonNull(transactionPropagationOperations.withPropagation({
+ Long innerReportId = expenseReportService.submitReport("employee-nested-inner", "training", 215.25)
+ String innerTransactionId = transactionPropagationOperations.currentTransactionId().orElseThrow()
+ assert innerTransactionId != outerTransactionId
+ assert expenseReportRepository.findById(innerReportId).isEmpty()
+
+ expenseReportService.approveReport(innerReportId)
+ assert transactionPropagationOperations.currentTransactionId().isEmpty()
+ new SubmittedExpenseReport(innerReportId, innerTransactionId)
+ }))
+
+ assert transactionPropagationOperations.currentTransactionId().orElseThrow() == outerTransactionId
+ assert expenseReportRepository.findById(innerReport.id()).orElseThrow().status == "APPROVED"
+
+ expenseReportService.approveReport(outerReportId)
+ assert transactionPropagationOperations.currentTransactionId().isEmpty()
+ [outerReportId, innerReport.id()]
+ }))
+
+ then:
+ expenseReportRepository.findById(reportIds[0]).orElseThrow().status == "APPROVED"
+ expenseReportRepository.findById(reportIds[1]).orElseThrow().status == "APPROVED"
+ }
+
+ private SubmittedExpenseReport submitViaHttp(String employeeId, String category, BigDecimal amount) {
+ HttpResponse submitResponse = client.toBlocking()
+ .exchange(HttpRequest.POST("/expense-reports/submit/${employeeId}/${category}/${amount}", ""), String)
+ String transactionId = submitResponse.headers.get(SESSIONLESS_TRANSACTION_HEADER)
+ assert submitResponse.status == HttpStatus.OK
+ assert transactionId
+ new SubmittedExpenseReport(Long.valueOf(submitResponse.body()), transactionId)
+ }
+
+ private SubmittedExpenseReport suspendReport(String employeeId, String category, BigDecimal amount) {
+ Objects.requireNonNull(transactionPropagationOperations.withPropagation({
+ Long reportId = expenseReportService.submitReport(employeeId, category, amount)
+ String transactionId = transactionPropagationOperations.currentTransactionId().orElseThrow()
+ new SubmittedExpenseReport(reportId, transactionId)
+ }))
+ }
+
+ private static final class SubmittedExpenseReport {
+ private final Long id
+ private final String transactionId
+
+ private SubmittedExpenseReport(Long id, String transactionId) {
+ this.id = id
+ this.transactionId = transactionId
+ }
+
+ Long id() {
+ id
+ }
+
+ String transactionId() {
+ transactionId
+ }
+ }
+}
+
+@Singleton
+@Requires(property = "spec.name", value = OracleSessionlessTransactionPropagationSpec.SPEC_NAME)
+class ExpenseReportService {
+
+ private final ExpenseReportRepository expenseReportRepository
+
+ ExpenseReportService(ExpenseReportRepository expenseReportRepository) {
+ this.expenseReportRepository = expenseReportRepository
+ }
+
+ @Transactional(propagation = TransactionDefinition.Propagation.SUSPEND, timeout = 3600)
+ Long submitReport(String employeeId, String category, BigDecimal amount) {
+ ExpenseReport report = expenseReportRepository.save(new ExpenseReport(
+ employeeId: employeeId,
+ category: category,
+ expenseAmount: amount,
+ status: "SUBMITTED"
+ ))
+ report.id
+ }
+
+ @Transactional(propagation = TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ void approveReport(Long id) {
+ expenseReportRepository.updateStatus(id, "APPROVED")
+ }
+
+ @Transactional(propagation = TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ void rejectReport(Long id) {
+ expenseReportRepository.updateStatus(id, "REJECTED")
+ throw new ExpenseRejectedException("Expense report failed policy check")
+ }
+}
+
+@ExecuteOn(TaskExecutors.IO)
+@Controller("/expense-reports")
+@Requires(property = "spec.name", value = OracleSessionlessTransactionPropagationSpec.SPEC_NAME)
+class ExpenseReportController {
+
+ private final ExpenseReportService expenseReportService
+
+ ExpenseReportController(ExpenseReportService expenseReportService) {
+ this.expenseReportService = expenseReportService
+ }
+
+ @Post("/submit/{employeeId}/{category}/{amount}")
+ Long submit(String employeeId, String category, BigDecimal amount) {
+ expenseReportService.submitReport(employeeId, category, amount)
+ }
+
+ @Post("/approve/{id}")
+ @Status(HttpStatus.NO_CONTENT)
+ void approve(Long id) {
+ expenseReportService.approveReport(id)
+ }
+
+ @Post("/reject/{id}")
+ @Status(HttpStatus.NO_CONTENT)
+ void reject(Long id) {
+ expenseReportService.rejectReport(id)
+ }
+}
+
+@MappedEntity("expense_report")
+class ExpenseReport {
+
+ @Id
+ @GeneratedValue(value = GeneratedValue.Type.SEQUENCE, ref = "EXPENSE_REPORT_SEQ")
+ Long id
+
+ String employeeId
+ String category
+ BigDecimal expenseAmount
+ String status
+}
+
+@JdbcRepository(dialect = Dialect.ORACLE)
+@Requires(property = "spec.name", value = OracleSessionlessTransactionPropagationSpec.SPEC_NAME)
+interface ExpenseReportRepository extends CrudRepository {
+
+ @Query("UPDATE expense_report SET status = :status WHERE id = :id")
+ void updateStatus(Long id, String status)
+}
+
+class ExpenseRejectedException extends RuntimeException {
+
+ ExpenseRejectedException(String message) {
+ super(message)
+ }
+}
From fb17bc687fadb7fff8c68c597d69b8c428db4ac0 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 13:14:00 +0200
Subject: [PATCH 24/32] Oracle sessionless transaction impl - reduced
visibility of classes, added javadoc
---
...nlessTransactionPropagationOperations.java | 28 +++++++++++++++++++
...ssionlessTransactionHttpConfiguration.java | 4 +--
.../OracleSessionlessTransactionIdCodec.java | 2 +-
.../OracleSessionlessTransactionManager.java | 4 +--
...nlessTransactionPropagationOperations.java | 2 +-
5 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java
index 872780e8e6b..7970e3cfd94 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java
@@ -16,6 +16,7 @@
package io.micronaut.transaction.jdbc.oracle;
import io.micronaut.core.propagation.PropagatedContext;
+import io.micronaut.transaction.TransactionDefinition;
import io.micronaut.transaction.exceptions.TransactionUsageException;
import jakarta.inject.Singleton;
import org.jspecify.annotations.Nullable;
@@ -24,11 +25,26 @@
import java.util.Optional;
import java.util.function.Supplier;
+/**
+ * Default implementation of {@link OracleSessionlessTransactionPropagationOperations}.
+ *
+ * This implementation creates a lexical {@link PropagatedContext} scope that contains a single
+ * {@link OracleSessionlessTransactionState}. The transaction manager uses that state to publish the
+ * GTRID produced by {@link TransactionDefinition.Propagation#SUSPEND} and to
+ * consume the GTRID required by {@link TransactionDefinition.Propagation#REQUIRES_SUSPENDED}.
+ * Encoded transaction identifiers are converted through {@link OracleSessionlessTransactionIdCodec}, so
+ * applications can replace the codec without changing propagation mechanics.
+ */
@Singleton
final class DefaultOracleSessionlessTransactionPropagationOperations implements OracleSessionlessTransactionPropagationOperations {
private final OracleSessionlessTransactionIdCodec transactionIdCodec;
+ /**
+ * Creates the default propagation operations.
+ *
+ * @param transactionIdCodec The codec used to encode and decode transaction identifiers
+ */
DefaultOracleSessionlessTransactionPropagationOperations(OracleSessionlessTransactionIdCodec transactionIdCodec) {
this.transactionIdCodec = transactionIdCodec;
}
@@ -64,6 +80,18 @@ public void clearTransactionId() {
OracleSessionlessTransactionState.current().ifPresent(OracleSessionlessTransactionState::clearGtrid);
}
+ /**
+ * Executes the supplier with the provided sessionless transaction state installed in the current
+ * propagation context.
+ *
+ * Programmatic propagation is lexical: the new state is visible only while the supplier runs,
+ * and any previous state is restored by {@link PropagatedContext} when the supplier exits.
+ *
+ * @param state The state to propagate
+ * @param supplier The supplier to execute
+ * @param The result type
+ * @return The supplier result
+ */
private static T withPropagation(OracleSessionlessTransactionState state,
Supplier supplier) {
Objects.requireNonNull(supplier, "supplier");
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java
index d4b51094a9f..a9af2463d7b 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java
@@ -16,15 +16,13 @@
package io.micronaut.transaction.jdbc.oracle;
import io.micronaut.context.annotation.ConfigurationProperties;
-import io.micronaut.core.annotation.Internal;
import io.micronaut.core.util.StringUtils;
/**
* Configuration for HTTP propagation of Oracle sessionless transaction ids.
*/
-@Internal
@ConfigurationProperties(OracleSessionlessTransactionHttpConfiguration.PREFIX)
-public class OracleSessionlessTransactionHttpConfiguration {
+final class OracleSessionlessTransactionHttpConfiguration {
/**
* The configuration prefix for Oracle sessionless transaction HTTP propagation.
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodec.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodec.java
index d7a1cb7e935..e25ebca53f9 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodec.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodec.java
@@ -24,7 +24,7 @@
* Applications can provide their own bean implementation to apply additional protection, for
* example signing or encrypting the encoded value before it is exposed over HTTP.
*
- * @since 5.0.1
+ * @since 5.1.0
*/
@Experimental
public interface OracleSessionlessTransactionIdCodec {
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
index 1d879f32ab3..1627289c213 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
@@ -18,7 +18,6 @@
import io.micronaut.context.annotation.EachBean;
import io.micronaut.context.annotation.Parameter;
import io.micronaut.context.annotation.Replaces;
-import io.micronaut.core.annotation.Internal;
import io.micronaut.data.connection.ConnectionOperations;
import io.micronaut.data.connection.SynchronousConnectionManager;
import io.micronaut.transaction.TransactionDefinition;
@@ -42,10 +41,9 @@
/**
* Oracle JDBC transaction manager with sessionless transaction propagation support.
*/
-@Internal
@EachBean(DataSource.class)
@Replaces(DataSourceTransactionManager.class)
-public class OracleSessionlessTransactionManager extends DataSourceTransactionManager {
+final class OracleSessionlessTransactionManager extends DataSourceTransactionManager {
@Inject
public OracleSessionlessTransactionManager(@NonNull DataSource dataSource,
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperations.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperations.java
index 1f7b72d5a43..4bdf6080e54 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperations.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperations.java
@@ -28,7 +28,7 @@
* HTTP can use this API to create an equivalent propagation scope and exchange encoded transaction
* identifiers with other transports.
*
- * @since 5.0.1
+ * @since 5.1.0
*/
@Experimental
public interface OracleSessionlessTransactionPropagationOperations {
From c140360b79dccc395ddcabd946379b2f6134b9c3 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 14:19:12 +0200
Subject: [PATCH 25/32] Renamed jdbc-sessionless-transaction-booking-java
module
---
.../build.gradle | 0
.../src/main/java/example/Application.java | 0
.../src/main/java/example/BookingController.java | 0
.../src/main/java/example/BookingService.java | 0
.../src/main/java/example/Seat.java | 0
.../src/main/java/example/SeatRepository.java | 0
.../src/main/resources/application.yml | 0
.../src/main/resources/logback.xml | 0
.../src/test/java/example/BookingControllerTest.java | 0
.../src/test/java/example/BookingServiceTest.java | 0
settings.gradle | 2 +-
.../guide/dbc/jdbc/oracleSessionlessTransactions.adoc | 10 +++++-----
12 files changed, 6 insertions(+), 6 deletions(-)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/build.gradle (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/java/example/Application.java (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/java/example/BookingController.java (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/java/example/BookingService.java (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/java/example/Seat.java (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/java/example/SeatRepository.java (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/resources/application.yml (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/main/resources/logback.xml (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/test/java/example/BookingControllerTest.java (100%)
rename doc-examples/{jdbc-sessionless-transaction-booking-java => jdbc-sessionless-transaction-booking}/src/test/java/example/BookingServiceTest.java (100%)
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle b/doc-examples/jdbc-sessionless-transaction-booking/build.gradle
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle
rename to doc-examples/jdbc-sessionless-transaction-booking/build.gradle
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Application.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Application.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Application.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Application.java
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingController.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingController.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingController.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingController.java
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingService.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingService.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Seat.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Seat.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Seat.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Seat.java
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/SeatRepository.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/SeatRepository.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/SeatRepository.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/SeatRepository.java
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/logback.xml b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/logback.xml
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/logback.xml
rename to doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/logback.xml
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java
diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java
similarity index 100%
rename from doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java
rename to doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java
diff --git a/settings.gradle b/settings.gradle
index fd5b70323fa..9a6acf2a4d5 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -108,7 +108,7 @@ include 'doc-examples:jdbc-multitenancy-datasource-example-java'
include 'doc-examples:jdbc-multitenancy-schema-example-java'
include 'doc-examples:jdbc-multitenancy-discriminator-example-java'
-include 'doc-examples:jdbc-sessionless-transaction-booking-java'
+include 'doc-examples:jdbc-sessionless-transaction-booking'
include 'doc-examples:jdbc-and-r2dbc-example-java'
diff --git a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc b/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
index 95c68c45235..1abe959e4d9 100644
--- a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
+++ b/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
@@ -13,7 +13,7 @@ For `SUSPEND` and `REQUIRES_SUSPENDED`, the underlying JDBC connection must unwr
The following service starts a sessionless transaction, suspends it, and later resumes it:
-snippet::example.BookingService[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
+snippet::example.BookingService[project="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
For a method declared with `SUSPEND`, the Oracle transaction manager:
@@ -76,15 +76,15 @@ After a resumed transaction commits or rolls back, the transaction manager clear
The following controller does not need to handle the header directly:
-snippet::example.BookingController[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
+snippet::example.BookingController[project="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
The HTTP client sends one request that starts and suspends the transaction:
-snippet::example.BookingControllerTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-suspend", indent="0"]
+snippet::example.BookingControllerTest[project="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-suspend", indent="0"]
Then sends the returned header back on a later request that resumes and completes it:
-snippet::example.BookingControllerTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-resume", indent="0"]
+snippet::example.BookingControllerTest[project="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="http-propagation-resume", indent="0"]
The header value is encoded through api:transaction.jdbc.oracle.OracleSessionlessTransactionIdCodec[]. The default codec
uses URL-safe Base64 without padding. Applications can provide their own `OracleSessionlessTransactionIdCodec` bean to sign,
@@ -100,7 +100,7 @@ For non-HTTP workflows, inject api:transaction.jdbc.oracle.OracleSessionlessTran
an explicit propagation scope. This is useful for tests, messaging, scheduled work, or any transport that needs to export
and import the encoded transaction id itself.
-snippet::example.BookingServiceTest[project-base="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="propagation", indent="0"]
+snippet::example.BookingServiceTest[project="doc-examples/jdbc-sessionless-transaction-booking", source="test", tags="propagation", indent="0"]
The first `withPropagation` call creates an empty propagation state. After the `SUSPEND` method returns,
`currentTransactionId()` exposes the encoded transaction id from that state. The second `withPropagation(transactionId, ...)`
From 92b62bc26d724241ffe9b852a5524de5be9e57cf Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 14:29:13 +0200
Subject: [PATCH 26/32] Addressed code review comments
---
.../OracleSessionlessTransactionManager.java | 8 +--
...leSessionlessTransactionManagerSpec.groovy | 50 +++++++++++++++++++
2 files changed, 55 insertions(+), 3 deletions(-)
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
index 1627289c213..b40249c38be 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
@@ -105,8 +105,10 @@ protected void doCommit(DefaultTransactionStatus status) {
@Override
protected void doRollback(DefaultTransactionStatus status) {
- if (status.getTransactionDefinition().getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
- rollbackResumedSessionlessTransaction(status);
+ TransactionDefinition.Propagation propagation = status.getTransactionDefinition().getPropagationBehavior();
+ if (propagation == TransactionDefinition.Propagation.SUSPEND
+ || propagation == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ rollbackSessionlessTransaction(status);
} else {
super.doRollback(status);
}
@@ -121,7 +123,7 @@ private void commitResumedSessionlessTransaction(DefaultTransactionStatus status) {
+ private void rollbackSessionlessTransaction(DefaultTransactionStatus status) {
Optional state = findSessionlessTransactionState();
try {
super.doRollback(status);
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
index 2c131fea2b2..74a8b729728 100644
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
@@ -133,6 +133,56 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
0 * connection.commit()
}
+ def "suspended transaction context is cleared on rollback"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+ def state = new OracleSessionlessTransactionState()
+ state.setGtrid([1, 2, 3] as byte[])
+
+ when:
+ Boolean presentAfterRollback = null
+ PropagatedContext.empty().plus(state).propagate({
+ manager.doRollback(status)
+ presentAfterRollback = state.gtrid.isPresent()
+ })
+
+ then:
+ 1 * connection.rollback()
+ !presentAfterRollback
+ state.gtrid.isEmpty()
+ }
+
+ def "suspended transaction context is cleared when rollback fails"() {
+ given:
+ def manager = newTransactionManager()
+ def connection = Mock(Connection)
+ def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def status = txStatus(connection, definition, manager)
+ def state = new OracleSessionlessTransactionState()
+ state.setGtrid([1, 2, 3] as byte[])
+
+ when:
+ TransactionSystemException thrownException = null
+ Boolean presentAfterRollback = null
+ PropagatedContext.empty().plus(state).propagate({
+ try {
+ manager.doRollback(status)
+ } catch (TransactionSystemException e) {
+ thrownException = e
+ presentAfterRollback = state.gtrid.isPresent()
+ }
+ })
+
+ then:
+ 1 * connection.rollback() >> { throw new SQLException("rollback failed") }
+ thrownException != null
+ !presentAfterRollback
+ state.gtrid.isEmpty()
+ }
+
def "resumed transaction context is cleared when commit fails"() {
given:
def manager = newTransactionManager()
From 55f47a0ee205a90ff2b68f57f6d48d1bde8bcf69 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 14:35:57 +0200
Subject: [PATCH 27/32] Addressed code review comments
---
.../jdbc/JdbcTransactionManagerCondition.java | 4 +++-
.../OracleSessionlessTransactionManager.java | 3 +++
...OracleSessionlessTransactionManagerSpec.groovy | 15 +++++++++++++++
3 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/JdbcTransactionManagerCondition.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/JdbcTransactionManagerCondition.java
index 71a48dde62a..6b4cd1208dd 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/JdbcTransactionManagerCondition.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/JdbcTransactionManagerCondition.java
@@ -15,11 +15,13 @@
*/
package io.micronaut.transaction.jdbc;
+import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.transaction.support.AbstractDataSourceTransactionManagerCondition;
+@Internal
@Introspected
-final class JdbcTransactionManagerCondition extends AbstractDataSourceTransactionManagerCondition {
+public final class JdbcTransactionManagerCondition extends AbstractDataSourceTransactionManagerCondition {
@Override
protected String getTransactionManagerName() {
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
index b40249c38be..e8556057b75 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
@@ -18,6 +18,7 @@
import io.micronaut.context.annotation.EachBean;
import io.micronaut.context.annotation.Parameter;
import io.micronaut.context.annotation.Replaces;
+import io.micronaut.context.annotation.Requires;
import io.micronaut.data.connection.ConnectionOperations;
import io.micronaut.data.connection.SynchronousConnectionManager;
import io.micronaut.transaction.TransactionDefinition;
@@ -25,6 +26,7 @@
import io.micronaut.transaction.exceptions.TransactionSystemException;
import io.micronaut.transaction.impl.DefaultTransactionStatus;
import io.micronaut.transaction.jdbc.DataSourceTransactionManager;
+import io.micronaut.transaction.jdbc.JdbcTransactionManagerCondition;
import io.micronaut.transaction.support.TransactionExecutionListener;
import jakarta.inject.Inject;
import oracle.jdbc.OracleConnection;
@@ -43,6 +45,7 @@
*/
@EachBean(DataSource.class)
@Replaces(DataSourceTransactionManager.class)
+@Requires(condition = JdbcTransactionManagerCondition.class)
final class OracleSessionlessTransactionManager extends DataSourceTransactionManager {
@Inject
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
index 74a8b729728..55b25dc1db1 100644
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
@@ -1,5 +1,6 @@
package io.micronaut.transaction.jdbc.oracle
+import io.micronaut.context.ApplicationContext
import io.micronaut.core.propagation.PropagatedContext
import io.micronaut.data.connection.ConnectionDefinition
import io.micronaut.data.connection.ConnectionOperations
@@ -20,6 +21,20 @@ import java.time.Duration
class OracleSessionlessTransactionManagerSpec extends Specification {
+ def "oracle manager is disabled when datasource selects another transaction manager"() {
+ given:
+ def context = ApplicationContext.run([
+ "datasources.default.url" : "jdbc:h2:mem:oracleSessionlessTxCondition;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE",
+ "datasources.default.transaction-manager": "hibernate"
+ ])
+
+ expect:
+ !context.containsBean(OracleSessionlessTransactionManager)
+
+ cleanup:
+ context.close()
+ }
+
def "begin delegates JDBC setup and starts sessionless transaction with listener support"() {
given:
def listener = Mock(TransactionExecutionListener)
From 9a7fe9cfdd15d28fb56386d1a6657e17ad17b738 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 15:06:48 +0200
Subject: [PATCH 28/32] Addressed code review comments
---
.../AbstractSpringTransactionOperations.java | 3 +-
.../jdbc/DataSourceTransactionManager.java | 7 ----
...PropagatedStatusTransactionOperations.java | 8 ++++
.../AbstractReactorTransactionOperations.java | 9 +++++
.../AbstractTransactionOperations.java | 1 +
.../transaction/support/TransactionUtil.java | 20 ++++++++++
.../java/io/micronaut/transaction/TxSpec.java | 23 +++++++++++
.../DoRollbackOnCommitExceptionTest.java | 40 +++++++++++++++++++
8 files changed, 103 insertions(+), 8 deletions(-)
diff --git a/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java b/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java
index 0c8e2a469f6..e8a12f4a0aa 100644
--- a/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java
+++ b/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java
@@ -28,6 +28,7 @@
import io.micronaut.transaction.exceptions.TransactionException;
import io.micronaut.transaction.support.AbstractPropagatedStatusTransactionOperations;
import io.micronaut.transaction.support.ExceptionUtil;
+import io.micronaut.transaction.support.TransactionUtil;
import org.jspecify.annotations.Nullable;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionDefinition;
@@ -111,6 +112,7 @@ protected R doExecute(TransactionDefinition definition, TransactionCallback<
@SuppressWarnings("NullAway")
private DefaultTransactionDefinition asSpringTxDefinition(TransactionDefinition definition) {
+ TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
final DefaultTransactionDefinition def = new DefaultTransactionDefinition();
definition.isReadOnly().ifPresent(def::setReadOnly);
def.setIsolationLevel(definition.getIsolationLevel().orElse(TransactionDefinition.Isolation.DEFAULT).getCode());
@@ -255,4 +257,3 @@ public void afterCompletion(int status) {
}
}
}
-
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
index 9e0829405e6..3e5241a77cb 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
@@ -195,13 +195,6 @@ public void executionComplete() {
}
}
- /**
- * @return Whether this manager supports Oracle sessionless transaction propagation modes.
- */
- protected boolean supportsOracleSessionlessTransactions() {
- return false;
- }
-
private void validateOracleSessionlessPropagation(TransactionDefinition definition) {
TransactionDefinition.Propagation propagation = definition.getPropagationBehavior();
if (propagation != TransactionDefinition.Propagation.SUSPEND
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java
index c1881f8d964..dbf65337fdd 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java
@@ -45,6 +45,13 @@ public abstract class AbstractPropagatedStatusTransactionOperations R doExecute(TransactionDefinition definition, TransactionCallback callback);
+ /**
+ * @return Whether this transaction manager supports Oracle sessionless transaction propagation modes.
+ */
+ protected boolean supportsOracleSessionlessTransactions() {
+ return false;
+ }
+
@Override
public final Optional> findTransactionStatus() {
return findTransactionStatusInternal().map(status -> status);
@@ -61,6 +68,7 @@ public final Optional findTransactionStatusInternal() {
@Override
public final R execute(@NonNull TransactionDefinition definition,
@NonNull TransactionCallback callback) {
+ TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
return doExecute(definition, status -> status.propagate(() -> {
try {
return callback.call(status);
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java
index 3cd0613c6d3..92ad280d023 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java
@@ -108,6 +108,7 @@ public final Flux withTransaction(@NonNull TransactionDefinition definiti
@NonNull TransactionalCallback handler) {
Objects.requireNonNull(definition, "Transaction definition cannot be null");
Objects.requireNonNull(handler, "Callback handler cannot be null");
+ TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
return Flux.deferContextual(contextView -> {
@Nullable ReactiveTransactionStatus transactionStatus = getTransactionStatus(contextView);
@@ -115,6 +116,13 @@ public final Flux withTransaction(@NonNull TransactionDefinition definiti
});
}
+ /**
+ * @return Whether this transaction manager supports Oracle sessionless transaction propagation modes.
+ */
+ protected boolean supportsOracleSessionlessTransactions() {
+ return false;
+ }
+
/**
* Execute the transaction with provided transaction status.
*
@@ -156,6 +164,7 @@ private Flux openNewConnectionAndTx(TransactionDefinition definition, Tra
public Mono withTransactionMono(TransactionDefinition definition, Function, Mono> handler) {
Objects.requireNonNull(definition, "Transaction definition cannot be null");
Objects.requireNonNull(handler, "Callback handler cannot be null");
+ TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
return Mono.deferContextual(contextView -> {
ReactiveTransactionStatus transactionStatus = getTransactionStatus(contextView);
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java
index 470689a90c5..4ba4c912ff4 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java
@@ -160,6 +160,7 @@ public void afterCompletion(Status status) {
@NonNull
@Override
public T getTransaction(TransactionDefinition definition) throws TransactionException {
+ TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
boolean debugEnabled = logger.isDebugEnabled();
if (debugEnabled) {
logger.debug("Getting transaction for definition [{}]", definition);
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java b/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java
index 130c9da4795..c89102cced3 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java
@@ -23,6 +23,7 @@
import io.micronaut.transaction.annotation.OracleTransactional;
import io.micronaut.transaction.annotation.Transactional;
import io.micronaut.transaction.exceptions.CannotCreateTransactionException;
+import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@@ -101,6 +102,25 @@ public static TransactionDefinition getTransactionDefinition(String name, Annota
return null;
}
+ /**
+ * Validates whether a transaction definition that uses Oracle sessionless propagation is supported.
+ *
+ * @param definition The transaction definition
+ * @param supported Whether the transaction manager supports Oracle sessionless transaction propagation
+ */
+ public static void validateOracleSessionlessPropagation(TransactionDefinition definition, boolean supported) {
+ TransactionDefinition.Propagation propagation = definition.getPropagationBehavior();
+ if (propagation != TransactionDefinition.Propagation.SUSPEND
+ && propagation != TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ return;
+ }
+ if (!supported) {
+ throw new TransactionSuspensionNotSupportedException(
+ "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ );
+ }
+ }
+
private static OracleTransactional.Priority parseOraclePriority(String priority) {
try {
return OracleTransactional.Priority.valueOf(priority.trim().toUpperCase(Locale.ROOT));
diff --git a/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java b/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java
index 9cb04ade272..10314e098fd 100644
--- a/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java
+++ b/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java
@@ -16,6 +16,7 @@
package io.micronaut.transaction;
import io.micronaut.context.ApplicationContext;
+import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
@@ -240,5 +241,27 @@ public void testTwoFluxJobsInOneTx() {
}
}
+ @Test
+ public void testReactiveTxRejectsOracleSessionlessPropagation() {
+ try (ApplicationContext applicationContext = ApplicationContext.run()) {
+ ReactiveTxManager txManager = applicationContext.getBean(ReactiveTxManager.class);
+
+ assertUnsupportedReactiveOracleSessionlessPropagation(txManager, TransactionDefinition.Propagation.SUSPEND);
+ assertUnsupportedReactiveOracleSessionlessPropagation(txManager, TransactionDefinition.Propagation.REQUIRES_SUSPENDED);
+ }
+ }
+
+ private static void assertUnsupportedReactiveOracleSessionlessPropagation(ReactiveTxManager txManager,
+ TransactionDefinition.Propagation propagation) {
+ TransactionSuspensionNotSupportedException exception = Assertions.assertThrows(
+ TransactionSuspensionNotSupportedException.class,
+ () -> txManager.withTransactionMono(TransactionDefinition.of(propagation), status -> Mono.just("ignored"))
+ );
+ Assertions.assertEquals(
+ "Propagation '" + propagation + "' requires Oracle sessionless transaction support",
+ exception.getMessage()
+ );
+ }
+
// end::test[]
}
diff --git a/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java b/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java
index 6181890d99f..3a1ab92cd2c 100644
--- a/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java
+++ b/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java
@@ -21,6 +21,7 @@
import io.micronaut.data.connection.ConnectionStatus;
import io.micronaut.data.connection.ConnectionSynchronization;
import io.micronaut.transaction.TransactionDefinition;
+import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
import io.micronaut.transaction.exceptions.TransactionSystemException;
import io.micronaut.transaction.exceptions.UnexpectedRollbackException;
import io.micronaut.transaction.impl.DefaultTransactionStatus;
@@ -36,6 +37,7 @@
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Verifies that doRollbackOnCommitException dispatches to the correct
@@ -135,6 +137,22 @@ void existingNonNestedBeforeCommitFailureDispatchesToSetRollbackOnly() {
);
}
+ @Test
+ void unsupportedOracleSessionlessPropagationIsRejectedBeforeTransactionalWork() {
+ assertUnsupportedOracleSessionlessPropagation(TransactionDefinition.Propagation.SUSPEND);
+ assertUnsupportedOracleSessionlessPropagation(TransactionDefinition.Propagation.REQUIRES_SUSPENDED);
+
+ assertEquals(List.of(), txManager.calls);
+ }
+
+ @Test
+ void unsupportedOracleSessionlessPropagationIsRejectedBeforeProgrammaticTransactionCreation() {
+ assertUnsupportedProgrammaticOracleSessionlessPropagation(TransactionDefinition.Propagation.SUSPEND);
+ assertUnsupportedProgrammaticOracleSessionlessPropagation(TransactionDefinition.Propagation.REQUIRES_SUSPENDED);
+
+ assertEquals(List.of(), txManager.calls);
+ }
+
private static void registerThrowingBeforeCommit(Object status) {
((InternalTransaction>) status).registerInvocationSynchronization(
new TransactionSynchronization() {
@@ -146,6 +164,28 @@ public void beforeCommit(boolean readOnly) {
);
}
+ private void assertUnsupportedOracleSessionlessPropagation(TransactionDefinition.Propagation propagation) {
+ TransactionSuspensionNotSupportedException exception = assertThrows(
+ TransactionSuspensionNotSupportedException.class,
+ () -> txManager.execute(TransactionDefinition.of(propagation), status -> null)
+ );
+ assertEquals(
+ "Propagation '" + propagation + "' requires Oracle sessionless transaction support",
+ exception.getMessage()
+ );
+ }
+
+ private void assertUnsupportedProgrammaticOracleSessionlessPropagation(TransactionDefinition.Propagation propagation) {
+ TransactionSuspensionNotSupportedException exception = assertThrows(
+ TransactionSuspensionNotSupportedException.class,
+ () -> txManager.getTransaction(TransactionDefinition.of(propagation))
+ );
+ assertEquals(
+ "Propagation '" + propagation + "' requires Oracle sessionless transaction support",
+ exception.getMessage()
+ );
+ }
+
/**
* Transaction manager that records which doXxx methods are called.
*/
From d39114423aff5bb7fc7872353b989dfaec445dfa Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 15:28:53 +0200
Subject: [PATCH 29/32] Fixed native image issue
---
.../src/main/resources/application.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml
index 01ec851f3ad..25981a64072 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml
+++ b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml
@@ -9,6 +9,7 @@ micronaut:
datasources:
default:
+ driverClassName: oracle.jdbc.OracleDriver
db-type: oracle
dialect: ORACLE
schema-generate: CREATE_DROP
From c8e2359987f90a15b042baf88fc66f8b95065eea Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 15:32:29 +0200
Subject: [PATCH 30/32] Addressed sonarqube reported issues
---
.../src/test/java/example/BookingControllerTest.java | 2 +-
.../src/test/java/example/BookingServiceTest.java | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java
index 1872e6a90c3..b42decbf13b 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java
@@ -19,7 +19,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest(transactional = false)
-public class BookingControllerTest {
+class BookingControllerTest {
private static final String SESSIONLESS_TRANSACTION_HEADER = "Oracle-Sessionless-Transaction-Id";
diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java
index c2275630b82..29e3c06ab75 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java
@@ -16,7 +16,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest(transactional = false)
-public class BookingServiceTest {
+class BookingServiceTest {
@Inject
BookingService bookingService;
From ff80d52f0a79f382a60d5808e7c5bed31721a1b2 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Wed, 27 May 2026 16:37:53 +0200
Subject: [PATCH 31/32] Removed unnecessary changes
---
.../jdbc/DataSourceTransactionManager.java | 29 --------------
.../DataSourceTransactionManagerSpec.groovy | 38 ++-----------------
2 files changed, 4 insertions(+), 63 deletions(-)
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
index 3e5241a77cb..a2c61038bec 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/DataSourceTransactionManager.java
@@ -23,7 +23,6 @@
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import io.micronaut.core.annotation.TypeHint;
-import io.micronaut.data.connection.ConnectionDefinition;
import io.micronaut.data.connection.ConnectionOperations;
import io.micronaut.data.connection.ConnectionSynchronization;
import io.micronaut.data.connection.SynchronousConnectionManager;
@@ -31,7 +30,6 @@
import io.micronaut.data.connection.support.JdbcConnectionUtils;
import io.micronaut.transaction.TransactionDefinition;
import io.micronaut.transaction.exceptions.CannotCreateTransactionException;
-import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
import io.micronaut.transaction.exceptions.TransactionSystemException;
import io.micronaut.transaction.impl.DefaultTransactionStatus;
import io.micronaut.transaction.support.AbstractDefaultTransactionOperations;
@@ -146,23 +144,9 @@ public boolean isEnforceReadOnly() {
return this.enforceReadOnly;
}
- @Override
- protected ConnectionDefinition getConnectionDefinition(TransactionDefinition transactionDefinition) {
- validateOracleSessionlessPropagation(transactionDefinition);
- return super.getConnectionDefinition(transactionDefinition);
- }
-
- @Override
- protected DefaultTransactionStatus createExistingTransactionStatus(TransactionDefinition definition,
- DefaultTransactionStatus existingTransaction) {
- validateOracleSessionlessPropagation(definition);
- return super.createExistingTransactionStatus(definition, existingTransaction);
- }
-
@Override
protected void doBegin(DefaultTransactionStatus status) {
TransactionDefinition definition = status.getTransactionDefinition();
- validateOracleSessionlessPropagation(definition);
Connection connection = status.getConnection();
List onComplete = new ArrayList<>(5);
@@ -195,19 +179,6 @@ public void executionComplete() {
}
}
- private void validateOracleSessionlessPropagation(TransactionDefinition definition) {
- TransactionDefinition.Propagation propagation = definition.getPropagationBehavior();
- if (propagation != TransactionDefinition.Propagation.SUSPEND
- && propagation != TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
- return;
- }
- if (!supportsOracleSessionlessTransactions()) {
- throw new TransactionSuspensionNotSupportedException(
- "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
- );
- }
- }
-
@Override
protected void doCommit(DefaultTransactionStatus status) {
Connection connection = status.getConnection();
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
index 9705fc5ef39..0a25d3de165 100644
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
@@ -1,33 +1,25 @@
package io.micronaut.transaction.jdbc
-import io.micronaut.data.connection.ConnectionDefinition
import io.micronaut.data.connection.ConnectionOperations
import io.micronaut.data.connection.SynchronousConnectionManager
-import io.micronaut.data.connection.support.DefaultConnectionStatus
import io.micronaut.transaction.TransactionDefinition
import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException
-import io.micronaut.transaction.impl.DefaultTransactionStatus
import spock.lang.Specification
import javax.sql.DataSource
-import java.sql.Connection
class DataSourceTransactionManagerSpec extends Specification {
- def "plain JDBC manager rejects Oracle sessionless propagation before JDBC begin"(TransactionDefinition.Propagation propagation) {
+ def "plain JDBC manager rejects Oracle sessionless propagation before transactional work"(TransactionDefinition.Propagation propagation) {
given:
def txManager = newTxManager()
- def connection = Mock(Connection)
- def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
- def txStatus = DefaultTransactionStatus.newTx(connectionStatus, definition(propagation), txManager)
when:
- txManager.doBegin(txStatus)
+ txManager.execute(definition(propagation), { status -> null })
then:
def e = thrown(TransactionSuspensionNotSupportedException)
e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
- 0 * connection._
where:
propagation << [
@@ -36,34 +28,12 @@ class DataSourceTransactionManagerSpec extends Specification {
]
}
- def "plain JDBC manager rejects Oracle sessionless propagation before joining an existing transaction"(TransactionDefinition.Propagation propagation) {
+ def "plain JDBC manager rejects Oracle sessionless propagation before programmatic transaction creation"(TransactionDefinition.Propagation propagation) {
given:
def txManager = newTxManager()
- def connection = Mock(Connection)
- def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
- def existingTransaction = DefaultTransactionStatus.newTx(connectionStatus, definition(TransactionDefinition.Propagation.REQUIRED), txManager)
when:
- txManager.createExistingTransactionStatus(definition(propagation), existingTransaction)
-
- then:
- def e = thrown(TransactionSuspensionNotSupportedException)
- e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
- 0 * connection._
-
- where:
- propagation << [
- TransactionDefinition.Propagation.SUSPEND,
- TransactionDefinition.Propagation.REQUIRES_SUSPENDED
- ]
- }
-
- def "plain JDBC manager rejects Oracle sessionless propagation before resolving a connection definition"(TransactionDefinition.Propagation propagation) {
- given:
- def txManager = newTxManager()
-
- when:
- txManager.getConnectionDefinition(definition(propagation))
+ txManager.getTransaction(definition(propagation))
then:
def e = thrown(TransactionSuspensionNotSupportedException)
From fa1b6f77a79b0708f9626fcaad62d66cb7f3be77 Mon Sep 17 00:00:00 2001
From: Milenko Supic
Date: Mon, 22 Jun 2026 12:34:58 +0200
Subject: [PATCH 32/32] Replaced the Oracle-specific SUSPEND and
REQUIRES_SUSPENDED transaction propagation values with
OracleTransactional.Sessionless modes
---
...ssionlessTransactionPropagationSpec.groovy | 9 ++--
.../AbstractSpringTransactionOperations.java | 2 +-
...nlessTransactionPropagationOperations.java | 6 +--
.../OracleSessionlessTransactionManager.java | 20 +++----
.../DataSourceTransactionManagerSpec.groovy | 43 +++++++--------
...leSessionlessTransactionManagerSpec.groovy | 43 +++++++--------
.../transaction/TransactionDefinition.java | 27 +---------
.../annotation/OracleTransactional.java | 44 ++++++++++++++-
...PropagatedStatusTransactionOperations.java | 4 +-
.../AbstractReactorTransactionOperations.java | 6 +--
.../AbstractTransactionOperations.java | 4 +-
.../transaction/support/TransactionUtil.java | 51 +++++++++++++++---
.../transaction/TransactionUtilSpec.java | 53 ++++++++++++++++++-
.../java/io/micronaut/transaction/TxSpec.java | 20 ++++---
.../DoRollbackOnCommitExceptionTest.java | 31 ++++++-----
.../src/main/java/example/BookingService.java | 7 ++-
src/main/docs/guide/shared/transactions.adoc | 21 --------
.../transactions/oracleTransactions.adoc | 6 +++
.../sessionlessTransactions.adoc} | 14 ++---
.../transactionPriority.adoc | 18 +++++++
src/main/docs/guide/toc.yml | 5 +-
21 files changed, 275 insertions(+), 159 deletions(-)
create mode 100644 src/main/docs/guide/shared/transactions/oracleTransactions.adoc
rename src/main/docs/guide/{dbc/jdbc/oracleSessionlessTransactions.adoc => shared/transactions/oracleTransactions/sessionlessTransactions.adoc} (87%)
create mode 100644 src/main/docs/guide/shared/transactions/oracleTransactions/transactionPriority.adoc
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy
index e45d640df83..591f4d10fb5 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy
@@ -37,8 +37,7 @@ import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import io.micronaut.test.extensions.spock.annotation.MicronautTest
-import io.micronaut.transaction.TransactionDefinition
-import io.micronaut.transaction.annotation.Transactional
+import io.micronaut.transaction.annotation.OracleTransactional
import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionHttpConfiguration
import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations
import jakarta.inject.Inject
@@ -224,7 +223,7 @@ class ExpenseReportService {
this.expenseReportRepository = expenseReportRepository
}
- @Transactional(propagation = TransactionDefinition.Propagation.SUSPEND, timeout = 3600)
+ @OracleTransactional(sessionless = OracleTransactional.Sessionless.SUSPEND, timeout = 3600)
Long submitReport(String employeeId, String category, BigDecimal amount) {
ExpenseReport report = expenseReportRepository.save(new ExpenseReport(
employeeId: employeeId,
@@ -235,12 +234,12 @@ class ExpenseReportService {
report.id
}
- @Transactional(propagation = TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ @OracleTransactional(sessionless = OracleTransactional.Sessionless.REQUIRES_SUSPENDED)
void approveReport(Long id) {
expenseReportRepository.updateStatus(id, "APPROVED")
}
- @Transactional(propagation = TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ @OracleTransactional(sessionless = OracleTransactional.Sessionless.REQUIRES_SUSPENDED)
void rejectReport(Long id) {
expenseReportRepository.updateStatus(id, "REJECTED")
throw new ExpenseRejectedException("Expense report failed policy check")
diff --git a/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java b/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java
index e8a12f4a0aa..dbb63eb67c1 100644
--- a/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java
+++ b/data-spring-jdbc/src/main/java/io/micronaut/data/spring/tx/AbstractSpringTransactionOperations.java
@@ -112,7 +112,7 @@ protected R doExecute(TransactionDefinition definition, TransactionCallback<
@SuppressWarnings("NullAway")
private DefaultTransactionDefinition asSpringTxDefinition(TransactionDefinition definition) {
- TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
+ TransactionUtil.validateOracleSessionlessMode(definition, supportsOracleSessionlessTransactions());
final DefaultTransactionDefinition def = new DefaultTransactionDefinition();
definition.isReadOnly().ifPresent(def::setReadOnly);
def.setIsolationLevel(definition.getIsolationLevel().orElse(TransactionDefinition.Isolation.DEFAULT).getCode());
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java
index 7970e3cfd94..264d6c38f94 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java
@@ -16,7 +16,7 @@
package io.micronaut.transaction.jdbc.oracle;
import io.micronaut.core.propagation.PropagatedContext;
-import io.micronaut.transaction.TransactionDefinition;
+import io.micronaut.transaction.annotation.OracleTransactional;
import io.micronaut.transaction.exceptions.TransactionUsageException;
import jakarta.inject.Singleton;
import org.jspecify.annotations.Nullable;
@@ -30,8 +30,8 @@
*
* This implementation creates a lexical {@link PropagatedContext} scope that contains a single
* {@link OracleSessionlessTransactionState}. The transaction manager uses that state to publish the
- * GTRID produced by {@link TransactionDefinition.Propagation#SUSPEND} and to
- * consume the GTRID required by {@link TransactionDefinition.Propagation#REQUIRES_SUSPENDED}.
+ * GTRID produced by {@link OracleTransactional.Sessionless#SUSPEND} and to
+ * consume the GTRID required by {@link OracleTransactional.Sessionless#REQUIRES_SUSPENDED}.
* Encoded transaction identifiers are converted through {@link OracleSessionlessTransactionIdCodec}, so
* applications can replace the codec without changing propagation mechanics.
*/
diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
index e8556057b75..1b184618eb3 100644
--- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
+++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java
@@ -22,12 +22,14 @@
import io.micronaut.data.connection.ConnectionOperations;
import io.micronaut.data.connection.SynchronousConnectionManager;
import io.micronaut.transaction.TransactionDefinition;
+import io.micronaut.transaction.annotation.OracleTransactional;
import io.micronaut.transaction.exceptions.CannotCreateTransactionException;
import io.micronaut.transaction.exceptions.TransactionSystemException;
import io.micronaut.transaction.impl.DefaultTransactionStatus;
import io.micronaut.transaction.jdbc.DataSourceTransactionManager;
import io.micronaut.transaction.jdbc.JdbcTransactionManagerCondition;
import io.micronaut.transaction.support.TransactionExecutionListener;
+import io.micronaut.transaction.support.TransactionUtil;
import jakarta.inject.Inject;
import oracle.jdbc.OracleConnection;
import org.jspecify.annotations.NonNull;
@@ -70,8 +72,8 @@ protected boolean supportsOracleSessionlessTransactions() {
@Override
protected void doBegin(DefaultTransactionStatus status) {
TransactionDefinition definition = status.getTransactionDefinition();
- TransactionDefinition.Propagation propagation = definition.getPropagationBehavior();
- if (propagation == TransactionDefinition.Propagation.SUSPEND) {
+ OracleTransactional.Sessionless sessionless = TransactionUtil.getOracleSessionlessMode(definition);
+ if (sessionless == OracleTransactional.Sessionless.SUSPEND) {
Optional state = findSessionlessTransactionState();
if (state.isEmpty()) {
throw new CannotCreateTransactionException("Oracle sessionless transaction propagation is not active");
@@ -84,7 +86,7 @@ protected void doBegin(DefaultTransactionStatus status) {
if (!state.get().setGtridIfAbsent(gtrid)) {
throw new CannotCreateTransactionException("Oracle sessionless transaction context already contains a transaction id");
}
- } else if (propagation == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ } else if (sessionless == OracleTransactional.Sessionless.REQUIRES_SUSPENDED) {
byte[] gtrid = findSessionlessTransactionState().flatMap(OracleSessionlessTransactionState::getGtrid)
.orElseThrow(() -> new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume"));
super.doBegin(status);
@@ -96,10 +98,10 @@ protected void doBegin(DefaultTransactionStatus status) {
@Override
protected void doCommit(DefaultTransactionStatus status) {
- TransactionDefinition.Propagation propagation = status.getTransactionDefinition().getPropagationBehavior();
- if (propagation == TransactionDefinition.Propagation.SUSPEND) {
+ OracleTransactional.Sessionless sessionless = TransactionUtil.getOracleSessionlessMode(status.getTransactionDefinition());
+ if (sessionless == OracleTransactional.Sessionless.SUSPEND) {
suspend(unwrapRequiredOracleForCompletion(status.getConnection()));
- } else if (propagation == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ } else if (sessionless == OracleTransactional.Sessionless.REQUIRES_SUSPENDED) {
commitResumedSessionlessTransaction(status);
} else {
super.doCommit(status);
@@ -108,9 +110,9 @@ protected void doCommit(DefaultTransactionStatus status) {
@Override
protected void doRollback(DefaultTransactionStatus status) {
- TransactionDefinition.Propagation propagation = status.getTransactionDefinition().getPropagationBehavior();
- if (propagation == TransactionDefinition.Propagation.SUSPEND
- || propagation == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ OracleTransactional.Sessionless sessionless = TransactionUtil.getOracleSessionlessMode(status.getTransactionDefinition());
+ if (sessionless == OracleTransactional.Sessionless.SUSPEND
+ || sessionless == OracleTransactional.Sessionless.REQUIRES_SUSPENDED) {
rollbackSessionlessTransaction(status);
} else {
super.doRollback(status);
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
index 0a25d3de165..e0cf62dea47 100644
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy
@@ -3,46 +3,48 @@ package io.micronaut.transaction.jdbc
import io.micronaut.data.connection.ConnectionOperations
import io.micronaut.data.connection.SynchronousConnectionManager
import io.micronaut.transaction.TransactionDefinition
+import io.micronaut.transaction.annotation.OracleTransactional
import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException
+import io.micronaut.transaction.support.DefaultTransactionDefinition
import spock.lang.Specification
import javax.sql.DataSource
class DataSourceTransactionManagerSpec extends Specification {
- def "plain JDBC manager rejects Oracle sessionless propagation before transactional work"(TransactionDefinition.Propagation propagation) {
+ def "plain JDBC manager rejects Oracle sessionless mode before transactional work"(OracleTransactional.Sessionless mode) {
given:
def txManager = newTxManager()
when:
- txManager.execute(definition(propagation), { status -> null })
+ txManager.execute(definition(mode), { status -> null })
then:
def e = thrown(TransactionSuspensionNotSupportedException)
- e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ e.message == "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support"
where:
- propagation << [
- TransactionDefinition.Propagation.SUSPEND,
- TransactionDefinition.Propagation.REQUIRES_SUSPENDED
+ mode << [
+ OracleTransactional.Sessionless.SUSPEND,
+ OracleTransactional.Sessionless.REQUIRES_SUSPENDED
]
}
- def "plain JDBC manager rejects Oracle sessionless propagation before programmatic transaction creation"(TransactionDefinition.Propagation propagation) {
+ def "plain JDBC manager rejects Oracle sessionless mode before programmatic transaction creation"(OracleTransactional.Sessionless mode) {
given:
def txManager = newTxManager()
when:
- txManager.getTransaction(definition(propagation))
+ txManager.getTransaction(definition(mode))
then:
def e = thrown(TransactionSuspensionNotSupportedException)
- e.message == "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ e.message == "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support"
where:
- propagation << [
- TransactionDefinition.Propagation.SUSPEND,
- TransactionDefinition.Propagation.REQUIRES_SUSPENDED
+ mode << [
+ OracleTransactional.Sessionless.SUSPEND,
+ OracleTransactional.Sessionless.REQUIRES_SUSPENDED
]
}
@@ -54,17 +56,10 @@ class DataSourceTransactionManagerSpec extends Specification {
)
}
- private static TransactionDefinition definition(TransactionDefinition.Propagation propagation) {
- new TransactionDefinition() {
- @Override
- String getName() {
- "test"
- }
-
- @Override
- TransactionDefinition.Propagation getPropagationBehavior() {
- propagation
- }
- }
+ private static TransactionDefinition definition(OracleTransactional.Sessionless mode) {
+ def definition = new DefaultTransactionDefinition()
+ definition.setName("test")
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, mode)
+ definition
}
}
diff --git a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
index 55b25dc1db1..e8733aac462 100644
--- a/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
+++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy
@@ -7,9 +7,11 @@ import io.micronaut.data.connection.ConnectionOperations
import io.micronaut.data.connection.SynchronousConnectionManager
import io.micronaut.data.connection.support.DefaultConnectionStatus
import io.micronaut.transaction.TransactionDefinition
+import io.micronaut.transaction.annotation.OracleTransactional
import io.micronaut.transaction.exceptions.CannotCreateTransactionException
import io.micronaut.transaction.exceptions.TransactionSystemException
import io.micronaut.transaction.impl.DefaultTransactionStatus
+import io.micronaut.transaction.support.DefaultTransactionDefinition
import io.micronaut.transaction.support.TransactionExecutionListener
import oracle.jdbc.OracleConnection
import spock.lang.Specification
@@ -41,7 +43,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
def manager = newTransactionManager([listener])
def connection = Mock(Connection)
def oracle = Mock(OracleConnection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND, Duration.ofSeconds(5))
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND, Duration.ofSeconds(5))
def connectionStatus = new DefaultConnectionStatus<>(connection, ConnectionDefinition.named("test"), true, null)
def status = DefaultTransactionStatus.newTx(connectionStatus, definition, manager)
def gtrid = [1, 2, 3] as byte[]
@@ -66,7 +68,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
def manager = newTransactionManager()
def connection = Mock(Connection)
def oracle = Mock(OracleConnection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
def state = new OracleSessionlessTransactionState()
@@ -85,7 +87,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
given:
def manager = newTransactionManager()
def connection = Mock(Connection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
def state = new OracleSessionlessTransactionState()
@@ -102,7 +104,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
given:
def manager = newTransactionManager()
def connection = Mock(Connection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
when:
@@ -117,7 +119,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
given:
def manager = newTransactionManager()
def connection = Mock(Connection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
def state = new OracleSessionlessTransactionState()
state.setGtrid([9, 9, 9] as byte[])
@@ -135,7 +137,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
def manager = newTransactionManager()
def connection = Mock(Connection)
def oracle = Mock(OracleConnection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
when:
@@ -152,7 +154,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
given:
def manager = newTransactionManager()
def connection = Mock(Connection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
def state = new OracleSessionlessTransactionState()
state.setGtrid([1, 2, 3] as byte[])
@@ -174,7 +176,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
given:
def manager = newTransactionManager()
def connection = Mock(Connection)
- def definition = definition(TransactionDefinition.Propagation.SUSPEND)
+ def definition = definition(OracleTransactional.Sessionless.SUSPEND)
def status = txStatus(connection, definition, manager)
def state = new OracleSessionlessTransactionState()
state.setGtrid([1, 2, 3] as byte[])
@@ -202,7 +204,7 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
given:
def manager = newTransactionManager()
def connection = Mock(Connection)
- def definition = definition(TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ def definition = definition(OracleTransactional.Sessionless.REQUIRES_SUSPENDED)
def status = txStatus(connection, definition, manager)
def state = new OracleSessionlessTransactionState()
state.setGtrid([4, 5, 6] as byte[])
@@ -241,23 +243,14 @@ class OracleSessionlessTransactionManagerSpec extends Specification {
DefaultTransactionStatus.newTx(connectionStatus, definition, manager)
}
- private static TransactionDefinition definition(TransactionDefinition.Propagation propagation,
+ private static TransactionDefinition definition(OracleTransactional.Sessionless mode,
Duration timeout = null) {
- new TransactionDefinition() {
- @Override
- String getName() {
- "test"
- }
-
- @Override
- TransactionDefinition.Propagation getPropagationBehavior() {
- propagation
- }
-
- @Override
- Optional getTimeout() {
- Optional.ofNullable(timeout)
- }
+ def definition = new DefaultTransactionDefinition()
+ definition.setName("test")
+ if (timeout != null) {
+ definition.setTimeout(timeout)
}
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, mode)
+ definition
}
}
diff --git a/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java b/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java
index 2b2bad70b83..d65a59278c2 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java
@@ -157,32 +157,7 @@ enum Propagation {
* when working on a JDBC 3.0 driver. Some JTA providers might support
* nested transactions as well.
*/
- NESTED,
- /**
- * Start an Oracle sessionless transaction and suspend it instead of committing when the
- * transactional boundary completes.
- * This propagation mode is intended for top-level Oracle sessionless transaction workflow
- * boundaries, such as an HTTP request that starts work and returns a suspended transaction
- * identifier to the caller. When an existing transaction is already active, Micronaut's
- * generic transaction orchestration treats this as participation in the existing transaction;
- * it does not create a nested sessionless transaction or suspend the existing transaction.
- *
NOTE: This mode requires a transaction manager that supports Oracle sessionless
- * transactions and an active Oracle sessionless transaction propagation context.
- */
- SUSPEND,
- /**
- * Resume an Oracle sessionless transaction from the current propagation context and complete
- * it when the transactional boundary completes.
- *
This propagation mode is intended for top-level Oracle sessionless transaction workflow
- * boundaries, such as a later HTTP request that supplies a previously suspended transaction
- * identifier. When an existing transaction is already active, Micronaut's generic transaction
- * orchestration treats this as participation in the existing transaction; it does not resume
- * the sessionless transaction from the propagation context.
- *
NOTE: This mode requires a transaction manager that supports Oracle sessionless
- * transactions and a transaction identifier in the active Oracle sessionless transaction
- * propagation context.
- */
- REQUIRES_SUSPENDED
+ NESTED
}
/**
diff --git a/data-tx/src/main/java/io/micronaut/transaction/annotation/OracleTransactional.java b/data-tx/src/main/java/io/micronaut/transaction/annotation/OracleTransactional.java
index d31f967f841..b81356242d8 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/annotation/OracleTransactional.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/annotation/OracleTransactional.java
@@ -26,7 +26,7 @@
import java.lang.annotation.Target;
/**
- * Oracle-specific transactional annotation that applies Oracle transaction priority.
+ * Oracle-specific transactional annotation that applies Oracle transaction options.
*
* @author radovanradic
* @since 5.0
@@ -43,6 +43,13 @@
*/
String ORACLE_PRIORITY = "oraclePriority";
+ /**
+ * Transaction definition property used to store Oracle sessionless transaction mode.
+ *
+ * @since 5.1.0
+ */
+ String ORACLE_SESSIONLESS_MODE = "oracleSessionlessMode";
+
/**
* Priority level for Oracle priority transactions.
*/
@@ -52,6 +59,30 @@ enum Priority {
HIGH
}
+ /**
+ * Sessionless transaction mode for Oracle JDBC transactions.
+ *
+ * @since 5.1.0
+ */
+ enum Sessionless {
+ /**
+ * Do not apply Oracle sessionless transaction semantics.
+ */
+ NONE,
+ /**
+ * Start an Oracle sessionless transaction and suspend it instead of committing when the
+ * transactional boundary completes.
+ *
The {@link OracleTransactional#timeout()} value is passed to Oracle when the
+ * sessionless transaction is started.
+ */
+ SUSPEND,
+ /**
+ * Resume an Oracle sessionless transaction from the current propagation context and complete
+ * it when the transactional boundary completes.
+ */
+ REQUIRES_SUSPENDED
+ }
+
/**
* Alias for {@link #transactionManager}.
*
@@ -88,6 +119,9 @@ enum Priority {
/**
* The timeout for this transaction.
+ *
When {@link #sessionless()} is {@link Sessionless#SUSPEND}, this timeout is passed to
+ * Oracle when the sessionless transaction is started. If no timeout is specified, the Oracle
+ * JDBC driver and database defaults apply.
*
* @return The timeout
*/
@@ -132,4 +166,12 @@ enum Priority {
* @return The priority level
*/
Priority priority() default Priority.HIGH;
+
+ /**
+ * The desired Oracle sessionless transaction mode.
+ *
+ * @return The sessionless transaction mode
+ * @since 5.1.0
+ */
+ Sessionless sessionless() default Sessionless.NONE;
}
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java
index dbf65337fdd..e15a5bb79cb 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractPropagatedStatusTransactionOperations.java
@@ -46,7 +46,7 @@ public abstract class AbstractPropagatedStatusTransactionOperations R doExecute(TransactionDefinition definition, TransactionCallback callback);
/**
- * @return Whether this transaction manager supports Oracle sessionless transaction propagation modes.
+ * @return Whether this transaction manager supports Oracle sessionless transaction modes.
*/
protected boolean supportsOracleSessionlessTransactions() {
return false;
@@ -68,7 +68,7 @@ public final Optional findTransactionStatusInternal() {
@Override
public final R execute(@NonNull TransactionDefinition definition,
@NonNull TransactionCallback callback) {
- TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
+ TransactionUtil.validateOracleSessionlessMode(definition, supportsOracleSessionlessTransactions());
return doExecute(definition, status -> status.propagate(() -> {
try {
return callback.call(status);
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java
index 92ad280d023..85861b0ee8a 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractReactorTransactionOperations.java
@@ -108,7 +108,7 @@ public final Flux withTransaction(@NonNull TransactionDefinition definiti
@NonNull TransactionalCallback handler) {
Objects.requireNonNull(definition, "Transaction definition cannot be null");
Objects.requireNonNull(handler, "Callback handler cannot be null");
- TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
+ TransactionUtil.validateOracleSessionlessMode(definition, supportsOracleSessionlessTransactions());
return Flux.deferContextual(contextView -> {
@Nullable ReactiveTransactionStatus transactionStatus = getTransactionStatus(contextView);
@@ -117,7 +117,7 @@ public final Flux withTransaction(@NonNull TransactionDefinition definiti
}
/**
- * @return Whether this transaction manager supports Oracle sessionless transaction propagation modes.
+ * @return Whether this transaction manager supports Oracle sessionless transaction modes.
*/
protected boolean supportsOracleSessionlessTransactions() {
return false;
@@ -164,7 +164,7 @@ private Flux openNewConnectionAndTx(TransactionDefinition definition, Tra
public Mono withTransactionMono(TransactionDefinition definition, Function, Mono> handler) {
Objects.requireNonNull(definition, "Transaction definition cannot be null");
Objects.requireNonNull(handler, "Callback handler cannot be null");
- TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
+ TransactionUtil.validateOracleSessionlessMode(definition, supportsOracleSessionlessTransactions());
return Mono.deferContextual(contextView -> {
ReactiveTransactionStatus transactionStatus = getTransactionStatus(contextView);
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java
index 4ba4c912ff4..71f03d72cb3 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/AbstractTransactionOperations.java
@@ -160,7 +160,7 @@ public void afterCompletion(Status status) {
@NonNull
@Override
public T getTransaction(TransactionDefinition definition) throws TransactionException {
- TransactionUtil.validateOracleSessionlessPropagation(definition, supportsOracleSessionlessTransactions());
+ TransactionUtil.validateOracleSessionlessMode(definition, supportsOracleSessionlessTransactions());
boolean debugEnabled = logger.isDebugEnabled();
if (debugEnabled) {
logger.debug("Getting transaction for definition [{}]", definition);
@@ -463,7 +463,7 @@ private T createAndBeginTransaction(@NonNull TransactionDefinition definition, @
private T createTransaction(@NonNull TransactionDefinition definition, @NonNull ConnectionStatus connectionStatus) {
return switch (definition.getPropagationBehavior()) {
- case REQUIRED, REQUIRES_NEW, NESTED, SUSPEND, REQUIRES_SUSPENDED ->
+ case REQUIRED, REQUIRES_NEW, NESTED ->
createNewTransactionStatus(connectionStatus, definition); // Nested propagation applies only for the existing TX
case SUPPORTS, NOT_SUPPORTED, NEVER ->
createNoTxTransactionStatus(connectionStatus, definition);
diff --git a/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java b/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java
index c89102cced3..8eb129ab026 100644
--- a/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java
+++ b/data-tx/src/main/java/io/micronaut/transaction/support/TransactionUtil.java
@@ -24,6 +24,7 @@
import io.micronaut.transaction.annotation.Transactional;
import io.micronaut.transaction.exceptions.CannotCreateTransactionException;
import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
+import io.micronaut.transaction.exceptions.TransactionUsageException;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@@ -77,6 +78,11 @@ public static TransactionDefinition getTransactionDefinition(String name, Annota
OracleTransactional.Priority priority = oracleTransactional.enumValue("priority", OracleTransactional.Priority.class)
.orElse(OracleTransactional.Priority.HIGH);
definition.putProperty(OracleTransactional.ORACLE_PRIORITY, priority);
+ OracleTransactional.Sessionless sessionless = oracleTransactional.enumValue("sessionless", OracleTransactional.Sessionless.class)
+ .orElse(OracleTransactional.Sessionless.NONE);
+ if (sessionless != OracleTransactional.Sessionless.NONE) {
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, sessionless);
+ }
}
return definition;
@@ -103,20 +109,43 @@ public static TransactionDefinition getTransactionDefinition(String name, Annota
}
/**
- * Validates whether a transaction definition that uses Oracle sessionless propagation is supported.
+ * Resolves Oracle sessionless transaction mode from a transaction definition.
*
* @param definition The transaction definition
- * @param supported Whether the transaction manager supports Oracle sessionless transaction propagation
+ * @return The Oracle sessionless transaction mode, or {@code null} if none is present
*/
- public static void validateOracleSessionlessPropagation(TransactionDefinition definition, boolean supported) {
- TransactionDefinition.Propagation propagation = definition.getPropagationBehavior();
- if (propagation != TransactionDefinition.Propagation.SUSPEND
- && propagation != TransactionDefinition.Propagation.REQUIRES_SUSPENDED) {
+ public static OracleTransactional.@Nullable Sessionless getOracleSessionlessMode(TransactionDefinition definition) {
+ Object value = definition.getProperties().get(OracleTransactional.ORACLE_SESSIONLESS_MODE);
+ OracleTransactional.Sessionless mode = null;
+ if (value instanceof OracleTransactional.Sessionless sessionless) {
+ mode = sessionless;
+ } else if (value instanceof String sessionless) {
+ mode = parseOracleSessionlessMode(sessionless);
+ } else if (value instanceof Enum> sessionless) {
+ mode = parseOracleSessionlessMode(sessionless.name());
+ }
+ return mode == OracleTransactional.Sessionless.NONE ? null : mode;
+ }
+
+ /**
+ * Validates whether a transaction definition that uses Oracle sessionless transaction mode is supported.
+ *
+ * @param definition The transaction definition
+ * @param supported Whether the transaction manager supports Oracle sessionless transactions
+ */
+ public static void validateOracleSessionlessMode(TransactionDefinition definition, boolean supported) {
+ OracleTransactional.Sessionless mode = getOracleSessionlessMode(definition);
+ if (mode == null) {
return;
}
if (!supported) {
throw new TransactionSuspensionNotSupportedException(
- "Propagation '" + propagation + "' requires Oracle sessionless transaction support"
+ "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support"
+ );
+ }
+ if (definition.getPropagationBehavior() != TransactionDefinition.Propagation.REQUIRED) {
+ throw new TransactionUsageException(
+ "Oracle sessionless transaction mode '" + mode + "' requires propagation 'REQUIRED'"
);
}
}
@@ -129,4 +158,12 @@ private static OracleTransactional.Priority parseOraclePriority(String priority)
}
}
+ private static OracleTransactional.Sessionless parseOracleSessionlessMode(String mode) {
+ try {
+ return OracleTransactional.Sessionless.valueOf(mode.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new CannotCreateTransactionException("Invalid Oracle sessionless transaction mode: " + mode, e);
+ }
+ }
+
}
diff --git a/data-tx/src/test/java/io/micronaut/transaction/TransactionUtilSpec.java b/data-tx/src/test/java/io/micronaut/transaction/TransactionUtilSpec.java
index 76ca8110998..58400eb7da4 100644
--- a/data-tx/src/test/java/io/micronaut/transaction/TransactionUtilSpec.java
+++ b/data-tx/src/test/java/io/micronaut/transaction/TransactionUtilSpec.java
@@ -21,12 +21,15 @@
import io.micronaut.transaction.annotation.OracleTransactional;
import io.micronaut.transaction.annotation.Transactional;
import io.micronaut.transaction.exceptions.CannotCreateTransactionException;
+import io.micronaut.transaction.exceptions.TransactionUsageException;
import io.micronaut.transaction.support.DefaultTransactionDefinition;
import io.micronaut.transaction.support.TransactionUtil;
import jakarta.inject.Singleton;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.time.Duration;
+
public class TransactionUtilSpec {
@Test
@@ -40,10 +43,19 @@ void testOracleTransactionalAnnotationWiring() {
OracleTransactional.Priority.MEDIUM,
priorityDefinition.getProperties().get(OracleTransactional.ORACLE_PRIORITY)
);
+ Assertions.assertEquals(
+ OracleTransactional.Sessionless.SUSPEND,
+ priorityDefinition.getProperties().get(OracleTransactional.ORACLE_SESSIONLESS_MODE)
+ );
+ Assertions.assertEquals(
+ Duration.ofSeconds(3600),
+ priorityDefinition.getTimeout().orElseThrow()
+ );
ExecutableMethod methodWithoutPriority = beanDefinition.getRequiredMethod("methodWithoutPriority");
TransactionDefinition defaultDefinition = TransactionUtil.getTransactionDefinition("test", methodWithoutPriority);
Assertions.assertFalse(defaultDefinition.getProperties().containsKey(OracleTransactional.ORACLE_PRIORITY));
+ Assertions.assertFalse(defaultDefinition.getProperties().containsKey(OracleTransactional.ORACLE_SESSIONLESS_MODE));
}
}
@@ -70,10 +82,49 @@ void testInvalidOraclePriorityFailsWithCannotCreateTransactionException() {
Assertions.assertEquals("Invalid Oracle transaction priority: invalid", exception.getMessage());
}
+ @Test
+ void testOracleSessionlessModeParsing() {
+ DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, " requires_suspended ");
+
+ Assertions.assertEquals(
+ OracleTransactional.Sessionless.REQUIRES_SUSPENDED,
+ TransactionUtil.getOracleSessionlessMode(definition)
+ );
+ }
+
+ @Test
+ void testInvalidOracleSessionlessModeFailsWithCannotCreateTransactionException() {
+ DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, "invalid");
+
+ CannotCreateTransactionException exception = Assertions.assertThrows(
+ CannotCreateTransactionException.class,
+ () -> TransactionUtil.getOracleSessionlessMode(definition)
+ );
+ Assertions.assertEquals("Invalid Oracle sessionless transaction mode: invalid", exception.getMessage());
+ }
+
+ @Test
+ void testOracleSessionlessModeRequiresRequiredPropagation() {
+ DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
+ definition.setPropagationBehavior(TransactionDefinition.Propagation.SUPPORTS);
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, OracleTransactional.Sessionless.SUSPEND);
+
+ TransactionUsageException exception = Assertions.assertThrows(
+ TransactionUsageException.class,
+ () -> TransactionUtil.validateOracleSessionlessMode(definition, true)
+ );
+ Assertions.assertEquals(
+ "Oracle sessionless transaction mode 'SUSPEND' requires propagation 'REQUIRED'",
+ exception.getMessage()
+ );
+ }
+
@Singleton
static class AnnotatedService {
- @OracleTransactional(priority = OracleTransactional.Priority.MEDIUM)
+ @OracleTransactional(priority = OracleTransactional.Priority.MEDIUM, sessionless = OracleTransactional.Sessionless.SUSPEND, timeout = 3600)
void methodWithPriority() {
// Does nothing, just to test TransactionUtil with OracleTransactional
}
diff --git a/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java b/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java
index 10314e098fd..130f11efbc5 100644
--- a/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java
+++ b/data-tx/src/test/java/io/micronaut/transaction/TxSpec.java
@@ -16,7 +16,9 @@
package io.micronaut.transaction;
import io.micronaut.context.ApplicationContext;
+import io.micronaut.transaction.annotation.OracleTransactional;
import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
+import io.micronaut.transaction.support.DefaultTransactionDefinition;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
@@ -246,22 +248,28 @@ public void testReactiveTxRejectsOracleSessionlessPropagation() {
try (ApplicationContext applicationContext = ApplicationContext.run()) {
ReactiveTxManager txManager = applicationContext.getBean(ReactiveTxManager.class);
- assertUnsupportedReactiveOracleSessionlessPropagation(txManager, TransactionDefinition.Propagation.SUSPEND);
- assertUnsupportedReactiveOracleSessionlessPropagation(txManager, TransactionDefinition.Propagation.REQUIRES_SUSPENDED);
+ assertUnsupportedReactiveOracleSessionlessMode(txManager, OracleTransactional.Sessionless.SUSPEND);
+ assertUnsupportedReactiveOracleSessionlessMode(txManager, OracleTransactional.Sessionless.REQUIRES_SUSPENDED);
}
}
- private static void assertUnsupportedReactiveOracleSessionlessPropagation(ReactiveTxManager txManager,
- TransactionDefinition.Propagation propagation) {
+ private static void assertUnsupportedReactiveOracleSessionlessMode(ReactiveTxManager txManager,
+ OracleTransactional.Sessionless mode) {
TransactionSuspensionNotSupportedException exception = Assertions.assertThrows(
TransactionSuspensionNotSupportedException.class,
- () -> txManager.withTransactionMono(TransactionDefinition.of(propagation), status -> Mono.just("ignored"))
+ () -> txManager.withTransactionMono(oracleSessionlessDefinition(mode), status -> Mono.just("ignored"))
);
Assertions.assertEquals(
- "Propagation '" + propagation + "' requires Oracle sessionless transaction support",
+ "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support",
exception.getMessage()
);
}
+ private static TransactionDefinition oracleSessionlessDefinition(OracleTransactional.Sessionless mode) {
+ DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, mode);
+ return definition;
+ }
+
// end::test[]
}
diff --git a/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java b/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java
index 3a1ab92cd2c..c6bfb8d08b4 100644
--- a/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java
+++ b/data-tx/src/test/java/io/micronaut/transaction/support/DoRollbackOnCommitExceptionTest.java
@@ -21,6 +21,7 @@
import io.micronaut.data.connection.ConnectionStatus;
import io.micronaut.data.connection.ConnectionSynchronization;
import io.micronaut.transaction.TransactionDefinition;
+import io.micronaut.transaction.annotation.OracleTransactional;
import io.micronaut.transaction.exceptions.TransactionSuspensionNotSupportedException;
import io.micronaut.transaction.exceptions.TransactionSystemException;
import io.micronaut.transaction.exceptions.UnexpectedRollbackException;
@@ -138,17 +139,17 @@ void existingNonNestedBeforeCommitFailureDispatchesToSetRollbackOnly() {
}
@Test
- void unsupportedOracleSessionlessPropagationIsRejectedBeforeTransactionalWork() {
- assertUnsupportedOracleSessionlessPropagation(TransactionDefinition.Propagation.SUSPEND);
- assertUnsupportedOracleSessionlessPropagation(TransactionDefinition.Propagation.REQUIRES_SUSPENDED);
+ void unsupportedOracleSessionlessModeIsRejectedBeforeTransactionalWork() {
+ assertUnsupportedOracleSessionlessMode(OracleTransactional.Sessionless.SUSPEND);
+ assertUnsupportedOracleSessionlessMode(OracleTransactional.Sessionless.REQUIRES_SUSPENDED);
assertEquals(List.of(), txManager.calls);
}
@Test
- void unsupportedOracleSessionlessPropagationIsRejectedBeforeProgrammaticTransactionCreation() {
- assertUnsupportedProgrammaticOracleSessionlessPropagation(TransactionDefinition.Propagation.SUSPEND);
- assertUnsupportedProgrammaticOracleSessionlessPropagation(TransactionDefinition.Propagation.REQUIRES_SUSPENDED);
+ void unsupportedOracleSessionlessModeIsRejectedBeforeProgrammaticTransactionCreation() {
+ assertUnsupportedProgrammaticOracleSessionlessMode(OracleTransactional.Sessionless.SUSPEND);
+ assertUnsupportedProgrammaticOracleSessionlessMode(OracleTransactional.Sessionless.REQUIRES_SUSPENDED);
assertEquals(List.of(), txManager.calls);
}
@@ -164,28 +165,34 @@ public void beforeCommit(boolean readOnly) {
);
}
- private void assertUnsupportedOracleSessionlessPropagation(TransactionDefinition.Propagation propagation) {
+ private void assertUnsupportedOracleSessionlessMode(OracleTransactional.Sessionless mode) {
TransactionSuspensionNotSupportedException exception = assertThrows(
TransactionSuspensionNotSupportedException.class,
- () -> txManager.execute(TransactionDefinition.of(propagation), status -> null)
+ () -> txManager.execute(oracleSessionlessDefinition(mode), status -> null)
);
assertEquals(
- "Propagation '" + propagation + "' requires Oracle sessionless transaction support",
+ "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support",
exception.getMessage()
);
}
- private void assertUnsupportedProgrammaticOracleSessionlessPropagation(TransactionDefinition.Propagation propagation) {
+ private void assertUnsupportedProgrammaticOracleSessionlessMode(OracleTransactional.Sessionless mode) {
TransactionSuspensionNotSupportedException exception = assertThrows(
TransactionSuspensionNotSupportedException.class,
- () -> txManager.getTransaction(TransactionDefinition.of(propagation))
+ () -> txManager.getTransaction(oracleSessionlessDefinition(mode))
);
assertEquals(
- "Propagation '" + propagation + "' requires Oracle sessionless transaction support",
+ "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support",
exception.getMessage()
);
}
+ private static TransactionDefinition oracleSessionlessDefinition(OracleTransactional.Sessionless mode) {
+ DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
+ definition.putProperty(OracleTransactional.ORACLE_SESSIONLESS_MODE, mode);
+ return definition;
+ }
+
/**
* Transaction manager that records which doXxx methods are called.
*/
diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java
index 3a7abafc5f4..b115bacc09a 100644
--- a/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java
+++ b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java
@@ -1,7 +1,6 @@
package example;
-import io.micronaut.transaction.TransactionDefinition;
-import io.micronaut.transaction.annotation.Transactional;
+import io.micronaut.transaction.annotation.OracleTransactional;
import jakarta.inject.Singleton;
@Singleton
@@ -13,12 +12,12 @@ public BookingService(SeatRepository seatRepository) {
this.seatRepository = seatRepository;
}
- @Transactional(propagation = TransactionDefinition.Propagation.SUSPEND)
+ @OracleTransactional(sessionless = OracleTransactional.Sessionless.SUSPEND, timeout = 60)
public Long holdSeat(Seat seat) {
return seatRepository.save(seat).getId();
}
- @Transactional(propagation = TransactionDefinition.Propagation.REQUIRES_SUSPENDED)
+ @OracleTransactional(sessionless = OracleTransactional.Sessionless.REQUIRES_SUSPENDED)
public void ticketSeat(Long id) {
Seat seat = seatRepository.findById(id).orElseThrow(() -> new RuntimeException("Seat not found"));
seat.setStatus("TICKETED");
diff --git a/src/main/docs/guide/shared/transactions.adoc b/src/main/docs/guide/shared/transactions.adoc
index 7b1f1fac514..e1caea526af 100644
--- a/src/main/docs/guide/shared/transactions.adoc
+++ b/src/main/docs/guide/shared/transactions.adoc
@@ -5,24 +5,3 @@ Micronaut Data maps the declared transaction annotation to the correct underlyin
NOTE: Starting Micronaut Data 4 repositories are no longer executed using a new transaction and will create a new connection if none is present.
TIP: If you prefer Spring-managed transactions for Hibernate or JDBC you can add the `micronaut-data-spring` dependency and Spring-managed transactions will be used instead. See the section on <> for more information.
-
-=== Oracle Transaction Priority
-
-For Oracle JDBC and R2DBC transactions, Micronaut Data supports Oracle transaction priority with ann:transaction.annotation.OracleTransactional[].
-The annotation is a transactional stereotype and can be used instead of ann:transaction.annotation.Transactional[] when Oracle transaction priority should be applied:
-
-[source,java]
-----
-import io.micronaut.transaction.annotation.OracleTransactional;
-
-@OracleTransactional(priority = OracleTransactional.Priority.MEDIUM)
-void updateInventory() {
- // ...
-}
-----
-
-When the transaction uses an Oracle connection, Micronaut Data sets the session `txn_priority` for the duration of the transaction and resets it afterwards.
-Non-Oracle databases ignore this property.
-
-NOTE: Oracle transaction priority requires database support and appropriate Oracle priority transaction configuration.
-Older Oracle versions that reject `txn_priority` continue without applying priority.
diff --git a/src/main/docs/guide/shared/transactions/oracleTransactions.adoc b/src/main/docs/guide/shared/transactions/oracleTransactions.adoc
new file mode 100644
index 00000000000..96f63513e15
--- /dev/null
+++ b/src/main/docs/guide/shared/transactions/oracleTransactions.adoc
@@ -0,0 +1,6 @@
+Micronaut Data provides Oracle-specific transaction options through ann:transaction.annotation.OracleTransactional[].
+The annotation is a transactional stereotype that can be used instead of ann:transaction.annotation.Transactional[] when an
+Oracle transaction feature should be applied.
+
+Oracle transaction priority is available for Oracle JDBC and R2DBC transactions.
+Oracle sessionless transactions are available for Oracle JDBC transactions.
diff --git a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc b/src/main/docs/guide/shared/transactions/oracleTransactions/sessionlessTransactions.adoc
similarity index 87%
rename from src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
rename to src/main/docs/guide/shared/transactions/oracleTransactions/sessionlessTransactions.adoc
index 1abe959e4d9..2c2c58db94f 100644
--- a/src/main/docs/guide/dbc/jdbc/oracleSessionlessTransactions.adoc
+++ b/src/main/docs/guide/shared/transactions/oracleTransactions/sessionlessTransactions.adoc
@@ -1,12 +1,14 @@
Oracle sessionless transactions allow a JDBC transaction to be suspended and later resumed, potentially from a different
-request. Micronaut Data supports this for Oracle JDBC by adding two transaction propagation modes:
+request. Micronaut Data supports this for Oracle JDBC with the ann:transaction.annotation.OracleTransactional[] `sessionless`
+attribute:
-* api:transaction.TransactionDefinition.Propagation#SUSPEND[]
-* api:transaction.TransactionDefinition.Propagation#REQUIRES_SUSPENDED[]
+* api:transaction.annotation.OracleTransactional.Sessionless#SUSPEND[]
+* api:transaction.annotation.OracleTransactional.Sessionless#REQUIRES_SUSPENDED[]
The feature is intended for top-level workflow boundaries. Do not use `SUSPEND` or `REQUIRES_SUSPENDED` inside another
active transaction. If a parent transaction is already active, Micronaut's generic transaction orchestration treats
the inner method as participating in that existing transaction, so the Oracle sessionless begin or resume lifecycle is not started.
+The sessionless modes require the default `REQUIRED` propagation and cannot be combined with other propagation values.
Oracle sessionless transaction support is available only when Oracle JDBC classes are present.
For `SUSPEND` and `REQUIRES_SUSPENDED`, the underlying JDBC connection must unwrap to an `oracle.jdbc.OracleConnection`.
@@ -15,7 +17,7 @@ The following service starts a sessionless transaction, suspends it, and later r
snippet::example.BookingService[project="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"]
-For a method declared with `SUSPEND`, the Oracle transaction manager:
+For a method annotated with `@OracleTransactional(sessionless = SUSPEND)`, the Oracle transaction manager:
* requires an active Oracle sessionless propagation state
* begins the JDBC transaction normally
@@ -23,10 +25,10 @@ For a method declared with `SUSPEND`, the Oracle transaction manager:
* stores the returned Oracle global transaction identifier, or GTRID, in the propagation state
* suspends the Oracle transaction instead of committing it when the method completes
-If the method declares a transaction timeout, that timeout is passed to Oracle when the sessionless transaction is started.
+If the method declares `@OracleTransactional(timeout = ...)`, that timeout is passed to Oracle when the sessionless transaction is started.
If no timeout is declared, the Oracle driver and database defaults apply.
-For a method declared with `REQUIRES_SUSPENDED`, the Oracle transaction manager:
+For a method annotated with `@OracleTransactional(sessionless = REQUIRES_SUSPENDED)`, the Oracle transaction manager:
* reads the GTRID from the active propagation state
* begins the JDBC transaction normally
diff --git a/src/main/docs/guide/shared/transactions/oracleTransactions/transactionPriority.adoc b/src/main/docs/guide/shared/transactions/oracleTransactions/transactionPriority.adoc
new file mode 100644
index 00000000000..24f2086d2c7
--- /dev/null
+++ b/src/main/docs/guide/shared/transactions/oracleTransactions/transactionPriority.adoc
@@ -0,0 +1,18 @@
+For Oracle JDBC and R2DBC transactions, Micronaut Data supports Oracle transaction priority with ann:transaction.annotation.OracleTransactional[].
+Set the `priority` attribute to request the desired Oracle transaction priority:
+
+[source,java]
+----
+import io.micronaut.transaction.annotation.OracleTransactional;
+
+@OracleTransactional(priority = OracleTransactional.Priority.MEDIUM)
+void updateInventory() {
+ // ...
+}
+----
+
+When the transaction uses an Oracle connection, Micronaut Data sets the session `txn_priority` for the duration of the transaction and resets it afterwards.
+Non-Oracle databases ignore this property.
+
+NOTE: Oracle transaction priority requires database support and appropriate Oracle priority transaction configuration.
+Older Oracle versions that reject `txn_priority` continue without applying priority.
diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml
index e9cb843b918..94ad7a61e81 100644
--- a/src/main/docs/guide/toc.yml
+++ b/src/main/docs/guide/toc.yml
@@ -44,6 +44,10 @@ shared:
title: Transactions
programmaticTransactions: Programmatic Transactions
transactionalEvents: Transactional Events
+ oracleTransactions:
+ title: Oracle Transactions
+ transactionPriority: Oracle Transaction Priority
+ sessionlessTransactions: Oracle Sessionless Transactions
kotlinCriteria: Kotlin Criteria API extensions
multitenancy:
title: Multi-tenancy
@@ -69,7 +73,6 @@ dbc:
title: JDBC
jdbcQuickStart: Quick Start
jdbcConfiguration: Configuration
- oracleSessionlessTransactions: Oracle Sessionless Transactions
r2dbc:
title: R2DBC
r2dbcQuickStart: Quick Start