From 3e4d7864488c76c7934dd03b731b2724814ada23 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 5 Mar 2026 15:40:37 +0100 Subject: [PATCH 01/32] Prepared test for sessionless transaction implementation --- .../transaction/TransactionDefinition.java | 4 +- .../AbstractTransactionOperations.java | 2 +- .../build.gradle | 24 +++++++ .../src/main/java/example/BookingService.java | 27 ++++++++ .../src/main/java/example/Seat.java | 65 +++++++++++++++++++ .../src/main/java/example/SeatRepository.java | 9 +++ .../src/main/resources/application.yml | 11 ++++ .../src/main/resources/logback.xml | 13 ++++ .../test/java/example/BookingServiceTest.java | 38 +++++++++++ settings.gradle | 2 + 10 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingService.java create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Seat.java create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/SeatRepository.java create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/logback.xml create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java 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 0c28061f63c..1daa64a642c 100644 --- a/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java +++ b/data-tx/src/main/java/io/micronaut/transaction/TransactionDefinition.java @@ -156,7 +156,9 @@ enum Propagation { * when working on a JDBC 3.0 driver. Some JTA providers might support * nested transactions as well. */ - NESTED + NESTED, + SUSPEND, + REQUIRES_SUSPENDED } /** 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 8e860c5b529..b4295abf130 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 @@ -453,7 +453,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 -> + case REQUIRED, REQUIRES_NEW, NESTED, SUSPEND, REQUIRES_SUSPENDED -> createNewTransactionStatus(connectionStatus, definition); // Nested propagation applies only for the existing TX case SUPPORTS, NOT_SUPPORTED, NEVER -> createNoTxTransactionStatus(connectionStatus, definition); diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle b/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle new file mode 100644 index 00000000000..eaf2cd1d06c --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle @@ -0,0 +1,24 @@ +plugins { + id "io.micronaut.build.internal.data-native-example" +} + +micronaut { + version libs.versions.micronaut.platform.get() + runtime "netty" + testRuntime "junit5" + testResources { + enabled = false + } +} + +dependencies { + annotationProcessor projects.micronautDataProcessor + annotationProcessor mnValidation.micronaut.validation + + implementation projects.micronautDataJdbc + implementation mnValidation.micronaut.validation + + runtimeOnly mnSql.micronaut.jdbc.tomcat + runtimeOnly mnSql.ojdbc11 + runtimeOnly mnLogging.logback.classic +} diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingService.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingService.java new file mode 100644 index 00000000000..3a7abafc5f4 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingService.java @@ -0,0 +1,27 @@ +package example; + +import io.micronaut.transaction.TransactionDefinition; +import io.micronaut.transaction.annotation.Transactional; +import jakarta.inject.Singleton; + +@Singleton +public class BookingService { + + private final SeatRepository seatRepository; + + public BookingService(SeatRepository seatRepository) { + this.seatRepository = seatRepository; + } + + @Transactional(propagation = TransactionDefinition.Propagation.SUSPEND) + public Long holdSeat(Seat seat) { + return seatRepository.save(seat).getId(); + } + + @Transactional(propagation = TransactionDefinition.Propagation.REQUIRES_SUSPENDED) + public void ticketSeat(Long id) { + Seat seat = seatRepository.findById(id).orElseThrow(() -> new RuntimeException("Seat not found")); + seat.setStatus("TICKETED"); + seatRepository.update(seat); + } +} diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Seat.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Seat.java new file mode 100644 index 00000000000..9eb8a22aec1 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Seat.java @@ -0,0 +1,65 @@ +package example; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +@MappedEntity +public class Seat { + + @Id + @GeneratedValue(GeneratedValue.Type.SEQUENCE) + private Long id; + private String flightId; + private String seatId; + private String customerId; + @Nullable + private String status; + + public Seat(String flightId, String seatId, String customerId) { + this.flightId = flightId; + this.seatId = seatId; + this.customerId = customerId; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFlightId() { + return flightId; + } + + public void setFlightId(String flightId) { + this.flightId = flightId; + } + + public String getSeatId() { + return seatId; + } + + public void setSeatId(String seatId) { + this.seatId = seatId; + } + + public String getCustomerId() { + return customerId; + } + + public void setCustomerId(String customerId) { + this.customerId = customerId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/SeatRepository.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/SeatRepository.java new file mode 100644 index 00000000000..e93bfaf26af --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/SeatRepository.java @@ -0,0 +1,9 @@ +package example; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface SeatRepository extends CrudRepository { +} 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 new file mode 100644 index 00000000000..bca8af4a5d4 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/application.yml @@ -0,0 +1,11 @@ +micronaut: + application: + name: jdbc-sessionless-transaction-booking-java + +datasources: + default: + driverClassName: oracle.jdbc.OracleDriver + dialect: ORACLE + url: jdbc:oracle:thin:@localhost:1521/freepdb1 + username: test + password: test diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/logback.xml b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/logback.xml new file mode 100644 index 00000000000..d8e68cf1386 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + + + 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 new file mode 100644 index 00000000000..f29c1319a02 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingServiceTest.java @@ -0,0 +1,38 @@ +package example; + +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(transactional = false) +public class BookingServiceTest { + + @Inject + BookingService bookingService; + + @Inject + SeatRepository seatRepository; + + @Test + void testTransactionResumed() { + Seat seat = new Seat("JU501", "2c", "msid"); + Long seatId = bookingService.holdSeat(seat); + + List seats = seatRepository.findAll(); + assertTrue(CollectionUtils.isEmpty(seats)); + + bookingService.ticketSeat(seatId); + + seats = seatRepository.findAll(); + assertFalse(CollectionUtils.isEmpty(seats)); + assertEquals(1, seats.size()); + assertEquals("TICKETED", seats.getFirst().getSeatId()); + } +} diff --git a/settings.gradle b/settings.gradle index 3dbee44ed4d..278112ca9df 100644 --- a/settings.gradle +++ b/settings.gradle @@ -101,6 +101,8 @@ 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-and-r2dbc-example-java' include 'doc-examples:r2dbc-example-java' From 09e94bae28144b3813a64f464ffef3e0a188c729 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 19 May 2026 16:21:44 +0200 Subject: [PATCH 02/32] Added OracleDataSourceTransactionManager --- .../jdbc/DataSourceTransactionManager.java | 2 +- .../OracleDataSourceTransactionManager.java | 179 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java 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 0797dfa8aaa..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 @@ -58,7 +58,7 @@ @EachBean(DataSource.class) @Requires(condition = JdbcTransactionManagerCondition.class) @TypeHint(DataSourceTransactionManager.class) -public final class DataSourceTransactionManager extends AbstractDefaultTransactionOperations { +public class DataSourceTransactionManager extends AbstractDefaultTransactionOperations { // Error with this message is thrown from SQL server when operation is not supported (like Connection.releaseSavepoint) private static final String OPERATION_NOT_SUPPORTED = "This operation is not supported."; diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java new file mode 100644 index 00000000000..b80c7eeb42f --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java @@ -0,0 +1,179 @@ +package io.micronaut.transaction.jdbc; + +import io.micronaut.context.annotation.EachBean; +import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.propagation.PropagatedContext; +import io.micronaut.core.propagation.PropagatedContextElement; +import io.micronaut.data.connection.ConnectionOperations; +import io.micronaut.data.connection.ConnectionSynchronization; +import io.micronaut.data.connection.SynchronousConnectionManager; +import io.micronaut.data.connection.support.JdbcConnectionUtils; +import io.micronaut.transaction.TransactionDefinition; +import io.micronaut.transaction.exceptions.CannotCreateTransactionException; +import io.micronaut.transaction.exceptions.TransactionSystemException; +import io.micronaut.transaction.impl.DefaultTransactionStatus; +import oracle.jdbc.OracleConnection; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Internal +@EachBean(DataSource.class) +@Requires(classes = OracleConnection.class) +public class OracleDataSourceTransactionManager extends DataSourceTransactionManager { + + public OracleDataSourceTransactionManager(@NonNull DataSource dataSource, + @Parameter ConnectionOperations connectionOperations, + @Parameter @Nullable SynchronousConnectionManager synchronousConnectionManager) { + super(dataSource, connectionOperations, synchronousConnectionManager); + } + + @Override + protected void doBegin(DefaultTransactionStatus status) { + TransactionDefinition definition = status.getTransactionDefinition(); + Connection connection = status.getConnection(); + + List onComplete = new ArrayList<>(5); + + definition.isReadOnly() + .ifPresent(readOnly -> JdbcConnectionUtils.applyReadOnly(logger, connection, readOnly, onComplete)); + definition.getIsolationLevel() + .ifPresent(isolation -> JdbcConnectionUtils.applyTransactionIsolation(logger, connection, isolation.getCode(), onComplete)); + JdbcConnectionUtils.applyAutoCommit(logger, connection, false, onComplete); + + OracleConnection oracle = unwrapOracle(connection).orElse(null); + if (oracle != null) { + if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { + Integer timeout = null; + if (definition.getTimeout().isPresent()) { + timeout = Math.toIntExact(definition.getTimeout().get().toSeconds()); + } + byte[] gtrid = startTransaction(oracle, timeout).orElseGet(() -> getTransactionId(oracle).orElse(null)); + if (gtrid == null) { + throw new CannotCreateTransactionException("Could not start Oracle sessionless transaction"); + } + putOracleElement(new OracleSessionlessElement(gtrid)); + } else if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { + Optional element = findOracleElement(); + if (element.isEmpty()) { + throw new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume"); + } + resume(oracle, element.get().gtrid()); + } + } + + + if (!onComplete.isEmpty()) { + Collections.reverse(onComplete); + status.getConnectionStatus().registerSynchronization(new ConnectionSynchronization() { + @Override + public void executionComplete() { + for (Runnable runnable : onComplete) { + runnable.run(); + } + } + }); + } + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + Connection connection = status.getConnection(); + TransactionDefinition definition = status.getTransactionDefinition(); + + OracleConnection oracle = unwrapOracle(connection).orElse(null); + if (oracle != null && definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { + suspend(oracle); + return; + } + + super.doCommit(status); + if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { + findOracleElement().ifPresent(OracleDataSourceTransactionManager::removeOracleElement); + } + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + super.doRollback(status); + if (status.getTransactionDefinition().getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { + findOracleElement().ifPresent(OracleDataSourceTransactionManager::removeOracleElement); + } + } + + private Optional unwrapOracle(Connection connection) { + if (connection == null) { + return Optional.empty(); + } + try { + OracleConnection oracleConnection = connection.unwrap(OracleConnection.class); + return Optional.ofNullable(oracleConnection); + } catch (Exception e) { + logger.error("Failed to unwrap Oracle connection", e); + return Optional.empty(); + } + } + + private Optional startTransaction(OracleConnection oracle, Integer timeout) { + try { + return Optional.ofNullable(timeout == null ? oracle.startTransaction() : oracle.startTransaction(timeout)); + } catch (Exception e) { + logger.error("Failed to start Oracle transaction", e); + return Optional.empty(); + } + } + + private Optional getTransactionId(OracleConnection oracle) { + try { + return Optional.ofNullable(oracle.getTransactionId()); + } catch (Exception e) { + logger.error("Failed to obtain Oracle transaction id", e); + return Optional.empty(); + } + } + + private static void suspend(OracleConnection oracle) { + try { + try { + oracle.suspendTransactionImmediately(); + return; + } catch (Exception ignored) { + } + oracle.suspendTransaction(); + } catch (Exception e) { + throw new TransactionSystemException("Could not suspend Oracle sessionless transaction", e); + } + } + + private void resume(OracleConnection oracle, byte[] gtrid) { + try { + oracle.resumeTransaction(gtrid); + } catch (Exception e) { + logger.error("Failed to resume Oracle transaction", e); + throw new TransactionSystemException("Could not resume Oracle sessionless transaction", e); + } + } + + private static Optional findOracleElement() { + return PropagatedContext.getOrEmpty().findAll(OracleSessionlessElement.class).findFirst(); + } + + private static void putOracleElement(OracleSessionlessElement element) { + PropagatedContext.getOrEmpty().plus(element).propagate(); + } + + private static void removeOracleElement(OracleSessionlessElement element) { + PropagatedContext.getOrEmpty().minus(element).propagate(); + } + + private record OracleSessionlessElement(byte[] gtrid) implements PropagatedContextElement { + } +} From e11e962acb4a2f68e8b8530ea35f8207927c65e3 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 20 May 2026 15:14:58 +0200 Subject: [PATCH 03/32] Oracle sessionless transaction impl - wip --- data-tx-jdbc/build.gradle | 2 + .../OracleSessionlessTransactionContext.java | 87 +++++++++++++ ...ssionlessTransactionHttpConfiguration.java | 75 +++++++++++ ...essionlessTransactionHttpServerFilter.java | 82 ++++++++++++ .../OracleSessionlessTransactionManager.java} | 106 +++++++++------ .../transaction/jdbc/oracle/package-info.java | 26 ++++ ...lessTransactionHttpServerFilterSpec.groovy | 122 ++++++++++++++++++ 7 files changed, 458 insertions(+), 42 deletions(-) create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilter.java rename data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/{OracleDataSourceTransactionManager.java => oracle/OracleSessionlessTransactionManager.java} (59%) create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java create mode 100644 data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/OracleSessionlessTransactionHttpServerFilterSpec.groovy diff --git a/data-tx-jdbc/build.gradle b/data-tx-jdbc/build.gradle index fd1fd18d1be..63460972412 100644 --- a/data-tx-jdbc/build.gradle +++ b/data-tx-jdbc/build.gradle @@ -12,6 +12,7 @@ dependencies { implementation mn.micronaut.inject implementation mn.micronaut.aop + compileOnly mn.micronaut.http compileOnly mnSql.micronaut.jdbc compileOnly mnSql.ojdbc11 @@ -20,6 +21,7 @@ dependencies { testImplementation projects.micronautDataProcessor testImplementation mn.micronaut.inject.java.test + testImplementation mn.micronaut.http testImplementation mn.jackson.databind testImplementation mnSql.micronaut.jdbc diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java new file mode 100644 index 00000000000..344f3bdb9d3 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java @@ -0,0 +1,87 @@ +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; + +import io.micronaut.core.propagation.PropagatedContext; +import io.micronaut.core.propagation.PropagatedContextElement; + +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; + +/** + * Propagated Oracle sessionless transaction state. + */ +final class OracleSessionlessTransactionContext implements PropagatedContextElement { + + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + + private final byte[] gtrid; + + OracleSessionlessTransactionContext(byte[] gtrid) { + this.gtrid = Objects.requireNonNull(gtrid, "gtrid").clone(); + } + + byte[] gtrid() { + return gtrid.clone(); + } + + String encode() { + return ENCODER.encodeToString(gtrid); + } + + static OracleSessionlessTransactionContext decode(String value) { + byte[] decoded = DECODER.decode(value); + if (decoded.length == 0) { + throw new IllegalArgumentException("Oracle sessionless transaction id cannot be empty"); + } + return new OracleSessionlessTransactionContext(decoded); + } + + static Optional find() { + return find(PropagatedContext.getOrEmpty()); + } + + static Optional find(PropagatedContext context) { + return context.findAll(OracleSessionlessTransactionContext.class).findFirst(); + } + + static PropagatedContext withoutExisting(PropagatedContext context) { + PropagatedContext current = context; + for (OracleSessionlessTransactionContext element : context.findAll(OracleSessionlessTransactionContext.class).toList()) { + current = current.minus(element); + } + return current; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (!(o instanceof OracleSessionlessTransactionContext that)) { + return false; + } + return Arrays.equals(gtrid, that.gtrid); + } + + @Override + public int hashCode() { + return Arrays.hashCode(gtrid); + } +} 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 new file mode 100644 index 00000000000..7b2362555a1 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java @@ -0,0 +1,75 @@ +/* + * Copyright 2017-2026 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.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 { + + /** + * The configuration prefix for Oracle sessionless transaction HTTP propagation. + */ + public static final String PREFIX = "micronaut.data.oracle.sessionless.http"; + + /** + * The default HTTP header that carries the encoded sessionless transaction id. + */ + public static final String DEFAULT_HEADER_NAME = "Oracle-Sessionless-Transaction-Id"; + + private boolean enabled; + private String headerName = DEFAULT_HEADER_NAME; + + /** + * @return Whether HTTP propagation of Oracle sessionless transaction ids is enabled. + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether HTTP propagation of Oracle sessionless transaction ids is enabled. + * + * @param enabled Whether HTTP propagation is enabled + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * @return The HTTP header that carries the encoded sessionless transaction id. + */ + public String getHeaderName() { + return headerName; + } + + /** + * Sets the HTTP header that carries the encoded sessionless transaction id. + * + * @param headerName The header name + */ + public void setHeaderName(String headerName) { + if (StringUtils.isNotEmpty(headerName)) { + this.headerName = headerName; + } + } +} 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 new file mode 100644 index 00000000000..e7e55c2ab01 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.propagation.MutablePropagatedContext; +import io.micronaut.core.propagation.PropagatedContext; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.RequestFilter; +import io.micronaut.http.annotation.ResponseFilter; +import io.micronaut.http.annotation.ServerFilter; +import io.micronaut.http.exceptions.HttpStatusException; + +import java.util.List; +import java.util.Optional; + +/** + * Bridges Oracle sessionless transaction ids between HTTP headers and propagated context. + */ +@Internal +@ServerFilter(ServerFilter.MATCH_ALL_PATTERN) +@Requires(classes = {HttpRequest.class, MutableHttpResponse.class, ServerFilter.class}) +@Requires(property = OracleSessionlessTransactionHttpConfiguration.PREFIX + ".enabled", value = StringUtils.TRUE) +final class OracleSessionlessTransactionHttpServerFilter { + + private final OracleSessionlessTransactionHttpConfiguration configuration; + + OracleSessionlessTransactionHttpServerFilter(OracleSessionlessTransactionHttpConfiguration configuration) { + this.configuration = configuration; + } + + @RequestFilter + void readTransactionId(HttpRequest request, MutablePropagatedContext propagatedContext) { + Optional value = request.getHeaders().findFirst(configuration.getHeaderName()); + if (value.isEmpty()) { + return; + } + try { + replaceTransactionContext(propagatedContext, OracleSessionlessTransactionContext.decode(value.get())); + } catch (IllegalArgumentException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); + } + } + + @ResponseFilter + void writeTransactionId(MutableHttpResponse response, MutablePropagatedContext propagatedContext) { + PropagatedContext context = propagatedContext.getContext(); + Optional transactionContext = OracleSessionlessTransactionContext.find(context == null ? PropagatedContext.empty() : context); + if (transactionContext.isPresent()) { + response.getHeaders().set(configuration.getHeaderName(), transactionContext.get().encode()); + } + } + + private static void replaceTransactionContext(MutablePropagatedContext propagatedContext, + OracleSessionlessTransactionContext transactionContext) { + PropagatedContext context = propagatedContext.getContext(); + if (context != null) { + List transactionContexts = context.findAll(OracleSessionlessTransactionContext.class).toList(); + for (OracleSessionlessTransactionContext existingTransactionContext : transactionContexts) { + propagatedContext.remove(existingTransactionContext); + } + } + propagatedContext.add(transactionContext); + } +} diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java similarity index 59% rename from data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java rename to data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java index b80c7eeb42f..32d22f055d4 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/OracleDataSourceTransactionManager.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java @@ -1,11 +1,26 @@ -package io.micronaut.transaction.jdbc; +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; 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.core.annotation.Internal; import io.micronaut.core.propagation.PropagatedContext; -import io.micronaut.core.propagation.PropagatedContextElement; import io.micronaut.data.connection.ConnectionOperations; import io.micronaut.data.connection.ConnectionSynchronization; import io.micronaut.data.connection.SynchronousConnectionManager; @@ -14,12 +29,14 @@ 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 oracle.jdbc.OracleConnection; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import javax.sql.DataSource; import java.sql.Connection; +import java.sql.SQLException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -28,11 +45,12 @@ @Internal @EachBean(DataSource.class) @Requires(classes = OracleConnection.class) -public class OracleDataSourceTransactionManager extends DataSourceTransactionManager { +@Replaces(DataSourceTransactionManager.class) +public class OracleSessionlessTransactionManager extends DataSourceTransactionManager { - public OracleDataSourceTransactionManager(@NonNull DataSource dataSource, - @Parameter ConnectionOperations connectionOperations, - @Parameter @Nullable SynchronousConnectionManager synchronousConnectionManager) { + public OracleSessionlessTransactionManager(@NonNull DataSource dataSource, + @Parameter ConnectionOperations connectionOperations, + @Parameter @Nullable SynchronousConnectionManager synchronousConnectionManager) { super(dataSource, connectionOperations, synchronousConnectionManager); } @@ -52,17 +70,10 @@ protected void doBegin(DefaultTransactionStatus status) { OracleConnection oracle = unwrapOracle(connection).orElse(null); if (oracle != null) { if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { - Integer timeout = null; - if (definition.getTimeout().isPresent()) { - timeout = Math.toIntExact(definition.getTimeout().get().toSeconds()); - } - byte[] gtrid = startTransaction(oracle, timeout).orElseGet(() -> getTransactionId(oracle).orElse(null)); - if (gtrid == null) { - throw new CannotCreateTransactionException("Could not start Oracle sessionless transaction"); - } - putOracleElement(new OracleSessionlessElement(gtrid)); + byte[] gtrid = startTransaction(oracle, getTimeoutSeconds(definition)); + putOracleElement(new OracleSessionlessTransactionContext(gtrid)); } else if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - Optional element = findOracleElement(); + Optional element = findOracleElement(); if (element.isEmpty()) { throw new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume"); } @@ -70,7 +81,6 @@ protected void doBegin(DefaultTransactionStatus status) { } } - if (!onComplete.isEmpty()) { Collections.reverse(onComplete); status.getConnectionStatus().registerSynchronization(new ConnectionSynchronization() { @@ -97,7 +107,7 @@ protected void doCommit(DefaultTransactionStatus status) { super.doCommit(status); if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - findOracleElement().ifPresent(OracleDataSourceTransactionManager::removeOracleElement); + findOracleElement().ifPresent(OracleSessionlessTransactionManager::removeOracleElement); } } @@ -105,11 +115,29 @@ protected void doCommit(DefaultTransactionStatus status) { protected void doRollback(DefaultTransactionStatus status) { super.doRollback(status); if (status.getTransactionDefinition().getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - findOracleElement().ifPresent(OracleDataSourceTransactionManager::removeOracleElement); + findOracleElement().ifPresent(OracleSessionlessTransactionManager::removeOracleElement); } } - private Optional unwrapOracle(Connection connection) { + @Nullable + private static Integer getTimeoutSeconds(TransactionDefinition definition) { + return definition.getTimeout() + .map(timeout -> toTimeoutSeconds(timeout.toSeconds())) + .orElse(null); + } + + private static int toTimeoutSeconds(long timeoutSeconds) { + try { + return Math.toIntExact(timeoutSeconds); + } catch (ArithmeticException e) { + throw new CannotCreateTransactionException( + "Oracle sessionless transaction timeout exceeds supported range", + e + ); + } + } + + private Optional unwrapOracle(@Nullable Connection connection) { if (connection == null) { return Optional.empty(); } @@ -122,21 +150,18 @@ private Optional unwrapOracle(Connection connection) { } } - private Optional startTransaction(OracleConnection oracle, Integer timeout) { + private byte[] startTransaction(OracleConnection oracle, @Nullable Integer timeout) { try { - return Optional.ofNullable(timeout == null ? oracle.startTransaction() : oracle.startTransaction(timeout)); - } catch (Exception e) { - logger.error("Failed to start Oracle transaction", e); - return Optional.empty(); - } - } - - private Optional getTransactionId(OracleConnection oracle) { - try { - return Optional.ofNullable(oracle.getTransactionId()); - } catch (Exception e) { - logger.error("Failed to obtain Oracle transaction id", e); - return Optional.empty(); + byte[] gtrid = timeout == null ? oracle.startTransaction() : oracle.startTransaction(timeout); + if (gtrid == null) { + gtrid = oracle.getTransactionId(); + } + if (gtrid == null) { + throw new CannotCreateTransactionException("Could not obtain Oracle sessionless transaction id"); + } + return gtrid; + } catch (SQLException e) { + throw new CannotCreateTransactionException("Could not start Oracle sessionless transaction", e); } } @@ -162,18 +187,15 @@ private void resume(OracleConnection oracle, byte[] gtrid) { } } - private static Optional findOracleElement() { - return PropagatedContext.getOrEmpty().findAll(OracleSessionlessElement.class).findFirst(); + private static Optional findOracleElement() { + return OracleSessionlessTransactionContext.find(); } - private static void putOracleElement(OracleSessionlessElement element) { - PropagatedContext.getOrEmpty().plus(element).propagate(); + private static void putOracleElement(OracleSessionlessTransactionContext element) { + OracleSessionlessTransactionContext.withoutExisting(PropagatedContext.getOrEmpty()).plus(element).propagate(); } - private static void removeOracleElement(OracleSessionlessElement element) { + private static void removeOracleElement(OracleSessionlessTransactionContext element) { PropagatedContext.getOrEmpty().minus(element).propagate(); } - - private record OracleSessionlessElement(byte[] gtrid) implements PropagatedContextElement { - } } diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java new file mode 100644 index 00000000000..c037c7575bf --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2017-2026 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. + */ +/** + * Oracle-specific JDBC transaction support. + * + *

Includes sessionless transaction integration for Oracle JDBC connections, with transaction identifiers + * propagated through Micronaut's {@link io.micronaut.core.propagation.PropagatedContext} and, when enabled, + * through HTTP request and response headers.

+ */ +@NullMarked +package io.micronaut.transaction.jdbc.oracle; + +import org.jspecify.annotations.NullMarked; 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 new file mode 100644 index 00000000000..5b976ca70b7 --- /dev/null +++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/OracleSessionlessTransactionHttpServerFilterSpec.groovy @@ -0,0 +1,122 @@ +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 + } +} From e2749384ac93cd201dadb270a59dada3f372e492 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 20 May 2026 15:54:26 +0200 Subject: [PATCH 04/32] Oracle sessionless transaction impl - wip --- data-tx-jdbc/build.gradle | 1 + .../OracleSessionlessTransactionManager.java | 128 +++++++++--------- 2 files changed, 68 insertions(+), 61 deletions(-) diff --git a/data-tx-jdbc/build.gradle b/data-tx-jdbc/build.gradle index 63460972412..ae576fd1f3f 100644 --- a/data-tx-jdbc/build.gradle +++ b/data-tx-jdbc/build.gradle @@ -24,6 +24,7 @@ dependencies { testImplementation mn.micronaut.http testImplementation mn.jackson.databind testImplementation mnSql.micronaut.jdbc + testImplementation mnSql.ojdbc11 testRuntimeOnly mnSql.h2 testRuntimeOnly mnSql.micronaut.jdbc.tomcat 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 32d22f055d4..c42c4e63b48 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,14 +22,14 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.data.connection.ConnectionOperations; -import io.micronaut.data.connection.ConnectionSynchronization; import io.micronaut.data.connection.SynchronousConnectionManager; -import io.micronaut.data.connection.support.JdbcConnectionUtils; 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.jdbc.DataSourceTransactionManager; +import io.micronaut.transaction.support.TransactionExecutionListener; +import jakarta.inject.Inject; import oracle.jdbc.OracleConnection; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -37,7 +37,6 @@ import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -48,50 +47,31 @@ @Replaces(DataSourceTransactionManager.class) public class OracleSessionlessTransactionManager extends DataSourceTransactionManager { + @Inject + public OracleSessionlessTransactionManager(@NonNull DataSource dataSource, + @Parameter ConnectionOperations connectionOperations, + @Parameter @Nullable SynchronousConnectionManager synchronousConnectionManager, + List> transactionExecutionListeners) { + super(dataSource, connectionOperations, synchronousConnectionManager, transactionExecutionListeners); + } + public OracleSessionlessTransactionManager(@NonNull DataSource dataSource, @Parameter ConnectionOperations connectionOperations, @Parameter @Nullable SynchronousConnectionManager synchronousConnectionManager) { - super(dataSource, connectionOperations, synchronousConnectionManager); + this(dataSource, connectionOperations, synchronousConnectionManager, Collections.emptyList()); } @Override protected void doBegin(DefaultTransactionStatus status) { - TransactionDefinition definition = status.getTransactionDefinition(); - Connection connection = status.getConnection(); + super.doBegin(status); - List onComplete = new ArrayList<>(5); - - definition.isReadOnly() - .ifPresent(readOnly -> JdbcConnectionUtils.applyReadOnly(logger, connection, readOnly, onComplete)); - definition.getIsolationLevel() - .ifPresent(isolation -> JdbcConnectionUtils.applyTransactionIsolation(logger, connection, isolation.getCode(), onComplete)); - JdbcConnectionUtils.applyAutoCommit(logger, connection, false, onComplete); - - OracleConnection oracle = unwrapOracle(connection).orElse(null); - if (oracle != null) { - if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { - byte[] gtrid = startTransaction(oracle, getTimeoutSeconds(definition)); - putOracleElement(new OracleSessionlessTransactionContext(gtrid)); - } else if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - Optional element = findOracleElement(); - if (element.isEmpty()) { - throw new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume"); - } - resume(oracle, element.get().gtrid()); + TransactionDefinition definition = status.getTransactionDefinition(); + switch (definition.getPropagationBehavior()) { + case SUSPEND -> startSessionlessTransaction(status.getConnection(), definition); + case REQUIRES_SUSPENDED -> resumeSessionlessTransaction(status.getConnection()); + default -> { } } - - if (!onComplete.isEmpty()) { - Collections.reverse(onComplete); - status.getConnectionStatus().registerSynchronization(new ConnectionSynchronization() { - @Override - public void executionComplete() { - for (Runnable runnable : onComplete) { - runnable.run(); - } - } - }); - } } @Override @@ -99,24 +79,48 @@ protected void doCommit(DefaultTransactionStatus status) { Connection connection = status.getConnection(); TransactionDefinition definition = status.getTransactionDefinition(); - OracleConnection oracle = unwrapOracle(connection).orElse(null); - if (oracle != null && definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { - suspend(oracle); + if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { + suspend(unwrapRequiredOracleForCompletion(connection)); return; } - super.doCommit(status); if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - findOracleElement().ifPresent(OracleSessionlessTransactionManager::removeOracleElement); + Optional element = findOracleElement(); + try { + super.doCommit(status); + } finally { + element.ifPresent(OracleSessionlessTransactionManager::removeOracleElement); + } + return; } + + super.doCommit(status); } @Override protected void doRollback(DefaultTransactionStatus status) { - super.doRollback(status); if (status.getTransactionDefinition().getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - findOracleElement().ifPresent(OracleSessionlessTransactionManager::removeOracleElement); + Optional element = findOracleElement(); + try { + super.doRollback(status); + } finally { + element.ifPresent(OracleSessionlessTransactionManager::removeOracleElement); + } + return; } + + super.doRollback(status); + } + + private static void startSessionlessTransaction(Connection connection, TransactionDefinition definition) { + byte[] gtrid = startTransaction(unwrapRequiredOracleForBegin(connection), getTimeoutSeconds(definition)); + putOracleElement(new OracleSessionlessTransactionContext(gtrid)); + } + + private static void resumeSessionlessTransaction(Connection connection) { + OracleSessionlessTransactionContext element = findOracleElement() + .orElseThrow(() -> new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume")); + resume(unwrapRequiredOracleForBegin(connection), element.gtrid()); } @Nullable @@ -137,20 +141,23 @@ private static int toTimeoutSeconds(long timeoutSeconds) { } } - private Optional unwrapOracle(@Nullable Connection connection) { - if (connection == null) { - return Optional.empty(); + private static OracleConnection unwrapRequiredOracleForBegin(Connection connection) { + try { + return connection.unwrap(OracleConnection.class); + } catch (SQLException e) { + throw new CannotCreateTransactionException("Oracle sessionless transactions require an Oracle JDBC connection", e); } + } + + private static OracleConnection unwrapRequiredOracleForCompletion(Connection connection) { try { - OracleConnection oracleConnection = connection.unwrap(OracleConnection.class); - return Optional.ofNullable(oracleConnection); - } catch (Exception e) { - logger.error("Failed to unwrap Oracle connection", e); - return Optional.empty(); + return connection.unwrap(OracleConnection.class); + } catch (SQLException e) { + throw new TransactionSystemException("Oracle sessionless transactions require an Oracle JDBC connection", e); } } - private byte[] startTransaction(OracleConnection oracle, @Nullable Integer timeout) { + private static byte[] startTransaction(OracleConnection oracle, @Nullable Integer timeout) { try { byte[] gtrid = timeout == null ? oracle.startTransaction() : oracle.startTransaction(timeout); if (gtrid == null) { @@ -167,22 +174,21 @@ private byte[] startTransaction(OracleConnection oracle, @Nullable Integer timeo private static void suspend(OracleConnection oracle) { try { + oracle.suspendTransactionImmediately(); + } catch (Exception immediateFailure) { try { - oracle.suspendTransactionImmediately(); - return; - } catch (Exception ignored) { + oracle.suspendTransaction(); + } catch (Exception fallbackFailure) { + fallbackFailure.addSuppressed(immediateFailure); + throw new TransactionSystemException("Could not suspend Oracle sessionless transaction", fallbackFailure); } - oracle.suspendTransaction(); - } catch (Exception e) { - throw new TransactionSystemException("Could not suspend Oracle sessionless transaction", e); } } - private void resume(OracleConnection oracle, byte[] gtrid) { + private static void resume(OracleConnection oracle, byte[] gtrid) { try { oracle.resumeTransaction(gtrid); } catch (Exception e) { - logger.error("Failed to resume Oracle transaction", e); throw new TransactionSystemException("Could not resume Oracle sessionless transaction", e); } } From 5886098d10257f29400acab3e549bd6b55a51e29 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 20 May 2026 16:06:36 +0200 Subject: [PATCH 05/32] Oracle sessionless transaction impl - wip --- ...essionlessTransactionHttpServerFilter.java | 10 +++---- ...va => OracleSessionlessTransactionId.java} | 18 ++++++------- .../OracleSessionlessTransactionManager.java | 26 ++++++++++--------- 3 files changed, 28 insertions(+), 26 deletions(-) rename data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/{OracleSessionlessTransactionContext.java => OracleSessionlessTransactionId.java} (74%) 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 e7e55c2ab01..d4c25b25afc 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 @@ -53,7 +53,7 @@ void readTransactionId(HttpRequest request, MutablePropagatedContext propagat return; } try { - replaceTransactionContext(propagatedContext, OracleSessionlessTransactionContext.decode(value.get())); + replaceTransactionContext(propagatedContext, OracleSessionlessTransactionId.decode(value.get())); } catch (IllegalArgumentException e) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); } @@ -62,18 +62,18 @@ void readTransactionId(HttpRequest request, MutablePropagatedContext propagat @ResponseFilter void writeTransactionId(MutableHttpResponse response, MutablePropagatedContext propagatedContext) { PropagatedContext context = propagatedContext.getContext(); - Optional transactionContext = OracleSessionlessTransactionContext.find(context == null ? PropagatedContext.empty() : context); + Optional transactionContext = OracleSessionlessTransactionId.find(context == null ? PropagatedContext.empty() : context); if (transactionContext.isPresent()) { response.getHeaders().set(configuration.getHeaderName(), transactionContext.get().encode()); } } private static void replaceTransactionContext(MutablePropagatedContext propagatedContext, - OracleSessionlessTransactionContext transactionContext) { + OracleSessionlessTransactionId transactionContext) { PropagatedContext context = propagatedContext.getContext(); if (context != null) { - List transactionContexts = context.findAll(OracleSessionlessTransactionContext.class).toList(); - for (OracleSessionlessTransactionContext existingTransactionContext : transactionContexts) { + List transactionContexts = context.findAll(OracleSessionlessTransactionId.class).toList(); + for (OracleSessionlessTransactionId existingTransactionContext : transactionContexts) { propagatedContext.remove(existingTransactionContext); } } diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionId.java similarity index 74% rename from data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java rename to data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionId.java index 344f3bdb9d3..9d233ce5df7 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionContext.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionId.java @@ -26,14 +26,14 @@ /** * Propagated Oracle sessionless transaction state. */ -final class OracleSessionlessTransactionContext implements PropagatedContextElement { +final class OracleSessionlessTransactionId implements PropagatedContextElement { private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); private final byte[] gtrid; - OracleSessionlessTransactionContext(byte[] gtrid) { + OracleSessionlessTransactionId(byte[] gtrid) { this.gtrid = Objects.requireNonNull(gtrid, "gtrid").clone(); } @@ -45,25 +45,25 @@ String encode() { return ENCODER.encodeToString(gtrid); } - static OracleSessionlessTransactionContext decode(String value) { + static OracleSessionlessTransactionId decode(String value) { byte[] decoded = DECODER.decode(value); if (decoded.length == 0) { throw new IllegalArgumentException("Oracle sessionless transaction id cannot be empty"); } - return new OracleSessionlessTransactionContext(decoded); + return new OracleSessionlessTransactionId(decoded); } - static Optional find() { + static Optional find() { return find(PropagatedContext.getOrEmpty()); } - static Optional find(PropagatedContext context) { - return context.findAll(OracleSessionlessTransactionContext.class).findFirst(); + static Optional find(PropagatedContext context) { + return context.findAll(OracleSessionlessTransactionId.class).findFirst(); } static PropagatedContext withoutExisting(PropagatedContext context) { PropagatedContext current = context; - for (OracleSessionlessTransactionContext element : context.findAll(OracleSessionlessTransactionContext.class).toList()) { + for (OracleSessionlessTransactionId element : context.findAll(OracleSessionlessTransactionId.class).toList()) { current = current.minus(element); } return current; @@ -74,7 +74,7 @@ public boolean equals(Object o) { if (o == this) { return true; } - if (!(o instanceof OracleSessionlessTransactionContext that)) { + if (!(o instanceof OracleSessionlessTransactionId that)) { return false; } return Arrays.equals(gtrid, that.gtrid); 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 c42c4e63b48..05c25fbce4c 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 @@ -85,11 +85,11 @@ protected void doCommit(DefaultTransactionStatus status) { } if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - Optional element = findOracleElement(); + Optional element = findSessionlessTransactionId(); try { super.doCommit(status); } finally { - element.ifPresent(OracleSessionlessTransactionManager::removeOracleElement); + element.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); } return; } @@ -100,11 +100,11 @@ protected void doCommit(DefaultTransactionStatus status) { @Override protected void doRollback(DefaultTransactionStatus status) { if (status.getTransactionDefinition().getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - Optional element = findOracleElement(); + Optional element = findSessionlessTransactionId(); try { super.doRollback(status); } finally { - element.ifPresent(OracleSessionlessTransactionManager::removeOracleElement); + element.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); } return; } @@ -114,11 +114,11 @@ protected void doRollback(DefaultTransactionStatus status) { private static void startSessionlessTransaction(Connection connection, TransactionDefinition definition) { byte[] gtrid = startTransaction(unwrapRequiredOracleForBegin(connection), getTimeoutSeconds(definition)); - putOracleElement(new OracleSessionlessTransactionContext(gtrid)); + propagateSessionlessTransactionId(new OracleSessionlessTransactionId(gtrid)); } private static void resumeSessionlessTransaction(Connection connection) { - OracleSessionlessTransactionContext element = findOracleElement() + OracleSessionlessTransactionId element = findSessionlessTransactionId() .orElseThrow(() -> new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume")); resume(unwrapRequiredOracleForBegin(connection), element.gtrid()); } @@ -193,15 +193,17 @@ private static void resume(OracleConnection oracle, byte[] gtrid) { } } - private static Optional findOracleElement() { - return OracleSessionlessTransactionContext.find(); + private static Optional findSessionlessTransactionId() { + return OracleSessionlessTransactionId.find(); } - private static void putOracleElement(OracleSessionlessTransactionContext element) { - OracleSessionlessTransactionContext.withoutExisting(PropagatedContext.getOrEmpty()).plus(element).propagate(); + private static void propagateSessionlessTransactionId(OracleSessionlessTransactionId transactionId) { + OracleSessionlessTransactionId.withoutExisting(PropagatedContext.getOrEmpty()) + .plus(transactionId) + .propagate(); } - private static void removeOracleElement(OracleSessionlessTransactionContext element) { - PropagatedContext.getOrEmpty().minus(element).propagate(); + private static void clearSessionlessTransactionId(OracleSessionlessTransactionId transactionId) { + PropagatedContext.getOrEmpty().minus(transactionId).propagate(); } } From a8d7af9c72456b47926b1dcfdd7864f1ee550160 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 20 May 2026 16:13:43 +0200 Subject: [PATCH 06/32] Oracle sessionless transaction impl - wip --- .../OracleSessionlessTransactionManager.java | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 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 05c25fbce4c..2581c687473 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 @@ -76,40 +76,23 @@ protected void doBegin(DefaultTransactionStatus status) { @Override protected void doCommit(DefaultTransactionStatus status) { - Connection connection = status.getConnection(); - TransactionDefinition definition = status.getTransactionDefinition(); - - if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.SUSPEND) { - suspend(unwrapRequiredOracleForCompletion(connection)); - return; + TransactionDefinition.Propagation propagation = status.getTransactionDefinition().getPropagationBehavior(); + if (propagation == TransactionDefinition.Propagation.SUSPEND) { + suspend(unwrapRequiredOracleForCompletion(status.getConnection())); + } else if (propagation == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { + commitResumedSessionlessTransaction(status); + } else { + super.doCommit(status); } - - if (definition.getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - Optional element = findSessionlessTransactionId(); - try { - super.doCommit(status); - } finally { - element.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); - } - return; - } - - super.doCommit(status); } @Override protected void doRollback(DefaultTransactionStatus status) { if (status.getTransactionDefinition().getPropagationBehavior() == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { - Optional element = findSessionlessTransactionId(); - try { - super.doRollback(status); - } finally { - element.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); - } - return; + rollbackResumedSessionlessTransaction(status); + } else { + super.doRollback(status); } - - super.doRollback(status); } private static void startSessionlessTransaction(Connection connection, TransactionDefinition definition) { @@ -123,6 +106,24 @@ private static void resumeSessionlessTransaction(Connection connection) { resume(unwrapRequiredOracleForBegin(connection), element.gtrid()); } + private void commitResumedSessionlessTransaction(DefaultTransactionStatus status) { + Optional transactionId = findSessionlessTransactionId(); + try { + super.doCommit(status); + } finally { + transactionId.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); + } + } + + private void rollbackResumedSessionlessTransaction(DefaultTransactionStatus status) { + Optional transactionId = findSessionlessTransactionId(); + try { + super.doRollback(status); + } finally { + transactionId.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); + } + } + @Nullable private static Integer getTimeoutSeconds(TransactionDefinition definition) { return definition.getTimeout() From a8b5c6c00f16b826a8e73b78b333c1ff08c9a377 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 10:15:37 +0200 Subject: [PATCH 07/32] Oracle sessionless transaction impl - wip --- ...nlessTransactionPropagationOperations.java | 79 +++++++++++++++++++ ...essionlessTransactionHttpServerFilter.java | 49 ++++++------ .../OracleSessionlessTransactionManager.java | 63 +++++++-------- ...nlessTransactionPropagationOperations.java | 71 +++++++++++++++++ ...=> OracleSessionlessTransactionState.java} | 66 +++++++++------- .../transaction/jdbc/oracle/package-info.java | 7 +- 6 files changed, 245 insertions(+), 90 deletions(-) create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperations.java rename data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/{OracleSessionlessTransactionId.java => OracleSessionlessTransactionState.java} (53%) 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 new file mode 100644 index 00000000000..1403f3fc567 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java @@ -0,0 +1,79 @@ +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.propagation.PropagatedContext; +import io.micronaut.transaction.exceptions.TransactionUsageException; +import jakarta.inject.Singleton; +import oracle.jdbc.OracleConnection; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +@Singleton +@Requires(classes = OracleConnection.class) +final class DefaultOracleSessionlessTransactionPropagationOperations implements OracleSessionlessTransactionPropagationOperations { + + @Override + public T withPropagation(Supplier supplier) { + return withPropagation(new OracleSessionlessTransactionState(), supplier); + } + + @Override + public T withPropagation(String encodedTransactionId, Supplier supplier) { + OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); + state.setEncodedGtrid(encodedTransactionId); + return withPropagation(state, supplier); + } + + @Override + public Optional currentTransactionId() { + return currentState() + .flatMap(OracleSessionlessTransactionState::getEncodedGtrid); + } + + @Override + public void setTransactionId(String encodedTransactionId) { + requiredState().setEncodedGtrid(encodedTransactionId); + } + + @Override + public void clearTransactionId() { + currentState().ifPresent(OracleSessionlessTransactionState::clearGtrid); + } + + private static T withPropagation(OracleSessionlessTransactionState state, + Supplier supplier) { + Objects.requireNonNull(supplier, "supplier"); + PropagatedContext context = OracleSessionlessTransactionState + .withoutExisting(PropagatedContext.getOrEmpty()) + .plus(state); + return context.propagate(supplier); + } + + private static Optional currentState() { + return OracleSessionlessTransactionState.current(); + } + + private static OracleSessionlessTransactionState requiredState() { + return currentState().orElseThrow(() -> + new TransactionUsageException("Oracle sessionless transaction propagation is not active") + ); + } +} 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 d4c25b25afc..7ccc650e75f 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 @@ -16,7 +16,6 @@ package io.micronaut.transaction.jdbc.oracle; import io.micronaut.context.annotation.Requires; -import io.micronaut.core.annotation.Internal; import io.micronaut.core.propagation.MutablePropagatedContext; import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.core.util.StringUtils; @@ -34,9 +33,8 @@ /** * Bridges Oracle sessionless transaction ids between HTTP headers and propagated context. */ -@Internal @ServerFilter(ServerFilter.MATCH_ALL_PATTERN) -@Requires(classes = {HttpRequest.class, MutableHttpResponse.class, ServerFilter.class}) +@Requires(classes = {HttpRequest.class, MutableHttpResponse.class}) @Requires(property = OracleSessionlessTransactionHttpConfiguration.PREFIX + ".enabled", value = StringUtils.TRUE) final class OracleSessionlessTransactionHttpServerFilter { @@ -47,36 +45,39 @@ final class OracleSessionlessTransactionHttpServerFilter { } @RequestFilter - void readTransactionId(HttpRequest request, MutablePropagatedContext propagatedContext) { + void readTransactionId(HttpRequest request, MutablePropagatedContext mutablePropagatedContext) { + OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); Optional value = request.getHeaders().findFirst(configuration.getHeaderName()); - if (value.isEmpty()) { - return; - } - try { - replaceTransactionContext(propagatedContext, OracleSessionlessTransactionId.decode(value.get())); - } catch (IllegalArgumentException e) { - throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); + if (value.isPresent()) { + try { + state.setEncodedGtrid(value.get()); + } catch (IllegalArgumentException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); + } } + replaceTransactionState(mutablePropagatedContext, state); } @ResponseFilter - void writeTransactionId(MutableHttpResponse response, MutablePropagatedContext propagatedContext) { - PropagatedContext context = propagatedContext.getContext(); - Optional transactionContext = OracleSessionlessTransactionId.find(context == null ? PropagatedContext.empty() : context); - if (transactionContext.isPresent()) { - response.getHeaders().set(configuration.getHeaderName(), transactionContext.get().encode()); + void writeTransactionId(MutableHttpResponse response, MutablePropagatedContext mutablePropagatedContext) { + PropagatedContext propagatedContext = mutablePropagatedContext.getContext(); + if (propagatedContext != null) { + propagatedContext.findAll(OracleSessionlessTransactionState.class) + .findFirst() + .flatMap(OracleSessionlessTransactionState::getEncodedGtrid) + .ifPresent(transactionId -> response.getHeaders().set(configuration.getHeaderName(), transactionId)); } } - private static void replaceTransactionContext(MutablePropagatedContext propagatedContext, - OracleSessionlessTransactionId transactionContext) { - PropagatedContext context = propagatedContext.getContext(); - if (context != null) { - List transactionContexts = context.findAll(OracleSessionlessTransactionId.class).toList(); - for (OracleSessionlessTransactionId existingTransactionContext : transactionContexts) { - propagatedContext.remove(existingTransactionContext); + private static void replaceTransactionState(MutablePropagatedContext mutablePropagatedContext, + OracleSessionlessTransactionState transactionState) { + PropagatedContext propagatedContext = mutablePropagatedContext.getContext(); + if (propagatedContext != null) { + List transactionStates = propagatedContext.findAll(OracleSessionlessTransactionState.class).toList(); + for (OracleSessionlessTransactionState existingTransactionState : transactionStates) { + mutablePropagatedContext.remove(existingTransactionState); } } - propagatedContext.add(transactionContext); + mutablePropagatedContext.add(transactionState); } } 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 2581c687473..b2efe6a3bcf 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 @@ -20,7 +20,6 @@ import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Internal; -import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.data.connection.ConnectionOperations; import io.micronaut.data.connection.SynchronousConnectionManager; import io.micronaut.transaction.TransactionDefinition; @@ -41,6 +40,9 @@ import java.util.List; import java.util.Optional; +/** + * Oracle JDBC transaction manager with sessionless transaction propagation support. + */ @Internal @EachBean(DataSource.class) @Requires(classes = OracleConnection.class) @@ -63,14 +65,28 @@ public OracleSessionlessTransactionManager(@NonNull DataSource dataSource, @Override protected void doBegin(DefaultTransactionStatus status) { - super.doBegin(status); - TransactionDefinition definition = status.getTransactionDefinition(); - switch (definition.getPropagationBehavior()) { - case SUSPEND -> startSessionlessTransaction(status.getConnection(), definition); - case REQUIRES_SUSPENDED -> resumeSessionlessTransaction(status.getConnection()); - default -> { + TransactionDefinition.Propagation propagation = definition.getPropagationBehavior(); + if (propagation == TransactionDefinition.Propagation.SUSPEND) { + Optional state = findSessionlessTransactionState(); + if (state.isEmpty()) { + throw new CannotCreateTransactionException("Oracle sessionless transaction propagation is not active"); + } + if (state.get().getGtrid().isPresent()) { + throw new CannotCreateTransactionException("Oracle sessionless transaction context already contains a transaction id"); } + super.doBegin(status); + byte[] gtrid = startTransaction(unwrapRequiredOracleForBegin(status.getConnection()), getTimeoutSeconds(definition)); + if (!state.get().setGtridIfAbsent(gtrid)) { + throw new CannotCreateTransactionException("Oracle sessionless transaction context already contains a transaction id"); + } + } else if (propagation == TransactionDefinition.Propagation.REQUIRES_SUSPENDED) { + byte[] gtrid = findSessionlessTransactionState().flatMap(OracleSessionlessTransactionState::getGtrid) + .orElseThrow(() -> new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume")); + super.doBegin(status); + resume(unwrapRequiredOracleForBegin(status.getConnection()), gtrid); + } else { + super.doBegin(status); } } @@ -95,32 +111,21 @@ protected void doRollback(DefaultTransactionStatus status) { } } - private static void startSessionlessTransaction(Connection connection, TransactionDefinition definition) { - byte[] gtrid = startTransaction(unwrapRequiredOracleForBegin(connection), getTimeoutSeconds(definition)); - propagateSessionlessTransactionId(new OracleSessionlessTransactionId(gtrid)); - } - - private static void resumeSessionlessTransaction(Connection connection) { - OracleSessionlessTransactionId element = findSessionlessTransactionId() - .orElseThrow(() -> new CannotCreateTransactionException("No Oracle sessionless transaction id found to resume")); - resume(unwrapRequiredOracleForBegin(connection), element.gtrid()); - } - private void commitResumedSessionlessTransaction(DefaultTransactionStatus status) { - Optional transactionId = findSessionlessTransactionId(); + Optional state = findSessionlessTransactionState(); try { super.doCommit(status); } finally { - transactionId.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); + state.ifPresent(OracleSessionlessTransactionState::clearGtrid); } } private void rollbackResumedSessionlessTransaction(DefaultTransactionStatus status) { - Optional transactionId = findSessionlessTransactionId(); + Optional state = findSessionlessTransactionState(); try { super.doRollback(status); } finally { - transactionId.ifPresent(OracleSessionlessTransactionManager::clearSessionlessTransactionId); + state.ifPresent(OracleSessionlessTransactionState::clearGtrid); } } @@ -194,17 +199,7 @@ private static void resume(OracleConnection oracle, byte[] gtrid) { } } - private static Optional findSessionlessTransactionId() { - return OracleSessionlessTransactionId.find(); - } - - private static void propagateSessionlessTransactionId(OracleSessionlessTransactionId transactionId) { - OracleSessionlessTransactionId.withoutExisting(PropagatedContext.getOrEmpty()) - .plus(transactionId) - .propagate(); - } - - private static void clearSessionlessTransactionId(OracleSessionlessTransactionId transactionId) { - PropagatedContext.getOrEmpty().minus(transactionId).propagate(); + private static Optional findSessionlessTransactionState() { + return OracleSessionlessTransactionState.current(); } } 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 new file mode 100644 index 00000000000..613b7fa310f --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionPropagationOperations.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; + +import io.micronaut.core.annotation.Experimental; +import org.jspecify.annotations.Nullable; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Operations for non-HTTP propagation of Oracle sessionless transaction identifiers. + * + *

The HTTP server filter installs the same propagation state for HTTP requests. Code running outside + * HTTP can use this API to create an equivalent propagation scope and exchange encoded transaction + * identifiers with other transports.

+ * + * @since 5.0.1 + */ +@Experimental +public interface OracleSessionlessTransactionPropagationOperations { + + /** + * Executes the supplier with an empty Oracle sessionless transaction propagation state. + * + * @param supplier The supplier to execute + * @param The result type + * @return The supplier result + */ + T withPropagation(Supplier supplier); + + /** + * Executes the supplier with an Oracle sessionless transaction identifier already available to resume. + * + * @param encodedTransactionId The Base64 URL-safe encoded transaction identifier + * @param supplier The supplier to execute + * @param The result type + * @return The supplier result + */ + T withPropagation(String encodedTransactionId, Supplier supplier); + + /** + * @return The current Base64 URL-safe encoded transaction identifier, if one is available + */ + Optional currentTransactionId(); + + /** + * Replaces the current transaction identifier in the active propagation state. + * + * @param encodedTransactionId The Base64 URL-safe encoded transaction identifier + */ + void setTransactionId(String encodedTransactionId); + + /** + * Clears the current transaction identifier from the active propagation state. + */ + void clearTransactionId(); +} diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionId.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java similarity index 53% rename from data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionId.java rename to data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java index 9d233ce5df7..2feae379ed5 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionId.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java @@ -17,8 +17,8 @@ import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.core.propagation.PropagatedContextElement; +import org.jspecify.annotations.Nullable; -import java.util.Arrays; import java.util.Base64; import java.util.Objects; import java.util.Optional; @@ -26,62 +26,70 @@ /** * Propagated Oracle sessionless transaction state. */ -final class OracleSessionlessTransactionId implements PropagatedContextElement { +final class OracleSessionlessTransactionState implements PropagatedContextElement { private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); - private final byte[] gtrid; + private byte @Nullable [] gtrid; - OracleSessionlessTransactionId(byte[] gtrid) { - this.gtrid = Objects.requireNonNull(gtrid, "gtrid").clone(); + Optional getGtrid() { + return Optional.ofNullable(gtrid).map(byte[]::clone); } - byte[] gtrid() { - return gtrid.clone(); + void setGtrid(byte[] gtrid) { + this.gtrid = copy(gtrid); } - String encode() { + boolean setGtridIfAbsent(byte[] gtrid) { + if (this.gtrid != null) { + return false; + } + this.gtrid = copy(gtrid); + return true; + } + + Optional getEncodedGtrid() { + return getGtrid().map(OracleSessionlessTransactionState::encodeGtrid); + } + + void setEncodedGtrid(String encodedGtrid) { + setGtrid(decodeGtrid(encodedGtrid)); + } + + void clearGtrid() { + gtrid = null; + } + + static String encodeGtrid(byte[] gtrid) { return ENCODER.encodeToString(gtrid); } - static OracleSessionlessTransactionId decode(String value) { - byte[] decoded = DECODER.decode(value); + static byte[] decodeGtrid(String encodedGtrid) { + byte[] decoded = DECODER.decode(encodedGtrid); if (decoded.length == 0) { throw new IllegalArgumentException("Oracle sessionless transaction id cannot be empty"); } - return new OracleSessionlessTransactionId(decoded); + return decoded; } - static Optional find() { + static Optional current() { return find(PropagatedContext.getOrEmpty()); } - static Optional find(PropagatedContext context) { - return context.findAll(OracleSessionlessTransactionId.class).findFirst(); + static Optional find(PropagatedContext context) { + return context.findAll(OracleSessionlessTransactionState.class).findFirst(); } static PropagatedContext withoutExisting(PropagatedContext context) { PropagatedContext current = context; - for (OracleSessionlessTransactionId element : context.findAll(OracleSessionlessTransactionId.class).toList()) { + for (OracleSessionlessTransactionState element : context.findAll(OracleSessionlessTransactionState.class).toList()) { current = current.minus(element); } return current; } - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (!(o instanceof OracleSessionlessTransactionId that)) { - return false; - } - return Arrays.equals(gtrid, that.gtrid); - } - - @Override - public int hashCode() { - return Arrays.hashCode(gtrid); + private static byte[] copy(byte[] gtrid) { + return Objects.requireNonNull(gtrid, "gtrid").clone(); } } diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java index c037c7575bf..a160c026966 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java @@ -16,9 +16,10 @@ /** * Oracle-specific JDBC transaction support. * - *

Includes sessionless transaction integration for Oracle JDBC connections, with transaction identifiers - * propagated through Micronaut's {@link io.micronaut.core.propagation.PropagatedContext} and, when enabled, - * through HTTP request and response headers.

+ *

Includes sessionless transaction integration for Oracle JDBC connections. Transaction identifiers + * are held in Micronaut's {@link io.micronaut.core.propagation.PropagatedContext}, installed automatically + * for HTTP requests when HTTP propagation is enabled, or programmatically through + * {@link io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations}.

*/ @NullMarked package io.micronaut.transaction.jdbc.oracle; From 485f332a68736d8277cc30dd0c9e1edd79b3c3be Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 11:06:59 +0200 Subject: [PATCH 08/32] Oracle sessionless transaction impl - wip --- .../build.gradle | 8 ++- .../src/main/java/example/Application.java | 10 +++ .../main/java/example/BookingController.java | 27 ++++++++ .../src/main/resources/application.yml | 5 ++ .../java/example/BookingControllerTest.java | 65 +++++++++++++++++++ .../test/java/example/BookingServiceTest.java | 31 ++++++--- 6 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Application.java create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingController.java create mode 100644 doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle b/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle index eaf2cd1d06c..f2ba1559da4 100644 --- a/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/build.gradle @@ -2,6 +2,10 @@ plugins { id "io.micronaut.build.internal.data-native-example" } +application { + mainClass = "example.Application" +} + micronaut { version libs.versions.micronaut.platform.get() runtime "netty" @@ -14,9 +18,11 @@ micronaut { dependencies { annotationProcessor projects.micronautDataProcessor annotationProcessor mnValidation.micronaut.validation - implementation projects.micronautDataJdbc implementation mnValidation.micronaut.validation + implementation mnSerde.micronaut.serde.jackson + + testImplementation mn.micronaut.http.client runtimeOnly mnSql.micronaut.jdbc.tomcat runtimeOnly mnSql.ojdbc11 diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Application.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Application.java new file mode 100644 index 00000000000..9cd8e9cc2fa --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/Application.java @@ -0,0 +1,10 @@ +package example; + +import io.micronaut.runtime.Micronaut; + +public class Application { + + public static void main(String[] args) { + Micronaut.run(Application.class); + } +} diff --git a/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingController.java b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingController.java new file mode 100644 index 00000000000..1f385c2c5b1 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/main/java/example/BookingController.java @@ -0,0 +1,27 @@ +package example; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; + +@Controller("/bookings") +public class BookingController { + + private final BookingService bookingService; + + public BookingController(BookingService bookingService) { + this.bookingService = bookingService; + } + + @Post("/hold/{flightId}/{seatId}/{customerId}") + public HttpResponse holdSeat(String flightId, String seatId, String customerId) { + Long seatIdValue = bookingService.holdSeat(new Seat(flightId, seatId, customerId)); + return HttpResponse.ok(seatIdValue.toString()); + } + + @Post("/ticket/{id}") + public HttpResponse ticketSeat(Long id) { + bookingService.ticketSeat(id); + return HttpResponse.noContent(); + } +} 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 bca8af4a5d4..56b201e4caa 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 @@ -1,6 +1,11 @@ micronaut: application: name: jdbc-sessionless-transaction-booking-java + data: + oracle: + sessionless: + http: + enabled: true datasources: default: 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 new file mode 100644 index 00000000000..72d10d4bcff --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking-java/src/test/java/example/BookingControllerTest.java @@ -0,0 +1,65 @@ +package example; + +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +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; + +@Property(name = "micronaut.http.client.read-timeout", value = "600") +@MicronautTest(transactional = false) +public class BookingControllerTest { + + private static final String SESSIONLESS_TRANSACTION_HEADER = "Oracle-Sessionless-Transaction-Id"; + + @Inject + @Client("/") + HttpClient client; + + @Inject + SeatRepository seatRepository; + + @BeforeEach + void cleanUp() { + seatRepository.deleteAll(); + } + + @Test + void testTransactionSuspendedAndResumedOverHttp() { + HttpResponse holdResponse = client.toBlocking() + .exchange(HttpRequest.POST("/bookings/hold/JU501/2c/msid", ""), String.class); + + assertEquals(HttpStatus.OK, holdResponse.getStatus()); + String transactionId = holdResponse.getHeaders().get(SESSIONLESS_TRANSACTION_HEADER); + assertNotNull(transactionId); + Long seatId = Long.valueOf(holdResponse.getBody().orElseThrow()); + + List seats = seatRepository.findAll(); + assertTrue(CollectionUtils.isEmpty(seats)); + + HttpRequest ticketRequest = HttpRequest.POST("/bookings/ticket/" + seatId, "") + .header(SESSIONLESS_TRANSACTION_HEADER, transactionId); + HttpResponse ticketResponse = client.toBlocking().exchange(ticketRequest, Void.class); + + assertEquals(HttpStatus.NO_CONTENT, ticketResponse.getStatus()); + assertFalse(ticketResponse.getHeaders().contains(SESSIONLESS_TRANSACTION_HEADER)); + + seats = seatRepository.findAll(); + assertFalse(CollectionUtils.isEmpty(seats)); + assertEquals(1, seats.size()); + assertEquals("TICKETED", seats.getFirst().getStatus()); + } +} 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 f29c1319a02..51c95782961 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 @@ -2,7 +2,9 @@ import io.micronaut.core.util.CollectionUtils; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations; import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; @@ -20,19 +22,30 @@ public class BookingServiceTest { @Inject SeatRepository seatRepository; + @Inject + OracleSessionlessTransactionPropagationOperations transactionPropagationOperations; + + @BeforeEach + void cleanUp() { + seatRepository.deleteAll(); + } + @Test void testTransactionResumed() { - Seat seat = new Seat("JU501", "2c", "msid"); - Long seatId = bookingService.holdSeat(seat); + transactionPropagationOperations.withPropagation(() -> { + Seat seat = new Seat("JU501", "2c", "msid"); + Long seatId = bookingService.holdSeat(seat); - List seats = seatRepository.findAll(); - assertTrue(CollectionUtils.isEmpty(seats)); + List seats = seatRepository.findAll(); + assertTrue(CollectionUtils.isEmpty(seats)); - bookingService.ticketSeat(seatId); + bookingService.ticketSeat(seatId); - seats = seatRepository.findAll(); - assertFalse(CollectionUtils.isEmpty(seats)); - assertEquals(1, seats.size()); - assertEquals("TICKETED", seats.getFirst().getSeatId()); + seats = seatRepository.findAll(); + assertFalse(CollectionUtils.isEmpty(seats)); + assertEquals(1, seats.size()); + assertEquals("TICKETED", seats.getFirst().getStatus()); + return null; + }); } } From 8a7c815151acf9b77b9b25d3b766a1f851c5e9a0 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 12:07:47 +0200 Subject: [PATCH 09/32] Oracle sessionless transaction impl - wip --- ...ltOracleSessionlessTransactionIdCodec.java | 51 +++++++++++++++++++ ...nlessTransactionPropagationOperations.java | 12 +++-- ...essionlessTransactionHttpServerFilter.java | 10 ++-- .../OracleSessionlessTransactionIdCodec.java | 49 ++++++++++++++++++ ...nlessTransactionPropagationOperations.java | 6 +-- .../OracleSessionlessTransactionState.java | 24 --------- .../transaction/jdbc/oracle/package-info.java | 4 +- 7 files changed, 122 insertions(+), 34 deletions(-) create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionIdCodec.java create mode 100644 data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodec.java diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionIdCodec.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionIdCodec.java new file mode 100644 index 00000000000..0e67b8574a1 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionIdCodec.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; + +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; + +import java.util.Base64; +import java.util.Objects; + +/** + * Default Oracle sessionless transaction id codec. + */ +@Singleton +@Requires(missingBeans = OracleSessionlessTransactionIdCodec.class) +final class DefaultOracleSessionlessTransactionIdCodec implements OracleSessionlessTransactionIdCodec { + + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + + @Override + public String encode(byte[] gtrid) { + byte[] value = Objects.requireNonNull(gtrid, "gtrid"); + if (value.length == 0) { + throw new IllegalArgumentException("Oracle sessionless transaction id cannot be empty"); + } + return ENCODER.encodeToString(value); + } + + @Override + public byte[] decode(String encodedTransactionId) { + byte[] decoded = DECODER.decode(Objects.requireNonNull(encodedTransactionId, "encodedTransactionId")); + if (decoded.length == 0) { + throw new IllegalArgumentException("Oracle sessionless transaction id cannot be empty"); + } + return decoded; + } +} 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 1403f3fc567..a0382818f1a 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 @@ -30,6 +30,12 @@ @Requires(classes = OracleConnection.class) final class DefaultOracleSessionlessTransactionPropagationOperations implements OracleSessionlessTransactionPropagationOperations { + private final OracleSessionlessTransactionIdCodec transactionIdCodec; + + DefaultOracleSessionlessTransactionPropagationOperations(OracleSessionlessTransactionIdCodec transactionIdCodec) { + this.transactionIdCodec = transactionIdCodec; + } + @Override public T withPropagation(Supplier supplier) { return withPropagation(new OracleSessionlessTransactionState(), supplier); @@ -38,19 +44,19 @@ final class DefaultOracleSessionlessTransactionPropagationOperations implements @Override public T withPropagation(String encodedTransactionId, Supplier supplier) { OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); - state.setEncodedGtrid(encodedTransactionId); + state.setGtrid(transactionIdCodec.decode(encodedTransactionId)); return withPropagation(state, supplier); } @Override public Optional currentTransactionId() { return currentState() - .flatMap(OracleSessionlessTransactionState::getEncodedGtrid); + .flatMap(state -> state.getGtrid().map(transactionIdCodec::encode)); } @Override public void setTransactionId(String encodedTransactionId) { - requiredState().setEncodedGtrid(encodedTransactionId); + requiredState().setGtrid(transactionIdCodec.decode(encodedTransactionId)); } @Override 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 7ccc650e75f..2ddbc00e9c9 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 @@ -39,9 +39,12 @@ final class OracleSessionlessTransactionHttpServerFilter { private final OracleSessionlessTransactionHttpConfiguration configuration; + private final OracleSessionlessTransactionIdCodec transactionIdCodec; - OracleSessionlessTransactionHttpServerFilter(OracleSessionlessTransactionHttpConfiguration configuration) { + OracleSessionlessTransactionHttpServerFilter(OracleSessionlessTransactionHttpConfiguration configuration, + OracleSessionlessTransactionIdCodec transactionIdCodec) { this.configuration = configuration; + this.transactionIdCodec = transactionIdCodec; } @RequestFilter @@ -50,7 +53,7 @@ void readTransactionId(HttpRequest request, MutablePropagatedContext mutableP Optional value = request.getHeaders().findFirst(configuration.getHeaderName()); if (value.isPresent()) { try { - state.setEncodedGtrid(value.get()); + state.setGtrid(transactionIdCodec.decode(value.get())); } catch (IllegalArgumentException e) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); } @@ -64,7 +67,8 @@ void writeTransactionId(MutableHttpResponse response, MutablePropagatedContex if (propagatedContext != null) { propagatedContext.findAll(OracleSessionlessTransactionState.class) .findFirst() - .flatMap(OracleSessionlessTransactionState::getEncodedGtrid) + .flatMap(OracleSessionlessTransactionState::getGtrid) + .map(transactionIdCodec::encode) .ifPresent(transactionId -> response.getHeaders().set(configuration.getHeaderName(), transactionId)); } } 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 new file mode 100644 index 00000000000..d7a1cb7e935 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionIdCodec.java @@ -0,0 +1,49 @@ +/* + * Copyright 2017-2026 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.transaction.jdbc.oracle; + +import io.micronaut.core.annotation.Experimental; + +/** + * Converts Oracle sessionless transaction identifiers between the JDBC binary representation and + * an external string representation suitable for transport propagation. + * + *

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 + */ +@Experimental +public interface OracleSessionlessTransactionIdCodec { + + /** + * Encodes a non-empty Oracle sessionless transaction identifier. + * + * @param gtrid The Oracle global transaction identifier + * @return The encoded transaction identifier + * @throws IllegalArgumentException If the identifier cannot be encoded + */ + String encode(byte[] gtrid); + + /** + * Decodes an encoded Oracle sessionless transaction identifier. + * + * @param encodedTransactionId The encoded transaction identifier + * @return The Oracle global transaction identifier + * @throws IllegalArgumentException If the value cannot be decoded + */ + byte[] decode(String encodedTransactionId); +} 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 613b7fa310f..1f7b72d5a43 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 @@ -45,7 +45,7 @@ public interface OracleSessionlessTransactionPropagationOperations { /** * Executes the supplier with an Oracle sessionless transaction identifier already available to resume. * - * @param encodedTransactionId The Base64 URL-safe encoded transaction identifier + * @param encodedTransactionId The transaction identifier encoded by {@link OracleSessionlessTransactionIdCodec} * @param supplier The supplier to execute * @param The result type * @return The supplier result @@ -53,14 +53,14 @@ public interface OracleSessionlessTransactionPropagationOperations { T withPropagation(String encodedTransactionId, Supplier supplier); /** - * @return The current Base64 URL-safe encoded transaction identifier, if one is available + * @return The current transaction identifier encoded by {@link OracleSessionlessTransactionIdCodec}, if one is available */ Optional currentTransactionId(); /** * Replaces the current transaction identifier in the active propagation state. * - * @param encodedTransactionId The Base64 URL-safe encoded transaction identifier + * @param encodedTransactionId The transaction identifier encoded by {@link OracleSessionlessTransactionIdCodec} */ void setTransactionId(String encodedTransactionId); diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java index 2feae379ed5..48662a98b39 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java @@ -19,7 +19,6 @@ import io.micronaut.core.propagation.PropagatedContextElement; import org.jspecify.annotations.Nullable; -import java.util.Base64; import java.util.Objects; import java.util.Optional; @@ -28,9 +27,6 @@ */ final class OracleSessionlessTransactionState implements PropagatedContextElement { - private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); - private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); - private byte @Nullable [] gtrid; Optional getGtrid() { @@ -49,30 +45,10 @@ boolean setGtridIfAbsent(byte[] gtrid) { return true; } - Optional getEncodedGtrid() { - return getGtrid().map(OracleSessionlessTransactionState::encodeGtrid); - } - - void setEncodedGtrid(String encodedGtrid) { - setGtrid(decodeGtrid(encodedGtrid)); - } - void clearGtrid() { gtrid = null; } - static String encodeGtrid(byte[] gtrid) { - return ENCODER.encodeToString(gtrid); - } - - static byte[] decodeGtrid(String encodedGtrid) { - byte[] decoded = DECODER.decode(encodedGtrid); - if (decoded.length == 0) { - throw new IllegalArgumentException("Oracle sessionless transaction id cannot be empty"); - } - return decoded; - } - static Optional current() { return find(PropagatedContext.getOrEmpty()); } diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java index a160c026966..f0369e05673 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java @@ -19,7 +19,9 @@ *

Includes sessionless transaction integration for Oracle JDBC connections. Transaction identifiers * are held in Micronaut's {@link io.micronaut.core.propagation.PropagatedContext}, installed automatically * for HTTP requests when HTTP propagation is enabled, or programmatically through - * {@link io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations}.

+ * {@link io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionPropagationOperations}. The external + * string representation is handled by {@link io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionIdCodec}, + * which applications may replace to add signing, encryption, or another transport encoding.

*/ @NullMarked package io.micronaut.transaction.jdbc.oracle; From c0a3c6a9b9d77b3dcec96c6d4e271d9bf436b0d6 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 13:47:22 +0200 Subject: [PATCH 10/32] Oracle sessionless transaction impl - wip --- ...eSessionlessTransactionPropagationOperations.java | 3 --- ...racleSessionlessTransactionHttpConfiguration.java | 12 ++++++------ ...OracleSessionlessTransactionHttpServerFilter.java | 2 +- .../oracle/OracleSessionlessTransactionManager.java | 2 -- .../transaction/jdbc/oracle/package-info.java | 5 +++++ .../src/main/resources/application.yml | 2 +- 6 files changed, 13 insertions(+), 13 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 a0382818f1a..a738e6cd0a5 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 @@ -15,11 +15,9 @@ */ package io.micronaut.transaction.jdbc.oracle; -import io.micronaut.context.annotation.Requires; import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.transaction.exceptions.TransactionUsageException; import jakarta.inject.Singleton; -import oracle.jdbc.OracleConnection; import org.jspecify.annotations.Nullable; import java.util.Objects; @@ -27,7 +25,6 @@ import java.util.function.Supplier; @Singleton -@Requires(classes = OracleConnection.class) final class DefaultOracleSessionlessTransactionPropagationOperations implements OracleSessionlessTransactionPropagationOperations { private final OracleSessionlessTransactionIdCodec transactionIdCodec; 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 7b2362555a1..d4b51094a9f 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 @@ -36,23 +36,23 @@ public class OracleSessionlessTransactionHttpConfiguration { */ public static final String DEFAULT_HEADER_NAME = "Oracle-Sessionless-Transaction-Id"; - private boolean enabled; + private boolean propagationEnabled; private String headerName = DEFAULT_HEADER_NAME; /** * @return Whether HTTP propagation of Oracle sessionless transaction ids is enabled. */ - public boolean isEnabled() { - return enabled; + public boolean isPropagationEnabled() { + return propagationEnabled; } /** * Sets whether HTTP propagation of Oracle sessionless transaction ids is enabled. * - * @param enabled Whether HTTP propagation is enabled + * @param propagationEnabled Whether HTTP propagation is enabled */ - public void setEnabled(boolean enabled) { - this.enabled = enabled; + public void setPropagationEnabled(boolean propagationEnabled) { + this.propagationEnabled = propagationEnabled; } /** 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 2ddbc00e9c9..b6b0b35a364 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 @@ -35,7 +35,7 @@ */ @ServerFilter(ServerFilter.MATCH_ALL_PATTERN) @Requires(classes = {HttpRequest.class, MutableHttpResponse.class}) -@Requires(property = OracleSessionlessTransactionHttpConfiguration.PREFIX + ".enabled", value = StringUtils.TRUE) +@Requires(property = OracleSessionlessTransactionHttpConfiguration.PREFIX + ".propagation-enabled", value = StringUtils.TRUE) final class OracleSessionlessTransactionHttpServerFilter { private final OracleSessionlessTransactionHttpConfiguration configuration; 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 b2efe6a3bcf..6dad36a2421 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.context.annotation.Requires; import io.micronaut.core.annotation.Internal; import io.micronaut.data.connection.ConnectionOperations; import io.micronaut.data.connection.SynchronousConnectionManager; @@ -45,7 +44,6 @@ */ @Internal @EachBean(DataSource.class) -@Requires(classes = OracleConnection.class) @Replaces(DataSourceTransactionManager.class) public class OracleSessionlessTransactionManager extends DataSourceTransactionManager { diff --git a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java index f0369e05673..799b21b89c1 100644 --- a/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java @@ -23,7 +23,12 @@ * string representation is handled by {@link io.micronaut.transaction.jdbc.oracle.OracleSessionlessTransactionIdCodec}, * which applications may replace to add signing, encryption, or another transport encoding.

*/ +@Configuration +@Requires(classes = OracleConnection.class) @NullMarked package io.micronaut.transaction.jdbc.oracle; +import io.micronaut.context.annotation.Configuration; +import io.micronaut.context.annotation.Requires; +import oracle.jdbc.OracleConnection; import org.jspecify.annotations.NullMarked; 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 56b201e4caa..cc99f087e76 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 @@ -5,7 +5,7 @@ micronaut: oracle: sessionless: http: - enabled: true + propagation-enabled: true datasources: default: From 666e7d456d2bcb64b608911794e89eb6c42c8f51 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 14:00:17 +0200 Subject: [PATCH 11/32] Oracle sessionless transaction impl - wip --- ...onlessTransactionPropagationOperations.java | 18 +++++------------- 1 file changed, 5 insertions(+), 13 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 a738e6cd0a5..5c6abb957e9 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 @@ -47,18 +47,20 @@ final class DefaultOracleSessionlessTransactionPropagationOperations implements @Override public Optional currentTransactionId() { - return currentState() + return OracleSessionlessTransactionState.current() .flatMap(state -> state.getGtrid().map(transactionIdCodec::encode)); } @Override public void setTransactionId(String encodedTransactionId) { - requiredState().setGtrid(transactionIdCodec.decode(encodedTransactionId)); + OracleSessionlessTransactionState.current().orElseThrow(() -> + new TransactionUsageException("Oracle sessionless transaction propagation is not active") + ).setGtrid(transactionIdCodec.decode(encodedTransactionId)); } @Override public void clearTransactionId() { - currentState().ifPresent(OracleSessionlessTransactionState::clearGtrid); + OracleSessionlessTransactionState.current().ifPresent(OracleSessionlessTransactionState::clearGtrid); } private static T withPropagation(OracleSessionlessTransactionState state, @@ -69,14 +71,4 @@ public void clearTransactionId() { .plus(state); return context.propagate(supplier); } - - private static Optional currentState() { - return OracleSessionlessTransactionState.current(); - } - - private static OracleSessionlessTransactionState requiredState() { - return currentState().orElseThrow(() -> - new TransactionUsageException("Oracle sessionless transaction propagation is not active") - ); - } } From 2a7b77aa906e4852d0f3b5d514f0a5b1da402578 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 14:26:56 +0200 Subject: [PATCH 12/32] Oracle sessionless transaction impl - wip --- ...nlessTransactionPropagationOperations.java | 3 +- .../test/java/example/BookingServiceTest.java | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) 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 5c6abb957e9..872780e8e6b 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 @@ -35,7 +35,8 @@ final class DefaultOracleSessionlessTransactionPropagationOperations implements @Override public T withPropagation(Supplier supplier) { - return withPropagation(new OracleSessionlessTransactionState(), supplier); + OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); + return withPropagation(state, supplier); } @Override 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 51c95782961..8d538b7b92c 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 @@ -48,4 +48,52 @@ void testTransactionResumed() { return null; }); } + + @Test + void testCurrentTransactionIdExportsSuspendedTransactionId() { + SuspendedSeat suspendedSeat = 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()); + return null; + }); + + assertTicketedSeat(); + } + + @Test + void testSetTransactionIdImportsIntoActivePropagationState() { + SuspendedSeat suspendedSeat = 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()); + + transactionPropagationOperations.setTransactionId(suspendedSeat.transactionId()); + assertEquals(suspendedSeat.transactionId(), transactionPropagationOperations.currentTransactionId().orElseThrow()); + + bookingService.ticketSeat(suspendedSeat.seatId()); + assertTrue(transactionPropagationOperations.currentTransactionId().isEmpty()); + return null; + }); + + assertTicketedSeat(); + } + + private void assertTicketedSeat() { + List seats = seatRepository.findAll(); + assertFalse(CollectionUtils.isEmpty(seats)); + assertEquals(1, seats.size()); + assertEquals("TICKETED", seats.getFirst().getStatus()); + } + + private record SuspendedSeat(Long seatId, String transactionId) { + } } From c4090f102e11641caad60482aba595015c210928 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 25 May 2026 15:56:49 +0200 Subject: [PATCH 13/32] Oracle sessionless transaction impl - wip --- ...essionlessTransactionHttpServerFilter.java | 28 ++++++++----------- .../transaction/TransactionDefinition.java | 23 +++++++++++++++ .../test/java/example/BookingServiceTest.java | 9 +++--- 3 files changed, 39 insertions(+), 21 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 b6b0b35a364..ba419061a5f 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 @@ -27,7 +27,6 @@ import io.micronaut.http.annotation.ServerFilter; import io.micronaut.http.exceptions.HttpStatusException; -import java.util.List; import java.util.Optional; /** @@ -49,16 +48,23 @@ final class OracleSessionlessTransactionHttpServerFilter { @RequestFilter void readTransactionId(HttpRequest request, MutablePropagatedContext mutablePropagatedContext) { + PropagatedContext propagatedContext = mutablePropagatedContext.getContext(); + if (propagatedContext != null && OracleSessionlessTransactionState.find(propagatedContext).isPresent()) { + throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Oracle sessionless transaction state already exists"); + } + OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); - Optional value = request.getHeaders().findFirst(configuration.getHeaderName()); - if (value.isPresent()) { + + Optional encodedTransactionId = request.getHeaders().findFirst(configuration.getHeaderName()); + if (encodedTransactionId.isPresent()) { try { - state.setGtrid(transactionIdCodec.decode(value.get())); + state.setGtrid(transactionIdCodec.decode(encodedTransactionId.get())); } catch (IllegalArgumentException e) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); } } - replaceTransactionState(mutablePropagatedContext, state); + + mutablePropagatedContext.add(state); } @ResponseFilter @@ -72,16 +78,4 @@ void writeTransactionId(MutableHttpResponse response, MutablePropagatedContex .ifPresent(transactionId -> response.getHeaders().set(configuration.getHeaderName(), transactionId)); } } - - private static void replaceTransactionState(MutablePropagatedContext mutablePropagatedContext, - OracleSessionlessTransactionState transactionState) { - PropagatedContext propagatedContext = mutablePropagatedContext.getContext(); - if (propagatedContext != null) { - List 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