Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Kotlin version 2.2.+
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
Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies {
dokka(projects.exposed.exposedSpringBoot4Starter)
dokka(projects.exposed.springTransaction)
dokka(projects.exposed.spring7Transaction)
dokka(projects.exposed.spring7ReactiveTransaction)

// Kover aggregated coverage dependencies
// Include all source modules for coverage aggregation
Expand All @@ -54,6 +55,7 @@ dependencies {
kover(project(":exposed-java-time"))
kover(project(":spring-transaction"))
kover(project(":spring7-transaction"))
kover(project(":spring7-reactive-transaction"))
kover(project(":exposed-spring-boot-starter"))
kover(project(":exposed-spring-boot4-starter"))
kover(project(":exposed-jdbc"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,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, spring7-reactive-transaction
// exposed-spring-boot-starter, exposed-spring-boot4-starter
exclude(
"org/jetbrains/exposed/v1/spring/*",
Expand Down
32 changes: 21 additions & 11 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -3846,17 +3846,6 @@ public abstract class org/jetbrains/exposed/v1/core/transactions/DatabasesManage
public fun setDefaultDatabase (Lorg/jetbrains/exposed/v1/core/DatabaseApi;)V
}

public final class org/jetbrains/exposed/v1/core/transactions/ThreadLocalTransactionsStack {
public static final field INSTANCE Lorg/jetbrains/exposed/v1/core/transactions/ThreadLocalTransactionsStack;
public final fun getTransactionIsInstance (Ljava/lang/Class;)Lorg/jetbrains/exposed/v1/core/Transaction;
public final fun getTransactionOrNull ()Lorg/jetbrains/exposed/v1/core/Transaction;
public final fun getTransactionOrNull (Lorg/jetbrains/exposed/v1/core/DatabaseApi;)Lorg/jetbrains/exposed/v1/core/Transaction;
public final fun isEmpty ()Z
public final fun popTransaction ()Lorg/jetbrains/exposed/v1/core/Transaction;
public final fun pushTransaction (Lorg/jetbrains/exposed/v1/core/Transaction;)V
public final fun threadTransactions ()Ljava/util/Stack;
}

public abstract interface class org/jetbrains/exposed/v1/core/transactions/TransactionInterface {
public abstract fun getDb ()Lorg/jetbrains/exposed/v1/core/DatabaseApi;
public abstract fun getOuterTransaction ()Lorg/jetbrains/exposed/v1/core/Transaction;
Expand Down Expand Up @@ -3906,6 +3895,27 @@ public final class org/jetbrains/exposed/v1/core/transactions/TransactionsKt {
public static final fun withThreadLocalTransaction (Lorg/jetbrains/exposed/v1/core/Transaction;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object;
}

public abstract interface class org/jetbrains/exposed/v1/core/transactions/TransactionsStack {
public fun getPriority ()I
public abstract fun getSize ()I
public abstract fun getTransactionIsInstance (Ljava/lang/Class;)Lorg/jetbrains/exposed/v1/core/Transaction;
public abstract fun getTransactionOrNull ()Lorg/jetbrains/exposed/v1/core/Transaction;
public abstract fun getTransactionOrNull (Lorg/jetbrains/exposed/v1/core/DatabaseApi;)Lorg/jetbrains/exposed/v1/core/Transaction;
public abstract fun getTransactionsAsIds ()Ljava/util/List;
public abstract fun isEmpty ()Z
public abstract fun popTransaction ()Lorg/jetbrains/exposed/v1/core/Transaction;
public abstract fun pushTransaction (Lorg/jetbrains/exposed/v1/core/Transaction;)V
}

public final class org/jetbrains/exposed/v1/core/transactions/TransactionsStack$DefaultImpls {
public static fun getPriority (Lorg/jetbrains/exposed/v1/core/transactions/TransactionsStack;)I
}

public final class org/jetbrains/exposed/v1/core/transactions/TransactionsStackProvider {
public static final field INSTANCE Lorg/jetbrains/exposed/v1/core/transactions/TransactionsStackProvider;
public final fun getStackImpl ()Lorg/jetbrains/exposed/v1/core/transactions/TransactionsStack;
}

public final class org/jetbrains/exposed/v1/core/transactions/suspend/TransactionContextElement : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/ThreadContextElement {
public static final field Key Lorg/jetbrains/exposed/v1/core/transactions/suspend/TransactionContextElement$Key;
public fun <init> (Lorg/jetbrains/exposed/v1/core/Transaction;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ import kotlin.concurrent.getOrSet
* To avoid misuse and potential memory leaks, prefer using the utilities:
* - withTransactionContext(...)
* - withThreadLocalTransaction(...)
* @suppress
*/
@InternalApi
object ThreadLocalTransactionsStack {
@OptIn(InternalApi::class)
internal object ThreadLocalTransactionsStack : TransactionsStack {
private val transactions = ThreadLocal<Stack<Transaction>>()

override val size: Int
get() = transactions.get().size

/**
* Pushes the given transaction onto the current thread's stack.
* If the stack does not exist yet for this thread, it is created.
*/
fun pushTransaction(transaction: Transaction) {
override fun pushTransaction(transaction: Transaction) {
transactions.getOrSet { Stack() }.push(transaction)
}

Expand All @@ -42,7 +44,7 @@ object ThreadLocalTransactionsStack {
* Automatically clears the thread-local when the stack becomes empty,
* helping the GC and preventing thread-local leaks.
*/
fun popTransaction(): Transaction {
override fun popTransaction(): Transaction {
val stack = transactions.get()
require(stack != null && stack.isNotEmpty()) { "No transaction to pop" }
val result = stack.pop()
Expand All @@ -59,7 +61,7 @@ object ThreadLocalTransactionsStack {
* Returns the current top transaction or null if none is present.
* Does not modify the stack.
*/
fun getTransactionOrNull(): Transaction? {
override fun getTransactionOrNull(): Transaction? {
val stack = transactions.get() ?: return null
return if (stack.isEmpty()) null else stack.peek()
}
Expand All @@ -81,29 +83,27 @@ object ThreadLocalTransactionsStack {
*/
// TODO make search in list is not optimal, another structure should be used
// Related issue: https://youtrack.jetbrains.com/issue/EXPOSED-915/ThreadLocalTransactionsStack-makes-inefficient-operations
fun getTransactionOrNull(db: DatabaseApi): Transaction? {
override fun getTransactionOrNull(db: DatabaseApi): Transaction? {
return transactions.get()?.findLast { it.db == db }
}

fun <T : Transaction> getTransactionIsInstance(klass: Class<T>): T? {
override fun <T : Transaction> getTransactionIsInstance(klass: Class<T>): T? {
return transactions.get()?.filterIsInstance(klass)?.lastOrNull()
}

/**
* Returns true if the current thread has no transactions,
* or if the stack exists but is empty.
*/
fun isEmpty(): Boolean {
override fun isEmpty(): Boolean {
val stack = transactions.get() ?: return true
return stack.isEmpty()
}

/**
* Returns transactions that belong to the current thread.
*
* Made for testing purposes. It's better to avoid manipulating the stack directly.
* Returns transactions that belong to the current thread as a list of their String id values.
*/
fun threadTransactions(): Stack<Transaction>? {
return transactions.get()
override fun getTransactionsAsIds(): List<String> {
return transactions.get()?.map { it.transactionId } ?: emptyList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ abstract class TransactionManagersContainerImpl<DB : DatabaseApi>(
* @return The current transaction manager, or null if none is available
*/
override fun getCurrentTransactionManagerOrNull(): TransactionManagerApi? {
return ThreadLocalTransactionsStack.getTransactionIsInstance(transactionClass())?.transactionManager
return TransactionsStackProvider.stackImpl.getTransactionIsInstance(transactionClass())?.transactionManager
?: databases.getPrimaryDatabase()?.let { getTransactionManager(it) }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import org.jetbrains.exposed.v1.core.Transaction
*/
@InternalApi
fun currentTransactionOrNull(): Transaction? {
return ThreadLocalTransactionsStack.getTransactionOrNull()
return TransactionsStackProvider.stackImpl.getTransactionOrNull()
}

/**
Expand All @@ -26,7 +26,7 @@ fun currentTransaction(): Transaction = currentTransactionOrNull() ?: error("No
* The method runs code block within the context of provided transaction.
* If transaction is null, the code block is executed without any transaction context.
*
* Provided transaction will be pushed into [ThreadLocalTransactionsStack] before executing code block,
* Provided transaction will be pushed into [TransactionsStack] before executing code block,
* and will be popped from the stack after code block is executed.
*
* @param transaction The transaction to be used in the context.
Expand All @@ -41,17 +41,17 @@ fun <T> withThreadLocalTransaction(transaction: Transaction?, block: () -> T): T
}

// Check if this transaction is already on the stack to avoid duplicate push/pop
val currentTransaction = ThreadLocalTransactionsStack.getTransactionOrNull()
val currentTransaction = TransactionsStackProvider.stackImpl.getTransactionOrNull()
if (currentTransaction?.transactionId == transaction.transactionId) {
// Transaction is already on the stack - just execute without pushing
return block()
}

// Transaction is not on stack, so we need to push/pop it
ThreadLocalTransactionsStack.pushTransaction(transaction)
TransactionsStackProvider.stackImpl.pushTransaction(transaction)
return try {
block()
} finally {
ThreadLocalTransactionsStack.popTransaction()
TransactionsStackProvider.stackImpl.popTransaction()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package org.jetbrains.exposed.v1.core.transactions

import org.jetbrains.exposed.v1.core.DatabaseApi
import org.jetbrains.exposed.v1.core.InternalApi
import org.jetbrains.exposed.v1.core.Transaction
import java.util.*

/**
* Base data structure for managing [Transaction] instances based on the underlying driver and/or manager combination.
* @suppress
*/
@InternalApi
interface TransactionsStack {
/**
* When multiple implementations of this interface are detected, this value determines which one will be used,
* with the highest value implementation being chosen.
*
* Exposed built-in implementations will have a default priority of either 0 or 1
* (for specific cases like Spring's transaction management). Custom implementations with a higher priority value
* will override these defaults when the registry first searches for an implementation.
* @suppress
*/
val priority: Int
get() = 0

/**
* The amount of [Transaction] instances being actively managed.
* @suppress
*/
val size: Int

/**
* Stores the provided [transaction] to the underlying data structure.
* @suppress
*/
fun pushTransaction(transaction: Transaction)

/**
* Removes the currently active [Transaction] from the underlying data structure.
* @suppress
*/
fun popTransaction(): Transaction

/**
* Returns the currently active [Transaction] from the underlying data structure,
* without modification of the collection, or `null` if none is active.
* @suppress
*/
fun getTransactionOrNull(): Transaction?

/**
* Returns the most recently active [Transaction] for the provided [db] from the underlying data structure,
* without modification of the collection, or `null` if none is found.
* @suppress
*/
fun getTransactionOrNull(db: DatabaseApi): Transaction?

/**
* Returns the most recently active [Transaction] of the provided type [klass] from the underlying data structure,
* without modification of the collection, or `null` if none is found.
* @suppress
*/
fun <T : Transaction> getTransactionIsInstance(klass: Class<T>): T?

/**
* Returns whether the underlying data structure stores any [Transaction] instances.
*/
fun isEmpty(): Boolean

/**
* Returns the stored [Transaction] instances as a list of their String id values.
*/
fun getTransactionsAsIds(): List<String>
}

/**
* Object responsible for locating and providing the appropriate implementation
* for the underlying transaction data structure.
* @suppress
*/
@InternalApi
object TransactionsStackProvider {
/**
* @suppress
*/
@OptIn(InternalApi::class)
val stackImpl: TransactionsStack = ServiceLoader
.load(TransactionsStack::class.java, TransactionsStack::class.java.classLoader)
.maxByOrNull { it.priority }
?: ThreadLocalTransactionsStack
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import kotlinx.coroutines.ThreadContextElement
import org.jetbrains.exposed.v1.core.InternalApi
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.exposedLogger
import org.jetbrains.exposed.v1.core.transactions.ThreadLocalTransactionsStack
import org.jetbrains.exposed.v1.core.transactions.TransactionsStackProvider
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext

Expand Down Expand Up @@ -32,10 +32,10 @@ class TransactionContextElement(
* @return The previous transaction that was on top of the stack, or null if none existed.
*/
override fun updateThreadContext(context: CoroutineContext): Transaction? {
val previousTransaction = ThreadLocalTransactionsStack.getTransactionOrNull()
val previousTransaction = TransactionsStackProvider.stackImpl.getTransactionOrNull()
if (previousTransaction?.transactionId == transaction.transactionId) return null

ThreadLocalTransactionsStack.pushTransaction(transaction)
TransactionsStackProvider.stackImpl.pushTransaction(transaction)
return transaction
}

Expand All @@ -50,7 +50,7 @@ class TransactionContextElement(
if (oldState == null) return

// Check if stack is empty - this can happen if withThreadLocalTransaction already popped it
val currentTransaction = ThreadLocalTransactionsStack.getTransactionOrNull()
val currentTransaction = TransactionsStackProvider.stackImpl.getTransactionOrNull()
if (currentTransaction == null) {
exposedLogger.warn(
"restoreThreadContext called for transaction ${transaction.transactionId} but stack is already empty. " +
Expand All @@ -69,7 +69,7 @@ class TransactionContextElement(
}

// Safe to pop
val poppedTransaction = ThreadLocalTransactionsStack.popTransaction()
val poppedTransaction = TransactionsStackProvider.stackImpl.popTransaction()
if (poppedTransaction.transactionId != transaction.transactionId) {
exposedLogger.warn(
"The current thread local stack of transactions had a transaction ${poppedTransaction.transactionId} on the top. " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package org.jetbrains.exposed.v1.jdbc.transactions
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
import org.jetbrains.exposed.v1.core.transactions.TransactionManagerApi
import org.jetbrains.exposed.v1.core.transactions.TransactionsStackProvider
import org.jetbrains.exposed.v1.core.transactions.suspend.TransactionContextElement
import org.jetbrains.exposed.v1.core.transactions.suspend.TransactionContextHolder
import org.jetbrains.exposed.v1.core.transactions.suspend.TransactionContextHolderImpl
Expand Down Expand Up @@ -73,7 +73,6 @@ internal fun JdbcTransactionManager.createTransactionContext(transaction: Transa
* @throws [IllegalStateException] If the transaction in the context is not a [JdbcTransaction]
*/
internal suspend fun JdbcTransactionManager.getCurrentContextTransaction(): JdbcTransaction? {
@OptIn(InternalApi::class)
val transaction = currentCoroutineContext()[contextKey]?.transaction
return when (transaction) {
null -> null
Expand All @@ -92,5 +91,5 @@ internal suspend fun JdbcTransactionManager.getCurrentContextTransaction(): Jdbc
*/
fun JdbcTransactionManager.currentOrNull(): JdbcTransaction? {
@OptIn(InternalApi::class)
return ThreadLocalTransactionsStack.getTransactionOrNull(db) as? JdbcTransaction
return TransactionsStackProvider.stackImpl.getTransactionOrNull(db) as? JdbcTransaction
}
Loading
Loading