From fd81e291ee7dad14437e021d8f676223ad8f3681 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:08:07 -0500 Subject: [PATCH 1/7] feat: Add Spring reactive transaction manager module --- build.gradle.kts | 2 + .../org/jetbrains/exposed/gradle/TestDbDsl.kt | 2 +- .../exposed/v1/r2dbc/R2dbcDatabase.kt | 2 - .../r2dbc/statements/R2dbcConnectionImpl.kt | 4 - .../v1/r2dbc/transactions/Transactions.kt | 2 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + .../api/spring-reactive-transaction.api | 12 + spring-reactive-transaction/build.gradle.kts | 59 +++ ...pringReactiveTransactionAttributeSource.kt | 38 ++ .../SpringReactiveTransactionManager.kt | 300 +++++++++++++ .../transaction/ConnectionFactorySpy.kt | 31 ++ .../reactive/transaction/ConnectionSpy.kt | 84 ++++ .../ExposedTransactionManagerTest.kt | 310 ++++++++++++++ .../transaction/SpringCoroutineTest.kt | 52 +++ .../SpringReactiveTransactionManagerTest.kt | 404 ++++++++++++++++++ .../SpringReactiveTransactionRollbackTest.kt | 140 ++++++ ...ReactiveTransactionSingleConnectionTest.kt | 111 +++++ .../SpringReactiveTransactionTestBase.kt | 104 +++++ .../SpringMultiContainerTransactionTest.kt | 5 + .../SpringTransactionManagerTest.kt | 11 + 21 files changed, 1669 insertions(+), 7 deletions(-) create mode 100644 spring-reactive-transaction/api/spring-reactive-transaction.api create mode 100644 spring-reactive-transaction/build.gradle.kts create mode 100644 spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt create mode 100644 spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt create mode 100644 spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt diff --git a/build.gradle.kts b/build.gradle.kts index e86d1648cf..b0d4740fe2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { dokka(projects.exposed.exposedSpringBoot4Starter) dokka(projects.exposed.springTransaction) dokka(projects.exposed.spring7Transaction) + dokka(projects.exposed.springReactiveTransaction) // Kover aggregated coverage dependencies // Include all source modules for coverage aggregation @@ -47,6 +48,7 @@ dependencies { kover(project(":exposed-java-time")) kover(project(":spring-transaction")) kover(project(":spring7-transaction")) + kover(project(":spring-reactive-transaction")) kover(project(":exposed-spring-boot-starter")) kover(project(":exposed-spring-boot4-starter")) kover(project(":exposed-jdbc")) diff --git a/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt b/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt index dcc582db30..99a4ba991d 100644 --- a/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt +++ b/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt @@ -102,7 +102,7 @@ private fun Project.createDbTestTaskByDialect(db: TestDb, taskName: String, dial if (db.ignoresSpringTests(dialect)) { filter { // exclude all test classes in Spring modules: - // spring-transaction, spring7-transaction, + // spring-transaction, spring7-transaction, spring-reactive-transaction // exposed-spring-boot-starter, exposed-spring-boot4-starter exclude( "org/jetbrains/exposed/v1/spring/*", diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/R2dbcDatabase.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/R2dbcDatabase.kt index 7bd24e3e70..ecd2b8d543 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/R2dbcDatabase.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/R2dbcDatabase.kt @@ -173,8 +173,6 @@ class R2dbcDatabase private constructor( connectionUrl = options.urlString connectionUrlMode = options.urlMode TransactionManager.registerManager(this, manager(this)) - // ABOVE should be replaced with BELOW when ThreadLocalTransactionManager is fully deprecated - // TransactionManager.registerManager(this, manager(this)) } } diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt index f2a2a5f3a4..74fb34f1ec 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt @@ -271,10 +271,6 @@ internal fun IsolationLevel.asInt(): Int = isolationLevelMapping.getOrElse(this) error("Unsupported IsolationLevel as Int: ${this.asSql()}") } -internal fun Int.asIsolationLevel(): IsolationLevel = isolationLevelMapping.entries - .firstOrNull { it.value == this }?.key - ?: error("Unsupported Int as IsolationLevel: $this") - internal suspend fun Connection.executeSQL(sqlQuery: String) { if (sqlQuery.isEmpty()) return diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt index b1ebab1e4a..088f45055d 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt @@ -114,6 +114,8 @@ suspend fun suspendTransaction( ): T { val databaseToUse = resolveR2dbcDatabaseOrThrow(db) val outer = databaseToUse.transactionManager.getCurrentContextTransaction() +// @OptIn(InternalApi::class) +// val outer = ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) as? R2dbcTransaction return if (outer != null) { val transaction = outer.transactionManager.newTransaction( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bceca8f572..983967d80b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin kotlinx-coroutines-debug = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-debug", version.ref = "kotlinCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } kotlinx-coroutines-reactive = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-reactive", version.ref = "kotlinCoroutines" } +kotlinx-coroutines-reactor = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-reactor", version.ref = "kotlinCoroutines" } kotlinx-jvm-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime-jvm", version.ref = "kotlinx-datetime" } kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } @@ -85,6 +86,7 @@ spring6-jdbc = { group = "org.springframework", name = "spring-jdbc", version.re spring6-context = { group = "org.springframework", name = "spring-context", version.ref = "springFramework6" } spring6-test = { group = "org.springframework", name = "spring-test", version.ref = "springFramework6" } spring7-jdbc = { group = "org.springframework", name = "spring-jdbc", version.ref = "springFramework7" } +spring-r2dbc = { group = "org.springframework", name = "spring-r2dbc", version.ref = "springFramework7" } spring7-context = { group = "org.springframework", name = "spring-context", version.ref = "springFramework7" } spring7-test = { group = "org.springframework", name = "spring-test", version.ref = "springFramework7" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f4f68dcae6..c7bf531f71 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include("exposed-jodatime") include("exposed-java-time") include("spring-transaction") include("spring7-transaction") +include("spring-reactive-transaction") include("exposed-spring-boot-starter") include("exposed-spring-boot4-starter") include("exposed-jdbc") diff --git a/spring-reactive-transaction/api/spring-reactive-transaction.api b/spring-reactive-transaction/api/spring-reactive-transaction.api new file mode 100644 index 0000000000..3c0839ae1f --- /dev/null +++ b/spring-reactive-transaction/api/spring-reactive-transaction.api @@ -0,0 +1,12 @@ +public final class org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource : org/springframework/transaction/interceptor/TransactionAttributeSource { + public fun ()V + public fun (Lorg/springframework/transaction/interceptor/TransactionAttributeSource;Ljava/util/List;)V + public synthetic fun (Lorg/springframework/transaction/interceptor/TransactionAttributeSource;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getTransactionAttribute (Ljava/lang/reflect/Method;Ljava/lang/Class;)Lorg/springframework/transaction/interceptor/TransactionAttribute; +} + +public final class org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager : org/springframework/transaction/reactive/AbstractReactiveTransactionManager { + public fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;Z)V + public synthetic fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + diff --git a/spring-reactive-transaction/build.gradle.kts b/spring-reactive-transaction/build.gradle.kts new file mode 100644 index 0000000000..edd73a64f0 --- /dev/null +++ b/spring-reactive-transaction/build.gradle.kts @@ -0,0 +1,59 @@ +import org.gradle.api.tasks.testing.logging.* +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.* + +plugins { + kotlin("jvm") + + alias(libs.plugins.dokka) +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":exposed-core")) + api(project(":exposed-r2dbc")) + api(libs.spring.r2dbc) + api(libs.spring7.context) + implementation(libs.kotlinx.coroutines.reactor) + + testImplementation(project(":exposed-r2dbc-tests")) + testImplementation(kotlin("test")) + testImplementation(libs.junit6) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.kotlinx.coroutines.debug) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.spring7.test) + testImplementation(libs.slf4j) + testImplementation(libs.log4j.slf4j.impl) + testImplementation(libs.log4j.api) + testImplementation(libs.log4j.core) + testImplementation(libs.r2dbc.h2) { + exclude(group = "com.h2database", module = "h2") + } +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +tasks.withType().configureEach { + if (JavaVersion.VERSION_1_8 > JavaVersion.current()) { + jvmArgs = listOf("-XX:MaxPermSize=256m") + } + testLogging { + events.addAll(listOf(TestLogEvent.PASSED, TestLogEvent.FAILED, TestLogEvent.SKIPPED)) + showStandardStreams = true + exceptionFormat = TestExceptionFormat.FULL + } + + useJUnitPlatform() +} diff --git a/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt b/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt new file mode 100644 index 0000000000..c22fd1f420 --- /dev/null +++ b/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt @@ -0,0 +1,38 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.R2dbcException +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource +import org.springframework.transaction.interceptor.RollbackRuleAttribute +import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute +import org.springframework.transaction.interceptor.TransactionAttribute +import org.springframework.transaction.interceptor.TransactionAttributeSource +import java.lang.reflect.Method + +/** + * A [TransactionAttributeSource] that adds `ExposedR2dbcException` to the rollback rules of the delegate. + * + * @property delegate The delegate [TransactionAttributeSource] to use. Defaults to [AnnotationTransactionAttributeSource]. + * If you use a custom [TransactionAttributeSource], you can pass it here. + */ +class ExposedSpringReactiveTransactionAttributeSource( + private val delegate: TransactionAttributeSource = AnnotationTransactionAttributeSource(), + private val rollbackExceptions: List> = listOf(R2dbcException::class.java) +) : TransactionAttributeSource { + + override fun getTransactionAttribute(method: Method, targetClass: Class<*>?): TransactionAttribute? { + val attr = delegate.getTransactionAttribute(method, targetClass) + if (attr is RuleBasedTransactionAttribute) { + val rules = attr.rollbackRules.toMutableList() + rollbackExceptions.forEach { exception -> + val containsException = rules.any { + it.exceptionName == exception.name + } + if (!containsException) { + rules.add(RollbackRuleAttribute(exception)) + } + } + attr.rollbackRules = rules + } + return attr + } +} diff --git a/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt b/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt new file mode 100644 index 0000000000..9510acc13f --- /dev/null +++ b/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt @@ -0,0 +1,300 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.IsolationLevel +import io.r2dbc.spi.R2dbcException +import kotlinx.coroutines.reactor.mono +import org.jetbrains.exposed.v1.core.InternalApi +import org.jetbrains.exposed.v1.core.StdOutSqlLogger +import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack +import org.jetbrains.exposed.v1.core.transactions.currentTransactionOrNull +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.transactionManager +import org.jetbrains.exposed.v1.r2dbc.withTransactionContext +import org.springframework.r2dbc.UncategorizedR2dbcException +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionSystemException +import org.springframework.transaction.reactive.AbstractReactiveTransactionManager +import org.springframework.transaction.reactive.GenericReactiveTransaction +import org.springframework.transaction.reactive.TransactionSynchronizationManager +import reactor.core.publisher.Mono + +/** + * Transaction Manager implementation that builds on top of Spring's standard reactive transaction workflow. + * + * @param connectionFactory The [ConnectionFactory] entry point for an R2DBC driver when getting a connection. + * @param databaseConfig The configuration that defines custom properties to be used with connections. + * At minimum, a configuration must be provided that specifies `R2dbcDatabaseConfig.explicitDialect`. + * @property showSql Whether transaction queries should be logged. Defaults to `false`. + */ +class SpringReactiveTransactionManager( + connectionFactory: ConnectionFactory, + databaseConfig: R2dbcDatabaseConfig.Builder, + private val showSql: Boolean = false, +) : AbstractReactiveTransactionManager() { + + private val database: R2dbcDatabase = R2dbcDatabase.connect( + connectionFactory = connectionFactory, + databaseConfig = databaseConfig + ) + + override fun doGetTransaction( + synchronizationManager: TransactionSynchronizationManager + ): Any { + val holder = ExposedTransactionObject(database = database) + val outer = holder.getCurrentTransaction() + + // Only clears up leftovers between transactions, to prevent invalid re-use; + // Will not be able to clean the final active transaction + if (outer != null && synchronizationManager.getResource(database) == null) { + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.popTransaction() + } + + return holder + } + + override fun doSuspend( + synchronizationManager: TransactionSynchronizationManager, + transaction: Any + ): Mono { + return Mono.defer { + val trxObject = transaction as ExposedTransactionObject + + val currentTransaction = trxObject.getCurrentTransaction() + + val holder = SuspendedObject( + transaction = currentTransaction ?: error("No transaction to suspend"), + ) + synchronizationManager.unbindResource(database) + + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.popTransaction() + + Mono.just(holder) + } + } + + override fun doResume( + synchronizationManager: TransactionSynchronizationManager, + transaction: Any?, + suspendedResources: Any + ): Mono { + return Mono.defer { + val suspendedObject = suspendedResources as SuspendedObject + + val suspendedTransaction = suspendedObject.transaction + + synchronizationManager.bindResource(database, suspendedTransaction) + + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.pushTransaction(suspendedTransaction) + + Mono.empty() + } + } + + override fun isExistingTransaction(transaction: Any): Boolean { + val trxObject = transaction as ExposedTransactionObject + + val currentTransaction = trxObject.getCurrentTransaction() + + return currentTransaction != null + } + + override fun doBegin( + synchronizationManager: TransactionSynchronizationManager, + transaction: Any, + definition: TransactionDefinition + ): Mono { + return Mono.defer { + val trxObject = transaction as ExposedTransactionObject + + @OptIn(InternalApi::class) + val currentTransaction = currentTransactionOrNull() as R2dbcTransaction? + val outerTransactionToUse = if (currentTransaction?.db == database) { + currentTransaction + } else { + null + } + + val newTransaction = trxObject.database.transactionManager.newTransaction( + isolation = definition.isolationLevel.resolveIsolationLevel(), + readOnly = definition.isReadOnly, + outerTransaction = outerTransactionToUse + ).apply { + if (definition.timeout != TransactionDefinition.TIMEOUT_DEFAULT) { + queryTimeout = definition.timeout + } + + if (showSql) { + addLogger(StdOutSqlLogger) + } + } + + trxObject.isNewConnection = newTransaction.outerTransaction == null || trxObject.isNestedTransactionAllowed + if (trxObject.isNewConnection) { + // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its outer + // transaction used when doCommit() or doRollback() is invoked + synchronizationManager.unbindResourceIfPossible(database) + + synchronizationManager.bindResource(database, newTransaction) + } + + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.pushTransaction(newTransaction) + + Mono.just(newTransaction) + }.then() + } + + override fun doCommit( + synchronizationManager: TransactionSynchronizationManager, + status: GenericReactiveTransaction + ): Mono { + return Mono.defer { + val trxObject = status.transaction as ExposedTransactionObject + + mono { + @OptIn(InternalApi::class) + withTransactionContext(synchronizationManager.getResourceOrThrow()) { + trxObject.commit() + } + + null + } + } + } + + override fun doRollback( + synchronizationManager: TransactionSynchronizationManager, + status: GenericReactiveTransaction + ): Mono { + return Mono.defer { + val trxObject = status.transaction as ExposedTransactionObject + + mono { + @OptIn(InternalApi::class) + withTransactionContext(synchronizationManager.getResourceOrThrow()) { + trxObject.rollback() + } + + null + } + } + } + + override fun doCleanupAfterCompletion( + synchronizationManager: TransactionSynchronizationManager, + transaction: Any + ): Mono { + return Mono.defer { + val trxObject = transaction as ExposedTransactionObject + + mono { + @OptIn(InternalApi::class) + withTransactionContext(synchronizationManager.getResourceOrThrow()) { + val completedTransaction = trxObject.getCurrentTransaction() + + completedTransaction + ?.let { + clearStatements(it) + + if (trxObject.isNewConnection) { + synchronizationManager.unbindResource(database) + + // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its + // now closed inner transaction used when doCommit() or doRollback() is later invoked + it.outerTransaction?.let { outer -> + synchronizationManager.bindResource(database, outer) + } + } + + it.close() + } + } + + null + } + } + } + + private fun clearStatements(transaction: R2dbcTransaction) { + val currentStatement = transaction.currentStatement + currentStatement?.let { + // No Statement.close() in R2DBC + transaction.currentStatement = null + } + transaction.clearExecutedStatements() + } + + override fun doSetRollbackOnly( + synchronizationManager: TransactionSynchronizationManager, + status: GenericReactiveTransaction + ): Mono { + return Mono.fromRunnable { + val trxObject = status.transaction as ExposedTransactionObject + + trxObject.setRollbackOnly() + } + } + + private fun TransactionSynchronizationManager.getResourceOrThrow(): R2dbcTransaction { + return this.getResource(database) as? R2dbcTransaction ?: error("No transaction value bound to the current context") + } + + private data class SuspendedObject( + val transaction: R2dbcTransaction + ) + + private data class ExposedTransactionObject( + val database: R2dbcDatabase + ) { + private var isRollback: Boolean = false + + val isNestedTransactionAllowed = database.config.useNestedTransactions + var isNewConnection: Boolean = false + + @Suppress("TooGenericExceptionCaught") + suspend fun commit() { + try { + getCurrentTransaction()?.commit() + } catch (error: R2dbcException) { + throw UncategorizedR2dbcException(error.message.orEmpty(), null, error) + } catch (error: Exception) { + throw TransactionSystemException(error.message.orEmpty(), error) + } + } + + @Suppress("TooGenericExceptionCaught") + suspend fun rollback() { + try { + getCurrentTransaction()?.rollback() + } catch (error: R2dbcException) { + throw UncategorizedR2dbcException(error.message.orEmpty(), null, error) + } catch (error: Exception) { + throw TransactionSystemException(error.message.orEmpty(), error) + } + } + + @OptIn(InternalApi::class) + fun getCurrentTransaction(): R2dbcTransaction? { + return ThreadLocalTransactionsStack.getTransactionOrNull(database) as R2dbcTransaction? + } + + fun setRollbackOnly() { + isRollback = true + } + } +} + +internal fun Int.resolveIsolationLevel(): IsolationLevel? = when (this) { + TransactionDefinition.ISOLATION_READ_UNCOMMITTED -> IsolationLevel.READ_UNCOMMITTED + TransactionDefinition.ISOLATION_READ_COMMITTED -> IsolationLevel.READ_COMMITTED + TransactionDefinition.ISOLATION_REPEATABLE_READ -> IsolationLevel.REPEATABLE_READ + TransactionDefinition.ISOLATION_SERIALIZABLE -> IsolationLevel.SERIALIZABLE + TransactionDefinition.ISOLATION_DEFAULT -> null + else -> error("Unsupported Int as IsolationLevel: $this") +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt new file mode 100644 index 0000000000..070d012897 --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt @@ -0,0 +1,31 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.Connection +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.ConnectionFactoryMetadata +import kotlinx.coroutines.reactive.awaitFirst +import org.reactivestreams.Publisher +import org.springframework.r2dbc.connection.ConnectionFactoryUtils +import reactor.core.publisher.Mono + +class ConnectionFactorySpy( + private val connectionSpy: (Connection) -> Connection +) : ConnectionFactory { + private val connectionPublisher = ConnectionFactoryUtils.getConnection( + ConnectionFactories.get("r2dbc:h2:mem:///test") + ) + + private lateinit var con: Connection + + suspend fun getCon(): Connection { + if (::con.isInitialized.not()) { + con = connectionSpy(connectionPublisher.awaitFirst()) + } + return con + } + + override fun create(): Publisher = Mono.just(con) + + override fun getMetadata(): ConnectionFactoryMetadata = throw NotImplementedError() +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt new file mode 100644 index 0000000000..eb3a49cd46 --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt @@ -0,0 +1,84 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.Connection +import io.r2dbc.spi.IsolationLevel +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono + +class ConnectionSpy(private val connection: Connection) : Connection by connection { + var commitCallCount: Int = 0 + var rollbackCallCount: Int = 0 + var closeCallCount: Int = 0 + var releaseSavepointCallCount: Int = 0 + var mockAutoCommit: Boolean = false + var mockTransactionIsolation: IsolationLevel = IsolationLevel.READ_COMMITTED + var mockCommit: () -> Unit = {} + var mockRollback: () -> Unit = {} + private val callOrder = mutableListOf() + + fun verifyCallOrder(vararg functions: String): Boolean { + val indices = functions.map { callOrder.indexOf(it) } + return indices.none { it == -1 } && indices == indices.sorted() + } + + fun clearMock() { + commitCallCount = 0 + rollbackCallCount = 0 + closeCallCount = 0 + releaseSavepointCallCount = 0 + mockAutoCommit = false + mockTransactionIsolation = IsolationLevel.READ_COMMITTED + mockCommit = {} + mockRollback = {} + callOrder.clear() + } + + override fun close(): Publisher = Mono.defer { + callOrder.add("close") + closeCallCount++ + + Mono.empty() + } as Publisher + + override fun beginTransaction(): Publisher = Mono.defer { + callOrder.add("setAutoCommit") + mockAutoCommit = false + + Mono.empty() + } as Publisher + + override fun isAutoCommit(): Boolean = mockAutoCommit + + override fun commitTransaction(): Publisher = Mono.defer { + callOrder.add("commit") + commitCallCount++ + mockCommit() + + Mono.empty() + } as Publisher + + override fun rollbackTransaction(): Publisher = Mono.defer { + callOrder.add("rollback") + rollbackCallCount++ + mockRollback() + + Mono.empty() + } as Publisher + + override fun rollbackTransactionToSavepoint(p0: String): Publisher = Mono.defer { + callOrder.add("rollback") + rollbackCallCount++ + mockRollback() + + Mono.empty() + } as Publisher + + override fun releaseSavepoint(p0: String): Publisher = Mono.defer { + callOrder.add("releaseSavepoint") + releaseSavepointCallCount++ + + Mono.empty() + } as Publisher + + override fun getTransactionIsolationLevel(): IsolationLevel = mockTransactionIsolation +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt new file mode 100644 index 0000000000..eb2f5c4070 --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt @@ -0,0 +1,310 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.InternalApi +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.RepeatedTest +import org.springframework.test.annotation.Commit +import org.springframework.transaction.IllegalTransactionStateException +import org.springframework.transaction.TransactionDefinition +import java.util.* +import kotlin.test.assertFailsWith + +open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { + + object T1 : Table() { + val c1 = varchar("c1", Int.MIN_VALUE.toString().length) + } + + private suspend fun T1.insertRandom() { + insert { + it[c1] = Random().nextInt().toString() + } + } + + @OptIn(InternalApi::class) + @BeforeEach + fun beforeTest() = runTest { + transactionManager.execute { + SchemaUtils.create(T1) + } + } + + @OptIn(InternalApi::class) + @AfterEach + fun afterTest() = runTest { + transactionManager.execute { + SchemaUtils.drop(T1) + } + } + + // @Transactional // see [runTestWithMockTransactional] + @Commit + @RepeatedTest(5) + open fun testConnection() = runTestWithMockTransactional { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + } + + // @Transactional // see [runTestWithMockTransactional] + @Commit + @RepeatedTest(5) + open fun testConnection2() = runTestWithMockTransactional { + val rnd = Random().nextInt().toString() + T1.insert { + it[c1] = rnd + } + assertEquals(rnd, T1.selectAll().single()[T1.c1]) + } + + @RepeatedTest(5) + @Commit + open fun testConnectionCombineWithExposedTransaction() = runTest { + suspendTransaction { + val rnd = Random().nextInt().toString() + T1.insert { + it[c1] = rnd + } + assertEquals(rnd, T1.selectAll().single()[T1.c1]) + + this@ExposedTransactionManagerTest.transactionManager.execute { + T1.insertRandom() + assertEquals(2, T1.selectAll().count()) + } + } + } + + // TODO - This (& only this test?) fails because of line 114 in suspendTransaction(); + // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) + @Disabled + @RepeatedTest(5) + @Commit +// @Transactional // see [runTestWithMockTransactional] + open fun testConnectionCombineWithExposedTransaction2() = runTestWithMockTransactional { + val rnd = Random().nextInt().toString() + T1.insert { + it[c1] = rnd + } + assertEquals(rnd, T1.selectAll().single()[T1.c1]) + + suspendTransaction { + T1.insertRandom() + assertEquals(2, T1.selectAll().count()) + } + } + + /** + * Test for Propagation.NESTED + * Execute within a nested transaction if a current transaction exists, behave like REQUIRED otherwise. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testConnectionWithNestedTransactionCommit() = runTestWithMockTransactional { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { + T1.insertRandom() + assertEquals(2, T1.selectAll().count()) + } + assertEquals(2, T1.selectAll().count()) + } + + /** + * Test for Propagation.NESTED with inner roll-back + * The nested transaction will be roll-back only inner transaction when the transaction marks as rollback. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testConnectionWithNestedTransactionInnerRollback() = runTestWithMockTransactional { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { status -> + T1.insertRandom() + assertEquals(2, T1.selectAll().count()) + status.setRollbackOnly() + } + assertEquals(1, T1.selectAll().count()) + } + + /** + * Test for Propagation.NESTED with outer roll-back + * The nested transaction will be roll-back entire transaction when the transaction marks as rollback. + */ + @RepeatedTest(5) + fun testConnectionWithNestedTransactionOuterRollback() = runTest { + transactionManager.execute { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + it.setRollbackOnly() + + transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { + T1.insertRandom() + assertEquals(2, T1.selectAll().count()) + } + assertEquals(2, T1.selectAll().count()) + } + + transactionManager.execute { + assertEquals(0, T1.selectAll().count()) + } + } + + // TODO + /** + * Test for Propagation.REQUIRES_NEW + * Create a new transaction, and suspend the current transaction if one exists. + */ + @Disabled("After doResume(), TransactionManager.current() in count() returns wrong unpopped transaction from thread-switch") + @RepeatedTest(1) + // @Transactional // see [runTestWithMockTransactional] + open fun testConnectionWithRequiresNew() = runTestWithMockTransactional { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + assertEquals(0, T1.selectAll().count()) + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + } + assertEquals(2, T1.selectAll().count()) + } + + // TODO + /** + * Test for Propagation.REQUIRES_NEW with inner transaction roll-back + * The inner transaction will be roll-back only inner transaction when the transaction marks as rollback. + * And since isolation level is READ_COMMITTED, the inner transaction can't see the changes of outer transaction. + */ + @Disabled("After doResume(), TransactionManager.current() in count() returns wrong unpopped transaction from thread-switch") + @RepeatedTest(5) + fun testConnectionWithRequiresNewWithInnerTransactionRollback() = runTest { + transactionManager.execute { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + T1.insertRandom() + assertEquals(1, T1.selectAll().count()) + it.setRollbackOnly() + } + assertEquals(1, T1.selectAll().count()) + } + + transactionManager.execute { + assertEquals(1, T1.selectAll().count()) + } + } + + /** + * Test for Propagation.NEVER + * Execute non-transactionally, throw an exception if a transaction exists. + */ + @RepeatedTest(5) +// @Transactional(propagation = Propagation.NEVER) // see [runTestWithMockTransactional] + open fun testPropagationNever() = runTestWithMockTransactional( + propagationBehavior = TransactionDefinition.PROPAGATION_NEVER + ) { + assertFailsWith { // Should Be "No transaction exists" + T1.insertRandom() + } + } + + /** + * Test for Propagation.NEVER + * Throw an exception cause outer transaction exists. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testPropagationNeverWithExistingTransaction() = runTestWithMockTransactional { + assertFailsWith { + T1.insertRandom() + transactionManager.execute(TransactionDefinition.PROPAGATION_NEVER) { + T1.insertRandom() + } + } + } + + /** + * Test for Propagation.MANDATORY + * Support a current transaction, throw an exception if none exists. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testPropagationMandatoryWithTransaction() = runTestWithMockTransactional { + T1.insertRandom() + transactionManager.execute(TransactionDefinition.PROPAGATION_MANDATORY) { + T1.insertRandom() + } + } + + /** + * Test for Propagation.MANDATORY + * Throw an exception cause no transaction exists. + */ + @RepeatedTest(5) + open fun testPropagationMandatoryWithoutTransaction() = runTest { + assertFailsWith { + transactionManager.execute(TransactionDefinition.PROPAGATION_MANDATORY) { + T1.insertRandom() + } + } + } + + /** + * Test for Propagation.SUPPORTS + * Support a current transaction, execute non-transactionally if none exists. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testPropagationSupportWithTransaction() = runTestWithMockTransactional { + T1.insertRandom() + transactionManager.execute(TransactionDefinition.PROPAGATION_SUPPORTS) { + T1.insertRandom() + } + } + + /** + * Test for Propagation.SUPPORTS + * Execute non-transactionally if none exists. + */ + @RepeatedTest(5) + open fun testPropagationSupportWithoutTransaction() = runTest { + transactionManager.execute(TransactionDefinition.PROPAGATION_SUPPORTS) { + assertFailsWith { // Should Be "No transaction exists" + T1.insertRandom() + } + } + } + + @Disabled + @RepeatedTest(5) + // @Transactional(isolation = Isolation.READ_COMMITTED) // see [runTestWithMockTransactional] + open fun testIsolationLevelReadUncommitted() = runTestWithMockTransactional( + isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED + ) { + assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) + T1.insertRandom() + val count = T1.selectAll().count() + transactionManager.execute( + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + TransactionDefinition.ISOLATION_READ_UNCOMMITTED + ) { + assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED) + assertEquals(count, T1.selectAll().count()) + } + assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) + } + + private suspend fun assertTransactionIsolationLevel(expected: Int) { + val connection = TransactionManager.current().connection() + assertEquals(expected.resolveIsolationLevel(), connection.getTransactionIsolation()) + } +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt new file mode 100644 index 0000000000..011574611c --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt @@ -0,0 +1,52 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import kotlinx.coroutines.* +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.RepeatedTest +import org.springframework.test.annotation.Commit +import kotlin.test.assertEquals + +open class SpringCoroutineTest : SpringReactiveTransactionTestBase() { + object Testing : Table("COROUTINE_TESTING") { + val id = integer("id").autoIncrement() + + override val primaryKey = PrimaryKey(id) + } + + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + @Commit + open fun testNestedCoroutineTransaction() = runTestWithMockTransactional { + try { + SchemaUtils.create(Testing) + + val mainJob = GlobalScope.async { + // @CoroutinesTimeout is not compatible with @Transactional + val results = withTimeout(1000) { + (1..5).map { indx -> + async(Dispatchers.IO) { + suspendTransaction { + Testing.insert { } + indx + } + } + }.awaitAll() + } + + assertEquals(15, results.sum()) + } + + while (!mainJob.isCompleted) Thread.sleep(100) + mainJob.getCompletionExceptionOrNull()?.let { throw it } + + assertEquals(5L, Testing.selectAll().count()) + } finally { + SchemaUtils.drop(Testing) + } + } +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt new file mode 100644 index 0000000000..014fa14bec --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt @@ -0,0 +1,404 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.InternalApi +import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.r2dbc.connection.TransactionAwareConnectionFactoryProxy +import org.springframework.transaction.IllegalTransactionStateException +import org.springframework.transaction.ReactiveTransaction +import org.springframework.transaction.ReactiveTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionSystemException +import org.springframework.transaction.reactive.TransactionalOperator +import org.springframework.transaction.reactive.executeAndAwait +import org.springframework.transaction.support.DefaultTransactionDefinition +import java.sql.SQLException +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SpringReactiveTransactionManagerTest { + + companion object { + val cf1 = ConnectionFactorySpy(::ConnectionSpy) + lateinit var con1: ConnectionSpy + val cf2 = ConnectionFactorySpy(::ConnectionSpy) + lateinit var con2: ConnectionSpy + + @BeforeAll + @JvmStatic + fun init() = runTest { + con1 = cf1.getCon() as ConnectionSpy + con2 = cf2.getCon() as ConnectionSpy + } + } + + @OptIn(InternalApi::class) + @BeforeEach + fun beforeTest() { + con1.clearMock() + con2.clearMock() + + // TODO - this should not be done, but transactions are not being popped on original thread after coroutine switches thread + ThreadLocalTransactionsStack.threadTransactions()?.clear() + } + + @OptIn(InternalApi::class) + @BeforeEach + fun afterTest() { + while (TransactionManager.defaultDatabase != null) { + TransactionManager.defaultDatabase?.let { TransactionManager.closeAndUnregister(it) } + } + } + + @Test + fun `set manager when transaction start`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert(false) + } + + @Test + fun `set right transaction manager when two transaction manager exist`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert(false) + + val tm2 = getDefaultManager(cf2) + tm2.executeAssert(false) + } + + @Test + fun `set right transaction manager when two transaction manager with nested transaction template`() = runTest { + val tm = getDefaultManager(cf1) + val tm2 = getDefaultManager(cf2) + + tm2.executeAssert(false) { + tm.executeAssert(false) + + assertEquals( + TransactionManager.currentOrNull()?.db?.let { TransactionManager.managerFor(it) }, + TransactionManager.current().transactionManager + ) + } + } + + @Test + fun `connection commit and close when transaction success`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert() + + assertTrue(con1.verifyCallOrder("setAutoCommit", "commit", "close")) + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `connection rollback and close when transaction fail`() = runTest { + val tm = getDefaultManager(cf1) + val ex = RuntimeException("Application exception") + try { + tm.executeAssert { + throw ex + } + } catch (e: Exception) { + assertEquals(e::class.java, ex::class.java) + assertEquals(e.message, ex.message) + } + assertEquals(1, con1.rollbackCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `connection commit and closed when nested transaction success`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert { + tm.executeAssert() + } + + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `connection commit and closed when two different transaction manager with nested transaction success`() = runTest { + val tm1 = getDefaultManager(cf1) + val tm2 = getDefaultManager(cf2) + + tm1.executeAssert { + tm2.executeAssert() + assertEquals( + TransactionManager.currentOrNull()?.db?.let { TransactionManager.managerFor(it) }, + TransactionManager.current().transactionManager + ) + } + + assertEquals(1, con2.commitCallCount) + assertEquals(1, con2.closeCallCount) + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `connection rollback and closed when two different transaction manager with nested transaction failed`() = runTest { + val tm1 = getDefaultManager(cf1) + val tm2 = getDefaultManager(cf2) + val ex = RuntimeException("Application exception") + try { + tm1.executeAssert { + tm2.executeAssert { + throw ex + } + assertEquals( + TransactionManager.currentOrNull()?.db?.let { TransactionManager.managerFor(it) }, + TransactionManager.current().transactionManager + ) + } + } catch (e: Exception) { + assertEquals(e::class.java, ex::class.java) + assertEquals(e.message, ex.message) + } + + assertEquals(0, con2.commitCallCount) + assertEquals(1, con2.rollbackCallCount) + assertEquals(1, con2.closeCallCount) + assertEquals(0, con1.commitCallCount) + assertEquals(1, con1.rollbackCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `transaction commit with transaction aware connection factory proxy`() = runTest { + val transactionAwareCf = TransactionAwareConnectionFactoryProxy(cf1) + val tm = getDefaultManager(transactionAwareCf) + tm.executeAssert() + + assertTrue(con1.verifyCallOrder("setAutoCommit", "commit")) + assertEquals(1, con1.commitCallCount) + assertTrue(con1.closeCallCount > 0) + } + + @Test + fun `transaction rollback with transaction aware connection factory proxy`() = runTest { + val transactionAwareCf = TransactionAwareConnectionFactoryProxy(cf1) + val tm = getDefaultManager(transactionAwareCf) + val ex = RuntimeException("Application exception") + try { + tm.executeAssert { + throw ex + } + } catch (e: Exception) { + assertEquals(e::class.java, ex::class.java) + assertEquals(e.message, ex.message) + } + + assertTrue(con1.verifyCallOrder("setAutoCommit", "rollback")) + assertEquals(1, con1.rollbackCallCount) + assertTrue(con1.closeCallCount > 0) + } + + @Test + fun `transaction with exception on rollback`() = runTest { + con1.mockRollback = { throw SQLException("Rollback failure") } + + val tm = getDefaultManager(cf1) + assertFailsWith { + tm.executeAssert { + assertEquals(false, it.isRollbackOnly) + it.setRollbackOnly() + assertEquals(true, it.isRollbackOnly) + } + } + + assertTrue(con1.verifyCallOrder("setAutoCommit", "rollback", "close")) + assertEquals(1, con1.rollbackCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `nested transaction with commit`() = runTest { + val tm = getDefaultManager(cf1, R2dbcDatabaseConfig { useNestedTransactions = true }) + + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_NESTED) { + assertTrue(it.isNewTransaction) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_NESTED) + assertTrue(it.isNewTransaction) + } + + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `nested transaction with rollback`() = runTest { + val tm = getDefaultManager(cf1, R2dbcDatabaseConfig { useNestedTransactions = true }) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_NESTED) { + assertTrue(it.isNewTransaction) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_NESTED) { status -> + status.setRollbackOnly() + } + assertTrue(it.isNewTransaction) + } + + assertEquals(1, con1.rollbackCallCount) + assertEquals(1, con1.releaseSavepointCallCount) + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `requires new with commit`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert { + assertTrue(it.isNewTransaction) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW) { status -> + assertTrue(status.isNewTransaction) + } + assertTrue(it.isNewTransaction) + } + + assertEquals(2, con1.commitCallCount) + assertEquals(2, con1.closeCallCount) + } + + @Test + fun `requires new with inner rollback`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert { + assertTrue(it.isNewTransaction) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW) { status -> + assertTrue(status.isNewTransaction) + status.setRollbackOnly() + } + assertTrue(it.isNewTransaction) + } + + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.rollbackCallCount) + assertEquals(2, con1.closeCallCount) + } + + @Test + fun `not support with required transaction`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert { + assertTrue(it.isNewTransaction) + tm.executeAssert( + initializeConnection = false, + propagationBehavior = TransactionDefinition.PROPAGATION_NOT_SUPPORTED + ) { + assertFailsWith { + TransactionManager.current().connection() + } + } + assertTrue(it.isNewTransaction) + TransactionManager.current().connection() + } + + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `mandatory with transaction`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert { + assertTrue(it.isNewTransaction) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_MANDATORY) + assertTrue(it.isNewTransaction) + TransactionManager.current().connection() + } + + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `mandatory without transaction`() = runTest { + val tm = getDefaultManager(cf1) + assertFailsWith { + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_MANDATORY) + } + } + + @Test + fun `support with transaction`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert { + assertTrue(it.isNewTransaction) + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_SUPPORTS) + assertTrue(it.isNewTransaction) + TransactionManager.current().connection() + } + + assertEquals(1, con1.commitCallCount) + assertEquals(1, con1.closeCallCount) + } + + @Test + fun `support without transaction`() = runTest { + val tm = getDefaultManager(cf1) + assertFailsWith { + tm.executeAssert(propagationBehavior = TransactionDefinition.PROPAGATION_SUPPORTS) + } + tm.executeAssert(initializeConnection = false, propagationBehavior = TransactionDefinition.PROPAGATION_SUPPORTS) + assertEquals(0, con1.commitCallCount) + assertEquals(0, con1.rollbackCallCount) + assertEquals(0, con1.closeCallCount) + } + + @Test + fun `transaction timeout`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert(initializeConnection = true, timeout = 1) { + assertEquals(1, TransactionManager.current().queryTimeout) + } + } + + @Test + fun `transaction timeout propagation`() = runTest { + val tm = getDefaultManager(cf1) + tm.executeAssert(initializeConnection = true, timeout = 1) { + tm.executeAssert(initializeConnection = true, timeout = 2) { + assertEquals(1, TransactionManager.current().queryTimeout) + } + assertEquals(1, TransactionManager.current().queryTimeout) + } + } + + private fun getDefaultManager( + connectionFactory: ConnectionFactory, + databaseConfig: R2dbcDatabaseConfig.Builder = R2dbcDatabaseConfig.Builder() + ): SpringReactiveTransactionManager = SpringReactiveTransactionManager( + connectionFactory = connectionFactory, + databaseConfig = databaseConfig.apply { explicitDialect = H2Dialect() } + ) + + private suspend fun ReactiveTransactionManager.executeAssert( + initializeConnection: Boolean = true, + propagationBehavior: Int = TransactionDefinition.PROPAGATION_REQUIRED, + timeout: Int? = null, + body: suspend (ReactiveTransaction) -> Unit = {} + ) { + val trxDef = DefaultTransactionDefinition(propagationBehavior).apply { + if (timeout != null) this.timeout = timeout + } + val trxOp = TransactionalOperator.create(this, trxDef) + trxOp.executeAndAwait { + TransactionManager.currentOrNull()?.db?.let { db -> + assertEquals( + TransactionManager.managerFor(db), + TransactionManager.current().transactionManager + ) + } + + if (initializeConnection) TransactionManager.current().connection() + body(it) + } + } +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt new file mode 100644 index 0000000000..ea17bbb9b2 --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt @@ -0,0 +1,140 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.InternalApi +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.ExposedR2dbcException +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.deleteAll +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.annotation.EnableTransactionManagement +import org.springframework.transaction.annotation.Transactional +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SpringReactiveTransactionRollbackTest { + + val container = AnnotationConfigApplicationContext(TransactionManagerAttributeSourceTestConfig::class.java) + + @OptIn(InternalApi::class) + @BeforeEach + fun beforeTest() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + testRollback.init() + } + + @OptIn(InternalApi::class) + @AfterEach + fun afterTest() { + container.close() + } + + @Test + fun `test ExposedR2dbcException rollback`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + assertFailsWith { + testRollback.suspendTransaction { + insertOriginTable() + insertWrongTable("1234567890") + } + } + + assertEquals(0, testRollback.entireTableSize()) + } + + @Test + fun `test RuntimeException rollback`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + assertFailsWith { + testRollback.suspendTransaction { + insertOriginTable() + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() + } + } + + assertEquals(0, testRollback.entireTableSize()) + } + + @Test + fun `test check exception commit`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + assertFailsWith { + testRollback.suspendTransaction { + insertOriginTable() + @Suppress("TooGenericExceptionThrown") + throw Exception() + } + } + + assertEquals(1, testRollback.entireTableSize()) + } +} + +@Configuration +@EnableTransactionManagement(proxyTargetClass = true) +open class TransactionManagerAttributeSourceTestConfig { + + @Bean + open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest1;DB_CLOSE_DELAY=-1;") + + @Bean + open fun transactionManager(connectionFactory: ConnectionFactory) = SpringReactiveTransactionManager( + connectionFactory, + R2dbcDatabaseConfig { explicitDialect = H2Dialect() } + ) + + @Bean + open fun transactionAttributeSource() = ExposedSpringReactiveTransactionAttributeSource() + + @Bean + open fun testRollback() = TestRollback() +} + +@Transactional +open class TestRollback { + + open suspend fun init() { + SchemaUtils.create(RollbackTable) + RollbackTable.deleteAll() + } + + open suspend fun suspendTransaction(block: suspend TestRollback.() -> Unit) { + block() + } + + open suspend fun insertOriginTable() { + RollbackTable.insert { + it[name] = "1" + } + } + + open suspend fun insertWrongTable(name: String) { + WrongDefinedRollbackTable.insert { + it[WrongDefinedRollbackTable.name] = name + } + } + + open suspend fun entireTableSize(): Long { + return RollbackTable.selectAll().count() + } +} + +object RollbackTable : LongIdTable("test_rollback") { + val name = varchar("name", 5) +} + +object WrongDefinedRollbackTable : LongIdTable("test_rollback") { + val name = varchar("name", 10) +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt new file mode 100644 index 0000000000..2edfa43142 --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt @@ -0,0 +1,111 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.InternalApi +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.r2dbc.connection.ConnectionFactoryUtils +import org.springframework.r2dbc.connection.SingleConnectionFactory +import org.springframework.transaction.ReactiveTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.annotation.EnableTransactionManagement +import kotlin.test.assertEquals + +class SpringReactiveTransactionSingleConnectionTest { + object T1 : Table() { + val c1 = varchar("c1", Int.MIN_VALUE.toString().length) + } + + val singleConnectionH2TestContainer = AnnotationConfigApplicationContext(SingleConnectionH2TestConfig::class.java) + val transactionManager: ReactiveTransactionManager = singleConnectionH2TestContainer.getBean(ReactiveTransactionManager::class.java) + val connectionFactory: ConnectionFactory = singleConnectionH2TestContainer.getBean(ConnectionFactory::class.java) + + @OptIn(InternalApi::class) + @BeforeEach + fun beforeTest() = runTest { + transactionManager.execute { + SchemaUtils.create(T1) + } + } + + @OptIn(InternalApi::class) + @AfterEach + fun afterTest() = runTest { + transactionManager.execute { + SchemaUtils.drop(T1) + } + singleConnectionH2TestContainer.close() + } + + @Test + fun `start transaction with non default isolation level`() = runTest { + transactionManager.execute( + isolationLevel = TransactionDefinition.ISOLATION_SERIALIZABLE, + ) { + T1.selectAll().toList() + } + } + + @Test + fun `nested transaction with non default isolation level`() = runTest { + transactionManager.execute( + isolationLevel = TransactionDefinition.ISOLATION_SERIALIZABLE, + ) { + T1.selectAll().toList() + + // Nested transaction will inherit isolation level from parent transaction because it uses the same connection + transactionManager.execute( + isolationLevel = TransactionDefinition.ISOLATION_READ_UNCOMMITTED, + ) { + val cx = ConnectionFactoryUtils.getConnection(connectionFactory).awaitFirst() + assertEquals( + cx.transactionIsolationLevel, + TransactionDefinition.ISOLATION_SERIALIZABLE.resolveIsolationLevel() + ) + cx.close().awaitFirstOrNull() + + T1.selectAll().toList() + } + T1.selectAll().toList() + } + } +} + +@Configuration +@EnableTransactionManagement(proxyTargetClass = true) +open class SingleConnectionH2TestConfig { + + @Bean + open fun singleConnectionH2Factory(): ConnectionFactory { + // args -> SingleConnectionFactory(url, suppressClose) + return SingleConnectionFactory( + "r2dbc:h2:mem:///regular;DB_CLOSE_DELAY=-1;", + true + ) + } + + @Bean + open fun singleConnectionH2TransactionManager( + @Qualifier("singleConnectionH2Factory") connectionFactory: ConnectionFactory + ): ReactiveTransactionManager = SpringReactiveTransactionManager( + connectionFactory, + R2dbcDatabaseConfig { + useNestedTransactions = true + explicitDialect = H2Dialect() + } + ) +} diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt new file mode 100644 index 0000000000..b218ba51a9 --- /dev/null +++ b/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt @@ -0,0 +1,104 @@ +package org.jetbrains.exposed.v1.spring.reactive.transaction + +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.transaction.ReactiveTransaction +import org.springframework.transaction.ReactiveTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.annotation.EnableTransactionManagement +import org.springframework.transaction.annotation.TransactionManagementConfigurer +import org.springframework.transaction.reactive.TransactionalOperator +import org.springframework.transaction.reactive.executeAndAwait +import org.springframework.transaction.support.DefaultTransactionDefinition + +@Configuration +@EnableTransactionManagement +/*(mode = AdviceMode.ASPECTJ, proxyTargetClass = true)*/ +open class TestConfig : TransactionManagementConfigurer { + + @Bean + open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest;DB_CLOSE_DELAY=-1;") + + @Bean + override fun annotationDrivenTransactionManager(): SpringReactiveTransactionManager = SpringReactiveTransactionManager( + cxFactory(), + R2dbcDatabaseConfig { + useNestedTransactions = true + explicitDialect = H2Dialect() + } + ) +} + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [TestConfig::class]) +@TestMethodOrder(MethodOrderer.MethodName::class) +@Suppress("UnnecessaryAbstractClass") +abstract class SpringReactiveTransactionTestBase { + + @Autowired + lateinit var ctx: ApplicationContext + + @Autowired + lateinit var transactionManager: ReactiveTransactionManager + + /** + * Invokes [runTest] with the [testBody] executed by a [TransactionalOperator] that is set up to follow the same + * rollback rules as `@Transactional`. + * + * Currently, `@Transactional` in Spring's `TestContext` is only configured to find a `PlatformTransactionManager`, + * so it is completely unusable for Spring-R2dbc unit tests. + * + * [Open Issue](https://github.com/spring-projects/spring-framework/issues/24226) + */ + fun runTestWithMockTransactional( + propagationBehavior: Int = TransactionDefinition.PROPAGATION_REQUIRED, + isolationLevel: Int = TransactionDefinition.ISOLATION_DEFAULT, + testBody: suspend TestScope.(ReactiveTransaction) -> Unit + ) { + if (transactionManager !is SpringReactiveTransactionManager) error("Wrong txManager instance: ${this.javaClass.name}") + + val trxDef = DefaultTransactionDefinition(propagationBehavior).apply { + this.isolationLevel = isolationLevel + } + + runTest { + val trxOp = TransactionalOperator.create(transactionManager, trxDef) + trxOp.executeAndAwait { + testBody(it) + it.setRollbackOnly() + } + } + } +} + +suspend fun ReactiveTransactionManager.execute( + propagationBehavior: Int = TransactionDefinition.PROPAGATION_REQUIRED, + isolationLevel: Int = TransactionDefinition.ISOLATION_DEFAULT, + readOnly: Boolean = false, + timeout: Int? = null, + block: suspend (ReactiveTransaction) -> Unit +) { + if (this !is SpringReactiveTransactionManager) error("Wrong txManager instance: ${this.javaClass.name}") + val trxDef = DefaultTransactionDefinition(propagationBehavior).apply { + this.isolationLevel = isolationLevel + if (readOnly) this.isReadOnly = true + if (timeout != null) this.timeout = timeout + } + val trxOp = TransactionalOperator.create(this, trxDef) + trxOp.executeAndAwait { + block(it) + } +} diff --git a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt index 6514a578f8..3c3717b59e 100644 --- a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt +++ b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt @@ -7,8 +7,10 @@ import org.jetbrains.exposed.v1.jdbc.deleteAll import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean @@ -20,6 +22,9 @@ import org.springframework.transaction.annotation.EnableTransactionManagement import org.springframework.transaction.annotation.Transactional import javax.sql.DataSource +// spring-r2dbc has no native support for distributed/XA transactions +// and this class tests such single transactions that span multiple databases. +@Tag(NO_R2DBC_SUPPORT) open class SpringMultiContainerTransactionTest { val orderContainer = AnnotationConfigApplicationContext(OrderConfig::class.java) diff --git a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt index 9982fab49c..8880ca4dd3 100644 --- a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt +++ b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt @@ -3,9 +3,11 @@ package org.jetbrains.exposed.v1.spring.transaction import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.tests.NOT_APPLICABLE_TO_R2DBC import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy @@ -162,6 +164,9 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } + // LazyConnectionDataSourceProxy has no R2DBC equivalent + // https://github.com/spring-projects/spring-framework/issues/33897 + @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction commit with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -171,6 +176,9 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } + // LazyConnectionDataSourceProxy has no R2DBC equivalent + // https://github.com/spring-projects/spring-framework/issues/33897 + @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction rollback with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -215,6 +223,9 @@ class SpringTransactionManagerTest { assertTrue(con1.closeCallCount > 0) } + // Rollback following commit failure was purposefully removed from Spring R2DBC + // https://github.com/spring-projects/spring-framework/pull/27572 + @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction exception on commit and rollback on commit failure`() { con1.mockCommit = { throw SQLException("Commit failure") } From 6daee8bf6b34c1107f4c4c5f60c09560f341a8e9 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:24:45 -0500 Subject: [PATCH 2/7] feat: Rename Spring reactive transaction module & rebase --- build.gradle.kts | 4 ++-- .../src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt | 2 +- gradle/libs.versions.toml | 2 +- settings.gradle.kts | 2 +- .../api/spring7-reactive-transaction.api | 4 ++-- .../build.gradle.kts | 2 +- .../ExposedSpringReactiveTransactionAttributeSource.kt | 2 +- .../reactive/transaction/SpringReactiveTransactionManager.kt | 2 +- .../v1/spring7}/reactive/transaction/ConnectionFactorySpy.kt | 2 +- .../exposed/v1/spring7}/reactive/transaction/ConnectionSpy.kt | 2 +- .../reactive/transaction/ExposedTransactionManagerTest.kt | 2 +- .../v1/spring7}/reactive/transaction/SpringCoroutineTest.kt | 2 +- .../transaction/SpringReactiveTransactionManagerTest.kt | 2 +- .../transaction/SpringReactiveTransactionRollbackTest.kt | 2 +- .../SpringReactiveTransactionSingleConnectionTest.kt | 2 +- .../reactive/transaction/SpringReactiveTransactionTestBase.kt | 2 +- 16 files changed, 18 insertions(+), 18 deletions(-) rename spring-reactive-transaction/api/spring-reactive-transaction.api => spring7-reactive-transaction/api/spring7-reactive-transaction.api (66%) rename {spring-reactive-transaction => spring7-reactive-transaction}/build.gradle.kts (98%) rename {spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt (96%) rename {spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/SpringReactiveTransactionManager.kt (99%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/ConnectionFactorySpy.kt (94%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/ConnectionSpy.kt (97%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/ExposedTransactionManagerTest.kt (99%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/SpringCoroutineTest.kt (96%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/SpringReactiveTransactionManagerTest.kt (99%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/SpringReactiveTransactionRollbackTest.kt (98%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt (98%) rename {spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring => spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7}/reactive/transaction/SpringReactiveTransactionTestBase.kt (98%) diff --git a/build.gradle.kts b/build.gradle.kts index b0d4740fe2..c73cc3ac34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,7 +38,7 @@ dependencies { dokka(projects.exposed.exposedSpringBoot4Starter) dokka(projects.exposed.springTransaction) dokka(projects.exposed.spring7Transaction) - dokka(projects.exposed.springReactiveTransaction) + dokka(projects.exposed.spring7ReactiveTransaction) // Kover aggregated coverage dependencies // Include all source modules for coverage aggregation @@ -48,7 +48,7 @@ dependencies { kover(project(":exposed-java-time")) kover(project(":spring-transaction")) kover(project(":spring7-transaction")) - kover(project(":spring-reactive-transaction")) + kover(project(":spring7-reactive-transaction")) kover(project(":exposed-spring-boot-starter")) kover(project(":exposed-spring-boot4-starter")) kover(project(":exposed-jdbc")) diff --git a/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt b/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt index 99a4ba991d..79d91c8f31 100644 --- a/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt +++ b/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/TestDbDsl.kt @@ -102,7 +102,7 @@ private fun Project.createDbTestTaskByDialect(db: TestDb, taskName: String, dial if (db.ignoresSpringTests(dialect)) { filter { // exclude all test classes in Spring modules: - // spring-transaction, spring7-transaction, spring-reactive-transaction + // spring-transaction, spring7-transaction, spring7-reactive-transaction // exposed-spring-boot-starter, exposed-spring-boot4-starter exclude( "org/jetbrains/exposed/v1/spring/*", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 983967d80b..85c746ba90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,7 @@ spring6-jdbc = { group = "org.springframework", name = "spring-jdbc", version.re spring6-context = { group = "org.springframework", name = "spring-context", version.ref = "springFramework6" } spring6-test = { group = "org.springframework", name = "spring-test", version.ref = "springFramework6" } spring7-jdbc = { group = "org.springframework", name = "spring-jdbc", version.ref = "springFramework7" } -spring-r2dbc = { group = "org.springframework", name = "spring-r2dbc", version.ref = "springFramework7" } +spring7-r2dbc = { group = "org.springframework", name = "spring-r2dbc", version.ref = "springFramework7" } spring7-context = { group = "org.springframework", name = "spring-context", version.ref = "springFramework7" } spring7-test = { group = "org.springframework", name = "spring-test", version.ref = "springFramework7" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c7bf531f71..f24ae2fe89 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,7 +7,7 @@ include("exposed-jodatime") include("exposed-java-time") include("spring-transaction") include("spring7-transaction") -include("spring-reactive-transaction") +include("spring7-reactive-transaction") include("exposed-spring-boot-starter") include("exposed-spring-boot4-starter") include("exposed-jdbc") diff --git a/spring-reactive-transaction/api/spring-reactive-transaction.api b/spring7-reactive-transaction/api/spring7-reactive-transaction.api similarity index 66% rename from spring-reactive-transaction/api/spring-reactive-transaction.api rename to spring7-reactive-transaction/api/spring7-reactive-transaction.api index 3c0839ae1f..81fcb4da1d 100644 --- a/spring-reactive-transaction/api/spring-reactive-transaction.api +++ b/spring7-reactive-transaction/api/spring7-reactive-transaction.api @@ -1,11 +1,11 @@ -public final class org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource : org/springframework/transaction/interceptor/TransactionAttributeSource { +public final class org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource : org/springframework/transaction/interceptor/TransactionAttributeSource { public fun ()V public fun (Lorg/springframework/transaction/interceptor/TransactionAttributeSource;Ljava/util/List;)V public synthetic fun (Lorg/springframework/transaction/interceptor/TransactionAttributeSource;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getTransactionAttribute (Ljava/lang/reflect/Method;Ljava/lang/Class;)Lorg/springframework/transaction/interceptor/TransactionAttribute; } -public final class org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager : org/springframework/transaction/reactive/AbstractReactiveTransactionManager { +public final class org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager : org/springframework/transaction/reactive/AbstractReactiveTransactionManager { public fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;Z)V public synthetic fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V } diff --git a/spring-reactive-transaction/build.gradle.kts b/spring7-reactive-transaction/build.gradle.kts similarity index 98% rename from spring-reactive-transaction/build.gradle.kts rename to spring7-reactive-transaction/build.gradle.kts index edd73a64f0..ea89328c24 100644 --- a/spring-reactive-transaction/build.gradle.kts +++ b/spring7-reactive-transaction/build.gradle.kts @@ -19,7 +19,7 @@ kotlin { dependencies { api(project(":exposed-core")) api(project(":exposed-r2dbc")) - api(libs.spring.r2dbc) + api(libs.spring7.r2dbc) api(libs.spring7.context) implementation(libs.kotlinx.coroutines.reactor) diff --git a/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt similarity index 96% rename from spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt rename to spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt index c22fd1f420..0600e0f47a 100644 --- a/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.R2dbcException import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource diff --git a/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt similarity index 99% rename from spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt rename to spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt index 9510acc13f..d5c2c3b360 100644 --- a/spring-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManager.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.ConnectionFactory import io.r2dbc.spi.IsolationLevel diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionFactorySpy.kt similarity index 94% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionFactorySpy.kt index 070d012897..179c6b2242 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionFactorySpy.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionFactorySpy.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.Connection import io.r2dbc.spi.ConnectionFactories diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt similarity index 97% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt index eb3a49cd46..924da96837 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ConnectionSpy.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.Connection import io.r2dbc.spi.IsolationLevel diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt similarity index 99% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt index eb2f5c4070..d4199610ab 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import kotlinx.coroutines.flow.single import kotlinx.coroutines.test.runTest diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt similarity index 96% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt index 011574611c..2153c180af 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringCoroutineTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import kotlinx.coroutines.* import org.jetbrains.exposed.v1.core.Table diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt similarity index 99% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt index 014fa14bec..b93a075bda 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.ConnectionFactory import kotlinx.coroutines.test.runTest diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt similarity index 98% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt index ea17bbb9b2..10707f6eb5 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionRollbackTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.ConnectionFactories import io.r2dbc.spi.ConnectionFactory diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt similarity index 98% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt index 2edfa43142..58c2cc218c 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.ConnectionFactory import kotlinx.coroutines.flow.toList diff --git a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt similarity index 98% rename from spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt index b218ba51a9..48e12146d1 100644 --- a/spring-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/reactive/transaction/SpringReactiveTransactionTestBase.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt @@ -1,4 +1,4 @@ -package org.jetbrains.exposed.v1.spring.reactive.transaction +package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.ConnectionFactories import io.r2dbc.spi.ConnectionFactory From 5de74acb5af83031ffe9cffec3ce751a7ea5d976 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:58:11 -0500 Subject: [PATCH 3/7] feat: Switch to Spring synchronized transaction over stack mangement where possible --- README.md | 1 + exposed-r2dbc/api/exposed-r2dbc.api | 1 + .../v1/r2dbc/transactions/Transactions.kt | 8 + .../api/spring7-reactive-transaction.api | 1 + ...pringReactiveTransactionAttributeSource.kt | 2 +- .../SpringReactiveTransactionManager.kt | 276 ++++++++---- .../reactive/transaction/ConnectionSpy.kt | 25 ++ .../ExposedTransactionManagerTest.kt | 35 +- .../MixedExposedR2dbcTransactionTest.kt | 169 ++++++++ .../R2dbcExposedTransactionManagerTest.kt | 314 ++++++++++++++ .../transaction/SpringCoroutineTest.kt | 2 +- .../SpringReactiveTransactionManagerTest.kt | 34 +- .../SpringReactiveTransactionRollbackTest.kt | 140 ------ .../SpringReactiveTransactionTestBase.kt | 5 +- .../SpringTransactionRollbackTest.kt | 399 ++++++++++++++++++ ... SpringTransactionSingleConnectionTest.kt} | 10 +- .../spring7/transaction/EntityUpdateTest.kt | 3 + .../ExposedTransactionManagerTest.kt | 3 + .../SpringMultiContainerTransactionTest.kt | 3 + .../SpringTransactionEntityTest.kt | 3 + .../SpringTransactionManagerTest.kt | 5 + .../TransactionSynchronizationTest.kt | 3 + 22 files changed, 1181 insertions(+), 261 deletions(-) create mode 100644 spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt create mode 100644 spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt delete mode 100644 spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt create mode 100644 spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt rename spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/{SpringReactiveTransactionSingleConnectionTest.kt => SpringTransactionSingleConnectionTest.kt} (94%) diff --git a/README.md b/README.md index 02519d300e..a3addbe7ce 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Kotlin version 2.1.+ The following module(s) require JDK 17 or newer: * `spring-transaction` - depends on Spring Framework 6 * `spring7-transaction` - depends on Spring Framework 7 +* `spring7-reactive-transaction` - depends on Spring Framework 7 * `exposed-spring-boot-starter` - depends on Spring Boot 3 * `exposed-spring-boot4-starter` - depends on Spring Boot 4 * `exposed-crypt` - depends on Spring Security 7 diff --git a/exposed-r2dbc/api/exposed-r2dbc.api b/exposed-r2dbc/api/exposed-r2dbc.api index 4440391a9e..3dccfad5ea 100644 --- a/exposed-r2dbc/api/exposed-r2dbc.api +++ b/exposed-r2dbc/api/exposed-r2dbc.api @@ -1066,6 +1066,7 @@ public final class org/jetbrains/exposed/v1/r2dbc/transactions/TransactionsKt { public static synthetic fun inTopLevelSuspendTransaction$default (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase;Lio/r2dbc/spi/IsolationLevel;Ljava/lang/Boolean;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun suspendTransaction (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase;Lio/r2dbc/spi/IsolationLevel;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun suspendTransaction$default (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase;Lio/r2dbc/spi/IsolationLevel;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun viewThreadStack ()Ljava/lang/String; } public abstract class org/jetbrains/exposed/v1/r2dbc/vendors/DatabaseDialectMetadata { diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt index 088f45055d..ce89abdd79 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt @@ -287,3 +287,11 @@ internal suspend fun closeStatementsAndConnection(transaction: R2dbcTransaction) exposedLogger.warn("Transaction close failed: ${it.message}. Statement: $currentStatement", it) } } + +@OptIn(InternalApi::class) +fun viewThreadStack(): String { + val currentThread = Thread.currentThread().name + val currentTrx = ThreadLocalTransactionsStack.getTransactionOrNull()?.transactionId ?: "NOT IN TRX" + val allTrx = ThreadLocalTransactionsStack.threadTransactions()?.map { it.transactionId } ?: listOf("EMPTY STACK") + return "\n\tTHREAD --> $currentThread\n\tTRX --> $currentTrx\n\tSTACK --> $allTrx" +} diff --git a/spring7-reactive-transaction/api/spring7-reactive-transaction.api b/spring7-reactive-transaction/api/spring7-reactive-transaction.api index 81fcb4da1d..121856e5b6 100644 --- a/spring7-reactive-transaction/api/spring7-reactive-transaction.api +++ b/spring7-reactive-transaction/api/spring7-reactive-transaction.api @@ -8,5 +8,6 @@ public final class org/jetbrains/exposed/v1/spring7/reactive/transaction/Exposed public final class org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager : org/springframework/transaction/reactive/AbstractReactiveTransactionManager { public fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;Z)V public synthetic fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getConnectionFactory ()Lio/r2dbc/spi/ConnectionFactory; } diff --git a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt index 0600e0f47a..05efd3822b 100644 --- a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedSpringReactiveTransactionAttributeSource.kt @@ -25,7 +25,7 @@ class ExposedSpringReactiveTransactionAttributeSource( val rules = attr.rollbackRules.toMutableList() rollbackExceptions.forEach { exception -> val containsException = rules.any { - it.exceptionName == exception.name + it is RollbackRuleAttribute && it.exceptionName == exception.name } if (!containsException) { rules.add(RollbackRuleAttribute(exception)) diff --git a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt index d5c2c3b360..498ef80890 100644 --- a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt @@ -1,19 +1,26 @@ package org.jetbrains.exposed.v1.spring7.reactive.transaction +import io.r2dbc.spi.Connection import io.r2dbc.spi.ConnectionFactory import io.r2dbc.spi.IsolationLevel import io.r2dbc.spi.R2dbcException +import kotlinx.coroutines.reactive.awaitLast import kotlinx.coroutines.reactor.mono import org.jetbrains.exposed.v1.core.InternalApi import org.jetbrains.exposed.v1.core.StdOutSqlLogger +import org.jetbrains.exposed.v1.core.exposedLogger import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack -import org.jetbrains.exposed.v1.core.transactions.currentTransactionOrNull +import org.jetbrains.exposed.v1.core.transactions.transactionScope import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.currentOrNull import org.jetbrains.exposed.v1.r2dbc.transactions.transactionManager -import org.jetbrains.exposed.v1.r2dbc.withTransactionContext +import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack +import org.reactivestreams.Publisher import org.springframework.r2dbc.UncategorizedR2dbcException +import org.springframework.r2dbc.connection.ConnectionHolder +import org.springframework.transaction.CannotCreateTransactionException import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionSystemException import org.springframework.transaction.reactive.AbstractReactiveTransactionManager @@ -30,30 +37,29 @@ import reactor.core.publisher.Mono * @property showSql Whether transaction queries should be logged. Defaults to `false`. */ class SpringReactiveTransactionManager( - connectionFactory: ConnectionFactory, + val connectionFactory: ConnectionFactory, databaseConfig: R2dbcDatabaseConfig.Builder, private val showSql: Boolean = false, ) : AbstractReactiveTransactionManager() { private val database: R2dbcDatabase = R2dbcDatabase.connect( connectionFactory = connectionFactory, - databaseConfig = databaseConfig + databaseConfig = databaseConfig, ) override fun doGetTransaction( synchronizationManager: TransactionSynchronizationManager ): Any { - val holder = ExposedTransactionObject(database = database) - val outer = holder.getCurrentTransaction() - - // Only clears up leftovers between transactions, to prevent invalid re-use; - // Will not be able to clean the final active transaction - if (outer != null && synchronizationManager.getResource(database) == null) { - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.popTransaction() + val retrieved = ExposedTransactionObject( + database = database, + ).apply { + connectionHolder = synchronizationManager.getResourceHolderOrNull() + synchronizationManager.getResourceAndSynchronize(this) } - return holder + synchronizationManager.printEverything(::doGetTransaction.name) + + return retrieved } override fun doSuspend( @@ -63,18 +69,16 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - val currentTransaction = trxObject.getCurrentTransaction() + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doSuspend.name) - val holder = SuspendedObject( - transaction = currentTransaction ?: error("No transaction to suspend"), - ) - synchronizationManager.unbindResource(database) - - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.popTransaction() + trxObject.connectionHolder = null - Mono.just(holder) + Mono + .justOrEmpty(synchronizationManager.unbindResource(connectionFactory) as ExposedHolderObject) + // traditionally should pop in doOnSuccess (done when syncing) } + // doAfterTerminate on defer would be closest but causes no transaction in context } override fun doResume( @@ -83,17 +87,18 @@ class SpringReactiveTransactionManager( suspendedResources: Any ): Mono { return Mono.defer { - val suspendedObject = suspendedResources as SuspendedObject + val suspendedObject = suspendedResources as ExposedHolderObject - val suspendedTransaction = suspendedObject.transaction - - synchronizationManager.bindResource(database, suspendedTransaction) + synchronizationManager.bindResource(connectionFactory, suspendedObject) @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.pushTransaction(suspendedTransaction) + ThreadLocalTransactionsStack.pushTransaction(suspendedObject.transaction) + + synchronizationManager.printEverything(::doResume.name) Mono.empty() } + // doFinally on defer may be a good contender } override fun isExistingTransaction(transaction: Any): Boolean { @@ -112,13 +117,13 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - @OptIn(InternalApi::class) - val currentTransaction = currentTransactionOrNull() as R2dbcTransaction? + val currentTransaction = synchronizationManager.getResourceAndSynchronize(transaction) val outerTransactionToUse = if (currentTransaction?.db == database) { currentTransaction } else { null } + synchronizationManager.printEverything(::doBegin.name) val newTransaction = trxObject.database.transactionManager.newTransaction( isolation = definition.isolationLevel.resolveIsolationLevel(), @@ -134,37 +139,65 @@ class SpringReactiveTransactionManager( } } - trxObject.isNewConnection = newTransaction.outerTransaction == null || trxObject.isNestedTransactionAllowed - if (trxObject.isNewConnection) { - // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its outer - // transaction used when doCommit() or doRollback() is invoked - synchronizationManager.unbindResourceIfPossible(database) + val newConnectionMono = mono { + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doResume.name) + + if (trxObject.connectionHolder == null) { + trxObject.connectionHolder = ExposedHolderObject(newTransaction.awaitConnection(), newTransaction) + trxObject.isNewConnectionHolder = true + } + trxObject.connectionHolder?.isSynchronizedWithTransaction = true - synchronizationManager.bindResource(database, newTransaction) + if (definition.timeout != TransactionDefinition.TIMEOUT_DEFAULT) { + trxObject.connectionHolder?.setTimeoutInSeconds(definition.timeout) + } } - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.pushTransaction(newTransaction) + Mono + .just(newTransaction) + .doOnSuccess { + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.pushTransaction(newTransaction) + } + .then(newConnectionMono) + .doOnSuccess { + if (trxObject.isNewConnectionHolder) { + synchronizationManager.bindResource(connectionFactory, trxObject.connectionHolder!!) + } + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doBegin.name) + } + .doOnError { ex -> + trxObject.connectionHolder = null + + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.popTransaction() - Mono.just(newTransaction) - }.then() + throw CannotCreateTransactionException("Could not open R2DBC Connection for transaction", ex) + } + // doFinally on kotlin mono may be a good contender + } + .then() } override fun doCommit( synchronizationManager: TransactionSynchronizationManager, status: GenericReactiveTransaction ): Mono { - return Mono.defer { - val trxObject = status.transaction as ExposedTransactionObject + val trxObject = status.transaction as ExposedTransactionObject - mono { - @OptIn(InternalApi::class) - withTransactionContext(synchronizationManager.getResourceOrThrow()) { - trxObject.commit() - } + synchronizationManager.getResourceAndSynchronize(trxObject) + ?: error("No synchronized transaction to commit") + synchronizationManager.printEverything(::doCommit.name) - null - } + return mono { + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doCommit.name) + + trxObject.commit() + + null } } @@ -172,17 +205,19 @@ class SpringReactiveTransactionManager( synchronizationManager: TransactionSynchronizationManager, status: GenericReactiveTransaction ): Mono { - return Mono.defer { - val trxObject = status.transaction as ExposedTransactionObject + val trxObject = status.transaction as ExposedTransactionObject - mono { - @OptIn(InternalApi::class) - withTransactionContext(synchronizationManager.getResourceOrThrow()) { - trxObject.rollback() - } + synchronizationManager.getResourceAndSynchronize(trxObject) + ?: error("No synchronized transaction to rollback") + synchronizationManager.printEverything(::doRollback.name) - null - } + return mono { + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doRollback.name) + + trxObject.rollback() + + null } } @@ -193,32 +228,42 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - mono { - @OptIn(InternalApi::class) - withTransactionContext(synchronizationManager.getResourceOrThrow()) { - val completedTransaction = trxObject.getCurrentTransaction() - - completedTransaction - ?.let { - clearStatements(it) + val completedTransaction = synchronizationManager.getResourceAndSynchronize(trxObject)?.also { + clearStatements(it) + } + synchronizationManager.printEverything(::doCleanupAfterCompletion.name) - if (trxObject.isNewConnection) { - synchronizationManager.unbindResource(database) + if (trxObject.isNewConnectionHolder) { + synchronizationManager.unbindResource(connectionFactory) - // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its - // now closed inner transaction used when doCommit() or doRollback() is later invoked - it.outerTransaction?.let { outer -> - synchronizationManager.bindResource(database, outer) - } - } + // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its + // now closed inner transaction used when doCommit() or doRollback() is later invoked + completedTransaction?.outerTransaction?.let { outer -> + synchronizationManager.bindResource(database, outer) - it.close() - } + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.pushTransaction(outer) } + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doCleanupAfterCompletion.name) + } + + mono { + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doCleanupAfterCompletion.name) + completedTransaction?.close() + null } + .doOnEach { + if (trxObject.isNewConnectionHolder) { + trxObject.connectionHolder?.released() + } + trxObject.connectionHolder?.clear() + } } + // doFinally on defer may be a good contender } private fun clearStatements(transaction: R2dbcTransaction) { @@ -237,25 +282,32 @@ class SpringReactiveTransactionManager( return Mono.fromRunnable { val trxObject = status.transaction as ExposedTransactionObject + synchronizationManager.getResourceAndSynchronize(trxObject) + synchronizationManager.printEverything(::doSetRollbackOnly.name) + + if (status.isDebug) { + exposedLogger.debug("Exposed transaction [${status.transactionName}] set rollback-only") + } + trxObject.setRollbackOnly() } } - private fun TransactionSynchronizationManager.getResourceOrThrow(): R2dbcTransaction { - return this.getResource(database) as? R2dbcTransaction ?: error("No transaction value bound to the current context") - } + private class ExposedHolderObject( + connection: Connection, + val transaction: R2dbcTransaction, + ) : ConnectionHolder(connection) - private data class SuspendedObject( - val transaction: R2dbcTransaction - ) + @Suppress("UNCHECKED_CAST") + private suspend fun R2dbcTransaction.awaitConnection(): Connection { + return (this.connection().connection as Publisher).awaitLast() + } private data class ExposedTransactionObject( - val database: R2dbcDatabase + val database: R2dbcDatabase, ) { - private var isRollback: Boolean = false - - val isNestedTransactionAllowed = database.config.useNestedTransactions - var isNewConnection: Boolean = false + var isNewConnectionHolder: Boolean = false + var connectionHolder: ExposedHolderObject? = null @Suppress("TooGenericExceptionCaught") suspend fun commit() { @@ -279,17 +331,63 @@ class SpringReactiveTransactionManager( } } - @OptIn(InternalApi::class) fun getCurrentTransaction(): R2dbcTransaction? { - return ThreadLocalTransactionsStack.getTransactionOrNull(database) as R2dbcTransaction? + return connectionHolder?.transaction } fun setRollbackOnly() { - isRollback = true + getCurrentTransaction()?.isRollback = true + connectionHolder?.setRollbackOnly() } } + + private fun TransactionSynchronizationManager.getResourceHolderOrNull(): ExposedHolderObject? { + return this.getResource(connectionFactory) as? ExposedHolderObject + } + + private fun TransactionSynchronizationManager.getResourceOrNull(): R2dbcTransaction? { + return this.getResourceHolderOrNull()?.transaction + } + + private fun TransactionSynchronizationManager.getResourceAndSynchronize( + trxObject: ExposedTransactionObject + ): R2dbcTransaction? { + val currentFromResource = this.getResourceOrNull() + val currentOnStack = trxObject.database.transactionManager.currentOrNull() + return when { + currentOnStack == null && currentFromResource != null -> { + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.pushTransaction(currentFromResource) + currentFromResource + } + currentOnStack != null && currentFromResource == null -> { + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.threadTransactions()?.clear() + null + } + currentOnStack != null && currentFromResource != null && currentOnStack != currentFromResource -> { + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.threadTransactions()?.clear() + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.pushTransaction(currentFromResource) + currentFromResource + } + else -> currentOnStack + } + } + + @OptIn(InternalApi::class) + private fun TransactionSynchronizationManager.printEverything(methodName: String) { + val resource = this.getResourceOrNull()?.transactionId ?: "NO SPRING TRX" + println("In $methodName...${viewThreadStack()}\n\tSPRING --> $resource") + } } +private var R2dbcTransaction.isRollback: Boolean by transactionScope { false } + +/** Returns the rollback status of the current [R2dbcTransaction]. */ +internal fun R2dbcTransaction.isMarkedRollback(): Boolean = isRollback + internal fun Int.resolveIsolationLevel(): IsolationLevel? = when (this) { TransactionDefinition.ISOLATION_READ_UNCOMMITTED -> IsolationLevel.READ_UNCOMMITTED TransactionDefinition.ISOLATION_READ_COMMITTED -> IsolationLevel.READ_COMMITTED diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt index 924da96837..950bcc0bd7 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt @@ -2,14 +2,17 @@ package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.Connection import io.r2dbc.spi.IsolationLevel +import io.r2dbc.spi.TransactionDefinition import org.reactivestreams.Publisher import reactor.core.publisher.Mono class ConnectionSpy(private val connection: Connection) : Connection by connection { + // some mocks from JDBC tests are excluded as they have no relevance here, like mockIsClosed var commitCallCount: Int = 0 var rollbackCallCount: Int = 0 var closeCallCount: Int = 0 var releaseSavepointCallCount: Int = 0 + var mockReadOnly: Boolean = false var mockAutoCommit: Boolean = false var mockTransactionIsolation: IsolationLevel = IsolationLevel.READ_COMMITTED var mockCommit: () -> Unit = {} @@ -26,6 +29,7 @@ class ConnectionSpy(private val connection: Connection) : Connection by connecti rollbackCallCount = 0 closeCallCount = 0 releaseSavepointCallCount = 0 + mockReadOnly = false mockAutoCommit = false mockTransactionIsolation = IsolationLevel.READ_COMMITTED mockCommit = {} @@ -40,6 +44,13 @@ class ConnectionSpy(private val connection: Connection) : Connection by connecti Mono.empty() } as Publisher + override fun setAutoCommit(autoCommit: Boolean): Publisher? = Mono.defer { + callOrder.add("setAutoCommit") + mockAutoCommit = autoCommit + + Mono.empty() + } as Publisher + override fun beginTransaction(): Publisher = Mono.defer { callOrder.add("setAutoCommit") mockAutoCommit = false @@ -47,6 +58,20 @@ class ConnectionSpy(private val connection: Connection) : Connection by connecti Mono.empty() } as Publisher + override fun beginTransaction(definition: TransactionDefinition?): Publisher? = Mono.defer { + callOrder.add("setAutoCommit") + mockAutoCommit = false + + definition + ?.getAttribute(TransactionDefinition.READ_ONLY) + ?.let { + callOrder.add("setReadOnly") + mockReadOnly = it + } + + Mono.empty() + } as Publisher + override fun isAutoCommit(): Boolean = mockAutoCommit override fun commitTransaction(): Publisher = Mono.defer { diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt index d4199610ab..c8197a84f3 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt @@ -2,17 +2,16 @@ package org.jetbrains.exposed.v1.spring7.reactive.transaction import kotlinx.coroutines.flow.single import kotlinx.coroutines.test.runTest -import org.jetbrains.exposed.v1.core.InternalApi import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.r2dbc.SchemaUtils import org.jetbrains.exposed.v1.r2dbc.insert import org.jetbrains.exposed.v1.r2dbc.selectAll import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.RepeatedTest import org.springframework.test.annotation.Commit import org.springframework.transaction.IllegalTransactionStateException @@ -32,33 +31,37 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { } } - @OptIn(InternalApi::class) @BeforeEach fun beforeTest() = runTest { + println("Starting beforeTest... : ${viewThreadStack()}") transactionManager.execute { + println("Doing work... : ${viewThreadStack()}") SchemaUtils.create(T1) } + println("Ending beforeTest... : ${viewThreadStack()}") } - @OptIn(InternalApi::class) @AfterEach fun afterTest() = runTest { + println("Starting afterTest... : ${viewThreadStack()}") transactionManager.execute { + println("Doing work... : ${viewThreadStack()}") SchemaUtils.drop(T1) } + println("Ending afterTest... : ${viewThreadStack()}") } + @RepeatedTest(5) // @Transactional // see [runTestWithMockTransactional] @Commit - @RepeatedTest(5) open fun testConnection() = runTestWithMockTransactional { T1.insertRandom() assertEquals(1, T1.selectAll().count()) } + @RepeatedTest(5) // @Transactional // see [runTestWithMockTransactional] @Commit - @RepeatedTest(5) open fun testConnection2() = runTestWithMockTransactional { val rnd = Random().nextInt().toString() T1.insert { @@ -86,7 +89,6 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { // TODO - This (& only this test?) fails because of line 114 in suspendTransaction(); // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) - @Disabled @RepeatedTest(5) @Commit // @Transactional // see [runTestWithMockTransactional] @@ -164,18 +166,33 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { * Test for Propagation.REQUIRES_NEW * Create a new transaction, and suspend the current transaction if one exists. */ - @Disabled("After doResume(), TransactionManager.current() in count() returns wrong unpopped transaction from thread-switch") @RepeatedTest(1) // @Transactional // see [runTestWithMockTransactional] open fun testConnectionWithRequiresNew() = runTestWithMockTransactional { + println("Starting actual test... : ${viewThreadStack()}") T1.insertRandom() assertEquals(1, T1.selectAll().count()) +// val currentCx1 = ConnectionFactoryUtils.doGetConnection( +// (transactionManager as SpringReactiveTransactionManager).connectionFactory +// ).awaitSingle() + println("Finished first work...") transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + println("Nested in test... : ${viewThreadStack()}") +// val currentCx2 = ConnectionFactoryUtils.doGetConnection( +// (transactionManager as SpringReactiveTransactionManager).connectionFactory +// ).awaitSingle() +// println("Nested work... on $currentCx2") assertEquals(0, T1.selectAll().count()) T1.insertRandom() assertEquals(1, T1.selectAll().count()) } + println("No longer nested in test... : ${viewThreadStack()}") +// val currentCx3 = ConnectionFactoryUtils.doGetConnection( +// (transactionManager as SpringReactiveTransactionManager).connectionFactory +// ).awaitSingle() +// println("Outer work... on $currentCx3") assertEquals(2, T1.selectAll().count()) + println("Ending actual test... : ${viewThreadStack()}") } // TODO @@ -184,7 +201,6 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { * The inner transaction will be roll-back only inner transaction when the transaction marks as rollback. * And since isolation level is READ_COMMITTED, the inner transaction can't see the changes of outer transaction. */ - @Disabled("After doResume(), TransactionManager.current() in count() returns wrong unpopped transaction from thread-switch") @RepeatedTest(5) fun testConnectionWithRequiresNewWithInnerTransactionRollback() = runTest { transactionManager.execute { @@ -284,7 +300,6 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { } } - @Disabled @RepeatedTest(5) // @Transactional(isolation = Isolation.READ_COMMITTED) // see [runTestWithMockTransactional] open fun testIsolationLevelReadUncommitted() = runTestWithMockTransactional( diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt new file mode 100644 index 0000000000..494ce6cd38 --- /dev/null +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt @@ -0,0 +1,169 @@ +package org.jetbrains.exposed.v1.spring7.reactive.transaction + +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class MixedExposedR2dbcTransactionTest : SpringReactiveTransactionTestBase() { + + @Autowired + private lateinit var mixedTransactionService: MixedTransactionService + + @BeforeEach + fun setUp() = runTest { + suspendTransaction { + SchemaUtils.create(CustomerTable) + } + } + + @AfterEach + fun tearDown() = runTest { + suspendTransaction { + SchemaUtils.drop(CustomerTable) + } + } + + @Test + fun testSuccessfulMixedTransaction() = runTest { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + + val customers = suspendTransaction { CustomerTable.selectAll().toList() } + + assertEquals(2, customers.size) + } + + @Test + fun testFailedMixedTransaction() = runTest { + assertFailsWith { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = true) + } + + val customers = suspendTransaction { CustomerTable.selectAll().toList() } + + assertEquals(0, customers.size) + } + + @Test + fun testSuccessfulRequiresNewTransactions() = runTest { + mixedTransactionService.withNewTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + mixedTransactionService.withNewTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + } + } + + val customers = suspendTransaction { CustomerTable.selectAll().toList() } + + assertEquals(4, customers.size) + } + + @Test + fun testFailedRequiresNewTransactions() = runTest { + mixedTransactionService.withNewTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + assertFailsWith { + mixedTransactionService.withNewTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = true) + } + } + } + + val customers = suspendTransaction { CustomerTable.selectAll().toList() } + + assertEquals(2, customers.size) + } + + @Test + fun testSuccessfulNestedTransactions() = runTest { + mixedTransactionService.withNewTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + mixedTransactionService.withNestedTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + } + } + + val customers = suspendTransaction { CustomerTable.selectAll().toList() } + + assertEquals(4, customers.size) + } + + @Test + fun testFailedNestedTransactions() = runTest { + mixedTransactionService.withNewTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = false) + assertFailsWith { + mixedTransactionService.withNestedTransaction { + mixedTransactionService.saveTwoThingsSpringTransactional(fail = true) + } + } + } + + val customers = suspendTransaction { CustomerTable.selectAll().toList() } + + assertEquals(2, customers.size) + } +} + +@Service +open class MixedTransactionService { + + @Autowired + private lateinit var connectionFactory: ConnectionFactory + + private val client: DatabaseClient by lazy { DatabaseClient.create(connectionFactory) } + private var nextNameIndex: Int = 0 + + @Transactional + open suspend fun saveTwoThingsSpringTransactional(fail: Boolean) { + saveTwoThings(fail) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + open suspend fun withNewTransaction(block: suspend () -> T): T { + return block() + } + + @Transactional(propagation = Propagation.NESTED) + open suspend fun withNestedTransaction(block: suspend () -> T): T { + return block() + } + + private suspend fun saveTwoThings(fail: Boolean) { + CustomerTable.insert { + it[id] = UUID.randomUUID() + it[name] = "Test${nextNameIndex++}" + } + client + .sql("INSERT INTO customer VALUES (:id, :name)") + .bind("id", UUID.randomUUID()) + .bind("name", "Test${nextNameIndex++}") + .fetch() + .rowsUpdated() + + @Suppress("UseCheckOrError") + if (fail) { + throw IllegalStateException("Fail") + } + } +} + +// originally should be in SpringTransactionEntityTest (but this does not exist for R2DBC) +object CustomerTable : UUIDTable(name = "customer") { + val name = varchar(name = "name", length = 255).uniqueIndex() +} diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt new file mode 100644 index 0000000000..7f3422d6eb --- /dev/null +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt @@ -0,0 +1,314 @@ +package org.jetbrains.exposed.v1.spring7.reactive.transaction + +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.RepeatedTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.r2dbc.core.awaitRowsUpdated +import org.springframework.r2dbc.core.awaitSingle +import org.springframework.test.annotation.Commit +import org.springframework.transaction.IllegalTransactionStateException +import org.springframework.transaction.TransactionDefinition +import java.util.* +import kotlin.test.assertFailsWith + +/** + * Similar to [ExposedTransactionManagerTest] but working with Spring R2DBC + * constructs like [DatabaseClient] instead of Exposed APIs to verify that it + * works like expected. + */ +open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { + object T1 : Table() { + val c1 = varchar("c1", Int.MIN_VALUE.toString().length) + } + + @Autowired + private lateinit var connectionFactory: ConnectionFactory + + private val r2dbc: DatabaseClient by lazy { DatabaseClient.create(connectionFactory) } + + private suspend fun insertRandom() { + r2dbc.sql( + "INSERT INTO ${T1.tableName} VALUES (:value)" + ).bind( + "value", Random().nextInt().toString() + ).fetch().awaitRowsUpdated() + } + + private suspend fun insert(value: String) { + r2dbc.sql( + "INSERT INTO ${T1.tableName} VALUES (:value)" + ).bind( + "value", value + ).fetch().awaitRowsUpdated() + } + + private suspend fun getCount(): Int = r2dbc + .sql("SELECT count(*) FROM ${T1.tableName}") + .map { row, _ -> + row.get(0, java.lang.Long::class.java)?.toInt() ?: 0 + } + .awaitSingle() + + private suspend fun getSingleValue(): String = r2dbc + .sql("SELECT * FROM ${T1.tableName}") + .map { row, _ -> row.get("c1", String::class.java) ?: "" } + .awaitSingle() + + @BeforeEach + fun beforeTest() = runTest { + transactionManager.execute { + SchemaUtils.create(T1) + } + } + + @AfterEach + fun afterTest() = runTest { + transactionManager.execute { + SchemaUtils.drop(T1) + } + } + + @RepeatedTest(5) + // @Transactional // see [runTestWithMockTransactional] + @Commit + open fun testConnection() = runTestWithMockTransactional { + insertRandom() + assertEquals(1, getCount()) + } + + @RepeatedTest(5) + // @Transactional // see [runTestWithMockTransactional] + @Commit + open fun testConnection2() = runTestWithMockTransactional { + val rnd = Random().nextInt().toString() + insert(rnd) + assertEquals(rnd, getSingleValue()) + } + + @RepeatedTest(5) + @Commit + open fun testConnectionCombineWithExposedTransaction() = runTest { + suspendTransaction { + val rnd = Random().nextInt().toString() + insert(rnd) + assertEquals(rnd, getSingleValue()) + + this@R2dbcExposedTransactionManagerTest.transactionManager.execute { + insertRandom() + assertEquals(2, getCount()) + } + } + } + + // TODO - This (& only this test?) fails because of line 114 in suspendTransaction(); + // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) + @RepeatedTest(5) + @Commit +// @Transactional // see [runTestWithMockTransactional] + open fun testConnectionCombineWithExposedTransaction2() = runTestWithMockTransactional { + val rnd = Random().nextInt().toString() + insert(rnd) + assertEquals(rnd, getSingleValue()) + + suspendTransaction { + insertRandom() + assertEquals(2, getCount()) + } + } + + /** + * Test for Propagation.NESTED + * Execute within a nested transaction if a current transaction exists, behave like REQUIRED otherwise. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testConnectionWithNestedTransactionCommit() = runTestWithMockTransactional { + insertRandom() + assertEquals(1, getCount()) + transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { + insertRandom() + assertEquals(2, getCount()) + } + assertEquals(2, getCount()) + } + + /** + * Test for Propagation.NESTED with inner roll-back + * The nested transaction will be roll-back only inner transaction when the transaction marks as rollback. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testConnectionWithNestedTransactionInnerRollback() = runTestWithMockTransactional { + insertRandom() + assertEquals(1, getCount()) + transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { status -> + insertRandom() + assertEquals(2, getCount()) + status.setRollbackOnly() + } + assertEquals(1, getCount()) + } + + /** + * Test for Propagation.NESTED with outer roll-back + * The nested transaction will be roll-back entire transaction when the transaction marks as rollback. + */ + @RepeatedTest(5) + fun testConnectionWithNestedTransactionOuterRollback() = runTest { + transactionManager.execute { + insertRandom() + assertEquals(1, getCount()) + it.setRollbackOnly() + + transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { + insertRandom() + assertEquals(2, getCount()) + } + assertEquals(2, getCount()) + } + + transactionManager.execute { + assertEquals(0, getCount()) + } + } + + // TODO + /** + * Test for Propagation.REQUIRES_NEW + * Create a new transaction, and suspend the current transaction if one exists. + */ + @RepeatedTest(5) + // @Transactional // see [runTestWithMockTransactional] + open fun testConnectionWithRequiresNew() = runTestWithMockTransactional { + insertRandom() + assertEquals(1, getCount()) + transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + assertEquals(0, getCount()) + insertRandom() + assertEquals(1, getCount()) + } + assertEquals(2, getCount()) + } + + // TODO + /** + * Test for Propagation.REQUIRES_NEW with inner transaction roll-back + * The inner transaction will be roll-back only inner transaction when the transaction marks as rollback. + * And since isolation level is READ_COMMITTED, the inner transaction can't see the changes of outer transaction. + */ + @RepeatedTest(5) + fun testConnectionWithRequiresNewWithInnerTransactionRollback() = runTest { + transactionManager.execute { + insertRandom() + assertEquals(1, getCount()) + transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + insertRandom() + assertEquals(1, getCount()) + it.setRollbackOnly() + } + assertEquals(1, getCount()) + } + + transactionManager.execute { + assertEquals(1, getCount()) + } + } + + /** + * Test for Propagation.NEVER + * Throw an exception cause outer transaction exists. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testPropagationNeverWithExistingTransaction() = runTestWithMockTransactional { + assertFailsWith { + insertRandom() + transactionManager.execute(TransactionDefinition.PROPAGATION_NEVER) { + insertRandom() + } + } + } + + /** + * Test for Propagation.MANDATORY + * Support a current transaction, throw an exception if none exists. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testPropagationMandatoryWithTransaction() = runTestWithMockTransactional { + insertRandom() + transactionManager.execute(TransactionDefinition.PROPAGATION_MANDATORY) { + insertRandom() + } + } + + /** + * Test for Propagation.MANDATORY + * Throw an exception cause no transaction exists. + */ + @RepeatedTest(5) + open fun testPropagationMandatoryWithoutTransaction() = runTest { + assertFailsWith { + transactionManager.execute(TransactionDefinition.PROPAGATION_MANDATORY) { + insertRandom() + } + } + } + + /** + * Test for Propagation.SUPPORTS + * Support a current transaction, execute non-transactionally if none exists. + */ + @RepeatedTest(5) +// @Transactional // see [runTestWithMockTransactional] + open fun testPropagationSupportWithTransaction() = runTestWithMockTransactional { + insertRandom() + transactionManager.execute(TransactionDefinition.PROPAGATION_SUPPORTS) { + insertRandom() + } + } + + /** + * Test for Propagation.SUPPORTS + * Execute non-transactionally if none exists. + */ + @RepeatedTest(5) + open fun testPropagationSupportWithoutTransaction() = runTest { + transactionManager.execute(TransactionDefinition.PROPAGATION_SUPPORTS) { + insertRandom() + } + assertEquals(1, getCount()) + } + + @RepeatedTest(5) + // @Transactional(isolation = Isolation.READ_COMMITTED) // see [runTestWithMockTransactional] + open fun testIsolationLevelReadUncommitted() = runTestWithMockTransactional( + isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED + ) { + assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) + insertRandom() + val count = getCount() + transactionManager.execute( + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + TransactionDefinition.ISOLATION_READ_UNCOMMITTED + ) { + assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED) + assertEquals(count, getCount()) + } + assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) + } + + private suspend fun assertTransactionIsolationLevel(expected: Int) { + val connection = TransactionManager.current().connection() + assertEquals(expected.resolveIsolationLevel(), connection.getTransactionIsolation()) + } +} diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt index 2153c180af..c7e94b7b0e 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringCoroutineTest.kt @@ -17,7 +17,7 @@ open class SpringCoroutineTest : SpringReactiveTransactionTestBase() { override val primaryKey = PrimaryKey(id) } - @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) @RepeatedTest(5) // @Transactional // see [runTestWithMockTransactional] @Commit diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt index b93a075bda..d643e11b00 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt @@ -1,26 +1,24 @@ package org.jetbrains.exposed.v1.spring7.reactive.transaction import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.R2dbcRollbackException import kotlinx.coroutines.test.runTest -import org.jetbrains.exposed.v1.core.InternalApi -import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.springframework.r2dbc.UncategorizedR2dbcException import org.springframework.r2dbc.connection.TransactionAwareConnectionFactoryProxy import org.springframework.transaction.IllegalTransactionStateException import org.springframework.transaction.ReactiveTransaction -import org.springframework.transaction.ReactiveTransactionManager import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.TransactionSystemException import org.springframework.transaction.reactive.TransactionalOperator import org.springframework.transaction.reactive.executeAndAwait import org.springframework.transaction.support.DefaultTransactionDefinition -import java.sql.SQLException import kotlin.test.assertFailsWith import kotlin.test.assertTrue @@ -32,6 +30,17 @@ class SpringReactiveTransactionManagerTest { val cf2 = ConnectionFactorySpy(::ConnectionSpy) lateinit var con2: ConnectionSpy + /** + * This test has a teardown that unregisters databases. The intention + * is to only tear down databases registered by this test - but + * an earlier implementation accidentally unregistered the main + * database registered by the [SpringReactiveTransactionTestBase]. + * + * To avoid doing such, we try to only unregister until we hit the + * one that was there before the start of this test. + */ + private var databaseBeforeTestStart: R2dbcDatabase? = null + @BeforeAll @JvmStatic fun init() = runTest { @@ -40,21 +49,20 @@ class SpringReactiveTransactionManagerTest { } } - @OptIn(InternalApi::class) @BeforeEach fun beforeTest() { + databaseBeforeTestStart = TransactionManager.primaryDatabase con1.clearMock() con2.clearMock() // TODO - this should not be done, but transactions are not being popped on original thread after coroutine switches thread - ThreadLocalTransactionsStack.threadTransactions()?.clear() +// ThreadLocalTransactionsStack.threadTransactions()?.clear() } - @OptIn(InternalApi::class) @BeforeEach fun afterTest() { - while (TransactionManager.defaultDatabase != null) { - TransactionManager.defaultDatabase?.let { TransactionManager.closeAndUnregister(it) } + while (TransactionManager.primaryDatabase != databaseBeforeTestStart) { + TransactionManager.primaryDatabase?.let { TransactionManager.closeAndUnregister(it) } } } @@ -204,10 +212,10 @@ class SpringReactiveTransactionManagerTest { @Test fun `transaction with exception on rollback`() = runTest { - con1.mockRollback = { throw SQLException("Rollback failure") } + con1.mockRollback = { throw R2dbcRollbackException("Rollback failure") } val tm = getDefaultManager(cf1) - assertFailsWith { + assertFailsWith { tm.executeAssert { assertEquals(false, it.isRollbackOnly) it.setRollbackOnly() @@ -379,7 +387,7 @@ class SpringReactiveTransactionManagerTest { databaseConfig = databaseConfig.apply { explicitDialect = H2Dialect() } ) - private suspend fun ReactiveTransactionManager.executeAssert( + private suspend fun SpringReactiveTransactionManager.executeAssert( initializeConnection: Boolean = true, propagationBehavior: Int = TransactionDefinition.PROPAGATION_REQUIRED, timeout: Int? = null, diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt deleted file mode 100644 index 10707f6eb5..0000000000 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionRollbackTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.jetbrains.exposed.v1.spring7.reactive.transaction - -import io.r2dbc.spi.ConnectionFactories -import io.r2dbc.spi.ConnectionFactory -import kotlinx.coroutines.test.runTest -import org.jetbrains.exposed.v1.core.InternalApi -import org.jetbrains.exposed.v1.core.dao.id.LongIdTable -import org.jetbrains.exposed.v1.core.vendors.H2Dialect -import org.jetbrains.exposed.v1.r2dbc.ExposedR2dbcException -import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig -import org.jetbrains.exposed.v1.r2dbc.SchemaUtils -import org.jetbrains.exposed.v1.r2dbc.deleteAll -import org.jetbrains.exposed.v1.r2dbc.insert -import org.jetbrains.exposed.v1.r2dbc.selectAll -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.context.annotation.AnnotationConfigApplicationContext -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.transaction.annotation.EnableTransactionManagement -import org.springframework.transaction.annotation.Transactional -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class SpringReactiveTransactionRollbackTest { - - val container = AnnotationConfigApplicationContext(TransactionManagerAttributeSourceTestConfig::class.java) - - @OptIn(InternalApi::class) - @BeforeEach - fun beforeTest() = runTest { - val testRollback = container.getBean(TestRollback::class.java) - testRollback.init() - } - - @OptIn(InternalApi::class) - @AfterEach - fun afterTest() { - container.close() - } - - @Test - fun `test ExposedR2dbcException rollback`() = runTest { - val testRollback = container.getBean(TestRollback::class.java) - assertFailsWith { - testRollback.suspendTransaction { - insertOriginTable() - insertWrongTable("1234567890") - } - } - - assertEquals(0, testRollback.entireTableSize()) - } - - @Test - fun `test RuntimeException rollback`() = runTest { - val testRollback = container.getBean(TestRollback::class.java) - assertFailsWith { - testRollback.suspendTransaction { - insertOriginTable() - @Suppress("TooGenericExceptionThrown") - throw RuntimeException() - } - } - - assertEquals(0, testRollback.entireTableSize()) - } - - @Test - fun `test check exception commit`() = runTest { - val testRollback = container.getBean(TestRollback::class.java) - assertFailsWith { - testRollback.suspendTransaction { - insertOriginTable() - @Suppress("TooGenericExceptionThrown") - throw Exception() - } - } - - assertEquals(1, testRollback.entireTableSize()) - } -} - -@Configuration -@EnableTransactionManagement(proxyTargetClass = true) -open class TransactionManagerAttributeSourceTestConfig { - - @Bean - open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest1;DB_CLOSE_DELAY=-1;") - - @Bean - open fun transactionManager(connectionFactory: ConnectionFactory) = SpringReactiveTransactionManager( - connectionFactory, - R2dbcDatabaseConfig { explicitDialect = H2Dialect() } - ) - - @Bean - open fun transactionAttributeSource() = ExposedSpringReactiveTransactionAttributeSource() - - @Bean - open fun testRollback() = TestRollback() -} - -@Transactional -open class TestRollback { - - open suspend fun init() { - SchemaUtils.create(RollbackTable) - RollbackTable.deleteAll() - } - - open suspend fun suspendTransaction(block: suspend TestRollback.() -> Unit) { - block() - } - - open suspend fun insertOriginTable() { - RollbackTable.insert { - it[name] = "1" - } - } - - open suspend fun insertWrongTable(name: String) { - WrongDefinedRollbackTable.insert { - it[WrongDefinedRollbackTable.name] = name - } - } - - open suspend fun entireTableSize(): Long { - return RollbackTable.selectAll().count() - } -} - -object RollbackTable : LongIdTable("test_rollback") { - val name = varchar("name", 5) -} - -object WrongDefinedRollbackTable : LongIdTable("test_rollback") { - val name = varchar("name", 10) -} diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt index 48e12146d1..455348fad9 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionTestBase.kt @@ -33,13 +33,16 @@ open class TestConfig : TransactionManagementConfigurer { open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest;DB_CLOSE_DELAY=-1;") @Bean - override fun annotationDrivenTransactionManager(): SpringReactiveTransactionManager = SpringReactiveTransactionManager( + override fun annotationDrivenTransactionManager(): ReactiveTransactionManager = SpringReactiveTransactionManager( cxFactory(), R2dbcDatabaseConfig { useNestedTransactions = true explicitDialect = H2Dialect() } ) + + @Bean + open fun mixedTransactionService(): MixedTransactionService = MixedTransactionService() } @ExtendWith(SpringExtension::class) diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt new file mode 100644 index 0000000000..981831abae --- /dev/null +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt @@ -0,0 +1,399 @@ +package org.jetbrains.exposed.v1.spring7.reactive.transaction + +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.deleteAll +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.IllegalTransactionStateException +import org.springframework.transaction.UnexpectedRollbackException +import org.springframework.transaction.annotation.EnableTransactionManagement +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SpringTransactionRollbackTest { + + val container = AnnotationConfigApplicationContext(TransactionManagerAttributeSourceTestConfig::class.java) + + @BeforeEach + fun beforeTest() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + testRollback.init() + } + + @AfterEach + fun afterTest() { + container.close() + } + + @Test + fun `test ExposedR2dbcException rollback`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + assertFailsWith { + testRollback.suspendTransaction { + insertOriginTable("1") + insertWrongTable("12345678901234567890") + } + } + + assertEquals(0, testRollback.entireTableSize()) + } + + @Test + fun `test RuntimeException rollback`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + assertFailsWith { + testRollback.suspendTransaction { + insertOriginTable("1") + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() + } + } + + assertEquals(0, testRollback.entireTableSize()) + } + + @Test + fun `test check exception commit`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + assertFailsWith { + testRollback.suspendTransaction { + insertOriginTable("1") + @Suppress("TooGenericExceptionThrown") + throw Exception() + } + } + + assertEquals(1, testRollback.entireTableSize()) + } + + @Test + fun `exception in inner Tx causes rollback of outer Tx`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + assertFailsWith { + testRollback.suspendTransaction { // outer logicTx start + testRollback.insertOriginTable("Tx1") + + try { + testRollback.suspendTransaction { // inner logicTx start + testRollback.insertOriginTable("Tx2") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() + // isGlobalRollbackOnParticipationFailure == true -> doSetRollbackOnly() -> mark as globalRollBack + } + } catch (@Suppress("SwallowedException") _: RuntimeException) { + // Ignore exception + } + } // when outer logicTx commit() -> check globalRollBack mark -> throw UnexpectedRollbackException -> rollback + } + + assertEquals(0, testRollback.entireTableSize()) + } + + @Test + fun `isRollback is managed separately for different transactions`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + assertFailsWith { + testRollback.suspendTransaction { // outer logicTx start + val outerTx = TransactionManager.currentOrNull() + assertNotNull(outerTx) + assertFalse(outerTx.isMarkedRollback()) + + try { + testRollback.suspendTransaction { // inner logicTx start; inner == outer + val innerTx = TransactionManager.currentOrNull() + assertNotNull(innerTx) + assertFalse(innerTx.isMarkedRollback()) + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() // mark as globalRollBack + } + } catch (@Suppress("SwallowedException") _: RuntimeException) { + // Ignore exception + } + + assertTrue(outerTx.isMarkedRollback()) + + testRollback.transactionWithRequiresNew { // separate transaction != outer + val newInnerTx = TransactionManager.currentOrNull() + assertNotNull(newInnerTx) + assertFalse(newInnerTx.isMarkedRollback()) + } + + testRollback.suspendTransaction { // inner == outer, so still marked for rollback + val innerTx = TransactionManager.currentOrNull() + assertNotNull(innerTx) + assertTrue(innerTx.isMarkedRollback()) + } + } + } + + testRollback.suspendTransaction { // new outer transaction not affected by previous transaction rollback status + val newTx = TransactionManager.currentOrNull() + assertNotNull(newTx) + assertFalse(newTx.isMarkedRollback()) + } + } + + @Test + fun `requiresNew should rollback innerTx without affecting outerTx`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + testRollback.suspendTransaction { // outer logicTx start + testRollback.insertOriginTable("Tx1") + + try { + testRollback.transactionWithRequiresNew { // outer != this + testRollback.insertOriginTable("Tx2") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() + } + } catch (@Suppress("SwallowedException") _: RuntimeException) { + // Ignore exception + } + } + + val entities = testRollback.selectAll() + assertEquals(1, entities.size) + assertEquals("Tx1", entities.first().name) + } + + @Test + fun `supports should participate in existing transaction but not rollback when none exists`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + assertFailsWith { + // Execute without a transaction -> Should not be rolled back + testRollback.transactionWithSupports { + testRollback.insertOriginTable("No Tx") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() // Non-transactional, so it should not be rolled back + } + } + + assertEquals(1, testRollback.entireTableSize()) // Data should remain + + // Execute within a transaction -> Should be rolled back + assertFailsWith { + testRollback.suspendTransaction { + testRollback.insertOriginTable("With Tx") + + testRollback.transactionWithSupports { // outer == this + testRollback.insertOriginTable("Supports Tx") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() // Should trigger rollback + } + } + } + + val entities = testRollback.selectAll() + assertEquals(1, entities.size) // Only the first case's data should remain + assertEquals("No Tx", entities.first().name) + } + + @Test + fun `notSupported should suspend outer transaction and execute without transaction`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + testRollback.suspendTransaction { + testRollback.insertOriginTable("Tx1") + + try { + testRollback.transactionWithNotSupported { + testRollback.insertOriginTable("No Tx") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() // Since it's non-transactional, it won't be rolled back + } + } catch (@Suppress("SwallowedException") _: RuntimeException) { + // Ignore exception + } + } + + assertEquals(2, testRollback.entireTableSize()) // Both records should remain + } + + @Test + fun `mandatory should fail if no existing transaction but participate if one exists`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + assertFailsWith { + testRollback.transactionWithMandatory { + testRollback.insertOriginTable("No Parent Tx") // Will trigger roll back + } + } + + testRollback.suspendTransaction { + testRollback.insertOriginTable("Tx 1") + testRollback.transactionWithMandatory { // outer == this + testRollback.insertOriginTable("Tx 2") + } + } + + assertEquals(2, testRollback.entireTableSize()) // Both records should remain + + assertFailsWith { + testRollback.suspendTransaction { + testRollback.insertOriginTable("Tx11") + + try { + testRollback.transactionWithMandatory { // outer == this + testRollback.insertOriginTable("Tx22") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() + } + } catch (@Suppress("SwallowedException") _: RuntimeException) { + // Ignore exception + } + } + } + + val entities = testRollback.selectAll() + assertEquals(2, entities.size) // Only original records should remain + assertTrue { entities.none { it.name.startsWith("No ") || it.name.startsWith("New ") } } + } + + @Test + fun `nested should rollback innerTx without affecting outerTx`() = runTest { + val testRollback = container.getBean(TestRollback::class.java) + + testRollback.suspendTransaction { + testRollback.insertOriginTable("Tx1") + + try { + testRollback.transactionWithNested { + testRollback.insertOriginTable("Tx2") + + @Suppress("TooGenericExceptionThrown") + throw RuntimeException() // Rollback only the inner transaction + } + } catch (@Suppress("SwallowedException") _: RuntimeException) { + // Ignore exception + } + } + + val entities = testRollback.selectAll() + assertEquals(1, entities.size) + assertEquals("Tx1", entities.first().name) // Only the outer transaction should remain + } +} + +@Configuration +@EnableTransactionManagement(proxyTargetClass = true) +open class TransactionManagerAttributeSourceTestConfig { + + @Bean + open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest1;DB_CLOSE_DELAY=-1;") + + @Bean + open fun transactionManager(connectionFactory: ConnectionFactory) = SpringReactiveTransactionManager( + connectionFactory, + R2dbcDatabaseConfig { explicitDialect = H2Dialect() } + ) + + @Bean + open fun transactionAttributeSource() = ExposedSpringReactiveTransactionAttributeSource() + + @Bean + open fun testRollback() = TestRollback() +} + +@Transactional +open class TestRollback { + + open suspend fun init() { + SchemaUtils.create(RollbackTable) + RollbackTable.deleteAll() + } + + open suspend fun suspendTransaction(block: suspend TestRollback.() -> Unit) { + block() + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + open suspend fun transactionWithRequiresNew(block: suspend TestRollback.() -> Unit) { + block() + } + + @Transactional(propagation = Propagation.NESTED) + open suspend fun transactionWithNested(block: suspend TestRollback.() -> Unit) { + block() + } + + @Transactional(propagation = Propagation.SUPPORTS) + open suspend fun transactionWithSupports(block: suspend TestRollback.() -> Unit) { + block() + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + open suspend fun transactionWithNotSupported(block: suspend TestRollback.() -> Unit) { + block() + } + + @Transactional(propagation = Propagation.MANDATORY) + open suspend fun transactionWithMandatory(block: suspend TestRollback.() -> Unit) { + block() + } + + open suspend fun insertOriginTable(name: String) { + RollbackTable.insert { + it[RollbackTable.name] = name + } + } + + open suspend fun insertWrongTable(name: String) { + WrongDefinedRollbackTable.insert { + it[WrongDefinedRollbackTable.name] = name + } + } + + open suspend fun entireTableSize(): Long { + return RollbackTable.selectAll().count() + } + + open suspend fun selectAll(): List { + return RollbackTable.selectAll().map { + RollbackEntity( + id = it[RollbackTable.id], + name = it[RollbackTable.name] + ) + }.toList() + } +} + +object RollbackTable : LongIdTable("test_rollback") { + val name = varchar("name", 5) +} + +object WrongDefinedRollbackTable : LongIdTable("test_rollback") { + val name = varchar("name", 10) +} + +data class RollbackEntity(val id: EntityID, val name: String) diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionSingleConnectionTest.kt similarity index 94% rename from spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt rename to spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionSingleConnectionTest.kt index 58c2cc218c..fdf2e28d57 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionSingleConnectionTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionSingleConnectionTest.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.test.runTest -import org.jetbrains.exposed.v1.core.InternalApi import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.vendors.H2Dialect import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig @@ -25,7 +24,7 @@ import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.annotation.EnableTransactionManagement import kotlin.test.assertEquals -class SpringReactiveTransactionSingleConnectionTest { +class SpringTransactionSingleConnectionTest { object T1 : Table() { val c1 = varchar("c1", Int.MIN_VALUE.toString().length) } @@ -34,7 +33,6 @@ class SpringReactiveTransactionSingleConnectionTest { val transactionManager: ReactiveTransactionManager = singleConnectionH2TestContainer.getBean(ReactiveTransactionManager::class.java) val connectionFactory: ConnectionFactory = singleConnectionH2TestContainer.getBean(ConnectionFactory::class.java) - @OptIn(InternalApi::class) @BeforeEach fun beforeTest() = runTest { transactionManager.execute { @@ -42,7 +40,6 @@ class SpringReactiveTransactionSingleConnectionTest { } } - @OptIn(InternalApi::class) @AfterEach fun afterTest() = runTest { transactionManager.execute { @@ -72,11 +69,12 @@ class SpringReactiveTransactionSingleConnectionTest { isolationLevel = TransactionDefinition.ISOLATION_READ_UNCOMMITTED, ) { val cx = ConnectionFactoryUtils.getConnection(connectionFactory).awaitFirst() + val actualLevel = cx.transactionIsolationLevel + cx.close().awaitFirstOrNull() assertEquals( - cx.transactionIsolationLevel, + actualLevel, TransactionDefinition.ISOLATION_SERIALIZABLE.resolveIsolationLevel() ) - cx.close().awaitFirstOrNull() T1.selectAll().toList() } diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/EntityUpdateTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/EntityUpdateTest.kt index 858e195a30..d20b5463f6 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/EntityUpdateTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/EntityUpdateTest.kt @@ -6,12 +6,15 @@ import org.jetbrains.exposed.v1.dao.IntEntity import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.test.annotation.Commit import org.springframework.transaction.annotation.Transactional import kotlin.test.fail +@Tag(MISSING_R2DBC_TEST) open class EntityUpdateTest : SpringTransactionTestBase() { object T1 : IntIdTable() { diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt index b7fd14575a..05f0bf324f 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt @@ -8,10 +8,12 @@ import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.Tag import org.springframework.test.annotation.Commit import org.springframework.transaction.IllegalTransactionStateException import org.springframework.transaction.TransactionDefinition @@ -290,6 +292,7 @@ open class ExposedTransactionManagerTest : SpringTransactionTestBase() { * Test for Timeout * Execute with query timeout */ + @Tag(NO_R2DBC_SUPPORT) @RepeatedTest(5) open fun testTimeout() { transactionManager.execute(timeout = 1) { diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt index 4fe2147c4a..7b9e9bdf6a 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt @@ -7,8 +7,10 @@ import org.jetbrains.exposed.v1.jdbc.deleteAll import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean @@ -20,6 +22,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement import org.springframework.transaction.annotation.Transactional import javax.sql.DataSource +@Tag(NO_R2DBC_SUPPORT) open class SpringMultiContainerTransactionTest { val orderContainer = AnnotationConfigApplicationContext(OrderConfig::class.java) diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionEntityTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionEntityTest.kt index b17949cadc..71b8156de6 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionEntityTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionEntityTest.kt @@ -6,8 +6,10 @@ import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.dao.java.UUIDEntity import org.jetbrains.exposed.v1.dao.java.UUIDEntityClass import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.annotation.Commit @@ -76,6 +78,7 @@ open class Service { } } +@Tag(MISSING_R2DBC_TEST) open class SpringTransactionEntityTest : SpringTransactionTestBase() { @Autowired diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt index 8442305cd4..d2bcdcac7f 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt @@ -3,9 +3,12 @@ package org.jetbrains.exposed.v1.spring7.transaction import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.tests.NOT_APPLICABLE_TO_R2DBC +import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy @@ -171,6 +174,7 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } + @Tag(NO_R2DBC_SUPPORT) // https://github.com/spring-projects/spring-data-relational/issues/2026 @Test fun `transaction rollback with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -215,6 +219,7 @@ class SpringTransactionManagerTest { assertTrue(con1.closeCallCount > 0) } + @Tag(NOT_APPLICABLE_TO_R2DBC) // no equivalent isRollbackOnCommitFailure property @Test fun `transaction exception on commit and rollback on commit failure`() { con1.mockCommit = { throw SQLException("Commit failure") } diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt index f8eb22c27f..7a403d22b0 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt @@ -1,12 +1,15 @@ package org.jetbrains.exposed.v1.spring7.transaction +import org.jetbrains.exposed.v1.tests.NOT_APPLICABLE_TO_R2DBC import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager +@Tag(NOT_APPLICABLE_TO_R2DBC) class TransactionSynchronizationTest : SpringTransactionTestBase() { private var synchronization: TestSynchronization = TestSynchronization() From 9c358bc69c9a00ae12ddc1c4234877d3bc9d04d2 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:56:40 -0500 Subject: [PATCH 4/7] feat: Fix doSuspend/doResume behavior with mono --- .../api/spring7-reactive-transaction.api | 1 - .../SpringReactiveTransactionManager.kt | 134 +++++++----------- .../ExposedTransactionManagerTest.kt | 63 ++------ .../R2dbcExposedTransactionManagerTest.kt | 58 ++++---- .../ExposedTransactionManagerTest.kt | 3 +- .../JdbcExposedTransactionManagerTest.kt | 5 +- 6 files changed, 95 insertions(+), 169 deletions(-) diff --git a/spring7-reactive-transaction/api/spring7-reactive-transaction.api b/spring7-reactive-transaction/api/spring7-reactive-transaction.api index 121856e5b6..81fcb4da1d 100644 --- a/spring7-reactive-transaction/api/spring7-reactive-transaction.api +++ b/spring7-reactive-transaction/api/spring7-reactive-transaction.api @@ -8,6 +8,5 @@ public final class org/jetbrains/exposed/v1/spring7/reactive/transaction/Exposed public final class org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager : org/springframework/transaction/reactive/AbstractReactiveTransactionManager { public fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;Z)V public synthetic fun (Lio/r2dbc/spi/ConnectionFactory;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabaseConfig$Builder;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getConnectionFactory ()Lio/r2dbc/spi/ConnectionFactory; } diff --git a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt index 498ef80890..e14b896b69 100644 --- a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt @@ -4,6 +4,7 @@ import io.r2dbc.spi.Connection import io.r2dbc.spi.ConnectionFactory import io.r2dbc.spi.IsolationLevel import io.r2dbc.spi.R2dbcException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.reactive.awaitLast import kotlinx.coroutines.reactor.mono import org.jetbrains.exposed.v1.core.InternalApi @@ -14,7 +15,6 @@ import org.jetbrains.exposed.v1.core.transactions.transactionScope import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction -import org.jetbrains.exposed.v1.r2dbc.transactions.currentOrNull import org.jetbrains.exposed.v1.r2dbc.transactions.transactionManager import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.reactivestreams.Publisher @@ -37,7 +37,7 @@ import reactor.core.publisher.Mono * @property showSql Whether transaction queries should be logged. Defaults to `false`. */ class SpringReactiveTransactionManager( - val connectionFactory: ConnectionFactory, + private val connectionFactory: ConnectionFactory, databaseConfig: R2dbcDatabaseConfig.Builder, private val showSql: Boolean = false, ) : AbstractReactiveTransactionManager() { @@ -50,16 +50,13 @@ class SpringReactiveTransactionManager( override fun doGetTransaction( synchronizationManager: TransactionSynchronizationManager ): Any { - val retrieved = ExposedTransactionObject( + synchronizationManager.printEverything(::doGetTransaction.name) + + return ExposedTransactionObject( database = database, ).apply { connectionHolder = synchronizationManager.getResourceHolderOrNull() - synchronizationManager.getResourceAndSynchronize(this) } - - synchronizationManager.printEverything(::doGetTransaction.name) - - return retrieved } override fun doSuspend( @@ -69,16 +66,23 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - synchronizationManager.getResourceAndSynchronize(trxObject) synchronizationManager.printEverything(::doSuspend.name) - trxObject.connectionHolder = null + synchronizationManager.getResourceOrNull() ?: error("No transaction to suspend") Mono - .justOrEmpty(synchronizationManager.unbindResource(connectionFactory) as ExposedHolderObject) - // traditionally should pop in doOnSuccess (done when syncing) + .justOrEmpty( + synchronizationManager.unbindResource(connectionFactory) as ExposedHolderObject + ) + .doOnSuccess { + trxObject.connectionHolder = null + + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.popTransaction() + + synchronizationManager.printEverything(::doSuspend.name) + } } - // doAfterTerminate on defer would be closest but causes no transaction in context } override fun doResume( @@ -98,15 +102,14 @@ class SpringReactiveTransactionManager( Mono.empty() } - // doFinally on defer may be a good contender } override fun isExistingTransaction(transaction: Any): Boolean { val trxObject = transaction as ExposedTransactionObject - val currentTransaction = trxObject.getCurrentTransaction() + println("In existingTransaction with ${trxObject.getCurrentTransaction()}") - return currentTransaction != null + return trxObject.getCurrentTransaction() != null } override fun doBegin( @@ -117,7 +120,7 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - val currentTransaction = synchronizationManager.getResourceAndSynchronize(transaction) + val currentTransaction = synchronizationManager.getResourceOrNull() val outerTransactionToUse = if (currentTransaction?.db == database) { currentTransaction } else { @@ -139,19 +142,15 @@ class SpringReactiveTransactionManager( } } - val newConnectionMono = mono { - synchronizationManager.getResourceAndSynchronize(trxObject) - synchronizationManager.printEverything(::doResume.name) - - if (trxObject.connectionHolder == null) { + // force new coroutine to start in current thread so that potential callbacks can access correct stack + val newConnectionMono = mono(Dispatchers.Unconfined) { + if (trxObject.connectionHolder == null || trxObject.isNestedTransactionAllowed) { trxObject.connectionHolder = ExposedHolderObject(newTransaction.awaitConnection(), newTransaction) trxObject.isNewConnectionHolder = true } trxObject.connectionHolder?.isSynchronizedWithTransaction = true - if (definition.timeout != TransactionDefinition.TIMEOUT_DEFAULT) { - trxObject.connectionHolder?.setTimeoutInSeconds(definition.timeout) - } + true } Mono @@ -162,10 +161,17 @@ class SpringReactiveTransactionManager( } .then(newConnectionMono) .doOnSuccess { + if (definition.timeout != TransactionDefinition.TIMEOUT_DEFAULT) { + trxObject.connectionHolder?.setTimeoutInSeconds(definition.timeout) + } + if (trxObject.isNewConnectionHolder) { + // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its outer + // transaction used when doCommit() or doRollback() is invoked + synchronizationManager.unbindResourceIfPossible(connectionFactory) as? ExposedHolderObject + synchronizationManager.bindResource(connectionFactory, trxObject.connectionHolder!!) } - synchronizationManager.getResourceAndSynchronize(trxObject) synchronizationManager.printEverything(::doBegin.name) } .doOnError { ex -> @@ -176,7 +182,6 @@ class SpringReactiveTransactionManager( throw CannotCreateTransactionException("Could not open R2DBC Connection for transaction", ex) } - // doFinally on kotlin mono may be a good contender } .then() } @@ -187,16 +192,15 @@ class SpringReactiveTransactionManager( ): Mono { val trxObject = status.transaction as ExposedTransactionObject - synchronizationManager.getResourceAndSynchronize(trxObject) - ?: error("No synchronized transaction to commit") + synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to commit") synchronizationManager.printEverything(::doCommit.name) - return mono { - synchronizationManager.getResourceAndSynchronize(trxObject) - synchronizationManager.printEverything(::doCommit.name) - + // force new coroutine to start in current thread so that doCleanupOnCompletion accesses correct stack + return mono(Dispatchers.Unconfined) { trxObject.commit() + synchronizationManager.printEverything("${::doCommit.name} [inner]") + null } } @@ -207,16 +211,15 @@ class SpringReactiveTransactionManager( ): Mono { val trxObject = status.transaction as ExposedTransactionObject - synchronizationManager.getResourceAndSynchronize(trxObject) - ?: error("No synchronized transaction to rollback") + synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to rollback") synchronizationManager.printEverything(::doRollback.name) - return mono { - synchronizationManager.getResourceAndSynchronize(trxObject) - synchronizationManager.printEverything(::doRollback.name) - + // force new coroutine to start in current thread so that doCleanupOnCompletion accesses correct stack + return mono(Dispatchers.Unconfined) { trxObject.rollback() + synchronizationManager.printEverything("${::doRollback.name} [inner]") + null } } @@ -228,32 +231,31 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - val completedTransaction = synchronizationManager.getResourceAndSynchronize(trxObject)?.also { + val completedTransaction = synchronizationManager.getResourceOrNull()?.also { clearStatements(it) } + + @OptIn(InternalApi::class) + ThreadLocalTransactionsStack.popTransaction() + synchronizationManager.printEverything(::doCleanupAfterCompletion.name) if (trxObject.isNewConnectionHolder) { - synchronizationManager.unbindResource(connectionFactory) + val previous = synchronizationManager.unbindResource(connectionFactory) as ExposedHolderObject // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its // now closed inner transaction used when doCommit() or doRollback() is later invoked completedTransaction?.outerTransaction?.let { outer -> - synchronizationManager.bindResource(database, outer) - - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.pushTransaction(outer) + synchronizationManager.bindResource(connectionFactory, ExposedHolderObject(previous.connection, outer)) } - - synchronizationManager.getResourceAndSynchronize(trxObject) - synchronizationManager.printEverything(::doCleanupAfterCompletion.name) } - mono { - synchronizationManager.getResourceAndSynchronize(trxObject) - synchronizationManager.printEverything(::doCleanupAfterCompletion.name) + // force new coroutine to start in current thread so that future callbacks can access correct stack + mono(Dispatchers.Unconfined) { completedTransaction?.close() + synchronizationManager.printEverything(::doCleanupAfterCompletion.name) + null } .doOnEach { @@ -263,7 +265,6 @@ class SpringReactiveTransactionManager( trxObject.connectionHolder?.clear() } } - // doFinally on defer may be a good contender } private fun clearStatements(transaction: R2dbcTransaction) { @@ -282,7 +283,6 @@ class SpringReactiveTransactionManager( return Mono.fromRunnable { val trxObject = status.transaction as ExposedTransactionObject - synchronizationManager.getResourceAndSynchronize(trxObject) synchronizationManager.printEverything(::doSetRollbackOnly.name) if (status.isDebug) { @@ -308,6 +308,7 @@ class SpringReactiveTransactionManager( ) { var isNewConnectionHolder: Boolean = false var connectionHolder: ExposedHolderObject? = null + val isNestedTransactionAllowed: Boolean = database.config.useNestedTransactions @Suppress("TooGenericExceptionCaught") suspend fun commit() { @@ -349,33 +350,6 @@ class SpringReactiveTransactionManager( return this.getResourceHolderOrNull()?.transaction } - private fun TransactionSynchronizationManager.getResourceAndSynchronize( - trxObject: ExposedTransactionObject - ): R2dbcTransaction? { - val currentFromResource = this.getResourceOrNull() - val currentOnStack = trxObject.database.transactionManager.currentOrNull() - return when { - currentOnStack == null && currentFromResource != null -> { - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.pushTransaction(currentFromResource) - currentFromResource - } - currentOnStack != null && currentFromResource == null -> { - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.threadTransactions()?.clear() - null - } - currentOnStack != null && currentFromResource != null && currentOnStack != currentFromResource -> { - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.threadTransactions()?.clear() - @OptIn(InternalApi::class) - ThreadLocalTransactionsStack.pushTransaction(currentFromResource) - currentFromResource - } - else -> currentOnStack - } - } - @OptIn(InternalApi::class) private fun TransactionSynchronizationManager.printEverything(methodName: String) { val resource = this.getResourceOrNull()?.transactionId ?: "NO SPRING TRX" diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt index c8197a84f3..e1cf43e818 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt @@ -6,12 +6,11 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.r2dbc.SchemaUtils import org.jetbrains.exposed.v1.r2dbc.insert import org.jetbrains.exposed.v1.r2dbc.selectAll -import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction -import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.RepeatedTest import org.springframework.test.annotation.Commit import org.springframework.transaction.IllegalTransactionStateException @@ -33,22 +32,16 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { @BeforeEach fun beforeTest() = runTest { - println("Starting beforeTest... : ${viewThreadStack()}") transactionManager.execute { - println("Doing work... : ${viewThreadStack()}") SchemaUtils.create(T1) } - println("Ending beforeTest... : ${viewThreadStack()}") } @AfterEach fun afterTest() = runTest { - println("Starting afterTest... : ${viewThreadStack()}") transactionManager.execute { - println("Doing work... : ${viewThreadStack()}") SchemaUtils.drop(T1) } - println("Ending afterTest... : ${viewThreadStack()}") } @RepeatedTest(5) @@ -87,9 +80,10 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { } } - // TODO - This (& only this test?) fails because of line 114 in suspendTransaction(); + // TODO - This (& only this test) fails because of line 114 in suspendTransaction(); // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) - @RepeatedTest(5) + @Disabled + @RepeatedTest(1) @Commit // @Transactional // see [runTestWithMockTransactional] open fun testConnectionCombineWithExposedTransaction2() = runTestWithMockTransactional { @@ -161,41 +155,23 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { } } - // TODO /** * Test for Propagation.REQUIRES_NEW * Create a new transaction, and suspend the current transaction if one exists. */ - @RepeatedTest(1) + @RepeatedTest(5) // @Transactional // see [runTestWithMockTransactional] open fun testConnectionWithRequiresNew() = runTestWithMockTransactional { - println("Starting actual test... : ${viewThreadStack()}") T1.insertRandom() assertEquals(1, T1.selectAll().count()) -// val currentCx1 = ConnectionFactoryUtils.doGetConnection( -// (transactionManager as SpringReactiveTransactionManager).connectionFactory -// ).awaitSingle() - println("Finished first work...") transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { - println("Nested in test... : ${viewThreadStack()}") -// val currentCx2 = ConnectionFactoryUtils.doGetConnection( -// (transactionManager as SpringReactiveTransactionManager).connectionFactory -// ).awaitSingle() -// println("Nested work... on $currentCx2") assertEquals(0, T1.selectAll().count()) T1.insertRandom() assertEquals(1, T1.selectAll().count()) } - println("No longer nested in test... : ${viewThreadStack()}") -// val currentCx3 = ConnectionFactoryUtils.doGetConnection( -// (transactionManager as SpringReactiveTransactionManager).connectionFactory -// ).awaitSingle() -// println("Outer work... on $currentCx3") assertEquals(2, T1.selectAll().count()) - println("Ending actual test... : ${viewThreadStack()}") } - // TODO /** * Test for Propagation.REQUIRES_NEW with inner transaction roll-back * The inner transaction will be roll-back only inner transaction when the transaction marks as rollback. @@ -223,7 +199,8 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { * Test for Propagation.NEVER * Execute non-transactionally, throw an exception if a transaction exists. */ - @RepeatedTest(5) + @Disabled + @RepeatedTest(1) // @Transactional(propagation = Propagation.NEVER) // see [runTestWithMockTransactional] open fun testPropagationNever() = runTestWithMockTransactional( propagationBehavior = TransactionDefinition.PROPAGATION_NEVER @@ -291,7 +268,8 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { * Test for Propagation.SUPPORTS * Execute non-transactionally if none exists. */ - @RepeatedTest(5) + @Disabled + @RepeatedTest(1) open fun testPropagationSupportWithoutTransaction() = runTest { transactionManager.execute(TransactionDefinition.PROPAGATION_SUPPORTS) { assertFailsWith { // Should Be "No transaction exists" @@ -299,27 +277,4 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { } } } - - @RepeatedTest(5) - // @Transactional(isolation = Isolation.READ_COMMITTED) // see [runTestWithMockTransactional] - open fun testIsolationLevelReadUncommitted() = runTestWithMockTransactional( - isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED - ) { - assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) - T1.insertRandom() - val count = T1.selectAll().count() - transactionManager.execute( - TransactionDefinition.PROPAGATION_REQUIRES_NEW, - TransactionDefinition.ISOLATION_READ_UNCOMMITTED - ) { - assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED) - assertEquals(count, T1.selectAll().count()) - } - assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) - } - - private suspend fun assertTransactionIsolationLevel(expected: Int) { - val connection = TransactionManager.current().connection() - assertEquals(expected.resolveIsolationLevel(), connection.getTransactionIsolation()) - } } diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt index 7f3422d6eb..8e65aa0108 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt @@ -4,7 +4,6 @@ import io.r2dbc.spi.ConnectionFactory import kotlinx.coroutines.test.runTest import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.r2dbc.SchemaUtils -import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -109,8 +108,6 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas } } - // TODO - This (& only this test?) fails because of line 114 in suspendTransaction(); - // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) @RepeatedTest(5) @Commit // @Transactional // see [runTestWithMockTransactional] @@ -145,16 +142,21 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas * Test for Propagation.NESTED with inner roll-back * The nested transaction will be roll-back only inner transaction when the transaction marks as rollback. */ - @RepeatedTest(5) + @RepeatedTest(1) // @Transactional // see [runTestWithMockTransactional] open fun testConnectionWithNestedTransactionInnerRollback() = runTestWithMockTransactional { + println("Start test...") insertRandom() assertEquals(1, getCount()) + println("Finished outside work...") transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { status -> + println("Nested test...") insertRandom() assertEquals(2, getCount()) status.setRollbackOnly() + println("Finished nested work...") } + println("Outside again work...") assertEquals(1, getCount()) } @@ -162,62 +164,77 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas * Test for Propagation.NESTED with outer roll-back * The nested transaction will be roll-back entire transaction when the transaction marks as rollback. */ - @RepeatedTest(5) + @RepeatedTest(1) fun testConnectionWithNestedTransactionOuterRollback() = runTest { + println("Start test... straight to trx1") transactionManager.execute { insertRandom() assertEquals(1, getCount()) it.setRollbackOnly() + println("Finished trx1 work...") transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { + println("Nested test...") insertRandom() assertEquals(2, getCount()) + println("Finished nested work...") } + println("Outside again work...") assertEquals(2, getCount()) } + println("Straight to trx2") transactionManager.execute { assertEquals(0, getCount()) } } - // TODO /** * Test for Propagation.REQUIRES_NEW * Create a new transaction, and suspend the current transaction if one exists. */ - @RepeatedTest(5) + @RepeatedTest(1) // @Transactional // see [runTestWithMockTransactional] open fun testConnectionWithRequiresNew() = runTestWithMockTransactional { + println("Start test...") insertRandom() assertEquals(1, getCount()) + println("Finished outside work...") transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + println("Nested test...") assertEquals(0, getCount()) insertRandom() assertEquals(1, getCount()) + println("Finished nested work...") } + println("Outside again work...") assertEquals(2, getCount()) } - // TODO /** * Test for Propagation.REQUIRES_NEW with inner transaction roll-back * The inner transaction will be roll-back only inner transaction when the transaction marks as rollback. * And since isolation level is READ_COMMITTED, the inner transaction can't see the changes of outer transaction. */ - @RepeatedTest(5) + @RepeatedTest(1) fun testConnectionWithRequiresNewWithInnerTransactionRollback() = runTest { + println("Start test... straight to trx1") transactionManager.execute { insertRandom() assertEquals(1, getCount()) + println("Finished trx1 work...") transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + println("Nested test...") insertRandom() assertEquals(1, getCount()) it.setRollbackOnly() + println("Finished nested work...") } + println("Outside again work...") assertEquals(1, getCount()) } + println("Straight to trx2") transactionManager.execute { assertEquals(1, getCount()) } @@ -288,27 +305,4 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas } assertEquals(1, getCount()) } - - @RepeatedTest(5) - // @Transactional(isolation = Isolation.READ_COMMITTED) // see [runTestWithMockTransactional] - open fun testIsolationLevelReadUncommitted() = runTestWithMockTransactional( - isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED - ) { - assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) - insertRandom() - val count = getCount() - transactionManager.execute( - TransactionDefinition.PROPAGATION_REQUIRES_NEW, - TransactionDefinition.ISOLATION_READ_UNCOMMITTED - ) { - assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED) - assertEquals(count, getCount()) - } - assertTransactionIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED) - } - - private suspend fun assertTransactionIsolationLevel(expected: Int) { - val connection = TransactionManager.current().connection() - assertEquals(expected.resolveIsolationLevel(), connection.getTransactionIsolation()) - } } diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt index 05f0bf324f..a48b5ce535 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt @@ -275,6 +275,7 @@ open class ExposedTransactionManagerTest : SpringTransactionTestBase() { /** * Test for Isolation Level */ + @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests @RepeatedTest(5) @Transactional(isolation = Isolation.READ_COMMITTED) open fun testIsolationLevelReadUncommitted() { @@ -292,7 +293,7 @@ open class ExposedTransactionManagerTest : SpringTransactionTestBase() { * Test for Timeout * Execute with query timeout */ - @Tag(NO_R2DBC_SUPPORT) + @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests @RepeatedTest(5) open fun testTimeout() { transactionManager.execute(timeout = 1) { diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt index e262a28dde..35dd0424cf 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt @@ -4,10 +4,12 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.Tag import org.springframework.beans.factory.annotation.Autowired import org.springframework.jdbc.core.simple.JdbcClient import org.springframework.test.annotation.Commit @@ -15,7 +17,7 @@ import org.springframework.transaction.IllegalTransactionStateException import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.annotation.Isolation import org.springframework.transaction.annotation.Transactional -import java.util.Random +import java.util.* import javax.sql.DataSource import kotlin.test.assertFailsWith @@ -277,6 +279,7 @@ open class JdbcExposedTransactionManagerTest : SpringTransactionTestBase() { /** * Test for Isolation Level */ + @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests @RepeatedTest(5) @Transactional(isolation = Isolation.READ_COMMITTED) open fun testIsolationLevelReadUncommitted() { From e8e7a8e95349d7c8460475aa887b5d060e4d9e63 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:13:58 -0500 Subject: [PATCH 5/7] feat: Fix all remaining bugs except for context propagation --- exposed-r2dbc/api/exposed-r2dbc.api | 6 + .../r2dbc/statements/R2dbcConnectionImpl.kt | 21 +- .../statements/api/R2dbcExposedConnection.kt | 3 + .../SpringReactiveTransactionManager.kt | 36 +-- .../reactive/transaction/ConnectionSpy.kt | 6 +- .../ExposedTransactionManagerTest.kt | 16 +- .../MixedExposedR2dbcTransactionTest.kt | 3 +- .../R2dbcExposedTransactionManagerTest.kt | 30 +-- .../SpringMultiContainerTransactionTest.kt | 208 ++++++++++++++++++ .../SpringReactiveTransactionManagerTest.kt | 3 - .../SpringTransactionRollbackTest.kt | 24 -- .../ExposedTransactionManagerTest.kt | 4 +- .../JdbcExposedTransactionManagerTest.kt | 2 +- .../SpringTransactionManagerTest.kt | 1 + .../SpringTransactionRollbackTest.kt | 5 + .../TransactionSynchronizationTest.kt | 1 + 16 files changed, 281 insertions(+), 88 deletions(-) create mode 100644 spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt diff --git a/exposed-r2dbc/api/exposed-r2dbc.api b/exposed-r2dbc/api/exposed-r2dbc.api index 3dccfad5ea..3afce10dd5 100644 --- a/exposed-r2dbc/api/exposed-r2dbc.api +++ b/exposed-r2dbc/api/exposed-r2dbc.api @@ -713,6 +713,7 @@ public class org/jetbrains/exposed/v1/r2dbc/statements/MergeSuspendExecutable : public final class org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl : org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection { public fun (Lorg/reactivestreams/Publisher;Ljava/lang/String;Lorg/jetbrains/exposed/v1/r2dbc/mappers/R2dbcTypeMapping;)V + public fun activeConnection (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun commit (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun executeInBatch (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -865,6 +866,7 @@ public final class org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcDatabaseMe } public abstract interface class org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection { + public fun activeConnection (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun commit (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun executeInBatch (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -889,6 +891,10 @@ public abstract interface class org/jetbrains/exposed/v1/r2dbc/statements/api/R2 public abstract fun setTransactionIsolation (Lio/r2dbc/spi/IsolationLevel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection$DefaultImpls { + public static fun activeConnection (Lorg/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract class org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedDatabaseMetadata : org/jetbrains/exposed/v1/core/statements/api/ExposedDatabaseMetadata { public fun (Ljava/lang/String;)V public abstract fun columns ([Lorg/jetbrains/exposed/v1/core/Table;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt index 74fb34f1ec..b6e03a4ab1 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt @@ -1,14 +1,10 @@ package org.jetbrains.exposed.v1.r2dbc.statements -import io.r2dbc.spi.Connection -import io.r2dbc.spi.IsolationLevel -import io.r2dbc.spi.Row -import io.r2dbc.spi.RowMetadata -import io.r2dbc.spi.Statement -import io.r2dbc.spi.TransactionDefinition -import io.r2dbc.spi.ValidationDepth +import io.r2dbc.spi.* import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asPublisher import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitLast import kotlinx.coroutines.reactive.awaitSingle @@ -43,12 +39,23 @@ import java.util.* */ @Suppress("UnusedPrivateMember", "SpreadOperator") class R2dbcConnectionImpl( + /** The publisher of underlying database [Connection] instances contained by this wrapper. */ override val connection: Publisher, private val vendorDialect: String, private val typeMapping: R2dbcTypeMapping ) : R2dbcExposedConnection> { private val metadataProvider: MetadataProvider = MetadataProvider.getProvider(vendorDialect) + /** + * Retrieves a publisher that provides only the single current underlying database [Connection] instance in use. + * If none is active, it awaits a value from the [connection] publisher and internally stores the result. + */ + override suspend fun activeConnection(): Publisher { + // retrieves localConnection if not null, otherwise awaits value from connection publisher + val acquiredConnection: Connection = withConnection { this } + return flowOf(acquiredConnection).asPublisher() + } + override suspend fun getCatalog(): String = withConnection { getCurrentCatalog(metadataProvider) ?: executeSQL(metadataProvider.getUsername()) { row, _ -> diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection.kt index 883abe3c85..3e94ec4446 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcExposedConnection.kt @@ -11,6 +11,9 @@ interface R2dbcExposedConnection { /** The underlying database connection object contained by this wrapper. */ val connection: OriginalConnection + /** Retrieves the current underlying database connection object in use. */ + suspend fun activeConnection(): OriginalConnection = connection + /** Retrieves the name of the connection's catalog. */ suspend fun getCatalog(): String diff --git a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt index e14b896b69..39cd1f0ed0 100644 --- a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt @@ -5,16 +5,18 @@ import io.r2dbc.spi.ConnectionFactory import io.r2dbc.spi.IsolationLevel import io.r2dbc.spi.R2dbcException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.reactive.awaitLast +import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactor.mono import org.jetbrains.exposed.v1.core.InternalApi import org.jetbrains.exposed.v1.core.StdOutSqlLogger import org.jetbrains.exposed.v1.core.exposedLogger import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack +import org.jetbrains.exposed.v1.core.transactions.currentTransactionOrNull import org.jetbrains.exposed.v1.core.transactions.transactionScope import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.currentOrNull import org.jetbrains.exposed.v1.r2dbc.transactions.transactionManager import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.reactivestreams.Publisher @@ -22,7 +24,7 @@ import org.springframework.r2dbc.UncategorizedR2dbcException import org.springframework.r2dbc.connection.ConnectionHolder import org.springframework.transaction.CannotCreateTransactionException import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.TransactionSystemException +import org.springframework.transaction.UnexpectedRollbackException import org.springframework.transaction.reactive.AbstractReactiveTransactionManager import org.springframework.transaction.reactive.GenericReactiveTransaction import org.springframework.transaction.reactive.TransactionSynchronizationManager @@ -107,7 +109,7 @@ class SpringReactiveTransactionManager( override fun isExistingTransaction(transaction: Any): Boolean { val trxObject = transaction as ExposedTransactionObject - println("In existingTransaction with ${trxObject.getCurrentTransaction()}") + println("In existingTransaction with ${trxObject.getCurrentTransaction()?.transactionId}") return trxObject.getCurrentTransaction() != null } @@ -120,7 +122,8 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - val currentTransaction = synchronizationManager.getResourceOrNull() + @OptIn(InternalApi::class) + val currentTransaction = currentTransactionOrNull() as? R2dbcTransaction val outerTransactionToUse = if (currentTransaction?.db == database) { currentTransaction } else { @@ -143,13 +146,14 @@ class SpringReactiveTransactionManager( } // force new coroutine to start in current thread so that potential callbacks can access correct stack - val newConnectionMono = mono(Dispatchers.Unconfined) { - if (trxObject.connectionHolder == null || trxObject.isNestedTransactionAllowed) { + val newConnectionMono: Mono = mono(Dispatchers.Unconfined) { + if (trxObject.connectionHolder == null || trxObject.isExposedNestedTransactionAllowed) { trxObject.connectionHolder = ExposedHolderObject(newTransaction.awaitConnection(), newTransaction) trxObject.isNewConnectionHolder = true } trxObject.connectionHolder?.isSynchronizedWithTransaction = true + // Reactor Mono.doOnSuccess() fails if completed result of chained mono is null/empty true } @@ -172,7 +176,7 @@ class SpringReactiveTransactionManager( synchronizationManager.bindResource(connectionFactory, trxObject.connectionHolder!!) } - synchronizationManager.printEverything(::doBegin.name) + synchronizationManager.printEverything("${::doBegin.name} [inner]") } .doOnError { ex -> trxObject.connectionHolder = null @@ -195,7 +199,7 @@ class SpringReactiveTransactionManager( synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to commit") synchronizationManager.printEverything(::doCommit.name) - // force new coroutine to start in current thread so that doCleanupOnCompletion accesses correct stack + // force new coroutine to start in current thread so that doCleanupOnCompletion can access correct stack return mono(Dispatchers.Unconfined) { trxObject.commit() @@ -214,7 +218,7 @@ class SpringReactiveTransactionManager( synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to rollback") synchronizationManager.printEverything(::doRollback.name) - // force new coroutine to start in current thread so that doCleanupOnCompletion accesses correct stack + // force new coroutine to start in current thread so that doCleanupOnCompletion can access correct stack return mono(Dispatchers.Unconfined) { trxObject.rollback() @@ -263,6 +267,7 @@ class SpringReactiveTransactionManager( trxObject.connectionHolder?.released() } trxObject.connectionHolder?.clear() + println("In ${::doCleanupAfterCompletion.name}: Releasing & clearing CH") } } } @@ -300,7 +305,7 @@ class SpringReactiveTransactionManager( @Suppress("UNCHECKED_CAST") private suspend fun R2dbcTransaction.awaitConnection(): Connection { - return (this.connection().connection as Publisher).awaitLast() + return (this.connection().activeConnection() as Publisher).awaitSingle() } private data class ExposedTransactionObject( @@ -308,16 +313,18 @@ class SpringReactiveTransactionManager( ) { var isNewConnectionHolder: Boolean = false var connectionHolder: ExposedHolderObject? = null - val isNestedTransactionAllowed: Boolean = database.config.useNestedTransactions + val isExposedNestedTransactionAllowed: Boolean = database.config.useNestedTransactions @Suppress("TooGenericExceptionCaught") suspend fun commit() { try { + if (connectionHolder?.isRollbackOnly == true) { + throw UnexpectedRollbackException("Attempting to commit a transaction that is only set for rollback") + } + getCurrentTransaction()?.commit() } catch (error: R2dbcException) { throw UncategorizedR2dbcException(error.message.orEmpty(), null, error) - } catch (error: Exception) { - throw TransactionSystemException(error.message.orEmpty(), error) } } @@ -327,13 +334,12 @@ class SpringReactiveTransactionManager( getCurrentTransaction()?.rollback() } catch (error: R2dbcException) { throw UncategorizedR2dbcException(error.message.orEmpty(), null, error) - } catch (error: Exception) { - throw TransactionSystemException(error.message.orEmpty(), error) } } fun getCurrentTransaction(): R2dbcTransaction? { return connectionHolder?.transaction + ?: database.transactionManager.currentOrNull() } fun setRollbackOnly() { diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt index 950bcc0bd7..8d0fd2a2c1 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ConnectionSpy.kt @@ -44,7 +44,7 @@ class ConnectionSpy(private val connection: Connection) : Connection by connecti Mono.empty() } as Publisher - override fun setAutoCommit(autoCommit: Boolean): Publisher? = Mono.defer { + override fun setAutoCommit(autoCommit: Boolean): Publisher = Mono.defer { callOrder.add("setAutoCommit") mockAutoCommit = autoCommit @@ -58,12 +58,12 @@ class ConnectionSpy(private val connection: Connection) : Connection by connecti Mono.empty() } as Publisher - override fun beginTransaction(definition: TransactionDefinition?): Publisher? = Mono.defer { + override fun beginTransaction(definition: TransactionDefinition): Publisher = Mono.defer { callOrder.add("setAutoCommit") mockAutoCommit = false definition - ?.getAttribute(TransactionDefinition.READ_ONLY) + .getAttribute(TransactionDefinition.READ_ONLY) ?.let { callOrder.add("setReadOnly") mockReadOnly = it diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt index e1cf43e818..f096dbe2f8 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt @@ -7,10 +7,10 @@ import org.jetbrains.exposed.v1.r2dbc.SchemaUtils import org.jetbrains.exposed.v1.r2dbc.insert import org.jetbrains.exposed.v1.r2dbc.selectAll import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.RepeatedTest import org.springframework.test.annotation.Commit import org.springframework.transaction.IllegalTransactionStateException @@ -82,21 +82,27 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { // TODO - This (& only this test) fails because of line 114 in suspendTransaction(); // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) - @Disabled @RepeatedTest(1) @Commit // @Transactional // see [runTestWithMockTransactional] open fun testConnectionCombineWithExposedTransaction2() = runTestWithMockTransactional { + println("Starting TEST...\n${viewThreadStack()}") val rnd = Random().nextInt().toString() T1.insert { it[c1] = rnd } assertEquals(rnd, T1.selectAll().single()[T1.c1]) + println("About to enter nested...") suspendTransaction { + println("Starting NESTED...\n${viewThreadStack()}") T1.insertRandom() assertEquals(2, T1.selectAll().count()) + println("NESTED = ${T1.selectAll().count()}") + println("Finishing NESTED...") } + println("TEST = ${T1.selectAll().count()}") + println("Finishing TEST...\n${viewThreadStack()}") } /** @@ -199,8 +205,7 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { * Test for Propagation.NEVER * Execute non-transactionally, throw an exception if a transaction exists. */ - @Disabled - @RepeatedTest(1) + @RepeatedTest(5) // @Transactional(propagation = Propagation.NEVER) // see [runTestWithMockTransactional] open fun testPropagationNever() = runTestWithMockTransactional( propagationBehavior = TransactionDefinition.PROPAGATION_NEVER @@ -268,8 +273,7 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { * Test for Propagation.SUPPORTS * Execute non-transactionally if none exists. */ - @Disabled - @RepeatedTest(1) + @RepeatedTest(5) open fun testPropagationSupportWithoutTransaction() = runTest { transactionManager.execute(TransactionDefinition.PROPAGATION_SUPPORTS) { assertFailsWith { // Should Be "No transaction exists" diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt index 494ce6cd38..ee46377d55 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/MixedExposedR2dbcTransactionTest.kt @@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.r2dbc.core.awaitRowsUpdated import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional @@ -154,7 +155,7 @@ open class MixedTransactionService { .bind("id", UUID.randomUUID()) .bind("name", "Test${nextNameIndex++}") .fetch() - .rowsUpdated() + .awaitRowsUpdated() @Suppress("UseCheckOrError") if (fail) { diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt index 8e65aa0108..6ace42d898 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/R2dbcExposedTransactionManagerTest.kt @@ -142,21 +142,16 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas * Test for Propagation.NESTED with inner roll-back * The nested transaction will be roll-back only inner transaction when the transaction marks as rollback. */ - @RepeatedTest(1) + @RepeatedTest(5) // @Transactional // see [runTestWithMockTransactional] open fun testConnectionWithNestedTransactionInnerRollback() = runTestWithMockTransactional { - println("Start test...") insertRandom() assertEquals(1, getCount()) - println("Finished outside work...") transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { status -> - println("Nested test...") insertRandom() assertEquals(2, getCount()) status.setRollbackOnly() - println("Finished nested work...") } - println("Outside again work...") assertEquals(1, getCount()) } @@ -164,26 +159,20 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas * Test for Propagation.NESTED with outer roll-back * The nested transaction will be roll-back entire transaction when the transaction marks as rollback. */ - @RepeatedTest(1) + @RepeatedTest(5) fun testConnectionWithNestedTransactionOuterRollback() = runTest { - println("Start test... straight to trx1") transactionManager.execute { insertRandom() assertEquals(1, getCount()) it.setRollbackOnly() - println("Finished trx1 work...") transactionManager.execute(TransactionDefinition.PROPAGATION_NESTED) { - println("Nested test...") insertRandom() assertEquals(2, getCount()) - println("Finished nested work...") } - println("Outside again work...") assertEquals(2, getCount()) } - println("Straight to trx2") transactionManager.execute { assertEquals(0, getCount()) } @@ -193,21 +182,16 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas * Test for Propagation.REQUIRES_NEW * Create a new transaction, and suspend the current transaction if one exists. */ - @RepeatedTest(1) + @RepeatedTest(5) // @Transactional // see [runTestWithMockTransactional] open fun testConnectionWithRequiresNew() = runTestWithMockTransactional { - println("Start test...") insertRandom() assertEquals(1, getCount()) - println("Finished outside work...") transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { - println("Nested test...") assertEquals(0, getCount()) insertRandom() assertEquals(1, getCount()) - println("Finished nested work...") } - println("Outside again work...") assertEquals(2, getCount()) } @@ -216,25 +200,19 @@ open class R2dbcExposedTransactionManagerTest : SpringReactiveTransactionTestBas * The inner transaction will be roll-back only inner transaction when the transaction marks as rollback. * And since isolation level is READ_COMMITTED, the inner transaction can't see the changes of outer transaction. */ - @RepeatedTest(1) + @RepeatedTest(5) fun testConnectionWithRequiresNewWithInnerTransactionRollback() = runTest { - println("Start test... straight to trx1") transactionManager.execute { insertRandom() assertEquals(1, getCount()) - println("Finished trx1 work...") transactionManager.execute(TransactionDefinition.PROPAGATION_REQUIRES_NEW) { - println("Nested test...") insertRandom() assertEquals(1, getCount()) it.setRollbackOnly() - println("Finished nested work...") } - println("Outside again work...") assertEquals(1, getCount()) } - println("Straight to trx2") transactionManager.execute { assertEquals(1, getCount()) } diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt new file mode 100644 index 0000000000..776975155d --- /dev/null +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt @@ -0,0 +1,208 @@ +package org.jetbrains.exposed.v1.spring7.reactive.transaction + +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.deleteAll +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.annotation.EnableTransactionManagement +import org.springframework.transaction.annotation.Transactional + +open class SpringMultiContainerTransactionTest { + + val orderContainer = AnnotationConfigApplicationContext(OrderConfig::class.java) + val paymentContainer = AnnotationConfigApplicationContext(PaymentConfig::class.java) + + val orders: Orders = orderContainer.getBean(Orders::class.java) + val payments: Payments = paymentContainer.getBean(Payments::class.java) + + @BeforeEach + open fun beforeTest() = runTest { + orders.init() + payments.init() + } + + @Test + open fun test1() = runTest { + Assertions.assertEquals(0, orders.findAll().size) + Assertions.assertEquals(0, payments.findAll().size) + } + + @Test + open fun test2() = runTest { + orders.create() + Assertions.assertEquals(1, orders.findAll().size) + payments.create() + Assertions.assertEquals(1, payments.findAll().size) + } + + @Test + open fun test3() = runTest { + orders.suspendTransaction { + payments.create() + orders.create() + payments.create() + } + Assertions.assertEquals(1, orders.findAll().size) + Assertions.assertEquals(2, payments.findAll().size) + } + + @Test + open fun test4() = runTest { + kotlin.runCatching { + orders.suspendTransaction { + orders.create() + payments.create() + throw SpringTransactionTestException() + } + } + Assertions.assertEquals(0, orders.findAll().size) + Assertions.assertEquals(1, payments.findAll().size) + } + + @Test + open fun test5() = runTest { + kotlin.runCatching { + orders.suspendTransaction { + orders.create() + payments.databaseTemplate { + payments.create() + throw SpringTransactionTestException() + } + } + } + Assertions.assertEquals(0, orders.findAll().size) + Assertions.assertEquals(0, payments.findAll().size) + } + + @Test + open fun test6() = runTest { + Assertions.assertEquals(0, orders.findAllWithExposedTrxBlock().size) + Assertions.assertEquals(0, payments.findAllWithExposedTrxBlock().size) + } + + @Test + open fun test7() = runTest { + orders.createWithExposedTrxBlock() + Assertions.assertEquals(1, orders.findAllWithExposedTrxBlock().size) + payments.createWithExposedTrxBlock() + Assertions.assertEquals(1, payments.findAllWithExposedTrxBlock().size) + } + + @Test + open fun test8() = runTest { + orders.suspendTransaction { + payments.createWithExposedTrxBlock() + orders.createWithExposedTrxBlock() + payments.createWithExposedTrxBlock() + } + Assertions.assertEquals(1, orders.findAllWithExposedTrxBlock().size) + Assertions.assertEquals(2, payments.findAllWithExposedTrxBlock().size) + } +} + +@Configuration +@EnableTransactionManagement(proxyTargetClass = true) +open class OrderConfig { + + @Bean + open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest1;DB_CLOSE_DELAY=-1;") + + @Bean + open fun transactionManager(connectionFactory: ConnectionFactory) = SpringReactiveTransactionManager( + connectionFactory, + R2dbcDatabaseConfig { explicitDialect = H2Dialect() } + ) + + @Bean + open fun orders() = Orders() +} + +@Transactional +open class Orders { + + open suspend fun findAll(): List = Order.selectAll().toList() + + // NOTE: qualifier names must be left in + open suspend fun findAllWithExposedTrxBlock() = org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction { findAll() } + + open suspend fun create() = Order.insertAndGetId { + it[buyer] = 123 + }.value + + // NOTE: qualifier names must be left in + open suspend fun createWithExposedTrxBlock() = org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction { create() } + + open suspend fun init() { + SchemaUtils.create(Order) + Order.deleteAll() + } + + open suspend fun suspendTransaction(block: suspend () -> Unit) { + block() + } +} + +object Order : LongIdTable("orders") { + val buyer = long("buyer_id") +} + +@Configuration +@EnableTransactionManagement(proxyTargetClass = true) +open class PaymentConfig { + + @Bean + open fun cxFactory(): ConnectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///embeddedTest2;DB_CLOSE_DELAY=-1;") + + @Bean + open fun transactionManager(connectionFactory: ConnectionFactory) = SpringReactiveTransactionManager( + connectionFactory, + R2dbcDatabaseConfig { explicitDialect = H2Dialect() } + ) + + @Bean + open fun payments() = Payments() +} + +@Transactional +open class Payments { + + open suspend fun findAll(): List = Payment.selectAll().toList() + + open suspend fun findAllWithExposedTrxBlock() = suspendTransaction { findAll() } + + open suspend fun create() = Payment.insertAndGetId { + it[state] = "state" + }.value + + open suspend fun createWithExposedTrxBlock() = suspendTransaction { create() } + + open suspend fun init() { + SchemaUtils.create(Payment) + Payment.deleteAll() + } + + open suspend fun databaseTemplate(block: suspend () -> Unit) { + block() + } +} + +object Payment : LongIdTable("payments") { + val state = varchar("state", 50) +} + +private class SpringTransactionTestException : Error() diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt index d643e11b00..2d661bb4de 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManagerTest.kt @@ -54,9 +54,6 @@ class SpringReactiveTransactionManagerTest { databaseBeforeTestStart = TransactionManager.primaryDatabase con1.clearMock() con2.clearMock() - - // TODO - this should not be done, but transactions are not being popped on original thread after coroutine switches thread -// ThreadLocalTransactionsStack.threadTransactions()?.clear() } @BeforeEach diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt index 981831abae..1d2d5ef6de 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringTransactionRollbackTest.kt @@ -279,30 +279,6 @@ class SpringTransactionRollbackTest { assertEquals(2, entities.size) // Only original records should remain assertTrue { entities.none { it.name.startsWith("No ") || it.name.startsWith("New ") } } } - - @Test - fun `nested should rollback innerTx without affecting outerTx`() = runTest { - val testRollback = container.getBean(TestRollback::class.java) - - testRollback.suspendTransaction { - testRollback.insertOriginTable("Tx1") - - try { - testRollback.transactionWithNested { - testRollback.insertOriginTable("Tx2") - - @Suppress("TooGenericExceptionThrown") - throw RuntimeException() // Rollback only the inner transaction - } - } catch (@Suppress("SwallowedException") _: RuntimeException) { - // Ignore exception - } - } - - val entities = testRollback.selectAll() - assertEquals(1, entities.size) - assertEquals("Tx1", entities.first().name) // Only the outer transaction should remain - } } @Configuration diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt index a48b5ce535..bc8b60176e 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/ExposedTransactionManagerTest.kt @@ -275,7 +275,7 @@ open class ExposedTransactionManagerTest : SpringTransactionTestBase() { /** * Test for Isolation Level */ - @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests + @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests with restricted level support @RepeatedTest(5) @Transactional(isolation = Isolation.READ_COMMITTED) open fun testIsolationLevelReadUncommitted() { @@ -293,7 +293,7 @@ open class ExposedTransactionManagerTest : SpringTransactionTestBase() { * Test for Timeout * Execute with query timeout */ - @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests + @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests with restricted timeout support @RepeatedTest(5) open fun testTimeout() { transactionManager.execute(timeout = 1) { diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt index 35dd0424cf..588d8ec20c 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt @@ -279,7 +279,7 @@ open class JdbcExposedTransactionManagerTest : SpringTransactionTestBase() { /** * Test for Isolation Level */ - @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests + @Tag(NO_R2DBC_SUPPORT) // H2_R2DBC used in tests with restricted level support @RepeatedTest(5) @Transactional(isolation = Isolation.READ_COMMITTED) open fun testIsolationLevelReadUncommitted() { diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt index d2bcdcac7f..672560a530 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt @@ -165,6 +165,7 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } + @Tag(NO_R2DBC_SUPPORT) // https://github.com/spring-projects/spring-data-relational/issues/2026 @Test fun `transaction commit with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt index 47fbd13ab5..1d23889864 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt @@ -8,8 +8,10 @@ import org.jetbrains.exposed.v1.jdbc.deleteAll import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull import org.springframework.context.annotation.AnnotationConfigApplicationContext @@ -273,6 +275,9 @@ class SpringTransactionRollbackTest { assertTrue { entities.none { it.name.startsWith("No ") || it.name.startsWith("New ") } } } + // Left out because rollback involving Spring (partial) NESTED is not actually supported by Exposed; + // this test would fail with "Transaction manager does not allow nested transactions by default" if catch block removed + @Tag(MISSING_R2DBC_TEST) @Test fun `nested should rollback innerTx without affecting outerTx`() { val testRollback = container.getBean(TestRollback::class.java) diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt index 7a403d22b0..914eaa13f8 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/TransactionSynchronizationTest.kt @@ -9,6 +9,7 @@ import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager +// Test interface use is only supported by AbstractPlatformTransactionManager @Tag(NOT_APPLICABLE_TO_R2DBC) class TransactionSynchronizationTest : SpringTransactionTestBase() { From 80f3f4fd69995f443d2a1aceef5f86c47f04ce82 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Fri, 6 Mar 2026 02:10:04 -0500 Subject: [PATCH 6/7] feat: Update KDocs and remove unneeded test tags --- .../SpringMultiContainerTransactionTest.kt | 5 ----- .../SpringTransactionManagerTest.kt | 11 ---------- .../SpringReactiveTransactionManager.kt | 21 ++++++++++++------- .../SpringMultiContainerTransactionTest.kt | 3 --- .../SpringTransactionManagerTest.kt | 14 ++++++++++--- 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt index 3c3717b59e..6514a578f8 100644 --- a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt +++ b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringMultiContainerTransactionTest.kt @@ -7,10 +7,8 @@ import org.jetbrains.exposed.v1.jdbc.deleteAll import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean @@ -22,9 +20,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement import org.springframework.transaction.annotation.Transactional import javax.sql.DataSource -// spring-r2dbc has no native support for distributed/XA transactions -// and this class tests such single transactions that span multiple databases. -@Tag(NO_R2DBC_SUPPORT) open class SpringMultiContainerTransactionTest { val orderContainer = AnnotationConfigApplicationContext(OrderConfig::class.java) diff --git a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt index 8880ca4dd3..9982fab49c 100644 --- a/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt +++ b/spring-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring/transaction/SpringTransactionManagerTest.kt @@ -3,11 +3,9 @@ package org.jetbrains.exposed.v1.spring.transaction import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -import org.jetbrains.exposed.v1.tests.NOT_APPLICABLE_TO_R2DBC import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy @@ -164,9 +162,6 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } - // LazyConnectionDataSourceProxy has no R2DBC equivalent - // https://github.com/spring-projects/spring-framework/issues/33897 - @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction commit with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -176,9 +171,6 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } - // LazyConnectionDataSourceProxy has no R2DBC equivalent - // https://github.com/spring-projects/spring-framework/issues/33897 - @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction rollback with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -223,9 +215,6 @@ class SpringTransactionManagerTest { assertTrue(con1.closeCallCount > 0) } - // Rollback following commit failure was purposefully removed from Spring R2DBC - // https://github.com/spring-projects/spring-framework/pull/27572 - @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction exception on commit and rollback on commit failure`() { con1.mockCommit = { throw SQLException("Commit failure") } diff --git a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt index 39cd1f0ed0..339decb3c5 100644 --- a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt @@ -148,7 +148,9 @@ class SpringReactiveTransactionManager( // force new coroutine to start in current thread so that potential callbacks can access correct stack val newConnectionMono: Mono = mono(Dispatchers.Unconfined) { if (trxObject.connectionHolder == null || trxObject.isExposedNestedTransactionAllowed) { - trxObject.connectionHolder = ExposedHolderObject(newTransaction.awaitConnection(), newTransaction) + @Suppress("UNCHECKED_CAST") + val actualConnection = (newTransaction.connection().activeConnection() as Publisher).awaitSingle() + trxObject.connectionHolder = ExposedHolderObject(actualConnection, newTransaction) trxObject.isNewConnectionHolder = true } trxObject.connectionHolder?.isSynchronizedWithTransaction = true @@ -298,16 +300,22 @@ class SpringReactiveTransactionManager( } } + /** + * This can be bound to the Spring R2DBC synchronization manager, which makes Spring R2DBC see the same + * connection as is currently held and managed by the Exposed [R2dbcTransaction]. + * + * When installed using [TransactionSynchronizationManager.bindResource], Spring R2DBC constructs like + * DatabaseClient will see the same connection as Exposed and partake in the same transaction with the + * same underlying autocommit-disabled connection. + * + * It additionally stores the active transaction that is using the held connection, so that the stack can be + * synchronized accurately when binding/unbinding the resource. + */ private class ExposedHolderObject( connection: Connection, val transaction: R2dbcTransaction, ) : ConnectionHolder(connection) - @Suppress("UNCHECKED_CAST") - private suspend fun R2dbcTransaction.awaitConnection(): Connection { - return (this.connection().activeConnection() as Publisher).awaitSingle() - } - private data class ExposedTransactionObject( val database: R2dbcDatabase, ) { @@ -356,7 +364,6 @@ class SpringReactiveTransactionManager( return this.getResourceHolderOrNull()?.transaction } - @OptIn(InternalApi::class) private fun TransactionSynchronizationManager.printEverything(methodName: String) { val resource = this.getResourceOrNull()?.transactionId ?: "NO SPRING TRX" println("In $methodName...${viewThreadStack()}\n\tSPRING --> $resource") diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt index 7b9e9bdf6a..4fe2147c4a 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringMultiContainerTransactionTest.kt @@ -7,10 +7,8 @@ import org.jetbrains.exposed.v1.jdbc.deleteAll import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean @@ -22,7 +20,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement import org.springframework.transaction.annotation.Transactional import javax.sql.DataSource -@Tag(NO_R2DBC_SUPPORT) open class SpringMultiContainerTransactionTest { val orderContainer = AnnotationConfigApplicationContext(OrderConfig::class.java) diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt index 672560a530..3dc130c627 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionManagerTest.kt @@ -165,7 +165,10 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } - @Tag(NO_R2DBC_SUPPORT) // https://github.com/spring-projects/spring-data-relational/issues/2026 + // LazyConnectionDataSourceProxy has no R2DBC equivalent + // https://github.com/spring-projects/spring-framework/issues/33897 + // https://github.com/spring-projects/spring-data-relational/issues/2026 + @Tag(NO_R2DBC_SUPPORT) @Test fun `transaction commit with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -175,7 +178,10 @@ class SpringTransactionManagerTest { assertEquals(1, con1.closeCallCount) } - @Tag(NO_R2DBC_SUPPORT) // https://github.com/spring-projects/spring-data-relational/issues/2026 + // LazyConnectionDataSourceProxy has no R2DBC equivalent + // https://github.com/spring-projects/spring-framework/issues/33897 + // https://github.com/spring-projects/spring-data-relational/issues/2026 + @Tag(NO_R2DBC_SUPPORT) @Test fun `transaction rollback with lazy connection data source proxy`() { val lazyDs = LazyConnectionDataSourceProxy(ds1) @@ -220,7 +226,9 @@ class SpringTransactionManagerTest { assertTrue(con1.closeCallCount > 0) } - @Tag(NOT_APPLICABLE_TO_R2DBC) // no equivalent isRollbackOnCommitFailure property + // Rollback following commit failure was purposefully removed from Spring R2DBC + // https://github.com/spring-projects/spring-framework/pull/27572 + @Tag(NOT_APPLICABLE_TO_R2DBC) @Test fun `transaction exception on commit and rollback on commit failure`() { con1.mockCommit = { throw SQLException("Commit failure") } From 605aec7f8c6731caa7029a4dada6974b64c8d82a Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:05:50 -0400 Subject: [PATCH 7/7] feat: Update suspendTransaction to use stack as SSOT and cleanup --- exposed-r2dbc/api/exposed-r2dbc.api | 1 - .../r2dbc/statements/R2dbcConnectionImpl.kt | 9 ++++- .../transactions/R2dbcTransactionManager.kt | 17 +++++---- .../v1/r2dbc/transactions/Transactions.kt | 12 +------ .../SpringReactiveTransactionManager.kt | 35 ++----------------- .../ExposedTransactionManagerTest.kt | 12 +------ .../SpringMultiContainerTransactionTest.kt | 28 +++++++++++++++ .../JdbcExposedTransactionManagerTest.kt | 2 +- .../SpringTransactionRollbackTest.kt | 6 ++-- 9 files changed, 54 insertions(+), 68 deletions(-) diff --git a/exposed-r2dbc/api/exposed-r2dbc.api b/exposed-r2dbc/api/exposed-r2dbc.api index 3afce10dd5..17787922c6 100644 --- a/exposed-r2dbc/api/exposed-r2dbc.api +++ b/exposed-r2dbc/api/exposed-r2dbc.api @@ -1072,7 +1072,6 @@ public final class org/jetbrains/exposed/v1/r2dbc/transactions/TransactionsKt { public static synthetic fun inTopLevelSuspendTransaction$default (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase;Lio/r2dbc/spi/IsolationLevel;Ljava/lang/Boolean;Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun suspendTransaction (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase;Lio/r2dbc/spi/IsolationLevel;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun suspendTransaction$default (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase;Lio/r2dbc/spi/IsolationLevel;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun viewThreadStack ()Ljava/lang/String; } public abstract class org/jetbrains/exposed/v1/r2dbc/vendors/DatabaseDialectMetadata { diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt index b6e03a4ab1..e09d2c5d42 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/statements/R2dbcConnectionImpl.kt @@ -48,7 +48,14 @@ class R2dbcConnectionImpl( /** * Retrieves a publisher that provides only the single current underlying database [Connection] instance in use. - * If none is active, it awaits a value from the [connection] publisher and internally stores the result. + * + * If none is active, it awaits a value from the [connection] publisher and internally stores the result. Retrieval + * of a new value will invoke `Connection.beginTransaction()`, so this method should preferably be called when an active + * transaction is underway, in order to retrieve the accurate connection object instance. + * + * If this method is invoked intentionally to trigger the start of a transaction, then [setTransactionDefinition] + * should ideally be manually called first with the appropriate [TransactionDefinition], as well as any other + * [Connection] configuration methods. */ override suspend fun activeConnection(): Publisher { // retrieves localConnection if not null, otherwise awaits value from connection publisher diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/R2dbcTransactionManager.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/R2dbcTransactionManager.kt index d49b0e3f0f..c5def3846f 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/R2dbcTransactionManager.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/R2dbcTransactionManager.kt @@ -1,7 +1,6 @@ package org.jetbrains.exposed.v1.r2dbc.transactions import io.r2dbc.spi.IsolationLevel -import kotlinx.coroutines.currentCoroutineContext import org.jetbrains.exposed.v1.core.InternalApi import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack @@ -65,23 +64,23 @@ internal fun R2dbcTransactionManager.createTransactionContext(transaction: Trans } /** - * Returns the current R2DBC transaction from the coroutine context, or null if none exists. + * Returns the current R2DBC transaction from the stack, or null if none exists. * - * This method performs type checking to ensure the transaction in the context is actually - * an [R2dbcTransaction]. If a non-R2DBC transaction is found in the context, an error is thrown + * This method performs type checking to ensure the transaction in the stack is actually + * an [R2dbcTransaction]. If a non-R2DBC transaction is found in the stack, an error is thrown * to prevent type confusion between JDBC and R2DBC transactions. * - * @return The current [R2dbcTransaction] from the coroutine context, or null if no transaction exists - * @throws [IllegalStateException] If the transaction in the context is not an [R2dbcTransaction] + * @return The current [R2dbcTransaction] from the stack, or null if no transaction exists + * @throws [IllegalStateException] If the transaction in the stack is not an [R2dbcTransaction] */ -internal suspend fun R2dbcTransactionManager.getCurrentContextTransaction(): R2dbcTransaction? { +internal fun R2dbcTransactionManager.getCurrentStackTransaction(): R2dbcTransaction? { @OptIn(InternalApi::class) - val transaction = currentCoroutineContext()[contextKey]?.transaction + val transaction = ThreadLocalTransactionsStack.getTransactionOrNull(db) return when { transaction == null -> null transaction is R2dbcTransaction -> transaction else -> error( - "Expected R2dbcTransaction in coroutine context but found ${transaction::class.simpleName}. " + + "Expected R2dbcTransaction in stack but found ${transaction::class.simpleName}. " + "This may indicate mixing JDBC and R2DBC transactions incorrectly." ) } diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt index ce89abdd79..208c1da7d8 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/transactions/Transactions.kt @@ -113,9 +113,7 @@ suspend fun suspendTransaction( statement: suspend R2dbcTransaction.() -> T ): T { val databaseToUse = resolveR2dbcDatabaseOrThrow(db) - val outer = databaseToUse.transactionManager.getCurrentContextTransaction() -// @OptIn(InternalApi::class) -// val outer = ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) as? R2dbcTransaction + val outer = databaseToUse.transactionManager.getCurrentStackTransaction() return if (outer != null) { val transaction = outer.transactionManager.newTransaction( @@ -287,11 +285,3 @@ internal suspend fun closeStatementsAndConnection(transaction: R2dbcTransaction) exposedLogger.warn("Transaction close failed: ${it.message}. Statement: $currentStatement", it) } } - -@OptIn(InternalApi::class) -fun viewThreadStack(): String { - val currentThread = Thread.currentThread().name - val currentTrx = ThreadLocalTransactionsStack.getTransactionOrNull()?.transactionId ?: "NOT IN TRX" - val allTrx = ThreadLocalTransactionsStack.threadTransactions()?.map { it.transactionId } ?: listOf("EMPTY STACK") - return "\n\tTHREAD --> $currentThread\n\tTRX --> $currentTrx\n\tSTACK --> $allTrx" -} diff --git a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt index 339decb3c5..f87ef1b161 100644 --- a/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt +++ b/spring7-reactive-transaction/src/main/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringReactiveTransactionManager.kt @@ -18,7 +18,6 @@ import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction import org.jetbrains.exposed.v1.r2dbc.transactions.currentOrNull import org.jetbrains.exposed.v1.r2dbc.transactions.transactionManager -import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.reactivestreams.Publisher import org.springframework.r2dbc.UncategorizedR2dbcException import org.springframework.r2dbc.connection.ConnectionHolder @@ -52,8 +51,6 @@ class SpringReactiveTransactionManager( override fun doGetTransaction( synchronizationManager: TransactionSynchronizationManager ): Any { - synchronizationManager.printEverything(::doGetTransaction.name) - return ExposedTransactionObject( database = database, ).apply { @@ -68,9 +65,7 @@ class SpringReactiveTransactionManager( return Mono.defer { val trxObject = transaction as ExposedTransactionObject - synchronizationManager.printEverything(::doSuspend.name) - - synchronizationManager.getResourceOrNull() ?: error("No transaction to suspend") + synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to suspend") Mono .justOrEmpty( @@ -81,8 +76,6 @@ class SpringReactiveTransactionManager( @OptIn(InternalApi::class) ThreadLocalTransactionsStack.popTransaction() - - synchronizationManager.printEverything(::doSuspend.name) } } } @@ -100,8 +93,6 @@ class SpringReactiveTransactionManager( @OptIn(InternalApi::class) ThreadLocalTransactionsStack.pushTransaction(suspendedObject.transaction) - synchronizationManager.printEverything(::doResume.name) - Mono.empty() } } @@ -109,8 +100,6 @@ class SpringReactiveTransactionManager( override fun isExistingTransaction(transaction: Any): Boolean { val trxObject = transaction as ExposedTransactionObject - println("In existingTransaction with ${trxObject.getCurrentTransaction()?.transactionId}") - return trxObject.getCurrentTransaction() != null } @@ -129,7 +118,6 @@ class SpringReactiveTransactionManager( } else { null } - synchronizationManager.printEverything(::doBegin.name) val newTransaction = trxObject.database.transactionManager.newTransaction( isolation = definition.isolationLevel.resolveIsolationLevel(), @@ -174,11 +162,11 @@ class SpringReactiveTransactionManager( if (trxObject.isNewConnectionHolder) { // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its outer // transaction used when doCommit() or doRollback() is invoked + // https://youtrack.jetbrains.com/issue/EXPOSED-996/Reassess-support-for-Spring-PROPAGATIONNESTED synchronizationManager.unbindResourceIfPossible(connectionFactory) as? ExposedHolderObject synchronizationManager.bindResource(connectionFactory, trxObject.connectionHolder!!) } - synchronizationManager.printEverything("${::doBegin.name} [inner]") } .doOnError { ex -> trxObject.connectionHolder = null @@ -199,14 +187,11 @@ class SpringReactiveTransactionManager( val trxObject = status.transaction as ExposedTransactionObject synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to commit") - synchronizationManager.printEverything(::doCommit.name) // force new coroutine to start in current thread so that doCleanupOnCompletion can access correct stack return mono(Dispatchers.Unconfined) { trxObject.commit() - synchronizationManager.printEverything("${::doCommit.name} [inner]") - null } } @@ -218,14 +203,11 @@ class SpringReactiveTransactionManager( val trxObject = status.transaction as ExposedTransactionObject synchronizationManager.getResourceOrNull() ?: error("No synchronized transaction to rollback") - synchronizationManager.printEverything(::doRollback.name) // force new coroutine to start in current thread so that doCleanupOnCompletion can access correct stack return mono(Dispatchers.Unconfined) { trxObject.rollback() - synchronizationManager.printEverything("${::doRollback.name} [inner]") - null } } @@ -244,13 +226,12 @@ class SpringReactiveTransactionManager( @OptIn(InternalApi::class) ThreadLocalTransactionsStack.popTransaction() - synchronizationManager.printEverything(::doCleanupAfterCompletion.name) - if (trxObject.isNewConnectionHolder) { val previous = synchronizationManager.unbindResource(connectionFactory) as ExposedHolderObject // otherwise a PROPAGATION_NESTED transaction would incorrectly have the context of its // now closed inner transaction used when doCommit() or doRollback() is later invoked + // https://youtrack.jetbrains.com/issue/EXPOSED-996/Reassess-support-for-Spring-PROPAGATIONNESTED completedTransaction?.outerTransaction?.let { outer -> synchronizationManager.bindResource(connectionFactory, ExposedHolderObject(previous.connection, outer)) } @@ -260,8 +241,6 @@ class SpringReactiveTransactionManager( mono(Dispatchers.Unconfined) { completedTransaction?.close() - synchronizationManager.printEverything(::doCleanupAfterCompletion.name) - null } .doOnEach { @@ -269,7 +248,6 @@ class SpringReactiveTransactionManager( trxObject.connectionHolder?.released() } trxObject.connectionHolder?.clear() - println("In ${::doCleanupAfterCompletion.name}: Releasing & clearing CH") } } } @@ -290,8 +268,6 @@ class SpringReactiveTransactionManager( return Mono.fromRunnable { val trxObject = status.transaction as ExposedTransactionObject - synchronizationManager.printEverything(::doSetRollbackOnly.name) - if (status.isDebug) { exposedLogger.debug("Exposed transaction [${status.transactionName}] set rollback-only") } @@ -363,11 +339,6 @@ class SpringReactiveTransactionManager( private fun TransactionSynchronizationManager.getResourceOrNull(): R2dbcTransaction? { return this.getResourceHolderOrNull()?.transaction } - - private fun TransactionSynchronizationManager.printEverything(methodName: String) { - val resource = this.getResourceOrNull()?.transactionId ?: "NO SPRING TRX" - println("In $methodName...${viewThreadStack()}\n\tSPRING --> $resource") - } } private var R2dbcTransaction.isRollback: Boolean by transactionScope { false } diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt index f096dbe2f8..3e524f5ffa 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/ExposedTransactionManagerTest.kt @@ -7,7 +7,6 @@ import org.jetbrains.exposed.v1.r2dbc.SchemaUtils import org.jetbrains.exposed.v1.r2dbc.insert import org.jetbrains.exposed.v1.r2dbc.selectAll import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction -import org.jetbrains.exposed.v1.r2dbc.transactions.viewThreadStack import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -80,29 +79,20 @@ open class ExposedTransactionManagerTest : SpringReactiveTransactionTestBase() { } } - // TODO - This (& only this test) fails because of line 114 in suspendTransaction(); - // If the line is reverted to original, it passes -> ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) - @RepeatedTest(1) + @RepeatedTest(5) @Commit // @Transactional // see [runTestWithMockTransactional] open fun testConnectionCombineWithExposedTransaction2() = runTestWithMockTransactional { - println("Starting TEST...\n${viewThreadStack()}") val rnd = Random().nextInt().toString() T1.insert { it[c1] = rnd } assertEquals(rnd, T1.selectAll().single()[T1.c1]) - println("About to enter nested...") suspendTransaction { - println("Starting NESTED...\n${viewThreadStack()}") T1.insertRandom() assertEquals(2, T1.selectAll().count()) - println("NESTED = ${T1.selectAll().count()}") - println("Finishing NESTED...") } - println("TEST = ${T1.selectAll().count()}") - println("Finishing TEST...\n${viewThreadStack()}") } /** diff --git a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt index 776975155d..51b41636be 100644 --- a/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt +++ b/spring7-reactive-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/reactive/transaction/SpringMultiContainerTransactionTest.kt @@ -113,6 +113,34 @@ open class SpringMultiContainerTransactionTest { Assertions.assertEquals(1, orders.findAllWithExposedTrxBlock().size) Assertions.assertEquals(2, payments.findAllWithExposedTrxBlock().size) } + + @Test + open fun test9() = runTest { + kotlin.runCatching { + orders.suspendTransaction { + orders.createWithExposedTrxBlock() + payments.createWithExposedTrxBlock() + throw SpringTransactionTestException() + } + } + Assertions.assertEquals(0, orders.findAllWithExposedTrxBlock().size) + Assertions.assertEquals(1, payments.findAllWithExposedTrxBlock().size) + } + + @Test + open fun test10() = runTest { + kotlin.runCatching { + orders.suspendTransaction { + orders.createWithExposedTrxBlock() + payments.databaseTemplate { + payments.createWithExposedTrxBlock() + throw SpringTransactionTestException() + } + } + } + Assertions.assertEquals(0, orders.findAllWithExposedTrxBlock().size) + Assertions.assertEquals(0, payments.findAllWithExposedTrxBlock().size) + } } @Configuration diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt index 588d8ec20c..5e99b4025d 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/JdbcExposedTransactionManagerTest.kt @@ -17,7 +17,7 @@ import org.springframework.transaction.IllegalTransactionStateException import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.annotation.Isolation import org.springframework.transaction.annotation.Transactional -import java.util.* +import java.util.Random import javax.sql.DataSource import kotlin.test.assertFailsWith diff --git a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt index 1d23889864..9bf3d025a8 100644 --- a/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt +++ b/spring7-transaction/src/test/kotlin/org/jetbrains/exposed/v1/spring7/transaction/SpringTransactionRollbackTest.kt @@ -275,8 +275,10 @@ class SpringTransactionRollbackTest { assertTrue { entities.none { it.name.startsWith("No ") || it.name.startsWith("New ") } } } - // Left out because rollback involving Spring (partial) NESTED is not actually supported by Exposed; - // this test would fail with "Transaction manager does not allow nested transactions by default" if catch block removed + // Left out because rollback involving Spring (partial) NESTED is not actually fully supported by Exposed; + // this test would fail with "Transaction manager does not allow nested transactions by default" if catch block removed, + // not the artificial RuntimeException as expected + // https://youtrack.jetbrains.com/issue/EXPOSED-996/Reassess-support-for-Spring-PROPAGATIONNESTED @Tag(MISSING_R2DBC_TEST) @Test fun `nested should rollback innerTx without affecting outerTx`() {