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..591f4d10fb5 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/sessionless/OracleSessionlessTransactionPropagationSpec.groovy @@ -0,0 +1,304 @@ +/* + * 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.annotation.OracleTransactional +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 + } + + @OracleTransactional(sessionless = OracleTransactional.Sessionless.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 + } + + @OracleTransactional(sessionless = OracleTransactional.Sessionless.REQUIRES_SUSPENDED) + void approveReport(Long id) { + expenseReportRepository.updateStatus(id, "APPROVED") + } + + @OracleTransactional(sessionless = OracleTransactional.Sessionless.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) + } +} 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..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 @@ -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.validateOracleSessionlessMode(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/build.gradle b/data-tx-jdbc/build.gradle index fd1fd18d1be..ae576fd1f3f 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,8 +21,10 @@ dependencies { testImplementation projects.micronautDataProcessor testImplementation mn.micronaut.inject.java.test + 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/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/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/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 new file mode 100644 index 00000000000..264d6c38f94 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/DefaultOracleSessionlessTransactionPropagationOperations.java @@ -0,0 +1,103 @@ +/* + * 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.transaction.annotation.OracleTransactional; +import io.micronaut.transaction.exceptions.TransactionUsageException; +import jakarta.inject.Singleton; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +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 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.

+ */ +@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; + } + + @Override + public T withPropagation(Supplier supplier) { + OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); + return withPropagation(state, supplier); + } + + @Override + public T withPropagation(String encodedTransactionId, Supplier supplier) { + OracleSessionlessTransactionState state = new OracleSessionlessTransactionState(); + state.setGtrid(transactionIdCodec.decode(encodedTransactionId)); + return withPropagation(state, supplier); + } + + @Override + public Optional currentTransactionId() { + return OracleSessionlessTransactionState.current() + .flatMap(state -> state.getGtrid().map(transactionIdCodec::encode)); + } + + @Override + public void setTransactionId(String encodedTransactionId) { + OracleSessionlessTransactionState.current().orElseThrow(() -> + new TransactionUsageException("Oracle sessionless transaction propagation is not active") + ).setGtrid(transactionIdCodec.decode(encodedTransactionId)); + } + + @Override + 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"); + PropagatedContext context = OracleSessionlessTransactionState + .withoutExisting(PropagatedContext.getOrEmpty()) + .plus(state); + return context.propagate(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 new file mode 100644 index 00000000000..a9af2463d7b --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpConfiguration.java @@ -0,0 +1,73 @@ +/* + * 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.util.StringUtils; + +/** + * Configuration for HTTP propagation of Oracle sessionless transaction ids. + */ +@ConfigurationProperties(OracleSessionlessTransactionHttpConfiguration.PREFIX) +final 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 propagationEnabled; + private String headerName = DEFAULT_HEADER_NAME; + + /** + * @return Whether HTTP propagation of Oracle sessionless transaction ids is enabled. + */ + public boolean isPropagationEnabled() { + return propagationEnabled; + } + + /** + * Sets whether HTTP propagation of Oracle sessionless transaction ids is enabled. + * + * @param propagationEnabled Whether HTTP propagation is enabled + */ + public void setPropagationEnabled(boolean propagationEnabled) { + this.propagationEnabled = propagationEnabled; + } + + /** + * @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..f4894635379 --- /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.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.Optional; + +/** + * Bridges Oracle sessionless transaction ids between HTTP headers and propagated context. + */ +@ServerFilter(ServerFilter.MATCH_ALL_PATTERN) +@Requires(classes = {HttpRequest.class, MutableHttpResponse.class}) +@Requires(property = OracleSessionlessTransactionHttpConfiguration.PREFIX + ".propagation-enabled", value = StringUtils.TRUE) +final class OracleSessionlessTransactionHttpServerFilter { + + private final OracleSessionlessTransactionHttpConfiguration configuration; + private final OracleSessionlessTransactionIdCodec transactionIdCodec; + + OracleSessionlessTransactionHttpServerFilter(OracleSessionlessTransactionHttpConfiguration configuration, + OracleSessionlessTransactionIdCodec transactionIdCodec) { + this.configuration = configuration; + this.transactionIdCodec = transactionIdCodec; + } + + @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 encodedTransactionId = request.getHeaders().findFirst(configuration.getHeaderName()); + if (encodedTransactionId.isPresent()) { + try { + state.setGtrid(transactionIdCodec.decode(encodedTransactionId.get())); + } catch (IllegalArgumentException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid Oracle sessionless transaction id"); + } + } + + mutablePropagatedContext.add(state); + } + + @ResponseFilter + void writeTransactionId(MutableHttpResponse response, MutablePropagatedContext mutablePropagatedContext) { + PropagatedContext propagatedContext = mutablePropagatedContext.getContext(); + if (propagatedContext != null) { + 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/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..e25ebca53f9 --- /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.1.0 + */ +@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/OracleSessionlessTransactionManager.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java new file mode 100644 index 00000000000..1b184618eb3 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManager.java @@ -0,0 +1,213 @@ +/* + * 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.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; +import org.jspecify.annotations.Nullable; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Oracle JDBC transaction manager with sessionless transaction propagation support. + */ +@EachBean(DataSource.class) +@Replaces(DataSourceTransactionManager.class) +@Requires(condition = JdbcTransactionManagerCondition.class) +final 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) { + this(dataSource, connectionOperations, synchronousConnectionManager, Collections.emptyList()); + } + + @Override + protected boolean supportsOracleSessionlessTransactions() { + return true; + } + + @Override + protected void doBegin(DefaultTransactionStatus status) { + TransactionDefinition definition = status.getTransactionDefinition(); + 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"); + } + 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 (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); + resume(unwrapRequiredOracleForBegin(status.getConnection()), gtrid); + } else { + super.doBegin(status); + } + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + OracleTransactional.Sessionless sessionless = TransactionUtil.getOracleSessionlessMode(status.getTransactionDefinition()); + if (sessionless == OracleTransactional.Sessionless.SUSPEND) { + suspend(unwrapRequiredOracleForCompletion(status.getConnection())); + } else if (sessionless == OracleTransactional.Sessionless.REQUIRES_SUSPENDED) { + commitResumedSessionlessTransaction(status); + } else { + super.doCommit(status); + } + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + OracleTransactional.Sessionless sessionless = TransactionUtil.getOracleSessionlessMode(status.getTransactionDefinition()); + if (sessionless == OracleTransactional.Sessionless.SUSPEND + || sessionless == OracleTransactional.Sessionless.REQUIRES_SUSPENDED) { + rollbackSessionlessTransaction(status); + } else { + super.doRollback(status); + } + } + + private void commitResumedSessionlessTransaction(DefaultTransactionStatus status) { + Optional state = findSessionlessTransactionState(); + try { + super.doCommit(status); + } finally { + state.ifPresent(OracleSessionlessTransactionState::clearGtrid); + } + } + + private void rollbackSessionlessTransaction(DefaultTransactionStatus status) { + Optional state = findSessionlessTransactionState(); + try { + super.doRollback(status); + } finally { + state.ifPresent(OracleSessionlessTransactionState::clearGtrid); + } + } + + @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 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 { + return connection.unwrap(OracleConnection.class); + } catch (SQLException e) { + throw new TransactionSystemException("Oracle sessionless transactions require an Oracle JDBC connection", e); + } + } + + private static byte[] startTransaction(OracleConnection oracle, @Nullable Integer timeout) { + try { + 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); + } + } + + private static void suspend(OracleConnection oracle) { + try { + oracle.suspendTransactionImmediately(); + } catch (Exception immediateFailure) { + try { + oracle.suspendTransaction(); + } catch (Exception fallbackFailure) { + fallbackFailure.addSuppressed(immediateFailure); + throw new TransactionSystemException("Could not suspend Oracle sessionless transaction", fallbackFailure); + } + } + } + + private static void resume(OracleConnection oracle, byte[] gtrid) { + try { + oracle.resumeTransaction(gtrid); + } catch (Exception e) { + throw new TransactionSystemException("Could not resume Oracle sessionless transaction", e); + } + } + + 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..4bdf6080e54 --- /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.1.0 + */ +@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 transaction identifier encoded by {@link OracleSessionlessTransactionIdCodec} + * @param supplier The supplier to execute + * @param The result type + * @return The supplier result + */ + T withPropagation(String encodedTransactionId, Supplier supplier); + + /** + * @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 transaction identifier encoded by {@link OracleSessionlessTransactionIdCodec} + */ + 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/OracleSessionlessTransactionState.java b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.java new file mode 100644 index 00000000000..48662a98b39 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionState.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.propagation.PropagatedContext; +import io.micronaut.core.propagation.PropagatedContextElement; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +import java.util.Optional; + +/** + * Propagated Oracle sessionless transaction state. + */ +final class OracleSessionlessTransactionState implements PropagatedContextElement { + + private byte @Nullable [] gtrid; + + Optional getGtrid() { + return Optional.ofNullable(gtrid).map(byte[]::clone); + } + + void setGtrid(byte[] gtrid) { + this.gtrid = copy(gtrid); + } + + boolean setGtridIfAbsent(byte[] gtrid) { + if (this.gtrid != null) { + return false; + } + this.gtrid = copy(gtrid); + return true; + } + + void clearGtrid() { + gtrid = null; + } + + static Optional current() { + return find(PropagatedContext.getOrEmpty()); + } + + static Optional find(PropagatedContext context) { + return context.findAll(OracleSessionlessTransactionState.class).findFirst(); + } + + static PropagatedContext withoutExisting(PropagatedContext context) { + PropagatedContext current = context; + for (OracleSessionlessTransactionState element : context.findAll(OracleSessionlessTransactionState.class).toList()) { + current = current.minus(element); + } + return current; + } + + 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 new file mode 100644 index 00000000000..799b21b89c1 --- /dev/null +++ b/data-tx-jdbc/src/main/java/io/micronaut/transaction/jdbc/oracle/package-info.java @@ -0,0 +1,34 @@ +/* + * 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. 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}. 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.

+ */ +@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/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..e0cf62dea47 --- /dev/null +++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/DataSourceTransactionManagerSpec.groovy @@ -0,0 +1,65 @@ +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 mode before transactional work"(OracleTransactional.Sessionless mode) { + given: + def txManager = newTxManager() + + when: + txManager.execute(definition(mode), { status -> null }) + + then: + def e = thrown(TransactionSuspensionNotSupportedException) + e.message == "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support" + + where: + mode << [ + OracleTransactional.Sessionless.SUSPEND, + OracleTransactional.Sessionless.REQUIRES_SUSPENDED + ] + } + + def "plain JDBC manager rejects Oracle sessionless mode before programmatic transaction creation"(OracleTransactional.Sessionless mode) { + given: + def txManager = newTxManager() + + when: + txManager.getTransaction(definition(mode)) + + then: + def e = thrown(TransactionSuspensionNotSupportedException) + e.message == "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support" + + where: + mode << [ + OracleTransactional.Sessionless.SUSPEND, + OracleTransactional.Sessionless.REQUIRES_SUSPENDED + ] + } + + private DataSourceTransactionManager newTxManager() { + new DataSourceTransactionManager( + Mock(DataSource), + Mock(ConnectionOperations), + Mock(SynchronousConnectionManager) + ) + } + + 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/OracleSessionlessTransactionHttpServerFilterSpec.groovy b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy new file mode 100644 index 00000000000..b1a61679e6d --- /dev/null +++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionHttpServerFilterSpec.groovy @@ -0,0 +1,193 @@ +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 + OracleSessionlessTransactionState.find(context.context).isEmpty() + } + + 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()) + 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) + OracleSessionlessTransactionState.find(context.context).isEmpty() + } + + 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) + OracleSessionlessTransactionState.find(context.context).isEmpty() + } + + 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) + def state = OracleSessionlessTransactionState.find(context.context).orElseThrow() + filter.writeTransactionId(response, context) + + then: + 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"() { + 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..e8733aac462 --- /dev/null +++ b/data-tx-jdbc/src/test/groovy/io/micronaut/transaction/jdbc/oracle/OracleSessionlessTransactionManagerSpec.groovy @@ -0,0 +1,256 @@ +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 +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 + +import javax.sql.DataSource +import java.sql.Connection +import java.sql.SQLException +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) + def manager = newTransactionManager([listener]) + def connection = Mock(Connection) + def oracle = Mock(OracleConnection) + 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[] + 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(OracleTransactional.Sessionless.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(OracleTransactional.Sessionless.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(OracleTransactional.Sessionless.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(OracleTransactional.Sessionless.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(OracleTransactional.Sessionless.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 "suspended transaction context is cleared on rollback"() { + given: + def manager = newTransactionManager() + def connection = Mock(Connection) + def definition = definition(OracleTransactional.Sessionless.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(OracleTransactional.Sessionless.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() + def connection = Mock(Connection) + def definition = definition(OracleTransactional.Sessionless.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(OracleTransactional.Sessionless mode, + Duration timeout = null) { + 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-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..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 c1881f8d964..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 @@ -45,6 +45,13 @@ public abstract class AbstractPropagatedStatusTransactionOperations R doExecute(TransactionDefinition definition, TransactionCallback callback); + /** + * @return Whether this transaction manager supports Oracle sessionless transaction 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.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 3cd0613c6d3..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,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.validateOracleSessionlessMode(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 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.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 887889ff81b..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,6 +160,7 @@ public void afterCompletion(Status status) { @NonNull @Override public T getTransaction(TransactionDefinition definition) throws TransactionException { + TransactionUtil.validateOracleSessionlessMode(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..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 @@ -23,6 +23,8 @@ 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 io.micronaut.transaction.exceptions.TransactionUsageException; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -76,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; @@ -101,6 +108,48 @@ public static TransactionDefinition getTransactionDefinition(String name, Annota return null; } + /** + * Resolves Oracle sessionless transaction mode from a transaction definition. + * + * @param definition The transaction definition + * @return The Oracle sessionless transaction mode, or {@code null} if none is present + */ + 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( + "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'" + ); + } + } + private static OracleTransactional.Priority parseOraclePriority(String priority) { try { return OracleTransactional.Priority.valueOf(priority.trim().toUpperCase(Locale.ROOT)); @@ -109,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 9cb04ade272..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,6 +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; @@ -240,5 +243,33 @@ public void testTwoFluxJobsInOneTx() { } } + @Test + public void testReactiveTxRejectsOracleSessionlessPropagation() { + try (ApplicationContext applicationContext = ApplicationContext.run()) { + ReactiveTxManager txManager = applicationContext.getBean(ReactiveTxManager.class); + + assertUnsupportedReactiveOracleSessionlessMode(txManager, OracleTransactional.Sessionless.SUSPEND); + assertUnsupportedReactiveOracleSessionlessMode(txManager, OracleTransactional.Sessionless.REQUIRES_SUSPENDED); + } + } + + private static void assertUnsupportedReactiveOracleSessionlessMode(ReactiveTxManager txManager, + OracleTransactional.Sessionless mode) { + TransactionSuspensionNotSupportedException exception = Assertions.assertThrows( + TransactionSuspensionNotSupportedException.class, + () -> txManager.withTransactionMono(oracleSessionlessDefinition(mode), status -> Mono.just("ignored")) + ); + Assertions.assertEquals( + "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 6181890d99f..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,8 @@ 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; import io.micronaut.transaction.impl.DefaultTransactionStatus; @@ -36,6 +38,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 +138,22 @@ void existingNonNestedBeforeCommitFailureDispatchesToSetRollbackOnly() { ); } + @Test + void unsupportedOracleSessionlessModeIsRejectedBeforeTransactionalWork() { + assertUnsupportedOracleSessionlessMode(OracleTransactional.Sessionless.SUSPEND); + assertUnsupportedOracleSessionlessMode(OracleTransactional.Sessionless.REQUIRES_SUSPENDED); + + assertEquals(List.of(), txManager.calls); + } + + @Test + void unsupportedOracleSessionlessModeIsRejectedBeforeProgrammaticTransactionCreation() { + assertUnsupportedProgrammaticOracleSessionlessMode(OracleTransactional.Sessionless.SUSPEND); + assertUnsupportedProgrammaticOracleSessionlessMode(OracleTransactional.Sessionless.REQUIRES_SUSPENDED); + + assertEquals(List.of(), txManager.calls); + } + private static void registerThrowingBeforeCommit(Object status) { ((InternalTransaction) status).registerInvocationSynchronization( new TransactionSynchronization() { @@ -146,6 +165,34 @@ public void beforeCommit(boolean readOnly) { ); } + private void assertUnsupportedOracleSessionlessMode(OracleTransactional.Sessionless mode) { + TransactionSuspensionNotSupportedException exception = assertThrows( + TransactionSuspensionNotSupportedException.class, + () -> txManager.execute(oracleSessionlessDefinition(mode), status -> null) + ); + assertEquals( + "Oracle sessionless transaction mode '" + mode + "' requires Oracle sessionless transaction support", + exception.getMessage() + ); + } + + private void assertUnsupportedProgrammaticOracleSessionlessMode(OracleTransactional.Sessionless mode) { + TransactionSuspensionNotSupportedException exception = assertThrows( + TransactionSuspensionNotSupportedException.class, + () -> txManager.getTransaction(oracleSessionlessDefinition(mode)) + ); + assertEquals( + "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/build.gradle b/doc-examples/jdbc-sessionless-transaction-booking/build.gradle new file mode 100644 index 00000000000..8d09e1158fc --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/build.gradle @@ -0,0 +1,37 @@ +import io.micronaut.testresources.buildtools.KnownModules + +plugins { + id "io.micronaut.build.internal.data-native-example" +} + +application { + mainClass = "example.Application" +} + +micronaut { + version libs.versions.micronaut.platform.get() + runtime "netty" + testRuntime "junit5" + testResources { + inferClasspath = false + additionalModules.add(KnownModules.JDBC_ORACLE_FREE) + clientTimeout = 300 + version = libs.versions.micronaut.testresources.get() + } +} + +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 + runtimeOnly mnLogging.logback.classic + + testResourcesService mnSerde.micronaut.serde.jackson +} diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Application.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Application.java new file mode 100644 index 00000000000..9cd8e9cc2fa --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/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/src/main/java/example/BookingController.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingController.java new file mode 100644 index 00000000000..1f385c2c5b1 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/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/src/main/java/example/BookingService.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java new file mode 100644 index 00000000000..b115bacc09a --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/BookingService.java @@ -0,0 +1,26 @@ +package example; + +import io.micronaut.transaction.annotation.OracleTransactional; +import jakarta.inject.Singleton; + +@Singleton +public class BookingService { + + private final SeatRepository seatRepository; + + public BookingService(SeatRepository seatRepository) { + this.seatRepository = seatRepository; + } + + @OracleTransactional(sessionless = OracleTransactional.Sessionless.SUSPEND, timeout = 60) + public Long holdSeat(Seat seat) { + return seatRepository.save(seat).getId(); + } + + @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"); + seatRepository.update(seat); + } +} diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Seat.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/Seat.java new file mode 100644 index 00000000000..9eb8a22aec1 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/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/src/main/java/example/SeatRepository.java b/doc-examples/jdbc-sessionless-transaction-booking/src/main/java/example/SeatRepository.java new file mode 100644 index 00000000000..e93bfaf26af --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/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/src/main/resources/application.yml b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml new file mode 100644 index 00000000000..25981a64072 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/application.yml @@ -0,0 +1,15 @@ +micronaut: + application: + name: jdbc-sessionless-transaction-booking-java + data: + oracle: + sessionless: + http: + propagation-enabled: true + +datasources: + default: + driverClassName: oracle.jdbc.OracleDriver + db-type: oracle + dialect: ORACLE + schema-generate: CREATE_DROP diff --git a/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/logback.xml b/doc-examples/jdbc-sessionless-transaction-booking/src/main/resources/logback.xml new file mode 100644 index 00000000000..d8e68cf1386 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/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/src/test/java/example/BookingControllerTest.java b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java new file mode 100644 index 00000000000..b42decbf13b --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingControllerTest.java @@ -0,0 +1,70 @@ +package example; + +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; + +@MicronautTest(transactional = false) +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() { + // 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)); + + 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/src/test/java/example/BookingServiceTest.java b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java new file mode 100644 index 00000000000..29e3c06ab75 --- /dev/null +++ b/doc-examples/jdbc-sessionless-transaction-booking/src/test/java/example/BookingServiceTest.java @@ -0,0 +1,106 @@ +package example; + +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; + +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) +class BookingServiceTest { + + @Inject + BookingService bookingService; + + @Inject + SeatRepository seatRepository; + + @Inject + OracleSessionlessTransactionPropagationOperations transactionPropagationOperations; + + @BeforeEach + void cleanUp() { + seatRepository.deleteAll(); + } + + @Test + void testTransactionResumed() { + transactionPropagationOperations.withPropagation(() -> { + 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().getStatus()); + return null; + }); + } + + @Test + void testCurrentTransactionIdExportsSuspendedTransactionId() { + // tag::propagation[] + 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; + }); + // end::propagation[] + assertTicketedSeat(); + } + + @Test + void testSetTransactionIdImportsIntoActivePropagationState() { + 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()); + + 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) { + } +} diff --git a/settings.gradle b/settings.gradle index 56b399245aa..8fdd1760cab 100644 --- a/settings.gradle +++ b/settings.gradle @@ -108,6 +108,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' + include 'doc-examples:jdbc-and-r2dbc-example-java' include 'doc-examples:r2dbc-example-java' 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/shared/transactions/oracleTransactions/sessionlessTransactions.adoc b/src/main/docs/guide/shared/transactions/oracleTransactions/sessionlessTransactions.adoc new file mode 100644 index 00000000000..2c2c58db94f --- /dev/null +++ b/src/main/docs/guide/shared/transactions/oracleTransactions/sessionlessTransactions.adoc @@ -0,0 +1,112 @@ +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 with the ann:transaction.annotation.OracleTransactional[] `sessionless` +attribute: + +* 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`. + +The following service starts a sessionless transaction, suspends it, and later resumes it: + +snippet::example.BookingService[project="doc-examples/jdbc-sessionless-transaction-booking", source="main", indent="0"] + +For a method annotated with `@OracleTransactional(sessionless = 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 `@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 annotated with `@OracleTransactional(sessionless = 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="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="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="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="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/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 c3a1d90d938..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